Merge remote-tracking branch 'origin/master'

# Conflicts:
#	src/app/[locale]/(protected)/construction/project/bidding/estimates/[id]/edit/page.tsx
#	src/app/[locale]/(protected)/construction/project/bidding/estimates/[id]/page.tsx
This commit is contained in:
2026-01-13 19:58:09 +09:00
132 changed files with 19588 additions and 1251 deletions

View File

@@ -9,6 +9,7 @@ import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import {
TodayIssueSection,
StatusBoardSection,
DailyReportSection,
MonthlyExpenseSection,
CardManagementSection,
@@ -214,12 +215,9 @@ export function CEODashboard() {
/>
<div className="space-y-6">
{/* 오늘의 이슈 */}
{dashboardSettings.todayIssue.enabled && (
<TodayIssueSection
items={data.todayIssue}
itemSettings={dashboardSettings.todayIssue.items}
/>
{/* 오늘의 이슈 (새 리스트 형태) */}
{dashboardSettings.todayIssueList && (
<TodayIssueSection items={data.todayIssueList} />
)}
{/* 일일 일보 */}
@@ -230,6 +228,14 @@ export function CEODashboard() {
/>
)}
{/* 현황판 (구 오늘의 이슈 - 카드 형태) */}
{(dashboardSettings.statusBoard?.enabled ?? dashboardSettings.todayIssue.enabled) && (
<StatusBoardSection
items={data.todayIssue}
itemSettings={dashboardSettings.statusBoard?.items ?? dashboardSettings.todayIssue.items}
/>
)}
{/* 당월 예상 지출 내역 */}
{dashboardSettings.monthlyExpense && (
<MonthlyExpenseSection

View File

@@ -35,8 +35,8 @@ import type {
} from '../types';
import { DEFAULT_DASHBOARD_SETTINGS } from '../types';
// 오늘의 이슈 항목 라벨
const TODAY_ISSUE_LABELS: Record<keyof TodayIssueSettings, string> = {
// 현황판 항목 라벨 (구 오늘의 이슈)
const STATUS_BOARD_LABELS: Record<keyof TodayIssueSettings, string> = {
orders: '수주',
debtCollection: '채권 추심',
safetyStock: '안전 재고',
@@ -83,37 +83,67 @@ export function DashboardSettingsDialog({
}));
}, []);
// 오늘의 이슈 전체 토글
const handleTodayIssueToggle = useCallback((enabled: boolean) => {
// 오늘의 이슈 (리스트 형태) 토글
const handleTodayIssueListToggle = useCallback((enabled: boolean) => {
setLocalSettings((prev) => ({
...prev,
todayIssue: {
...prev.todayIssue,
enabled,
// 전체 OFF 시 개별 항목도 모두 OFF
items: enabled
? prev.todayIssue.items
: Object.keys(prev.todayIssue.items).reduce(
(acc, key) => ({ ...acc, [key]: false }),
{} as TodayIssueSettings
),
},
todayIssueList: enabled,
}));
}, []);
// 오늘의 이슈 개별 항목 토글
const handleTodayIssueItemToggle = useCallback(
(key: keyof TodayIssueSettings, enabled: boolean) => {
setLocalSettings((prev) => ({
// 현황판 전체 토글 (구 오늘의 이슈)
const handleStatusBoardToggle = useCallback((enabled: boolean) => {
setLocalSettings((prev) => {
const statusBoardItems = prev.statusBoard?.items ?? prev.todayIssue.items;
return {
...prev,
todayIssue: {
...prev.todayIssue,
items: {
...prev.todayIssue.items,
[key]: enabled,
},
statusBoard: {
enabled,
// 전체 OFF 시 개별 항목도 모두 OFF
items: enabled
? statusBoardItems
: Object.keys(statusBoardItems).reduce(
(acc, key) => ({ ...acc, [key]: false }),
{} as TodayIssueSettings
),
},
}));
// Legacy 호환성 유지
todayIssue: {
enabled,
items: enabled
? statusBoardItems
: Object.keys(statusBoardItems).reduce(
(acc, key) => ({ ...acc, [key]: false }),
{} as TodayIssueSettings
),
},
};
});
}, []);
// 현황판 개별 항목 토글
const handleStatusBoardItemToggle = useCallback(
(key: keyof TodayIssueSettings, enabled: boolean) => {
setLocalSettings((prev) => {
const statusBoardItems = prev.statusBoard?.items ?? prev.todayIssue.items;
const newItems = {
...statusBoardItems,
[key]: enabled,
};
return {
...prev,
statusBoard: {
...prev.statusBoard,
enabled: prev.statusBoard?.enabled ?? prev.todayIssue.enabled,
items: newItems,
},
// Legacy 호환성 유지
todayIssue: {
...prev.todayIssue,
items: newItems,
},
};
});
},
[]
);
@@ -280,30 +310,44 @@ export function DashboardSettingsDialog({
</DialogHeader>
<div className="space-y-3 p-4">
{/* 오늘의 이슈 섹션 */}
{/* 오늘의 이슈 (리스트 형태) */}
<SectionRow
label="오늘의 이슈"
checked={localSettings.todayIssueList}
onCheckedChange={handleTodayIssueListToggle}
/>
{/* 일일 일보 */}
<SectionRow
label="일일 일보"
checked={localSettings.dailyReport}
onCheckedChange={(checked) => handleSectionToggle('dailyReport', checked)}
/>
{/* 현황판 (구 오늘의 이슈 - 카드 형태) */}
<div className="space-y-0 rounded-lg overflow-hidden">
<div className="flex items-center justify-between py-3 px-4 bg-gray-200">
<span className="text-sm font-medium text-gray-800"> </span>
<span className="text-sm font-medium text-gray-800"></span>
<ToggleSwitch
checked={localSettings.todayIssue.enabled}
onCheckedChange={handleTodayIssueToggle}
checked={localSettings.statusBoard?.enabled ?? localSettings.todayIssue.enabled}
onCheckedChange={handleStatusBoardToggle}
/>
</div>
{localSettings.todayIssue.enabled && (
{(localSettings.statusBoard?.enabled ?? localSettings.todayIssue.enabled) && (
<div className="bg-gray-50">
{(Object.keys(TODAY_ISSUE_LABELS) as Array<keyof TodayIssueSettings>).map(
{(Object.keys(STATUS_BOARD_LABELS) as Array<keyof TodayIssueSettings>).map(
(key) => (
<div
key={key}
className="flex items-center justify-between py-2.5 px-6 border-t border-gray-200"
>
<span className="text-sm text-gray-600">
{TODAY_ISSUE_LABELS[key]}
{STATUS_BOARD_LABELS[key]}
</span>
<ToggleSwitch
checked={localSettings.todayIssue.items[key]}
checked={(localSettings.statusBoard?.items ?? localSettings.todayIssue.items)[key]}
onCheckedChange={(checked) =>
handleTodayIssueItemToggle(key, checked)
handleStatusBoardItemToggle(key, checked)
}
/>
</div>
@@ -313,13 +357,6 @@ export function DashboardSettingsDialog({
)}
</div>
{/* 일일 일보 */}
<SectionRow
label="일일 일보"
checked={localSettings.dailyReport}
onCheckedChange={(checked) => handleSectionToggle('dailyReport', checked)}
/>
{/* 당월 예상 지출 내역 */}
<SectionRow
label="당월 예상 지출 내역"

View File

@@ -15,6 +15,128 @@ export const mockData: CEODashboardData = {
{ id: '7', label: '발주', count: 3, path: '/construction/order/order-management', isHighlighted: false },
{ id: '8', label: '결재 요청', count: 3, path: '/approval/inbox', isHighlighted: false },
],
todayIssueList: [
{
id: 'til1',
badge: '수주 성공',
content: 'A전자 신규 수주 450,000,000원 확정',
time: '10분 전',
needsApproval: false,
path: '/sales/order-management-sales',
},
{
id: 'til2',
badge: '주식 이슈',
content: 'B물산 미수금 15,000,000원 연체 15일',
time: '1시간 전',
needsApproval: false,
path: '/accounting/receivables-status',
},
{
id: 'til3',
badge: '직정 제고',
content: '원자재 3종 안전재고 미달',
time: '20시간 전',
needsApproval: false,
path: '/material/stock-status',
},
{
id: 'til4',
badge: '지출예상내역서',
content: '품의서명 외 5건 (2,500,000원)',
time: '20시간 전',
needsApproval: true,
path: '/approval/inbox',
},
{
id: 'til5',
badge: '세금 신고',
content: '4분기 부가세 신고 D-15',
time: '20시간 전',
needsApproval: false,
path: '/accounting/tax',
},
{
id: 'til6',
badge: '결재 요청',
content: '법인카드 사용 내역 승인 요청 (김철수)',
time: '30분 전',
needsApproval: true,
path: '/approval/inbox',
},
{
id: 'til7',
badge: '수주 성공',
content: 'C건설 추가 발주 120,000,000원 확정',
time: '2시간 전',
needsApproval: false,
path: '/sales/order-management-sales',
},
{
id: 'til8',
badge: '기타',
content: '신규 거래처 D산업 등록 완료',
time: '3시간 전',
needsApproval: false,
path: '/accounting/vendors',
},
{
id: 'til9',
badge: '결재 요청',
content: '출장비 정산 승인 요청 (이영희)',
time: '4시간 전',
needsApproval: true,
path: '/approval/inbox',
},
{
id: 'til10',
badge: '주식 이슈',
content: 'E물류 미수금 8,500,000원 연체 7일',
time: '5시간 전',
needsApproval: false,
path: '/accounting/receivables-status',
},
{
id: 'til11',
badge: '직정 제고',
content: '부품 A-102 재고 부족 경고',
time: '6시간 전',
needsApproval: false,
path: '/material/stock-status',
},
{
id: 'til12',
badge: '지출예상내역서',
content: '장비 구매 품의서 (15,000,000원)',
time: '8시간 전',
needsApproval: true,
path: '/approval/inbox',
},
{
id: 'til13',
badge: '수주 성공',
content: 'F테크 유지보수 계약 연장 85,000,000원',
time: '어제',
needsApproval: false,
path: '/sales/order-management-sales',
},
{
id: 'til14',
badge: '세금 신고',
content: '원천세 신고 완료',
time: '어제',
needsApproval: false,
path: '/accounting/tax',
},
{
id: 'til15',
badge: '결재 요청',
content: '연차 사용 승인 요청 (박지민 외 2명)',
time: '어제',
needsApproval: true,
path: '/hr/vacation-management',
},
],
dailyReport: {
date: '2026년 1월 5일 월요일',
cards: [

View File

@@ -0,0 +1,73 @@
'use client';
import { useRouter } from 'next/navigation';
import { Card, CardContent } from '@/components/ui/card';
import { SectionTitle, IssueCardItem } from '../components';
import type { TodayIssueItem, TodayIssueSettings } from '../types';
// 라벨 → 설정키 매핑
const LABEL_TO_SETTING_KEY: Record<string, keyof TodayIssueSettings> = {
'수주': 'orders',
'채권 추심': 'debtCollection',
'안전 재고': 'safetyStock',
'세금 신고': 'taxReport',
'신규 업체 등록': 'newVendor',
'연차': 'annualLeave',
'지각': 'lateness',
'결근': 'absence',
'발주': 'purchase',
'결재 요청': 'approvalRequest',
};
interface StatusBoardSectionProps {
items: TodayIssueItem[];
itemSettings?: TodayIssueSettings;
}
export function StatusBoardSection({ items, itemSettings }: StatusBoardSectionProps) {
const router = useRouter();
const handleItemClick = (path: string) => {
router.push(path);
};
// 설정에 따라 항목 필터링
const filteredItems = itemSettings
? items.filter((item) => {
const settingKey = LABEL_TO_SETTING_KEY[item.label];
return settingKey ? itemSettings[settingKey] : true;
})
: items;
// 아이템 개수에 따른 동적 그리드 클래스 (xs: 344px Galaxy Fold 지원)
const getGridColsClass = () => {
const count = filteredItems.length;
if (count <= 1) return 'grid-cols-1';
if (count === 2) return 'grid-cols-1 xs:grid-cols-2';
if (count === 3) return 'grid-cols-1 xs:grid-cols-2 md:grid-cols-3';
// 4개 이상: 최대 4열, 넘치면 아래로
return 'grid-cols-1 xs:grid-cols-2 md:grid-cols-4';
};
return (
<Card>
<CardContent className="p-6">
<SectionTitle title="현황판" badge="warning" />
<div className={`grid ${getGridColsClass()} gap-3`}>
{filteredItems.map((item) => (
<IssueCardItem
key={item.id}
label={item.label}
count={item.count}
subLabel={item.subLabel}
isHighlighted={item.isHighlighted}
onClick={() => handleItemClick(item.path)}
icon={item.icon}
/>
))}
</div>
</CardContent>
</Card>
);
}

View File

@@ -1,71 +1,168 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Card, CardContent } from '@/components/ui/card';
import { SectionTitle, IssueCardItem } from '../components';
import type { TodayIssueItem, TodayIssueSettings } from '../types';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { toast } from 'sonner';
import type { TodayIssueListItem, TodayIssueListBadgeType } from '../types';
// 라벨 → 설정키 매핑
const LABEL_TO_SETTING_KEY: Record<string, keyof TodayIssueSettings> = {
'수주': 'orders',
'채권 추심': 'debtCollection',
'안전 재고': 'safetyStock',
'세금 신고': 'taxReport',
'신규 업체 등록': 'newVendor',
'연차': 'annualLeave',
'지각': 'lateness',
'결근': 'absence',
'발주': 'purchase',
'결재 요청': 'approvalRequest',
// 뱃지 색상 매핑
const BADGE_COLORS: Record<TodayIssueListBadgeType, string> = {
'수주 성공': 'bg-blue-100 text-blue-700 hover:bg-blue-100',
'주식 이슈': 'bg-purple-100 text-purple-700 hover:bg-purple-100',
'직정 제고': 'bg-orange-100 text-orange-700 hover:bg-orange-100',
'지출예상내역서': 'bg-green-100 text-green-700 hover:bg-green-100',
'세금 신고': 'bg-red-100 text-red-700 hover:bg-red-100',
'결재 요청': 'bg-yellow-100 text-yellow-700 hover:bg-yellow-100',
'기타': 'bg-gray-100 text-gray-700 hover:bg-gray-100',
};
// 필터 옵션
const FILTER_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: '수주 성공', label: '수주 성공' },
{ value: '주식 이슈', label: '주식 이슈' },
{ value: '직정 제고', label: '직정 제고' },
{ value: '지출예상내역서', label: '지출예상내역서' },
{ value: '세금 신고', label: '세금 신고' },
{ value: '결재 요청', label: '결재 요청' },
];
interface TodayIssueSectionProps {
items: TodayIssueItem[];
itemSettings?: TodayIssueSettings;
items: TodayIssueListItem[];
}
export function TodayIssueSection({ items, itemSettings }: TodayIssueSectionProps) {
export function TodayIssueSection({ items }: TodayIssueSectionProps) {
const router = useRouter();
const [filter, setFilter] = useState<string>('all');
const [dismissedIds, setDismissedIds] = useState<Set<string>>(new Set());
const handleItemClick = (path: string) => {
router.push(path);
// 확인되지 않은 아이템만 필터링
const activeItems = items.filter((item) => !dismissedIds.has(item.id));
// 필터링된 아이템
const filteredItems = filter === 'all'
? activeItems
: activeItems.filter((item) => item.badge === filter);
// 아이템 클릭
const handleItemClick = (item: TodayIssueListItem) => {
if (item.path) {
router.push(item.path);
}
};
// 설정에 따라 항목 필터링
const filteredItems = itemSettings
? items.filter((item) => {
const settingKey = LABEL_TO_SETTING_KEY[item.label];
return settingKey ? itemSettings[settingKey] : true;
})
: items;
// 확인 버튼 클릭 (목록에서 제거)
const handleDismiss = (item: TodayIssueListItem) => {
setDismissedIds((prev) => new Set(prev).add(item.id));
toast.success(`"${item.content}" 확인 완료`);
};
// 아이템 개수에 따른 동적 그리드 클래스 (xs: 344px Galaxy Fold 지원)
const getGridColsClass = () => {
const count = filteredItems.length;
if (count <= 1) return 'grid-cols-1';
if (count === 2) return 'grid-cols-1 xs:grid-cols-2';
if (count === 3) return 'grid-cols-1 xs:grid-cols-2 md:grid-cols-3';
// 4개 이상: 최대 4열, 넘치면 아래로
return 'grid-cols-1 xs:grid-cols-2 md:grid-cols-4';
// 승인 버튼 클릭
const handleApprove = (item: TodayIssueListItem) => {
setDismissedIds((prev) => new Set(prev).add(item.id));
toast.success(`"${item.content}" 승인 처리되었습니다.`);
};
// 반려 버튼 클릭
const handleReject = (item: TodayIssueListItem) => {
setDismissedIds((prev) => new Set(prev).add(item.id));
toast.error(`"${item.content}" 반려 처리되었습니다.`);
};
return (
<Card>
<CardContent className="p-6">
<SectionTitle title="오늘의 이슈" badge="warning" />
{/* 헤더 */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900"> </h2>
<Select value={filter} onValueChange={setFilter}>
<SelectTrigger className="w-32 h-9">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
{FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className={`grid ${getGridColsClass()} gap-3`}>
{filteredItems.map((item) => (
<IssueCardItem
key={item.id}
label={item.label}
count={item.count}
subLabel={item.subLabel}
isHighlighted={item.isHighlighted}
onClick={() => handleItemClick(item.path)}
icon={item.icon}
/>
))}
{/* 리스트 */}
<div className="space-y-3 max-h-[400px] overflow-y-auto pr-1">
{filteredItems.length === 0 ? (
<div className="text-center py-8 text-gray-500">
.
</div>
) : (
filteredItems.map((item) => (
<div
key={item.id}
className="flex items-center justify-between p-4 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors cursor-pointer"
onClick={() => handleItemClick(item)}
>
{/* 좌측: 뱃지 + 내용 */}
<div className="flex items-center gap-3 flex-1 min-w-0">
<Badge
variant="secondary"
className={`shrink-0 ${BADGE_COLORS[item.badge]}`}
>
{item.badge}
</Badge>
<span className="text-sm text-gray-800 truncate">
{item.content}
</span>
</div>
{/* 우측: 시간 + 버튼 */}
<div className="flex items-center gap-3 shrink-0 ml-4" onClick={(e) => e.stopPropagation()}>
<span className="text-xs text-gray-500 whitespace-nowrap">
{item.time}
</span>
{item.needsApproval ? (
<div className="flex items-center gap-2">
<Button
size="sm"
variant="default"
className="h-7 px-3 bg-blue-500 hover:bg-blue-600 text-white text-xs"
onClick={() => handleApprove(item)}
>
</Button>
<Button
size="sm"
variant="outline"
className="h-7 px-3 text-xs"
onClick={() => handleReject(item)}
>
</Button>
</div>
) : (
<Button
size="sm"
variant="outline"
className="h-7 px-3 text-xs text-gray-600 hover:text-green-600 hover:border-green-600 hover:bg-green-50"
onClick={() => handleDismiss(item)}
>
</Button>
)}
</div>
</div>
))
)}
</div>
</CardContent>
</Card>

View File

@@ -1,4 +1,5 @@
export { TodayIssueSection } from './TodayIssueSection';
export { StatusBoardSection } from './StatusBoardSection';
export { DailyReportSection } from './DailyReportSection';
export { MonthlyExpenseSection } from './MonthlyExpenseSection';
export { CardManagementSection } from './CardManagementSection';

View File

@@ -45,7 +45,7 @@ export interface AmountCard {
isHighlighted?: boolean; // 빨간색 강조
}
// 오늘의 이슈 항목
// 오늘의 이슈 항목 (카드 형태 - 현황판용)
export interface TodayIssueItem {
id: string;
label: string;
@@ -56,6 +56,26 @@ export interface TodayIssueItem {
icon?: React.ComponentType<{ className?: string }>; // 카드 아이콘
}
// 오늘의 이슈 뱃지 타입
export type TodayIssueListBadgeType =
| '수주 성공'
| '주식 이슈'
| '직정 제고'
| '지출예상내역서'
| '세금 신고'
| '결재 요청'
| '기타';
// 오늘의 이슈 리스트 아이템 (리스트 형태 - 새로운 오늘의 이슈용)
export interface TodayIssueListItem {
id: string;
badge: TodayIssueListBadgeType;
content: string;
time: string; // "10분 전", "1시간 전" 등
needsApproval?: boolean; // 승인/반려 버튼 표시 여부
path?: string; // 클릭 시 이동할 경로
}
// 일일 일보 데이터
export interface DailyReportData {
date: string; // "2026년 1월 5일 월요일"
@@ -135,7 +155,8 @@ export type CalendarTaskFilterType = 'all' | 'schedule' | 'order' | 'constructio
// CEO Dashboard 전체 데이터
export interface CEODashboardData {
todayIssue: TodayIssueItem[];
todayIssue: TodayIssueItem[]; // 현황판용 (구 오늘의 이슈)
todayIssueList: TodayIssueListItem[]; // 새 오늘의 이슈 (리스트 형태)
dailyReport: DailyReportData;
monthlyExpense: MonthlyExpenseData;
cardManagement: CardManagementData;
@@ -194,8 +215,10 @@ export interface WelfareSettings {
// 대시보드 전체 설정
export interface DashboardSettings {
// 오늘의 이슈 섹션
todayIssue: {
// 오늘의 이슈 섹션 (새 리스트 형태)
todayIssueList: boolean;
// 현황판 섹션 (구 오늘의 이슈 - 카드 형태)
statusBoard: {
enabled: boolean;
items: TodayIssueSettings;
};
@@ -212,6 +235,11 @@ export interface DashboardSettings {
debtCollection: boolean;
vat: boolean;
calendar: boolean;
// Legacy: 기존 todayIssue 호환용 (deprecated, statusBoard로 대체)
todayIssue: {
enabled: boolean;
items: TodayIssueSettings;
};
}
// ===== 상세 모달 공통 타입 =====
@@ -398,7 +426,10 @@ export interface DetailModalConfig {
// 기본 설정값
export const DEFAULT_DASHBOARD_SETTINGS: DashboardSettings = {
todayIssue: {
// 새 오늘의 이슈 (리스트 형태)
todayIssueList: true,
// 현황판 (구 오늘의 이슈 - 카드 형태)
statusBoard: {
enabled: true,
items: {
orders: true,
@@ -436,4 +467,20 @@ export const DEFAULT_DASHBOARD_SETTINGS: DashboardSettings = {
debtCollection: true,
vat: true,
calendar: true,
// Legacy: 기존 todayIssue 호환용 (statusBoard와 동일)
todayIssue: {
enabled: true,
items: {
orders: true,
debtCollection: true,
safetyStock: true,
taxReport: false,
newVendor: false,
annualLeave: true,
lateness: true,
absence: false,
purchase: false,
approvalRequest: false,
},
},
};

View File

@@ -6,15 +6,8 @@ import { FileText, Clock, Trophy, Pencil } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox';
import { IntegratedListTemplateV2, TableColumn, StatCard } from '@/components/templates/IntegratedListTemplateV2';
import { MultiSelectOption } from '@/components/ui/multi-select-combobox';
import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
import { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { toast } from 'sonner';
@@ -335,6 +328,81 @@ export default function BiddingListClient({ initialData = [], initialStats }: Bi
}
}, [selectedItems, loadData]);
// ===== filterConfig 기반 통합 필터 시스템 =====
const filterConfig: FilterFieldConfig[] = useMemo(() => [
{
key: 'partner',
label: '거래처',
type: 'multi',
options: MOCK_PARTNERS.map(o => ({
value: o.value,
label: o.label,
})),
},
{
key: 'bidder',
label: '입찰자',
type: 'multi',
options: MOCK_BIDDERS.map(o => ({
value: o.value,
label: o.label,
})),
},
{
key: 'status',
label: '상태',
type: 'single',
options: BIDDING_STATUS_OPTIONS.filter(o => o.value !== 'all').map(o => ({
value: o.value,
label: o.label,
})),
allOptionLabel: '전체',
},
{
key: 'sortBy',
label: '정렬',
type: 'single',
options: BIDDING_SORT_OPTIONS.map(o => ({
value: o.value,
label: o.label,
})),
allOptionLabel: '최신순 (입찰일)',
},
], []);
const filterValues: FilterValues = useMemo(() => ({
partner: partnerFilters,
bidder: bidderFilters,
status: statusFilter,
sortBy: sortBy,
}), [partnerFilters, bidderFilters, statusFilter, sortBy]);
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
switch (key) {
case 'partner':
setPartnerFilters(value as string[]);
break;
case 'bidder':
setBidderFilters(value as string[]);
break;
case 'status':
setStatusFilter(value as string);
break;
case 'sortBy':
setSortBy(value as string);
break;
}
setCurrentPage(1);
}, []);
const handleFilterReset = useCallback(() => {
setPartnerFilters([]);
setBidderFilters([]);
setStatusFilter('all');
setSortBy('biddingDateDesc');
setCurrentPage(1);
}, []);
// 테이블 행 렌더링
const renderTableRow = useCallback(
(bidding: Bidding, index: number, globalIndex: number) => {
@@ -450,63 +518,6 @@ export default function BiddingListClient({ initialData = [], initialStats }: Bi
},
];
// 테이블 헤더 액션 (총 건수 + 필터 4개: 거래처, 입찰자, 상태, 정렬)
const tableHeaderActions = (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-muted-foreground whitespace-nowrap">
{sortedBiddings.length}
</span>
{/* 거래처 필터 (다중선택) */}
<MultiSelectCombobox
options={MOCK_PARTNERS}
value={partnerFilters}
onChange={setPartnerFilters}
placeholder="거래처"
searchPlaceholder="거래처 검색..."
className="w-[120px]"
/>
{/* 입찰자 필터 (다중선택) */}
<MultiSelectCombobox
options={MOCK_BIDDERS}
value={bidderFilters}
onChange={setBidderFilters}
placeholder="입찰자"
searchPlaceholder="입찰자 검색..."
className="w-[120px]"
/>
{/* 상태 필터 */}
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{BIDDING_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 정렬 */}
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="최신순 (입찰일)" />
</SelectTrigger>
<SelectContent>
{BIDDING_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
return (
<>
<IntegratedListTemplateV2
@@ -515,7 +526,11 @@ export default function BiddingListClient({ initialData = [], initialStats }: Bi
icon={FileText}
headerActions={headerActions}
stats={statsCardsData}
tableHeaderActions={tableHeaderActions}
filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="입찰 필터"
searchValue={searchValue}
onSearchChange={handleSearchChange}
searchPlaceholder="입찰번호, 거래처, 현장명 검색"

View File

@@ -36,7 +36,7 @@ import {
getEmptyContractFormData,
contractDetailToFormData,
} from './types';
import { updateContract, deleteContract } from './actions';
import { updateContract, deleteContract, createContract } from './actions';
import { downloadFileById } from '@/lib/utils/fileDownload';
import { ContractDocumentModal } from './modals/ContractDocumentModal';
import {
@@ -59,19 +59,22 @@ function formatFileSize(bytes: number): string {
}
interface ContractDetailFormProps {
mode: 'view' | 'edit';
mode: 'view' | 'edit' | 'create';
contractId: string;
initialData?: ContractDetail;
isChangeContract?: boolean; // 변경 계약서 생성 여부
}
export default function ContractDetailForm({
mode,
contractId,
initialData,
isChangeContract = false,
}: ContractDetailFormProps) {
const router = useRouter();
const isViewMode = mode === 'view';
const isEditMode = mode === 'edit';
const isCreateMode = mode === 'create';
// 폼 데이터
const [formData, setFormData] = useState<ContractFormData>(
@@ -121,10 +124,19 @@ export default function ContractDetailForm({
router.push(`/ko/construction/project/contract/${contractId}/edit`);
}, [router, contractId]);
const handleCancel = useCallback(() => {
router.push(`/ko/construction/project/contract/${contractId}`);
// 변경 계약서 생성 핸들러
const handleCreateChangeContract = useCallback(() => {
router.push(`/ko/construction/project/contract/create?baseContractId=${contractId}`);
}, [router, contractId]);
const handleCancel = useCallback(() => {
if (isCreateMode) {
router.push('/ko/construction/project/contract');
} else {
router.push(`/ko/construction/project/contract/${contractId}`);
}
}, [router, contractId, isCreateMode]);
// 폼 필드 변경
const handleFieldChange = useCallback(
(field: keyof ContractFormData, value: string | number) => {
@@ -141,14 +153,28 @@ export default function ContractDetailForm({
const handleConfirmSave = useCallback(async () => {
setIsLoading(true);
try {
const result = await updateContract(contractId, formData);
if (result.success) {
toast.success('수정이 완료되었습니다.');
setShowSaveDialog(false);
router.push(`/ko/construction/project/contract/${contractId}`);
router.refresh();
if (isCreateMode) {
// 새 계약 생성 (변경 계약서 포함)
const result = await createContract(formData);
if (result.success && result.data) {
toast.success(isChangeContract ? '변경 계약서가 생성되었습니다.' : '계약이 생성되었습니다.');
setShowSaveDialog(false);
router.push(`/ko/construction/project/contract/${result.data.id}`);
router.refresh();
} else {
toast.error(result.error || '저장에 실패했습니다.');
}
} else {
toast.error(result.error || '저장에 실패했습니다.');
// 기존 계약 수정
const result = await updateContract(contractId, formData);
if (result.success) {
toast.success('수정이 완료되었습니다.');
setShowSaveDialog(false);
router.push(`/ko/construction/project/contract/${contractId}`);
router.refresh();
} else {
toast.error(result.error || '저장에 실패했습니다.');
}
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
@@ -156,7 +182,7 @@ export default function ContractDetailForm({
} finally {
setIsLoading(false);
}
}, [router, contractId, formData]);
}, [router, contractId, formData, isCreateMode, isChangeContract]);
// 삭제 핸들러
const handleDelete = useCallback(() => {
@@ -280,6 +306,9 @@ export default function ContractDetailForm({
// 헤더 액션 버튼
const headerActions = isViewMode ? (
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleCreateChangeContract}>
</Button>
<Button variant="outline" onClick={handleViewDocument}>
<Eye className="h-4 w-4 mr-2" />
@@ -289,6 +318,15 @@ export default function ContractDetailForm({
</Button>
<Button onClick={handleEdit}></Button>
</div>
) : isCreateMode ? (
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleSave} disabled={isLoading}>
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleCancel}>
@@ -303,10 +341,15 @@ export default function ContractDetailForm({
</div>
);
// 페이지 타이틀
const pageTitle = isCreateMode
? (isChangeContract ? '변경 계약서 생성' : '계약 등록')
: '계약 상세';
return (
<PageLayout>
<PageHeader
title="계약 상세"
title={pageTitle}
description="계약 정보를 관리합니다"
icon={FileText}
onBack={handleBack}
@@ -483,8 +526,8 @@ export default function ContractDetailForm({
</CardHeader>
<CardContent>
<div className="space-y-3">
{/* 파일 선택 버튼 (수정 모드에서만) */}
{isEditMode && (
{/* 파일 선택 버튼 (수정/생성 모드에서만) */}
{(isEditMode || isCreateMode) && (
<Button variant="outline" onClick={handleContractFileSelect}>
</Button>
@@ -498,7 +541,7 @@ export default function ContractDetailForm({
<span className="text-sm font-medium">{formData.contractFile.name}</span>
<span className="text-xs text-blue-600">( )</span>
</div>
{isEditMode && (
{(isEditMode || isCreateMode) && (
<Button
variant="ghost"
size="icon"
@@ -526,7 +569,7 @@ export default function ContractDetailForm({
<Download className="h-4 w-4 mr-1" />
</Button>
{isEditMode && (
{(isEditMode || isCreateMode) && (
<Button
variant="ghost"
size="icon"
@@ -562,7 +605,7 @@ export default function ContractDetailForm({
</CardHeader>
<CardContent>
{/* 드래그 앤 드롭 영역 */}
{isEditMode && (
{(isEditMode || isCreateMode) && (
<div
className={`border-2 border-dashed rounded-lg p-8 text-center mb-4 transition-colors cursor-pointer ${
isDragging ? 'border-primary bg-primary/5' : 'border-muted-foreground/25'
@@ -605,7 +648,7 @@ export default function ContractDetailForm({
<Download className="h-4 w-4 mr-1" />
</Button>
{isEditMode && (
{(isEditMode || isCreateMode) && (
<Button
variant="ghost"
size="icon"

View File

@@ -6,15 +6,8 @@ import { FileText, Clock, CheckCircle, Pencil, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox';
import { IntegratedListTemplateV2, TableColumn, StatCard } from '@/components/templates/IntegratedListTemplateV2';
import { MultiSelectOption } from '@/components/ui/multi-select-combobox';
import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
import { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { toast } from 'sonner';
@@ -350,6 +343,95 @@ export default function ContractListClient({
}
}, [selectedItems, loadData]);
// ===== filterConfig 기반 통합 필터 시스템 =====
const filterConfig: FilterFieldConfig[] = useMemo(() => [
{
key: 'partner',
label: '거래처',
type: 'multi',
options: MOCK_PARTNERS.map(o => ({
value: o.value,
label: o.label,
})),
},
{
key: 'contractManager',
label: '계약담당자',
type: 'multi',
options: MOCK_CONTRACT_MANAGERS.map(o => ({
value: o.value,
label: o.label,
})),
},
{
key: 'constructionPM',
label: '공사PM',
type: 'multi',
options: MOCK_CONSTRUCTION_PMS.map(o => ({
value: o.value,
label: o.label,
})),
},
{
key: 'status',
label: '상태',
type: 'single',
options: CONTRACT_STATUS_OPTIONS.filter(o => o.value !== 'all').map(o => ({
value: o.value,
label: o.label,
})),
allOptionLabel: '전체',
},
{
key: 'sortBy',
label: '정렬',
type: 'single',
options: CONTRACT_SORT_OPTIONS.map(o => ({
value: o.value,
label: o.label,
})),
allOptionLabel: '최신순 (계약일)',
},
], []);
const filterValues: FilterValues = useMemo(() => ({
partner: partnerFilters,
contractManager: contractManagerFilters,
constructionPM: constructionPMFilters,
status: statusFilter,
sortBy: sortBy,
}), [partnerFilters, contractManagerFilters, constructionPMFilters, statusFilter, sortBy]);
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
switch (key) {
case 'partner':
setPartnerFilters(value as string[]);
break;
case 'contractManager':
setContractManagerFilters(value as string[]);
break;
case 'constructionPM':
setConstructionPMFilters(value as string[]);
break;
case 'status':
setStatusFilter(value as string);
break;
case 'sortBy':
setSortBy(value as string);
break;
}
setCurrentPage(1);
}, []);
const handleFilterReset = useCallback(() => {
setPartnerFilters([]);
setContractManagerFilters([]);
setConstructionPMFilters([]);
setStatusFilter('all');
setSortBy('contractDateDesc');
setCurrentPage(1);
}, []);
// 테이블 행 렌더링
// 순서: 체크박스, 번호, 계약번호, 거래처, 현장명, 계약담당자, 공사PM, 총 개소, 계약금액, 계약기간, 상태, 작업
const renderTableRow = useCallback(
@@ -475,73 +557,6 @@ export default function ContractListClient({
},
];
// 테이블 헤더 액션 (총 건수 + 필터들)
const tableHeaderActions = (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-muted-foreground whitespace-nowrap">
{sortedContracts.length}
</span>
{/* 거래처 필터 */}
<MultiSelectCombobox
options={MOCK_PARTNERS}
value={partnerFilters}
onChange={setPartnerFilters}
placeholder="거래처"
searchPlaceholder="거래처 검색..."
className="w-[120px]"
/>
{/* 계약담당자 필터 */}
<MultiSelectCombobox
options={MOCK_CONTRACT_MANAGERS}
value={contractManagerFilters}
onChange={setContractManagerFilters}
placeholder="계약담당자"
searchPlaceholder="계약담당자 검색..."
className="w-[130px]"
/>
{/* 공사PM 필터 */}
<MultiSelectCombobox
options={MOCK_CONSTRUCTION_PMS}
value={constructionPMFilters}
onChange={setConstructionPMFilters}
placeholder="공사PM"
searchPlaceholder="공사PM 검색..."
className="w-[120px]"
/>
{/* 상태 필터 */}
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{CONTRACT_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 정렬 */}
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="최신순 (계약일)" />
</SelectTrigger>
<SelectContent>
{CONTRACT_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
return (
<>
<IntegratedListTemplateV2
@@ -550,7 +565,11 @@ export default function ContractListClient({
icon={FileText}
headerActions={headerActions}
stats={statsCardsData}
tableHeaderActions={tableHeaderActions}
filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="계약 필터"
searchValue={searchValue}
onSearchChange={handleSearchChange}
searchPlaceholder="계약번호, 거래처, 현장명 검색"

View File

@@ -407,4 +407,26 @@ export async function deleteContracts(ids: string[]): Promise<{
console.error('계약 일괄 삭제 오류:', error);
return { success: false, error: '일괄 삭제에 실패했습니다.' };
}
}
// 계약 생성 (변경 계약서 생성 포함)
export async function createContract(
_data: ContractFormData
): Promise<{
success: boolean;
data?: { id: string };
error?: string;
}> {
try {
await new Promise((resolve) => setTimeout(resolve, 500));
// TODO: 실제 API 연동 시 데이터 생성 로직
// 새 계약 ID 생성 (목업)
const newId = String(MOCK_CONTRACTS.length + 1);
return { success: true, data: { id: newId } };
} catch (error) {
console.error('createContract error:', error);
return { success: false, error: '계약 생성에 실패했습니다.' };
}
}

View File

@@ -6,15 +6,8 @@ import { FileText, FileTextIcon, Clock, FileCheck, Pencil } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox';
import { IntegratedListTemplateV2, TableColumn, StatCard } from '@/components/templates/IntegratedListTemplateV2';
import { MultiSelectOption } from '@/components/ui/multi-select-combobox';
import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
import { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { toast } from 'sonner';
@@ -315,6 +308,81 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
}
}, [selectedItems, loadData]);
// ===== filterConfig 기반 통합 필터 시스템 =====
const filterConfig: FilterFieldConfig[] = useMemo(() => [
{
key: 'partner',
label: '거래처',
type: 'multi',
options: MOCK_PARTNERS.map(o => ({
value: o.value,
label: o.label,
})),
},
{
key: 'estimator',
label: '견적자',
type: 'multi',
options: MOCK_ESTIMATORS.map(o => ({
value: o.value,
label: o.label,
})),
},
{
key: 'status',
label: '상태',
type: 'single',
options: ESTIMATE_STATUS_OPTIONS.filter(o => o.value !== 'all').map(o => ({
value: o.value,
label: o.label,
})),
allOptionLabel: '전체',
},
{
key: 'sortBy',
label: '정렬',
type: 'single',
options: ESTIMATE_SORT_OPTIONS.map(o => ({
value: o.value,
label: o.label,
})),
allOptionLabel: '최신순',
},
], []);
const filterValues: FilterValues = useMemo(() => ({
partner: partnerFilters,
estimator: estimatorFilters,
status: statusFilter,
sortBy: sortBy,
}), [partnerFilters, estimatorFilters, statusFilter, sortBy]);
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
switch (key) {
case 'partner':
setPartnerFilters(value as string[]);
break;
case 'estimator':
setEstimatorFilters(value as string[]);
break;
case 'status':
setStatusFilter(value as string);
break;
case 'sortBy':
setSortBy(value as string);
break;
}
setCurrentPage(1);
}, []);
const handleFilterReset = useCallback(() => {
setPartnerFilters([]);
setEstimatorFilters([]);
setStatusFilter('all');
setSortBy('latest');
setCurrentPage(1);
}, []);
// 테이블 행 렌더링
const renderTableRow = useCallback(
(estimate: Estimate, index: number, globalIndex: number) => {
@@ -428,63 +496,6 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
},
];
// 테이블 헤더 액션 (총 건수 + 필터들)
const tableHeaderActions = (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-muted-foreground whitespace-nowrap">
{sortedEstimates.length}
</span>
{/* 거래처 필터 (다중선택) */}
<MultiSelectCombobox
options={MOCK_PARTNERS}
value={partnerFilters}
onChange={setPartnerFilters}
placeholder="거래처"
searchPlaceholder="거래처 검색..."
className="w-[120px]"
/>
{/* 견적자 필터 (다중선택) */}
<MultiSelectCombobox
options={MOCK_ESTIMATORS}
value={estimatorFilters}
onChange={setEstimatorFilters}
placeholder="견적자"
searchPlaceholder="견적자 검색..."
className="w-[120px]"
/>
{/* 상태 필터 */}
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[110px]">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{ESTIMATE_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 정렬 */}
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="최신순" />
</SelectTrigger>
<SelectContent>
{ESTIMATE_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
return (
<>
<IntegratedListTemplateV2
@@ -493,7 +504,11 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
icon={FileText}
headerActions={headerActions}
stats={statsCardsData}
tableHeaderActions={tableHeaderActions}
filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="견적 필터"
searchValue={searchValue}
onSearchChange={handleSearchChange}
searchPlaceholder="견적번호, 거래처, 현장명 검색"

View File

@@ -88,6 +88,8 @@ export function EstimateDocumentModal({
address: '주소',
amount: formData.summaryItems.reduce((sum, item) => sum + item.totalCost, 0),
date: formData.bidInfo.bidDate || '2025년 12월 12일',
manager: formData.estimateCompanyManager || '',
managerContact: formData.estimateCompanyManagerContact || '',
contact: {
hp: '010-3679-2188',
tel: '(02) 849-5130',
@@ -194,17 +196,22 @@ export function EstimateDocumentModal({
<td className="border border-gray-400 px-3 py-2">{documentData.date}</td>
</tr>
<tr>
<td className="border border-gray-400 px-3 py-2 bg-gray-100 text-center"></td>
<td className="border border-gray-400 px-3 py-2 bg-gray-100 text-center"></td>
<td className="border border-gray-400 px-3 py-2"></td>
<td className="border border-gray-400 px-3 py-2 bg-gray-100 text-center"></td>
<td className="border border-gray-400 px-3 py-2">
<td className="border border-gray-400 px-3 py-2 bg-gray-100 text-center" rowSpan={2}></td>
<td className="border border-gray-400 px-3 py-2" rowSpan={2}>
<div className="space-y-0.5 text-xs">
<div> : {documentData.manager}</div>
<div>H . P : {documentData.contact.hp}</div>
<div>T E L : {documentData.contact.tel}</div>
<div>F A X : {documentData.contact.fax}</div>
</div>
</td>
</tr>
<tr>
<td className="border border-gray-400 px-3 py-2 bg-gray-100 text-center"></td>
<td className="border border-gray-400 px-3 py-2"></td>
</tr>
</tbody>
</table>

View File

@@ -53,29 +53,46 @@ export function EstimateInfoSection({
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<Input value={formData.estimateCode} disabled className="bg-gray-50" />
<CardContent className="space-y-4">
{/* 1행: 견적번호, 견적자 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<Input value={formData.estimateCode} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<Input value={formData.estimatorName} disabled className="bg-gray-50" />
</div>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<Input value={formData.estimatorName} disabled className="bg-gray-50" />
{/* 2행: 견적 회사 담당자, 견적 회사 담당자 연락처 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"> </Label>
<Input value={formData.estimateCompanyManager} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"> </Label>
<Input value={formData.estimateCompanyManagerContact} disabled className="bg-gray-50" />
</div>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<Input
value={formatAmount(formData.estimateAmount)}
disabled
className="bg-gray-50 text-right"
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<div className="flex items-center h-10 px-3 border rounded-md bg-gray-50">
<span className={STATUS_STYLES[formData.status]}>
{STATUS_LABELS[formData.status]}
</span>
{/* 3행: 견적금액, 상태 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<Input
value={formatAmount(formData.estimateAmount)}
disabled
className="bg-gray-50 text-right"
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<div className="flex items-center h-10 px-3 border rounded-md bg-gray-50">
<span className={STATUS_STYLES[formData.status]}>
{STATUS_LABELS[formData.status]}
</span>
</div>
</div>
</div>
</CardContent>

View File

@@ -78,9 +78,11 @@ export function PriceAdjustmentSection({
>
</Button>
{/* 초기화 버튼 주석처리
<Button type="button" variant="outline" size="sm" onClick={onReset}>
초기화
</Button>
*/}
</div>
)}
</CardHeader>

View File

@@ -184,6 +184,8 @@ export interface EstimateDetailFormData {
estimateCode: string;
estimatorId: string;
estimatorName: string;
estimateCompanyManager: string; // 견적 회사 담당자
estimateCompanyManagerContact: string; // 견적 회사 담당자 연락처
estimateAmount: number;
status: EstimateStatus;
@@ -251,6 +253,8 @@ export function getEmptyEstimateDetailFormData(): EstimateDetailFormData {
estimateCode: '',
estimatorId: '',
estimatorName: '',
estimateCompanyManager: '',
estimateCompanyManagerContact: '',
estimateAmount: 0,
status: 'pending',
siteBriefing: {
@@ -290,6 +294,8 @@ export function estimateDetailToFormData(detail: EstimateDetail): EstimateDetail
estimateCode: detail.estimateCode,
estimatorId: detail.estimatorId,
estimatorName: detail.estimatorName,
estimateCompanyManager: detail.estimateCompanyManager || '',
estimateCompanyManagerContact: detail.estimateCompanyManagerContact || '',
estimateAmount: detail.estimateAmount,
status: detail.status,
siteBriefing: detail.siteBriefing,
@@ -315,6 +321,8 @@ export interface Estimate {
projectName: string; // 현장명
estimatorId: string; // 견적자 ID
estimatorName: string; // 견적자명
estimateCompanyManager: string; // 견적 회사 담당자
estimateCompanyManagerContact: string; // 견적 회사 담당자 연락처
// 견적 정보
itemCount: number; // 총 개소 (품목 수)

View File

@@ -6,15 +6,8 @@ import { FileText, Clock, CheckCircle, Pencil } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox';
import { IntegratedListTemplateV2, TableColumn, StatCard } from '@/components/templates/IntegratedListTemplateV2';
import { MultiSelectOption } from '@/components/ui/multi-select-combobox';
import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
import { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { toast } from 'sonner';
@@ -272,6 +265,95 @@ export default function HandoverReportListClient({
[router]
);
// ===== filterConfig 기반 통합 필터 시스템 =====
const filterConfig: FilterFieldConfig[] = useMemo(() => [
{
key: 'partner',
label: '거래처',
type: 'multi',
options: MOCK_PARTNERS.map(o => ({
value: o.value,
label: o.label,
})),
},
{
key: 'contractManager',
label: '계약담당자',
type: 'multi',
options: MOCK_CONTRACT_MANAGERS.map(o => ({
value: o.value,
label: o.label,
})),
},
{
key: 'constructionPM',
label: '공사PM',
type: 'multi',
options: MOCK_CONSTRUCTION_PMS.map(o => ({
value: o.value,
label: o.label,
})),
},
{
key: 'status',
label: '상태',
type: 'single',
options: REPORT_STATUS_OPTIONS.filter(o => o.value !== 'all').map(o => ({
value: o.value,
label: o.label,
})),
allOptionLabel: '전체',
},
{
key: 'sortBy',
label: '정렬',
type: 'single',
options: REPORT_SORT_OPTIONS.map(o => ({
value: o.value,
label: o.label,
})),
allOptionLabel: '최신순 (계약시작일)',
},
], []);
const filterValues: FilterValues = useMemo(() => ({
partner: partnerFilters,
contractManager: contractManagerFilters,
constructionPM: constructionPMFilters,
status: statusFilter,
sortBy: sortBy,
}), [partnerFilters, contractManagerFilters, constructionPMFilters, statusFilter, sortBy]);
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
switch (key) {
case 'partner':
setPartnerFilters(value as string[]);
break;
case 'contractManager':
setContractManagerFilters(value as string[]);
break;
case 'constructionPM':
setConstructionPMFilters(value as string[]);
break;
case 'status':
setStatusFilter(value as string);
break;
case 'sortBy':
setSortBy(value as string);
break;
}
setCurrentPage(1);
}, []);
const handleFilterReset = useCallback(() => {
setPartnerFilters([]);
setContractManagerFilters([]);
setConstructionPMFilters([]);
setStatusFilter('all');
setSortBy('contractDateDesc');
setCurrentPage(1);
}, []);
// 테이블 행 렌더링
const renderTableRow = useCallback(
(report: HandoverReport, index: number, globalIndex: number) => {
@@ -389,73 +471,6 @@ export default function HandoverReportListClient({
},
];
// 테이블 헤더 액션
const tableHeaderActions = (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-muted-foreground whitespace-nowrap">
{sortedReports.length}
</span>
{/* 거래처 필터 */}
<MultiSelectCombobox
options={MOCK_PARTNERS}
value={partnerFilters}
onChange={setPartnerFilters}
placeholder="거래처"
searchPlaceholder="거래처 검색..."
className="w-[130px]"
/>
{/* 계약담당자 필터 */}
<MultiSelectCombobox
options={MOCK_CONTRACT_MANAGERS}
value={contractManagerFilters}
onChange={setContractManagerFilters}
placeholder="계약담당자"
searchPlaceholder="계약담당자 검색..."
className="w-[130px]"
/>
{/* 공사PM 필터 */}
<MultiSelectCombobox
options={MOCK_CONSTRUCTION_PMS}
value={constructionPMFilters}
onChange={setConstructionPMFilters}
placeholder="공사PM"
searchPlaceholder="공사PM 검색..."
className="w-[120px]"
/>
{/* 상태 필터 */}
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{REPORT_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 정렬 */}
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="최신순 (계약시작일)" />
</SelectTrigger>
<SelectContent>
{REPORT_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
return (
<>
<IntegratedListTemplateV2
@@ -464,7 +479,11 @@ export default function HandoverReportListClient({
icon={FileText}
headerActions={headerActions}
stats={statsCardsData}
tableHeaderActions={tableHeaderActions}
filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="인수인계보고서 필터"
searchValue={searchValue}
onSearchChange={handleSearchChange}
searchPlaceholder="보고서번호, 거래처, 현장명 검색"

View File

@@ -0,0 +1,693 @@
'use client';
import { useState, useCallback, useRef, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { AlertTriangle, List, Mic, X, Undo2, Upload } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { toast } from 'sonner';
import type { Issue, IssueFormData, IssueImage, IssueStatus, IssueCategory, IssuePriority } from './types';
import {
ISSUE_STATUS_FORM_OPTIONS,
ISSUE_PRIORITY_FORM_OPTIONS,
ISSUE_CATEGORY_FORM_OPTIONS,
MOCK_CONSTRUCTION_NUMBERS,
MOCK_ISSUE_PARTNERS,
MOCK_ISSUE_SITES,
MOCK_ISSUE_REPORTERS,
MOCK_ISSUE_ASSIGNEES,
} from './types';
import { createIssue, updateIssue, withdrawIssue } from './actions';
interface IssueDetailFormProps {
issue?: Issue;
mode?: 'view' | 'edit' | 'create';
}
export default function IssueDetailForm({ issue, mode = 'view' }: IssueDetailFormProps) {
const router = useRouter();
const isEditMode = mode === 'edit';
const isCreateMode = mode === 'create';
const isViewMode = mode === 'view';
// 이미지 업로드 ref
const imageInputRef = useRef<HTMLInputElement>(null);
// 철회 다이얼로그
const [withdrawDialogOpen, setWithdrawDialogOpen] = useState(false);
// 폼 상태
const [formData, setFormData] = useState<IssueFormData>({
issueNumber: issue?.issueNumber || '',
constructionNumber: issue?.constructionNumber || '',
partnerName: issue?.partnerName || '',
siteName: issue?.siteName || '',
constructionPM: issue?.constructionPM || '',
constructionManagers: issue?.constructionManagers || '',
reporter: issue?.reporter || '',
assignee: issue?.assignee || '',
reportDate: issue?.reportDate || new Date().toISOString().split('T')[0],
resolvedDate: issue?.resolvedDate || '',
status: issue?.status || 'received',
category: issue?.category || 'material',
priority: issue?.priority || 'normal',
title: issue?.title || '',
content: issue?.content || '',
images: issue?.images || [],
});
const [isSubmitting, setIsSubmitting] = useState(false);
// 시공번호 변경 시 관련 정보 자동 채움
useEffect(() => {
if (formData.constructionNumber) {
const construction = MOCK_CONSTRUCTION_NUMBERS.find(
(c) => c.value === formData.constructionNumber
);
if (construction) {
setFormData((prev) => ({
...prev,
partnerName: construction.partnerName,
siteName: construction.siteName,
constructionPM: construction.pm,
constructionManagers: construction.managers,
}));
}
}
}, [formData.constructionNumber]);
// 담당자 지정 시 상태를 처리중으로 자동 변경
const handleAssigneeChange = useCallback((value: string) => {
setFormData((prev) => ({
...prev,
assignee: value,
// 담당자가 지정되고 현재 상태가 '접수'이면 '처리중'으로 변경
status: value && prev.status === 'received' ? 'in_progress' : prev.status,
}));
if (value && formData.status === 'received') {
toast.info('담당자가 지정되어 상태가 "처리중"으로 변경되었습니다.');
}
}, [formData.status]);
// 중요도 변경 시 긴급이면 알림 표시
const handlePriorityChange = useCallback((value: string) => {
setFormData((prev) => ({ ...prev, priority: value as IssuePriority }));
if (value === 'urgent') {
toast.warning('긴급 이슈로 설정되었습니다. 공사PM과 대표에게 알림이 발송됩니다.');
}
}, []);
// 입력 핸들러
const handleInputChange = useCallback(
(field: keyof IssueFormData) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setFormData((prev) => ({ ...prev, [field]: e.target.value }));
},
[]
);
const handleSelectChange = useCallback((field: keyof IssueFormData) => (value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
}, []);
// 수정 버튼 클릭
const handleEditClick = useCallback(() => {
if (issue?.id) {
router.push(`/ko/construction/project/issue-management/${issue.id}/edit`);
}
}, [router, issue?.id]);
// 저장
const handleSubmit = useCallback(async () => {
if (!formData.title.trim()) {
toast.error('제목을 입력해주세요.');
return;
}
if (!formData.constructionNumber) {
toast.error('시공번호를 선택해주세요.');
return;
}
setIsSubmitting(true);
try {
if (isCreateMode) {
const result = await createIssue({
issueNumber: `ISS-${Date.now()}`,
constructionNumber: formData.constructionNumber,
partnerName: formData.partnerName,
siteName: formData.siteName,
constructionPM: formData.constructionPM,
constructionManagers: formData.constructionManagers,
category: formData.category,
title: formData.title,
content: formData.content,
reporter: formData.reporter,
reportDate: formData.reportDate,
resolvedDate: formData.resolvedDate || null,
assignee: formData.assignee,
priority: formData.priority,
status: formData.status,
images: formData.images,
});
if (result.success) {
toast.success('이슈가 등록되었습니다.');
router.push('/ko/construction/project/issue-management');
} else {
toast.error(result.error || '이슈 등록에 실패했습니다.');
}
} else {
const result = await updateIssue(issue!.id, {
constructionNumber: formData.constructionNumber,
partnerName: formData.partnerName,
siteName: formData.siteName,
constructionPM: formData.constructionPM,
constructionManagers: formData.constructionManagers,
category: formData.category,
title: formData.title,
content: formData.content,
reporter: formData.reporter,
reportDate: formData.reportDate,
resolvedDate: formData.resolvedDate || null,
assignee: formData.assignee,
priority: formData.priority,
status: formData.status,
images: formData.images,
});
if (result.success) {
toast.success('이슈가 수정되었습니다.');
router.push('/ko/construction/project/issue-management');
} else {
toast.error(result.error || '이슈 수정에 실패했습니다.');
}
}
} catch {
toast.error('저장에 실패했습니다.');
} finally {
setIsSubmitting(false);
}
}, [formData, isCreateMode, issue, router]);
// 취소
const handleCancel = useCallback(() => {
router.back();
}, [router]);
// 철회
const handleWithdraw = useCallback(async () => {
if (!issue?.id) return;
try {
const result = await withdrawIssue(issue.id);
if (result.success) {
toast.success('이슈가 철회되었습니다.');
router.push('/ko/construction/project/issue-management');
} else {
toast.error(result.error || '이슈 철회에 실패했습니다.');
}
} catch {
toast.error('이슈 철회에 실패했습니다.');
} finally {
setWithdrawDialogOpen(false);
}
}, [issue?.id, router]);
// 이미지 업로드 핸들러
const handleImageUpload = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
const newImages: IssueImage[] = Array.from(files).map((file, index) => ({
id: `img-${Date.now()}-${index}`,
url: URL.createObjectURL(file),
fileName: file.name,
uploadedAt: new Date().toISOString(),
}));
setFormData((prev) => ({
...prev,
images: [...prev.images, ...newImages],
}));
toast.success(`${files.length}개의 이미지가 추가되었습니다.`);
// 입력 초기화
if (imageInputRef.current) {
imageInputRef.current.value = '';
}
}, []);
// 이미지 삭제
const handleImageRemove = useCallback((imageId: string) => {
setFormData((prev) => ({
...prev,
images: prev.images.filter((img) => img.id !== imageId),
}));
toast.success('이미지가 삭제되었습니다.');
}, []);
// 녹음 버튼 (UI만)
const handleRecordClick = useCallback(() => {
toast.info('녹음 기능은 준비 중입니다.');
}, []);
// 읽기 전용 여부
const isReadOnly = isViewMode;
return (
<PageLayout>
<PageHeader
title={isCreateMode ? '이슈 등록' : '이슈 상세'}
description="이슈를 등록하고 관리합니다"
icon={AlertTriangle}
actions={
isViewMode ? (
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => router.push('/ko/construction/project/issue-management')}
>
<List className="mr-2 h-4 w-4" />
</Button>
<Button
variant="outline"
onClick={() => setWithdrawDialogOpen(true)}
className="text-orange-600 border-orange-300 hover:bg-orange-50"
>
<Undo2 className="mr-2 h-4 w-4" />
</Button>
<Button onClick={handleEditClick}></Button>
</div>
) : (
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => router.push('/ko/construction/project/issue-management')}
>
<List className="mr-2 h-4 w-4" />
</Button>
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting ? '저장 중...' : '저장'}
</Button>
</div>
)
}
/>
<div className="space-y-6">
{/* 이슈 정보 카드 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 이슈번호 */}
<div className="space-y-2">
<Label htmlFor="issueNumber"></Label>
<Input
id="issueNumber"
value={formData.issueNumber || (isCreateMode ? '자동 생성' : '')}
disabled
className="bg-muted"
/>
</div>
{/* 시공번호 */}
<div className="space-y-2">
<Label htmlFor="constructionNumber"></Label>
<Select
value={formData.constructionNumber}
onValueChange={handleSelectChange('constructionNumber')}
disabled={isReadOnly}
>
<SelectTrigger id="constructionNumber">
<SelectValue placeholder="시공번호 선택" />
</SelectTrigger>
<SelectContent>
{MOCK_CONSTRUCTION_NUMBERS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 거래처 */}
<div className="space-y-2">
<Label htmlFor="partnerName"></Label>
<Select
value={formData.partnerName}
onValueChange={handleSelectChange('partnerName')}
disabled={isReadOnly}
>
<SelectTrigger id="partnerName">
<SelectValue placeholder="거래처 선택" />
</SelectTrigger>
<SelectContent>
{MOCK_ISSUE_PARTNERS.map((option) => (
<SelectItem key={option.value} value={option.label}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 현장 */}
<div className="space-y-2">
<Label htmlFor="siteName"></Label>
<Select
value={formData.siteName}
onValueChange={handleSelectChange('siteName')}
disabled={isReadOnly}
>
<SelectTrigger id="siteName">
<SelectValue placeholder="현장 선택" />
</SelectTrigger>
<SelectContent>
{MOCK_ISSUE_SITES.map((option) => (
<SelectItem key={option.value} value={option.label}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 공사PM (자동) */}
<div className="space-y-2">
<Label htmlFor="constructionPM">PM</Label>
<Input
id="constructionPM"
value={formData.constructionPM}
disabled
className="bg-muted"
placeholder="시공번호 선택 시 자동 입력"
/>
</div>
{/* 공사담당자 (자동) */}
<div className="space-y-2">
<Label htmlFor="constructionManagers"></Label>
<Input
id="constructionManagers"
value={formData.constructionManagers}
disabled
className="bg-muted"
placeholder="시공번호 선택 시 자동 입력"
/>
</div>
{/* 보고자 */}
<div className="space-y-2">
<Label htmlFor="reporter"></Label>
<Select
value={formData.reporter}
onValueChange={handleSelectChange('reporter')}
disabled={isReadOnly}
>
<SelectTrigger id="reporter">
<SelectValue placeholder="보고자 선택" />
</SelectTrigger>
<SelectContent>
{MOCK_ISSUE_REPORTERS.map((option) => (
<SelectItem key={option.value} value={option.label}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 담당자 */}
<div className="space-y-2">
<Label htmlFor="assignee"></Label>
<Select
value={formData.assignee}
onValueChange={handleAssigneeChange}
disabled={isReadOnly}
>
<SelectTrigger id="assignee">
<SelectValue placeholder="담당자 선택" />
</SelectTrigger>
<SelectContent>
{MOCK_ISSUE_ASSIGNEES.map((option) => (
<SelectItem key={option.value} value={option.label}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 이슈보고일 */}
<div className="space-y-2">
<Label htmlFor="reportDate"></Label>
<Input
id="reportDate"
type="date"
value={formData.reportDate}
onChange={handleInputChange('reportDate')}
disabled={isReadOnly}
/>
</div>
{/* 이슈해결일 */}
<div className="space-y-2">
<Label htmlFor="resolvedDate"></Label>
<Input
id="resolvedDate"
type="date"
value={formData.resolvedDate}
onChange={handleInputChange('resolvedDate')}
disabled={isReadOnly}
/>
</div>
{/* 상태 */}
<div className="space-y-2 md:col-span-2">
<Label htmlFor="status"></Label>
<Select
value={formData.status}
onValueChange={(value) => handleSelectChange('status')(value as IssueStatus)}
disabled={isReadOnly}
>
<SelectTrigger id="status" className="w-full md:w-[200px]">
<SelectValue placeholder="상태 선택" />
</SelectTrigger>
<SelectContent>
{ISSUE_STATUS_FORM_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* 이슈 보고 카드 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* 구분 & 중요도 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 구분 */}
<div className="space-y-2">
<Label htmlFor="category"></Label>
<Select
value={formData.category}
onValueChange={(value) => handleSelectChange('category')(value as IssueCategory)}
disabled={isReadOnly}
>
<SelectTrigger id="category">
<SelectValue placeholder="구분 선택" />
</SelectTrigger>
<SelectContent>
{ISSUE_CATEGORY_FORM_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 중요도 */}
<div className="space-y-2">
<Label htmlFor="priority"></Label>
<Select
value={formData.priority}
onValueChange={handlePriorityChange}
disabled={isReadOnly}
>
<SelectTrigger id="priority">
<SelectValue placeholder="중요도 선택" />
</SelectTrigger>
<SelectContent>
{ISSUE_PRIORITY_FORM_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 제목 */}
<div className="space-y-2">
<Label htmlFor="title"></Label>
<Input
id="title"
value={formData.title}
onChange={handleInputChange('title')}
placeholder="제목을 입력하세요"
disabled={isReadOnly}
/>
</div>
{/* 내용 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="content"></Label>
{!isReadOnly && (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleRecordClick}
>
<Mic className="mr-2 h-4 w-4" />
</Button>
)}
</div>
<Textarea
id="content"
value={formData.content}
onChange={handleInputChange('content')}
placeholder="내용을 입력하세요"
rows={6}
disabled={isReadOnly}
/>
</div>
</div>
</CardContent>
</Card>
{/* 사진 카드 */}
<Card>
<CardHeader className="border-b pb-4">
<CardTitle className="text-base font-medium"></CardTitle>
</CardHeader>
<CardContent className="pt-4 space-y-4">
{/* 업로드 버튼 */}
{!isReadOnly && (
<div>
<label className="inline-flex items-center gap-2 px-4 py-2 border rounded-md cursor-pointer hover:bg-muted/50 transition-colors">
<Upload className="h-4 w-4" />
<span className="text-sm"> </span>
<input
ref={imageInputRef}
type="file"
accept="image/*"
multiple
onChange={handleImageUpload}
className="hidden"
/>
</label>
</div>
)}
{/* 업로드된 사진 목록 */}
{formData.images.length > 0 ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{formData.images.map((image) => (
<div key={image.id} className="relative group">
<img
src={image.url}
alt={image.fileName}
className="w-full h-32 object-cover rounded-lg border"
/>
{!isReadOnly && (
<button
type="button"
onClick={() => handleImageRemove(image.id)}
className="absolute top-1 right-1 p-1 bg-black/50 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="h-4 w-4" />
</button>
)}
<div className="text-xs text-muted-foreground truncate mt-1">
{image.fileName}
</div>
</div>
))}
</div>
) : (
<div className="text-center text-muted-foreground py-4">
.
</div>
)}
</CardContent>
</Card>
</div>
{/* 철회 확인 다이얼로그 */}
<AlertDialog open={withdrawDialogOpen} onOpenChange={setWithdrawDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
?
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleWithdraw}
className="bg-orange-600 hover:bg-orange-700"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PageLayout>
);
}

View File

@@ -0,0 +1,679 @@
'use client';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { AlertTriangle, Pencil, Plus, Inbox, Clock, CheckCircle, XCircle, Undo2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import { MultiSelectOption } from '@/components/ui/multi-select-combobox';
import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
import { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { toast } from 'sonner';
import type {
Issue,
IssueStats,
} from './types';
import {
ISSUE_STATUS_OPTIONS,
ISSUE_PRIORITY_OPTIONS,
ISSUE_CATEGORY_OPTIONS,
ISSUE_SORT_OPTIONS,
ISSUE_STATUS_STYLES,
ISSUE_STATUS_LABELS,
ISSUE_PRIORITY_STYLES,
ISSUE_PRIORITY_LABELS,
ISSUE_CATEGORY_LABELS,
MOCK_ISSUE_PARTNERS,
MOCK_ISSUE_SITES,
MOCK_ISSUE_REPORTERS,
MOCK_ISSUE_ASSIGNEES,
} from './types';
import {
getIssueList,
getIssueStats,
withdrawIssues,
} from './actions';
// 테이블 컬럼 정의
// 체크박스, 번호, 이슈번호, 시공번호, 거래처, 현장, 구분, 제목, 보고자, 이슈보고일, 이슈해결일, 담당자, 중요도, 상태, 작업
const tableColumns: TableColumn[] = [
{ key: 'no', label: '번호', className: 'w-[50px] text-center' },
{ key: 'issueNumber', label: '이슈번호', className: 'w-[120px]' },
{ key: 'constructionNumber', label: '시공번호', className: 'w-[100px]' },
{ key: 'partnerName', label: '거래처', className: 'w-[100px]' },
{ key: 'siteName', label: '현장', className: 'min-w-[120px]' },
{ key: 'category', label: '구분', className: 'w-[80px] text-center' },
{ key: 'title', label: '제목', className: 'min-w-[150px]' },
{ key: 'reporter', label: '보고자', className: 'w-[80px]' },
{ key: 'reportDate', label: '이슈보고일', className: 'w-[100px]' },
{ key: 'resolvedDate', label: '이슈해결일', className: 'w-[100px]' },
{ key: 'assignee', label: '담당자', className: 'w-[80px]' },
{ key: 'priority', label: '중요도', className: 'w-[80px] text-center' },
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
];
interface IssueManagementListClientProps {
initialData?: Issue[];
initialStats?: IssueStats;
}
export default function IssueManagementListClient({
initialData = [],
initialStats,
}: IssueManagementListClientProps) {
const router = useRouter();
// 상태
const [issues, setIssues] = useState<Issue[]>(initialData);
const [stats, setStats] = useState<IssueStats | null>(initialStats || null);
const [searchValue, setSearchValue] = useState('');
// 다중선택 필터 (빈 배열 = 전체)
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
const [siteFilters, setSiteFilters] = useState<string[]>([]);
const [categoryFilters, setCategoryFilters] = useState<string[]>([]);
const [reporterFilters, setReporterFilters] = useState<string[]>([]);
const [assigneeFilters, setAssigneeFilters] = useState<string[]>([]);
// 단일선택 필터
const [priorityFilter, setPriorityFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [sortBy, setSortBy] = useState<string>('latest');
const [startDate, setStartDate] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [activeStatTab, setActiveStatTab] = useState<'all' | 'received' | 'in_progress' | 'resolved' | 'unresolved'>('all');
const [withdrawDialogOpen, setWithdrawDialogOpen] = useState(false);
const itemsPerPage = 20;
// 데이터 로드
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const [listResult, statsResult] = await Promise.all([
getIssueList({
size: 1000,
startDate: startDate || undefined,
endDate: endDate || undefined,
}),
getIssueStats(),
]);
if (listResult.success && listResult.data) {
setIssues(listResult.data.items);
}
if (statsResult.success && statsResult.data) {
setStats(statsResult.data);
}
} catch {
toast.error('데이터 로드에 실패했습니다.');
} finally {
setIsLoading(false);
}
}, [startDate, endDate]);
// 초기 데이터가 없으면 로드
useEffect(() => {
if (initialData.length === 0) {
loadData();
}
}, [initialData.length, loadData]);
// 다중선택 필터 옵션 변환 (MultiSelectCombobox용)
const partnerOptions: MultiSelectOption[] = useMemo(() =>
MOCK_ISSUE_PARTNERS.map(p => ({ value: p.value, label: p.label })),
[]);
const siteOptions: MultiSelectOption[] = useMemo(() =>
MOCK_ISSUE_SITES.map(s => ({ value: s.value, label: s.label })),
[]);
const categoryOptions: MultiSelectOption[] = useMemo(() =>
ISSUE_CATEGORY_OPTIONS.filter(c => c.value !== 'all').map(c => ({ value: c.value, label: c.label })),
[]);
const reporterOptions: MultiSelectOption[] = useMemo(() =>
MOCK_ISSUE_REPORTERS.map(r => ({ value: r.value, label: r.label })),
[]);
const assigneeOptions: MultiSelectOption[] = useMemo(() =>
MOCK_ISSUE_ASSIGNEES.map(a => ({ value: a.value, label: a.label })),
[]);
// 필터링된 데이터
const filteredIssues = useMemo(() => {
return issues.filter((item) => {
// 상태 탭 필터
if (activeStatTab !== 'all' && item.status !== activeStatTab) return false;
// 상태 필터
if (statusFilter !== 'all' && item.status !== statusFilter) return false;
// 중요도 필터
if (priorityFilter !== 'all' && item.priority !== priorityFilter) return false;
// 거래처 필터 (다중선택)
if (partnerFilters.length > 0) {
const matchingPartner = MOCK_ISSUE_PARTNERS.find((p) => p.label === item.partnerName);
if (!matchingPartner || !partnerFilters.includes(matchingPartner.value)) {
return false;
}
}
// 현장 필터 (다중선택)
if (siteFilters.length > 0) {
const matchingSite = MOCK_ISSUE_SITES.find((s) => s.label === item.siteName);
if (!matchingSite || !siteFilters.includes(matchingSite.value)) {
return false;
}
}
// 구분 필터 (다중선택)
if (categoryFilters.length > 0) {
if (!categoryFilters.includes(item.category)) {
return false;
}
}
// 보고자 필터 (다중선택)
if (reporterFilters.length > 0) {
const matchingReporter = MOCK_ISSUE_REPORTERS.find((r) => r.label === item.reporter);
if (!matchingReporter || !reporterFilters.includes(matchingReporter.value)) {
return false;
}
}
// 담당자 필터 (다중선택)
if (assigneeFilters.length > 0) {
const matchingAssignee = MOCK_ISSUE_ASSIGNEES.find((a) => a.label === item.assignee);
if (!matchingAssignee || !assigneeFilters.includes(matchingAssignee.value)) {
return false;
}
}
// 검색 필터
if (searchValue) {
const search = searchValue.toLowerCase();
return (
item.issueNumber.toLowerCase().includes(search) ||
item.constructionNumber.toLowerCase().includes(search) ||
item.partnerName.toLowerCase().includes(search) ||
item.siteName.toLowerCase().includes(search) ||
item.title.toLowerCase().includes(search) ||
item.reporter.toLowerCase().includes(search) ||
item.assignee.toLowerCase().includes(search)
);
}
return true;
});
}, [issues, activeStatTab, statusFilter, priorityFilter, partnerFilters, siteFilters, categoryFilters, reporterFilters, assigneeFilters, searchValue]);
// 정렬
const sortedIssues = useMemo(() => {
const sorted = [...filteredIssues];
switch (sortBy) {
case 'latest':
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
break;
case 'oldest':
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
break;
case 'reportDate':
sorted.sort((a, b) => new Date(b.reportDate).getTime() - new Date(a.reportDate).getTime());
break;
case 'priorityHigh':
const priorityOrder: Record<string, number> = { urgent: 0, normal: 1 };
sorted.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]);
break;
case 'priorityLow':
const priorityOrderLow: Record<string, number> = { urgent: 1, normal: 0 };
sorted.sort((a, b) => priorityOrderLow[a.priority] - priorityOrderLow[b.priority]);
break;
}
return sorted;
}, [filteredIssues, sortBy]);
// 페이지네이션
const totalPages = Math.ceil(sortedIssues.length / itemsPerPage);
const paginatedData = useMemo(() => {
const start = (currentPage - 1) * itemsPerPage;
return sortedIssues.slice(start, start + itemsPerPage);
}, [sortedIssues, currentPage, itemsPerPage]);
// 핸들러
const handleSearchChange = useCallback((value: string) => {
setSearchValue(value);
setCurrentPage(1);
}, []);
const handleToggleSelection = useCallback((id: string) => {
setSelectedItems((prev) => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
}, []);
const handleToggleSelectAll = useCallback(() => {
if (selectedItems.size === paginatedData.length) {
setSelectedItems(new Set());
} else {
setSelectedItems(new Set(paginatedData.map((c) => c.id)));
}
}, [selectedItems.size, paginatedData]);
const handleRowClick = useCallback(
(item: Issue) => {
router.push(`/ko/construction/project/issue-management/${item.id}`);
},
[router]
);
const handleEdit = useCallback(
(e: React.MouseEvent, itemId: string) => {
e.stopPropagation();
router.push(`/ko/construction/project/issue-management/${itemId}/edit`);
},
[router]
);
const handleCreateIssue = useCallback(() => {
router.push('/ko/construction/project/issue-management/new');
}, [router]);
// 철회 다이얼로그 열기
const handleWithdrawClick = useCallback(() => {
if (selectedItems.size === 0) {
toast.error('철회할 이슈를 선택해주세요.');
return;
}
setWithdrawDialogOpen(true);
}, [selectedItems.size]);
// 철회 실행
const handleWithdraw = useCallback(async () => {
try {
const ids = Array.from(selectedItems);
const result = await withdrawIssues(ids);
if (result.success) {
toast.success(`${ids.length}건의 이슈가 철회되었습니다.`);
setSelectedItems(new Set());
loadData();
} else {
toast.error(result.error || '이슈 철회에 실패했습니다.');
}
} catch {
toast.error('이슈 철회에 실패했습니다.');
} finally {
setWithdrawDialogOpen(false);
}
}, [selectedItems, loadData]);
// 날짜 포맷
const formatDate = (dateStr: string | null) => {
if (!dateStr) return '-';
return dateStr.split('T')[0];
};
// 테이블 행 렌더링
const renderTableRow = useCallback(
(item: Issue, index: number, globalIndex: number) => {
const isSelected = selectedItems.has(item.id);
return (
<TableRow
key={item.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleRowClick(item)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => handleToggleSelection(item.id)}
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell>{item.issueNumber}</TableCell>
<TableCell>{item.constructionNumber}</TableCell>
<TableCell>{item.partnerName}</TableCell>
<TableCell>{item.siteName}</TableCell>
<TableCell className="text-center">
<span className="px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-700">
{ISSUE_CATEGORY_LABELS[item.category]}
</span>
</TableCell>
<TableCell className="max-w-[200px] truncate" title={item.title}>{item.title}</TableCell>
<TableCell>{item.reporter}</TableCell>
<TableCell>{formatDate(item.reportDate)}</TableCell>
<TableCell>{formatDate(item.resolvedDate)}</TableCell>
<TableCell>{item.assignee}</TableCell>
<TableCell className="text-center">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${ISSUE_PRIORITY_STYLES[item.priority]}`}>
{ISSUE_PRIORITY_LABELS[item.priority]}
</span>
</TableCell>
<TableCell className="text-center">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${ISSUE_STATUS_STYLES[item.status]}`}>
{ISSUE_STATUS_LABELS[item.status]}
</span>
</TableCell>
<TableCell className="text-center">
{isSelected && (
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => handleEdit(e, item.id)}
>
<Pencil className="h-4 w-4" />
</Button>
</div>
)}
</TableCell>
</TableRow>
);
},
[selectedItems, handleToggleSelection, handleRowClick, handleEdit]
);
// 모바일 카드 렌더링
const renderMobileCard = useCallback(
(item: Issue, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
return (
<MobileCard
title={item.title}
subtitle={item.issueNumber}
badge={ISSUE_STATUS_LABELS[item.status]}
badgeVariant="secondary"
isSelected={isSelected}
onToggle={onToggle}
onClick={() => handleRowClick(item)}
details={[
{ label: '거래처', value: item.partnerName },
{ label: '현장', value: item.siteName },
{ label: '보고일', value: formatDate(item.reportDate) },
{ label: '중요도', value: ISSUE_PRIORITY_LABELS[item.priority] },
]}
/>
);
},
[handleRowClick]
);
// 헤더 액션 (DateRangeSelector + 이슈 등록 버튼)
const headerActions = (
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
extraActions={
<Button onClick={handleCreateIssue}>
<Plus className="mr-2 h-4 w-4" />
</Button>
}
/>
);
// 통계 카드 클릭 핸들러
const handleStatClick = useCallback((tab: 'all' | 'received' | 'in_progress' | 'resolved' | 'unresolved') => {
setActiveStatTab(tab);
setCurrentPage(1);
}, []);
// 통계 카드 데이터
const statsCardsData: StatCard[] = [
{
label: '접수',
value: stats?.received ?? 0,
icon: Inbox,
iconColor: 'text-blue-600',
onClick: () => handleStatClick('received'),
isActive: activeStatTab === 'received',
},
{
label: '처리중',
value: stats?.inProgress ?? 0,
icon: Clock,
iconColor: 'text-yellow-600',
onClick: () => handleStatClick('in_progress'),
isActive: activeStatTab === 'in_progress',
},
{
label: '해결완료',
value: stats?.resolved ?? 0,
icon: CheckCircle,
iconColor: 'text-green-600',
onClick: () => handleStatClick('resolved'),
isActive: activeStatTab === 'resolved',
},
{
label: '미해결',
value: stats?.unresolved ?? 0,
icon: XCircle,
iconColor: 'text-red-600',
onClick: () => handleStatClick('unresolved'),
isActive: activeStatTab === 'unresolved',
},
];
// ===== filterConfig 기반 통합 필터 시스템 =====
const filterConfig: FilterFieldConfig[] = useMemo(() => [
{
key: 'partner',
label: '거래처',
type: 'multi',
options: partnerOptions.map(o => ({
value: o.value,
label: o.label,
})),
},
{
key: 'site',
label: '현장명',
type: 'multi',
options: siteOptions.map(o => ({
value: o.value,
label: o.label,
})),
},
{
key: 'category',
label: '구분',
type: 'multi',
options: categoryOptions.map(o => ({
value: o.value,
label: o.label,
})),
},
{
key: 'reporter',
label: '보고자',
type: 'multi',
options: reporterOptions.map(o => ({
value: o.value,
label: o.label,
})),
},
{
key: 'assignee',
label: '담당자',
type: 'multi',
options: assigneeOptions.map(o => ({
value: o.value,
label: o.label,
})),
},
{
key: 'priority',
label: '중요도',
type: 'single',
options: ISSUE_PRIORITY_OPTIONS.filter(o => o.value !== 'all').map(o => ({
value: o.value,
label: o.label,
})),
allOptionLabel: '전체',
},
{
key: 'status',
label: '상태',
type: 'single',
options: ISSUE_STATUS_OPTIONS.filter(o => o.value !== 'all').map(o => ({
value: o.value,
label: o.label,
})),
allOptionLabel: '전체',
},
{
key: 'sortBy',
label: '정렬',
type: 'single',
options: ISSUE_SORT_OPTIONS.map(o => ({
value: o.value,
label: o.label,
})),
allOptionLabel: '최신순',
},
], [partnerOptions, siteOptions, categoryOptions, reporterOptions, assigneeOptions]);
const filterValues: FilterValues = useMemo(() => ({
partner: partnerFilters,
site: siteFilters,
category: categoryFilters,
reporter: reporterFilters,
assignee: assigneeFilters,
priority: priorityFilter,
status: statusFilter,
sortBy: sortBy,
}), [partnerFilters, siteFilters, categoryFilters, reporterFilters, assigneeFilters, priorityFilter, statusFilter, sortBy]);
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
switch (key) {
case 'partner':
setPartnerFilters(value as string[]);
break;
case 'site':
setSiteFilters(value as string[]);
break;
case 'category':
setCategoryFilters(value as string[]);
break;
case 'reporter':
setReporterFilters(value as string[]);
break;
case 'assignee':
setAssigneeFilters(value as string[]);
break;
case 'priority':
setPriorityFilter(value as string);
break;
case 'status':
setStatusFilter(value as string);
break;
case 'sortBy':
setSortBy(value as string);
break;
}
setCurrentPage(1);
}, []);
const handleFilterReset = useCallback(() => {
setPartnerFilters([]);
setSiteFilters([]);
setCategoryFilters([]);
setReporterFilters([]);
setAssigneeFilters([]);
setPriorityFilter('all');
setStatusFilter('all');
setSortBy('latest');
setCurrentPage(1);
}, []);
// 철회 버튼 (bulkActions용)
const bulkActions = selectedItems.size > 0 ? (
<Button
variant="outline"
size="sm"
onClick={handleWithdrawClick}
className="text-orange-600 border-orange-300 hover:bg-orange-50"
>
<Undo2 className="mr-2 h-4 w-4" />
({selectedItems.size})
</Button>
) : null;
return (
<>
<IntegratedListTemplateV2
title="이슈관리"
description="이슈 목록을 관리합니다"
icon={AlertTriangle}
headerActions={headerActions}
stats={statsCardsData}
filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="이슈 필터"
bulkActions={bulkActions}
searchValue={searchValue}
onSearchChange={handleSearchChange}
searchPlaceholder="이슈번호, 시공번호, 거래처, 현장, 제목, 보고자, 담당자 검색"
tableColumns={tableColumns}
data={paginatedData}
allData={sortedIssues}
getItemId={(item) => item.id}
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
selectedItems={selectedItems}
onToggleSelection={handleToggleSelection}
onToggleSelectAll={handleToggleSelectAll}
pagination={{
currentPage,
totalPages,
totalItems: sortedIssues.length,
itemsPerPage,
onPageChange: setCurrentPage,
}}
/>
{/* 철회 확인 다이얼로그 */}
<AlertDialog open={withdrawDialogOpen} onOpenChange={setWithdrawDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{selectedItems.size} ?
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleWithdraw}
className="bg-orange-600 hover:bg-orange-700"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,417 @@
'use server';
import type {
Issue,
IssueStats,
IssueFilter,
IssueListResponse,
} from './types';
/**
* 이슈관리 Server Actions
*/
// Mock 이슈 데이터
const mockIssues: Issue[] = [
{
id: '1',
issueNumber: 'ISS-2025-001',
constructionNumber: 'CON-001',
partnerName: '대한건설',
siteName: '서울 강남 현장',
constructionPM: '홍길동',
constructionManagers: '홍길동, 김철수, 이영희',
category: 'material',
title: '자재 품질 불량',
content: '납품된 철근 일부에 녹이 발생하여 품질 검수가 필요합니다.',
reporter: '홍길동',
reportDate: '2025-09-01',
resolvedDate: '2025-09-03',
assignee: '김과장',
priority: 'urgent',
status: 'resolved',
description: '납품된 철근 일부에 녹이 발생',
createdAt: '2025-09-01T09:00:00Z',
updatedAt: '2025-09-03T15:00:00Z',
},
{
id: '2',
issueNumber: 'ISS-2025-002',
constructionNumber: 'CON-002',
partnerName: '삼성시공',
siteName: '부산 해운대 현장',
constructionPM: '김철수',
constructionManagers: '김철수, 박민수',
category: 'safety',
title: '안전장비 미착용',
content: '현장 작업자 안전모 미착용 발견되어 시정 조치가 필요합니다.',
reporter: '김철수',
reportDate: '2025-09-02',
resolvedDate: null,
assignee: '이부장',
priority: 'urgent',
status: 'in_progress',
description: '현장 작업자 안전모 미착용 발견',
createdAt: '2025-09-02T10:00:00Z',
updatedAt: '2025-09-02T10:00:00Z',
},
{
id: '3',
issueNumber: 'ISS-2025-003',
constructionNumber: 'CON-001',
partnerName: '대한건설',
siteName: '서울 강남 현장',
constructionPM: '홍길동',
constructionManagers: '홍길동, 김철수, 이영희',
category: 'process',
title: '공정 지연',
content: '우천으로 인한 외부 공사가 지연되고 있습니다.',
reporter: '이영희',
reportDate: '2025-09-03',
resolvedDate: null,
assignee: '박대리',
priority: 'normal',
status: 'received',
description: '우천으로 인한 외부 공사 지연',
createdAt: '2025-09-03T08:00:00Z',
updatedAt: '2025-09-03T08:00:00Z',
},
{
id: '4',
issueNumber: 'ISS-2025-004',
constructionNumber: 'CON-003',
partnerName: 'LG건설',
siteName: '대전 유성 현장',
constructionPM: '이영희',
constructionManagers: '이영희, 최대리',
category: 'etc',
title: '예산 초과 우려',
content: '자재비 상승으로 인한 예산 초과가 예상됩니다.',
reporter: '박민수',
reportDate: '2025-09-01',
resolvedDate: null,
assignee: '정차장',
priority: 'normal',
status: 'unresolved',
description: '자재비 상승으로 인한 예산 초과 예상',
createdAt: '2025-09-01T11:00:00Z',
updatedAt: '2025-09-01T11:00:00Z',
},
{
id: '5',
issueNumber: 'ISS-2025-005',
constructionNumber: 'CON-004',
partnerName: '현대건설',
siteName: '인천 송도 현장',
constructionPM: '박민수',
constructionManagers: '박민수, 홍길동',
category: 'etc',
title: '민원 발생',
content: '인근 주민으로부터 소음 민원이 접수되었습니다.',
reporter: '최대리',
reportDate: '2025-09-02',
resolvedDate: '2025-09-02',
assignee: '송이사',
priority: 'normal',
status: 'resolved',
description: '소음 민원 접수',
createdAt: '2025-09-02T14:00:00Z',
updatedAt: '2025-09-02T18:00:00Z',
},
{
id: '6',
issueNumber: 'ISS-2025-006',
constructionNumber: 'CON-002',
partnerName: '삼성시공',
siteName: '부산 해운대 현장',
constructionPM: '김철수',
constructionManagers: '김철수, 박민수',
category: 'material',
title: '시공 품질 미달',
content: '콘크리트 타설 품질이 기준에 미달합니다.',
reporter: '홍길동',
reportDate: '2025-09-03',
resolvedDate: null,
assignee: '김과장',
priority: 'urgent',
status: 'received',
description: '콘크리트 타설 품질 기준 미달',
createdAt: '2025-09-03T09:30:00Z',
updatedAt: '2025-09-03T09:30:00Z',
},
{
id: '7',
issueNumber: 'ISS-2025-007',
constructionNumber: 'CON-005',
partnerName: 'SK건설',
siteName: '광주 북구 현장',
constructionPM: '최대리',
constructionManagers: '최대리, 김철수, 이영희',
category: 'safety',
title: '장비 점검 필요',
content: '크레인 정기 점검 시기가 도래하여 점검이 필요합니다.',
reporter: '김철수',
reportDate: '2025-09-01',
resolvedDate: null,
assignee: '이부장',
priority: 'normal',
status: 'in_progress',
description: '크레인 정기 점검 시기 도래',
createdAt: '2025-09-01T13:00:00Z',
updatedAt: '2025-09-02T10:00:00Z',
},
{
id: '8',
issueNumber: 'ISS-2025-008',
constructionNumber: 'CON-001',
partnerName: '대한건설',
siteName: '서울 강남 현장',
constructionPM: '홍길동',
constructionManagers: '홍길동, 김철수, 이영희',
category: 'process',
title: '인력 부족',
content: '숙련공 부족으로 공사 진행에 어려움이 있습니다.',
reporter: '이영희',
reportDate: '2025-09-02',
resolvedDate: null,
assignee: '박대리',
priority: 'urgent',
status: 'in_progress',
description: '숙련공 부족으로 공사 진행 어려움',
createdAt: '2025-09-02T08:30:00Z',
updatedAt: '2025-09-03T09:00:00Z',
},
// 추가 더미 데이터
...Array.from({ length: 47 }, (_, i) => ({
id: `${i + 9}`,
issueNumber: `ISS-2025-${String(i + 9).padStart(3, '0')}`,
constructionNumber: `CON-${String((i % 5) + 1).padStart(3, '0')}`,
partnerName: ['대한건설', '삼성시공', 'LG건설', '현대건설', 'SK건설'][i % 5],
siteName: ['서울 강남 현장', '부산 해운대 현장', '대전 유성 현장', '인천 송도 현장', '광주 북구 현장'][i % 5],
constructionPM: ['홍길동', '김철수', '이영희', '박민수', '최대리'][i % 5],
constructionManagers: ['홍길동, 김철수', '김철수, 박민수', '이영희, 최대리', '박민수, 홍길동', '최대리, 김철수'][i % 5],
category: (['material', 'drawing', 'process', 'safety', 'etc'] as const)[i % 5],
title: `이슈 ${i + 9}`,
content: `이슈 ${i + 9}에 대한 상세 내용입니다.`,
reporter: ['홍길동', '김철수', '이영희', '박민수', '최대리'][i % 5],
reportDate: `2025-09-${String((i % 28) + 1).padStart(2, '0')}`,
resolvedDate: i % 3 === 0 ? `2025-09-${String(Math.min((i % 28) + 3, 30)).padStart(2, '0')}` : null,
assignee: ['김과장', '이부장', '박대리', '정차장', '송이사'][i % 5],
priority: (['urgent', 'normal'] as const)[i % 2],
status: (['received', 'in_progress', 'resolved', 'unresolved'] as const)[i % 4],
description: `이슈 설명 ${i + 9}`,
createdAt: `2025-09-${String((i % 28) + 1).padStart(2, '0')}T09:00:00Z`,
updatedAt: `2025-09-${String((i % 28) + 1).padStart(2, '0')}T09:00:00Z`,
})),
];
// 이슈 목록 조회
export async function getIssueList(
filter?: IssueFilter
): Promise<{ success: boolean; data?: IssueListResponse; error?: string }> {
try {
let filtered = [...mockIssues];
// 거래처 필터 (다중선택)
if (filter?.partners && filter.partners.length > 0) {
filtered = filtered.filter((issue) =>
filter.partners!.some((p) => issue.partnerName.includes(p) || p.includes(issue.partnerName))
);
}
// 현장 필터 (다중선택)
if (filter?.sites && filter.sites.length > 0) {
filtered = filtered.filter((issue) =>
filter.sites!.some((s) => issue.siteName.includes(s) || s.includes(issue.siteName))
);
}
// 구분 필터 (다중선택)
if (filter?.categories && filter.categories.length > 0) {
filtered = filtered.filter((issue) =>
filter.categories!.includes(issue.category)
);
}
// 보고자 필터 (다중선택)
if (filter?.reporters && filter.reporters.length > 0) {
filtered = filtered.filter((issue) =>
filter.reporters!.some((r) => issue.reporter.includes(r) || r.includes(issue.reporter))
);
}
// 담당자 필터 (다중선택)
if (filter?.assignees && filter.assignees.length > 0) {
filtered = filtered.filter((issue) =>
filter.assignees!.some((a) => issue.assignee.includes(a) || a.includes(issue.assignee))
);
}
// 중요도 필터 (단일선택)
if (filter?.priority && filter.priority !== 'all') {
filtered = filtered.filter((issue) => issue.priority === filter.priority);
}
// 상태 필터 (단일선택)
if (filter?.status && filter.status !== 'all') {
filtered = filtered.filter((issue) => issue.status === filter.status);
}
// 날짜 필터
if (filter?.startDate) {
filtered = filtered.filter((issue) => issue.reportDate >= filter.startDate!);
}
if (filter?.endDate) {
filtered = filtered.filter((issue) => issue.reportDate <= filter.endDate!);
}
// 정렬
if (filter?.sortBy) {
switch (filter.sortBy) {
case 'latest':
filtered.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
break;
case 'oldest':
filtered.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
break;
case 'reportDate':
filtered.sort((a, b) => new Date(b.reportDate).getTime() - new Date(a.reportDate).getTime());
break;
case 'priorityHigh':
const priorityOrder: Record<string, number> = { urgent: 0, normal: 1 };
filtered.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]);
break;
case 'priorityLow':
const priorityOrderLow: Record<string, number> = { urgent: 1, normal: 0 };
filtered.sort((a, b) => priorityOrderLow[a.priority] - priorityOrderLow[b.priority]);
break;
}
}
const page = filter?.page ?? 1;
const size = filter?.size ?? 20;
const start = (page - 1) * size;
const paginatedItems = filtered.slice(start, start + size);
return {
success: true,
data: {
items: paginatedItems,
total: filtered.length,
page,
size,
totalPages: Math.ceil(filtered.length / size),
},
};
} catch (error) {
console.error('getIssueList error:', error);
return { success: false, error: '이슈 목록 조회에 실패했습니다.' };
}
}
// 이슈 통계 조회
export async function getIssueStats(): Promise<{
success: boolean;
data?: IssueStats;
error?: string;
}> {
try {
const received = mockIssues.filter((i) => i.status === 'received').length;
const inProgress = mockIssues.filter((i) => i.status === 'in_progress').length;
const resolved = mockIssues.filter((i) => i.status === 'resolved').length;
const unresolved = mockIssues.filter((i) => i.status === 'unresolved').length;
return {
success: true,
data: {
received,
inProgress,
resolved,
unresolved,
},
};
} catch (error) {
console.error('getIssueStats error:', error);
return { success: false, error: '이슈 통계 조회에 실패했습니다.' };
}
}
// 이슈 상세 조회
export async function getIssue(
id: string
): Promise<{ success: boolean; data?: Issue; error?: string }> {
try {
const issue = mockIssues.find((i) => i.id === id);
if (!issue) {
return { success: false, error: '이슈를 찾을 수 없습니다.' };
}
return { success: true, data: issue };
} catch (error) {
console.error('getIssue error:', error);
return { success: false, error: '이슈 조회에 실패했습니다.' };
}
}
// 이슈 수정
export async function updateIssue(
id: string,
data: Partial<Issue>
): Promise<{ success: boolean; error?: string }> {
try {
console.log('Update issue:', id, data);
// 실제 구현에서는 DB 업데이트
return { success: true };
} catch (error) {
console.error('updateIssue error:', error);
return { success: false, error: '이슈 수정에 실패했습니다.' };
}
}
// 이슈 생성
export async function createIssue(
data: Omit<Issue, 'id' | 'createdAt' | 'updatedAt'>
): Promise<{ success: boolean; data?: Issue; error?: string }> {
try {
console.log('Create issue:', data);
const newIssue: Issue = {
...data,
id: `new-${Date.now()}`,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
return { success: true, data: newIssue };
} catch (error) {
console.error('createIssue error:', error);
return { success: false, error: '이슈 등록에 실패했습니다.' };
}
}
// 이슈 철회 (단일)
export async function withdrawIssue(
id: string
): Promise<{ success: boolean; error?: string }> {
try {
console.log('Withdraw issue:', id);
// 실제 구현에서는 DB 상태 업데이트 (삭제가 아닌 철회 상태로 변경)
return { success: true };
} catch (error) {
console.error('withdrawIssue error:', error);
return { success: false, error: '이슈 철회에 실패했습니다.' };
}
}
// 이슈 철회 (다중)
export async function withdrawIssues(
ids: string[]
): Promise<{ success: boolean; error?: string }> {
try {
console.log('Withdraw issues:', ids);
// 실제 구현에서는 DB 상태 일괄 업데이트
return { success: true };
} catch (error) {
console.error('withdrawIssues error:', error);
return { success: false, error: '이슈 일괄 철회에 실패했습니다.' };
}
}

View File

@@ -0,0 +1,4 @@
export { default as IssueManagementListClient } from './IssueManagementListClient';
export { default as IssueDetailForm } from './IssueDetailForm';
export * from './types';
export * from './actions';

View File

@@ -0,0 +1,237 @@
/**
* 이슈관리 타입 정의
*/
// 이슈 상태
export type IssueStatus = 'received' | 'in_progress' | 'resolved' | 'unresolved';
// 이슈 중요도 (긴급, 일반)
export type IssuePriority = 'urgent' | 'normal';
// 이슈 구분 (자재, 도면, 공정, 안전, 기타)
export type IssueCategory = 'material' | 'drawing' | 'process' | 'safety' | 'etc';
// 이슈 이미지
export interface IssueImage {
id: string;
url: string;
fileName: string;
uploadedAt: string;
}
// 이슈 데이터
export interface Issue {
id: string;
issueNumber: string; // 이슈번호
constructionNumber: string; // 시공번호
partnerName: string; // 거래처
siteName: string; // 현장
constructionPM?: string; // 공사PM (자동)
constructionManagers?: string; // 공사담당자 (자동, 다중)
category: IssueCategory; // 구분
title: string; // 제목
content?: string; // 내용
reporter: string; // 보고자
reportDate: string; // 이슈보고일
resolvedDate: string | null; // 이슈해결일
assignee: string; // 담당자
priority: IssuePriority; // 중요도
status: IssueStatus; // 상태
images?: IssueImage[]; // 사진
description?: string; // 설명 (레거시)
createdAt: string;
updatedAt: string;
}
// 이슈 폼 데이터
export interface IssueFormData {
issueNumber: string;
constructionNumber: string;
partnerName: string;
siteName: string;
constructionPM: string;
constructionManagers: string;
reporter: string;
assignee: string;
reportDate: string;
resolvedDate: string;
status: IssueStatus;
category: IssueCategory;
priority: IssuePriority;
title: string;
content: string;
images: IssueImage[];
}
// 이슈 통계
export interface IssueStats {
received: number; // 접수
inProgress: number; // 처리중
resolved: number; // 해결완료
unresolved: number; // 미해결
}
// 이슈 필터
export interface IssueFilter {
partners?: string[]; // 거래처 (다중선택)
sites?: string[]; // 현장 (다중선택)
categories?: string[]; // 구분 (다중선택)
reporters?: string[]; // 보고자 (다중선택)
assignees?: string[]; // 담당자 (다중선택)
priority?: string; // 중요도 (단일선택)
status?: string; // 상태 (단일선택)
sortBy?: string; // 정렬
startDate?: string;
endDate?: string;
page?: number;
size?: number;
}
// API 응답
export interface IssueListResponse {
items: Issue[];
total: number;
page: number;
size: number;
totalPages: number;
}
// 상태 옵션
export const ISSUE_STATUS_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: 'received', label: '접수' },
{ value: 'in_progress', label: '처리중' },
{ value: 'resolved', label: '해결완료' },
{ value: 'unresolved', label: '미해결' },
];
// 상태 라벨
export const ISSUE_STATUS_LABELS: Record<IssueStatus, string> = {
received: '접수',
in_progress: '처리중',
resolved: '해결완료',
unresolved: '미해결',
};
// 상태 스타일
export const ISSUE_STATUS_STYLES: Record<IssueStatus, string> = {
received: 'bg-blue-100 text-blue-700',
in_progress: 'bg-yellow-100 text-yellow-700',
resolved: 'bg-green-100 text-green-700',
unresolved: 'bg-red-100 text-red-700',
};
// 중요도 옵션 (긴급, 일반)
export const ISSUE_PRIORITY_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: 'urgent', label: '긴급' },
{ value: 'normal', label: '일반' },
];
// 중요도 라벨
export const ISSUE_PRIORITY_LABELS: Record<IssuePriority, string> = {
urgent: '긴급',
normal: '일반',
};
// 중요도 스타일
export const ISSUE_PRIORITY_STYLES: Record<IssuePriority, string> = {
urgent: 'bg-red-100 text-red-700',
normal: 'bg-gray-100 text-gray-700',
};
// 구분 옵션 (자재, 도면, 공정, 안전, 기타)
export const ISSUE_CATEGORY_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: 'material', label: '자재' },
{ value: 'drawing', label: '도면' },
{ value: 'process', label: '공정' },
{ value: 'safety', label: '안전' },
{ value: 'etc', label: '기타' },
];
// 구분 라벨
export const ISSUE_CATEGORY_LABELS: Record<IssueCategory, string> = {
material: '자재',
drawing: '도면',
process: '공정',
safety: '안전',
etc: '기타',
};
// 정렬 옵션
export const ISSUE_SORT_OPTIONS = [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '오래된순' },
{ value: 'reportDate', label: '보고일순' },
{ value: 'priorityHigh', label: '중요도 높은순' },
{ value: 'priorityLow', label: '중요도 낮은순' },
];
// Mock 거래처 데이터
export const MOCK_ISSUE_PARTNERS = [
{ value: 'partner1', label: '대한건설' },
{ value: 'partner2', label: '삼성시공' },
{ value: 'partner3', label: 'LG건설' },
{ value: 'partner4', label: '현대건설' },
{ value: 'partner5', label: 'SK건설' },
];
// Mock 현장 데이터
export const MOCK_ISSUE_SITES = [
{ value: 'site1', label: '서울 강남 현장' },
{ value: 'site2', label: '부산 해운대 현장' },
{ value: 'site3', label: '대전 유성 현장' },
{ value: 'site4', label: '인천 송도 현장' },
{ value: 'site5', label: '광주 북구 현장' },
];
// Mock 보고자 데이터
export const MOCK_ISSUE_REPORTERS = [
{ value: 'reporter1', label: '홍길동' },
{ value: 'reporter2', label: '김철수' },
{ value: 'reporter3', label: '이영희' },
{ value: 'reporter4', label: '박민수' },
{ value: 'reporter5', label: '최대리' },
];
// Mock 담당자 데이터
export const MOCK_ISSUE_ASSIGNEES = [
{ value: 'assignee1', label: '김과장' },
{ value: 'assignee2', label: '이부장' },
{ value: 'assignee3', label: '박대리' },
{ value: 'assignee4', label: '정차장' },
{ value: 'assignee5', label: '송이사' },
];
// Mock 시공번호 데이터 (상세 폼용)
export const MOCK_CONSTRUCTION_NUMBERS = [
{ value: 'CON-001', label: 'CON-001', partnerName: '대한건설', siteName: '서울 강남 현장', pm: '홍길동', managers: '홍길동, 김철수, 이영희' },
{ value: 'CON-002', label: 'CON-002', partnerName: '삼성시공', siteName: '부산 해운대 현장', pm: '김철수', managers: '김철수, 박민수' },
{ value: 'CON-003', label: 'CON-003', partnerName: 'LG건설', siteName: '대전 유성 현장', pm: '이영희', managers: '이영희, 최대리' },
{ value: 'CON-004', label: 'CON-004', partnerName: '현대건설', siteName: '인천 송도 현장', pm: '박민수', managers: '박민수, 홍길동' },
{ value: 'CON-005', label: 'CON-005', partnerName: 'SK건설', siteName: '광주 북구 현장', pm: '최대리', managers: '최대리, 김철수, 이영희' },
];
// 폼용 상태 옵션 (전체 제외)
export const ISSUE_STATUS_FORM_OPTIONS = [
{ value: 'received', label: '접수' },
{ value: 'in_progress', label: '처리중' },
{ value: 'resolved', label: '해결완료' },
{ value: 'unresolved', label: '미해결' },
];
// 폼용 중요도 옵션 (전체 제외)
export const ISSUE_PRIORITY_FORM_OPTIONS = [
{ value: 'urgent', label: '긴급' },
{ value: 'normal', label: '일반' },
];
// 폼용 구분 옵션 (전체 제외)
export const ISSUE_CATEGORY_FORM_OPTIONS = [
{ value: 'material', label: '자재' },
{ value: 'drawing', label: '도면' },
{ value: 'process', label: '공정' },
{ value: 'safety', label: '안전' },
{ value: 'etc', label: '기타' },
];

View File

@@ -512,59 +512,58 @@ export default function ItemDetailClient({
/>
</div>
</div>
</CardContent>
</Card>
{/* 발주 항목 */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base"> </CardTitle>
{!isReadOnly && (
<Button variant="outline" size="sm" onClick={handleAddOrderItem}>
<Plus className="mr-1 h-4 w-4" />
</Button>
)}
</CardHeader>
<CardContent>
{formData.orderItems.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
.
{!isReadOnly && ' 추가 버튼을 클릭하여 항목을 추가하세요.'}
</div>
) : (
<div className="space-y-3">
<div className="grid grid-cols-[1fr_1fr_40px] gap-2 text-sm font-medium text-muted-foreground">
<div></div>
<div> </div>
<div></div>
{/* 발주 항목 구분정보 */}
{/* 수정 모드: 항상 표시 (추가/삭제 가능) */}
{/* 상세 모드: 데이터가 있을 때만 표시 (읽기 전용) */}
{(!isReadOnly || formData.orderItems.length > 0) && (
<div className="pt-4">
{/* 헤더 */}
<div className="grid grid-cols-[1fr_1fr_auto] gap-4 items-center mb-4">
<div className="text-base font-semibold"> </div>
<div className="text-base font-semibold"> </div>
{!isReadOnly && (
<Button size="sm" onClick={handleAddOrderItem}>
</Button>
)}
</div>
{formData.orderItems.map((item) => (
<div key={item.id} className="grid grid-cols-[1fr_1fr_40px] gap-2 items-center">
<Input
value={item.label}
onChange={(e) => handleOrderItemChange(item.id, 'label', e.target.value)}
placeholder="예: 무게"
disabled={isReadOnly}
/>
<Input
value={item.value}
onChange={(e) => handleOrderItemChange(item.id, 'value', e.target.value)}
placeholder="예: 400KG"
disabled={isReadOnly}
/>
{!isReadOnly && (
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
onClick={() => handleRemoveOrderItem(item.id)}
>
<X className="h-4 w-4" />
</Button>
)}
{/* 항목 리스트 */}
{formData.orderItems.length === 0 ? (
<div className="text-center py-8 text-muted-foreground border rounded-lg">
. .
</div>
))}
) : (
<div className="space-y-3">
{formData.orderItems.map((item) => (
<div key={item.id} className={`grid ${isReadOnly ? 'grid-cols-2' : 'grid-cols-[1fr_1fr_auto]'} gap-4 items-center`}>
<Input
value={item.label}
onChange={(e) => handleOrderItemChange(item.id, 'label', e.target.value)}
placeholder="예: 무게"
disabled={isReadOnly}
/>
<Input
value={item.value}
onChange={(e) => handleOrderItemChange(item.id, 'value', e.target.value)}
placeholder="예: 400KG"
disabled={isReadOnly}
/>
{!isReadOnly && (
<Button
variant="default"
size="icon"
className="h-10 w-10 bg-black hover:bg-black/80"
onClick={() => handleRemoveOrderItem(item.id)}
>
<X className="h-4 w-4 text-white" />
</Button>
)}
</div>
))}
</div>
)}
</div>
)}
</CardContent>

View File

@@ -8,14 +8,7 @@ import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { IntegratedListTemplateV2, TableColumn } from '@/components/templates/IntegratedListTemplateV2';
import { IntegratedListTemplateV2, TableColumn, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
import { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { toast } from 'sonner';
@@ -427,130 +420,112 @@ export default function ItemManagementClient({
/>
);
// 테이블 헤더 액션 (6개 필터)
const tableHeaderActions = (
<div className="flex items-center gap-2 flex-wrap">
{/* 총 건수 */}
<span className="text-sm text-muted-foreground whitespace-nowrap">
{sortedItems.length}
</span>
// ===== filterConfig 기반 통합 필터 시스템 =====
const filterConfig: FilterFieldConfig[] = useMemo(() => [
{
key: 'itemType',
label: '품목유형',
type: 'single',
options: ITEM_TYPE_OPTIONS.filter(o => o.value !== 'all').map(o => ({
value: o.value,
label: o.label,
})),
allOptionLabel: '전체',
},
{
key: 'category',
label: '카테고리',
type: 'single',
options: categoryOptions.map(c => ({
value: c.id,
label: c.name,
})),
allOptionLabel: '전체',
},
{
key: 'specification',
label: '규격',
type: 'single',
options: SPECIFICATION_OPTIONS.filter(o => o.value !== 'all').map(o => ({
value: o.value,
label: o.label,
})),
allOptionLabel: '전체',
},
{
key: 'orderType',
label: '구분',
type: 'single',
options: ORDER_TYPE_OPTIONS.filter(o => o.value !== 'all').map(o => ({
value: o.value,
label: o.label,
})),
allOptionLabel: '전체',
},
{
key: 'status',
label: '상태',
type: 'single',
options: STATUS_OPTIONS.filter(o => o.value !== 'all').map(o => ({
value: o.value,
label: o.label,
})),
allOptionLabel: '전체',
},
{
key: 'sortBy',
label: '정렬',
type: 'single',
options: SORT_OPTIONS.map(o => ({
value: o.value,
label: o.label,
})),
allOptionLabel: '최신순',
},
], [categoryOptions]);
{/* 품목유형 필터 */}
<Select
value={itemTypeFilter}
onValueChange={(v) => {
setItemTypeFilter(v as ItemType | 'all');
setCurrentPage(1);
}}
>
<SelectTrigger className="w-[90px]">
<SelectValue placeholder="품목유형" />
</SelectTrigger>
<SelectContent>
{ITEM_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
const filterValues: FilterValues = useMemo(() => ({
itemType: itemTypeFilter,
category: categoryFilter,
specification: specificationFilter,
orderType: orderTypeFilter,
status: statusFilter,
sortBy: sortBy,
}), [itemTypeFilter, categoryFilter, specificationFilter, orderTypeFilter, statusFilter, sortBy]);
{/* 카테고리 필터 */}
<Select
value={categoryFilter}
onValueChange={(v) => {
setCategoryFilter(v);
setCurrentPage(1);
}}
>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="카테고리" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{categoryOptions.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
switch (key) {
case 'itemType':
setItemTypeFilter(value as ItemType | 'all');
break;
case 'category':
setCategoryFilter(value as string);
break;
case 'specification':
setSpecificationFilter(value as Specification | 'all');
break;
case 'orderType':
setOrderTypeFilter(value as OrderType | 'all');
break;
case 'status':
setStatusFilter(value as ItemStatus | 'all');
break;
case 'sortBy':
setSortBy(value as 'latest' | 'oldest');
break;
}
setCurrentPage(1);
}, []);
{/* 규격 필터 */}
<Select
value={specificationFilter}
onValueChange={(v) => {
setSpecificationFilter(v as Specification | 'all');
setCurrentPage(1);
}}
>
<SelectTrigger className="w-[80px]">
<SelectValue placeholder="규격" />
</SelectTrigger>
<SelectContent>
{SPECIFICATION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 구분 필터 */}
<Select
value={orderTypeFilter}
onValueChange={(v) => {
setOrderTypeFilter(v as OrderType | 'all');
setCurrentPage(1);
}}
>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="구분" />
</SelectTrigger>
<SelectContent>
{ORDER_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 상태 필터 */}
<Select
value={statusFilter}
onValueChange={(v) => {
setStatusFilter(v as ItemStatus | 'all');
setCurrentPage(1);
}}
>
<SelectTrigger className="w-[80px]">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 정렬 */}
<Select value={sortBy} onValueChange={(v) => setSortBy(v as 'latest' | 'oldest')}>
<SelectTrigger className="w-[90px]">
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
const handleFilterReset = useCallback(() => {
setItemTypeFilter('all');
setCategoryFilter('all');
setSpecificationFilter('all');
setOrderTypeFilter('all');
setStatusFilter('all');
setSortBy('latest');
setCurrentPage(1);
}, []);
return (
<>
@@ -573,7 +548,11 @@ export default function ItemManagementClient({
iconColor: 'text-green-500',
},
]}
tableHeaderActions={tableHeaderActions}
filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="품목 필터"
searchValue={searchValue}
onSearchChange={handleSearchChange}
searchPlaceholder="품목명, 품목번호, 카테고리 검색"

View File

@@ -62,6 +62,10 @@ export default function LaborDetailClient({
const [formData, setFormData] = useState<LaborFormData>(initialFormData);
const [originalData, setOriginalData] = useState<Labor | null>(null);
// 소수점 입력을 위한 문자열 상태 (입력 중인 값 유지)
const [minMInput, setMinMInput] = useState<string>('');
const [maxMInput, setMaxMInput] = useState<string>('');
// 상태
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
@@ -84,6 +88,9 @@ export default function LaborDetailClient({
laborPrice: result.data.laborPrice,
status: result.data.status,
});
// 소수점 입력용 문자열 상태 초기화
setMinMInput(result.data.minM === 0 ? '' : result.data.minM.toString());
setMaxMInput(result.data.maxM === 0 ? '' : result.data.maxM.toString());
} else {
toast.error(result.error || '노임 정보를 불러오는데 실패했습니다.');
router.push('/ko/construction/order/base-info/labor');
@@ -107,19 +114,62 @@ export default function LaborDetailClient({
[]
);
// 숫자 입력 (소수점 둘째자리까지)
const handleNumberChange = useCallback(
(field: 'minM' | 'maxM' | 'laborPrice', value: string) => {
// 최소 M / 최대 M 입력 핸들러 (소수점 둘째자리까지)
const handleMinMChange = useCallback(
(value: string) => {
// 빈 값 허용
if (value === '') {
handleFieldChange(field, field === 'laborPrice' ? null : 0);
setMinMInput('');
handleFieldChange('minM', 0);
return;
}
// 소수점 둘째자리까지 허용
// 소수점 둘째자리까지 허용하는 정규식
const regex = /^\d*\.?\d{0,2}$/;
if (regex.test(value)) {
setMinMInput(value);
const numValue = parseFloat(value);
if (!isNaN(numValue)) {
handleFieldChange(field, numValue);
handleFieldChange('minM', numValue);
}
}
},
[handleFieldChange]
);
const handleMaxMChange = useCallback(
(value: string) => {
// 빈 값 허용
if (value === '') {
setMaxMInput('');
handleFieldChange('maxM', 0);
return;
}
// 소수점 둘째자리까지 허용하는 정규식
const regex = /^\d*\.?\d{0,2}$/;
if (regex.test(value)) {
setMaxMInput(value);
const numValue = parseFloat(value);
if (!isNaN(numValue)) {
handleFieldChange('maxM', numValue);
}
}
},
[handleFieldChange]
);
// 노임단가 입력 핸들러 (정수만)
const handleLaborPriceChange = useCallback(
(value: string) => {
if (value === '') {
handleFieldChange('laborPrice', null);
return;
}
// 정수만 허용
const regex = /^\d*$/;
if (regex.test(value)) {
const numValue = parseInt(value, 10);
if (!isNaN(numValue)) {
handleFieldChange('laborPrice', numValue);
}
}
},
@@ -213,6 +263,9 @@ export default function LaborDetailClient({
laborPrice: originalData.laborPrice,
status: originalData.status,
});
// 소수점 입력용 문자열 상태도 복원
setMinMInput(originalData.minM === 0 ? '' : originalData.minM.toString());
setMaxMInput(originalData.maxM === 0 ? '' : originalData.maxM.toString());
}
router.replace(`/ko/construction/order/base-info/labor/${laborId}`);
}
@@ -339,8 +392,8 @@ export default function LaborDetailClient({
id="minM"
type="text"
inputMode="decimal"
value={formData.minM === 0 ? '' : formData.minM.toString()}
onChange={(e) => handleNumberChange('minM', e.target.value)}
value={minMInput}
onChange={(e) => handleMinMChange(e.target.value)}
placeholder="0.00"
disabled={isReadOnly}
/>
@@ -351,8 +404,8 @@ export default function LaborDetailClient({
id="maxM"
type="text"
inputMode="decimal"
value={formData.maxM === 0 ? '' : formData.maxM.toString()}
onChange={(e) => handleNumberChange('maxM', e.target.value)}
value={maxMInput}
onChange={(e) => handleMaxMChange(e.target.value)}
placeholder="0.00"
disabled={isReadOnly}
/>
@@ -366,9 +419,9 @@ export default function LaborDetailClient({
<Input
id="laborPrice"
type="text"
inputMode="decimal"
inputMode="numeric"
value={formData.laborPrice === null ? '' : formData.laborPrice.toString()}
onChange={(e) => handleNumberChange('laborPrice', e.target.value)}
onChange={(e) => handleLaborPriceChange(e.target.value)}
placeholder="0"
disabled={isReadOnly}
/>

View File

@@ -15,7 +15,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { IntegratedListTemplateV2, TableColumn } from '@/components/templates/IntegratedListTemplateV2';
import { IntegratedListTemplateV2, TableColumn, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
import { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { toast } from 'sonner';
@@ -369,6 +369,66 @@ export default function LaborManagementClient({
[handleRowClick]
);
// ===== filterConfig 기반 통합 필터 시스템 =====
const filterConfig: FilterFieldConfig[] = useMemo(() => [
{
key: 'category',
label: '구분',
type: 'single',
options: CATEGORY_OPTIONS.filter(o => o.value !== 'all').map(o => ({
value: o.value,
label: o.label,
})),
},
{
key: 'status',
label: '상태',
type: 'single',
options: STATUS_OPTIONS.filter(o => o.value !== 'all').map(o => ({
value: o.value,
label: o.label,
})),
},
{
key: 'sortBy',
label: '정렬',
type: 'single',
options: SORT_OPTIONS.map(o => ({
value: o.value,
label: o.label,
})),
allOptionLabel: '최신순',
},
], []);
const filterValues: FilterValues = useMemo(() => ({
category: categoryFilter,
status: statusFilter,
sortBy: sortBy,
}), [categoryFilter, statusFilter, sortBy]);
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
switch (key) {
case 'category':
setCategoryFilter(value as LaborCategory | 'all');
break;
case 'status':
setStatusFilter(value as LaborStatus | 'all');
break;
case 'sortBy':
setSortBy(value as SortOrder);
break;
}
setCurrentPage(1);
}, []);
const handleFilterReset = useCallback(() => {
setCategoryFilter('all');
setStatusFilter('all');
setSortBy('최신순');
setCurrentPage(1);
}, []);
// 헤더 액션 (날짜선택 + 등록 버튼)
const headerActions = (
<DateRangeSelector
@@ -471,6 +531,11 @@ export default function LaborManagementClient({
},
]}
tableHeaderActions={tableHeaderActions}
filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="노임 필터"
searchValue={searchValue}
onSearchChange={handleSearchChange}
searchPlaceholder="노임번호, 구분 검색"

View File

@@ -3,6 +3,8 @@
// 구분 옵션
export const CATEGORY_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: '작업반장', label: '작업반장' },
{ value: '작업자', label: '작업자' },
{ value: '가로', label: '가로' },
{ value: '세로할증', label: '세로할증' },
] as const;

View File

@@ -1,7 +1,7 @@
// 노임관리 타입 정의
// 구분 타입
export type LaborCategory = '가로' | '세로할증';
export type LaborCategory = '작업반장' | '작업자' | '가로' | '세로할증';
// 상태 타입
export type LaborStatus = '사용' | '중지';

View File

@@ -0,0 +1,773 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Wrench, List, Plus, Trash2, FileText, Upload, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { MultiSelectCombobox } from '@/components/ui/multi-select-combobox';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { toast } from 'sonner';
import {
getConstructionManagementDetail,
updateConstructionManagementDetail,
completeConstruction,
} from './actions';
import { getOrderDetailFull } from '../order-management/actions';
import { OrderDocumentModal } from '../order-management/modals/OrderDocumentModal';
import type {
ConstructionManagementDetail,
ConstructionDetailFormData,
WorkerInfo,
WorkProgressInfo,
PhotoInfo,
} from './types';
import type { OrderDetail } from '../order-management/types';
import {
MOCK_EMPLOYEES,
MOCK_CM_WORK_TEAM_LEADERS,
CONSTRUCTION_MANAGEMENT_STATUS_LABELS,
CONSTRUCTION_MANAGEMENT_STATUS_STYLES,
} from './types';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
interface ConstructionDetailClientProps {
id: string;
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();
// 모드 플래그
const isViewMode = mode === 'view';
const isEditMode = mode === 'edit';
// 데이터 상태
const [detail, setDetail] = useState<ConstructionManagementDetail | null>(null);
const [isLoading, setIsLoading] = useState(true);
// 폼 데이터 상태
const [formData, setFormData] = useState<ConstructionDetailFormData>({
workTeamLeader: '',
workerInfoList: [],
workProgressList: [],
workLogContent: '',
photos: [],
isIssueReported: false,
});
// 발주서 모달 상태
const [showOrderModal, setShowOrderModal] = useState(false);
const [orderData, setOrderData] = useState<OrderDetail | null>(null);
// 시공 완료 다이얼로그 상태
const [showCompleteDialog, setShowCompleteDialog] = useState(false);
// 데이터 로드
useEffect(() => {
const loadData = async () => {
try {
setIsLoading(true);
const result = await getConstructionManagementDetail(id);
if (result.success && result.data) {
setDetail(result.data);
setFormData({
workTeamLeader: result.data.workTeamLeader,
workerInfoList: result.data.workerInfoList,
workProgressList: result.data.workProgressList,
workLogContent: result.data.workLogContent,
photos: result.data.photos,
isIssueReported: result.data.isIssueReported,
});
} else {
toast.error(result.error || '시공 정보를 불러올 수 없습니다.');
router.push('/ko/construction/project/construction-management');
}
} catch (error) {
console.error('Failed to load construction detail:', error);
toast.error('시공 정보를 불러올 수 없습니다.');
} finally {
setIsLoading(false);
}
};
loadData();
}, [id, router]);
// 목록으로 돌아가기
const handleBack = () => {
router.push('/ko/construction/project/construction-management');
};
// 수정 페이지로 이동
const handleEdit = () => {
router.push(`/ko/construction/project/construction-management/${id}/edit`);
};
// 취소 (상세 페이지로 돌아가기)
const handleCancel = () => {
router.push(`/ko/construction/project/construction-management/${id}`);
};
// 작업반장 변경
const handleWorkTeamLeaderChange = (value: string) => {
setFormData((prev) => ({ ...prev, workTeamLeader: value }));
};
// 작업자 정보 추가
const handleAddWorkerInfo = () => {
const newWorkerInfo: WorkerInfo = {
id: `worker-${Date.now()}`,
workDate: new Date().toISOString().split('T')[0],
workers: [],
};
setFormData((prev) => ({
...prev,
workerInfoList: [...prev.workerInfoList, newWorkerInfo],
}));
};
// 작업자 정보 삭제
const handleDeleteWorkerInfo = (workerId: string) => {
setFormData((prev) => ({
...prev,
workerInfoList: prev.workerInfoList.filter((w) => w.id !== workerId),
}));
};
// 작업자 정보 변경
const handleWorkerInfoChange = (
workerId: string,
field: keyof WorkerInfo,
value: string | string[]
) => {
setFormData((prev) => ({
...prev,
workerInfoList: prev.workerInfoList.map((w) =>
w.id === workerId ? { ...w, [field]: value } : w
),
}));
};
// 공과 정보 추가
const handleAddWorkProgress = () => {
const newProgress: WorkProgressInfo = {
id: `progress-${Date.now()}`,
scheduleDate: '',
workName: '',
};
setFormData((prev) => ({
...prev,
workProgressList: [...prev.workProgressList, newProgress],
}));
};
// 공과 정보 삭제
const handleDeleteWorkProgress = (progressId: string) => {
setFormData((prev) => ({
...prev,
workProgressList: prev.workProgressList.filter((p) => p.id !== progressId),
}));
};
// 공과 정보 변경
const handleWorkProgressChange = (
progressId: string,
field: keyof WorkProgressInfo,
value: string
) => {
setFormData((prev) => ({
...prev,
workProgressList: prev.workProgressList.map((p) =>
p.id === progressId ? { ...p, [field]: value } : p
),
}));
};
// 작업일지 변경
const handleWorkLogChange = (value: string) => {
setFormData((prev) => ({ ...prev, workLogContent: value }));
};
// 사진 업로드 (임시 - 실제로는 파일 업로드 로직 필요)
const handlePhotoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
// 임시 목업: 파일 정보를 photos에 추가
const newPhotos: PhotoInfo[] = Array.from(files).map((file, index) => ({
id: `photo-${Date.now()}-${index}`,
url: URL.createObjectURL(file),
name: file.name,
uploadedAt: new Date().toISOString(),
}));
setFormData((prev) => ({
...prev,
photos: [...prev.photos, ...newPhotos],
}));
};
// 사진 삭제
const handleDeletePhoto = (photoId: string) => {
setFormData((prev) => ({
...prev,
photos: prev.photos.filter((p) => p.id !== photoId),
}));
};
// 발주서 보기
const handleViewOrder = async () => {
if (!detail?.orderId) return;
try {
const result = await getOrderDetailFull(detail.orderId);
if (result.success && result.data) {
setOrderData(result.data);
setShowOrderModal(true);
} else {
toast.error('발주서 정보를 불러올 수 없습니다.');
}
} catch (error) {
console.error('Failed to load order detail:', error);
toast.error('발주서 정보를 불러올 수 없습니다.');
}
};
// 저장
const handleSave = async () => {
try {
const result = await updateConstructionManagementDetail(id, formData);
if (result.success) {
toast.success('저장되었습니다.');
} else {
toast.error(result.error || '저장에 실패했습니다.');
}
} catch (error) {
console.error('Failed to save:', error);
toast.error('저장에 실패했습니다.');
}
};
// 시공 완료 버튼 활성화 조건: 작업일지 + 사진 모두 있어야 함
const canComplete =
detail?.status === 'in_progress' &&
formData.workLogContent.trim() !== '' &&
formData.photos.length > 0;
// 시공 완료 처리
const handleComplete = async () => {
try {
const result = await completeConstruction(id);
if (result.success) {
toast.success('시공이 완료되었습니다.');
router.push('/ko/construction/project/construction-management');
} else {
toast.error(result.error || '시공 완료 처리에 실패했습니다.');
}
} catch (error) {
console.error('Failed to complete:', error);
toast.error('시공 완료 처리에 실패했습니다.');
}
};
// 로딩 상태
if (isLoading) {
return (
<PageLayout>
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-muted-foreground"> ...</div>
</div>
</PageLayout>
);
}
if (!detail) {
return null;
}
// 헤더 액션 - view/edit 모드에 따라 분리
const headerActions = isViewMode ? (
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleBack}>
<List className="h-4 w-4 mr-2" />
</Button>
<Button onClick={handleEdit}></Button>
</div>
) : (
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleBack}>
<List className="h-4 w-4 mr-2" />
</Button>
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleSave}></Button>
</div>
);
return (
<PageLayout>
<PageHeader
title="시공 상세"
description="시공 정보를 확인하고 관리합니다"
icon={Wrench}
onBack={handleBack}
actions={headerActions}
/>
<div className="space-y-6">
{/* 시공 정보 */}
<Card>
<CardHeader className="border-b pb-4">
<CardTitle className="text-base font-medium"> </CardTitle>
</CardHeader>
<CardContent className="pt-4">
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{/* 시공번호 */}
<div className="space-y-1">
<label className="text-sm text-muted-foreground"></label>
<div className="font-medium">{detail.constructionNumber}</div>
</div>
{/* 현장 */}
<div className="space-y-1">
<label className="text-sm text-muted-foreground"></label>
<div className="font-medium">{detail.siteName}</div>
</div>
{/* 시공투입일 */}
<div className="space-y-1">
<label className="text-sm text-muted-foreground"></label>
<div className="font-medium">{formatDate(detail.constructionStartDate)}</div>
</div>
{/* 시공완료일 */}
<div className="space-y-1">
<label className="text-sm text-muted-foreground"></label>
<div className="font-medium">{formatDate(detail.constructionEndDate)}</div>
</div>
{/* 작업반장 */}
<div className="space-y-1">
<label className="text-sm text-muted-foreground"></label>
{isViewMode ? (
<div className="font-medium">{formData.workTeamLeader || '-'}</div>
) : (
<Select
value={formData.workTeamLeader}
onValueChange={handleWorkTeamLeaderChange}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
{MOCK_CM_WORK_TEAM_LEADERS.map((leader) => (
<SelectItem key={leader.value} value={leader.label}>
{leader.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* 상태 */}
<div className="space-y-1">
<label className="text-sm text-muted-foreground"></label>
<div>
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${CONSTRUCTION_MANAGEMENT_STATUS_STYLES[detail.status]}`}
>
{CONSTRUCTION_MANAGEMENT_STATUS_LABELS[detail.status]}
</span>
</div>
</div>
</div>
</CardContent>
</Card>
{/* 작업자 정보 */}
<Card>
<CardHeader className="border-b pb-4">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-medium"> </CardTitle>
{isEditMode && (
<Button variant="outline" size="sm" onClick={handleAddWorkerInfo}>
<Plus className="h-4 w-4 mr-1" />
</Button>
)}
</div>
</CardHeader>
<CardContent className="pt-4">
{formData.workerInfoList.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
{isViewMode ? '등록된 작업자 정보가 없습니다.' : '작업자 정보가 없습니다. 추가 버튼을 클릭하여 등록하세요.'}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="border-b">
<th className="text-left py-2 px-3 text-sm font-medium w-16"></th>
<th className="text-left py-2 px-3 text-sm font-medium w-40"></th>
<th className="text-left py-2 px-3 text-sm font-medium"></th>
{isEditMode && (
<th className="text-center py-2 px-3 text-sm font-medium w-20"></th>
)}
</tr>
</thead>
<tbody>
{formData.workerInfoList.map((worker, index) => (
<tr key={worker.id} className="border-b">
<td className="py-2 px-3">{index + 1}</td>
<td className="py-2 px-3">
{isViewMode ? (
<span>{worker.workDate || '-'}</span>
) : (
<Input
type="date"
value={worker.workDate}
onChange={(e) =>
handleWorkerInfoChange(worker.id, 'workDate', e.target.value)
}
className="w-full"
/>
)}
</td>
<td className="py-2 px-3">
{isViewMode ? (
<span>
{worker.workers.length > 0
? worker.workers
.map((w) => MOCK_EMPLOYEES.find((e) => e.value === w)?.label || w)
.join(', ')
: '-'}
</span>
) : (
<MultiSelectCombobox
options={MOCK_EMPLOYEES}
value={worker.workers}
onChange={(value) =>
handleWorkerInfoChange(worker.id, 'workers', value)
}
placeholder="작업자 선택"
className="w-full"
/>
)}
</td>
{isEditMode && (
<td className="py-2 px-3 text-center">
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteWorkerInfo(worker.id)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{/* 공과 정보 */}
<Card>
<CardHeader className="border-b pb-4">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-medium"> </CardTitle>
{isEditMode && (
<Button variant="outline" size="sm" onClick={handleAddWorkProgress}>
<Plus className="h-4 w-4 mr-1" />
</Button>
)}
</div>
</CardHeader>
<CardContent className="pt-4">
{formData.workProgressList.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
{isViewMode ? '등록된 공과 정보가 없습니다.' : '공과 정보가 없습니다. 추가 버튼을 클릭하여 등록하세요.'}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="border-b">
<th className="text-left py-2 px-3 text-sm font-medium w-16"></th>
<th className="text-left py-2 px-3 text-sm font-medium w-48"></th>
<th className="text-left py-2 px-3 text-sm font-medium"></th>
{isEditMode && (
<th className="text-center py-2 px-3 text-sm font-medium w-20"></th>
)}
</tr>
</thead>
<tbody>
{formData.workProgressList.map((progress, index) => (
<tr key={progress.id} className="border-b">
<td className="py-2 px-3">{index + 1}</td>
<td className="py-2 px-3">
{isViewMode ? (
<span>{progress.scheduleDate || '-'}</span>
) : (
<Input
type="datetime-local"
value={progress.scheduleDate.replace(' ', 'T')}
onChange={(e) =>
handleWorkProgressChange(
progress.id,
'scheduleDate',
e.target.value.replace('T', ' ')
)
}
className="w-full"
/>
)}
</td>
<td className="py-2 px-3">
{isViewMode ? (
<span>{progress.workName || '-'}</span>
) : (
<Input
type="text"
value={progress.workName}
onChange={(e) =>
handleWorkProgressChange(progress.id, 'workName', e.target.value)
}
placeholder="공과명을 입력하세요"
className="w-full"
/>
)}
</td>
{isEditMode && (
<td className="py-2 px-3 text-center">
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteWorkProgress(progress.id)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{/* 발주서 영역 */}
<Card>
<CardHeader className="border-b pb-4">
<CardTitle className="text-base font-medium"></CardTitle>
</CardHeader>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<FileText className="h-5 w-5 text-muted-foreground" />
<button
type="button"
onClick={handleViewOrder}
className="text-primary hover:underline font-medium"
>
{detail.orderNumber}
</button>
<span className="text-muted-foreground text-sm">( )</span>
</div>
</CardContent>
</Card>
{/* 이슈 목록 / 이슈 보고 - 카드 2개 형태 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 이슈 목록 카드 */}
<Card
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => router.push(`/ko/construction/project/issue-management?orderId=${detail.orderId}`)}
>
<CardContent className="pt-6 pb-6">
<div className="space-y-2">
<h3 className="text-base font-medium"> </h3>
<p className="text-3xl font-bold">{detail.issueCount}</p>
</div>
</CardContent>
</Card>
{/* 이슈 보고 카드 */}
<Card
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => router.push(`/ko/construction/project/issue-management/new?orderId=${detail.orderId}`)}
>
<CardContent className="pt-6 pb-6">
<div className="space-y-2">
<h3 className="text-base font-medium"> </h3>
<p className="text-sm text-muted-foreground">
</p>
</div>
</CardContent>
</Card>
</div>
{/* 작업일지 */}
<Card>
<CardHeader className="border-b pb-4">
<CardTitle className="text-base font-medium"></CardTitle>
</CardHeader>
<CardContent className="pt-4">
{isViewMode ? (
<div className="min-h-[100px] whitespace-pre-wrap">
{formData.workLogContent || '등록된 작업일지가 없습니다.'}
</div>
) : (
<Textarea
value={formData.workLogContent}
onChange={(e) => handleWorkLogChange(e.target.value)}
placeholder="작업일지를 입력하세요"
className="min-h-[150px]"
/>
)}
</CardContent>
</Card>
{/* 사진 */}
<Card>
<CardHeader className="border-b pb-4">
<CardTitle className="text-base font-medium"></CardTitle>
</CardHeader>
<CardContent className="pt-4 space-y-4">
{/* 업로드 버튼 - edit 모드에서만 */}
{isEditMode && (
<div>
<label className="inline-flex items-center gap-2 px-4 py-2 border rounded-md cursor-pointer hover:bg-muted/50 transition-colors">
<Upload className="h-4 w-4" />
<span className="text-sm"> </span>
<input
type="file"
accept="image/*"
multiple
onChange={handlePhotoUpload}
className="hidden"
/>
</label>
</div>
)}
{/* 업로드된 사진 목록 */}
{formData.photos.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{formData.photos.map((photo) => (
<div key={photo.id} className="relative group">
<img
src={photo.url}
alt={photo.name}
className="w-full h-32 object-cover rounded-lg border"
/>
{isEditMode && (
<button
type="button"
onClick={() => handleDeletePhoto(photo.id)}
className="absolute top-1 right-1 p-1 bg-black/50 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="h-4 w-4" />
</button>
)}
<div className="text-xs text-muted-foreground truncate mt-1">
{photo.name}
</div>
</div>
))}
</div>
)}
{formData.photos.length === 0 && (
<div className="text-center text-muted-foreground py-4">
.
</div>
)}
</CardContent>
</Card>
{/* 시공 완료 버튼 - edit 모드에서만 */}
{isEditMode && detail.status === 'in_progress' && (
<div className="flex justify-end">
<Button
size="lg"
onClick={() => setShowCompleteDialog(true)}
disabled={!canComplete}
>
</Button>
{!canComplete && (
<span className="ml-3 text-sm text-muted-foreground self-center">
* .
</span>
)}
</div>
)}
</div>
{/* 발주서 모달 */}
{orderData && (
<OrderDocumentModal
open={showOrderModal}
onOpenChange={setShowOrderModal}
order={orderData}
/>
)}
{/* 시공 완료 확인 다이얼로그 */}
<AlertDialog open={showCompleteDialog} onOpenChange={setShowCompleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleComplete}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PageLayout>
);
}

View File

@@ -0,0 +1,716 @@
'use client';
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox';
import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
import { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { ScheduleCalendar, ScheduleEvent, DayBadge } from '@/components/common/ScheduleCalendar';
import { toast } from 'sonner';
import { 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';
// 테이블 컬럼 정의
// 체크박스, 번호, 시공번호, 거래처, 현장명, 공사PM, 작업반장, 작업자, 시공투입일, 시공완료일, 상태, 작업
const tableColumns: TableColumn[] = [
{ 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();
// 상태
const [constructions, setConstructions] = useState<ConstructionManagement[]>(initialData);
const [stats, setStats] = useState<ConstructionManagementStats | null>(initialStats || null);
const [searchValue, setSearchValue] = useState('');
// 다중선택 필터 (빈 배열 = 전체)
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
const [siteNameFilters, setSiteNameFilters] = useState<string[]>([]);
const [constructionPMFilters, setConstructionPMFilters] = useState<string[]>([]);
const [workTeamFilters, setWorkTeamFilters] = useState<string[]>([]);
// 달력용 필터
const [calendarSiteFilters, setCalendarSiteFilters] = useState<string[]>([]);
const [calendarWorkTeamFilters, setCalendarWorkTeamFilters] = useState<string[]>([]);
const [statusFilter, setStatusFilter] = useState<string>('all');
const [sortBy, setSortBy] = useState<string>('latest');
const [startDate, setStartDate] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [activeStatTab, setActiveStatTab] = useState<'all' | 'in_progress' | 'completed'>('all');
const [selectedCalendarDate, setSelectedCalendarDate] = useState<Date | null>(null);
const [calendarDate, setCalendarDate] = useState<Date>(new Date());
const itemsPerPage = 20;
// 데이터 로드
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const [listResult, statsResult] = await Promise.all([
getConstructionManagementList({
size: 1000,
startDate: startDate || undefined,
endDate: endDate || undefined,
}),
getConstructionManagementStats(),
]);
if (listResult.success && listResult.data) {
setConstructions(listResult.data.items);
}
if (statsResult.success && statsResult.data) {
setStats(statsResult.data);
}
} catch {
toast.error('데이터 로드에 실패했습니다.');
} finally {
setIsLoading(false);
}
}, [startDate, endDate]);
// 초기 데이터가 없으면 로드
useEffect(() => {
if (initialData.length === 0) {
loadData();
}
}, [initialData.length, loadData]);
// 다중선택 필터 옵션 변환 (MultiSelectCombobox용)
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 constructions
.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,
}));
}, [constructions, calendarSiteFilters, calendarWorkTeamFilters]);
// 달력용 뱃지 데이터 - 사용하지 않음
const calendarBadges: DayBadge[] = [];
// 필터링된 데이터
const filteredConstructions = useMemo(() => {
return constructions.filter((item) => {
// 상태 탭 필터
if (activeStatTab === 'in_progress' && item.status !== 'in_progress') return false;
if (activeStatTab === 'completed' && item.status !== 'completed') return false;
// 상태 필터
if (statusFilter !== 'all' && item.status !== statusFilter) return false;
// 거래처 필터 (다중선택)
if (partnerFilters.length > 0) {
const matchingPartner = MOCK_CM_PARTNERS.find((p) => p.label === item.partnerName);
if (!matchingPartner || !partnerFilters.includes(matchingPartner.value)) {
return false;
}
}
// 현장명 필터 (다중선택)
if (siteNameFilters.length > 0) {
const matchingSite = MOCK_CM_SITES.find((s) => s.label === item.siteName);
if (!matchingSite || !siteNameFilters.includes(matchingSite.value)) {
return false;
}
}
// 공사PM 필터 (다중선택)
if (constructionPMFilters.length > 0) {
const matchingPM = MOCK_CM_CONSTRUCTION_PM.find((p) => p.label === item.constructionPM);
if (!matchingPM || !constructionPMFilters.includes(matchingPM.value)) {
return false;
}
}
// 작업반장 필터 (다중선택)
if (workTeamFilters.length > 0) {
const matchingLeader = MOCK_CM_WORK_TEAM_LEADERS.find((l) => l.label === item.workTeamLeader);
if (!matchingLeader || !workTeamFilters.includes(matchingLeader.value)) {
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;
}
}
// 검색 필터
if (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)
);
}
return true;
});
}, [constructions, activeStatTab, statusFilter, partnerFilters, siteNameFilters, constructionPMFilters, workTeamFilters, selectedCalendarDate, searchValue]);
// 정렬
const sortedConstructions = useMemo(() => {
const sorted = [...filteredConstructions];
switch (sortBy) {
case 'latest':
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
break;
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;
}
return sorted;
}, [filteredConstructions, sortBy]);
// 페이지네이션
const totalPages = Math.ceil(sortedConstructions.length / itemsPerPage);
const paginatedData = useMemo(() => {
const start = (currentPage - 1) * itemsPerPage;
return sortedConstructions.slice(start, start + itemsPerPage);
}, [sortedConstructions, currentPage, itemsPerPage]);
// 핸들러
const handleSearchChange = useCallback((value: string) => {
setSearchValue(value);
setCurrentPage(1);
}, []);
const handleToggleSelection = useCallback((id: string) => {
setSelectedItems((prev) => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
}, []);
const handleToggleSelectAll = useCallback(() => {
if (selectedItems.size === paginatedData.length) {
setSelectedItems(new Set());
} else {
setSelectedItems(new Set(paginatedData.map((c) => c.id)));
}
}, [selectedItems.size, paginatedData]);
const handleRowClick = useCallback(
(item: ConstructionManagement) => {
router.push(`/ko/construction/project/construction-management/${item.id}`);
},
[router]
);
const handleEdit = useCallback(
(e: React.MouseEvent, itemId: string) => {
e.stopPropagation();
router.push(`/ko/construction/project/construction-management/${itemId}/edit`);
},
[router]
);
// 달력 이벤트 핸들러
const handleCalendarDateClick = useCallback((date: Date) => {
// 같은 날짜 클릭 시 선택 해제
if (selectedCalendarDate && isSameDay(selectedCalendarDate, date)) {
setSelectedCalendarDate(null);
} else {
setSelectedCalendarDate(date);
}
setCurrentPage(1);
}, [selectedCalendarDate]);
const handleCalendarEventClick = useCallback((event: ScheduleEvent) => {
if (event.data) {
router.push(`/ko/construction/project/construction-management/${event.id}`);
}
}, [router]);
const handleCalendarMonthChange = useCallback((date: Date) => {
setCalendarDate(date);
}, []);
// 날짜 포맷
const formatDate = (dateStr: string | null) => {
if (!dateStr) return '-';
return dateStr.split('T')[0];
};
// 테이블 행 렌더링
const renderTableRow = useCallback(
(item: ConstructionManagement, index: number, globalIndex: number) => {
const isSelected = selectedItems.has(item.id);
return (
<TableRow
key={item.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleRowClick(item)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => handleToggleSelection(item.id)}
/>
</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">
{isSelected && (
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => handleEdit(e, item.id)}
>
<Pencil className="h-4 w-4" />
</Button>
</div>
)}
</TableCell>
</TableRow>
);
},
[selectedItems, handleToggleSelection, handleRowClick, handleEdit]
);
// 모바일 카드 렌더링
const renderMobileCard = useCallback(
(item: ConstructionManagement, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
return (
<MobileCard
title={item.siteName}
subtitle={item.constructionNumber}
badge={CONSTRUCTION_MANAGEMENT_STATUS_LABELS[item.status]}
badgeVariant="secondary"
badgeClassName={CONSTRUCTION_MANAGEMENT_STATUS_STYLES[item.status]}
isSelected={isSelected}
onToggle={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) },
]}
/>
);
},
[handleRowClick]
);
// 헤더 액션 (DateRangeSelector)
const headerActions = (
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
);
// 통계 카드 클릭 핸들러
const handleStatClick = useCallback((tab: 'all' | 'in_progress' | 'completed') => {
setActiveStatTab(tab);
setCurrentPage(1);
}, []);
// 통계 카드 데이터
const statsCardsData: StatCard[] = [
{
label: '시공진행',
value: stats?.inProgress ?? 0,
icon: Clock,
iconColor: 'text-yellow-600',
onClick: () => handleStatClick('in_progress'),
isActive: activeStatTab === 'in_progress',
},
{
label: '시공완료',
value: stats?.completed ?? 0,
icon: CheckCircle,
iconColor: 'text-green-600',
onClick: () => handleStatClick('completed'),
isActive: activeStatTab === 'completed',
},
];
// 모바일 필터 설정
const mobileFilterFields: FilterFieldConfig[] = [
{
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(opt => opt.value !== 'all'),
},
{
key: 'sortBy',
label: '정렬',
type: 'single',
options: CONSTRUCTION_MANAGEMENT_SORT_OPTIONS,
allOptionLabel: '최신순',
},
];
// 모바일 필터 값
const mobileFilterValues: FilterValues = {
partners: partnerFilters,
sites: siteNameFilters,
constructionPMs: constructionPMFilters,
workTeamLeaders: workTeamFilters,
status: statusFilter,
sortBy: sortBy,
};
// 모바일 필터 변경 핸들러
const handleMobileFilterChange = (key: string, value: string | string[]) => {
switch (key) {
case 'partners':
setPartnerFilters(value as string[]);
break;
case 'sites':
setSiteNameFilters(value as string[]);
break;
case 'constructionPMs':
setConstructionPMFilters(value as string[]);
break;
case 'workTeamLeaders':
setWorkTeamFilters(value as string[]);
break;
case 'status':
setStatusFilter(value as string);
break;
case 'sortBy':
setSortBy(value as string);
break;
}
setCurrentPage(1);
};
// 모바일 필터 초기화 핸들러
const handleMobileFilterReset = useCallback(() => {
setPartnerFilters([]);
setSiteNameFilters([]);
setConstructionPMFilters([]);
setWorkTeamFilters([]);
setStatusFilter('all');
setSortBy('latest');
setCurrentPage(1);
}, []);
// 테이블 헤더 액션 (기획서 요구사항)
// 거래처, 현장명, 공사PM, 작업반장, 상태, 정렬
const tableHeaderActions = (
<div className="flex items-center gap-2 flex-wrap justify-end w-full">
{/* PC용 개별 필터 */}
<div className="flex items-center gap-2 flex-wrap">
{/* 1. 거래처 필터 (다중선택) */}
<MultiSelectCombobox
options={partnerOptions}
value={partnerFilters}
onChange={setPartnerFilters}
placeholder="거래처"
searchPlaceholder="거래처 검색..."
className="w-[120px]"
/>
{/* 2. 현장명 필터 (다중선택) */}
<MultiSelectCombobox
options={siteOptions}
value={siteNameFilters}
onChange={setSiteNameFilters}
placeholder="현장명"
searchPlaceholder="현장명 검색..."
className="w-[140px]"
/>
{/* 3. 공사PM 필터 (다중선택) */}
<MultiSelectCombobox
options={constructionPMOptions}
value={constructionPMFilters}
onChange={setConstructionPMFilters}
placeholder="공사PM"
searchPlaceholder="공사PM 검색..."
className="w-[120px]"
/>
{/* 4. 작업반장 필터 (다중선택) */}
<MultiSelectCombobox
options={workTeamOptions}
value={workTeamFilters}
onChange={setWorkTeamFilters}
placeholder="작업반장"
searchPlaceholder="작업반장 검색..."
className="w-[120px]"
/>
{/* 5. 상태 필터 (단일선택) */}
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{CONSTRUCTION_MANAGEMENT_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 6. 정렬 (단일선택) */}
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
{CONSTRUCTION_MANAGEMENT_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 달력 날짜 필터 초기화 */}
{selectedCalendarDate && (
<Button
variant="outline"
size="sm"
onClick={() => setSelectedCalendarDate(null)}
>
</Button>
)}
</div>
);
// 달력 필터 슬롯 (현장 + 작업반장 - 다중선택)
const calendarFilterSlot = (
<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>
);
return (
<>
<IntegratedListTemplateV2
title="시공관리"
description="시공 스케줄 및 목록을 관리합니다"
icon={HardHat}
headerActions={headerActions}
stats={statsCardsData}
tableHeaderActions={tableHeaderActions}
filterConfig={mobileFilterFields}
filterValues={mobileFilterValues}
onFilterChange={handleMobileFilterChange}
onFilterReset={handleMobileFilterReset}
filterTitle="시공관리 필터"
searchValue={searchValue}
onSearchChange={handleSearchChange}
searchPlaceholder="시공번호, 거래처, 현장명, 작업반장, 작업자 검색"
tableColumns={tableColumns}
data={paginatedData}
allData={sortedConstructions}
getItemId={(item) => item.id}
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
selectedItems={selectedItems}
onToggleSelection={handleToggleSelection}
onToggleSelectAll={handleToggleSelectAll}
pagination={{
currentPage,
totalPages,
totalItems: sortedConstructions.length,
itemsPerPage,
onPageChange: setCurrentPage,
}}
// 달력 섹션 추가
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={isLoading}
/>
</div>
}
/>
</>
);
}

View File

@@ -0,0 +1,198 @@
'use client';
import { useState } from 'react';
import { ChevronUp, ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import type {
DetailCategory,
ConstructionItem,
IssueItem,
} from './types';
import {
DETAIL_CATEGORY_LABELS,
CONSTRUCTION_STATUS_LABELS,
ISSUE_STATUS_LABELS,
} from './types';
interface DetailAccordionProps {
categories: DetailCategory[];
selectedDetailId?: string | null;
onDetailSelect?: (id: string) => void;
}
export default function DetailAccordion({
categories,
selectedDetailId,
onDetailSelect
}: DetailAccordionProps) {
// 첫 번째 카테고리만 기본 열림
const [openCategories, setOpenCategories] = useState<string[]>(
categories.length > 0 ? [categories[0].type] : []
);
const toggleCategory = (type: string) => {
setOpenCategories((prev) =>
prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type]
);
};
if (categories.length === 0) {
return (
<div className="flex items-center justify-center h-24 text-sm text-muted-foreground">
.
</div>
);
}
return (
<div className="space-y-3">
{categories.map((category) => (
<CategoryAccordionItem
key={category.type}
category={category}
isOpen={openCategories.includes(category.type)}
onToggle={() => toggleCategory(category.type)}
selectedDetailId={selectedDetailId}
onDetailSelect={onDetailSelect}
/>
))}
</div>
);
}
interface CategoryAccordionItemProps {
category: DetailCategory;
isOpen: boolean;
onToggle: () => void;
selectedDetailId?: string | null;
onDetailSelect?: (id: string) => void;
}
function CategoryAccordionItem({
category,
isOpen,
onToggle,
selectedDetailId,
onDetailSelect,
}: CategoryAccordionItemProps) {
const label = DETAIL_CATEGORY_LABELS[category.type];
return (
<div className="border rounded-lg overflow-hidden bg-card">
{/* 아코디언 헤더 */}
<button
type="button"
onClick={onToggle}
className="w-full flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
>
<div>
<h4 className="text-sm font-semibold text-foreground">{label}</h4>
<p className="text-xs text-muted-foreground">{category.count}</p>
</div>
{isOpen ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</button>
{/* 아코디언 컨텐츠 */}
{isOpen && (
<div className="border-t p-2 space-y-2 bg-muted/30 max-h-[300px] overflow-y-auto">
{category.type === 'construction' &&
category.constructionItems?.map((item) => (
<ConstructionCard
key={item.id}
item={item}
isSelected={selectedDetailId === item.id}
onClick={() => onDetailSelect?.(item.id)}
/>
))}
{category.type === 'issue' &&
category.issueItems?.map((item) => (
<IssueCard
key={item.id}
item={item}
isSelected={selectedDetailId === item.id}
onClick={() => onDetailSelect?.(item.id)}
/>
))}
</div>
)}
</div>
);
}
interface ConstructionCardProps {
item: ConstructionItem;
isSelected?: boolean;
onClick?: () => void;
}
function ConstructionCard({ item, isSelected, onClick }: ConstructionCardProps) {
const statusLabel = CONSTRUCTION_STATUS_LABELS[item.status];
const isInProgress = item.status === 'in_progress';
return (
<div
className={cn(
'bg-card rounded-lg p-3 border cursor-pointer transition-all hover:shadow-md',
isSelected && 'ring-2 ring-primary border-primary'
)}
onClick={onClick}
>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">{item.number}</p>
<p className="text-xs text-muted-foreground">
: {item.inputDate}
</p>
</div>
<Badge
variant={isInProgress ? 'default' : 'secondary'}
className={cn(
'text-xs shrink-0',
isInProgress && 'bg-blue-500 hover:bg-blue-600'
)}
>
{statusLabel}
</Badge>
</div>
</div>
);
}
interface IssueCardProps {
item: IssueItem;
isSelected?: boolean;
onClick?: () => void;
}
function IssueCard({ item, isSelected, onClick }: IssueCardProps) {
const statusLabel = ISSUE_STATUS_LABELS[item.status];
const isOpen = item.status === 'open';
return (
<div
className={cn(
'bg-card rounded-lg p-3 border cursor-pointer transition-all hover:shadow-md',
isSelected && 'ring-2 ring-primary border-primary'
)}
onClick={onClick}
>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">{item.number}</p>
<p className="text-xs text-muted-foreground">{item.title}</p>
</div>
<Badge
variant={isOpen ? 'destructive' : 'secondary'}
className="text-xs shrink-0"
>
{statusLabel}
</Badge>
</div>
</div>
);
}

View File

@@ -0,0 +1,68 @@
'use client';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { StageDetail, StageCardStatus } from './types';
import { DETAIL_CONFIG } from './types';
interface DetailCardProps {
detail: StageDetail;
onClick?: () => void;
}
export default function DetailCard({ detail, onClick }: DetailCardProps) {
const config = DETAIL_CONFIG[detail.type];
// 상태 뱃지 색상
const getStatusBadge = (status?: StageCardStatus) => {
if (!status) return null;
switch (status) {
case 'completed':
return <Badge variant="secondary" className="text-xs"></Badge>;
case 'in_progress':
return <Badge className="text-xs bg-yellow-500"></Badge>;
case 'waiting':
return <Badge variant="outline" className="text-xs"></Badge>;
default:
return null;
}
};
return (
<div
className={cn(
'p-3 bg-card rounded-lg border cursor-pointer transition-all hover:shadow-md'
)}
onClick={onClick}
>
{/* 헤더: 상세 타입 + 상태 뱃지 */}
<div className="flex items-center justify-between gap-2 mb-2">
<span className="text-xs font-medium text-muted-foreground">
{config.label}
</span>
{getStatusBadge(detail.status)}
</div>
{/* 제목 */}
<h4 className="text-sm font-medium text-foreground mb-2 line-clamp-1">
{detail.title}
</h4>
{/* 날짜 또는 담당자 */}
<div className="text-xs text-muted-foreground">
{detail.date && (
<div className="flex justify-between">
<span>{config.dateLabel}</span>
<span>{detail.date.replace(/-/g, '.')}</span>
</div>
)}
{detail.pm && (
<div className="flex justify-between">
<span>{config.dateLabel}</span>
<span>{detail.pm}</span>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,51 @@
'use client';
import { ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
interface KanbanColumnProps {
title: string;
count?: number;
headerAction?: ReactNode;
children: ReactNode;
className?: string;
emptyMessage?: string;
isEmpty?: boolean;
}
export default function KanbanColumn({
title,
count,
headerAction,
children,
className,
emptyMessage = '항목이 없습니다.',
isEmpty = false,
}: KanbanColumnProps) {
return (
<div className={cn('flex flex-col flex-1 min-w-0 bg-muted/30 rounded-lg', className)}>
{/* 컬럼 헤더 */}
<div className="flex items-center justify-between p-3 border-b">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
{count !== undefined && (
<Badge className="text-xs bg-blue-500 hover:bg-blue-600">{count}</Badge>
)}
</div>
{headerAction}
</div>
{/* 컬럼 컨텐츠 */}
<div className="flex-1 p-2 space-y-2 overflow-y-auto min-h-[500px] max-h-[calc(100vh-300px)]">
{isEmpty ? (
<div className="flex items-center justify-center h-24 text-sm text-muted-foreground">
{emptyMessage}
</div>
) : (
children
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,85 @@
'use client';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { ProjectDetail, ProjectStatus } from './types';
interface ProjectCardProps {
project: ProjectDetail;
isSelected?: boolean;
onClick?: () => void;
}
export default function ProjectCard({ project, isSelected, onClick }: ProjectCardProps) {
// 상태 뱃지 색상
const getStatusBadge = (status: ProjectStatus, hasUrgentIssue: boolean) => {
if (hasUrgentIssue) {
return <Badge variant="destructive" className="text-xs"></Badge>;
}
switch (status) {
case 'completed':
return <Badge variant="secondary" className="text-xs"></Badge>;
case 'in_progress':
return <Badge className="text-xs bg-blue-500"></Badge>;
default:
return <Badge variant="outline" className="text-xs">{status}</Badge>;
}
};
// 금액 포맷
const formatAmount = (amount: number) => {
return amount.toLocaleString();
};
return (
<div
className={cn(
'p-3 bg-card rounded-lg border cursor-pointer transition-all hover:shadow-md',
isSelected && 'ring-2 ring-primary border-primary'
)}
onClick={onClick}
>
{/* 헤더: 현장명 + 상태 뱃지 */}
<div className="flex items-start justify-between gap-2 mb-2">
<h4 className="text-sm font-medium text-foreground line-clamp-1">
{project.siteName}
</h4>
{getStatusBadge(project.status, project.hasUrgentIssue)}
</div>
{/* 진행률 */}
<div className="mb-2">
<div className="flex items-center justify-between text-xs text-muted-foreground mb-1">
<span></span>
<span className="font-medium text-foreground">{project.progressRate}%</span>
</div>
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all',
project.status === 'completed' ? 'bg-gray-400' :
project.hasUrgentIssue ? 'bg-red-500' : 'bg-blue-500'
)}
style={{ width: `${project.progressRate}%` }}
/>
</div>
</div>
{/* 정보 */}
<div className="space-y-1 text-xs text-muted-foreground">
<div className="flex justify-between">
<span>{project.partnerName}</span>
<span className="font-medium text-foreground">{project.totalLocations}</span>
</div>
<div className="flex justify-between">
<span></span>
<span className="font-medium text-foreground">{formatAmount(project.contractAmount)}</span>
</div>
<div className="flex justify-between">
<span></span>
<span>{project.startDate.replace(/-/g, '.')} ~ {project.endDate.replace(/-/g, '.')}</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,196 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { FolderKanban, ClipboardList, PlayCircle, CheckCircle2, Search } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { toast } from 'sonner';
import { format, startOfMonth, endOfMonth } from 'date-fns';
import ProjectKanbanBoard from './ProjectKanbanBoard';
import ProjectEndDialog from './ProjectEndDialog';
import type { ProjectDetail, ProjectStats, SelectOption } from './types';
import { getProjectsForKanban, getProjectStats, getPartnerOptions, getSiteOptions } from './actions';
interface ProjectDetailClientProps {
projectId?: string;
}
export default function ProjectDetailClient({ projectId }: ProjectDetailClientProps) {
// 데이터 상태
const [projects, setProjects] = useState<ProjectDetail[]>([]);
const [stats, setStats] = useState<ProjectStats>({ total: 0, inProgress: 0, completed: 0 });
const [partnerOptions, setPartnerOptions] = useState<SelectOption[]>([]);
const [siteOptions, setSiteOptions] = useState<SelectOption[]>([]);
const [isLoading, setIsLoading] = useState(true);
// 필터 상태
const [filterStartDate, setFilterStartDate] = useState(() =>
format(startOfMonth(new Date()), 'yyyy-MM-dd')
);
const [filterEndDate, setFilterEndDate] = useState(() =>
format(endOfMonth(new Date()), 'yyyy-MM-dd')
);
const [searchQuery, setSearchQuery] = useState('');
// 프로젝트 종료 다이얼로그 상태
const [endDialogOpen, setEndDialogOpen] = useState(false);
const [selectedProject, setSelectedProject] = useState<ProjectDetail | null>(null);
// 데이터 로드
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const [projectsResult, statsResult, partnersResult, sitesResult] = await Promise.all([
getProjectsForKanban(),
getProjectStats(),
getPartnerOptions(),
getSiteOptions(),
]);
if (projectsResult.success && projectsResult.data) {
setProjects(projectsResult.data);
}
if (statsResult.success && statsResult.data) {
setStats(statsResult.data);
}
if (partnersResult.success && partnersResult.data) {
setPartnerOptions(partnersResult.data);
}
if (sitesResult.success && sitesResult.data) {
setSiteOptions(sitesResult.data);
}
} catch {
toast.error('데이터 로드에 실패했습니다.');
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
// 검색 필터링된 프로젝트
const filteredProjects = projects.filter((project) => {
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
return (
project.siteName.toLowerCase().includes(query) ||
project.partnerName.toLowerCase().includes(query) ||
project.contractNumber.toLowerCase().includes(query)
);
});
// 프로젝트 종료 버튼 클릭 핸들러
const handleProjectEndClick = (project: ProjectDetail) => {
setSelectedProject(project);
setEndDialogOpen(true);
};
// 프로젝트 종료 성공 핸들러
const handleEndSuccess = () => {
loadData();
};
return (
<PageLayout>
{/* 페이지 헤더 */}
<PageHeader
title="프로젝트 실행 관리"
description="프로젝트 실행 관리(제안서)"
icon={FolderKanban}
/>
{/* 기간 선택 (달력 + 프리셋 버튼) */}
<DateRangeSelector
startDate={filterStartDate}
endDate={filterEndDate}
onStartDateChange={setFilterStartDate}
onEndDateChange={setFilterEndDate}
/>
{/* 상태 카드 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 rounded-lg">
<ClipboardList className="h-5 w-5 text-blue-600" />
</div>
<div>
<p className="text-sm text-muted-foreground"> </p>
<p className="text-2xl font-bold">{stats.total}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-100 rounded-lg">
<PlayCircle className="h-5 w-5 text-green-600" />
</div>
<div>
<p className="text-sm text-muted-foreground"> </p>
<p className="text-2xl font-bold">{stats.inProgress}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-gray-100 rounded-lg">
<CheckCircle2 className="h-5 w-5 text-gray-600" />
</div>
<div>
<p className="text-sm text-muted-foreground"> </p>
<p className="text-2xl font-bold">{stats.completed}</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* 검색 영역 */}
<div className="relative max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="프로젝트 검색 (현장명, 거래처, 계약번호)"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
{/* 칸반 보드 */}
<Card>
<CardContent className="p-4 min-h-[600px]">
{isLoading ? (
<div className="flex items-center justify-center h-[500px]">
<p className="text-muted-foreground"> ...</p>
</div>
) : (
<ProjectKanbanBoard
projects={filteredProjects}
partnerOptions={partnerOptions}
siteOptions={siteOptions}
onProjectEndClick={handleProjectEndClick}
/>
)}
</CardContent>
</Card>
{/* 프로젝트 종료 다이얼로그 */}
<ProjectEndDialog
open={endDialogOpen}
onOpenChange={setEndDialogOpen}
project={selectedProject}
onSuccess={handleEndSuccess}
/>
</PageLayout>
);
}

View File

@@ -0,0 +1,194 @@
'use client';
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { toast } from 'sonner';
import type { ProjectDetail, ProjectEndFormData } from './types';
import { PROJECT_END_STATUS_OPTIONS } from './types';
import { updateProjectEnd } from './actions';
interface ProjectEndDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
project: ProjectDetail | null;
onSuccess?: () => void;
}
export default function ProjectEndDialog({
open,
onOpenChange,
project,
onSuccess,
}: ProjectEndDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState<ProjectEndFormData>({
projectId: '',
projectName: '',
workDate: '',
completionDate: '',
status: 'in_progress',
memo: '',
});
// 프로젝트가 변경되면 폼 데이터 초기화
useEffect(() => {
if (project) {
setFormData({
projectId: project.id,
projectName: project.siteName,
workDate: project.endDate, // 결선작업일은 프로젝트 종료일로 설정
completionDate: new Date().toISOString().split('T')[0], // 오늘 날짜
status: project.status === 'completed' ? 'completed' : 'in_progress',
memo: '',
});
}
}, [project]);
// 수정 버튼 클릭
const handleSubmit = async () => {
if (!project) return;
setIsSubmitting(true);
try {
const result = await updateProjectEnd(formData);
if (result.success) {
toast.success('프로젝트 종료 처리가 완료되었습니다.');
onOpenChange(false);
onSuccess?.();
} else {
toast.error(result.error || '처리 중 오류가 발생했습니다.');
}
} catch {
toast.error('처리 중 오류가 발생했습니다.');
} finally {
setIsSubmitting(false);
}
};
// 삭제 버튼 클릭
const handleDelete = () => {
toast.info('삭제 기능은 준비 중입니다.');
};
if (!project) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
{/* 프로젝트 (현장명) - 읽기전용 */}
<div className="grid gap-2">
<Label htmlFor="projectName"></Label>
<Input
id="projectName"
value={formData.projectName}
disabled
className="bg-muted"
/>
</div>
{/* 결선작업일 - 읽기전용 */}
<div className="grid gap-2">
<Label htmlFor="workDate"></Label>
<Input
id="workDate"
type="date"
value={formData.workDate}
disabled
className="bg-muted"
/>
</div>
{/* 결선완료일 - 입력 */}
<div className="grid gap-2">
<Label htmlFor="completionDate"></Label>
<Input
id="completionDate"
type="date"
value={formData.completionDate}
onChange={(e) =>
setFormData((prev) => ({ ...prev, completionDate: e.target.value }))
}
/>
</div>
{/* 상태 - 셀렉트 */}
<div className="grid gap-2">
<Label htmlFor="status"></Label>
<Select
value={formData.status}
onValueChange={(value: 'in_progress' | 'completed') =>
setFormData((prev) => ({ ...prev, status: value }))
}
>
<SelectTrigger>
<SelectValue placeholder="상태 선택" />
</SelectTrigger>
<SelectContent>
{PROJECT_END_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 메모 - 텍스트에어리어 */}
<div className="grid gap-2">
<Label htmlFor="memo"></Label>
<Textarea
id="memo"
placeholder="메모를 입력해주세요."
value={formData.memo}
onChange={(e) =>
setFormData((prev) => ({ ...prev, memo: e.target.value }))
}
className="min-h-[100px]"
/>
</div>
</div>
{/* 버튼 영역 */}
<div className="flex justify-center gap-3">
<Button
variant="outline"
onClick={handleDelete}
disabled={isSubmitting}
className="w-24"
>
</Button>
<Button
onClick={handleSubmit}
disabled={isSubmitting}
className="w-24"
>
{isSubmitting ? '처리중...' : '수정'}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,366 @@
'use client';
import { useMemo, useRef, useEffect, useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { Project, ChartViewMode } from './types';
import { GANTT_BAR_COLORS } from './types';
interface ProjectGanttChartProps {
projects: Project[];
viewMode: ChartViewMode;
currentDate: Date;
onProjectClick: (project: Project) => void;
onDateChange: (date: Date) => void;
}
export default function ProjectGanttChart({
projects,
viewMode,
currentDate,
onProjectClick,
onDateChange,
}: ProjectGanttChartProps) {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [isScrolling, setIsScrolling] = useState(false);
// 현재 날짜 기준으로 표시할 기간 계산
const { columns, startDate, endDate, yearGroups, monthGroups } = useMemo(() => {
const now = currentDate;
if (viewMode === 'day') {
// 일 모드: 현재 월의 1일~말일
const year = now.getFullYear();
const month = now.getMonth();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const cols = Array.from({ length: daysInMonth }, (_, i) => ({
label: String(i + 1),
date: new Date(year, month, i + 1),
year,
month,
}));
return {
columns: cols,
startDate: new Date(year, month, 1),
endDate: new Date(year, month, daysInMonth),
yearGroups: null,
monthGroups: null,
};
} else if (viewMode === 'week') {
// 주 모드: 현재 월 기준 전후 2개월 (총 12주)
const year = now.getFullYear();
const month = now.getMonth();
// 전월 1일부터 시작
const startMonth = month === 0 ? 11 : month - 1;
const startYear = month === 0 ? year - 1 : year;
const periodStart = new Date(startYear, startMonth, 1);
// 다음월 말일까지
const endMonth = month === 11 ? 0 : month + 1;
const endYear = month === 11 ? year + 1 : year;
const periodEnd = new Date(endYear, endMonth + 1, 0);
// 주차별 컬럼 생성 (월요일 시작)
const cols: { label: string; date: Date; year: number; month: number; weekStart: Date; weekEnd: Date }[] = [];
const tempDate = new Date(periodStart);
// 첫 번째 월요일 찾기
while (tempDate.getDay() !== 1) {
tempDate.setDate(tempDate.getDate() + 1);
}
let weekNum = 1;
while (tempDate <= periodEnd) {
const weekStart = new Date(tempDate);
const weekEnd = new Date(tempDate);
weekEnd.setDate(weekEnd.getDate() + 6);
cols.push({
label: `${weekNum}`,
date: new Date(tempDate),
year: tempDate.getFullYear(),
month: tempDate.getMonth(),
weekStart,
weekEnd,
});
tempDate.setDate(tempDate.getDate() + 7);
weekNum++;
}
// 월별 그룹 계산
const monthGroupsMap = new Map<string, number>();
cols.forEach((col) => {
const key = `${col.year}-${col.month}`;
monthGroupsMap.set(key, (monthGroupsMap.get(key) || 0) + 1);
});
const mGroups = Array.from(monthGroupsMap.entries()).map(([key, count]) => {
const [y, m] = key.split('-').map(Number);
return { year: y, month: m, count, label: `${m + 1}` };
});
return {
columns: cols,
startDate: cols[0]?.weekStart || periodStart,
endDate: cols[cols.length - 1]?.weekEnd || periodEnd,
yearGroups: null,
monthGroups: mGroups,
};
} else {
// 월 모드: 전년도 + 올해 (2년치, 24개월)
const year = now.getFullYear();
const prevYear = year - 1;
const cols: { label: string; date: Date; year: number; month: number }[] = [];
// 전년도 12개월
for (let i = 0; i < 12; i++) {
cols.push({
label: `${i + 1}`,
date: new Date(prevYear, i, 1),
year: prevYear,
month: i,
});
}
// 올해 12개월
for (let i = 0; i < 12; i++) {
cols.push({
label: `${i + 1}`,
date: new Date(year, i, 1),
year: year,
month: i,
});
}
return {
columns: cols,
startDate: new Date(prevYear, 0, 1),
endDate: new Date(year, 11, 31),
yearGroups: [
{ year: prevYear, count: 12 },
{ year: year, count: 12 },
],
monthGroups: null,
};
}
}, [viewMode, currentDate]);
// 막대 위치 및 너비 계산
const getBarStyle = (project: Project) => {
const projectStart = new Date(project.startDate);
const projectEnd = new Date(project.endDate);
// 범위 밖이면 표시 안함
if (projectEnd < startDate || projectStart > endDate) {
return null;
}
// 시작/종료 위치 계산
const totalDays = (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24);
const barStartDays = Math.max(0, (projectStart.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
const barEndDays = Math.min(totalDays, (projectEnd.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
const leftPercent = (barStartDays / totalDays) * 100;
const widthPercent = ((barEndDays - barStartDays) / totalDays) * 100;
// 색상 결정
let bgColor = GANTT_BAR_COLORS.in_progress;
if (project.status === 'completed') {
bgColor = GANTT_BAR_COLORS.completed;
} else if (project.hasUrgentIssue || project.status === 'urgent') {
bgColor = GANTT_BAR_COLORS.urgent;
}
return {
left: `${leftPercent}%`,
width: `${Math.max(widthPercent, 1)}%`,
backgroundColor: bgColor,
};
};
// 이전/다음 이동
const handlePrev = () => {
const newDate = new Date(currentDate);
if (viewMode === 'day') {
newDate.setMonth(newDate.getMonth() - 1);
} else if (viewMode === 'week') {
newDate.setMonth(newDate.getMonth() - 1);
} else {
newDate.setFullYear(newDate.getFullYear() - 1);
}
onDateChange(newDate);
};
const handleNext = () => {
const newDate = new Date(currentDate);
if (viewMode === 'day') {
newDate.setMonth(newDate.getMonth() + 1);
} else if (viewMode === 'week') {
newDate.setMonth(newDate.getMonth() + 1);
} else {
newDate.setFullYear(newDate.getFullYear() + 1);
}
onDateChange(newDate);
};
// 월 모드에서 올해 시작 위치로 스크롤
useEffect(() => {
if (scrollContainerRef.current && viewMode === 'month') {
// 올해 1월 위치로 스크롤 (전년도 12개월 건너뛰기)
const totalWidth = scrollContainerRef.current.scrollWidth;
const scrollPosition = totalWidth / 2 - scrollContainerRef.current.clientWidth / 3;
scrollContainerRef.current.scrollLeft = Math.max(0, scrollPosition);
} else if (scrollContainerRef.current && viewMode === 'day') {
const today = new Date();
const dayOfMonth = today.getDate();
const columnWidth = scrollContainerRef.current.scrollWidth / columns.length;
const scrollPosition = (dayOfMonth - 1) * columnWidth - scrollContainerRef.current.clientWidth / 2;
scrollContainerRef.current.scrollLeft = Math.max(0, scrollPosition);
}
}, [viewMode, columns.length]);
return (
<div className="border rounded-lg bg-card">
{/* 헤더: 날짜 네비게이션 */}
<div className="flex items-center justify-between p-3 border-b">
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" onClick={handlePrev}>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm font-medium min-w-[140px] text-center">
{viewMode === 'day'
? `${currentDate.getFullYear()}${currentDate.getMonth() + 1}`
: viewMode === 'week'
? `${currentDate.getFullYear()}${currentDate.getMonth()}월 ~ ${currentDate.getMonth() + 2}`
: `${currentDate.getFullYear() - 1}년 ~ ${currentDate.getFullYear()}`}
</span>
<Button variant="outline" size="icon" onClick={handleNext}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
{/* 범례 */}
<div className="flex items-center gap-4 text-xs">
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded" style={{ backgroundColor: GANTT_BAR_COLORS.in_progress }} />
<span></span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded" style={{ backgroundColor: GANTT_BAR_COLORS.completed }} />
<span></span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded" style={{ backgroundColor: GANTT_BAR_COLORS.urgent }} />
<span> </span>
</div>
</div>
</div>
{/* 차트 영역 */}
<div className="overflow-hidden">
<div
ref={scrollContainerRef}
className="overflow-x-auto"
onMouseDown={() => setIsScrolling(true)}
onMouseUp={() => setIsScrolling(false)}
onMouseLeave={() => setIsScrolling(false)}
>
<div className={cn(
viewMode === 'month' ? 'min-w-[1600px]' : viewMode === 'week' ? 'min-w-[1000px]' : 'min-w-[800px]'
)}>
{/* 전체를 하나의 세로 스크롤 영역으로 */}
<div className="max-h-[450px] overflow-y-auto">
{/* 연도 헤더 (월 모드에서만) */}
{viewMode === 'month' && yearGroups && (
<div className="flex bg-muted/50 sticky top-0 z-20">
{yearGroups.map((group) => (
<div
key={group.year}
className="flex-1 p-1.5 text-xs font-semibold text-center border-r border-border last:border-r-0"
style={{ flex: group.count }}
>
{group.year}
</div>
))}
</div>
)}
{/* 월 헤더 (주 모드에서만) */}
{viewMode === 'week' && monthGroups && (
<div className="flex bg-muted/50 sticky top-0 z-20">
{monthGroups.map((group, idx) => (
<div
key={`${group.year}-${group.month}-${idx}`}
className="flex-1 p-1.5 text-xs font-semibold text-center border-r border-border last:border-r-0"
style={{ flex: group.count }}
>
{group.label}
</div>
))}
</div>
)}
{/* 컬럼 헤더 - 날짜/주/월 */}
<div className={cn(
'flex bg-muted/30 sticky z-10',
(viewMode === 'month' || viewMode === 'week') ? 'top-[30px]' : 'top-0'
)}>
{columns.map((col, idx) => (
<div
key={idx}
className={cn(
'flex-1 p-2 text-xs text-center border-r border-border last:border-r-0',
viewMode === 'day' && col.date.getDay() === 0 && 'text-red-500',
viewMode === 'day' && col.date.getDay() === 6 && 'text-blue-500'
)}
>
{col.label}
</div>
))}
</div>
{/* 프로젝트 행들 (가로선 없음) */}
{projects.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
.
</div>
) : (
projects.map((project) => {
const barStyle = getBarStyle(project);
return (
<div
key={project.id}
className="relative h-12 hover:bg-muted/10 cursor-pointer"
onClick={() => !isScrolling && onProjectClick(project)}
>
{/* 그리드 세로선 */}
<div className="absolute inset-0 flex">
{columns.map((_, idx) => (
<div key={idx} className="flex-1 border-r border-border last:border-r-0" />
))}
</div>
{/* 막대 - 프로젝트명 직접 표시 */}
{barStyle && (
<div
className="absolute top-1/2 -translate-y-1/2 h-7 rounded text-xs text-white flex items-center px-2 truncate shadow-sm"
style={barStyle}
>
<span className="truncate font-medium">
[{project.partnerName}] {project.siteName} {project.progressRate}%
</span>
</div>
)}
</div>
);
})
)}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,244 @@
'use client';
import { useState, useMemo, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import KanbanColumn from './KanbanColumn';
import ProjectCard from './ProjectCard';
import StageCard from './StageCard';
import DetailAccordion from './DetailAccordion';
import type { ProjectDetail, Stage, DetailCategory, SelectOption } from './types';
import { STAGE_LABELS } from './types';
import { getDetailCategories } from './actions';
interface ProjectKanbanBoardProps {
projects: ProjectDetail[];
partnerOptions?: SelectOption[];
siteOptions?: SelectOption[];
onProjectEndClick?: (project: ProjectDetail) => void;
}
export default function ProjectKanbanBoard({
projects,
partnerOptions = [],
siteOptions = [],
onProjectEndClick,
}: ProjectKanbanBoardProps) {
// 필터 상태
const [selectedPartner, setSelectedPartner] = useState<string>('all');
const [selectedSite, setSelectedSite] = useState<string>('all');
// 선택된 프로젝트
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
// 선택된 단계
const [selectedStageId, setSelectedStageId] = useState<string | null>(null);
// 상세 카테고리 (시공, 이슈 아코디언)
const [detailCategories, setDetailCategories] = useState<DetailCategory[]>([]);
const [isLoadingCategories, setIsLoadingCategories] = useState(false);
// 선택된 상세 아이템
const [selectedDetailId, setSelectedDetailId] = useState<string | null>(null);
// 필터링된 프로젝트
const filteredProjects = useMemo(() => {
let result = [...projects];
if (selectedPartner !== 'all') {
result = result.filter((p) => p.partnerName === selectedPartner);
}
if (selectedSite !== 'all') {
result = result.filter((p) => p.siteName === selectedSite);
}
return result;
}, [projects, selectedPartner, selectedSite]);
// 선택된 프로젝트 정보
const selectedProject = useMemo(() => {
return filteredProjects.find((p) => p.id === selectedProjectId) || null;
}, [filteredProjects, selectedProjectId]);
// 선택된 프로젝트의 단계 목록
const stages = useMemo(() => {
if (!selectedProject) return [];
return selectedProject.stages || [];
}, [selectedProject]);
// 선택된 단계 정보
const selectedStage = useMemo(() => {
return stages.find((s) => s.id === selectedStageId) || null;
}, [stages, selectedStageId]);
// 단계 선택 시 상세 카테고리 로드
useEffect(() => {
if (!selectedStageId) {
setDetailCategories([]);
return;
}
const loadCategories = async () => {
setIsLoadingCategories(true);
try {
const result = await getDetailCategories(selectedStageId);
if (result.success && result.data) {
setDetailCategories(result.data);
} else {
setDetailCategories([]);
}
} catch {
setDetailCategories([]);
} finally {
setIsLoadingCategories(false);
}
};
loadCategories();
}, [selectedStageId]);
// 프로젝트 선택 핸들러
const handleProjectClick = (project: ProjectDetail) => {
if (selectedProjectId === project.id) {
// 이미 선택된 프로젝트 클릭 시 선택 해제
setSelectedProjectId(null);
setSelectedStageId(null);
} else {
setSelectedProjectId(project.id);
setSelectedStageId(null);
}
};
// 단계 선택 핸들러
const handleStageClick = (stage: Stage) => {
if (selectedStageId === stage.id) {
setSelectedStageId(null);
} else {
setSelectedStageId(stage.id);
}
// 단계 변경 시 상세 선택 초기화
setSelectedDetailId(null);
};
// 상세 아이템 선택 핸들러
const handleDetailSelect = (id: string) => {
if (selectedDetailId === id) {
setSelectedDetailId(null);
} else {
setSelectedDetailId(id);
}
};
// 프로젝트 종료 버튼 클릭 핸들러
const handleProjectEndClick = () => {
if (selectedProject && onProjectEndClick) {
onProjectEndClick(selectedProject);
}
};
return (
<div className="space-y-4">
{/* 필터 영역 */}
<div className="flex items-center justify-end gap-3">
<Select value={selectedPartner} onValueChange={setSelectedPartner}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{partnerOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={selectedSite} onValueChange={setSelectedSite}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{siteOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 칸반 보드 */}
<div className="flex gap-4 w-full">
{/* 프로젝트 컬럼 */}
<KanbanColumn
title="프로젝트"
count={filteredProjects.length}
isEmpty={filteredProjects.length === 0}
emptyMessage="프로젝트가 없습니다."
>
{filteredProjects.map((project) => (
<ProjectCard
key={project.id}
project={project}
isSelected={selectedProjectId === project.id}
onClick={() => handleProjectClick(project)}
/>
))}
</KanbanColumn>
{/* 단계 컬럼 */}
<KanbanColumn
title="단계"
headerAction={
selectedProject && (
<Button
variant="destructive"
size="sm"
className="h-7 text-xs"
onClick={handleProjectEndClick}
>
</Button>
)
}
isEmpty={stages.length === 0}
emptyMessage={selectedProjectId ? '단계가 없습니다.' : '프로젝트를 선택하세요.'}
>
{stages.map((stage) => (
<StageCard
key={stage.id}
stage={stage}
isSelected={selectedStageId === stage.id}
onClick={() => handleStageClick(stage)}
/>
))}
</KanbanColumn>
{/* 상세 컬럼 */}
<KanbanColumn
title="상세"
isEmpty={!selectedStageId && detailCategories.length === 0}
emptyMessage={selectedStageId ? '상세 항목이 없습니다.' : '단계를 선택하세요.'}
>
{isLoadingCategories ? (
<div className="flex items-center justify-center h-24 text-sm text-muted-foreground">
...
</div>
) : (
<DetailAccordion
categories={detailCategories}
selectedDetailId={selectedDetailId}
onDetailSelect={handleDetailSelect}
/>
)}
</KanbanColumn>
</div>
</div>
);
}

View File

@@ -0,0 +1,661 @@
'use client';
import { useState, useMemo, useCallback, useEffect, Fragment } from 'react';
import { useRouter } from 'next/navigation';
import { FolderKanban, Pencil, ClipboardList, PlayCircle, CheckCircle2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { MobileCard } from '@/components/molecules/MobileCard';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import type { Project, ProjectStats, ChartViewMode, SelectOption } from './types';
import { STATUS_OPTIONS, SORT_OPTIONS } from './types';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { format, startOfMonth, endOfMonth } from 'date-fns';
import {
getProjectList,
getProjectStats,
getPartnerOptions,
getSiteOptions,
getContractManagerOptions,
getConstructionPMOptions,
} from './actions';
import ProjectGanttChart from './ProjectGanttChart';
// 다중 선택 셀렉트 컴포넌트
function MultiSelectFilter({
label,
options,
value,
onChange,
}: {
label: string;
options: SelectOption[];
value: string[];
onChange: (value: string[]) => void;
}) {
const [open, setOpen] = useState(false);
const handleToggle = (optionValue: string) => {
if (optionValue === 'all') {
onChange(['all']);
} else {
const newValue = value.includes(optionValue)
? value.filter((v) => v !== optionValue && v !== 'all')
: [...value.filter((v) => v !== 'all'), optionValue];
onChange(newValue.length === 0 ? ['all'] : newValue);
}
};
const displayValue = value.includes('all') || value.length === 0
? '전체'
: value.length === 1
? options.find((o) => o.value === value[0])?.label || value[0]
: `${value.length}개 선택`;
return (
<div className="relative">
<Button
variant="outline"
className="w-[140px] justify-between text-left font-normal"
onClick={() => setOpen(!open)}
>
<span className="truncate">{displayValue}</span>
</Button>
{open && (
<>
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<div className="absolute top-full left-0 z-50 mt-1 w-[200px] rounded-md border bg-popover p-1 shadow-md">
<div
className="flex items-center gap-2 p-2 hover:bg-muted rounded cursor-pointer"
onClick={() => handleToggle('all')}
>
<Checkbox checked={value.includes('all') || value.length === 0} />
<span className="text-sm"></span>
</div>
{options.map((option) => (
<div
key={option.value}
className="flex items-center gap-2 p-2 hover:bg-muted rounded cursor-pointer"
onClick={() => handleToggle(option.value)}
>
<Checkbox checked={value.includes(option.value)} />
<span className="text-sm">{option.label}</span>
</div>
))}
</div>
</>
)}
</div>
);
}
interface ProjectListClientProps {
initialData?: Project[];
initialStats?: ProjectStats;
}
export default function ProjectListClient({ initialData = [], initialStats }: ProjectListClientProps) {
const router = useRouter();
// 상태
const [projects, setProjects] = useState<Project[]>(initialData);
const [stats, setStats] = useState<ProjectStats>(
initialStats ?? { total: 0, inProgress: 0, completed: 0 }
);
const [isLoading, setIsLoading] = useState(false);
// 날짜 범위 (기간 선택)
const [filterStartDate, setFilterStartDate] = useState(() => format(startOfMonth(new Date()), 'yyyy-MM-dd'));
const [filterEndDate, setFilterEndDate] = useState(() => format(endOfMonth(new Date()), 'yyyy-MM-dd'));
// 간트차트 상태
const [chartViewMode, setChartViewMode] = useState<ChartViewMode>('day');
// TODO: 실제 API 연동 시 new Date()로 변경 (현재 목업 데이터가 2025년이라 임시 설정)
const [chartDate, setChartDate] = useState(new Date(2025, 0, 15));
const [chartPartnerFilter, setChartPartnerFilter] = useState<string[]>(['all']);
const [chartSiteFilter, setChartSiteFilter] = useState<string[]>(['all']);
// 테이블 필터
const [partnerFilter, setPartnerFilter] = useState<string[]>(['all']);
const [contractManagerFilter, setContractManagerFilter] = useState<string[]>(['all']);
const [pmFilter, setPmFilter] = useState<string[]>(['all']);
const [statusFilter, setStatusFilter] = useState('all');
const [sortBy, setSortBy] = useState<'latest' | 'progress' | 'register' | 'completion'>('latest');
// 필터 옵션들
const [partnerOptions, setPartnerOptions] = useState<SelectOption[]>([]);
const [siteOptions, setSiteOptions] = useState<SelectOption[]>([]);
const [contractManagerOptions, setContractManagerOptions] = useState<SelectOption[]>([]);
const [pmOptions, setPmOptions] = useState<SelectOption[]>([]);
// 테이블 상태
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 20;
// 데이터 로드
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const [listResult, statsResult, partners, sites, managers, pms] = await Promise.all([
getProjectList({
partners: partnerFilter.includes('all') ? undefined : partnerFilter,
contractManagers: contractManagerFilter.includes('all') ? undefined : contractManagerFilter,
constructionPMs: pmFilter.includes('all') ? undefined : pmFilter,
status: statusFilter === 'all' ? undefined : statusFilter,
sortBy,
size: 1000,
}),
getProjectStats(),
getPartnerOptions(),
getSiteOptions(),
getContractManagerOptions(),
getConstructionPMOptions(),
]);
if (listResult.success && listResult.data) {
setProjects(listResult.data.items);
}
if (statsResult.success && statsResult.data) {
setStats(statsResult.data);
}
if (partners.success && partners.data) {
setPartnerOptions(partners.data);
}
if (sites.success && sites.data) {
setSiteOptions(sites.data);
}
if (managers.success && managers.data) {
setContractManagerOptions(managers.data);
}
if (pms.success && pms.data) {
setPmOptions(pms.data);
}
} catch {
toast.error('데이터 로드에 실패했습니다.');
} finally {
setIsLoading(false);
}
}, [partnerFilter, contractManagerFilter, pmFilter, statusFilter, sortBy]);
useEffect(() => {
loadData();
}, [loadData]);
// 간트차트용 필터링된 프로젝트
const chartProjects = useMemo(() => {
return projects.filter((project) => {
if (!chartPartnerFilter.includes('all') && !chartPartnerFilter.includes(project.partnerName)) {
return false;
}
if (!chartSiteFilter.includes('all') && !chartSiteFilter.includes(project.siteName)) {
return false;
}
return true;
});
}, [projects, chartPartnerFilter, chartSiteFilter]);
// 페이지네이션
const totalPages = Math.ceil(projects.length / itemsPerPage);
const paginatedData = useMemo(() => {
const start = (currentPage - 1) * itemsPerPage;
return projects.slice(start, start + itemsPerPage);
}, [projects, currentPage, itemsPerPage]);
const startIndex = (currentPage - 1) * itemsPerPage;
// 핸들러
const handleToggleSelection = useCallback((id: string) => {
setSelectedItems((prev) => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
}, []);
const handleToggleSelectAll = useCallback(() => {
if (selectedItems.size === paginatedData.length) {
setSelectedItems(new Set());
} else {
setSelectedItems(new Set(paginatedData.map((p) => p.id)));
}
}, [selectedItems.size, paginatedData]);
const handleRowClick = useCallback(
(project: Project) => {
router.push(`/ko/construction/project/management/${project.id}`);
},
[router]
);
const handleEdit = useCallback(
(e: React.MouseEvent, projectId: string) => {
e.stopPropagation();
router.push(`/ko/construction/project/management/${projectId}/edit`);
},
[router]
);
const handleGanttProjectClick = useCallback(
(project: Project) => {
router.push(`/ko/construction/project/management/${project.id}`);
},
[router]
);
// 금액 포맷
const formatAmount = (amount: number) => {
return amount.toLocaleString() + '원';
};
// 날짜 포맷
const formatDate = (dateStr: string) => {
return dateStr.replace(/-/g, '.');
};
// 상태 뱃지
const getStatusBadge = (status: string, hasUrgentIssue: boolean) => {
if (hasUrgentIssue) {
return <Badge variant="destructive"></Badge>;
}
switch (status) {
case 'completed':
return <Badge variant="secondary"></Badge>;
case 'in_progress':
return <Badge variant="default"></Badge>;
default:
return <Badge variant="outline">{status}</Badge>;
}
};
const allSelected = selectedItems.size === paginatedData.length && paginatedData.length > 0;
return (
<PageLayout>
{/* 페이지 헤더 */}
<PageHeader
title="프로젝트 관리"
description="계약 완료 시 자동 등록된 프로젝트를 관리합니다"
icon={FolderKanban}
/>
{/* 기간 선택 (달력 + 프리셋 버튼) */}
<DateRangeSelector
startDate={filterStartDate}
endDate={filterEndDate}
onStartDateChange={setFilterStartDate}
onEndDateChange={setFilterEndDate}
/>
{/* 상태 카드 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 rounded-lg">
<ClipboardList className="h-5 w-5 text-blue-600" />
</div>
<div>
<p className="text-sm text-muted-foreground"> </p>
<p className="text-2xl font-bold">{stats.total}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-100 rounded-lg">
<PlayCircle className="h-5 w-5 text-green-600" />
</div>
<div>
<p className="text-sm text-muted-foreground"> </p>
<p className="text-2xl font-bold">{stats.inProgress}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-gray-100 rounded-lg">
<CheckCircle2 className="h-5 w-5 text-gray-600" />
</div>
<div>
<p className="text-sm text-muted-foreground"> </p>
<p className="text-2xl font-bold">{stats.completed}</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* 프로젝트 일정 간트차트 */}
<Card>
<CardContent className="p-4">
<div className="flex flex-col gap-4">
{/* 간트차트 상단 컨트롤 */}
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-2">
<span className="text-sm font-medium"> </span>
</div>
<div className="flex flex-wrap items-center gap-3">
{/* 일/주/월 전환 */}
<div className="flex border rounded-md">
<Button
variant={chartViewMode === 'day' ? 'default' : 'ghost'}
size="sm"
className="rounded-r-none border-r-0"
onClick={() => setChartViewMode('day')}
>
</Button>
<Button
variant={chartViewMode === 'week' ? 'default' : 'ghost'}
size="sm"
className="rounded-none border-r-0"
onClick={() => setChartViewMode('week')}
>
</Button>
<Button
variant={chartViewMode === 'month' ? 'default' : 'ghost'}
size="sm"
className="rounded-l-none"
onClick={() => setChartViewMode('month')}
>
</Button>
</div>
{/* 거래처 필터 */}
<MultiSelectFilter
label="거래처"
options={partnerOptions}
value={chartPartnerFilter}
onChange={setChartPartnerFilter}
/>
{/* 현장 필터 */}
<MultiSelectFilter
label="현장"
options={siteOptions}
value={chartSiteFilter}
onChange={setChartSiteFilter}
/>
</div>
</div>
{/* 간트차트 */}
<ProjectGanttChart
projects={chartProjects}
viewMode={chartViewMode}
currentDate={chartDate}
onProjectClick={handleGanttProjectClick}
onDateChange={setChartDate}
/>
</div>
</CardContent>
</Card>
{/* 테이블 영역 */}
<Card>
<CardContent className="pt-6">
{/* 테이블 헤더 (필터들) */}
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
<span className="text-sm text-muted-foreground whitespace-nowrap">
{projects.length}
</span>
<div className="flex flex-wrap items-center gap-3">
{/* 거래처 필터 */}
<MultiSelectFilter
label="거래처"
options={partnerOptions}
value={partnerFilter}
onChange={(v) => {
setPartnerFilter(v);
setCurrentPage(1);
}}
/>
{/* 계약담당자 필터 */}
<MultiSelectFilter
label="계약담당자"
options={contractManagerOptions}
value={contractManagerFilter}
onChange={(v) => {
setContractManagerFilter(v);
setCurrentPage(1);
}}
/>
{/* 공사PM 필터 */}
<MultiSelectFilter
label="공사PM"
options={pmOptions}
value={pmFilter}
onChange={(v) => {
setPmFilter(v);
setCurrentPage(1);
}}
/>
{/* 상태 필터 */}
<Select value={statusFilter} onValueChange={(v) => { setStatusFilter(v); setCurrentPage(1); }}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 정렬 */}
<Select value={sortBy} onValueChange={(v) => setSortBy(v as typeof sortBy)}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="최신순" />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 데스크톱 테이블 */}
<div className="hidden xl:block rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="h-14">
<TableHead className="w-[50px] text-center">
<Checkbox
checked={allSelected}
onCheckedChange={handleToggleSelectAll}
/>
</TableHead>
<TableHead className="w-[60px] text-center"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="min-w-[150px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[100px]">PM</TableHead>
<TableHead className="w-[80px] text-center"> </TableHead>
<TableHead className="w-[120px] text-right"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[120px] text-right"> </TableHead>
<TableHead className="w-[180px] text-center"> </TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody className="[&_tr]:h-14 [&_tr]:min-h-[56px] [&_tr]:max-h-[56px]">
{paginatedData.length === 0 ? (
<TableRow>
<TableCell colSpan={14} className="h-24 text-center">
.
</TableCell>
</TableRow>
) : (
paginatedData.map((project, index) => {
const isSelected = selectedItems.has(project.id);
const globalIndex = startIndex + index + 1;
return (
<TableRow
key={project.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleRowClick(project)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => handleToggleSelection(project.id)}
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell className="font-medium">{project.contractNumber}</TableCell>
<TableCell>{project.partnerName}</TableCell>
<TableCell>{project.siteName}</TableCell>
<TableCell>{project.contractManager}</TableCell>
<TableCell>{project.constructionPM}</TableCell>
<TableCell className="text-center">{project.totalLocations}</TableCell>
<TableCell className="text-right">{formatAmount(project.contractAmount)}</TableCell>
<TableCell className="text-center">{project.progressRate}%</TableCell>
<TableCell className="text-right">{formatAmount(project.accumulatedPayment)}</TableCell>
<TableCell className="text-center">
{formatDate(project.startDate)} ~ {formatDate(project.endDate)}
</TableCell>
<TableCell className="text-center">
{getStatusBadge(project.status, project.hasUrgentIssue)}
</TableCell>
<TableCell className="text-center">
{isSelected && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => handleEdit(e, project.id)}
>
<Pencil className="h-4 w-4" />
</Button>
)}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
{/* 모바일/태블릿 카드 뷰 */}
<div className="xl:hidden space-y-4 md:grid md:grid-cols-2 md:gap-4 lg:grid-cols-3">
{projects.length === 0 ? (
<div className="text-center py-6 text-muted-foreground border rounded-lg">
.
</div>
) : (
projects.map((project, index) => {
const isSelected = selectedItems.has(project.id);
return (
<MobileCard
key={project.id}
title={project.siteName}
subtitle={project.contractNumber}
badge={project.hasUrgentIssue ? '긴급' : project.status === 'completed' ? '완료' : '진행중'}
badgeVariant={project.hasUrgentIssue ? 'destructive' : project.status === 'completed' ? 'secondary' : 'default'}
isSelected={isSelected}
onToggle={() => handleToggleSelection(project.id)}
onClick={() => handleRowClick(project)}
details={[
{ label: '거래처', value: project.partnerName },
{ label: '공사PM', value: project.constructionPM },
{ label: '진행률', value: `${project.progressRate}%` },
{ label: '계약금액', value: formatAmount(project.contractAmount) },
]}
/>
);
})
)}
</div>
</CardContent>
</Card>
{/* 페이지네이션 */}
{totalPages > 1 && (
<div className="hidden xl:flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{projects.length} {startIndex + 1}-{Math.min(startIndex + itemsPerPage, projects.length)}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage - 1)}
disabled={currentPage === 1}
>
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => {
if (
page === 1 ||
page === totalPages ||
(page >= currentPage - 2 && page <= currentPage + 2)
) {
return (
<Button
key={page}
variant={page === currentPage ? 'default' : 'outline'}
size="sm"
onClick={() => setCurrentPage(page)}
className="min-w-[36px]"
>
{page}
</Button>
);
} else if (page === currentPage - 3 || page === currentPage + 3) {
return <span key={page} className="px-2">...</span>;
}
return null;
})}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage + 1)}
disabled={currentPage === totalPages}
>
</Button>
</div>
</div>
)}
</PageLayout>
);
}

View File

@@ -0,0 +1,84 @@
'use client';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { Stage, StageCardStatus } from './types';
import { STAGE_LABELS, STAGE_CARD_STATUS_LABELS } from './types';
interface StageCardProps {
stage: Stage;
isSelected?: boolean;
onClick?: () => void;
}
export default function StageCard({ stage, isSelected, onClick }: StageCardProps) {
// 상태 뱃지 색상
const getStatusBadge = (status: StageCardStatus) => {
switch (status) {
case 'completed':
return <Badge variant="secondary" className="text-xs"></Badge>;
case 'in_progress':
return <Badge className="text-xs bg-yellow-500"></Badge>;
case 'waiting':
return <Badge variant="outline" className="text-xs"></Badge>;
default:
return null;
}
};
// 금액 포맷
const formatAmount = (amount: number) => {
return amount.toLocaleString();
};
return (
<div
className={cn(
'p-3 bg-card rounded-lg border cursor-pointer transition-all hover:shadow-md',
isSelected && 'ring-2 ring-primary border-primary'
)}
onClick={onClick}
>
{/* 헤더: 단계명 + 상태 뱃지 */}
<div className="flex items-center justify-between gap-2 mb-2">
<span className="text-xs font-medium text-muted-foreground">
{STAGE_LABELS[stage.type]}
</span>
{getStatusBadge(stage.status)}
</div>
{/* 현장명 */}
<h4 className="text-sm font-medium text-foreground mb-2 line-clamp-1">
{stage.siteName}
</h4>
{/* 세부 정보 */}
<div className="space-y-1 text-xs text-muted-foreground">
{stage.date && (
<div className="flex justify-between">
<span></span>
<span>{stage.date.replace(/-/g, '.')}</span>
</div>
)}
{stage.amount && (
<div className="flex justify-between">
<span></span>
<span className="font-medium text-foreground">{formatAmount(stage.amount)}</span>
</div>
)}
{stage.count && (
<div className="flex justify-between">
<span></span>
<span>{stage.count}</span>
</div>
)}
{stage.pm && (
<div className="flex justify-between">
<span>PM</span>
<span>{stage.pm}</span>
</div>
)}
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
export { default as ProjectListClient } from './ProjectListClient';
export { default as ProjectGanttChart } from './ProjectGanttChart';
export * from './types';
export * from './actions';

View File

@@ -0,0 +1,460 @@
/**
* 프로젝트 관리 타입 정의
*/
// 프로젝트 상태
export type ProjectStatus = 'in_progress' | 'completed' | 'urgent';
// 프로젝트 타입
export interface Project {
id: string;
contractNumber: string; // 계약번호
partnerName: string; // 거래처명
siteName: string; // 현장명
contractManager: string; // 계약담당자
constructionPM: string; // 공사PM
totalLocations: number; // 총 개소
contractAmount: number; // 계약금액
progressRate: number; // 진행률 (0-100)
accumulatedPayment: number; // 누계 기성
startDate: string; // 프로젝트 시작일
endDate: string; // 프로젝트 종료일
status: ProjectStatus; // 상태
hasUrgentIssue: boolean; // 긴급 이슈 여부
createdAt: string;
updatedAt: string;
}
// 프로젝트 통계
export interface ProjectStats {
total: number; // 전체 프로젝트
inProgress: number; // 프로젝트 진행
completed: number; // 프로젝트 완료
}
// 프로젝트 필터
export interface ProjectFilter {
partners?: string[]; // 거래처 (다중선택)
sites?: string[]; // 현장 (다중선택)
contractManagers?: string[]; // 계약담당자 (다중선택)
constructionPMs?: string[]; // 공사PM (다중선택)
status?: string; // 상태 (단일선택)
sortBy?: 'latest' | 'progress' | 'register' | 'completion'; // 정렬
page?: number;
size?: number;
}
// 기간 탭 타입
export type PeriodTab = 'thisYear' | 'twoMonthsAgo' | 'lastMonth' | 'thisMonth' | 'yesterday' | 'today';
// 차트 뷰 모드
export type ChartViewMode = 'day' | 'week' | 'month';
// API 응답 타입
export interface ProjectListResponse {
items: Project[];
total: number;
page: number;
size: number;
totalPages: number;
}
// 셀렉트 옵션
export interface SelectOption {
value: string;
label: string;
}
// 기간 탭 옵션
export const PERIOD_TAB_OPTIONS: { value: PeriodTab; label: string }[] = [
{ value: 'thisYear', label: '당해년도' },
{ value: 'twoMonthsAgo', label: '전전월' },
{ value: 'lastMonth', label: '전월' },
{ value: 'thisMonth', label: '당월' },
{ value: 'yesterday', label: '어제' },
{ value: 'today', label: '오늘' },
];
// 상태 옵션
export const STATUS_OPTIONS: SelectOption[] = [
{ value: 'all', label: '전체' },
{ value: 'in_progress', label: '진행중' },
{ value: 'completed', label: '완료' },
];
// 정렬 옵션
export const SORT_OPTIONS: SelectOption[] = [
{ value: 'latest', label: '최신순' },
{ value: 'progress', label: '진전순' },
{ value: 'register', label: '등록순' },
{ value: 'completion', label: '완성일순' },
];
// 간트차트 막대 색상
export const GANTT_BAR_COLORS = {
completed: '#9CA3AF', // 회색 - 종료
in_progress: '#3B82F6', // 파란색 - 진행중
urgent: '#991B1B', // 버건디 - 긴급 이슈
} as const;
// ============================================
// 프로젝트 실행관리 상세 페이지 타입
// ============================================
// 단계 타입
export type StageType = 'bid' | 'contract' | 'order' | 'construction' | 'payment';
// 단계 라벨
export const STAGE_LABELS: Record<StageType, string> = {
bid: '입찰',
contract: '계약',
order: '발주',
construction: '시공',
payment: '기성청구',
};
// 상세 항목 타입 (하위 목록 없는 경우)
export type DetailType =
| 'site_briefing' // 현장설명회
| 'estimation' // 건적
| 'bid_result' // 입찰
| 'handover_report' // 인수인계보고서
| 'structure_review' // 구조검토
| 'completion'; // 종료
// 상세 항목 라벨 및 날짜 필드명
export const DETAIL_CONFIG: Record<DetailType, { label: string; dateLabel: string }> = {
site_briefing: { label: '현장설명회', dateLabel: '현장설명회일' },
estimation: { label: '건적', dateLabel: '건적완료일' },
bid_result: { label: '입찰', dateLabel: '확정일' },
handover_report: { label: '인수인계보고서', dateLabel: '공사PM' },
structure_review: { label: '구조검토', dateLabel: '구조검토완료일' },
completion: { label: '종료', dateLabel: '결선완료일' },
};
// 단계 카드 상태
export type StageCardStatus = 'waiting' | 'in_progress' | 'completed';
// 단계 카드 상태 라벨
export const STAGE_CARD_STATUS_LABELS: Record<StageCardStatus, string> = {
waiting: '대기',
in_progress: '진행중',
completed: '완료',
};
// 단계 데이터
export interface Stage {
id: string;
type: StageType;
siteName: string;
status: StageCardStatus;
date?: string; // 해당 단계 날짜
amount?: number; // 금액 (계약금 등)
count?: number; // 개소 수
pm?: string; // 담당 PM
}
// 상세 항목 데이터 (하위 목록 없는 경우)
export interface StageDetail {
id: string;
type: DetailType;
title: string; // 제목 (현장명 등)
date?: string; // 날짜
pm?: string; // 담당자 (인수인계보고서용)
status?: StageCardStatus;
}
// 프로젝트 상세 (칸반 보드용)
export interface ProjectDetail extends Project {
stages: Stage[]; // 단계 목록
details: StageDetail[]; // 상세 목록
detailCategories?: DetailCategory[]; // 상세 카테고리 (시공, 이슈 아코디언)
}
// 프로젝트 종료 폼 데이터
export interface ProjectEndFormData {
projectId: string;
projectName: string; // 현장명 (읽기전용)
workDate: string; // 결선작업일 (읽기전용)
completionDate: string; // 결선완료일
status: 'in_progress' | 'completed'; // 상태
memo: string; // 메모
}
// 프로젝트 종료 상태 옵션
export const PROJECT_END_STATUS_OPTIONS: SelectOption[] = [
{ value: 'in_progress', label: '프로젝트 진행' },
{ value: 'completed', label: '프로젝트 완료' },
];
// ============================================
// 상세 컬럼 아코디언 구조 타입
// ============================================
// 상세 카테고리 타입 (시공, 이슈 등)
export type DetailCategoryType = 'construction' | 'issue';
// 상세 카테고리 라벨
export const DETAIL_CATEGORY_LABELS: Record<DetailCategoryType, string> = {
construction: '시공',
issue: '이슈',
};
// 시공 상태
export type ConstructionStatus = 'in_progress' | 'completed';
// 시공 상태 라벨
export const CONSTRUCTION_STATUS_LABELS: Record<ConstructionStatus, string> = {
in_progress: '시공진행',
completed: '시공완료',
};
// 이슈 상태
export type IssueStatus = 'open' | 'resolved';
// 이슈 상태 라벨
export const ISSUE_STATUS_LABELS: Record<IssueStatus, string> = {
open: '미해결',
resolved: '해결완료',
};
// 시공 상세 항목
export interface ConstructionItem {
id: string;
number: string; // 번호 (123123)
inputDate: string; // 시공투입일
status: ConstructionStatus;
}
// 이슈 상세 항목
export interface IssueItem {
id: string;
number: string; // 번호
title: string; // 이슈 제목
status: IssueStatus;
createdAt: string;
}
// 상세 카테고리 데이터
export interface DetailCategory {
type: DetailCategoryType;
count: number;
constructionItems?: ConstructionItem[];
issueItems?: IssueItem[];
}
// ============================================
// 시공관리 리스트 페이지 타입
// ============================================
// 시공관리 상태
export type ConstructionManagementStatus = 'in_progress' | 'completed';
// 시공관리 상태 라벨
export const CONSTRUCTION_MANAGEMENT_STATUS_LABELS: Record<ConstructionManagementStatus, string> = {
in_progress: '시공진행',
completed: '시공완료',
};
// 시공관리 상태 스타일
export const CONSTRUCTION_MANAGEMENT_STATUS_STYLES: Record<ConstructionManagementStatus, string> = {
in_progress: 'bg-yellow-100 text-yellow-800',
completed: 'bg-green-100 text-green-800',
};
// 시공관리 리스트 아이템
export interface ConstructionManagement {
id: string;
constructionNumber: string; // 시공번호
partnerName: string; // 거래처
siteName: string; // 현장명
constructionPM: string; // 공사PM
workTeamLeader: string; // 작업반장
worker: string; // 작업자
workerCount: number; // 작업자 인원수
constructionStartDate: string; // 시공투입일
constructionEndDate: string | null; // 시공완료일
status: ConstructionManagementStatus;
periodStart: string; // 달력용 시작일
periodEnd: string; // 달력용 종료일
createdAt: string;
updatedAt: string;
}
// 시공관리 통계
export interface ConstructionManagementStats {
total: number;
inProgress: number;
completed: number;
}
// 시공관리 필터
export interface ConstructionManagementFilter {
partners?: string[]; // 거래처 (다중선택)
sites?: string[]; // 현장명 (다중선택)
constructionPMs?: string[]; // 공사PM (다중선택)
workTeamLeaders?: string[]; // 작업반장 (다중선택)
status?: string; // 상태 (단일선택)
sortBy?: string; // 정렬
startDate?: string;
endDate?: string;
page?: number;
size?: number;
}
// 시공관리 리스트 응답
export interface ConstructionManagementListResponse {
items: ConstructionManagement[];
total: number;
page: number;
size: number;
totalPages: number;
}
// 시공관리 상태 옵션
export const CONSTRUCTION_MANAGEMENT_STATUS_OPTIONS: SelectOption[] = [
{ value: 'all', label: '전체' },
{ value: 'in_progress', label: '시공중' },
{ value: 'completed', label: '완료' },
];
// 시공관리 정렬 옵션
export const CONSTRUCTION_MANAGEMENT_SORT_OPTIONS: SelectOption[] = [
{ value: 'latest', label: '최신순' },
{ value: 'register', label: '등록순' },
{ value: 'completionDateDesc', label: '시공완료일 최신순' },
{ value: 'partnerNameAsc', label: '거래처명 오름차' },
{ value: 'partnerNameDesc', label: '거래처명 내림차' },
];
// 시공관리 목업 거래처 목록
export const MOCK_CM_PARTNERS: SelectOption[] = [
{ value: 'partner1', label: '대한건설' },
{ value: 'partner2', label: '삼성시공' },
{ value: 'partner3', label: 'LG건설' },
{ value: 'partner4', label: '현대건설' },
{ value: 'partner5', label: 'SK건설' },
];
// 시공관리 목업 현장 목록
export const MOCK_CM_SITES: SelectOption[] = [
{ value: 'site1', label: '서울 강남 현장' },
{ value: 'site2', label: '부산 해운대 현장' },
{ value: 'site3', label: '대전 유성 현장' },
{ value: 'site4', label: '인천 송도 현장' },
{ value: 'site5', label: '광주 북구 현장' },
];
// 시공관리 목업 공사PM 목록
export const MOCK_CM_CONSTRUCTION_PM: SelectOption[] = [
{ value: 'pm1', label: '김철수' },
{ value: 'pm2', label: '박민수' },
{ value: 'pm3', label: '정대리' },
{ value: 'pm4', label: '윤대리' },
{ value: 'pm5', label: '오차장' },
];
// 시공관리 목업 작업반장 목록
export const MOCK_CM_WORK_TEAM_LEADERS: SelectOption[] = [
{ value: 'leader1', label: '이반장' },
{ value: 'leader2', label: '김반장' },
{ value: 'leader3', label: '박반장' },
{ value: 'leader4', label: '최반장' },
{ value: 'leader5', label: '정반장' },
];
// 시공관리 달력 색상 (작업반장별)
export const getConstructionScheduleColor = (workTeamLeader: string): string => {
const colorMap: Record<string, string> = {
'이반장': '#3B82F6', // blue
'김반장': '#EF4444', // red
'박반장': '#22C55E', // green
'최반장': '#F59E0B', // amber
'정반장': '#8B5CF6', // purple
};
return colorMap[workTeamLeader] || '#6B7280'; // 기본 gray
};
// ============================================
// 시공 상세 페이지 타입
// ============================================
// 작업자 정보
export interface WorkerInfo {
id: string;
workDate: string; // 작업일
workers: string[]; // 작업자 목록 (다중선택)
}
// 공과 정보
export interface WorkProgressInfo {
id: string;
scheduleDate: string; // 일정 (날짜+시간)
workName: string; // 공과명
}
// 사진 정보
export interface PhotoInfo {
id: string;
url: string;
name: string;
uploadedAt: string;
}
// 시공 상세 데이터
export interface ConstructionManagementDetail {
id: string;
constructionNumber: string; // 시공번호
siteName: string; // 현장
constructionStartDate: string; // 시공투입일
constructionEndDate: string | null; // 시공완료일
workTeamLeader: string; // 작업반장
status: ConstructionManagementStatus;
// 작업자 정보 (동적 테이블)
workerInfoList: WorkerInfo[];
// 공과 정보 (동적 테이블)
workProgressList: WorkProgressInfo[];
// 발주서 정보
orderNumber: string; // 발주번호
orderId: string; // 발주 ID (팝업용)
// 이슈 정보
issueCount: number; // 이슈 건수
// 작업일지
workLogContent: string; // 작업일지 내용
// 사진
photos: PhotoInfo[];
// 이슈 보고 체크 여부
isIssueReported: boolean;
createdAt: string;
updatedAt: string;
}
// 시공 상세 폼 데이터 (수정용)
export interface ConstructionDetailFormData {
workTeamLeader: string;
workerInfoList: WorkerInfo[];
workProgressList: WorkProgressInfo[];
workLogContent: string;
photos: PhotoInfo[];
isIssueReported: boolean;
}
// 목업 사원 목록 (작업자 선택용)
export const MOCK_EMPLOYEES: SelectOption[] = [
{ value: 'emp1', label: '홍길동' },
{ value: 'emp2', label: '김영희' },
{ value: 'emp3', label: '이철수' },
{ value: 'emp4', label: '박민수' },
{ value: 'emp5', label: '정대리' },
{ value: 'emp6', label: '최과장' },
{ value: 'emp7', label: '윤부장' },
{ value: 'emp8', label: '오차장' },
];

View File

@@ -8,6 +8,7 @@ import type { OrderDetail } from './types';
import { useOrderDetailForm } from './hooks/useOrderDetailForm';
import { OrderInfoCard } from './cards/OrderInfoCard';
import { ContractInfoCard } from './cards/ContractInfoCard';
import { ConstructionDetailCard } from './cards/ConstructionDetailCard';
import { OrderScheduleCard } from './cards/OrderScheduleCard';
import { OrderMemoCard } from './cards/OrderMemoCard';
import { OrderDetailItemTable } from './tables/OrderDetailItemTable';
@@ -159,6 +160,13 @@ export default function OrderDetailForm({
onFieldChange={handleFieldChange}
/>
{/* 시공 상세 */}
<ConstructionDetailCard
formData={formData}
isViewMode={isViewMode}
onFieldChange={handleFieldChange}
/>
{/* 발주 스케줄 (달력) */}
<OrderScheduleCard
events={calendarEvents}

View File

@@ -14,7 +14,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox';
import { IntegratedListTemplateV2, TableColumn, StatCard } from '@/components/templates/IntegratedListTemplateV2';
import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
import { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { ScheduleCalendar, ScheduleEvent, DayBadge } from '@/components/common/ScheduleCalendar';
@@ -70,9 +70,8 @@ const tableColumns: TableColumn[] = [
{ key: 'orderType', label: '구분', className: 'w-[80px] text-center' },
{ key: 'item', label: '품목', className: 'w-[80px]' },
{ key: 'quantity', label: '수량', className: 'w-[60px] text-right' },
{ key: 'orderDate', label: '발주일', className: 'w-[90px]' },
{ key: 'plannedDeliveryDate', label: '계획납품일', className: 'w-[90px]' },
{ key: 'actualDeliveryDate', label: '실제납품일', className: 'w-[90px]' },
{ key: 'orderDate', label: '발주일', className: 'w-[90px]' }, { key: 'plannedDeliveryDate', label: '계획인수일', className: 'w-[90px]' },
{ key: 'actualDeliveryDate', label: '실제인수일', className: 'w-[90px]' },
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
];
@@ -568,41 +567,41 @@ export default function OrderManagementListClient({
/>
);
// Stats 카드 데이터
const statsCardsData: StatCard[] = [
{
label: '전체 발주',
value: stats?.total ?? 0,
icon: Package,
iconColor: 'text-blue-600',
onClick: () => setActiveStatTab('all'),
isActive: activeStatTab === 'all',
},
{
label: '발주대기',
value: stats?.waiting ?? 0,
icon: Clock,
iconColor: 'text-yellow-600',
onClick: () => setActiveStatTab('waiting'),
isActive: activeStatTab === 'waiting',
},
{
label: '발주완료',
value: stats?.orderComplete ?? 0,
icon: AlertCircle,
iconColor: 'text-blue-600',
onClick: () => setActiveStatTab('order_complete'),
isActive: activeStatTab === 'order_complete',
},
{
label: '납품완료',
value: stats?.deliveryComplete ?? 0,
icon: CheckCircle,
iconColor: 'text-green-600',
onClick: () => setActiveStatTab('delivery_complete'),
isActive: activeStatTab === 'delivery_complete',
},
];
// Stats 카드 데이터 - 기획서에 없어서 주석 처리
// const statsCardsData: StatCard[] = [
// {
// label: '전체 발주',
// value: stats?.total ?? 0,
// icon: Package,
// iconColor: 'text-blue-600',
// onClick: () => setActiveStatTab('all'),
// isActive: activeStatTab === 'all',
// },
// {
// label: '발주대기',
// value: stats?.waiting ?? 0,
// icon: Clock,
// iconColor: 'text-yellow-600',
// onClick: () => setActiveStatTab('waiting'),
// isActive: activeStatTab === 'waiting',
// },
// {
// label: '발주완료',
// value: stats?.orderComplete ?? 0,
// icon: AlertCircle,
// iconColor: 'text-blue-600',
// onClick: () => setActiveStatTab('order_complete'),
// isActive: activeStatTab === 'order_complete',
// },
// {
// label: '납품완료',
// value: stats?.deliveryComplete ?? 0,
// icon: CheckCircle,
// iconColor: 'text-green-600',
// onClick: () => setActiveStatTab('delivery_complete'),
// isActive: activeStatTab === 'delivery_complete',
// },
// ];
// 필터 옵션들
const partnerOptions: MultiSelectOption[] = useMemo(() => MOCK_PARTNERS, []);
@@ -611,8 +610,82 @@ export default function OrderManagementListClient({
const orderCompanyOptions: MultiSelectOption[] = useMemo(() => MOCK_ORDER_COMPANIES, []);
const orderTypeOptions: MultiSelectOption[] = useMemo(() => ORDER_TYPE_OPTIONS, []);
// 테이블 헤더 액션 (기획서 요구사항)
// 거래처, 현장명, 공사PM, 발주담당자, 발주처, 작업반장, 구분, 상태, 최신순
// ===== filterConfig 기반 통합 필터 시스템 =====
const filterConfig: FilterFieldConfig[] = useMemo(() => [
{ key: 'partners', label: '거래처', type: 'multi', options: partnerOptions },
{ key: 'sites', label: '현장명', type: 'multi', options: siteOptions },
{ key: 'constructionPMs', label: '공사PM', type: 'multi', options: constructionPMOptions },
{ key: 'orderManagers', label: '발주담당자', type: 'multi', options: orderManagerOptions },
{ key: 'orderCompanies', label: '발주처', type: 'multi', options: orderCompanyOptions },
{ key: 'workTeamLeaders', label: '작업반장', type: 'multi', options: workTeamOptions },
{ key: 'orderTypes', label: '구분', type: 'multi', options: orderTypeOptions },
{ key: 'status', label: '상태', type: 'single', options: ORDER_STATUS_OPTIONS.filter(opt => opt.value !== 'all') },
{ key: 'sortBy', label: '정렬', type: 'single', options: ORDER_SORT_OPTIONS, allOptionLabel: '최신순' },
], [partnerOptions, siteOptions, constructionPMOptions, orderManagerOptions, orderCompanyOptions, workTeamOptions, orderTypeOptions]);
// filterValues 객체
const filterValues: FilterValues = useMemo(() => ({
partners: partnerFilters,
sites: siteNameFilters,
constructionPMs: constructionPMFilters,
orderManagers: orderManagerFilters,
orderCompanies: orderCompanyFilters,
workTeamLeaders: workTeamFilters,
orderTypes: orderTypeFilters,
status: statusFilter,
sortBy: sortBy,
}), [partnerFilters, siteNameFilters, constructionPMFilters, orderManagerFilters, orderCompanyFilters, workTeamFilters, orderTypeFilters, statusFilter, sortBy]);
// 필터 변경 핸들러
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
switch (key) {
case 'partners':
setPartnerFilters(value as string[]);
break;
case 'sites':
setSiteNameFilters(value as string[]);
break;
case 'constructionPMs':
setConstructionPMFilters(value as string[]);
break;
case 'orderManagers':
setOrderManagerFilters(value as string[]);
break;
case 'orderCompanies':
setOrderCompanyFilters(value as string[]);
break;
case 'workTeamLeaders':
setWorkTeamFilters(value as string[]);
break;
case 'orderTypes':
setOrderTypeFilters(value as string[]);
break;
case 'status':
setStatusFilter(value as string);
break;
case 'sortBy':
setSortBy(value as string);
break;
}
setCurrentPage(1);
}, []);
// 필터 초기화 핸들러
const handleFilterReset = useCallback(() => {
setPartnerFilters([]);
setSiteNameFilters([]);
setConstructionPMFilters([]);
setOrderManagerFilters([]);
setOrderCompanyFilters([]);
setWorkTeamFilters([]);
setOrderTypeFilters([]);
setStatusFilter('all');
setSortBy('latest');
setCurrentPage(1);
}, []);
// 테이블 헤더 추가 액션 (총건 표시 + 달력 날짜 필터 해제)
// 필터는 filterConfig로 자동 생성됨
const tableHeaderActions = (
<div className="flex items-center gap-2 flex-wrap">
{/* 총건 표시 */}
@@ -625,104 +698,6 @@ export default function OrderManagementListClient({
)}
</span>
{/* 1. 거래처 필터 (다중선택) */}
<MultiSelectCombobox
options={partnerOptions}
value={partnerFilters}
onChange={setPartnerFilters}
placeholder="거래처"
searchPlaceholder="거래처 검색..."
className="w-[120px]"
/>
{/* 2. 현장명 필터 (다중선택) */}
<MultiSelectCombobox
options={siteOptions}
value={siteNameFilters}
onChange={setSiteNameFilters}
placeholder="현장명"
searchPlaceholder="현장명 검색..."
className="w-[140px]"
/>
{/* 3. 공사PM 필터 (다중선택) */}
<MultiSelectCombobox
options={constructionPMOptions}
value={constructionPMFilters}
onChange={setConstructionPMFilters}
placeholder="공사PM"
searchPlaceholder="공사PM 검색..."
className="w-[120px]"
/>
{/* 4. 발주담당자 필터 (다중선택) */}
<MultiSelectCombobox
options={orderManagerOptions}
value={orderManagerFilters}
onChange={setOrderManagerFilters}
placeholder="발주담당자"
searchPlaceholder="발주담당자 검색..."
className="w-[120px]"
/>
{/* 5. 발주처 필터 (다중선택) */}
<MultiSelectCombobox
options={orderCompanyOptions}
value={orderCompanyFilters}
onChange={setOrderCompanyFilters}
placeholder="발주처"
searchPlaceholder="발주처 검색..."
className="w-[100px]"
/>
{/* 6. 작업반장 필터 (다중선택) */}
<MultiSelectCombobox
options={workTeamOptions}
value={workTeamFilters}
onChange={setWorkTeamFilters}
placeholder="작업반장"
searchPlaceholder="작업반장 검색..."
className="w-[110px]"
/>
{/* 7. 구분 필터 (다중선택) */}
<MultiSelectCombobox
options={orderTypeOptions}
value={orderTypeFilters}
onChange={setOrderTypeFilters}
placeholder="구분"
searchPlaceholder="구분 검색..."
className="w-[100px]"
/>
{/* 8. 상태 필터 (단일선택) */}
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{ORDER_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 9. 최신순 필터 (단일선택) */}
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
{ORDER_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 달력 날짜 필터 초기화 */}
{selectedCalendarDate && (
<Button
@@ -767,7 +742,13 @@ export default function OrderManagementListClient({
description="발주 스케줄 및 목록을 관리합니다"
icon={Package}
headerActions={headerActions}
stats={statsCardsData}
// stats={statsCardsData} // 기획서에 없어서 주석 처리
// 통합 필터 시스템 - PC는 인라인, 모바일은 바텀시트 자동 분기
filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="발주 필터"
tableHeaderActions={tableHeaderActions}
searchValue={searchValue}
onSearchChange={handleSearchChange}
@@ -802,7 +783,7 @@ export default function OrderManagementListClient({
onMonthChange={handleCalendarMonthChange}
titleSlot="발주 스케줄"
filterSlot={calendarFilterSlot}
maxEventsPerDay={3}
maxEventsPerDay={5}
weekStartsOn={0}
isLoading={isLoading}
/>

View File

@@ -0,0 +1,85 @@
'use client';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { OrderDetailFormData } from '../types';
import { MOCK_WORK_TEAM_LEADERS } from '../types';
interface ConstructionDetailCardProps {
formData: OrderDetailFormData;
isViewMode: boolean;
onFieldChange: (field: keyof OrderDetailFormData, value: string) => void;
}
export function ConstructionDetailCard({
formData,
isViewMode,
onFieldChange,
}: ConstructionDetailCardProps) {
return (
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 작업반장 */}
<div className="space-y-2">
<Label></Label>
<Select
value={formData.workTeamLeader}
onValueChange={(value) => onFieldChange('workTeamLeader', value)}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{MOCK_WORK_TEAM_LEADERS.map((option) => (
<SelectItem key={option.value} value={option.label}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 시공투입일 ~ 시공완료일 */}
<div className="space-y-2">
<Label>
<span className="text-destructive ml-1">*</span>
<span className="mx-2">~</span>
</Label>
<div className="flex items-center gap-2">
<Input
type="date"
value={formData.constructionStartDate}
onChange={(e) => onFieldChange('constructionStartDate', e.target.value)}
disabled={isViewMode}
required
className="flex-1"
/>
<span className="text-muted-foreground">~</span>
<Input
type="date"
value={formData.constructionEndDate}
onChange={(e) => onFieldChange('constructionEndDate', e.target.value)}
disabled={isViewMode}
className="flex-1"
/>
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -40,7 +40,7 @@ export function ContractInfoCard({
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* 거래처명 */}
<div className="space-y-2">
<Label></Label>
<Label><span className="text-destructive ml-1">*</span></Label>
<Select
value={formData.partnerId}
onValueChange={(value) => {
@@ -67,7 +67,7 @@ export function ContractInfoCard({
{/* 현장명 */}
<div className="space-y-2">
<Label></Label>
<Label><span className="text-destructive ml-1">*</span></Label>
<Input
value={formData.siteName}
onChange={(e) => onFieldChange('siteName', e.target.value)}
@@ -77,7 +77,7 @@ export function ContractInfoCard({
{/* 계약번호 */}
<div className="space-y-2">
<Label></Label>
<Label><span className="text-destructive ml-1">*</span></Label>
<Input
value={formData.contractNumber}
onChange={(e) => onFieldChange('contractNumber', e.target.value)}
@@ -87,7 +87,7 @@ export function ContractInfoCard({
{/* 공사PM */}
<div className="space-y-2">
<Label>PM</Label>
<Label>PM<span className="text-destructive ml-1">*</span></Label>
<Select
value={formData.constructionPMId}
onValueChange={(value) => {
@@ -114,7 +114,7 @@ export function ContractInfoCard({
{/* 공사담당자 */}
<div className="space-y-2 md:col-span-2 lg:col-span-4">
<Label></Label>
<Label><span className="text-destructive ml-1">*</span></Label>
<div className="flex flex-wrap gap-2">
{formData.constructionManagers.map((manager, index) => (
<div key={index} className="flex items-center gap-1">

View File

@@ -34,7 +34,7 @@ export function OrderInfoCard({ formData, isViewMode, onFieldChange }: OrderInfo
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* 발주번호 */}
<div className="space-y-2">
<Label></Label>
<Label><span className="text-destructive ml-1">*</span></Label>
<Input
value={formData.orderNumber}
onChange={(e) => onFieldChange('orderNumber', e.target.value)}
@@ -44,7 +44,7 @@ export function OrderInfoCard({ formData, isViewMode, onFieldChange }: OrderInfo
{/* 발주일 (발주처) */}
<div className="space-y-2">
<Label></Label>
<Label><span className="text-destructive ml-1">*</span></Label>
<Select
value={formData.orderCompanyId}
onValueChange={(value) => onFieldChange('orderCompanyId', value)}
@@ -65,7 +65,7 @@ export function OrderInfoCard({ formData, isViewMode, onFieldChange }: OrderInfo
{/* 구분 */}
<div className="space-y-2">
<Label></Label>
<Label><span className="text-destructive ml-1">*</span></Label>
<Select
value={formData.orderType}
onValueChange={(value) => onFieldChange('orderType', value as OrderType)}
@@ -86,7 +86,7 @@ export function OrderInfoCard({ formData, isViewMode, onFieldChange }: OrderInfo
{/* 상태 */}
<div className="space-y-2">
<Label></Label>
<Label><span className="text-destructive ml-1">*</span></Label>
<Select
value={formData.status}
onValueChange={(value) => onFieldChange('status', value as OrderStatus)}
@@ -107,7 +107,7 @@ export function OrderInfoCard({ formData, isViewMode, onFieldChange }: OrderInfo
{/* 발주담당자 */}
<div className="space-y-2">
<Label></Label>
<Label><span className="text-destructive ml-1">*</span></Label>
<Select
value={formData.orderManager}
onValueChange={(value) => onFieldChange('orderManager', value)}
@@ -128,7 +128,7 @@ export function OrderInfoCard({ formData, isViewMode, onFieldChange }: OrderInfo
{/* 화물도착지 */}
<div className="space-y-2">
<Label></Label>
<Label><span className="text-destructive ml-1">*</span></Label>
<Input
value={formData.deliveryAddress}
onChange={(e) => onFieldChange('deliveryAddress', e.target.value)}

View File

@@ -32,7 +32,7 @@ export function OrderScheduleCard({
onDateClick={onDateClick}
onEventClick={() => {}}
onMonthChange={onMonthChange}
maxEventsPerDay={3}
maxEventsPerDay={5}
weekStartsOn={0}
isLoading={false}
/>

View File

@@ -125,7 +125,7 @@ export function OrderDocumentModal({
{/* 기본 정보 테이블 */}
<table className="w-full border-collapse border border-gray-300 text-sm mb-8">
<tbody>
{/* 출고일 / 작업 */}
{/* 출고일 / 작업반장 */}
<tr>
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-center w-28 font-medium">
@@ -133,32 +133,32 @@ export function OrderDocumentModal({
<td className="border border-gray-300 px-4 py-3">
{formatDate(order.plannedDeliveryDate)}
</td>
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-center w-28 font-medium">
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-center w-32 font-medium">
</th>
<td className="border border-gray-300 px-4 py-3">
{order.workTeamLeader || '-'}
</td>
</tr>
{/* 현장명 / 연락처 */}
{/* 현장명 / 작업반장 연락처 */}
<tr>
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-center font-medium">
</th>
<td className="border border-gray-300 px-4 py-3">{order.siteName || '-'}</td>
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-center font-medium">
</th>
<td className="border border-gray-300 px-4 py-3">-</td>
</tr>
{/* 화물 도착지 / 발주담당자 */}
<tr>
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-center font-medium">
<th rowSpan={2} className="border border-gray-300 px-4 py-3 bg-gray-50 text-center font-medium align-middle">
</th>
<td className="border border-gray-300 px-4 py-3">{order.deliveryAddress || '-'}</td>
<td rowSpan={2} className="border border-gray-300 px-4 py-3 align-middle">{order.deliveryAddress || '-'}</td>
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-center font-medium">
</th>
@@ -166,6 +166,14 @@ export function OrderDocumentModal({
{order.orderManager || '-'}
</td>
</tr>
{/* 발주담당자 연락처 */}
<tr>
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-center font-medium">
</th>
<td className="border border-gray-300 px-4 py-3">-</td>
</tr>
</tbody>
</table>

View File

@@ -166,8 +166,8 @@ export function OrderDetailItemTable({
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[60px] text-center"></TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
</TableRow>
</TableHeader>

View File

@@ -48,9 +48,9 @@ export interface Order {
quantity: number;
/** 발주일 */
orderDate: string;
/** 계획납품일 */
/** 계획인수일 */
plannedDeliveryDate: string;
/** 실제 납품일 */
/** 실제 인수일 */
actualDeliveryDate: string | null;
/** 상태 */
status: OrderStatus;
@@ -74,9 +74,9 @@ export interface OrderStats {
waiting: number;
/** 발주완료 */
orderComplete: number;
/** 납품예정 */
/** 인수예정 */
deliveryScheduled: number;
/** 납품완료 */
/** 인수완료 */
deliveryComplete: number;
}
@@ -87,8 +87,8 @@ export const ORDER_STATUS_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: 'waiting', label: '발주대기' },
{ value: 'order_complete', label: '발주완료' },
{ value: 'delivery_scheduled', label: '납품예정' },
{ value: 'delivery_complete', label: '납품완료' },
{ value: 'delivery_scheduled', label: '인수예정' },
{ value: 'delivery_complete', label: '인수완료' },
] as const;
/**
@@ -97,8 +97,8 @@ export const ORDER_STATUS_OPTIONS = [
export const ORDER_STATUS_LABELS: Record<OrderStatus, string> = {
waiting: '발주대기',
order_complete: '발주완료',
delivery_scheduled: '납품예정',
delivery_complete: '납품완료',
delivery_scheduled: '인수예정',
delivery_complete: '인수완료',
};
/**
@@ -210,8 +210,8 @@ export const ORDER_SORT_OPTIONS = [
{ value: 'partnerNameDesc', label: '거래처명 ↓' },
{ value: 'siteNameAsc', label: '현장명 ↑' },
{ value: 'siteNameDesc', label: '현장명 ↓' },
{ value: 'deliveryDateAsc', label: '납품일 ↑' },
{ value: 'deliveryDateDesc', label: '납품일 ↓' },
{ value: 'deliveryDateAsc', label: '인수일 ↑' },
{ value: 'deliveryDateDesc', label: '인수일 ↓' },
] as const;
/**
@@ -322,9 +322,9 @@ export interface OrderDetailItem {
imageUrl: string;
/** 발주일 */
orderDate: string;
/** 계획 납품일 */
/** 계획 인수일 */
plannedDeliveryDate: string;
/** 실제 납품일 */
/** 실제 인수일 */
actualDeliveryDate: string;
/** 상태 */
status: OrderStatus;
@@ -412,6 +412,12 @@ export interface OrderDetailFormData {
constructionPM: string;
/** 공사담당자 목록 */
constructionManagers: string[];
/** 작업반장 */
workTeamLeader: string;
/** 시공투입일 (필수) */
constructionStartDate: string;
/** 시공완료일 */
constructionEndDate: string;
/** 발주 상세 카테고리 목록 */
orderCategories: OrderDetailCategory[];
/** 비고 */
@@ -518,6 +524,9 @@ export function getEmptyOrderDetailFormData(): OrderDetailFormData {
constructionPMId: '',
constructionPM: '',
constructionManagers: [],
workTeamLeader: '',
constructionStartDate: '',
constructionEndDate: '',
orderCategories: [],
memo: '',
periodStart: '',
@@ -563,6 +572,9 @@ export function orderDetailToFormData(detail: OrderDetail): OrderDetailFormData
constructionPMId: detail.constructionPMId,
constructionPM: detail.constructionPM,
constructionManagers: detail.constructionManagers,
workTeamLeader: detail.workTeamLeader,
constructionStartDate: detail.constructionStartDate,
constructionEndDate: '', // Order 인터페이스에는 없으므로 빈 값
orderCategories: Array.from(categoryMap.values()),
memo: detail.memo,
periodStart: detail.periodStart,

View File

@@ -14,7 +14,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { IntegratedListTemplateV2, TabOption, TableColumn } from '@/components/templates/IntegratedListTemplateV2';
import { IntegratedListTemplateV2, TabOption, TableColumn, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
import { MobileCard } from '@/components/molecules/MobileCard';
import { toast } from 'sonner';
import {
@@ -375,6 +375,54 @@ export default function PartnerListClient({ initialData = [], initialStats }: Pa
[handleRowClick]
);
// ===== filterConfig 기반 통합 필터 시스템 =====
const filterConfig: FilterFieldConfig[] = useMemo(() => [
{
key: 'badDebt',
label: '악성채권',
type: 'single',
options: [
{ value: 'badDebt', label: '악성채권' },
{ value: 'normal', label: '정상' },
],
},
{
key: 'sortBy',
label: '정렬',
type: 'single',
options: [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
{ value: 'nameAsc', label: '이름 오름차순' },
{ value: 'nameDesc', label: '이름 내림차순' },
],
allOptionLabel: '최신순',
},
], []);
const filterValues: FilterValues = useMemo(() => ({
badDebt: badDebtFilter,
sortBy: sortBy,
}), [badDebtFilter, sortBy]);
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
switch (key) {
case 'badDebt':
setBadDebtFilter(value as 'all' | 'badDebt' | 'normal');
break;
case 'sortBy':
setSortBy(value as 'latest' | 'oldest' | 'nameAsc' | 'nameDesc');
break;
}
setCurrentPage(1);
}, []);
const handleFilterReset = useCallback(() => {
setBadDebtFilter('all');
setSortBy('latest');
setCurrentPage(1);
}, []);
// 헤더 액션 (등록 버튼만)
const headerActions = (
<div className="flex items-center justify-end w-full">
@@ -445,6 +493,11 @@ export default function PartnerListClient({ initialData = [], initialStats }: Pa
activeTab={activeTab}
onTabChange={handleTabChange}
tableHeaderActions={tableHeaderActions}
filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="거래처 필터"
searchValue={searchValue}
onSearchChange={handleSearchChange}
searchPlaceholder="거래처명, 거래처번호, 대표자 검색"

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { DollarSign, List } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -51,8 +51,6 @@ interface FormData {
unit: string;
division: string;
vendor: string;
purchasePrice: number;
marginRate: number;
sellingPrice: number;
status: PricingStatus;
note: string;
@@ -66,8 +64,6 @@ const initialFormData: FormData = {
unit: '',
division: '',
vendor: '',
purchasePrice: 0,
marginRate: 0,
sellingPrice: 0,
status: 'in_use',
note: '',
@@ -109,8 +105,6 @@ export default function PricingDetailClient({ id, mode }: PricingDetailClientPro
unit: result.data.unit,
division: result.data.division,
vendor: result.data.vendor,
purchasePrice: result.data.purchasePrice,
marginRate: result.data.marginRate,
sellingPrice: result.data.sellingPrice,
status: result.data.status,
note: '',
@@ -130,29 +124,12 @@ export default function PricingDetailClient({ id, mode }: PricingDetailClientPro
loadData();
}, [id, mode, isViewMode, isEditMode, router]);
// 판매단가 자동 계산: 매입단가 * (1 + 마진율/100)
const calculatedSellingPrice = useMemo(() => {
const price = formData.purchasePrice * (1 + formData.marginRate / 100);
return Math.round(price);
}, [formData.purchasePrice, formData.marginRate]);
// 매입단가 변경
const handlePurchasePriceChange = useCallback((value: string) => {
// 판매단가 변경
const handleSellingPriceChange = useCallback((value: string) => {
const numValue = parseFloat(value) || 0;
setFormData((prev) => ({
...prev,
purchasePrice: numValue,
sellingPrice: Math.round(numValue * (1 + prev.marginRate / 100)),
}));
}, []);
// 마진율 변경
const handleMarginRateChange = useCallback((value: string) => {
const numValue = parseFloat(value) || 0;
setFormData((prev) => ({
...prev,
marginRate: numValue,
sellingPrice: Math.round(prev.purchasePrice * (1 + numValue / 100)),
sellingPrice: numValue,
}));
}, []);
@@ -185,9 +162,7 @@ export default function PricingDetailClient({ id, mode }: PricingDetailClientPro
unit: formData.unit,
division: formData.division,
vendor: formData.vendor,
purchasePrice: formData.purchasePrice,
marginRate: formData.marginRate,
sellingPrice: calculatedSellingPrice,
sellingPrice: formData.sellingPrice,
status: formData.status,
});
@@ -200,9 +175,7 @@ export default function PricingDetailClient({ id, mode }: PricingDetailClientPro
} else if (isEditMode && id) {
const result = await updatePricing(id, {
vendor: formData.vendor,
purchasePrice: formData.purchasePrice,
marginRate: formData.marginRate,
sellingPrice: calculatedSellingPrice,
sellingPrice: formData.sellingPrice,
status: formData.status,
});
@@ -218,7 +191,7 @@ export default function PricingDetailClient({ id, mode }: PricingDetailClientPro
} finally {
setIsLoading(false);
}
}, [isCreateMode, isEditMode, id, formData, calculatedSellingPrice, router]);
}, [isCreateMode, isEditMode, id, formData, router]);
// 삭제
const handleDelete = useCallback(async () => {
@@ -379,7 +352,7 @@ export default function PricingDetailClient({ id, mode }: PricingDetailClientPro
</div>
</div>
{/* 거래처 / 매단가 */}
{/* 거래처 / 매단가 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
@@ -400,48 +373,17 @@ export default function PricingDetailClient({ id, mode }: PricingDetailClientPro
</Select>
)}
</div>
<div className="space-y-2">
<Label></Label>
{isViewMode ? (
<Input value={formatNumber(formData.purchasePrice)} disabled />
) : (
<Input
type="number"
value={formData.purchasePrice}
onChange={(e) => handlePurchasePriceChange(e.target.value)}
placeholder="매입단가 입력"
/>
)}
</div>
</div>
{/* 마진율 / 판매단가 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label> (%)</Label>
{isViewMode ? (
<Input value={`${formData.marginRate}%`} disabled />
) : (
<Input
type="number"
step="0.1"
value={formData.marginRate}
onChange={(e) => handleMarginRateChange(e.target.value)}
placeholder="마진율 입력"
/>
)}
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={formatNumber(isViewMode ? formData.sellingPrice : calculatedSellingPrice)}
disabled
className="bg-muted"
/>
{!isViewMode && (
<p className="text-xs text-muted-foreground">
× (1 + ) =
</p>
{isViewMode ? (
<Input value={formatNumber(formData.sellingPrice)} disabled />
) : (
<Input
type="number"
value={formData.sellingPrice}
onChange={(e) => handleSellingPriceChange(e.target.value)}
placeholder="판매단가 입력"
/>
)}
</div>
</div>

View File

@@ -7,14 +7,7 @@ import { Button } from '@/components/ui/button';
import { TableCell, TableRow, TableHead } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { IntegratedListTemplateV2, StatCard } from '@/components/templates/IntegratedListTemplateV2';
import { IntegratedListTemplateV2, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
import { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { toast } from 'sonner';
@@ -325,8 +318,6 @@ export default function PricingListClient({
<TableHead className="w-[60px]"></TableHead>
<TableHead className="w-[60px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[100px] text-right"></TableHead>
<TableHead className="w-[70px] text-right"></TableHead>
<TableHead className="w-[100px] text-right"></TableHead>
<TableHead className="w-[70px] text-center"></TableHead>
{selectedItems.size > 0 && (
@@ -368,8 +359,6 @@ export default function PricingListClient({
<TableCell>{pricing.unit}</TableCell>
<TableCell>{pricing.division}</TableCell>
<TableCell>{pricing.vendor}</TableCell>
<TableCell className="text-right">{formatNumber(pricing.purchasePrice)}</TableCell>
<TableCell className="text-right">{pricing.marginRate}%</TableCell>
<TableCell className="text-right">{formatNumber(pricing.sellingPrice)}</TableCell>
<TableCell className="text-center">
<Badge className={PRICING_STATUS_STYLES[pricing.status]}>
@@ -473,98 +462,112 @@ export default function PricingListClient({
},
];
// 테이블 헤더 액션 (필터 6개)
const tableHeaderActions = (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-muted-foreground whitespace-nowrap">
{sortedPricing.length}
</span>
// ===== filterConfig 기반 통합 필터 시스템 =====
const filterConfig: FilterFieldConfig[] = useMemo(() => [
{
key: 'itemType',
label: '품목유형',
type: 'single',
options: ITEM_TYPE_OPTIONS.filter(o => o.value !== 'all').map(o => ({
value: o.value,
label: o.label,
})),
allOptionLabel: '전체',
},
{
key: 'category',
label: '카테고리',
type: 'single',
options: CATEGORY_OPTIONS.filter(o => o.value !== 'all').map(o => ({
value: o.value,
label: o.label,
})),
allOptionLabel: '전체',
},
{
key: 'spec',
label: '규격',
type: 'single',
options: SPEC_OPTIONS.filter(o => o.value !== 'all').map(o => ({
value: o.value,
label: o.label,
})),
allOptionLabel: '전체',
},
{
key: 'division',
label: '구분',
type: 'single',
options: DIVISION_OPTIONS.filter(o => o.value !== 'all').map(o => ({
value: o.value,
label: o.label,
})),
allOptionLabel: '전체',
},
{
key: 'status',
label: '상태',
type: 'single',
options: STATUS_OPTIONS.filter(o => o.value !== 'all').map(o => ({
value: o.value,
label: o.label,
})),
allOptionLabel: '전체',
},
{
key: 'sortBy',
label: '정렬',
type: 'single',
options: SORT_OPTIONS.map(o => ({
value: o.value,
label: o.label,
})),
allOptionLabel: '최신순',
},
], []);
{/* 품목유형 필터 */}
<Select value={itemTypeFilter} onValueChange={setItemTypeFilter}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="품목유형" />
</SelectTrigger>
<SelectContent>
{ITEM_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
const filterValues: FilterValues = useMemo(() => ({
itemType: itemTypeFilter,
category: categoryFilter,
spec: specFilter,
division: divisionFilter,
status: statusFilter,
sortBy: sortBy,
}), [itemTypeFilter, categoryFilter, specFilter, divisionFilter, statusFilter, sortBy]);
{/* 카테고리 필터 */}
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="카테고리" />
</SelectTrigger>
<SelectContent>
{CATEGORY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
switch (key) {
case 'itemType':
setItemTypeFilter(value as string);
break;
case 'category':
setCategoryFilter(value as string);
break;
case 'spec':
setSpecFilter(value as string);
break;
case 'division':
setDivisionFilter(value as string);
break;
case 'status':
setStatusFilter(value as string);
break;
case 'sortBy':
setSortBy(value as string);
break;
}
setCurrentPage(1);
}, []);
{/* 규격 필터 */}
<Select value={specFilter} onValueChange={setSpecFilter}>
<SelectTrigger className="w-[90px]">
<SelectValue placeholder="규격" />
</SelectTrigger>
<SelectContent>
{SPEC_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 구분 필터 */}
<Select value={divisionFilter} onValueChange={setDivisionFilter}>
<SelectTrigger className="w-[110px]">
<SelectValue placeholder="구분" />
</SelectTrigger>
<SelectContent>
{DIVISION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 상태 필터 */}
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[90px]">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 정렬 */}
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[110px]">
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
const handleFilterReset = useCallback(() => {
setItemTypeFilter('all');
setCategoryFilter('all');
setSpecFilter('all');
setDivisionFilter('all');
setStatusFilter('all');
setSortBy('latest');
setCurrentPage(1);
}, []);
// 동적 컬럼으로 인해 tableColumns를 사용하지 않고 커스텀 헤더 사용
const emptyTableColumns: { key: string; label: string; className?: string }[] = [];
@@ -577,7 +580,11 @@ export default function PricingListClient({
icon={DollarSign}
headerActions={headerActions}
stats={statsCardsData}
tableHeaderActions={tableHeaderActions}
filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="단가 필터"
searchValue={searchValue}
onSearchChange={handleSearchChange}
searchPlaceholder="단가번호, 품목명, 카테고리, 거래처 검색"

View File

@@ -0,0 +1,246 @@
'use client';
import { FileText, List, Eye, Edit } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { ProgressBillingDetail } from './types';
import { useProgressBillingDetailForm } from './hooks/useProgressBillingDetailForm';
import { ProgressBillingInfoCard } from './cards/ProgressBillingInfoCard';
import { ContractInfoCard } from './cards/ContractInfoCard';
import { ProgressBillingItemTable } from './tables/ProgressBillingItemTable';
import { PhotoTable } from './tables/PhotoTable';
import { DirectConstructionModal } from './modals/DirectConstructionModal';
import { IndirectConstructionModal } from './modals/IndirectConstructionModal';
import { PhotoDocumentModal } from './modals/PhotoDocumentModal';
interface ProgressBillingDetailFormProps {
mode: 'view' | 'edit';
billingId: string;
initialData?: ProgressBillingDetail;
}
export default function ProgressBillingDetailForm({
mode,
billingId,
initialData,
}: ProgressBillingDetailFormProps) {
const {
// Mode flags
isViewMode,
isEditMode,
// Form data
formData,
// Loading state
isLoading,
// Dialog states
showSaveDialog,
setShowSaveDialog,
showDeleteDialog,
setShowDeleteDialog,
// Modal states
showDirectConstructionModal,
setShowDirectConstructionModal,
showIndirectConstructionModal,
setShowIndirectConstructionModal,
showPhotoDocumentModal,
setShowPhotoDocumentModal,
// Selection states
selectedBillingItems,
selectedPhotoItems,
// Navigation handlers
handleBack,
handleEdit,
handleCancel,
// Form handlers
handleFieldChange,
// CRUD handlers
handleSave,
handleConfirmSave,
handleDelete,
handleConfirmDelete,
// Billing item handlers
handleBillingItemChange,
handleToggleBillingItemSelection,
handleToggleSelectAllBillingItems,
handleApplySelectedBillingItems,
// Photo item handlers
handleTogglePhotoItemSelection,
handleToggleSelectAllPhotoItems,
handleApplySelectedPhotoItems,
handlePhotoSelect,
// Modal handlers
handleViewDirectConstruction,
handleViewIndirectConstruction,
handleViewPhotoDocument,
} = useProgressBillingDetailForm({ mode, billingId, initialData });
// 헤더 액션 버튼
const headerActions = isViewMode ? (
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleBack}>
<List className="h-4 w-4 mr-2" />
</Button>
<Button variant="outline" onClick={handleViewDirectConstruction}>
<Eye className="h-4 w-4 mr-2" />
</Button>
<Button variant="outline" onClick={handleViewIndirectConstruction}>
<Eye className="h-4 w-4 mr-2" />
</Button>
<Button variant="outline" onClick={handleViewPhotoDocument}>
<Eye className="h-4 w-4 mr-2" />
</Button>
<Button onClick={handleEdit}>
<Edit className="h-4 w-4 mr-2" />
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleBack}>
<List className="h-4 w-4 mr-2" />
</Button>
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleSave} disabled={isLoading}>
</Button>
</div>
);
return (
<PageLayout>
<PageHeader
title="기성청구 상세"
description="기성청구를 등록하고 관리합니다"
icon={FileText}
onBack={handleBack}
actions={headerActions}
/>
<div className="space-y-6">
{/* 기성청구 정보 */}
<ProgressBillingInfoCard
formData={formData}
isViewMode={isViewMode}
onFieldChange={handleFieldChange}
/>
{/* 계약 정보 */}
<ContractInfoCard formData={formData} />
{/* 기성청구 내역 */}
<ProgressBillingItemTable
items={formData.billingItems}
isViewMode={isViewMode}
isEditMode={isEditMode}
selectedItems={selectedBillingItems}
onToggleSelection={handleToggleBillingItemSelection}
onToggleSelectAll={handleToggleSelectAllBillingItems}
onApplySelected={handleApplySelectedBillingItems}
onItemChange={handleBillingItemChange}
/>
{/* 사진대지 */}
<PhotoTable
items={formData.photoItems}
isViewMode={isViewMode}
isEditMode={isEditMode}
selectedItems={selectedPhotoItems}
onToggleSelection={handleTogglePhotoItemSelection}
onToggleSelectAll={handleToggleSelectAllPhotoItems}
onApplySelected={handleApplySelectedPhotoItems}
onPhotoSelect={handlePhotoSelect}
/>
</div>
{/* 저장 확인 다이얼로그 */}
<AlertDialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmSave} disabled={isLoading}>
{isLoading ? '저장 중...' : '저장'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
disabled={isLoading}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isLoading ? '삭제 중...' : '삭제'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 직접 공사 내역서 모달 */}
<DirectConstructionModal
open={showDirectConstructionModal}
onOpenChange={setShowDirectConstructionModal}
data={formData}
/>
{/* 간접 공사 내역서 모달 */}
<IndirectConstructionModal
open={showIndirectConstructionModal}
onOpenChange={setShowIndirectConstructionModal}
data={formData}
/>
{/* 사진대지 모달 */}
<PhotoDocumentModal
open={showPhotoDocumentModal}
onOpenChange={setShowPhotoDocumentModal}
data={formData}
/>
</PageLayout>
);
}

View File

@@ -0,0 +1,440 @@
'use client';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { FileText, Pencil } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import { MultiSelectOption } from '@/components/ui/multi-select-combobox';
import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
import { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { toast } from 'sonner';
import type { ProgressBilling, ProgressBillingStats } from './types';
import {
PROGRESS_BILLING_STATUS_OPTIONS,
PROGRESS_BILLING_SORT_OPTIONS,
PROGRESS_BILLING_STATUS_STYLES,
PROGRESS_BILLING_STATUS_LABELS,
MOCK_PARTNERS,
MOCK_SITES,
PARTNER_SITES_MAP,
} from './types';
import {
getProgressBillingList,
getProgressBillingStats,
} from './actions';
// 테이블 컬럼 정의
const tableColumns: TableColumn[] = [
{ key: 'no', label: '번호', className: 'w-[50px] text-center' },
{ key: 'billingNumber', label: '기성청구번호', className: 'w-[140px]' },
{ key: 'partnerName', label: '거래처', className: 'w-[120px]' },
{ key: 'siteName', label: '현장명', className: 'min-w-[150px]' },
{ key: 'round', label: '회차', className: 'w-[60px] text-center' },
{ key: 'billingYearMonth', label: '기성청구연월', className: 'w-[110px] text-center' },
{ key: 'previousBilling', label: '전회기성', className: 'w-[120px] text-right' },
{ key: 'currentBilling', label: '금회기성', className: 'w-[120px] text-right' },
{ key: 'cumulativeBilling', label: '누계기성', className: 'w-[120px] text-right' },
{ key: 'status', label: '상태', className: 'w-[100px] text-center' },
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
];
interface ProgressBillingManagementListClientProps {
initialData?: ProgressBilling[];
initialStats?: ProgressBillingStats;
}
export default function ProgressBillingManagementListClient({
initialData = [],
initialStats,
}: ProgressBillingManagementListClientProps) {
const router = useRouter();
// 상태
const [billings, setBillings] = useState<ProgressBilling[]>(initialData);
const [stats, setStats] = useState<ProgressBillingStats | null>(initialStats || null);
const [searchValue, setSearchValue] = useState('');
// 다중선택 필터
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
const [siteFilters, setSiteFilters] = useState<string[]>([]);
const [statusFilter, setStatusFilter] = useState<string>('all');
const [sortBy, setSortBy] = useState<string>('latest');
const [startDate, setStartDate] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [activeStatTab, setActiveStatTab] = useState<'all' | 'contractWaiting' | 'contractComplete'>('all');
const itemsPerPage = 20;
// 데이터 로드
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const [listResult, statsResult] = await Promise.all([
getProgressBillingList({
size: 1000,
startDate: startDate || undefined,
endDate: endDate || undefined,
}),
getProgressBillingStats(),
]);
if (listResult.success && listResult.data) {
setBillings(listResult.data.items);
}
if (statsResult.success && statsResult.data) {
setStats(statsResult.data);
}
} catch {
toast.error('데이터 로드에 실패했습니다.');
} finally {
setIsLoading(false);
}
}, [startDate, endDate]);
// 초기 데이터가 없으면 로드
useEffect(() => {
if (initialData.length === 0) {
loadData();
}
}, [initialData.length, loadData]);
// 거래처 선택에 따른 현장 옵션 필터링
const filteredSiteOptions: MultiSelectOption[] = useMemo(() => {
if (partnerFilters.length === 0) {
return MOCK_SITES;
}
// 선택된 거래처들의 현장 ID 수집
const availableSiteIds = new Set<string>();
partnerFilters.forEach((partnerId) => {
const siteIds = PARTNER_SITES_MAP[partnerId] || [];
siteIds.forEach((siteId) => availableSiteIds.add(siteId));
});
return MOCK_SITES.filter((site) => availableSiteIds.has(site.value));
}, [partnerFilters]);
// 필터링된 데이터
const filteredBillings = useMemo(() => {
return billings.filter((billing) => {
// 상태 탭 필터
if (activeStatTab === 'contractWaiting' &&
billing.status !== 'billing_waiting' &&
billing.status !== 'approval_waiting') return false;
if (activeStatTab === 'contractComplete' && billing.status !== 'billing_complete') return false;
// 상태 필터
if (statusFilter !== 'all' && billing.status !== statusFilter) return false;
// 거래처 필터 (다중선택)
if (partnerFilters.length > 0 && !partnerFilters.includes(billing.partnerId)) {
return false;
}
// 현장 필터 (다중선택)
if (siteFilters.length > 0 && !siteFilters.includes(billing.siteId)) {
return false;
}
// 검색 필터
if (searchValue) {
const search = searchValue.toLowerCase();
return (
billing.billingNumber.toLowerCase().includes(search) ||
billing.partnerName.toLowerCase().includes(search) ||
billing.siteName.toLowerCase().includes(search)
);
}
return true;
});
}, [billings, activeStatTab, statusFilter, partnerFilters, siteFilters, searchValue]);
// 정렬
const sortedBillings = useMemo(() => {
const sorted = [...filteredBillings];
switch (sortBy) {
case 'latest':
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
break;
case 'oldest':
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).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;
case 'siteNameAsc':
sorted.sort((a, b) => a.siteName.localeCompare(b.siteName, 'ko'));
break;
case 'siteNameDesc':
sorted.sort((a, b) => b.siteName.localeCompare(a.siteName, 'ko'));
break;
}
return sorted;
}, [filteredBillings, sortBy]);
// 페이지네이션
const totalPages = Math.ceil(sortedBillings.length / itemsPerPage);
const paginatedData = useMemo(() => {
const start = (currentPage - 1) * itemsPerPage;
return sortedBillings.slice(start, start + itemsPerPage);
}, [sortedBillings, currentPage, itemsPerPage]);
// 핸들러
const handleSearchChange = useCallback((value: string) => {
setSearchValue(value);
setCurrentPage(1);
}, []);
const handleToggleSelection = useCallback((id: string) => {
setSelectedItems((prev) => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
}, []);
const handleToggleSelectAll = useCallback(() => {
if (selectedItems.size === paginatedData.length) {
setSelectedItems(new Set());
} else {
setSelectedItems(new Set(paginatedData.map((b) => b.id)));
}
}, [selectedItems.size, paginatedData]);
const handleRowClick = useCallback(
(billing: ProgressBilling) => {
router.push(`/ko/construction/billing/progress-billing-management/${billing.id}`);
},
[router]
);
const handleEdit = useCallback(
(e: React.MouseEvent, billingId: string) => {
e.stopPropagation();
router.push(`/ko/construction/billing/progress-billing-management/${billingId}/edit`);
},
[router]
);
// 금액 포맷
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('ko-KR').format(amount);
};
// 테이블 행 렌더링
const renderTableRow = useCallback(
(billing: ProgressBilling, index: number, globalIndex: number) => {
const isSelected = selectedItems.has(billing.id);
return (
<TableRow
key={billing.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleRowClick(billing)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => handleToggleSelection(billing.id)}
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell>{billing.billingNumber}</TableCell>
<TableCell>{billing.partnerName}</TableCell>
<TableCell>{billing.siteName}</TableCell>
<TableCell className="text-center">{billing.round}</TableCell>
<TableCell className="text-center">{billing.billingYearMonth}</TableCell>
<TableCell className="text-right">{formatCurrency(billing.previousBilling)}</TableCell>
<TableCell className="text-right">{formatCurrency(billing.currentBilling)}</TableCell>
<TableCell className="text-right">{formatCurrency(billing.cumulativeBilling)}</TableCell>
<TableCell className="text-center">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${PROGRESS_BILLING_STATUS_STYLES[billing.status]}`}>
{PROGRESS_BILLING_STATUS_LABELS[billing.status]}
</span>
</TableCell>
<TableCell className="text-center">
{isSelected && (
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => handleEdit(e, billing.id)}
>
<Pencil className="h-4 w-4" />
</Button>
</div>
)}
</TableCell>
</TableRow>
);
},
[selectedItems, handleToggleSelection, handleRowClick, handleEdit]
);
// 모바일 카드 렌더링
const renderMobileCard = useCallback(
(billing: ProgressBilling, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
return (
<MobileCard
title={billing.siteName}
subtitle={billing.billingNumber}
badge={PROGRESS_BILLING_STATUS_LABELS[billing.status]}
badgeVariant="secondary"
isSelected={isSelected}
onToggle={onToggle}
onClick={() => handleRowClick(billing)}
details={[
{ label: '거래처', value: billing.partnerName },
{ label: '회차', value: `${billing.round}` },
{ label: '금회기성', value: formatCurrency(billing.currentBilling) },
]}
/>
);
},
[handleRowClick]
);
// 헤더 액션 (날짜 범위 + 퀵버튼)
const headerActions = (
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
showQuickButtons={true}
/>
);
// Stats 카드 데이터
const statsCardsData: StatCard[] = [
{
label: '전체 계약',
value: stats?.total ?? 0,
icon: FileText,
iconColor: 'text-blue-600',
onClick: () => setActiveStatTab('all'),
isActive: activeStatTab === 'all',
},
{
label: '계약대기',
value: stats?.contractWaiting ?? 0,
icon: FileText,
iconColor: 'text-yellow-600',
onClick: () => setActiveStatTab('contractWaiting'),
isActive: activeStatTab === 'contractWaiting',
},
{
label: '계약완료',
value: stats?.contractComplete ?? 0,
icon: FileText,
iconColor: 'text-green-600',
onClick: () => setActiveStatTab('contractComplete'),
isActive: activeStatTab === 'contractComplete',
},
];
// 필터 옵션들
const partnerOptions: MultiSelectOption[] = useMemo(() => MOCK_PARTNERS, []);
// filterConfig 기반 통합 필터 시스템
const filterConfig: FilterFieldConfig[] = useMemo(() => [
{ key: 'partners', label: '거래처', type: 'multi', options: partnerOptions },
{ key: 'sites', label: '현장명', type: 'multi', options: filteredSiteOptions },
{ key: 'status', label: '상태', type: 'single', options: PROGRESS_BILLING_STATUS_OPTIONS.filter(opt => opt.value !== 'all') },
{ key: 'sortBy', label: '정렬', type: 'single', options: PROGRESS_BILLING_SORT_OPTIONS.map(opt => ({ value: opt.value, label: opt.label })), allOptionLabel: '최신순' },
], [partnerOptions, filteredSiteOptions]);
// filterValues 객체
const filterValues: FilterValues = useMemo(() => ({
partners: partnerFilters,
sites: siteFilters,
status: statusFilter,
sortBy: sortBy,
}), [partnerFilters, siteFilters, statusFilter, sortBy]);
// 필터 변경 핸들러
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
switch (key) {
case 'partners':
setPartnerFilters(value as string[]);
// 거래처 변경 시 현장 필터 초기화
setSiteFilters([]);
break;
case 'sites':
setSiteFilters(value as string[]);
break;
case 'status':
setStatusFilter(value as string);
break;
case 'sortBy':
setSortBy(value as string);
break;
}
setCurrentPage(1);
}, []);
// 필터 초기화 핸들러
const handleFilterReset = useCallback(() => {
setPartnerFilters([]);
setSiteFilters([]);
setStatusFilter('all');
setSortBy('latest');
setCurrentPage(1);
}, []);
// 테이블 헤더 추가 액션
const tableHeaderActions = (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-muted-foreground whitespace-nowrap">
{sortedBillings.length}
</span>
</div>
);
return (
<IntegratedListTemplateV2
title="기성청구관리"
description="기성청구를 등록하고 관리합니다."
icon={FileText}
headerActions={headerActions}
stats={statsCardsData}
filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="기성청구 필터"
tableHeaderActions={tableHeaderActions}
searchValue={searchValue}
onSearchChange={handleSearchChange}
searchPlaceholder="기성청구번호, 거래처, 현장명 검색"
tableColumns={tableColumns}
data={paginatedData}
allData={sortedBillings}
getItemId={(item) => item.id}
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
selectedItems={selectedItems}
onToggleSelection={handleToggleSelection}
onToggleSelectAll={handleToggleSelectAll}
pagination={{
currentPage,
totalPages,
totalItems: sortedBillings.length,
itemsPerPage,
onPageChange: setCurrentPage,
}}
/>
);
}

View File

@@ -0,0 +1,317 @@
'use server';
import type {
ProgressBilling,
ProgressBillingStats,
ProgressBillingStatus,
ProgressBillingDetail,
ProgressBillingDetailFormData,
} from './types';
import { MOCK_PROGRESS_BILLING_DETAIL } from './types';
import { format, subMonths } from 'date-fns';
/**
* 목업 기성청구 데이터 생성
*/
function generateMockProgressBillings(): ProgressBilling[] {
const partners = [
{ id: '1', name: '(주)대한건설' },
{ id: '2', name: '삼성물산' },
{ id: '3', name: '현대건설' },
{ id: '4', name: 'GS건설' },
{ id: '5', name: '대림산업' },
];
const sites = [
{ id: '1', name: '강남 오피스빌딩 신축' },
{ id: '2', name: '판교 데이터센터' },
{ id: '3', name: '송도 물류센터' },
{ id: '4', name: '인천공항 터미널' },
{ id: '5', name: '부산항 창고' },
];
const statuses: ProgressBillingStatus[] = ['billing_waiting', 'approval_waiting', 'constructor_sent', 'billing_complete'];
const billings: ProgressBilling[] = [];
const baseDate = new Date(2026, 0, 1);
for (let i = 0; i < 50; i++) {
const partner = partners[i % partners.length];
const site = sites[i % sites.length];
const status = statuses[i % statuses.length];
const round = (i % 12) + 1;
const monthOffset = i % 6;
const billingDate = subMonths(baseDate, monthOffset);
// 기성 금액 계산 (회차에 따라 누적)
const baseAmount = 10000000 + (i * 500000);
const previousBilling = round > 1 ? baseAmount * (round - 1) : 0;
const currentBilling = baseAmount;
const cumulativeBilling = previousBilling + currentBilling;
billings.push({
id: `billing-${i + 1}`,
billingNumber: `PB-${2026}-${String(i + 1).padStart(4, '0')}`,
partnerId: partner.id,
partnerName: partner.name,
siteId: site.id,
siteName: site.name,
round,
billingYearMonth: format(billingDate, 'yyyy-MM'),
previousBilling,
currentBilling,
cumulativeBilling,
status,
createdAt: format(billingDate, "yyyy-MM-dd'T'HH:mm:ss"),
updatedAt: format(baseDate, "yyyy-MM-dd'T'HH:mm:ss"),
});
}
return billings;
}
// 캐시된 목업 데이터
let cachedBillings: ProgressBilling[] | null = null;
function getMockBillings(): ProgressBilling[] {
if (!cachedBillings) {
cachedBillings = generateMockProgressBillings();
}
return cachedBillings;
}
/**
* 기성청구 목록 조회
*/
export async function getProgressBillingList(params?: {
size?: number;
page?: number;
startDate?: string;
endDate?: string;
status?: string;
partnerIds?: string[];
siteIds?: string[];
search?: string;
}): Promise<{
success: boolean;
data?: { items: ProgressBilling[]; total: number };
error?: string;
}> {
try {
let billings = getMockBillings();
// 날짜 필터
if (params?.startDate && params?.endDate) {
billings = billings.filter((billing) => {
const billingDate = billing.billingYearMonth;
return billingDate >= params.startDate!.slice(0, 7) && billingDate <= params.endDate!.slice(0, 7);
});
}
// 상태 필터
if (params?.status && params.status !== 'all') {
billings = billings.filter((billing) => billing.status === params.status);
}
// 거래처 필터 (다중선택)
if (params?.partnerIds && params.partnerIds.length > 0) {
billings = billings.filter((billing) => params.partnerIds!.includes(billing.partnerId));
}
// 현장 필터 (다중선택)
if (params?.siteIds && params.siteIds.length > 0) {
billings = billings.filter((billing) => params.siteIds!.includes(billing.siteId));
}
// 검색
if (params?.search) {
const search = params.search.toLowerCase();
billings = billings.filter(
(billing) =>
billing.billingNumber.toLowerCase().includes(search) ||
billing.partnerName.toLowerCase().includes(search) ||
billing.siteName.toLowerCase().includes(search)
);
}
// 페이지네이션
const page = params?.page || 1;
const size = params?.size || 1000;
const start = (page - 1) * size;
const paginatedBillings = billings.slice(start, start + size);
return {
success: true,
data: {
items: paginatedBillings,
total: billings.length,
},
};
} catch {
return {
success: false,
error: '기성청구 목록 조회에 실패했습니다.',
};
}
}
/**
* 기성청구 통계 조회
*/
export async function getProgressBillingStats(): Promise<{
success: boolean;
data?: ProgressBillingStats;
error?: string;
}> {
try {
const billings = getMockBillings();
const stats: ProgressBillingStats = {
total: billings.length,
contractWaiting: billings.filter((b) => b.status === 'billing_waiting' || b.status === 'approval_waiting').length,
contractComplete: billings.filter((b) => b.status === 'billing_complete').length,
};
return {
success: true,
data: stats,
};
} catch {
return {
success: false,
error: '기성청구 통계 조회에 실패했습니다.',
};
}
}
/**
* 기성청구 상세 조회
*/
export async function getProgressBillingDetail(id: string): Promise<{
success: boolean;
data?: ProgressBillingDetail;
error?: string;
}> {
try {
// TODO: 실제 API 호출로 대체
// const response = await apiClient.get(`/progress-billing/${id}`);
// 목업 데이터 반환
await new Promise((resolve) => setTimeout(resolve, 300));
return {
success: true,
data: {
...MOCK_PROGRESS_BILLING_DETAIL,
id,
},
};
} catch (error) {
console.error('Failed to fetch progress billing detail:', error);
return {
success: false,
error: '기성청구 정보를 불러오는데 실패했습니다.',
};
}
}
/**
* 기성청구 저장 (생성/수정)
*/
export async function saveProgressBilling(
id: string | null,
data: ProgressBillingDetailFormData
): Promise<{
success: boolean;
data?: ProgressBillingDetail;
error?: string;
}> {
try {
// TODO: 실제 API 호출로 대체
// const response = id
// ? await apiClient.put(`/progress-billing/${id}`, data)
// : await apiClient.post('/progress-billing', data);
await new Promise((resolve) => setTimeout(resolve, 500));
console.log('Save progress billing:', { id, data });
return {
success: true,
data: {
...MOCK_PROGRESS_BILLING_DETAIL,
id: id || String(Date.now()),
...data,
},
};
} catch (error) {
console.error('Failed to save progress billing:', error);
return {
success: false,
error: '기성청구 저장에 실패했습니다.',
};
}
}
/**
* 기성청구 삭제
*/
export async function deleteProgressBilling(id: string): Promise<{
success: boolean;
error?: string;
}> {
try {
// TODO: 실제 API 호출로 대체
// await apiClient.delete(`/progress-billing/${id}`);
await new Promise((resolve) => setTimeout(resolve, 500));
console.log('Delete progress billing:', id);
return {
success: true,
};
} catch (error) {
console.error('Failed to delete progress billing:', error);
return {
success: false,
error: '기성청구 삭제에 실패했습니다.',
};
}
}
/**
* 기성청구 상태 변경
*/
export async function updateProgressBillingStatus(
id: string,
status: string
): Promise<{
success: boolean;
error?: string;
}> {
try {
// TODO: 실제 API 호출로 대체
// await apiClient.patch(`/progress-billing/${id}/status`, { status });
await new Promise((resolve) => setTimeout(resolve, 300));
console.log('Update progress billing status:', { id, status });
// 기성청구완료 시 매출 자동 등록 로직
if (status === 'completed') {
console.log('Auto-register sales for completed billing:', id);
// TODO: 매출 자동 등록 API 호출
}
return {
success: true,
};
} catch (error) {
console.error('Failed to update progress billing status:', error);
return {
success: false,
error: '상태 변경에 실패했습니다.',
};
}
}

View File

@@ -0,0 +1,57 @@
'use client';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { ProgressBillingDetailFormData } from '../types';
interface ContractInfoCardProps {
formData: ProgressBillingDetailFormData;
}
export function ContractInfoCard({ formData }: ContractInfoCardProps) {
return (
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* 거래처명 */}
<div className="space-y-2">
<Label></Label>
<Input value={formData.partnerName} placeholder="회사명" disabled className="bg-muted" />
</div>
{/* 현장명 */}
<div className="space-y-2">
<Label></Label>
<Input value={formData.siteName} disabled className="bg-muted" />
</div>
{/* 계약번호 */}
<div className="space-y-2">
<Label></Label>
<Input value={formData.contractNumber} disabled className="bg-muted" />
</div>
{/* 공사PM */}
<div className="space-y-2">
<Label>PM</Label>
<Input value={formData.constructionPM} disabled className="bg-muted" />
</div>
{/* 공사담당자 */}
<div className="space-y-2 md:col-span-2">
<Label></Label>
<Input
value={formData.constructionManagers.join(', ')}
disabled
className="bg-muted"
/>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,77 @@
'use client';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { ProgressBillingDetailFormData, ProgressBillingStatus } from '../types';
import { PROGRESS_BILLING_STATUS_OPTIONS } from '../types';
interface ProgressBillingInfoCardProps {
formData: ProgressBillingDetailFormData;
isViewMode: boolean;
onFieldChange: (field: keyof ProgressBillingDetailFormData, value: string | number) => void;
}
export function ProgressBillingInfoCard({
formData,
isViewMode,
onFieldChange,
}: ProgressBillingInfoCardProps) {
return (
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* 기성청구번호 */}
<div className="space-y-2">
<Label></Label>
<Input value={formData.billingNumber} disabled className="bg-muted" />
</div>
{/* 회차 */}
<div className="space-y-2">
<Label></Label>
<Input value={`${formData.billingRound}회차`} disabled className="bg-muted" />
</div>
{/* 기성청구연월 */}
<div className="space-y-2">
<Label></Label>
<Input value={formData.billingYearMonth} disabled className="bg-muted" />
</div>
{/* 상태 */}
<div className="space-y-2">
<Label></Label>
<Select
key={`status-${formData.status}`}
value={formData.status}
onValueChange={(value) => onFieldChange('status', value as ProgressBillingStatus)}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="상태 선택" />
</SelectTrigger>
<SelectContent>
{PROGRESS_BILLING_STATUS_OPTIONS.filter((o) => o.value !== 'all').map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,273 @@
'use client';
import { useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import type {
ProgressBillingDetail,
ProgressBillingDetailFormData,
ProgressBillingItem,
} from '../types';
import {
progressBillingDetailToFormData,
getEmptyProgressBillingDetailFormData,
MOCK_PROGRESS_BILLING_DETAIL,
} from '../types';
interface UseProgressBillingDetailFormProps {
mode: 'view' | 'edit';
billingId: string;
initialData?: ProgressBillingDetail;
}
export function useProgressBillingDetailForm({
mode,
billingId,
initialData,
}: UseProgressBillingDetailFormProps) {
const router = useRouter();
// Mode flags
const isViewMode = mode === 'view';
const isEditMode = mode === 'edit';
// Form data state
const [formData, setFormData] = useState<ProgressBillingDetailFormData>(() => {
if (initialData) {
return progressBillingDetailToFormData(initialData);
}
// 목업 데이터 사용
return progressBillingDetailToFormData(MOCK_PROGRESS_BILLING_DETAIL);
});
// Loading state
const [isLoading, setIsLoading] = useState(false);
// Dialog states
const [showSaveDialog, setShowSaveDialog] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
// Selection states for billing items
const [selectedBillingItems, setSelectedBillingItems] = useState<Set<string>>(new Set());
// Selection states for photo items
const [selectedPhotoItems, setSelectedPhotoItems] = useState<Set<string>>(new Set());
// Modal states
const [showDirectConstructionModal, setShowDirectConstructionModal] = useState(false);
const [showIndirectConstructionModal, setShowIndirectConstructionModal] = useState(false);
const [showPhotoDocumentModal, setShowPhotoDocumentModal] = useState(false);
// Navigation handlers
const handleBack = useCallback(() => {
router.push('/construction/billing/progress-billing-management');
}, [router]);
const handleEdit = useCallback(() => {
router.push('/construction/billing/progress-billing-management/' + billingId + '/edit');
}, [router, billingId]);
const handleCancel = useCallback(() => {
router.push('/construction/billing/progress-billing-management/' + billingId);
}, [router, billingId]);
// Form handlers
const handleFieldChange = useCallback(
(field: keyof ProgressBillingDetailFormData, value: string | number) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
},
[]
);
// Save handlers
const handleSave = useCallback(() => {
setShowSaveDialog(true);
}, []);
const handleConfirmSave = useCallback(async () => {
setIsLoading(true);
try {
// TODO: API 호출
console.log('Save billing data:', formData);
await new Promise((resolve) => setTimeout(resolve, 500));
setShowSaveDialog(false);
router.push('/construction/billing/progress-billing-management/' + billingId);
} catch (error) {
console.error('Save failed:', error);
} finally {
setIsLoading(false);
}
}, [formData, router, billingId]);
// Delete handlers
const handleDelete = useCallback(() => {
setShowDeleteDialog(true);
}, []);
const handleConfirmDelete = useCallback(async () => {
setIsLoading(true);
try {
// TODO: API 호출
console.log('Delete billing:', billingId);
await new Promise((resolve) => setTimeout(resolve, 500));
setShowDeleteDialog(false);
router.push('/construction/billing/progress-billing-management');
} catch (error) {
console.error('Delete failed:', error);
} finally {
setIsLoading(false);
}
}, [router, billingId]);
// Billing item handlers
const handleBillingItemChange = useCallback(
(itemId: string, field: keyof ProgressBillingItem, value: string | number) => {
setFormData((prev) => ({
...prev,
billingItems: prev.billingItems.map((item) =>
item.id === itemId ? { ...item, [field]: value } : item
),
}));
},
[]
);
const handleToggleBillingItemSelection = useCallback((itemId: string) => {
setSelectedBillingItems((prev) => {
const newSet = new Set(prev);
if (newSet.has(itemId)) {
newSet.delete(itemId);
} else {
newSet.add(itemId);
}
return newSet;
});
}, []);
const handleToggleSelectAllBillingItems = useCallback(() => {
setSelectedBillingItems((prev) => {
if (prev.size === formData.billingItems.length) {
return new Set();
}
return new Set(formData.billingItems.map((item) => item.id));
});
}, [formData.billingItems]);
const handleApplySelectedBillingItems = useCallback(() => {
// 선택된 항목의 변경사항 적용 (현재는 선택 해제만)
setSelectedBillingItems(new Set());
}, []);
// Photo item handlers
const handleTogglePhotoItemSelection = useCallback((itemId: string) => {
setSelectedPhotoItems((prev) => {
const newSet = new Set(prev);
if (newSet.has(itemId)) {
newSet.delete(itemId);
} else {
newSet.add(itemId);
}
return newSet;
});
}, []);
const handleToggleSelectAllPhotoItems = useCallback(() => {
setSelectedPhotoItems((prev) => {
if (prev.size === formData.photoItems.length) {
return new Set();
}
return new Set(formData.photoItems.map((item) => item.id));
});
}, [formData.photoItems]);
const handleApplySelectedPhotoItems = useCallback(() => {
// 선택된 항목의 변경사항 적용 (현재는 선택 해제만)
setSelectedPhotoItems(new Set());
}, []);
// Photo select handler (라디오 버튼으로 사진 선택)
const handlePhotoSelect = useCallback((itemId: string, photoIndex: number) => {
setFormData((prev) => ({
...prev,
photoItems: prev.photoItems.map((item) =>
item.id === itemId ? { ...item, selectedPhotoIndex: photoIndex } : item
),
}));
}, []);
// Modal handlers
const handleViewDirectConstruction = useCallback(() => {
setShowDirectConstructionModal(true);
}, []);
const handleViewIndirectConstruction = useCallback(() => {
setShowIndirectConstructionModal(true);
}, []);
const handleViewPhotoDocument = useCallback(() => {
setShowPhotoDocumentModal(true);
}, []);
return {
// Mode flags
isViewMode,
isEditMode,
// Form data
formData,
// Loading state
isLoading,
// Dialog states
showSaveDialog,
setShowSaveDialog,
showDeleteDialog,
setShowDeleteDialog,
// Modal states
showDirectConstructionModal,
setShowDirectConstructionModal,
showIndirectConstructionModal,
setShowIndirectConstructionModal,
showPhotoDocumentModal,
setShowPhotoDocumentModal,
// Selection states
selectedBillingItems,
selectedPhotoItems,
// Navigation handlers
handleBack,
handleEdit,
handleCancel,
// Form handlers
handleFieldChange,
// CRUD handlers
handleSave,
handleConfirmSave,
handleDelete,
handleConfirmDelete,
// Billing item handlers
handleBillingItemChange,
handleToggleBillingItemSelection,
handleToggleSelectAllBillingItems,
handleApplySelectedBillingItems,
// Photo item handlers
handleTogglePhotoItemSelection,
handleToggleSelectAllPhotoItems,
handleApplySelectedPhotoItems,
handlePhotoSelect,
// Modal handlers
handleViewDirectConstruction,
handleViewIndirectConstruction,
handleViewPhotoDocument,
};
}

View File

@@ -0,0 +1,3 @@
export { default as ProgressBillingDetailForm } from './ProgressBillingDetailForm';
export * from './types';
export * from './actions';

View File

@@ -0,0 +1,268 @@
'use client';
import {
Dialog,
DialogContent,
DialogTitle,
VisuallyHidden,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Edit, Trash2, Printer, X } from 'lucide-react';
import { toast } from 'sonner';
import { printArea } from '@/lib/print-utils';
import type { ProgressBillingDetailFormData } from '../types';
// 숫자 포맷팅 (천단위 콤마)
function formatNumber(num: number | undefined): string {
if (num === undefined || num === null) return '-';
return num.toLocaleString('ko-KR');
}
interface DirectConstructionModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
data: ProgressBillingDetailFormData;
}
// 직접 공사 내역 아이템 타입
interface DirectConstructionItem {
id: string;
name: string;
product: string;
width: number;
height: number;
quantity: number;
unit: string;
contractUnitPrice: number;
contractAmount: number;
prevQuantity: number;
prevAmount: number;
currentQuantity: number;
currentAmount: number;
cumulativeQuantity: number;
cumulativeAmount: number;
remark?: string;
}
// 목업 데이터 생성
function generateMockItems(billingItems: ProgressBillingDetailFormData['billingItems']): DirectConstructionItem[] {
return billingItems.map((item, index) => ({
id: item.id,
name: item.name || '명칭',
product: item.product || '제품명',
width: item.width || 2500,
height: item.height || 3200,
quantity: 1,
unit: 'EA',
contractUnitPrice: 2500000,
contractAmount: 2500000,
prevQuantity: index < 4 ? 0 : 0.8,
prevAmount: index < 4 ? 0 : 1900000,
currentQuantity: 0.8,
currentAmount: 1900000,
cumulativeQuantity: 0.8,
cumulativeAmount: 1900000,
remark: '',
}));
}
export function DirectConstructionModal({
open,
onOpenChange,
data,
}: DirectConstructionModalProps) {
// 핸들러
const handleEdit = () => {
toast.info('수정 기능은 준비 중입니다.');
};
const handleDelete = () => {
toast.info('삭제 기능은 준비 중입니다.');
};
const handlePrint = () => {
printArea({ title: '직접 공사 내역서 인쇄' });
};
// 목업 데이터
const items = generateMockItems(data.billingItems);
// 합계 계산
const totalContractAmount = items.reduce((sum, item) => sum + item.contractAmount, 0);
const totalCurrentAmount = items.reduce((sum, item) => sum + item.currentAmount, 0);
const totalCumulativeAmount = items.reduce((sum, item) => sum + item.cumulativeAmount, 0);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] md:max-w-[1000px] lg:max-w-[1200px] h-[95vh] p-0 flex flex-col gap-0 overflow-hidden [&>button]:hidden">
<VisuallyHidden>
<DialogTitle> </DialogTitle>
</VisuallyHidden>
{/* 헤더 영역 */}
<div className="print-hidden flex-shrink-0 flex items-center justify-between px-6 py-4 border-b bg-white">
<h2 className="text-lg font-semibold"> </h2>
<Button
variant="ghost"
size="icon"
onClick={() => onOpenChange(false)}
className="h-8 w-8"
>
<X className="h-4 w-4" />
</Button>
</div>
{/* 버튼 영역 */}
<div className="print-hidden flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
<Button variant="outline" size="sm" onClick={handleEdit}>
<Edit className="h-4 w-4 mr-1" />
</Button>
<Button variant="outline" size="sm" onClick={handleDelete}>
<Trash2 className="h-4 w-4 mr-1" />
</Button>
<Button variant="outline" size="sm" onClick={handlePrint}>
<Printer className="h-4 w-4 mr-1" />
</Button>
</div>
{/* 문서 영역 */}
<div className="print-area flex-1 overflow-y-auto bg-gray-100 p-6">
<div className="max-w-[297mm] mx-auto bg-white shadow-lg rounded-lg p-8">
{/* 상단: 제목 + 결재란 */}
<div className="flex items-start justify-between mb-6">
{/* 좌측: 제목 및 문서 정보 */}
<div className="flex-1">
<h1 className="text-2xl font-bold mb-2"> </h1>
<div className="text-sm text-gray-600">
: {data.billingNumber || 'ABC123'} | 작성일자: 2025년 11 11
</div>
</div>
{/* 우측: 결재란 */}
<table className="border-collapse border border-gray-400 text-sm">
<tbody>
<tr>
<th rowSpan={3} className="border border-gray-400 px-3 py-1 bg-gray-50 text-center align-middle w-12">
<br />
</th>
<th className="border border-gray-400 px-6 py-1 bg-gray-50 text-center whitespace-nowrap"></th>
<th className="border border-gray-400 px-6 py-1 bg-gray-50 text-center whitespace-nowrap"></th>
</tr>
<tr>
<td className="border border-gray-400 px-6 py-2 text-center h-10 whitespace-nowrap"></td>
<td className="border border-gray-400 px-6 py-2 text-center h-10 whitespace-nowrap"></td>
</tr>
<tr>
<td className="border border-gray-400 px-6 py-1 text-center text-xs text-gray-500 whitespace-nowrap"></td>
<td className="border border-gray-400 px-6 py-1 text-center text-xs text-gray-500 whitespace-nowrap"></td>
</tr>
</tbody>
</table>
</div>
{/* 기성내역 제목 */}
<div className="text-center font-bold text-lg mb-4">
{data.billingRound || 1} ({data.billingYearMonth || '2025년 11월'})
</div>
{/* 현장 정보 */}
<div className="mb-4">
<span className="font-bold"> : {data.siteName || '현장명'}</span>
</div>
{/* 테이블 */}
<div className="overflow-x-auto">
<table className="w-full border-collapse border border-gray-400 text-xs">
<thead>
{/* 1행: 상위 헤더 */}
<tr className="bg-gray-50">
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-10">No.</th>
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center min-w-[80px]"></th>
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center min-w-[60px]"></th>
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
<span className="inline-flex items-center gap-1">
mm
</span>
</th>
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-12"></th>
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-12"></th>
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
<span className="inline-flex items-center gap-1">
</span>
</th>
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
<span className="inline-flex items-center gap-1">
</span>
</th>
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
<span className="inline-flex items-center gap-1">
</span>
</th>
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
<span className="inline-flex items-center gap-1">
</span>
</th>
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-16"></th>
</tr>
{/* 2행: 하위 헤더 */}
<tr className="bg-gray-50">
<th className="border border-gray-400 px-2 py-1 text-center w-14"></th>
<th className="border border-gray-400 px-2 py-1 text-center w-14"></th>
<th className="border border-gray-400 px-2 py-1 text-center w-16"></th>
<th className="border border-gray-400 px-2 py-1 text-center w-20"></th>
<th className="border border-gray-400 px-2 py-1 text-center w-12"></th>
<th className="border border-gray-400 px-2 py-1 text-center w-20"></th>
<th className="border border-gray-400 px-2 py-1 text-center w-12"></th>
<th className="border border-gray-400 px-2 py-1 text-center w-20"></th>
<th className="border border-gray-400 px-2 py-1 text-center w-12"></th>
<th className="border border-gray-400 px-2 py-1 text-center w-20"></th>
</tr>
</thead>
<tbody>
{items.map((item, index) => (
<tr key={item.id}>
<td className="border border-gray-400 px-2 py-2 text-center">{index + 1}</td>
<td className="border border-gray-400 px-2 py-2">{item.name}</td>
<td className="border border-gray-400 px-2 py-2">{item.product}</td>
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.width)}</td>
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.height)}</td>
<td className="border border-gray-400 px-2 py-2 text-center">{item.quantity}</td>
<td className="border border-gray-400 px-2 py-2 text-center">{item.unit}</td>
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.contractUnitPrice)}</td>
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.contractAmount)}</td>
<td className="border border-gray-400 px-2 py-2 text-center">{item.prevQuantity || '-'}</td>
<td className="border border-gray-400 px-2 py-2 text-right">{item.prevAmount ? formatNumber(item.prevAmount) : '-'}</td>
<td className="border border-gray-400 px-2 py-2 text-center">{item.currentQuantity}</td>
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.currentAmount)}</td>
<td className="border border-gray-400 px-2 py-2 text-center">{item.cumulativeQuantity}</td>
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.cumulativeAmount)}</td>
<td className="border border-gray-400 px-2 py-2">{item.remark || ''}</td>
</tr>
))}
{/* 합계 행 */}
<tr className="bg-gray-50 font-bold">
<td colSpan={8} className="border border-gray-400 px-2 py-2 text-center"></td>
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(totalContractAmount)}</td>
<td colSpan={2} className="border border-gray-400 px-2 py-2"></td>
<td className="border border-gray-400 px-2 py-2"></td>
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(totalCurrentAmount)}</td>
<td className="border border-gray-400 px-2 py-2"></td>
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(totalCumulativeAmount)}</td>
<td className="border border-gray-400 px-2 py-2"></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,382 @@
'use client';
import {
Dialog,
DialogContent,
DialogTitle,
VisuallyHidden,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Edit, Trash2, Printer, X } from 'lucide-react';
import { toast } from 'sonner';
import { printArea } from '@/lib/print-utils';
import type { ProgressBillingDetailFormData } from '../types';
// 숫자 포맷팅 (천단위 콤마)
function formatNumber(num: number | undefined): string {
if (num === undefined || num === null) return '-';
return num.toLocaleString('ko-KR');
}
interface IndirectConstructionModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
data: ProgressBillingDetailFormData;
}
// 간접 공사 내역 아이템 타입
interface IndirectConstructionItem {
id: string;
name: string;
spec: string;
unit: string;
contractQuantity: number;
contractAmount: number;
prevQuantity: number;
prevAmount: number;
currentQuantity: number;
currentAmount: number;
cumulativeQuantity: number;
cumulativeAmount: number;
remark?: string;
}
// 목업 데이터 생성
function generateMockItems(): IndirectConstructionItem[] {
return [
{
id: '1',
name: '국민연금',
spec: '직접노무비 × 4.50%',
unit: '식',
contractQuantity: 1,
contractAmount: 2500000,
prevQuantity: 0,
prevAmount: 0,
currentQuantity: 0,
currentAmount: 2500000,
cumulativeQuantity: 0,
cumulativeAmount: 2500000,
},
{
id: '2',
name: '건강보험',
spec: '직접노무비 × 3.545%',
unit: '식',
contractQuantity: 1,
contractAmount: 2500000,
prevQuantity: 0,
prevAmount: 0,
currentQuantity: 0,
currentAmount: 2500000,
cumulativeQuantity: 0,
cumulativeAmount: 2500000,
},
{
id: '3',
name: '노인장기요양보험료',
spec: '건강보험료 × 12.81%',
unit: '식',
contractQuantity: 1,
contractAmount: 2500000,
prevQuantity: 0,
prevAmount: 0,
currentQuantity: 0,
currentAmount: 2500000,
cumulativeQuantity: 0,
cumulativeAmount: 2500000,
},
{
id: '4',
name: '고용보험',
spec: '직접공사비 × 30% × 1.57%',
unit: '식',
contractQuantity: 1,
contractAmount: 2500000,
prevQuantity: 0,
prevAmount: 0,
currentQuantity: 0,
currentAmount: 2500000,
cumulativeQuantity: 0,
cumulativeAmount: 2500000,
},
{
id: '5',
name: '일반관리비',
spec: '1) 직접공사비 × 업체요율\n2) 공과물비+작업비 시공비 포함',
unit: '식',
contractQuantity: 1,
contractAmount: 2500000,
prevQuantity: 0,
prevAmount: 0,
currentQuantity: 0,
currentAmount: 2500000,
cumulativeQuantity: 0,
cumulativeAmount: 2500000,
},
{
id: '6',
name: '안전관리비',
spec: '직접공사비 × 0.3%(일반건산)',
unit: '식',
contractQuantity: 1,
contractAmount: 2500000,
prevQuantity: 0,
prevAmount: 0,
currentQuantity: 0,
currentAmount: 2500000,
cumulativeQuantity: 0,
cumulativeAmount: 2500000,
},
{
id: '7',
name: '안전검사자',
spec: '실투입 × 양정실시',
unit: 'M/D',
contractQuantity: 1,
contractAmount: 2500000,
prevQuantity: 0,
prevAmount: 0,
currentQuantity: 0,
currentAmount: 2500000,
cumulativeQuantity: 0,
cumulativeAmount: 2500000,
},
{
id: '8',
name: '신호수 및 위기감시자',
spec: '실투입 × 양정실시',
unit: 'M/D',
contractQuantity: 1,
contractAmount: 2500000,
prevQuantity: 0,
prevAmount: 0,
currentQuantity: 0,
currentAmount: 2500000,
cumulativeQuantity: 0,
cumulativeAmount: 2500000,
},
{
id: '9',
name: '퇴직공제부금',
spec: '직접노무비 × 2.3%',
unit: '식',
contractQuantity: 1,
contractAmount: 2500000,
prevQuantity: 0,
prevAmount: 0,
currentQuantity: 0,
currentAmount: 2500000,
cumulativeQuantity: 0,
cumulativeAmount: 2500000,
},
{
id: '10',
name: '폐기물처리비',
spec: '직접공사비 × 요제요율이상',
unit: '식',
contractQuantity: 1,
contractAmount: 2500000,
prevQuantity: 0,
prevAmount: 0,
currentQuantity: 0,
currentAmount: 2500000,
cumulativeQuantity: 0,
cumulativeAmount: 2500000,
},
{
id: '11',
name: '건설기계대여자금보증료',
spec: '(직접비+간접공사비) × 0.07%',
unit: '식',
contractQuantity: 1,
contractAmount: 2500000,
prevQuantity: 0,
prevAmount: 0,
currentQuantity: 0,
currentAmount: 2500000,
cumulativeQuantity: 0,
cumulativeAmount: 2500000,
},
];
}
export function IndirectConstructionModal({
open,
onOpenChange,
data,
}: IndirectConstructionModalProps) {
// 핸들러
const handleEdit = () => {
toast.info('수정 기능은 준비 중입니다.');
};
const handleDelete = () => {
toast.info('삭제 기능은 준비 중입니다.');
};
const handlePrint = () => {
printArea({ title: '간접 공사 내역서 인쇄' });
};
// 목업 데이터
const items = generateMockItems();
// 합계 계산
const totalContractAmount = items.reduce((sum, item) => sum + item.contractAmount, 0);
const totalCurrentAmount = items.reduce((sum, item) => sum + item.currentAmount, 0);
const totalCumulativeAmount = items.reduce((sum, item) => sum + item.cumulativeAmount, 0);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] md:max-w-[1000px] lg:max-w-[1200px] h-[95vh] p-0 flex flex-col gap-0 overflow-hidden [&>button]:hidden">
<VisuallyHidden>
<DialogTitle> </DialogTitle>
</VisuallyHidden>
{/* 헤더 영역 */}
<div className="print-hidden flex-shrink-0 flex items-center justify-between px-6 py-4 border-b bg-white">
<h2 className="text-lg font-semibold"> </h2>
<Button
variant="ghost"
size="icon"
onClick={() => onOpenChange(false)}
className="h-8 w-8"
>
<X className="h-4 w-4" />
</Button>
</div>
{/* 버튼 영역 */}
<div className="print-hidden flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
<Button variant="outline" size="sm" onClick={handleEdit}>
<Edit className="h-4 w-4 mr-1" />
</Button>
<Button variant="outline" size="sm" onClick={handleDelete}>
<Trash2 className="h-4 w-4 mr-1" />
</Button>
<Button variant="outline" size="sm" onClick={handlePrint}>
<Printer className="h-4 w-4 mr-1" />
</Button>
</div>
{/* 문서 영역 */}
<div className="print-area flex-1 overflow-y-auto bg-gray-100 p-6">
<div className="max-w-[297mm] mx-auto bg-white shadow-lg rounded-lg p-8">
{/* 상단: 제목 + 결재란 */}
<div className="flex items-start justify-between mb-6">
{/* 좌측: 제목 및 문서 정보 */}
<div className="flex-1">
<h1 className="text-2xl font-bold mb-2"> </h1>
<div className="text-sm text-gray-600">
: {data.billingNumber || 'ABC123'} | 작성일자: 2025년 11 11
</div>
</div>
{/* 우측: 결재란 */}
<table className="border-collapse border border-gray-400 text-sm">
<tbody>
<tr>
<th rowSpan={3} className="border border-gray-400 px-3 py-1 bg-gray-50 text-center align-middle w-12">
<br />
</th>
<th className="border border-gray-400 px-6 py-1 bg-gray-50 text-center whitespace-nowrap"></th>
<th className="border border-gray-400 px-6 py-1 bg-gray-50 text-center whitespace-nowrap"></th>
</tr>
<tr>
<td className="border border-gray-400 px-6 py-2 text-center h-10 whitespace-nowrap"></td>
<td className="border border-gray-400 px-6 py-2 text-center h-10 whitespace-nowrap"></td>
</tr>
<tr>
<td className="border border-gray-400 px-6 py-1 text-center text-xs text-gray-500 whitespace-nowrap"></td>
<td className="border border-gray-400 px-6 py-1 text-center text-xs text-gray-500 whitespace-nowrap"></td>
</tr>
</tbody>
</table>
</div>
{/* 기성내역 제목 */}
<div className="text-center font-bold text-lg mb-4">
{data.billingRound || 1} ({data.billingYearMonth || '2025년 11월'})
</div>
{/* 현장 정보 */}
<div className="mb-4">
<span className="font-bold"> : {data.siteName || '현장명'}</span>
</div>
{/* 테이블 */}
<div className="overflow-x-auto">
<table className="w-full border-collapse border border-gray-400 text-xs">
<thead>
{/* 1행: 상위 헤더 */}
<tr className="bg-gray-50">
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-10">No.</th>
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center min-w-[100px]"></th>
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center min-w-[180px]"></th>
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-14"></th>
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
</th>
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
</th>
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
</th>
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
</th>
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-16"></th>
</tr>
{/* 2행: 하위 헤더 */}
<tr className="bg-gray-50">
<th className="border border-gray-400 px-2 py-1 text-center w-12"></th>
<th className="border border-gray-400 px-2 py-1 text-center w-20"></th>
<th className="border border-gray-400 px-2 py-1 text-center w-12"></th>
<th className="border border-gray-400 px-2 py-1 text-center w-20"></th>
<th className="border border-gray-400 px-2 py-1 text-center w-12"></th>
<th className="border border-gray-400 px-2 py-1 text-center w-20"></th>
<th className="border border-gray-400 px-2 py-1 text-center w-12"></th>
<th className="border border-gray-400 px-2 py-1 text-center w-20"></th>
</tr>
</thead>
<tbody>
{items.map((item, index) => (
<tr key={item.id}>
<td className="border border-gray-400 px-2 py-2 text-center">{index + 1}</td>
<td className="border border-gray-400 px-2 py-2">{item.name}</td>
<td className="border border-gray-400 px-2 py-2 whitespace-pre-line text-xs">{item.spec}</td>
<td className="border border-gray-400 px-2 py-2 text-center">{item.unit}</td>
<td className="border border-gray-400 px-2 py-2 text-center">{item.contractQuantity}</td>
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.contractAmount)}</td>
<td className="border border-gray-400 px-2 py-2 text-center">{item.prevQuantity || '-'}</td>
<td className="border border-gray-400 px-2 py-2 text-right">{item.prevAmount ? formatNumber(item.prevAmount) : '-'}</td>
<td className="border border-gray-400 px-2 py-2 text-center">{item.currentQuantity || '-'}</td>
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.currentAmount)}</td>
<td className="border border-gray-400 px-2 py-2 text-center">{item.cumulativeQuantity || '-'}</td>
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.cumulativeAmount)}</td>
<td className="border border-gray-400 px-2 py-2">{item.remark || ''}</td>
</tr>
))}
{/* 합계 행 */}
<tr className="bg-gray-50 font-bold">
<td colSpan={5} className="border border-gray-400 px-2 py-2 text-center"></td>
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(totalContractAmount)}</td>
<td colSpan={2} className="border border-gray-400 px-2 py-2"></td>
<td colSpan={2} className="border border-gray-400 px-2 py-2 text-right">{formatNumber(totalCurrentAmount)}</td>
<td colSpan={2} className="border border-gray-400 px-2 py-2 text-right">{formatNumber(totalCumulativeAmount)}</td>
<td className="border border-gray-400 px-2 py-2"></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,210 @@
'use client';
import {
Dialog,
DialogContent,
DialogTitle,
VisuallyHidden,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Edit, Trash2, Printer, X } from 'lucide-react';
import { toast } from 'sonner';
import { printArea } from '@/lib/print-utils';
import type { ProgressBillingDetailFormData } from '../types';
interface PhotoDocumentModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
data: ProgressBillingDetailFormData;
}
// 사진대지 아이템 타입
interface PhotoDocumentItem {
id: string;
imageUrl: string;
name: string;
}
// 목업 데이터 생성
function generateMockPhotos(photoItems: ProgressBillingDetailFormData['photoItems']): PhotoDocumentItem[] {
// 기존 photoItems에서 선택된 사진들을 가져오거나 목업 생성
const photos: PhotoDocumentItem[] = [];
photoItems.forEach((item) => {
if (item.photos && item.photos.length > 0) {
const selectedIndex = item.selectedPhotoIndex ?? 0;
photos.push({
id: item.id,
imageUrl: item.photos[selectedIndex] || item.photos[0],
name: item.name,
});
}
});
// 최소 6개 항목 채우기 (2열 × 3행)
while (photos.length < 6) {
photos.push({
id: `mock-${photos.length}`,
imageUrl: '',
name: '명칭',
});
}
return photos;
}
export function PhotoDocumentModal({
open,
onOpenChange,
data,
}: PhotoDocumentModalProps) {
// 핸들러
const handleEdit = () => {
toast.info('수정 기능은 준비 중입니다.');
};
const handleDelete = () => {
toast.info('삭제 기능은 준비 중입니다.');
};
const handlePrint = () => {
printArea({ title: '사진대지 인쇄' });
};
// 목업 데이터
const photos = generateMockPhotos(data.photoItems);
// 2열로 그룹화
const photoRows: PhotoDocumentItem[][] = [];
for (let i = 0; i < photos.length; i += 2) {
photoRows.push(photos.slice(i, i + 2));
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] md:max-w-[900px] lg:max-w-[1000px] h-[95vh] p-0 flex flex-col gap-0 overflow-hidden [&>button]:hidden">
<VisuallyHidden>
<DialogTitle></DialogTitle>
</VisuallyHidden>
{/* 헤더 영역 */}
<div className="print-hidden flex-shrink-0 flex items-center justify-between px-6 py-4 border-b bg-white">
<h2 className="text-lg font-semibold"></h2>
<Button
variant="ghost"
size="icon"
onClick={() => onOpenChange(false)}
className="h-8 w-8"
>
<X className="h-4 w-4" />
</Button>
</div>
{/* 버튼 영역 */}
<div className="print-hidden flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
<Button variant="outline" size="sm" onClick={handleEdit}>
<Edit className="h-4 w-4 mr-1" />
</Button>
<Button variant="outline" size="sm" onClick={handleDelete}>
<Trash2 className="h-4 w-4 mr-1" />
</Button>
<Button variant="outline" size="sm" onClick={handlePrint}>
<Printer className="h-4 w-4 mr-1" />
</Button>
</div>
{/* 문서 영역 */}
<div className="print-area flex-1 overflow-y-auto bg-gray-100 p-6">
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg p-8">
{/* 상단: 제목 + 결재란 */}
<div className="flex items-start justify-between mb-6">
{/* 좌측: 제목 및 문서 정보 */}
<div className="flex-1">
<h1 className="text-2xl font-bold mb-2"></h1>
<div className="text-sm text-gray-600">
: {data.billingNumber || 'ABC123'} | 작성일자: 2025년 11 11
</div>
</div>
{/* 우측: 결재란 */}
<table className="border-collapse border border-gray-400 text-sm">
<tbody>
<tr>
<th rowSpan={3} className="border border-gray-400 px-3 py-1 bg-gray-50 text-center align-middle w-12">
<br />
</th>
<th className="border border-gray-400 px-6 py-1 bg-gray-50 text-center whitespace-nowrap"></th>
<th className="border border-gray-400 px-6 py-1 bg-gray-50 text-center whitespace-nowrap"></th>
</tr>
<tr>
<td className="border border-gray-400 px-6 py-2 text-center h-10 whitespace-nowrap"></td>
<td className="border border-gray-400 px-6 py-2 text-center h-10 whitespace-nowrap"></td>
</tr>
<tr>
<td className="border border-gray-400 px-6 py-1 text-center text-xs text-gray-500 whitespace-nowrap"></td>
<td className="border border-gray-400 px-6 py-1 text-center text-xs text-gray-500 whitespace-nowrap"></td>
</tr>
</tbody>
</table>
</div>
{/* 기성신청 사진대지 제목 */}
<div className="text-center font-bold text-lg mb-4">
{data.billingRound || 1} ({data.billingYearMonth || '2025년 11월'})
</div>
{/* 현장 정보 */}
<div className="mb-6">
<span className="font-bold"> : {data.siteName || '현장명'}</span>
</div>
{/* 사진 그리드 */}
<div className="border border-gray-400">
{photoRows.map((row, rowIndex) => (
<div key={rowIndex} className="grid grid-cols-2">
{row.map((photo, colIndex) => (
<div
key={photo.id}
className={`border border-gray-400 ${colIndex === 0 ? 'border-l-0' : ''} ${rowIndex === 0 ? 'border-t-0' : ''}`}
>
{/* 이미지 영역 */}
<div className="aspect-[4/3] bg-gray-100 flex items-center justify-center overflow-hidden">
{photo.imageUrl ? (
<img
src={photo.imageUrl}
alt={photo.name}
className="w-full h-full object-cover"
/>
) : (
<span className="text-gray-400 text-lg">IMG</span>
)}
</div>
{/* 명칭 라벨 */}
<div className="border-t border-gray-400 px-4 py-3 text-center bg-white">
<span className="text-sm font-medium">{photo.name}</span>
</div>
</div>
))}
{/* 홀수 개일 때 빈 셀 채우기 */}
{row.length === 1 && (
<div className="border border-gray-400 border-t-0">
<div className="aspect-[4/3] bg-gray-100 flex items-center justify-center">
<span className="text-gray-400 text-lg">IMG</span>
</div>
<div className="border-t border-gray-400 px-4 py-3 text-center bg-white">
<span className="text-sm font-medium"></span>
</div>
</div>
)}
</div>
))}
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,147 @@
'use client';
import Image from 'next/image';
import { Check } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import type { PhotoItem } from '../types';
interface PhotoTableProps {
items: PhotoItem[];
isViewMode: boolean;
isEditMode: boolean;
selectedItems: Set<string>;
onToggleSelection: (itemId: string) => void;
onToggleSelectAll: () => void;
onApplySelected: () => void;
onPhotoSelect?: (itemId: string, photoIndex: number) => void;
}
export function PhotoTable({
items,
isViewMode,
isEditMode,
selectedItems,
onToggleSelection,
onToggleSelectAll,
onApplySelected,
onPhotoSelect,
}: PhotoTableProps) {
const allSelected = items.length > 0 && items.every((item) => selectedItems.has(item.id));
const handleApply = () => {
onApplySelected();
toast.success('적용이 완료되었습니다.');
};
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div className="flex items-center gap-2">
<CardTitle className="text-lg"></CardTitle>
<span className="text-sm text-muted-foreground">
{items.length}{selectedItems.size > 0 && ', ' + selectedItems.size + '건 선택'}
</span>
</div>
{isEditMode && selectedItems.size > 0 && (
<Button size="sm" onClick={handleApply}>
<Check className="h-4 w-4 mr-1" />
</Button>
)}
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[40px]">
<Checkbox
checked={allSelected}
onCheckedChange={onToggleSelectAll}
disabled={isViewMode}
/>
</TableHead>
<TableHead className="w-[40px]"></TableHead>
<TableHead className="w-[70px]"></TableHead>
<TableHead className="w-[90px]"></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, index) => (
<TableRow key={item.id} className="h-[280px]">
<TableCell className="align-middle">
<Checkbox
checked={selectedItems.has(item.id)}
onCheckedChange={() => onToggleSelection(item.id)}
disabled={isViewMode}
/>
</TableCell>
<TableCell className="align-middle">{index + 1}</TableCell>
<TableCell className="align-middle">{item.constructionNumber}</TableCell>
<TableCell className="align-middle">{item.name}</TableCell>
<TableCell>
{item.photos && item.photos.length > 0 ? (
<div className="flex gap-8 flex-1">
{item.photos.map((photo, photoIdx) => (
<label
key={photoIdx}
className="flex flex-col items-center gap-3 cursor-pointer flex-1"
>
<div
className={`relative w-full min-w-[280px] h-[200px] border-2 rounded overflow-hidden transition-all bg-muted ${
item.selectedPhotoIndex === photoIdx
? 'border-primary ring-2 ring-primary'
: 'border-border hover:border-primary/50'
}`}
>
<Image
src={photo}
alt={item.name + ' 사진 ' + (photoIdx + 1)}
fill
className="object-contain"
/>
</div>
{isEditMode && (
<input
type="radio"
name={`photo-select-${item.id}`}
checked={item.selectedPhotoIndex === photoIdx}
onChange={() => onPhotoSelect?.(item.id, photoIdx)}
className="w-5 h-5 accent-primary"
/>
)}
</label>
))}
</div>
) : (
<span className="text-muted-foreground text-sm">-</span>
)}
</TableCell>
</TableRow>
))}
{items.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,188 @@
'use client';
import { Check } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import type { ProgressBillingItem } from '../types';
import { MOCK_BILLING_NAMES } from '../types';
interface ProgressBillingItemTableProps {
items: ProgressBillingItem[];
isViewMode: boolean;
isEditMode: boolean;
selectedItems: Set<string>;
onToggleSelection: (itemId: string) => void;
onToggleSelectAll: () => void;
onApplySelected: () => void;
onItemChange: (itemId: string, field: keyof ProgressBillingItem, value: string | number) => void;
}
export function ProgressBillingItemTable({
items,
isViewMode,
isEditMode,
selectedItems,
onToggleSelection,
onToggleSelectAll,
onApplySelected,
onItemChange,
}: ProgressBillingItemTableProps) {
const allSelected = items.length > 0 && items.every((item) => selectedItems.has(item.id));
const handleApply = () => {
onApplySelected();
toast.success('적용이 완료되었습니다.');
};
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div className="flex items-center gap-2">
<CardTitle className="text-lg"> </CardTitle>
<span className="text-sm text-muted-foreground">
{items.length}{selectedItems.size > 0 && `, ${selectedItems.size}건 선택`}
</span>
</div>
{isEditMode && selectedItems.size > 0 && (
<Button size="sm" onClick={handleApply}>
<Check className="h-4 w-4 mr-1" />
</Button>
)}
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px]">
<Checkbox
checked={allSelected}
onCheckedChange={onToggleSelectAll}
disabled={isViewMode}
/>
</TableHead>
<TableHead className="w-[60px]"></TableHead>
<TableHead className="min-w-[120px]"></TableHead>
<TableHead className="min-w-[100px]"></TableHead>
<TableHead className="min-w-[80px]"></TableHead>
<TableHead className="min-w-[60px]"></TableHead>
<TableHead className="min-w-[60px]"></TableHead>
<TableHead className="min-w-[80px]"></TableHead>
<TableHead className="min-w-[100px]"></TableHead>
<TableHead className="min-w-[100px]"></TableHead>
<TableHead className="min-w-[80px]"></TableHead>
<TableHead className="min-w-[100px]"></TableHead>
<TableHead className="min-w-[60px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, index) => (
<TableRow key={item.id}>
<TableCell>
<Checkbox
checked={selectedItems.has(item.id)}
onCheckedChange={() => onToggleSelection(item.id)}
disabled={isViewMode}
/>
</TableCell>
<TableCell>{index + 1}</TableCell>
<TableCell>{item.constructionNumber}</TableCell>
<TableCell>{item.name}</TableCell>
<TableCell>
{isEditMode ? (
<Select
value={item.product}
onValueChange={(value) => onItemChange(item.id, 'product', value)}
>
<SelectTrigger className="min-w-[80px]">
<SelectValue placeholder="제품 선택" />
</SelectTrigger>
<SelectContent>
{MOCK_BILLING_NAMES.map((option) => (
<SelectItem key={option.value} value={option.label}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
item.product
)}
</TableCell>
<TableCell>
{isEditMode ? (
<Input
type="number"
value={item.width}
onChange={(e) => onItemChange(item.id, 'width', Number(e.target.value))}
className="min-w-[50px]"
/>
) : (
item.width.toLocaleString()
)}
</TableCell>
<TableCell>
{isEditMode ? (
<Input
type="number"
value={item.height}
onChange={(e) => onItemChange(item.id, 'height', Number(e.target.value))}
className="min-w-[50px]"
/>
) : (
item.height.toLocaleString()
)}
</TableCell>
<TableCell>{item.workTeamLeader}</TableCell>
<TableCell>{item.constructionStartDate}</TableCell>
<TableCell>{item.constructionEndDate || '-'}</TableCell>
<TableCell>
{isEditMode ? (
<Input
type="number"
step="0.01"
value={item.quantity}
onChange={(e) => onItemChange(item.id, 'quantity', Number(e.target.value))}
className="min-w-[60px]"
/>
) : (
item.quantity.toLocaleString()
)}
</TableCell>
<TableCell>{item.currentBilling.toLocaleString()}</TableCell>
<TableCell>{item.status}</TableCell>
</TableRow>
))}
{items.length === 0 && (
<TableRow>
<TableCell colSpan={13} className="text-center text-muted-foreground py-8">
.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,483 @@
/**
* 기성청구관리 타입 정의
*/
/**
* 기성청구 상태
*/
export type ProgressBillingStatus =
| 'billing_waiting' // 기성청구대기
| 'approval_waiting' // 승인대기
| 'constructor_sent' // 건설사전송
| 'billing_complete'; // 기성청구완료
/**
* 기성청구 상태 옵션
*/
export const PROGRESS_BILLING_STATUS_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: 'billing_waiting', label: '기성청구대기' },
{ value: 'approval_waiting', label: '승인대기' },
{ value: 'constructor_sent', label: '건설사전송' },
{ value: 'billing_complete', label: '기성청구완료' },
] as const;
/**
* 기성청구 상태 라벨
*/
export const PROGRESS_BILLING_STATUS_LABELS: Record<ProgressBillingStatus, string> = {
billing_waiting: '기성청구대기',
approval_waiting: '승인대기',
constructor_sent: '건설사전송',
billing_complete: '기성청구완료',
};
/**
* 기성청구 상태 스타일
*/
export const PROGRESS_BILLING_STATUS_STYLES: Record<ProgressBillingStatus, string> = {
billing_waiting: 'bg-yellow-100 text-yellow-800',
approval_waiting: 'bg-blue-100 text-blue-800',
constructor_sent: 'bg-purple-100 text-purple-800',
billing_complete: 'bg-green-100 text-green-800',
};
/**
* 정렬 옵션
*/
export const PROGRESS_BILLING_SORT_OPTIONS = [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
{ value: 'partnerNameAsc', label: '거래처명 오름차순' },
{ value: 'partnerNameDesc', label: '거래처명 내림차순' },
{ value: 'siteNameAsc', label: '현장명 오름차순' },
{ value: 'siteNameDesc', label: '현장명 내림차순' },
] as const;
/**
* 기성청구 항목 인터페이스
*/
export interface ProgressBilling {
id: string;
billingNumber: string;
partnerId: string;
partnerName: string;
siteId: string;
siteName: string;
round: number;
billingYearMonth: string;
previousBilling: number;
currentBilling: number;
cumulativeBilling: number;
status: ProgressBillingStatus;
createdAt: string;
updatedAt: string;
}
/**
* 기성청구 통계 인터페이스
*/
export interface ProgressBillingStats {
total: number;
contractWaiting: number;
contractComplete: number;
}
/**
* 목업 거래처 목록
*/
export const MOCK_PARTNERS = [
{ value: '1', label: '(주)대한건설' },
{ value: '2', label: '삼성물산' },
{ value: '3', label: '현대건설' },
{ value: '4', label: 'GS건설' },
{ value: '5', label: '대림산업' },
];
/**
* 목업 현장 목록
*/
export const MOCK_SITES = [
{ value: '1', label: '강남 오피스빌딩 신축' },
{ value: '2', label: '판교 데이터센터' },
{ value: '3', label: '송도 물류센터' },
{ value: '4', label: '인천공항 터미널' },
{ value: '5', label: '부산항 창고' },
];
/**
* 거래처별 현장 매핑
*/
export const PARTNER_SITES_MAP: Record<string, typeof MOCK_SITES> = {
'1': [
{ value: '1', label: '강남 오피스빌딩 신축' },
{ value: '2', label: '판교 데이터센터' },
],
'2': [
{ value: '3', label: '송도 물류센터' },
],
'3': [
{ value: '4', label: '인천공항 터미널' },
{ value: '5', label: '부산항 창고' },
],
'4': [
{ value: '1', label: '강남 오피스빌딩 신축' },
{ value: '3', label: '송도 물류센터' },
],
'5': [
{ value: '2', label: '판교 데이터센터' },
{ value: '4', label: '인천공항 터미널' },
],
};
/**
* 필터 옵션 공통 타입
*/
export interface FilterOption {
value: string;
label: string;
}
/**
* 기성청구 내역 아이템 (테이블 로우)
*/
export interface ProgressBillingItem {
id: string;
/** 시공번호 */
constructionNumber: string;
/** 명칭 */
name: string;
/** 제품 */
product: string;
/** 가로 */
width: number;
/** 세로 */
height: number;
/** 작업반장 */
workTeamLeader: string;
/** 시공투입일 */
constructionStartDate: string;
/** 시공완료일 */
constructionEndDate: string;
/** 수량 */
quantity: number;
/** 금회기성 */
currentBilling: number;
/** 상태 */
status: string;
/** 사진 URL 목록 (최대 2장) */
photos: string[];
}
/**
* 사진대지 아이템 (테이블 로우)
*/
export interface PhotoItem {
id: string;
/** 시공번호 */
constructionNumber: string;
/** 명칭 */
name: string;
/** 사진 URL 목록 (2장) */
photos: string[];
/** 선택된 사진 인덱스 */
selectedPhotoIndex?: number;
}
/**
* 기성청구 상세 데이터
*/
export interface ProgressBillingDetail {
/** ID */
id: string;
/** 기성청구번호 */
billingNumber: string;
/** 회차 */
billingRound: number;
/** 기성청구연월 */
billingYearMonth: string;
/** 상태 */
status: ProgressBillingStatus;
/** 거래처 ID */
partnerId: string;
/** 거래처명 */
partnerName: string;
/** 현장명 */
siteName: string;
/** 계약번호 */
contractNumber: string;
/** 계약 ID */
contractId: string;
/** 공사PM */
constructionPM: string;
/** 공사담당자 목록 */
constructionManagers: string[];
/** 기성청구 내역 목록 */
billingItems: ProgressBillingItem[];
/** 사진대지 목록 */
photoItems: PhotoItem[];
/** 생성일 */
createdAt: string;
/** 수정일 */
updatedAt: string;
}
/**
* 기성청구 상세 폼 데이터
*/
export interface ProgressBillingDetailFormData {
/** 기성청구번호 */
billingNumber: string;
/** 회차 */
billingRound: number;
/** 기성청구연월 */
billingYearMonth: string;
/** 상태 */
status: ProgressBillingStatus;
/** 거래처 ID */
partnerId: string;
/** 거래처명 */
partnerName: string;
/** 현장명 */
siteName: string;
/** 계약번호 */
contractNumber: string;
/** 계약 ID */
contractId: string;
/** 공사PM */
constructionPM: string;
/** 공사담당자 목록 */
constructionManagers: string[];
/** 기성청구 내역 목록 */
billingItems: ProgressBillingItem[];
/** 사진대지 목록 */
photoItems: PhotoItem[];
}
/**
* 목업 명칭 목록
*/
export const MOCK_BILLING_NAMES: FilterOption[] = [
{ value: '1', label: '제품명 ▼' },
{ value: '2', label: '강봉A' },
{ value: '3', label: '강봉B' },
{ value: '4', label: '철근A' },
{ value: '5', label: '철근B' },
];
/**
* 빈 기성청구 내역 아이템 생성
*/
export function getEmptyProgressBillingItem(): ProgressBillingItem {
return {
id: String(Date.now()),
constructionNumber: '',
name: '',
product: '',
width: 0,
height: 0,
workTeamLeader: '',
constructionStartDate: '',
constructionEndDate: '',
quantity: 0,
currentBilling: 0,
status: '',
photos: [],
};
}
/**
* 빈 사진대지 아이템 생성
*/
export function getEmptyPhotoItem(): PhotoItem {
return {
id: String(Date.now()),
constructionNumber: '',
name: '',
photos: [],
selectedPhotoIndex: undefined,
};
}
/**
* 빈 기성청구 상세 폼 데이터 생성
*/
export function getEmptyProgressBillingDetailFormData(): ProgressBillingDetailFormData {
return {
billingNumber: '',
billingRound: 1,
billingYearMonth: '',
status: 'billing_waiting',
partnerId: '',
partnerName: '',
siteName: '',
contractNumber: '',
contractId: '',
constructionPM: '',
constructionManagers: [],
billingItems: [],
photoItems: [],
};
}
/**
* ProgressBillingDetail을 폼 데이터로 변환
*/
export function progressBillingDetailToFormData(
detail: ProgressBillingDetail
): ProgressBillingDetailFormData {
return {
billingNumber: detail.billingNumber,
billingRound: detail.billingRound,
billingYearMonth: detail.billingYearMonth,
status: detail.status,
partnerId: detail.partnerId,
partnerName: detail.partnerName,
siteName: detail.siteName,
contractNumber: detail.contractNumber,
contractId: detail.contractId,
constructionPM: detail.constructionPM,
constructionManagers: detail.constructionManagers,
billingItems: detail.billingItems,
photoItems: detail.photoItems,
};
}
/**
* 목업 기성청구 상세 데이터
*/
export const MOCK_PROGRESS_BILLING_DETAIL: ProgressBillingDetail = {
id: '1',
billingNumber: '123123',
billingRound: 1,
billingYearMonth: '2025년 10월',
status: 'billing_waiting',
partnerId: '1',
partnerName: '현장명',
siteName: '현장명',
contractNumber: '123123',
contractId: '1',
constructionPM: '이름',
constructionManagers: ['이름', '이름', '이름'],
billingItems: [
{
id: '1',
constructionNumber: '123123',
name: 'FSS801(주차장)',
product: '제품명 ▼',
width: 2500,
height: 3200,
workTeamLeader: '홍길동',
constructionStartDate: '2025-12-15',
constructionEndDate: '2025-12-15',
quantity: 100,
currentBilling: 1000000,
status: '시공완료',
photos: [],
},
{
id: '2',
constructionNumber: '123123',
name: 'FSS801(주차장)',
product: '제품명 ▼',
width: 2500,
height: 3200,
workTeamLeader: '홍길동',
constructionStartDate: '2025-12-15',
constructionEndDate: '2025-12-15',
quantity: 100,
currentBilling: 1000000,
status: '시공진행',
photos: [],
},
{
id: '3',
constructionNumber: '123123',
name: 'FSS801(주차장)',
product: '제품명 ▼',
width: 2500,
height: 3200,
workTeamLeader: '홍길동',
constructionStartDate: '2025-12-15',
constructionEndDate: '2025-12-15',
quantity: 100,
currentBilling: 1000000,
status: '시공완료',
photos: [],
},
{
id: '4',
constructionNumber: '123123',
name: 'FSS801(주차장)',
product: '제품명 ▼',
width: 2500,
height: 3200,
workTeamLeader: '홍길동',
constructionStartDate: '2025-12-15',
constructionEndDate: '2025-12-15',
quantity: 100,
currentBilling: 1000000,
status: '시공진행',
photos: [],
},
{
id: '5',
constructionNumber: '123123',
name: 'FSS801(주차장)',
product: '제품명 ▼',
width: 2500,
height: 3200,
workTeamLeader: '홍길동',
constructionStartDate: '2025-12-15',
constructionEndDate: '',
quantity: 100,
currentBilling: 1000000,
status: '시공완료',
photos: [],
},
{
id: '6',
constructionNumber: '123123',
name: 'FSS801(주차장)',
product: '제품명 ▼',
width: 2500,
height: 3200,
workTeamLeader: '홍길동',
constructionStartDate: '2025-12-15',
constructionEndDate: '2025-12-15',
quantity: 100,
currentBilling: 1000000,
status: '시공완료',
photos: [],
},
{
id: '7',
constructionNumber: '123123',
name: 'FSS801(주차장)',
product: '제품명 ▼',
width: 2500,
height: 3200,
workTeamLeader: '홍길동',
constructionStartDate: '2025-12-15',
constructionEndDate: '2025-12-15',
quantity: 100,
currentBilling: 1000000,
status: '시공진행',
photos: [],
},
],
photoItems: [
{
id: '1',
constructionNumber: '123123',
name: 'FSS801(주차장)',
photos: [
'https://placehold.co/200x150/e2e8f0/64748b?text=IMG',
'https://placehold.co/200x150/e2e8f0/64748b?text=IMG',
],
selectedPhotoIndex: 0,
},
],
createdAt: '2025-01-10T09:00:00Z',
updatedAt: '2025-01-10T09:00:00Z',
};

View File

@@ -6,15 +6,8 @@ import { Calendar, CalendarDays, CalendarCheck, CalendarClock, Plus, Pencil, Tra
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox';
import { IntegratedListTemplateV2, TableColumn, StatCard } from '@/components/templates/IntegratedListTemplateV2';
import { MultiSelectOption } from '@/components/ui/multi-select-combobox';
import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
import { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { toast } from 'sonner';
@@ -343,6 +336,96 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin
}
}, [selectedItems, loadData]);
// ===== filterConfig 기반 통합 필터 시스템 =====
const filterConfig: FilterFieldConfig[] = useMemo(() => [
{
key: 'partner',
label: '거래처',
type: 'multi',
options: MOCK_PARTNERS.map(o => ({
value: o.value,
label: o.label,
})),
},
{
key: 'type',
label: '구분',
type: 'single',
options: TYPE_OPTIONS.filter(o => o.value !== 'all').map(o => ({
value: o.value,
label: o.label,
})),
allOptionLabel: '전체',
},
{
key: 'attendee',
label: '참석자',
type: 'multi',
options: MOCK_ATTENDEES.map(o => ({
value: o.value,
label: o.label,
})),
},
{
key: 'status',
label: '상태',
type: 'single',
options: STATUS_OPTIONS.filter(o => o.value !== 'all').map(o => ({
value: o.value,
label: o.label,
})),
allOptionLabel: '전체',
},
{
key: 'sortBy',
label: '정렬',
type: 'single',
options: SORT_OPTIONS.map(o => ({
value: o.value,
label: o.label,
})),
allOptionLabel: '최신순',
},
], []);
const filterValues: FilterValues = useMemo(() => ({
partner: partnerFilters,
type: typeFilter,
attendee: attendeeFilters,
status: statusFilter,
sortBy: sortBy,
}), [partnerFilters, typeFilter, attendeeFilters, statusFilter, sortBy]);
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
switch (key) {
case 'partner':
setPartnerFilters(value as string[]);
break;
case 'type':
setTypeFilter(value as string);
break;
case 'attendee':
setAttendeeFilters(value as string[]);
break;
case 'status':
setStatusFilter(value as string);
break;
case 'sortBy':
setSortBy(value as string);
break;
}
setCurrentPage(1);
}, []);
const handleFilterReset = useCallback(() => {
setPartnerFilters([]);
setTypeFilter('all');
setAttendeeFilters([]);
setStatusFilter('all');
setSortBy('latest');
setCurrentPage(1);
}, []);
// 테이블 행 렌더링
const renderTableRow = useCallback(
(briefing: SiteBriefing, index: number, globalIndex: number) => {
@@ -474,77 +557,6 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin
},
];
// 테이블 헤더 액션 (총 건수 + 필터들)
const tableHeaderActions = (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-muted-foreground whitespace-nowrap">
{sortedBriefings.length}
</span>
{/* 거래처 필터 (다중선택) */}
<MultiSelectCombobox
options={MOCK_PARTNERS}
value={partnerFilters}
onChange={setPartnerFilters}
placeholder="거래처"
searchPlaceholder="거래처 검색..."
className="w-[120px]"
/>
{/* 구분 필터 */}
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="구분" />
</SelectTrigger>
<SelectContent>
{TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 참석자 필터 (다중선택) */}
<MultiSelectCombobox
options={MOCK_ATTENDEES}
value={attendeeFilters}
onChange={setAttendeeFilters}
placeholder="참석자"
searchPlaceholder="참석자 검색..."
className="w-[120px]"
/>
{/* 상태 필터 */}
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 정렬 */}
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[130px]">
<SelectValue placeholder="최신순" />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
return (
<>
<IntegratedListTemplateV2
@@ -553,7 +565,11 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin
icon={Calendar}
headerActions={headerActions}
stats={statsCardsData}
tableHeaderActions={tableHeaderActions}
filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="현장설명회 필터"
searchValue={searchValue}
onSearchChange={handleSearchChange}
searchPlaceholder="현장번호, 거래처, 현장명 검색"

View File

@@ -14,7 +14,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox';
import { IntegratedListTemplateV2, TableColumn, StatCard } from '@/components/templates/IntegratedListTemplateV2';
import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
import { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { toast } from 'sonner';
@@ -389,6 +389,66 @@ export default function SiteManagementListClient({
},
];
// ===== filterConfig 기반 통합 필터 시스템 =====
const filterConfig: FilterFieldConfig[] = useMemo(() => [
{
key: 'partner',
label: '거래처',
type: 'multi',
options: MOCK_PARTNERS.map(o => ({
value: o.value,
label: o.label,
})),
},
{
key: 'status',
label: '상태',
type: 'single',
options: SITE_STATUS_OPTIONS.filter(o => o.value !== 'all').map(o => ({
value: o.value,
label: o.label,
})),
},
{
key: 'sortBy',
label: '정렬',
type: 'single',
options: SITE_SORT_OPTIONS.map(o => ({
value: o.value,
label: o.label,
})),
allOptionLabel: '최신순',
},
], []);
const filterValues: FilterValues = useMemo(() => ({
partner: partnerFilters,
status: statusFilter,
sortBy: sortBy,
}), [partnerFilters, statusFilter, sortBy]);
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
switch (key) {
case 'partner':
setPartnerFilters(value as string[]);
break;
case 'status':
setStatusFilter(value as string);
break;
case 'sortBy':
setSortBy(value as string);
break;
}
setCurrentPage(1);
}, []);
const handleFilterReset = useCallback(() => {
setPartnerFilters([]);
setStatusFilter('all');
setSortBy('latest');
setCurrentPage(1);
}, []);
// 테이블 헤더 액션 (총 건수 + 필터들)
const tableHeaderActions = (
<div className="flex items-center gap-2 flex-wrap">
@@ -445,6 +505,11 @@ export default function SiteManagementListClient({
headerActions={headerActions}
stats={statsCardsData}
tableHeaderActions={tableHeaderActions}
filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="현장 필터"
searchValue={searchValue}
onSearchChange={handleSearchChange}
searchPlaceholder="현장번호, 거래처, 현장명, 위치 검색"

View File

@@ -14,7 +14,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox';
import { IntegratedListTemplateV2, TableColumn, StatCard } from '@/components/templates/IntegratedListTemplateV2';
import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
import { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { toast } from 'sonner';
@@ -422,6 +422,66 @@ export default function StructureReviewListClient({
},
];
// ===== filterConfig 기반 통합 필터 시스템 =====
const filterConfig: FilterFieldConfig[] = useMemo(() => [
{
key: 'partner',
label: '거래처',
type: 'multi',
options: MOCK_PARTNERS.map(o => ({
value: o.value,
label: o.label,
})),
},
{
key: 'status',
label: '상태',
type: 'single',
options: STRUCTURE_REVIEW_STATUS_OPTIONS.filter(o => o.value !== 'all').map(o => ({
value: o.value,
label: o.label,
})),
},
{
key: 'sortBy',
label: '정렬',
type: 'single',
options: STRUCTURE_REVIEW_SORT_OPTIONS.map(o => ({
value: o.value,
label: o.label,
})),
allOptionLabel: '최신순',
},
], []);
const filterValues: FilterValues = useMemo(() => ({
partner: partnerFilters,
status: statusFilter,
sortBy: sortBy,
}), [partnerFilters, statusFilter, sortBy]);
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
switch (key) {
case 'partner':
setPartnerFilters(value as string[]);
break;
case 'status':
setStatusFilter(value as string);
break;
case 'sortBy':
setSortBy(value as string);
break;
}
setCurrentPage(1);
}, []);
const handleFilterReset = useCallback(() => {
setPartnerFilters([]);
setStatusFilter('all');
setSortBy('latest');
setCurrentPage(1);
}, []);
// 테이블 헤더 액션
const tableHeaderActions = (
<div className="flex items-center gap-2 flex-wrap">
@@ -478,6 +538,11 @@ export default function StructureReviewListClient({
headerActions={headerActions}
stats={statsCardsData}
tableHeaderActions={tableHeaderActions}
filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="구조검토 필터"
searchValue={searchValue}
onSearchChange={handleSearchChange}
searchPlaceholder="검토번호, 거래처, 현장명, 검토회사 검색"

View File

@@ -0,0 +1,592 @@
'use client';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Zap, Pencil, Trash2, FileText, CheckCircle, Clock } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import { MultiSelectOption } from '@/components/ui/multi-select-combobox';
import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
import { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { toast } from 'sonner';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { Utility, UtilityStats } from './types';
import {
UTILITY_STATUS_OPTIONS,
UTILITY_SORT_OPTIONS,
UTILITY_STATUS_STYLES,
UTILITY_STATUS_LABELS,
MOCK_PARTNERS,
MOCK_SITES,
MOCK_CONSTRUCTION_PM,
MOCK_UTILITY_TYPES,
MOCK_WORK_TEAM_LEADERS,
} from './types';
import {
getUtilityList,
getUtilityStats,
deleteUtility,
deleteUtilities,
} from './actions';
// 테이블 컬럼 정의
const tableColumns: TableColumn[] = [
{ key: 'no', label: '번호', className: 'w-[50px] text-center' },
{ key: 'utilityNumber', label: '공과번호', className: 'w-[120px]' },
{ key: 'partnerName', label: '거래처', className: 'w-[100px]' },
{ key: 'siteName', label: '현장명', className: 'min-w-[120px]' },
{ key: 'constructionPM', label: '공사PM', className: 'w-[80px]' },
{ key: 'utilityType', label: '공과', className: 'w-[80px]' },
{ key: 'scheduledDate', label: '공과예정일시', className: 'w-[110px]' },
{ key: 'amount', label: '금액', className: 'w-[100px] text-right' },
{ key: 'workTeamLeader', label: '작업반장', className: 'w-[80px]' },
{ key: 'constructionStartDate', label: '시공투입일', className: 'w-[100px]' },
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
{ key: 'actions', label: '작업', className: 'w-[100px] text-center' },
];
interface UtilityManagementListClientProps {
initialData?: Utility[];
initialStats?: UtilityStats;
}
export default function UtilityManagementListClient({
initialData = [],
initialStats,
}: UtilityManagementListClientProps) {
const router = useRouter();
// 상태
const [utilities, setUtilities] = useState<Utility[]>(initialData);
const [stats, setStats] = useState<UtilityStats | null>(initialStats || null);
const [searchValue, setSearchValue] = useState('');
// 다중선택 필터 (빈 배열 = 전체)
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
const [siteFilters, setSiteFilters] = useState<string[]>([]);
const [constructionPMFilters, setConstructionPMFilters] = useState<string[]>([]);
const [utilityTypeFilter, setUtilityTypeFilter] = useState<string>('all');
const [workTeamFilters, setWorkTeamFilters] = useState<string[]>([]);
const [statusFilter, setStatusFilter] = useState<string>('all');
const [sortBy, setSortBy] = useState<string>('latest');
const [startDate, setStartDate] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
const [activeStatTab, setActiveStatTab] = useState<'all' | 'waiting' | 'complete'>('all');
const itemsPerPage = 20;
// 데이터 로드
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const [listResult, statsResult] = await Promise.all([
getUtilityList({
size: 1000,
startDate: startDate || undefined,
endDate: endDate || undefined,
}),
getUtilityStats(),
]);
if (listResult.success && listResult.data) {
setUtilities(listResult.data.items);
}
if (statsResult.success && statsResult.data) {
setStats(statsResult.data);
}
} catch {
toast.error('데이터 로드에 실패했습니다.');
} finally {
setIsLoading(false);
}
}, [startDate, endDate]);
// 초기 데이터가 없으면 로드
useEffect(() => {
if (initialData.length === 0) {
loadData();
}
}, [initialData.length, loadData]);
// 필터링된 데이터
const filteredUtilities = useMemo(() => {
return utilities.filter((utility) => {
// 상태 탭 필터
if (activeStatTab === 'waiting' && utility.status !== 'scheduled' && utility.status !== 'issued') return false;
if (activeStatTab === 'complete' && utility.status !== 'completed') return false;
// 상태 필터
if (statusFilter !== 'all' && utility.status !== statusFilter) return false;
// 거래처 필터 (다중선택)
if (partnerFilters.length > 0 && !partnerFilters.includes(utility.partnerId)) {
return false;
}
// 현장명 필터 (다중선택)
if (siteFilters.length > 0 && !siteFilters.includes(utility.siteId)) {
return false;
}
// 공사PM 필터 (다중선택)
if (constructionPMFilters.length > 0 && !constructionPMFilters.includes(utility.constructionPMId)) {
return false;
}
// 공과 유형 필터 (단일선택)
if (utilityTypeFilter !== 'all') {
const matchingType = MOCK_UTILITY_TYPES.find((t) => t.value === utilityTypeFilter);
if (!matchingType || utility.utilityType !== matchingType.label) {
return false;
}
}
// 작업반장 필터 (다중선택)
if (workTeamFilters.length > 0 && !workTeamFilters.includes(utility.workTeamLeaderId)) {
return false;
}
// 검색 필터
if (searchValue) {
const search = searchValue.toLowerCase();
return (
utility.utilityNumber.toLowerCase().includes(search) ||
utility.partnerName.toLowerCase().includes(search) ||
utility.siteName.toLowerCase().includes(search) ||
utility.constructionPM.toLowerCase().includes(search)
);
}
return true;
});
}, [utilities, activeStatTab, statusFilter, partnerFilters, siteFilters, constructionPMFilters, utilityTypeFilter, workTeamFilters, searchValue]);
// 정렬
const sortedUtilities = useMemo(() => {
const sorted = [...filteredUtilities];
switch (sortBy) {
case 'latest':
sorted.sort((a, b) => new Date(b.scheduledDate).getTime() - new Date(a.scheduledDate).getTime());
break;
case 'oldest':
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
break;
case 'partnerNameDesc':
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
break;
case 'issuedDate':
sorted.sort((a, b) => new Date(b.scheduledDate).getTime() - new Date(a.scheduledDate).getTime());
break;
case 'completedDate':
sorted.sort((a, b) => {
if (a.status === 'completed' && b.status !== 'completed') return -1;
if (a.status !== 'completed' && b.status === 'completed') return 1;
return 0;
});
break;
}
return sorted;
}, [filteredUtilities, sortBy]);
// 페이지네이션
const totalPages = Math.ceil(sortedUtilities.length / itemsPerPage);
const paginatedData = useMemo(() => {
const start = (currentPage - 1) * itemsPerPage;
return sortedUtilities.slice(start, start + itemsPerPage);
}, [sortedUtilities, currentPage, itemsPerPage]);
// 핸들러
const handleSearchChange = useCallback((value: string) => {
setSearchValue(value);
setCurrentPage(1);
}, []);
const handleToggleSelection = useCallback((id: string) => {
setSelectedItems((prev) => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
}, []);
const handleToggleSelectAll = useCallback(() => {
if (selectedItems.size === paginatedData.length) {
setSelectedItems(new Set());
} else {
setSelectedItems(new Set(paginatedData.map((u) => u.id)));
}
}, [selectedItems.size, paginatedData]);
const handleRowClick = useCallback(
(utility: Utility) => {
router.push(`/ko/construction/project/utility-management/${utility.id}`);
},
[router]
);
const handleEdit = useCallback(
(e: React.MouseEvent, utilityId: string) => {
e.stopPropagation();
router.push(`/ko/construction/project/utility-management/${utilityId}/edit`);
},
[router]
);
const handleDeleteClick = useCallback((e: React.MouseEvent, utilityId: string) => {
e.stopPropagation();
setDeleteTargetId(utilityId);
setDeleteDialogOpen(true);
}, []);
const handleDeleteConfirm = useCallback(async () => {
if (!deleteTargetId) return;
setIsLoading(true);
try {
const result = await deleteUtility(deleteTargetId);
if (result.success) {
toast.success('공과가 삭제되었습니다.');
setUtilities((prev) => prev.filter((u) => u.id !== deleteTargetId));
setSelectedItems((prev) => {
const newSet = new Set(prev);
newSet.delete(deleteTargetId);
return newSet;
});
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} catch {
toast.error('삭제 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
setDeleteDialogOpen(false);
setDeleteTargetId(null);
}
}, [deleteTargetId]);
const handleBulkDeleteClick = useCallback(() => {
if (selectedItems.size === 0) {
toast.warning('삭제할 항목을 선택해주세요.');
return;
}
setBulkDeleteDialogOpen(true);
}, [selectedItems.size]);
const handleBulkDeleteConfirm = useCallback(async () => {
if (selectedItems.size === 0) return;
setIsLoading(true);
try {
const ids = Array.from(selectedItems);
const result = await deleteUtilities(ids);
if (result.success) {
toast.success(`${result.deletedCount}개 항목이 삭제되었습니다.`);
await loadData();
setSelectedItems(new Set());
} else {
toast.error(result.error || '일괄 삭제에 실패했습니다.');
}
} catch {
toast.error('일괄 삭제 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
setBulkDeleteDialogOpen(false);
}
}, [selectedItems, loadData]);
// 날짜 포맷
const formatDate = (dateStr: string | null) => {
if (!dateStr) return '-';
return dateStr.split('T')[0];
};
// 금액 포맷
const formatAmount = (amount: number) => {
return amount.toLocaleString('ko-KR') + '원';
};
// 테이블 행 렌더링
const renderTableRow = useCallback(
(utility: Utility, index: number, globalIndex: number) => {
const isSelected = selectedItems.has(utility.id);
return (
<TableRow
key={utility.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleRowClick(utility)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => handleToggleSelection(utility.id)}
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell>{utility.utilityNumber}</TableCell>
<TableCell>{utility.partnerName}</TableCell>
<TableCell>{utility.siteName}</TableCell>
<TableCell>{utility.constructionPM}</TableCell>
<TableCell>{utility.utilityType}</TableCell>
<TableCell>{formatDate(utility.scheduledDate)}</TableCell>
<TableCell className="text-right">{formatAmount(utility.amount)}</TableCell>
<TableCell>{utility.workTeamLeader}</TableCell>
<TableCell>{formatDate(utility.constructionStartDate)}</TableCell>
<TableCell className="text-center">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${UTILITY_STATUS_STYLES[utility.status]}`}>
{UTILITY_STATUS_LABELS[utility.status]}
</span>
</TableCell>
<TableCell className="text-center">
{isSelected && (
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => handleEdit(e, utility.id)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={(e) => handleDeleteClick(e, utility.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</TableCell>
</TableRow>
);
},
[selectedItems, handleToggleSelection, handleRowClick, handleEdit, handleDeleteClick]
);
// 모바일 카드 렌더링
const renderMobileCard = useCallback(
(utility: Utility, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
return (
<MobileCard
title={utility.siteName}
subtitle={utility.utilityNumber}
badge={UTILITY_STATUS_LABELS[utility.status]}
badgeVariant="secondary"
isSelected={isSelected}
onToggle={onToggle}
onClick={() => handleRowClick(utility)}
details={[
{ label: '거래처', value: utility.partnerName },
{ label: '공사PM', value: utility.constructionPM },
{ label: '금액', value: formatAmount(utility.amount) },
]}
/>
);
},
[handleRowClick]
);
// 헤더 액션 (날짜 선택 + 날짜 버튼 - DateRangeSelector에 내장)
const headerActions = (
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
);
// Stats 카드 데이터 (전체 계약, 계약 대기, 계약 완료)
const statsCardsData: StatCard[] = [
{
label: '전체 계약',
value: stats?.totalContract ?? 0,
icon: FileText,
iconColor: 'text-blue-600',
onClick: () => setActiveStatTab('all'),
isActive: activeStatTab === 'all',
},
{
label: '계약 대기',
value: stats?.contractWaiting ?? 0,
icon: Clock,
iconColor: 'text-yellow-600',
onClick: () => setActiveStatTab('waiting'),
isActive: activeStatTab === 'waiting',
},
{
label: '계약 완료',
value: stats?.contractComplete ?? 0,
icon: CheckCircle,
iconColor: 'text-green-600',
onClick: () => setActiveStatTab('complete'),
isActive: activeStatTab === 'complete',
},
];
// 필터 옵션들
const partnerOptions: MultiSelectOption[] = useMemo(() => MOCK_PARTNERS, []);
const siteOptions: MultiSelectOption[] = useMemo(() => MOCK_SITES, []);
const constructionPMOptions: MultiSelectOption[] = useMemo(() => MOCK_CONSTRUCTION_PM, []);
const utilityTypeOptions: MultiSelectOption[] = useMemo(() => MOCK_UTILITY_TYPES, []);
const workTeamOptions: MultiSelectOption[] = useMemo(() => MOCK_WORK_TEAM_LEADERS, []);
// 필터 설정 (7개)
const filterConfig: FilterFieldConfig[] = useMemo(() => [
{ key: 'partners', label: '거래처', type: 'multi', options: partnerOptions },
{ key: 'sites', label: '현장명', type: 'multi', options: siteOptions },
{ key: 'constructionPMs', label: '공사PM', type: 'multi', options: constructionPMOptions },
{ key: 'utilityType', label: '공과', type: 'single', options: utilityTypeOptions },
{ key: 'workTeamLeaders', label: '작업반장', type: 'multi', options: workTeamOptions },
{ key: 'status', label: '상태', type: 'single', options: UTILITY_STATUS_OPTIONS.filter(opt => opt.value !== 'all') },
{ key: 'sortBy', label: '정렬', type: 'single', options: UTILITY_SORT_OPTIONS.map(o => ({ value: o.value, label: o.label })), allOptionLabel: '최신순' },
], [partnerOptions, siteOptions, constructionPMOptions, utilityTypeOptions, workTeamOptions]);
// filterValues 객체
const filterValues: FilterValues = useMemo(() => ({
partners: partnerFilters,
sites: siteFilters,
constructionPMs: constructionPMFilters,
utilityType: utilityTypeFilter,
workTeamLeaders: workTeamFilters,
status: statusFilter,
sortBy: sortBy,
}), [partnerFilters, siteFilters, constructionPMFilters, utilityTypeFilter, workTeamFilters, statusFilter, sortBy]);
// 필터 변경 핸들러
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
switch (key) {
case 'partners':
setPartnerFilters(value as string[]);
break;
case 'sites':
setSiteFilters(value as string[]);
break;
case 'constructionPMs':
setConstructionPMFilters(value as string[]);
break;
case 'utilityType':
setUtilityTypeFilter(value as string);
break;
case 'workTeamLeaders':
setWorkTeamFilters(value as string[]);
break;
case 'status':
setStatusFilter(value as string);
break;
case 'sortBy':
setSortBy(value as string);
break;
}
setCurrentPage(1);
}, []);
// 필터 초기화 핸들러
const handleFilterReset = useCallback(() => {
setPartnerFilters([]);
setSiteFilters([]);
setConstructionPMFilters([]);
setUtilityTypeFilter('all');
setWorkTeamFilters([]);
setStatusFilter('all');
setSortBy('latest');
setCurrentPage(1);
}, []);
// 테이블 헤더 추가 액션
const tableHeaderActions = (
<span className="text-sm text-muted-foreground whitespace-nowrap">
{sortedUtilities.length}
</span>
);
return (
<>
<IntegratedListTemplateV2
title="공과관리"
description="공과 목록을 관리합니다"
icon={Zap}
headerActions={headerActions}
stats={statsCardsData}
filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="공과 필터"
tableHeaderActions={tableHeaderActions}
searchValue={searchValue}
onSearchChange={handleSearchChange}
searchPlaceholder="공과번호, 거래처, 현장명, 공사PM 검색"
tableColumns={tableColumns}
data={paginatedData}
allData={sortedUtilities}
getItemId={(item) => item.id}
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
selectedItems={selectedItems}
onToggleSelection={handleToggleSelection}
onToggleSelectAll={handleToggleSelectAll}
onBulkDelete={handleBulkDeleteClick}
pagination={{
currentPage,
totalPages,
totalItems: sortedUtilities.length,
itemsPerPage,
onPageChange: setCurrentPage,
}}
/>
{/* 단일 삭제 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteConfirm}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 일괄 삭제 다이얼로그 */}
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{selectedItems.size} ? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleBulkDeleteConfirm}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,286 @@
'use server';
import type { Utility, UtilityStats, UtilityStatus } from './types';
import { format, addDays, subDays, subMonths } from 'date-fns';
/**
* 목업 공과 데이터 생성 (고정 데이터)
*/
function generateMockUtilities(): Utility[] {
// types.ts MOCK_PARTNERS와 일치
const partners = [
{ id: '1', name: '(주)대한건설' },
{ id: '2', name: '삼성물산' },
{ id: '3', name: '현대건설' },
{ id: '4', name: 'GS건설' },
{ id: '5', name: '대림산업' },
];
// types.ts MOCK_SITES와 일치
const sites = [
{ id: '1', name: '강남 오피스빌딩 신축' },
{ id: '2', name: '판교 데이터센터' },
{ id: '3', name: '송도 물류센터' },
{ id: '4', name: '인천공항 터미널' },
{ id: '5', name: '부산항 창고' },
];
// types.ts MOCK_CONSTRUCTION_PM과 일치
const constructionPMs = [
{ id: '1', name: '홍길동' },
{ id: '2', name: '김철수' },
{ id: '3', name: '이영희' },
{ id: '4', name: '박민수' },
];
// types.ts MOCK_UTILITY_TYPES와 일치
const utilityTypes = ['전기공과', '수도공과', '가스공과', '통신공과', '난방공과'];
// types.ts MOCK_WORK_TEAM_LEADERS와 일치
const workTeamLeaders = [
{ id: '1', name: '이반장' },
{ id: '2', name: '김반장' },
{ id: '3', name: '박반장' },
{ id: '4', name: '최반장' },
];
const statuses: UtilityStatus[] = ['scheduled', 'issued', 'completed', 'expired'];
const utilities: Utility[] = [];
// 고정 기준일 (2026-01-06)
const baseDate = new Date(2026, 0, 6);
for (let i = 0; i < 50; i++) {
const partner = partners[i % partners.length];
const site = sites[i % sites.length];
const pm = constructionPMs[i % constructionPMs.length];
const workTeamLeader = workTeamLeaders[i % workTeamLeaders.length];
const status = statuses[i % statuses.length];
const utilityType = utilityTypes[i % utilityTypes.length];
// 날짜도 index 기반으로 고정
const monthOffset = i % 3;
const dayOffset = (i * 3) % 30;
const periodStart = subMonths(addDays(baseDate, -dayOffset), monthOffset);
const periodEnd = addDays(periodStart, 10 + (i % 20));
const scheduledDate = addDays(periodStart, i % 5);
const constructionStartDate = addDays(periodStart, i % 7);
utilities.push({
id: `utility-${i + 1}`,
utilityNumber: `UTL-${2026}-${String(i + 1).padStart(4, '0')}`,
partnerId: partner.id,
partnerName: partner.name,
siteId: site.id,
siteName: site.name,
constructionPMId: pm.id,
constructionPM: pm.name,
utilityType,
scheduledDate: format(scheduledDate, 'yyyy-MM-dd'),
amount: 100000 + (i * 50000) % 900000, // 100,000 ~ 1,000,000 고정 패턴
workTeamLeaderId: workTeamLeader.id,
workTeamLeader: workTeamLeader.name,
constructionStartDate: format(constructionStartDate, 'yyyy-MM-dd'),
status,
periodStart: format(periodStart, 'yyyy-MM-dd'),
periodEnd: format(periodEnd, 'yyyy-MM-dd'),
createdAt: format(subDays(periodStart, i % 10), 'yyyy-MM-dd\'T\'HH:mm:ss'),
updatedAt: format(baseDate, 'yyyy-MM-dd\'T\'HH:mm:ss'),
});
}
return utilities;
}
// 캐시된 목업 데이터
let cachedUtilities: Utility[] | null = null;
function getMockUtilities(): Utility[] {
if (!cachedUtilities) {
cachedUtilities = generateMockUtilities();
}
return cachedUtilities;
}
/**
* 공과 목록 조회
*/
export async function getUtilityList(params?: {
size?: number;
page?: number;
startDate?: string;
endDate?: string;
status?: string;
partnerId?: string;
search?: string;
}): Promise<{
success: boolean;
data?: { items: Utility[]; total: number };
error?: string;
}> {
try {
let utilities = getMockUtilities();
// 날짜 필터
if (params?.startDate && params?.endDate) {
utilities = utilities.filter((utility) => {
return utility.periodStart >= params.startDate! && utility.periodEnd <= params.endDate!;
});
}
// 상태 필터
if (params?.status && params.status !== 'all') {
utilities = utilities.filter((utility) => utility.status === params.status);
}
// 거래처 필터
if (params?.partnerId && params.partnerId !== 'all') {
utilities = utilities.filter((utility) => utility.partnerId === params.partnerId);
}
// 검색
if (params?.search) {
const search = params.search.toLowerCase();
utilities = utilities.filter(
(utility) =>
utility.utilityNumber.toLowerCase().includes(search) ||
utility.partnerName.toLowerCase().includes(search) ||
utility.siteName.toLowerCase().includes(search) ||
utility.constructionPM.toLowerCase().includes(search)
);
}
// 페이지네이션
const page = params?.page || 1;
const size = params?.size || 1000;
const start = (page - 1) * size;
const paginatedUtilities = utilities.slice(start, start + size);
return {
success: true,
data: {
items: paginatedUtilities,
total: utilities.length,
},
};
} catch {
return {
success: false,
error: '공과 목록 조회에 실패했습니다.',
};
}
}
/**
* 공과 통계 조회 (상단 카드용)
*/
export async function getUtilityStats(): Promise<{
success: boolean;
data?: UtilityStats;
error?: string;
}> {
try {
const utilities = getMockUtilities();
// 상단 카드: 전체 계약, 계약 대기, 계약 완료
const stats: UtilityStats = {
totalContract: utilities.length,
contractWaiting: utilities.filter((u) => u.status === 'scheduled' || u.status === 'issued').length,
contractComplete: utilities.filter((u) => u.status === 'completed').length,
};
return {
success: true,
data: stats,
};
} catch {
return {
success: false,
error: '공과 통계 조회에 실패했습니다.',
};
}
}
/**
* 공과 삭제
*/
export async function deleteUtility(id: string): Promise<{
success: boolean;
error?: string;
}> {
try {
if (cachedUtilities) {
cachedUtilities = cachedUtilities.filter((u) => u.id !== id);
}
return { success: true };
} catch {
return {
success: false,
error: '공과 삭제에 실패했습니다.',
};
}
}
/**
* 공과 일괄 삭제
*/
export async function deleteUtilities(ids: string[]): Promise<{
success: boolean;
deletedCount?: number;
error?: string;
}> {
try {
if (cachedUtilities) {
const beforeCount = cachedUtilities.length;
cachedUtilities = cachedUtilities.filter((u) => !ids.includes(u.id));
const deletedCount = beforeCount - cachedUtilities.length;
return {
success: true,
deletedCount,
};
}
return {
success: true,
deletedCount: ids.length,
};
} catch {
return {
success: false,
error: '공과 일괄 삭제에 실패했습니다.',
};
}
}
/**
* 공과 상세 조회
*/
export async function getUtilityDetail(id: string): Promise<{
success: boolean;
data?: Utility;
error?: string;
}> {
try {
const utilities = getMockUtilities();
const utility = utilities.find((u) => u.id === id);
if (!utility) {
return {
success: false,
error: '공과를 찾을 수 없습니다.',
};
}
return {
success: true,
data: utility,
};
} catch {
return {
success: false,
error: '공과 상세 조회에 실패했습니다.',
};
}
}

View File

@@ -0,0 +1,32 @@
/**
* 공과관리 컴포넌트
*/
export { default as UtilityManagementListClient } from './UtilityManagementListClient';
export type {
Utility,
UtilityStats,
UtilityStatus,
FilterOption,
} from './types';
export {
UTILITY_STATUS_OPTIONS,
UTILITY_STATUS_LABELS,
UTILITY_STATUS_STYLES,
UTILITY_SORT_OPTIONS,
MOCK_PARTNERS,
MOCK_SITES,
MOCK_CONSTRUCTION_PM,
MOCK_UTILITY_TYPES,
MOCK_WORK_TEAM_LEADERS,
} from './types';
export {
getUtilityList,
getUtilityStats,
deleteUtility,
deleteUtilities,
getUtilityDetail,
} from './actions';

View File

@@ -0,0 +1,209 @@
/**
* 공과관리 타입 정의
*/
/**
* 공과 상태
*/
export type UtilityStatus = 'scheduled' | 'issued' | 'completed' | 'expired';
/**
* 공과 데이터
*/
export interface Utility {
/** 공과 ID */
id: string;
/** 공과번호 */
utilityNumber: string;
/** 거래처 ID */
partnerId: string;
/** 거래처명 */
partnerName: string;
/** 현장 ID */
siteId: string;
/** 현장명 */
siteName: string;
/** 공사PM ID */
constructionPMId: string;
/** 공사PM */
constructionPM: string;
/** 공과 유형 (품목유형의 공과인 목록) */
utilityType: string;
/** 공과예정일시 */
scheduledDate: string;
/** 금액 */
amount: number;
/** 작업반장 ID */
workTeamLeaderId: string;
/** 작업반장 */
workTeamLeader: string;
/** 시공투입일 */
constructionStartDate: string;
/** 상태 */
status: UtilityStatus;
/** 기간 (시작일) - 달력용 */
periodStart: string;
/** 기간 (종료일) - 달력용 */
periodEnd: string;
/** 생성일 */
createdAt: string;
/** 수정일 */
updatedAt: string;
}
/**
* 공과 통계 (상단 카드)
*/
export interface UtilityStats {
/** 전체 계약 */
totalContract: number;
/** 계약 대기 */
contractWaiting: number;
/** 계약 완료 */
contractComplete: number;
}
/**
* 공과 상태 옵션
*/
export const UTILITY_STATUS_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: 'scheduled', label: '공과예정' },
{ value: 'issued', label: '공과발행' },
{ value: 'completed', label: '공과완료' },
{ value: 'expired', label: '공과만료' },
] as const;
/**
* 공과 상태 라벨
*/
export const UTILITY_STATUS_LABELS: Record<UtilityStatus, string> = {
scheduled: '공과예정',
issued: '공과발행',
completed: '공과완료',
expired: '공과만료',
};
/**
* 공과 상태 스타일
*/
export const UTILITY_STATUS_STYLES: Record<UtilityStatus, string> = {
scheduled: 'bg-yellow-100 text-yellow-800',
issued: 'bg-blue-100 text-blue-800',
completed: 'bg-green-100 text-green-800',
expired: 'bg-red-100 text-red-800',
};
/**
* 정렬 옵션
*/
export const UTILITY_SORT_OPTIONS = [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
{ value: 'partnerNameDesc', label: '거래처명 내림차순' },
{ value: 'issuedDate', label: '공과발행일' },
{ value: 'completedDate', label: '공과완료' },
] as const;
/**
* 스케줄 색상 팔레트 (10가지 고정 색상)
*/
export const SCHEDULE_COLOR_PALETTE = [
{ name: 'blue', bg: 'bg-blue-500', text: 'text-white', hex: '#3b82f6' },
{ name: 'green', bg: 'bg-green-500', text: 'text-white', hex: '#22c55e' },
{ name: 'yellow', bg: 'bg-yellow-500', text: 'text-white', hex: '#eab308' },
{ name: 'red', bg: 'bg-red-500', text: 'text-white', hex: '#ef4444' },
{ name: 'purple', bg: 'bg-purple-500', text: 'text-white', hex: '#a855f7' },
{ name: 'pink', bg: 'bg-pink-500', text: 'text-white', hex: '#ec4899' },
{ name: 'orange', bg: 'bg-orange-500', text: 'text-white', hex: '#f97316' },
{ name: 'teal', bg: 'bg-teal-500', text: 'text-white', hex: '#14b8a6' },
{ name: 'indigo', bg: 'bg-indigo-500', text: 'text-white', hex: '#6366f1' },
{ name: 'cyan', bg: 'bg-cyan-500', text: 'text-white', hex: '#06b6d4' },
] as const;
/**
* 공사PM별 색상 매핑
*/
const CONSTRUCTION_PM_COLOR_MAP: Record<string, string> = {
'홍길동': 'blue',
'김철수': 'green',
'이영희': 'pink',
'박민수': 'purple',
};
/**
* 공사PM 이름 기반 스케줄 색상 반환
*/
export function getScheduleColorByPM(pmName: string): string {
if (CONSTRUCTION_PM_COLOR_MAP[pmName]) {
return CONSTRUCTION_PM_COLOR_MAP[pmName];
}
let hash = 0;
for (let i = 0; i < pmName.length; i++) {
hash = pmName.charCodeAt(i) + ((hash << 5) - hash);
}
const index = Math.abs(hash) % SCHEDULE_COLOR_PALETTE.length;
return SCHEDULE_COLOR_PALETTE[index].name;
}
/**
* 필터 옵션 공통 타입
*/
export interface FilterOption {
value: string;
label: string;
}
/**
* 목업 거래처 목록 (매입 거래처)
*/
export const MOCK_PARTNERS: FilterOption[] = [
{ value: '1', label: '(주)대한건설' },
{ value: '2', label: '삼성물산' },
{ value: '3', label: '현대건설' },
{ value: '4', label: 'GS건설' },
{ value: '5', label: '대림산업' },
];
/**
* 목업 현장 목록
*/
export const MOCK_SITES: FilterOption[] = [
{ value: '1', label: '강남 오피스빌딩 신축' },
{ value: '2', label: '판교 데이터센터' },
{ value: '3', label: '송도 물류센터' },
{ value: '4', label: '인천공항 터미널' },
{ value: '5', label: '부산항 창고' },
];
/**
* 목업 공사PM 목록
*/
export const MOCK_CONSTRUCTION_PM: FilterOption[] = [
{ value: '1', label: '홍길동' },
{ value: '2', label: '김철수' },
{ value: '3', label: '이영희' },
{ value: '4', label: '박민수' },
];
/**
* 목업 공과 유형 목록 (품목유형의 공과인 목록)
*/
export const MOCK_UTILITY_TYPES: FilterOption[] = [
{ value: '1', label: '전기공과' },
{ value: '2', label: '수도공과' },
{ value: '3', label: '가스공과' },
{ value: '4', label: '통신공과' },
{ value: '5', label: '난방공과' },
];
/**
* 목업 작업반장 목록
*/
export const MOCK_WORK_TEAM_LEADERS: FilterOption[] = [
{ value: '1', label: '이반장' },
{ value: '2', label: '김반장' },
{ value: '3', label: '박반장' },
{ value: '4', label: '최반장' },
];

View File

@@ -0,0 +1,543 @@
'use client';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Users, Eye, FileText, 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 { MultiSelectOption } from '@/components/ui/multi-select-combobox';
import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
import { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { toast } from 'sonner';
import type { WorkerStatus, WorkerStatusStats } from './types';
import {
WORKER_CATEGORY_OPTIONS,
WORKER_CATEGORY_LABELS,
WORKER_STATUS_OPTIONS,
WORKER_STATUS_LABELS,
WORKER_STATUS_STYLES,
WORKER_SORT_OPTIONS,
MOCK_WORKER_PARTNERS,
MOCK_WORKER_SITES,
MOCK_WORKER_DEPARTMENTS,
MOCK_WORKER_NAMES,
} from './types';
import { getWorkerStatusList, getWorkerStatusStats } from './actions';
// 테이블 컬럼 정의
const tableColumns: TableColumn[] = [
{ key: 'no', label: '번호', className: 'w-[50px] text-center' },
{ key: 'partnerName', label: '거래처', className: 'w-[100px]' },
{ key: 'siteName', label: '현장', className: 'min-w-[100px]' },
{ key: 'category', label: '구분', className: 'w-[80px] text-center' },
{ key: 'department', label: '부서', className: 'w-[80px]' },
{ key: 'workerName', label: '이름', className: 'w-[80px]' },
{ key: 'baseDate', label: '기준일', className: 'w-[100px]' },
{ key: 'checkInTime', label: '출근', className: 'w-[80px] text-center' },
{ key: 'checkOutTime', label: '퇴근', className: 'w-[80px] text-center' },
{ key: 'constructionNumber', label: '시공번호', className: 'w-[120px]' },
{ key: 'laborCost', label: '노임', className: 'w-[100px] text-right' },
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
{ key: 'actions', label: '작업', className: 'w-[60px] text-center' },
];
interface WorkerStatusListClientProps {
initialData?: WorkerStatus[];
initialStats?: WorkerStatusStats;
}
export default function WorkerStatusListClient({
initialData = [],
initialStats,
}: WorkerStatusListClientProps) {
const router = useRouter();
// 상태
const [workers, setWorkers] = useState<WorkerStatus[]>(initialData);
const [stats, setStats] = useState<WorkerStatusStats | null>(initialStats || null);
const [searchValue, setSearchValue] = useState('');
// 다중선택 필터 (빈 배열 = 전체)
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
const [siteFilters, setSiteFilters] = useState<string[]>([]);
const [departmentFilters, setDepartmentFilters] = useState<string[]>([]);
const [nameFilters, setNameFilters] = useState<string[]>([]);
// 단일선택 필터
const [categoryFilter, setCategoryFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [sortBy, setSortBy] = useState<string>('latest');
const [startDate, setStartDate] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [activeStatTab, setActiveStatTab] = useState<'all' | 'all_contract' | 'pending' | 'completed'>('all');
const itemsPerPage = 20;
// 데이터 로드
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const [listResult, statsResult] = await Promise.all([
getWorkerStatusList({
size: 1000,
startDate: startDate || undefined,
endDate: endDate || undefined,
}),
getWorkerStatusStats(),
]);
if (listResult.success && listResult.data) {
setWorkers(listResult.data.items);
}
if (statsResult.success && statsResult.data) {
setStats(statsResult.data);
}
} catch {
toast.error('데이터 로드에 실패했습니다.');
} finally {
setIsLoading(false);
}
}, [startDate, endDate]);
// 초기 데이터가 없으면 로드
useEffect(() => {
if (initialData.length === 0) {
loadData();
}
}, [initialData.length, loadData]);
// 다중선택 필터 옵션 변환 (MultiSelectCombobox용)
const partnerOptions: MultiSelectOption[] = useMemo(() =>
MOCK_WORKER_PARTNERS.map(p => ({ value: p.value, label: p.label })),
[]);
const siteOptions: MultiSelectOption[] = useMemo(() =>
MOCK_WORKER_SITES.map(s => ({ value: s.value, label: s.label })),
[]);
const departmentOptions: MultiSelectOption[] = useMemo(() =>
MOCK_WORKER_DEPARTMENTS.map(d => ({ value: d.value, label: d.label })),
[]);
const nameOptions: MultiSelectOption[] = useMemo(() =>
MOCK_WORKER_NAMES.map(n => ({ value: n.value, label: n.label })),
[]);
// 필터링된 데이터
const filteredWorkers = useMemo(() => {
return workers.filter((item) => {
// 상태 탭 필터 (계약상태)
if (activeStatTab !== 'all' && item.contractStatus !== activeStatTab) return false;
// 구분 필터
if (categoryFilter !== 'all' && item.category !== categoryFilter) return false;
// 상태 필터 (출근상태)
if (statusFilter !== 'all' && item.status !== statusFilter) return false;
// 거래처 필터 (다중선택)
if (partnerFilters.length > 0) {
const matchingPartner = MOCK_WORKER_PARTNERS.find((p) => p.label === item.partnerName);
if (!matchingPartner || !partnerFilters.includes(matchingPartner.value)) {
return false;
}
}
// 현장 필터 (다중선택)
if (siteFilters.length > 0) {
const matchingSite = MOCK_WORKER_SITES.find((s) => s.label === item.siteName);
if (!matchingSite || !siteFilters.includes(matchingSite.value)) {
return false;
}
}
// 부서 필터 (다중선택)
if (departmentFilters.length > 0) {
const matchingDept = MOCK_WORKER_DEPARTMENTS.find((d) => d.label === item.department);
if (!matchingDept || !departmentFilters.includes(matchingDept.value)) {
return false;
}
}
// 이름 필터 (다중선택)
if (nameFilters.length > 0) {
const matchingName = MOCK_WORKER_NAMES.find((n) => n.label === item.workerName);
if (!matchingName || !nameFilters.includes(matchingName.value)) {
return false;
}
}
// 검색 필터
if (searchValue) {
const search = searchValue.toLowerCase();
return (
item.partnerName.toLowerCase().includes(search) ||
item.siteName.toLowerCase().includes(search) ||
item.department.toLowerCase().includes(search) ||
item.workerName.toLowerCase().includes(search) ||
item.constructionNumber.toLowerCase().includes(search)
);
}
return true;
});
}, [workers, activeStatTab, categoryFilter, statusFilter, partnerFilters, siteFilters, departmentFilters, nameFilters, searchValue]);
// 정렬
const sortedWorkers = useMemo(() => {
const sorted = [...filteredWorkers];
switch (sortBy) {
case 'latest':
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
break;
case 'oldest':
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
break;
case 'partnerAsc':
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName));
break;
case 'partnerDesc':
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName));
break;
case 'siteAsc':
sorted.sort((a, b) => a.siteName.localeCompare(b.siteName));
break;
case 'siteDesc':
sorted.sort((a, b) => b.siteName.localeCompare(a.siteName));
break;
}
return sorted;
}, [filteredWorkers, sortBy]);
// 페이지네이션
const totalPages = Math.ceil(sortedWorkers.length / itemsPerPage);
const paginatedData = useMemo(() => {
const start = (currentPage - 1) * itemsPerPage;
return sortedWorkers.slice(start, start + itemsPerPage);
}, [sortedWorkers, currentPage, itemsPerPage]);
// 핸들러
const handleSearchChange = useCallback((value: string) => {
setSearchValue(value);
setCurrentPage(1);
}, []);
const handleToggleSelection = useCallback((id: string) => {
setSelectedItems((prev) => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
}, []);
const handleToggleSelectAll = useCallback(() => {
if (selectedItems.size === paginatedData.length) {
setSelectedItems(new Set());
} else {
setSelectedItems(new Set(paginatedData.map((c) => c.id)));
}
}, [selectedItems.size, paginatedData]);
const handleViewDetail = useCallback(
(e: React.MouseEvent, itemId: string) => {
e.stopPropagation();
router.push(`/ko/construction/project/worker-status/${itemId}`);
},
[router]
);
const handleRowClick = useCallback(
(item: WorkerStatus) => {
router.push(`/ko/construction/project/worker-status/${item.id}`);
},
[router]
);
// 시간 포맷
const formatTime = (timeStr: string | null) => {
if (!timeStr) return '-';
return timeStr;
};
// 금액 포맷
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('ko-KR').format(amount);
};
// 테이블 행 렌더링
const renderTableRow = useCallback(
(item: WorkerStatus, index: number, globalIndex: number) => {
const isSelected = selectedItems.has(item.id);
return (
<TableRow
key={item.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleRowClick(item)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => handleToggleSelection(item.id)}
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell>{item.partnerName}</TableCell>
<TableCell>{item.siteName}</TableCell>
<TableCell className="text-center">
<span className="px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-700">
{WORKER_CATEGORY_LABELS[item.category]}
</span>
</TableCell>
<TableCell>{item.department}</TableCell>
<TableCell>{item.workerName}</TableCell>
<TableCell>{item.baseDate}</TableCell>
<TableCell className="text-center">{formatTime(item.checkInTime)}</TableCell>
<TableCell className="text-center">{formatTime(item.checkOutTime)}</TableCell>
<TableCell>{item.constructionNumber}</TableCell>
<TableCell className="text-right">{formatCurrency(item.laborCost)}</TableCell>
<TableCell className="text-center">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${WORKER_STATUS_STYLES[item.status]}`}>
{WORKER_STATUS_LABELS[item.status]}
</span>
</TableCell>
<TableCell className="text-center">
{isSelected && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => handleViewDetail(e, item.id)}
>
<Eye className="h-4 w-4" />
</Button>
)}
</TableCell>
</TableRow>
);
},
[selectedItems, handleToggleSelection, handleRowClick, handleViewDetail]
);
// 모바일 카드 렌더링
const renderMobileCard = useCallback(
(item: WorkerStatus, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
return (
<MobileCard
title={item.workerName}
subtitle={`${item.partnerName} - ${item.siteName}`}
badge={WORKER_STATUS_LABELS[item.status]}
badgeVariant="secondary"
isSelected={isSelected}
onToggle={onToggle}
onClick={() => handleRowClick(item)}
details={[
{ label: '구분', value: WORKER_CATEGORY_LABELS[item.category] },
{ label: '부서', value: item.department },
{ label: '기준일', value: item.baseDate },
{ label: '노임', value: formatCurrency(item.laborCost) },
]}
/>
);
},
[handleRowClick]
);
// 헤더 액션 (DateRangeSelector)
const headerActions = (
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
);
// 통계 카드 클릭 핸들러
const handleStatClick = useCallback((tab: 'all' | 'all_contract' | 'pending' | 'completed') => {
setActiveStatTab(tab);
setCurrentPage(1);
}, []);
// 통계 카드 데이터
const statsCardsData: StatCard[] = [
{
label: '전체 계약',
value: stats?.allContract ?? 0,
icon: FileText,
iconColor: 'text-blue-600',
onClick: () => handleStatClick('all_contract'),
isActive: activeStatTab === 'all_contract',
},
{
label: '계약대기',
value: stats?.pending ?? 0,
icon: Clock,
iconColor: 'text-yellow-600',
onClick: () => handleStatClick('pending'),
isActive: activeStatTab === 'pending',
},
{
label: '계약완료',
value: stats?.completed ?? 0,
icon: CheckCircle,
iconColor: 'text-green-600',
onClick: () => handleStatClick('completed'),
isActive: activeStatTab === 'completed',
},
];
// ===== filterConfig 기반 통합 필터 시스템 =====
const filterConfig: FilterFieldConfig[] = useMemo(() => [
{
key: 'partner',
label: '거래처',
type: 'multi',
options: partnerOptions.map(o => ({
value: o.value,
label: o.label,
})),
},
{
key: 'site',
label: '현장명',
type: 'multi',
options: siteOptions.map(o => ({
value: o.value,
label: o.label,
})),
},
{
key: 'category',
label: '구분',
type: 'single',
options: WORKER_CATEGORY_OPTIONS.filter(o => o.value !== 'all').map(o => ({
value: o.value,
label: o.label,
})),
allOptionLabel: '전체',
},
{
key: 'department',
label: '부서',
type: 'multi',
options: departmentOptions.map(o => ({
value: o.value,
label: o.label,
})),
},
{
key: 'name',
label: '이름',
type: 'multi',
options: nameOptions.map(o => ({
value: o.value,
label: o.label,
})),
},
{
key: 'status',
label: '상태',
type: 'single',
options: WORKER_STATUS_OPTIONS.filter(o => o.value !== 'all').map(o => ({
value: o.value,
label: o.label,
})),
allOptionLabel: '전체',
},
{
key: 'sortBy',
label: '정렬',
type: 'single',
options: WORKER_SORT_OPTIONS.map(o => ({
value: o.value,
label: o.label,
})),
allOptionLabel: '최신순',
},
], [partnerOptions, siteOptions, departmentOptions, nameOptions]);
const filterValues: FilterValues = useMemo(() => ({
partner: partnerFilters,
site: siteFilters,
category: categoryFilter,
department: departmentFilters,
name: nameFilters,
status: statusFilter,
sortBy: sortBy,
}), [partnerFilters, siteFilters, categoryFilter, departmentFilters, nameFilters, statusFilter, sortBy]);
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
switch (key) {
case 'partner':
setPartnerFilters(value as string[]);
break;
case 'site':
setSiteFilters(value as string[]);
break;
case 'category':
setCategoryFilter(value as string);
break;
case 'department':
setDepartmentFilters(value as string[]);
break;
case 'name':
setNameFilters(value as string[]);
break;
case 'status':
setStatusFilter(value as string);
break;
case 'sortBy':
setSortBy(value as string);
break;
}
setCurrentPage(1);
}, []);
const handleFilterReset = useCallback(() => {
setPartnerFilters([]);
setSiteFilters([]);
setCategoryFilter('all');
setDepartmentFilters([]);
setNameFilters([]);
setStatusFilter('all');
setSortBy('latest');
setCurrentPage(1);
}, []);
return (
<IntegratedListTemplateV2
title="작업인력현황"
description="작업인력현황을 확인합니다"
icon={Users}
headerActions={headerActions}
stats={statsCardsData}
filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="작업인력 필터"
searchValue={searchValue}
onSearchChange={handleSearchChange}
searchPlaceholder="거래처, 현장, 부서, 이름, 시공번호 검색"
tableColumns={tableColumns}
data={paginatedData}
allData={sortedWorkers}
getItemId={(item) => item.id}
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
selectedItems={selectedItems}
onToggleSelection={handleToggleSelection}
onToggleSelectAll={handleToggleSelectAll}
pagination={{
currentPage,
totalPages,
totalItems: sortedWorkers.length,
itemsPerPage,
onPageChange: setCurrentPage,
}}
/>
);
}

View File

@@ -0,0 +1,111 @@
'use server';
import type { WorkerStatus, WorkerStatusStats } from './types';
// Mock 데이터 생성
const generateMockData = (): WorkerStatus[] => {
const partners = ['현성엘리', '삼성전자', '대우건설', 'LG전자'];
const sites = ['문정교회', '강남빌딩', '서초타워', '판교오피스'];
const departments = ['시공', '설계', '관리', '영업'];
const names = ['김정수', '김동혁', '이영희', '박철수', '최민수', '홍길동', '이순신', '강감찬'];
const categories: ('foreman' | 'worker')[] = ['foreman', 'worker'];
const statuses: ('absent' | 'checked_in' | 'checked_out' | 'early_leave' | 'other')[] = [
'absent', 'checked_in', 'checked_out', 'early_leave', 'other'
];
const contractStatuses: ('all_contract' | 'pending' | 'completed')[] = ['all_contract', 'pending', 'completed'];
const mockData: WorkerStatus[] = [];
for (let i = 1; i <= 30; i++) {
const status = statuses[Math.floor(Math.random() * statuses.length)];
const hasCheckIn = status !== 'absent';
const hasCheckOut = status === 'checked_out' || status === 'early_leave';
mockData.push({
id: `worker-${i}`,
partnerName: partners[Math.floor(Math.random() * partners.length)],
siteName: sites[Math.floor(Math.random() * sites.length)],
category: categories[Math.floor(Math.random() * categories.length)],
department: departments[Math.floor(Math.random() * departments.length)],
workerName: names[Math.floor(Math.random() * names.length)],
baseDate: `2025-09-${String(Math.floor(Math.random() * 30) + 1).padStart(2, '0')}`,
checkInTime: hasCheckIn ? `${String(8 + Math.floor(Math.random() * 2)).padStart(2, '0')}:${String(Math.floor(Math.random() * 60)).padStart(2, '0')}` : null,
checkOutTime: hasCheckOut ? `${String(17 + Math.floor(Math.random() * 3)).padStart(2, '0')}:${String(Math.floor(Math.random() * 60)).padStart(2, '0')}` : null,
constructionNumber: `CON-2025-${String(i).padStart(4, '0')}`,
laborCost: (100000 + Math.floor(Math.random() * 100000)),
status,
contractStatus: contractStatuses[Math.floor(Math.random() * contractStatuses.length)],
createdAt: new Date(2025, 8, i).toISOString(),
updatedAt: new Date(2025, 8, i).toISOString(),
});
}
return mockData;
};
interface GetWorkerStatusListParams {
page?: number;
size?: number;
startDate?: string;
endDate?: string;
}
interface GetWorkerStatusListResult {
success: boolean;
data?: {
items: WorkerStatus[];
totalItems: number;
totalPages: number;
};
error?: string;
}
export async function getWorkerStatusList(
params: GetWorkerStatusListParams = {}
): Promise<GetWorkerStatusListResult> {
try {
// Mock 데이터 반환
const mockData = generateMockData();
return {
success: true,
data: {
items: mockData,
totalItems: mockData.length,
totalPages: Math.ceil(mockData.length / (params.size || 20)),
},
};
} catch (error) {
console.error('Failed to fetch worker status list:', error);
return {
success: false,
error: '작업인력현황 목록을 불러오는데 실패했습니다.',
};
}
}
interface GetWorkerStatusStatsResult {
success: boolean;
data?: WorkerStatusStats;
error?: string;
}
export async function getWorkerStatusStats(): Promise<GetWorkerStatusStatsResult> {
try {
// Mock 통계 데이터
return {
success: true,
data: {
allContract: 25,
pending: 8,
completed: 17,
},
};
} catch (error) {
console.error('Failed to fetch worker status stats:', error);
return {
success: false,
error: '통계 정보를 불러오는데 실패했습니다.',
};
}
}

View File

@@ -0,0 +1,116 @@
// 작업인력현황 타입 정의
// 작업인력 항목
export interface WorkerStatus {
id: string;
partnerName: string; // 거래처
siteName: string; // 현장
category: 'foreman' | 'worker'; // 구분: 작업반장/작업인
department: string; // 부서
workerName: string; // 이름
baseDate: string; // 기준일
checkInTime: string | null; // 출근
checkOutTime: string | null; // 퇴근
constructionNumber: string; // 시공번호
laborCost: number; // 노임
status: 'absent' | 'checked_in' | 'checked_out' | 'early_leave' | 'other'; // 상태
contractStatus: 'all_contract' | 'pending' | 'completed'; // 계약상태
createdAt: string;
updatedAt: string;
}
// 통계
export interface WorkerStatusStats {
allContract: number; // 전체 계약
pending: number; // 계약대기
completed: number; // 계약완료
}
// 구분 옵션
export const WORKER_CATEGORY_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: 'foreman', label: '작업반장' },
{ value: 'worker', label: '작업인' },
] as const;
export const WORKER_CATEGORY_LABELS: Record<string, string> = {
foreman: '작업반장',
worker: '작업인',
};
// 상태 옵션
export const WORKER_STATUS_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: 'absent', label: '미출근' },
{ value: 'checked_in', label: '출근' },
{ value: 'checked_out', label: '퇴근' },
{ value: 'early_leave', label: '조퇴' },
{ value: 'other', label: '기타' },
] as const;
export const WORKER_STATUS_LABELS: Record<string, string> = {
absent: '미출근',
checked_in: '출근',
checked_out: '퇴근',
early_leave: '조퇴',
other: '기타',
};
export const WORKER_STATUS_STYLES: Record<string, string> = {
absent: 'bg-gray-100 text-gray-700',
checked_in: 'bg-green-100 text-green-700',
checked_out: 'bg-blue-100 text-blue-700',
early_leave: 'bg-yellow-100 text-yellow-700',
other: 'bg-orange-100 text-orange-700',
};
// 계약상태 옵션
export const CONTRACT_STATUS_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: 'all_contract', label: '전체 계약' },
{ value: 'pending', label: '계약대기' },
{ value: 'completed', label: '계약완료' },
] as const;
// 정렬 옵션
export const WORKER_SORT_OPTIONS = [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
{ value: 'partnerAsc', label: '거래처명 오름차순' },
{ value: 'partnerDesc', label: '거래처명 내림차순' },
{ value: 'siteAsc', label: '현장명 오름차순' },
{ value: 'siteDesc', label: '현장명 내림차순' },
] as const;
// Mock 데이터 - 거래처 목록
export const MOCK_WORKER_PARTNERS = [
{ value: 'partner1', label: '현성엘리' },
{ value: 'partner2', label: '삼성전자' },
{ value: 'partner3', label: '대우건설' },
{ value: 'partner4', label: 'LG전자' },
];
// Mock 데이터 - 현장 목록
export const MOCK_WORKER_SITES = [
{ value: 'site1', label: '문정교회' },
{ value: 'site2', label: '강남빌딩' },
{ value: 'site3', label: '서초타워' },
{ value: 'site4', label: '판교오피스' },
];
// Mock 데이터 - 부서 목록
export const MOCK_WORKER_DEPARTMENTS = [
{ value: 'dept1', label: '시공' },
{ value: 'dept2', label: '설계' },
{ value: 'dept3', label: '관리' },
{ value: 'dept4', label: '영업' },
];
// Mock 데이터 - 이름 목록
export const MOCK_WORKER_NAMES = [
{ value: 'name1', label: '김정수' },
{ value: 'name2', label: '김동혁' },
{ value: 'name3', label: '이영희' },
{ value: 'name4', label: '박철수' },
{ value: 'name5', label: '최민수' },
];

View File

@@ -27,61 +27,97 @@ export function CalendarHeader({
];
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between pb-3 border-b">
{/* 좌측: 타이틀 + 년월 네비게이션 */}
<div className="flex items-center gap-4">
{titleSlot && (
<span className="text-base font-semibold text-foreground">{titleSlot}</span>
)}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
className="h-8 w-8 shrink-0 hover:bg-primary/10"
onClick={onPrevMonth}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex flex-col gap-3 pb-3 border-b">
{/* PC: 타이틀 + 네비게이션 | 뷰전환 + 필터 (한 줄) */}
{/* 모바일: 타이틀 / 네비게이션 + 뷰전환 / 필터 (세 줄) */}
<span className="text-lg font-bold min-w-[120px] text-center">
{formatYearMonth(currentDate)}
</span>
<Button
variant="outline"
size="icon"
className="h-8 w-8 shrink-0 hover:bg-primary/10"
onClick={onNextMonth}
>
<ChevronRight className="h-4 w-4" />
</Button>
{/* 1줄(모바일) / 좌측(PC): 타이틀 */}
{titleSlot && (
<div className="xl:hidden text-base font-semibold text-foreground">
{titleSlot}
</div>
</div>
)}
{/* 우측: 뷰 전환 + 필터 */}
<div className="flex items-center gap-3">
{/* 뷰 전환 탭 */}
<div className="flex rounded-md border">
{views.map((v) => (
<button
key={v.value}
onClick={() => onViewChange(v.value)}
className={cn(
'px-4 py-1.5 text-sm font-medium transition-colors',
'first:rounded-l-md last:rounded-r-md',
view === v.value
? 'bg-primary text-primary-foreground'
: 'hover:bg-primary/10 text-foreground'
)}
{/* 2줄(모바일) / 전체(PC): 네비게이션 + 뷰전환 + 필터 */}
<div className="flex flex-col xl:flex-row xl:items-center xl:justify-between gap-3">
{/* 좌측: (PC에서만 타이틀) + 네비게이션 */}
<div className="flex items-center gap-4">
{titleSlot && (
<span className="hidden xl:block text-base font-semibold text-foreground">
{titleSlot}
</span>
)}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
className="h-8 w-8 shrink-0 hover:bg-primary/10"
onClick={onPrevMonth}
>
{v.label}
</button>
))}
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-lg font-bold min-w-[120px] text-center">
{formatYearMonth(currentDate)}
</span>
<Button
variant="outline"
size="icon"
className="h-8 w-8 shrink-0 hover:bg-primary/10"
onClick={onNextMonth}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
{/* 모바일: 뷰 전환 탭 (네비게이션 옆) */}
<div className="flex xl:hidden rounded-md border">
{views.map((v) => (
<button
key={v.value}
onClick={() => onViewChange(v.value)}
className={cn(
'px-4 py-1.5 text-sm font-medium transition-colors',
'first:rounded-l-md last:rounded-r-md',
view === v.value
? 'bg-primary text-primary-foreground'
: 'hover:bg-primary/10 text-foreground'
)}
>
{v.label}
</button>
))}
</div>
</div>
{/* 필터 슬롯 */}
{filterSlot && <div className="flex items-center gap-2">{filterSlot}</div>}
{/* 우측(PC만): 뷰 전환 + 필터 */}
<div className="hidden xl:flex items-center gap-3">
<div className="flex rounded-md border">
{views.map((v) => (
<button
key={v.value}
onClick={() => onViewChange(v.value)}
className={cn(
'px-4 py-1.5 text-sm font-medium transition-colors',
'first:rounded-l-md last:rounded-r-md',
view === v.value
? 'bg-primary text-primary-foreground'
: 'hover:bg-primary/10 text-foreground'
)}
>
{v.label}
</button>
))}
</div>
{filterSlot && <div className="flex items-center gap-2">{filterSlot}</div>}
</div>
</div>
{/* 3줄(모바일만): 필터 */}
{filterSlot && (
<div className="flex xl:hidden items-center gap-2">{filterSlot}</div>
)}
</div>
);
}

View File

@@ -11,6 +11,7 @@ interface DayCellProps {
isToday: boolean;
isSelected: boolean;
isWeekend: boolean;
isPast: boolean;
badge?: DayBadge;
onClick: (date: Date) => void;
}
@@ -28,6 +29,7 @@ export function DayCell({
isToday,
isSelected,
isWeekend,
isPast,
badge,
onClick,
}: DayCellProps) {
@@ -44,11 +46,15 @@ export function DayCell({
'hover:bg-primary/10',
// 현재 월 여부
isCurrentMonth ? 'text-foreground' : 'text-muted-foreground/40',
// 주말 색상
isWeekend && isCurrentMonth && 'text-red-500',
// 오늘
isToday && 'bg-accent text-accent-foreground font-bold',
// 선택됨
// 지난 일자 - 더 명확한 회색 (현재 월에서만)
isPast && isCurrentMonth && !isToday && !isSelected && 'text-gray-400',
// 주말 색상 (지난 일자가 아닌 경우만)
isWeekend && isCurrentMonth && !isPast && 'text-red-500',
// 지난 주말 - 연한 색상
isWeekend && isCurrentMonth && isPast && !isToday && !isSelected && 'text-red-300',
// 오늘 - 굵은 글씨 (외곽선은 부모 셀에 적용)
isToday && !isSelected && 'font-bold text-primary',
// 선택됨 - 배경색 하이라이트
isSelected && 'bg-primary text-primary-foreground hover:bg-primary'
)}
>

View File

@@ -11,6 +11,7 @@ import {
getWeekdayHeaders,
isCurrentMonth,
checkIsToday,
checkIsPast,
isSameDate,
splitIntoWeeks,
getEventSegmentsForWeek,
@@ -173,6 +174,10 @@ function WeekRow({
const dayEvents = getEventsForDate(events, date);
const isSelected = isSameDate(selectedDate, date);
const isToday = checkIsToday(date);
const isPast = checkIsPast(date);
const isCurrMonth = isCurrentMonth(date, currentDate);
return (
<div
key={date.toISOString()}
@@ -180,7 +185,11 @@ function WeekRow({
'relative p-1 border-r last:border-r-0',
'flex flex-col cursor-pointer transition-colors',
// 기본 배경
!isCurrentMonth(date, currentDate) && 'bg-muted/30',
!isCurrMonth && 'bg-muted/30',
// 지난 일자 - 회색 배경 (현재 월, 오늘/선택 제외)
isPast && isCurrMonth && !isToday && !isSelected && 'bg-gray-200 dark:bg-gray-700',
// 오늘 - 셀 전체 외곽선 하이라이트
isToday && !isSelected && 'ring-2 ring-primary ring-inset',
// 선택된 날짜 - 셀 전체 배경색 변경 (테두리 없이)
isSelected && 'bg-primary/15'
)}
@@ -189,10 +198,11 @@ function WeekRow({
{/* 날짜 셀 */}
<DayCell
date={date}
isCurrentMonth={isCurrentMonth(date, currentDate)}
isToday={checkIsToday(date)}
isCurrentMonth={isCurrMonth}
isToday={isToday}
isSelected={isSelected}
isWeekend={isWeekend}
isPast={isPast}
badge={badge}
onClick={onDateClick}
/>

View File

@@ -40,7 +40,7 @@ export function ScheduleCalendar({
onViewChange,
titleSlot,
filterSlot,
maxEventsPerDay = 3,
maxEventsPerDay = 5,
weekStartsOn = 0,
isLoading = false,
className,

View File

@@ -7,10 +7,12 @@ import {
endOfMonth,
startOfWeek,
endOfWeek,
startOfDay,
eachDayOfInterval,
isSameMonth,
isSameDay,
isToday,
isBefore,
format,
addMonths,
subMonths,
@@ -71,6 +73,15 @@ export function checkIsToday(date: Date): boolean {
return isToday(date);
}
/**
* 날짜가 오늘 이전인지 확인 (지난 일자)
*/
export function checkIsPast(date: Date): boolean {
const today = startOfDay(new Date());
const targetDate = startOfDay(date);
return isBefore(targetDate, today);
}
/**
* 두 날짜가 같은지 확인
*/

View File

@@ -193,9 +193,33 @@ export function EmployeeForm({
}
}, [employee, mode]);
// 휴대폰 번호 자동 하이픈 포맷팅
const formatPhoneNumber = (value: string): string => {
const numbers = value.replace(/[^0-9]/g, '');
if (numbers.length <= 3) return numbers;
if (numbers.length <= 7) return `${numbers.slice(0, 3)}-${numbers.slice(3)}`;
return `${numbers.slice(0, 3)}-${numbers.slice(3, 7)}-${numbers.slice(7, 11)}`;
};
// 주민등록번호 자동 하이픈 포맷팅
const formatResidentNumber = (value: string): string => {
const numbers = value.replace(/[^0-9]/g, '');
if (numbers.length <= 6) return numbers;
return `${numbers.slice(0, 6)}-${numbers.slice(6, 13)}`;
};
// 입력 변경 핸들러
const handleChange = (field: keyof EmployeeFormData, value: unknown) => {
setFormData(prev => ({ ...prev, [field]: value }));
let formattedValue = value;
// 자동 하이픈 적용
if (field === 'phone' && typeof value === 'string') {
formattedValue = formatPhoneNumber(value);
} else if (field === 'residentNumber' && typeof value === 'string') {
formattedValue = formatResidentNumber(value);
}
setFormData(prev => ({ ...prev, [field]: formattedValue }));
// 에러 초기화
if (errors[field as keyof ValidationErrors]) {
setErrors(prev => ({ ...prev, [field]: undefined }));
@@ -233,8 +257,8 @@ export function EmployeeForm({
if (mode === 'create') {
if (!formData.password) {
newErrors.password = '비밀번호를 입력해주세요.';
} else if (formData.password.length < 6) {
newErrors.password = '비밀번호는 6자 이상이어야 합니다.';
} else if (formData.password.length < 8) {
newErrors.password = '비밀번호는 8자 이상이어야 합니다.';
}
if (formData.password !== formData.confirmPassword) {
@@ -357,8 +381,8 @@ export function EmployeeForm({
<form onSubmit={handleSubmit} className="space-y-6">
{/* 사원 정보 - 프로필 사진 + 기본 정보 */}
<Card>
<CardHeader className="bg-black text-white rounded-t-lg">
<CardTitle className="text-base font-medium"> </CardTitle>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="pt-6">
{/* 기본 정보 필드들 */}
@@ -455,8 +479,8 @@ export function EmployeeForm({
{/* 사원 상세 */}
{(fieldSettings.showProfileImage || fieldSettings.showEmployeeCode || fieldSettings.showGender || fieldSettings.showAddress) && (
<Card>
<CardHeader className="bg-black text-white rounded-t-lg">
<CardTitle className="text-base font-medium"> </CardTitle>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="pt-6 space-y-4">
{/* 프로필 사진 + 사원코드/성별 */}
@@ -585,8 +609,8 @@ export function EmployeeForm({
{/* 인사 정보 */}
{(fieldSettings.showHireDate || fieldSettings.showEmploymentType || fieldSettings.showRank || fieldSettings.showStatus || fieldSettings.showDepartment || fieldSettings.showPosition || fieldSettings.showClockInLocation || fieldSettings.showClockOutLocation || fieldSettings.showResignationDate || fieldSettings.showResignationReason) && (
<Card>
<CardHeader className="bg-black text-white rounded-t-lg">
<CardTitle className="text-base font-medium"> </CardTitle>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="pt-6 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -821,8 +845,8 @@ export function EmployeeForm({
{/* 사용자 정보 */}
<Card>
<CardHeader className="bg-black text-white rounded-t-lg">
<CardTitle className="text-base font-medium"> </CardTitle>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="pt-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">

View File

@@ -121,8 +121,9 @@ export function DateRangeSelector({
return (
<div className="flex flex-col gap-2 w-full">
{/* 상단: 날짜 선택 + 기간 버튼 */}
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
{/* 1줄: 날짜 + 프리셋 */}
{/* 태블릿/모바일(~1279px): 세로 배치 / PC(1280px+): 가로 한 줄 */}
<div className="flex flex-col xl:flex-row xl:items-center gap-2">
{/* 날짜 범위 선택 (Input type="date") */}
{!hideDateInputs && (
<div className="flex items-center gap-1 shrink-0">
@@ -145,7 +146,7 @@ export function DateRangeSelector({
{/* 기간 버튼들 - 모바일에서 가로 스크롤 */}
{!hidePresets && presets.length > 0 && (
<div
className="overflow-x-auto -mx-1 px-1"
className="overflow-x-auto -mx-1 px-1 xl:overflow-visible xl:mx-0 xl:px-0"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
<div className="flex items-center gap-1 min-w-max [&::-webkit-scrollbar]:hidden">
@@ -165,9 +166,9 @@ export function DateRangeSelector({
)}
</div>
{/* 하단: 추가 액션 버튼들 */}
{/* 2줄: 추가 액션 버튼들 - 항상 별도 줄, 오른쪽 정렬 */}
{extraActions && (
<div className="flex items-center gap-2 flex-wrap sm:justify-end">
<div className="flex items-center gap-2 justify-end">
{extraActions}
</div>
)}

View File

@@ -17,6 +17,7 @@ interface MobileCardProps {
description?: string;
badge?: string;
badgeVariant?: 'default' | 'secondary' | 'destructive' | 'outline';
badgeClassName?: string;
isSelected?: boolean;
onToggle?: () => void;
onClick?: () => void;
@@ -31,6 +32,7 @@ export function MobileCard({
description,
badge,
badgeVariant = 'default',
badgeClassName,
isSelected = false,
onToggle,
onClick,
@@ -63,7 +65,7 @@ export function MobileCard({
<div className="text-sm text-muted-foreground">{subtitle}</div>
)}
</div>
{badge && <Badge variant={badgeVariant}>{badge}</Badge>}
{badge && <Badge variant={badgeVariant} className={badgeClassName}>{badge}</Badge>}
</div>
{/* 설명 */}

View File

@@ -0,0 +1,335 @@
'use client';
/**
* 모바일 종합 필터 컴포넌트
*
* PC에서 여러 개의 필터를 모바일에서는 하나의 바텀시트로 통합
* - 단일선택(single), 다중선택(multi) 필드 지원
* - 적용된 필터 개수 배지 표시
* - 초기화/적용 버튼
* - PC와 동일한 셀렉트 박스 형태로 컴팩트하게 표시
*/
import * as React from 'react';
import { Filter, X, Check, RotateCcw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Label } from '@/components/ui/label';
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerFooter,
DrawerClose,
} from '@/components/ui/drawer';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { MultiSelectCombobox } from '@/components/ui/multi-select-combobox';
import { cn } from '@/lib/utils';
// 필터 옵션 타입
export interface FilterOption {
value: string;
label: string;
}
// 필터 필드 설정 타입
export interface FilterFieldConfig {
key: string;
label: string;
type: 'single' | 'multi';
options: FilterOption[];
allOptionLabel?: string; // single 타입에서 "전체" 옵션 라벨 (기본: '전체')
}
// 필터 값 타입
export type FilterValues = Record<string, string | string[]>;
// MobileFilter Props
export interface MobileFilterProps {
fields: FilterFieldConfig[];
values: FilterValues;
onChange: (key: string, value: string | string[]) => void;
onReset: () => void;
onApply?: () => void;
buttonLabel?: string;
title?: string;
className?: string;
/** 적용된 필터를 버튼 아래 태그로 표시할지 여부 (기본: true) */
showAppliedTags?: boolean;
}
/**
* 적용된 필터 개수 계산
*/
function countActiveFilters(
fields: FilterFieldConfig[],
values: FilterValues
): number {
let count = 0;
for (const field of fields) {
const value = values[field.key];
if (field.type === 'single') {
// single: 'all'이 아니면 활성화
if (value && value !== 'all') {
count++;
}
} else {
// multi: 배열에 값이 있으면 활성화
if (Array.isArray(value) && value.length > 0) {
count++;
}
}
}
return count;
}
/**
* 필터 필드 요약 텍스트 생성
*/
function getFieldSummary(
field: FilterFieldConfig,
value: string | string[] | undefined
): string {
if (field.type === 'single') {
if (!value || value === 'all') return field.allOptionLabel || '전체';
const option = field.options.find((opt) => opt.value === value);
return option?.label || '전체';
} else {
const arr = Array.isArray(value) ? value : [];
if (arr.length === 0) return '전체';
if (arr.length === field.options.length) return '전체';
const firstOption = field.options.find((opt) => arr.includes(opt.value));
if (arr.length === 1) return firstOption?.label || '';
return `${firstOption?.label}${arr.length - 1}`;
}
}
/**
* 적용된 필터 태그 목록 생성
*/
function getAppliedFilterTags(
fields: FilterFieldConfig[],
values: FilterValues
): Array<{ key: string; label: string; displayValue: string }> {
const tags: Array<{ key: string; label: string; displayValue: string }> = [];
for (const field of fields) {
const value = values[field.key];
if (field.type === 'single') {
// single: 'all'이 아니면 태그 추가
if (value && value !== 'all') {
const option = field.options.find((opt) => opt.value === value);
if (option) {
tags.push({
key: field.key,
label: field.label,
displayValue: option.label,
});
}
}
} else {
// multi: 배열에 값이 있으면 태그 추가
const arr = Array.isArray(value) ? value : [];
if (arr.length > 0) {
const firstOption = field.options.find((opt) => arr.includes(opt.value));
const displayValue =
arr.length === 1
? firstOption?.label || ''
: `${firstOption?.label}${arr.length - 1}`;
tags.push({
key: field.key,
label: field.label,
displayValue,
});
}
}
}
return tags;
}
export function MobileFilter({
fields,
values,
onChange,
onReset,
onApply,
buttonLabel = '필터',
title = '검색 필터',
className,
showAppliedTags = true,
}: MobileFilterProps) {
const [open, setOpen] = React.useState(false);
const activeCount = countActiveFilters(fields, values);
const appliedTags = showAppliedTags ? getAppliedFilterTags(fields, values) : [];
// 개별 필터 초기화 핸들러
const handleClearFilter = (key: string) => {
const field = fields.find((f) => f.key === key);
if (field) {
if (field.type === 'single') {
onChange(key, 'all');
} else {
onChange(key, []);
}
}
};
// 초기화 핸들러
const handleReset = () => {
onReset();
};
// 적용 핸들러
const handleApply = () => {
if (onApply) {
onApply();
}
setOpen(false);
};
return (
<div className="flex flex-col gap-2">
{/* 상단: 필터 버튼 + 적용된 태그 */}
<div className="flex items-center gap-2 flex-wrap">
{/* 필터 버튼 */}
<Button
variant="outline"
size="sm"
className={cn('gap-2', className)}
onClick={() => setOpen(true)}
>
<Filter className="h-4 w-4" />
<span>{buttonLabel}</span>
{activeCount > 0 && (
<Badge
variant="secondary"
className="h-5 min-w-5 rounded-full px-1.5 text-xs bg-primary text-primary-foreground"
>
{activeCount}
</Badge>
)}
</Button>
{/* 적용된 필터 태그 */}
{showAppliedTags && appliedTags.length > 0 && (
<>
{appliedTags.map((tag) => (
<Badge
key={tag.key}
variant="secondary"
className="gap-1 pr-1 text-xs font-normal bg-muted hover:bg-muted"
>
<span className="text-muted-foreground">{tag.label}:</span>
<span>{tag.displayValue}</span>
<button
type="button"
onClick={() => handleClearFilter(tag.key)}
className="ml-0.5 rounded-full hover:bg-foreground/10 p-0.5"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
{/* 전체 초기화 버튼 */}
<button
type="button"
onClick={onReset}
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1"
>
<RotateCcw className="h-3 w-3" />
</button>
</>
)}
</div>
{/* 필터 Drawer (바텀시트) */}
<Drawer open={open} onOpenChange={setOpen}>
<DrawerContent className="max-h-[85vh] flex flex-col">
<DrawerHeader className="border-b flex-shrink-0">
<div className="flex items-center justify-between">
<DrawerTitle>{title}</DrawerTitle>
<DrawerClose asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<X className="h-4 w-4" />
</Button>
</DrawerClose>
</div>
</DrawerHeader>
{/* 컴팩트한 셀렉트 박스 형태 - 스크롤 가능 */}
<div className="px-4 py-4 space-y-4 overflow-y-auto flex-1">
{fields.map((field) => (
<div key={field.key} className="space-y-1.5">
<Label className="text-sm font-medium text-muted-foreground">
{field.label}
</Label>
{field.type === 'single' ? (
// 단일선택: Select
<Select
value={(values[field.key] as string) || 'all'}
onValueChange={(value) => onChange(field.key, value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={field.allOptionLabel || '전체'} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{field.allOptionLabel || '전체'}
</SelectItem>
{field.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
// 다중선택: MultiSelectCombobox
<MultiSelectCombobox
options={field.options.map((opt) => ({
value: opt.value,
label: opt.label,
}))}
value={(values[field.key] as string[]) || []}
onChange={(value) => onChange(field.key, value)}
placeholder="전체"
searchPlaceholder={`${field.label} 검색...`}
className="w-full"
/>
)}
</div>
))}
</div>
<DrawerFooter className="border-t flex-row gap-2 flex-shrink-0">
<Button
variant="outline"
className="flex-1 gap-2"
onClick={handleReset}
>
<RotateCcw className="h-4 w-4" />
</Button>
<Button className="flex-1 gap-2" onClick={handleApply}>
<Check className="h-4 w-4" />
</Button>
</DrawerFooter>
</DrawerContent>
</Drawer>
</div>
);
}

View File

@@ -0,0 +1,128 @@
/**
* 품목 검색 모달
*
* - 품목 코드로 검색
* - 품목 목록에서 선택
*/
"use client";
import { useState, useMemo } from "react";
import { Search, X } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "../ui/dialog";
import { Input } from "../ui/input";
// =============================================================================
// 목데이터 - 품목 목록
// =============================================================================
const MOCK_ITEMS = [
{ code: "KSS01", name: "스크린", description: "방화스크린 기본형" },
{ code: "KSS02", name: "스크린", description: "방화스크린 고급형" },
{ code: "KSS03", name: "슬랫", description: "방화슬랫 기본형" },
{ code: "KSS04", name: "스크린", description: "방화스크린 특수형" },
];
// =============================================================================
// Props
// =============================================================================
interface ItemSearchModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelectItem: (item: { code: string; name: string }) => void;
tabLabel?: string;
}
// =============================================================================
// 컴포넌트
// =============================================================================
export function ItemSearchModal({
open,
onOpenChange,
onSelectItem,
tabLabel,
}: ItemSearchModalProps) {
const [searchQuery, setSearchQuery] = useState("");
// 검색 필터링
const filteredItems = useMemo(() => {
if (!searchQuery) return MOCK_ITEMS;
const query = searchQuery.toLowerCase();
return MOCK_ITEMS.filter(
(item) =>
item.code.toLowerCase().includes(query) ||
item.name.toLowerCase().includes(query) ||
item.description.toLowerCase().includes(query)
);
}, [searchQuery]);
const handleSelect = (item: (typeof MOCK_ITEMS)[0]) => {
onSelectItem({ code: item.code, name: item.name });
onOpenChange(false);
setSearchQuery("");
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
{/* 검색 입력 */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="원하는 검색어..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
className="absolute right-3 top-1/2 -translate-y-1/2"
>
<X className="h-4 w-4 text-gray-400 hover:text-gray-600" />
</button>
)}
</div>
{/* 품목 목록 */}
<div className="max-h-[300px] overflow-y-auto border rounded-lg divide-y">
{filteredItems.length === 0 ? (
<div className="p-4 text-center text-gray-500 text-sm">
</div>
) : (
filteredItems.map((item) => (
<div
key={item.code}
onClick={() => handleSelect(item)}
className="p-3 hover:bg-blue-50 cursor-pointer transition-colors"
>
<div className="flex items-center justify-between">
<div>
<span className="font-semibold text-gray-900">{item.code}</span>
<span className="ml-2 text-sm text-gray-500">{item.name}</span>
</div>
</div>
{item.description && (
<p className="text-xs text-gray-400 mt-1">{item.description}</p>
)}
</div>
))
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,623 @@
/**
* 선택 개소 상세 정보 패널
*
* - 제품 정보 (제품명, 오픈사이즈, 제작사이즈, 산출중량, 산출면적, 수량)
* - 필수 설정 (가이드레일, 전원, 제어기)
* - 탭: 본체(스크린/슬랫), 절곡품-가이드레일, 절곡품-케이스, 절곡품-하단마감재, 모터&제어기, 부자재
* - 탭별 품목 테이블 (각 탭마다 다른 컬럼 구조)
*/
"use client";
import { useState, useMemo } from "react";
import { Package, Settings, Plus, Trash2 } from "lucide-react";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { ItemSearchModal } from "./ItemSearchModal";
// 납품길이 옵션
const DELIVERY_LENGTH_OPTIONS = [
{ value: "3000", label: "3000" },
{ value: "4000", label: "4000" },
{ value: "5000", label: "5000" },
{ value: "6000", label: "6000" },
];
// 목데이터 - 탭별 품목 아이템 (각 탭마다 다른 구조)
const MOCK_BOM_ITEMS = {
// 본체 (스크린/슬랫): 품목명, 제작사이즈, 수량, 작업
body: [
{ id: "b1", item_name: "실리카 스크린", manufacture_size: "5280*3280", quantity: 1, unit: "EA", total_price: 1061676 },
],
// 절곡품 - 가이드레일: 품목명, 재질, 규격, 납품길이, 수량, 작업
"guide-rail": [
{ id: "g1", item_name: "벽면형 마감재", material: "알루미늄", spec: "50mm", delivery_length: "4000", quantity: 2, unit: "EA", total_price: 84048 },
{ id: "g2", item_name: "본체 가이드 레일", material: "스틸", spec: "20mm", delivery_length: "4000", quantity: 2, unit: "EA", total_price: 32508 },
],
// 절곡품 - 케이스: 품목명, 재질, 규격, 납품길이, 수량, 작업
case: [
{ id: "c1", item_name: "전면부 케이스", material: "알루미늄", spec: "30mm", delivery_length: "4000", quantity: 1, unit: "EA", total_price: 30348 },
],
// 절곡품 - 하단마감재: 품목명, 재질, 규격, 납품길이, 수량, 작업
bottom: [
{ id: "bt1", item_name: "하단 하우징", material: "스틸", spec: "40mm", delivery_length: "4000", quantity: 1, unit: "EA", total_price: 15420 },
],
// 모터 & 제어기: 품목명, 유형, 사양, 수량, 작업
motor: [
{ id: "m1", item_name: "직류 모터", type: "220V", spec: "1/2HP", quantity: 1, unit: "EA", total_price: 250000 },
{ id: "m2", item_name: "제어기", type: "디지털", spec: "", quantity: 1, unit: "EA", total_price: 150000 },
],
// 부자재: 품목명, 규격, 납품길이, 수량, 작업
accessory: [
{ id: "a1", item_name: "각파이프 25mm", spec: "25*25*2.0t", delivery_length: "4000", quantity: 2, unit: "EA", total_price: 17000 },
{ id: "a2", item_name: "플랫바 20mm", spec: "20*3.0t", delivery_length: "4000", quantity: 1, unit: "EA", total_price: 4200 },
],
};
import type { LocationItem } from "./QuoteRegistrationV2";
import type { FinishedGoods } from "./actions";
// =============================================================================
// 상수
// =============================================================================
// 가이드레일 설치 유형
const GUIDE_RAIL_TYPES = [
{ value: "wall", label: "벽면형" },
{ value: "floor", label: "측면형" },
];
// 모터 전원
const MOTOR_POWERS = [
{ value: "single", label: "단상(220V)" },
{ value: "three", label: "삼상(380V)" },
];
// 연동제어기
const CONTROLLERS = [
{ value: "basic", label: "단독" },
{ value: "smart", label: "연동" },
{ value: "premium", label: "매립형-뒷박스포함" },
];
// 탭 정의 (6개)
const DETAIL_TABS = [
{ value: "body", label: "본체 (스크린/슬랫)" },
{ value: "guide-rail", label: "절곡품 - 가이드레일" },
{ value: "case", label: "절곡품 - 케이스" },
{ value: "bottom", label: "절곡품 - 하단마감재" },
{ value: "motor", label: "모터 & 제어기" },
{ value: "accessory", label: "부자재" },
];
// =============================================================================
// Props
// =============================================================================
interface LocationDetailPanelProps {
location: LocationItem | null;
onUpdateLocation: (locationId: string, updates: Partial<LocationItem>) => void;
finishedGoods: FinishedGoods[];
disabled?: boolean;
}
// =============================================================================
// 컴포넌트
// =============================================================================
export function LocationDetailPanel({
location,
onUpdateLocation,
finishedGoods,
disabled = false,
}: LocationDetailPanelProps) {
// ---------------------------------------------------------------------------
// 상태
// ---------------------------------------------------------------------------
const [activeTab, setActiveTab] = useState("body");
const [itemSearchOpen, setItemSearchOpen] = useState(false);
// ---------------------------------------------------------------------------
// 계산된 값
// ---------------------------------------------------------------------------
// 제품 정보
const product = useMemo(() => {
if (!location?.productCode) return null;
return finishedGoods.find((fg) => fg.item_code === location.productCode);
}, [location?.productCode, finishedGoods]);
// BOM 아이템을 탭별로 분류 (목데이터 사용)
const bomItemsByTab = useMemo(() => {
// bomResult가 없으면 목데이터 사용
if (!location?.bomResult?.items) {
return MOCK_BOM_ITEMS;
}
const items = location.bomResult.items;
const result: Record<string, typeof items> = {
body: [],
"guide-rail": [],
case: [],
bottom: [],
};
items.forEach((item) => {
const processGroup = item.process_group?.toLowerCase() || "";
if (processGroup.includes("본체") || processGroup.includes("스크린") || processGroup.includes("슬랫")) {
result.body.push(item);
} else if (processGroup.includes("가이드") || processGroup.includes("레일")) {
result["guide-rail"].push(item);
} else if (processGroup.includes("케이스")) {
result.case.push(item);
} else if (processGroup.includes("하단") || processGroup.includes("마감")) {
result.bottom.push(item);
} else {
// 기타 항목은 본체에 포함
result.body.push(item);
}
});
return result;
}, [location?.bomResult?.items]);
// 탭별 소계
const tabSubtotals = useMemo(() => {
const result: Record<string, number> = {};
Object.entries(bomItemsByTab).forEach(([tab, items]) => {
result[tab] = items.reduce((sum, item) => sum + (item.total_price || 0), 0);
});
return result;
}, [bomItemsByTab]);
// ---------------------------------------------------------------------------
// 핸들러
// ---------------------------------------------------------------------------
const handleFieldChange = (field: keyof LocationItem, value: string | number) => {
if (!location || disabled) return;
onUpdateLocation(location.id, { [field]: value });
};
// ---------------------------------------------------------------------------
// 렌더링: 빈 상태
// ---------------------------------------------------------------------------
if (!location) {
return (
<div className="flex flex-col items-center justify-center h-full bg-gray-50 text-gray-500">
<Package className="h-12 w-12 mb-4 text-gray-300" />
<p className="text-lg font-medium"> </p>
<p className="text-sm"> </p>
</div>
);
}
// ---------------------------------------------------------------------------
// 렌더링: 상세 정보
// ---------------------------------------------------------------------------
return (
<div className="flex flex-col h-full overflow-hidden">
{/* 헤더 */}
<div className="bg-white px-4 py-3 border-b">
<div className="flex items-center justify-between">
<h3 className="text-lg font-bold">
{location.floor} / {location.code}
</h3>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">:</span>
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">
{location.productCode}
</Badge>
{location.bomResult && (
<Badge variant="default" className="bg-green-600">
</Badge>
)}
</div>
</div>
</div>
{/* 제품 정보 */}
<div className="bg-gray-50 px-4 py-3 border-b space-y-3">
{/* 오픈사이즈 */}
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600 w-20"></span>
<div className="flex items-center gap-2">
<Input
type="number"
value={location.openWidth}
onChange={(e) => handleFieldChange("openWidth", parseFloat(e.target.value) || 0)}
disabled={disabled}
className="w-24 h-8 text-center font-bold"
/>
<span className="text-gray-400">×</span>
<Input
type="number"
value={location.openHeight}
onChange={(e) => handleFieldChange("openHeight", parseFloat(e.target.value) || 0)}
disabled={disabled}
className="w-24 h-8 text-center font-bold"
/>
{!disabled && (
<Badge variant="secondary" className="text-xs"></Badge>
)}
</div>
</div>
{/* 제작사이즈, 산출중량, 산출면적, 수량 */}
<div className="grid grid-cols-4 gap-4 text-sm">
<div>
<span className="text-gray-500"></span>
<p className="font-semibold">
{location.manufactureWidth || location.openWidth + 280} × {location.manufactureHeight || location.openHeight + 280}
</p>
</div>
<div>
<span className="text-gray-500"></span>
<p className="font-semibold">{location.weight?.toFixed(1) || "-"} <span className="text-xs text-gray-400">kg</span></p>
</div>
<div>
<span className="text-gray-500"></span>
<p className="font-semibold">{location.area?.toFixed(1) || "-"} <span className="text-xs text-gray-400">m²</span></p>
</div>
<div>
<span className="text-gray-500"></span>
<Input
type="number"
min="1"
value={location.quantity}
onChange={(e) => handleFieldChange("quantity", parseInt(e.target.value) || 1)}
disabled={disabled}
className="w-24 h-7 text-center font-semibold"
/>
</div>
</div>
</div>
{/* 필수 설정 (읽기 전용) */}
<div className="bg-white px-4 py-3 border-b">
<h4 className="text-sm font-semibold text-gray-700 mb-2 flex items-center gap-1">
<Settings className="h-4 w-4" />
</h4>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="text-xs text-gray-500 flex items-center gap-1 mb-1">
🔧
</label>
<Badge variant="outline" className="bg-gray-50 text-gray-700 border-gray-300 px-3 py-1.5">
{GUIDE_RAIL_TYPES.find(t => t.value === location.guideRailType)?.label || location.guideRailType}
</Badge>
</div>
<div>
<label className="text-xs text-gray-500 flex items-center gap-1 mb-1">
</label>
<Badge variant="outline" className="bg-gray-50 text-gray-700 border-gray-300 px-3 py-1.5">
{MOTOR_POWERS.find(p => p.value === location.motorPower)?.label || location.motorPower}
</Badge>
</div>
<div>
<label className="text-xs text-gray-500 flex items-center gap-1 mb-1">
📦
</label>
<Badge variant="outline" className="bg-gray-50 text-gray-700 border-gray-300 px-3 py-1.5">
{CONTROLLERS.find(c => c.value === location.controller)?.label || location.controller}
</Badge>
</div>
</div>
</div>
{/* 탭 및 품목 테이블 */}
<div className="flex-1 overflow-hidden flex flex-col">
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col h-full">
{/* 탭 목록 - 스크롤 가능 */}
<div className="border-b bg-white overflow-x-auto">
<TabsList className="w-max min-w-full justify-start rounded-none bg-transparent h-auto p-0">
{DETAIL_TABS.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className="rounded-none border-b-2 border-transparent data-[state=active]:border-blue-500 data-[state=active]:bg-blue-50 data-[state=active]:text-blue-700 px-4 py-2 text-sm whitespace-nowrap"
>
{tab.label}
</TabsTrigger>
))}
</TabsList>
</div>
{/* 본체 (스크린/슬랫) 탭 */}
<TabsContent value="body" className="flex-1 overflow-auto m-0 p-0">
<div className="bg-amber-50 border border-amber-200 rounded-lg m-4">
<Table>
<TableHeader>
<TableRow className="bg-amber-100/50">
<TableHead className="font-semibold"></TableHead>
<TableHead className="text-center font-semibold"></TableHead>
<TableHead className="text-center font-semibold w-24"></TableHead>
<TableHead className="text-center font-semibold w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bomItemsByTab.body.map((item: any) => (
<TableRow key={item.id} className="bg-white">
<TableCell className="font-medium">{item.item_name}</TableCell>
<TableCell className="text-center text-gray-600">{item.manufacture_size || "-"}</TableCell>
<TableCell className="text-center">
<Input
type="number"
defaultValue={item.quantity}
className="w-16 h-8 text-center"
min={1}
readOnly={disabled}
/>
</TableCell>
<TableCell className="text-center">
<Button variant="ghost" size="sm" className="text-red-500 hover:text-red-700 hover:bg-red-50 p-1" disabled={disabled}>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{/* 품목 추가 버튼 + 안내 */}
<div className="p-3 flex items-center justify-between border-t border-amber-200">
<Button
variant="outline"
size="sm"
className="bg-blue-100 border-blue-300 text-blue-700 hover:bg-blue-200"
onClick={() => setItemSearchOpen(true)}
disabled={disabled}
>
<Plus className="h-4 w-4 mr-1" />
</Button>
<span className="text-sm text-gray-500 flex items-center gap-1">
💡
</span>
</div>
</div>
</TabsContent>
{/* 절곡품 - 가이드레일, 케이스, 하단마감재 탭 */}
{["guide-rail", "case", "bottom"].map((tabValue) => (
<TabsContent key={tabValue} value={tabValue} className="flex-1 overflow-auto m-0 p-0">
<div className="bg-amber-50 border border-amber-200 rounded-lg m-4">
<Table>
<TableHeader>
<TableRow className="bg-amber-100/50">
<TableHead className="font-semibold"></TableHead>
<TableHead className="text-center font-semibold"></TableHead>
<TableHead className="text-center font-semibold"></TableHead>
<TableHead className="text-center font-semibold w-28"></TableHead>
<TableHead className="text-center font-semibold w-24"></TableHead>
<TableHead className="text-center font-semibold w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bomItemsByTab[tabValue]?.map((item: any) => (
<TableRow key={item.id} className="bg-white">
<TableCell className="font-medium">{item.item_name}</TableCell>
<TableCell className="text-center text-gray-600">{item.material || "-"}</TableCell>
<TableCell className="text-center text-gray-600">{item.spec || "-"}</TableCell>
<TableCell className="text-center">
<Select defaultValue={item.delivery_length} disabled={disabled}>
<SelectTrigger className="w-24 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DELIVERY_LENGTH_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="text-center">
<Input
type="number"
defaultValue={item.quantity}
className="w-16 h-8 text-center"
min={1}
readOnly={disabled}
/>
</TableCell>
<TableCell className="text-center">
<Button variant="ghost" size="sm" className="text-red-500 hover:text-red-700 hover:bg-red-50 p-1" disabled={disabled}>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{/* 품목 추가 버튼 + 안내 */}
<div className="p-3 flex items-center justify-between border-t border-amber-200">
<Button
variant="outline"
size="sm"
className="bg-blue-100 border-blue-300 text-blue-700 hover:bg-blue-200"
onClick={() => setItemSearchOpen(true)}
disabled={disabled}
>
<Plus className="h-4 w-4 mr-1" />
</Button>
<span className="text-sm text-gray-500 flex items-center gap-1">
💡
</span>
</div>
</div>
</TabsContent>
))}
{/* 모터 & 제어기 탭 */}
<TabsContent value="motor" className="flex-1 overflow-auto m-0 p-0">
<div className="bg-amber-50 border border-amber-200 rounded-lg m-4">
<Table>
<TableHeader>
<TableRow className="bg-amber-100/50">
<TableHead className="font-semibold"></TableHead>
<TableHead className="text-center font-semibold"></TableHead>
<TableHead className="text-center font-semibold"></TableHead>
<TableHead className="text-center font-semibold w-24"></TableHead>
<TableHead className="text-center font-semibold w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bomItemsByTab.motor?.map((item: any) => (
<TableRow key={item.id} className="bg-white">
<TableCell className="font-medium">{item.item_name}</TableCell>
<TableCell className="text-center text-gray-600">{item.type || "-"}</TableCell>
<TableCell className="text-center text-gray-600">{item.spec || "-"}</TableCell>
<TableCell className="text-center">
<Input
type="number"
defaultValue={item.quantity}
className="w-16 h-8 text-center"
min={1}
readOnly={disabled}
/>
</TableCell>
<TableCell className="text-center">
<Button variant="ghost" size="sm" className="text-red-500 hover:text-red-700 hover:bg-red-50 p-1" disabled={disabled}>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{/* 품목 추가 버튼 + 안내 */}
<div className="p-3 flex items-center justify-between border-t border-amber-200">
<Button
variant="outline"
size="sm"
className="bg-blue-100 border-blue-300 text-blue-700 hover:bg-blue-200"
onClick={() => setItemSearchOpen(true)}
disabled={disabled}
>
<Plus className="h-4 w-4 mr-1" />
</Button>
<span className="text-sm text-gray-500 flex items-center gap-1">
💡
</span>
</div>
</div>
</TabsContent>
{/* 부자재 탭 */}
<TabsContent value="accessory" className="flex-1 overflow-auto m-0 p-0">
<div className="bg-amber-50 border border-amber-200 rounded-lg m-4">
<Table>
<TableHeader>
<TableRow className="bg-amber-100/50">
<TableHead className="font-semibold"></TableHead>
<TableHead className="text-center font-semibold"></TableHead>
<TableHead className="text-center font-semibold w-28"></TableHead>
<TableHead className="text-center font-semibold w-24"></TableHead>
<TableHead className="text-center font-semibold w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bomItemsByTab.accessory?.map((item: any) => (
<TableRow key={item.id} className="bg-white">
<TableCell className="font-medium">{item.item_name}</TableCell>
<TableCell className="text-center text-gray-600">{item.spec || "-"}</TableCell>
<TableCell className="text-center">
<Select defaultValue={item.delivery_length} disabled={disabled}>
<SelectTrigger className="w-24 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DELIVERY_LENGTH_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="text-center">
<Input
type="number"
defaultValue={item.quantity}
className="w-16 h-8 text-center"
min={1}
readOnly={disabled}
/>
</TableCell>
<TableCell className="text-center">
<Button variant="ghost" size="sm" className="text-red-500 hover:text-red-700 hover:bg-red-50 p-1" disabled={disabled}>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{/* 품목 추가 버튼 + 안내 */}
<div className="p-3 flex items-center justify-between border-t border-amber-200">
<Button
variant="outline"
size="sm"
className="bg-blue-100 border-blue-300 text-blue-700 hover:bg-blue-200"
onClick={() => setItemSearchOpen(true)}
disabled={disabled}
>
<Plus className="h-4 w-4 mr-1" />
</Button>
<span className="text-sm text-gray-500 flex items-center gap-1">
💡
</span>
</div>
</div>
</TabsContent>
</Tabs>
</div>
{/* 금액 안내 */}
{!location.bomResult && (
<div className="bg-blue-50 px-4 py-2 border-t border-blue-200 text-center text-sm text-blue-700">
💡
</div>
)}
{/* 품목 검색 모달 */}
<ItemSearchModal
open={itemSearchOpen}
onOpenChange={setItemSearchOpen}
onSelectItem={(item) => {
console.log(`[테스트] 품목 선택: ${item.code} - ${item.name} (탭: ${activeTab})`);
}}
tabLabel={DETAIL_TABS.find((t) => t.value === activeTab)?.label}
/>
</div>
);
}

View File

@@ -0,0 +1,548 @@
/**
* 발주 개소 목록 패널
*
* - 개소 목록 테이블
* - 품목 추가 폼
* - 엑셀 업로드/다운로드
*/
"use client";
import { useState, useCallback } from "react";
import { Plus, Upload, Download, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../ui/table";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "../ui/alert-dialog";
import type { LocationItem } from "./QuoteRegistrationV2";
import type { FinishedGoods } from "./actions";
import * as XLSX from "xlsx";
// =============================================================================
// 상수
// =============================================================================
// 가이드레일 설치 유형
const GUIDE_RAIL_TYPES = [
{ value: "wall", label: "벽면형" },
{ value: "floor", label: "측면형" },
];
// 모터 전원
const MOTOR_POWERS = [
{ value: "single", label: "단상(220V)" },
{ value: "three", label: "삼상(380V)" },
];
// 연동제어기
const CONTROLLERS = [
{ value: "basic", label: "단독" },
{ value: "smart", label: "연동" },
{ value: "premium", label: "매립형-뒷박스포함" },
];
// =============================================================================
// Props
// =============================================================================
interface LocationListPanelProps {
locations: LocationItem[];
selectedLocationId: string | null;
onSelectLocation: (id: string) => void;
onAddLocation: (location: Omit<LocationItem, "id">) => void;
onDeleteLocation: (id: string) => void;
onExcelUpload: (locations: Omit<LocationItem, "id">[]) => void;
finishedGoods: FinishedGoods[];
disabled?: boolean;
}
// =============================================================================
// 컴포넌트
// =============================================================================
export function LocationListPanel({
locations,
selectedLocationId,
onSelectLocation,
onAddLocation,
onDeleteLocation,
onExcelUpload,
finishedGoods,
disabled = false,
}: LocationListPanelProps) {
// ---------------------------------------------------------------------------
// 상태
// ---------------------------------------------------------------------------
// 추가 폼 상태
const [formData, setFormData] = useState({
floor: "",
code: "",
openWidth: "",
openHeight: "",
productCode: "",
quantity: "1",
guideRailType: "wall",
motorPower: "single",
controller: "basic",
});
// 삭제 확인 다이얼로그
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
// ---------------------------------------------------------------------------
// 핸들러
// ---------------------------------------------------------------------------
// 폼 필드 변경
const handleFormChange = useCallback((field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
}, []);
// 개소 추가
const handleAdd = useCallback(() => {
// 유효성 검사
if (!formData.floor || !formData.code) {
toast.error("층과 부호를 입력해주세요.");
return;
}
if (!formData.openWidth || !formData.openHeight) {
toast.error("가로와 세로를 입력해주세요.");
return;
}
if (!formData.productCode) {
toast.error("제품을 선택해주세요.");
return;
}
const product = finishedGoods.find((fg) => fg.item_code === formData.productCode);
const newLocation: Omit<LocationItem, "id"> = {
floor: formData.floor,
code: formData.code,
openWidth: parseFloat(formData.openWidth) || 0,
openHeight: parseFloat(formData.openHeight) || 0,
productCode: formData.productCode,
productName: product?.item_name || formData.productCode,
quantity: parseInt(formData.quantity) || 1,
guideRailType: formData.guideRailType,
motorPower: formData.motorPower,
controller: formData.controller,
wingSize: 50,
inspectionFee: 50000,
};
onAddLocation(newLocation);
// 폼 초기화 (일부 필드 유지)
setFormData((prev) => ({
...prev,
floor: "",
code: "",
openWidth: "",
openHeight: "",
quantity: "1",
}));
}, [formData, finishedGoods, onAddLocation]);
// 엑셀 양식 다운로드
const handleDownloadTemplate = useCallback(() => {
const templateData = [
{
: "1층",
: "FSS-01",
가로: 5000,
세로: 3000,
: "KSS01",
수량: 1,
: "wall",
: "single",
: "basic",
},
];
const ws = XLSX.utils.json_to_sheet(templateData);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "개소목록");
// 컬럼 너비 설정
ws["!cols"] = [
{ wch: 10 }, // 층
{ wch: 12 }, // 부호
{ wch: 10 }, // 가로
{ wch: 10 }, // 세로
{ wch: 15 }, // 제품코드
{ wch: 8 }, // 수량
{ wch: 12 }, // 가이드레일
{ wch: 12 }, // 전원
{ wch: 12 }, // 제어기
];
XLSX.writeFile(wb, "견적_개소목록_양식.xlsx");
toast.success("엑셀 양식이 다운로드되었습니다.");
}, []);
// 엑셀 업로드
const handleFileUpload = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = new Uint8Array(e.target?.result as ArrayBuffer);
const workbook = XLSX.read(data, { type: "array" });
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const jsonData = XLSX.utils.sheet_to_json(worksheet);
const parsedLocations: Omit<LocationItem, "id">[] = jsonData.map((row: any) => {
const productCode = row["제품코드"] || "";
const product = finishedGoods.find((fg) => fg.item_code === productCode);
return {
floor: String(row["층"] || ""),
code: String(row["부호"] || ""),
openWidth: parseFloat(row["가로"]) || 0,
openHeight: parseFloat(row["세로"]) || 0,
productCode: productCode,
productName: product?.item_name || productCode,
quantity: parseInt(row["수량"]) || 1,
guideRailType: row["가이드레일"] || "wall",
motorPower: row["전원"] || "single",
controller: row["제어기"] || "basic",
wingSize: 50,
inspectionFee: 50000,
};
});
// 유효한 데이터만 필터링
const validLocations = parsedLocations.filter(
(loc) => loc.floor && loc.code && loc.openWidth > 0 && loc.openHeight > 0
);
if (validLocations.length === 0) {
toast.error("유효한 데이터가 없습니다. 양식을 확인해주세요.");
return;
}
onExcelUpload(validLocations);
} catch (error) {
console.error("엑셀 파싱 오류:", error);
toast.error("엑셀 파일을 읽는 중 오류가 발생했습니다.");
}
};
reader.readAsArrayBuffer(file);
// 파일 입력 초기화
event.target.value = "";
},
[finishedGoods, onExcelUpload]
);
// ---------------------------------------------------------------------------
// 렌더링
// ---------------------------------------------------------------------------
return (
<div className="border-r border-gray-200 flex flex-col">
{/* 헤더 */}
<div className="bg-blue-100 px-4 py-3 border-b border-blue-200">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-blue-800">
📋 ({locations.length})
</h3>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
onClick={handleDownloadTemplate}
disabled={disabled}
className="text-xs"
>
<Download className="h-3 w-3 mr-1" />
</Button>
<label>
<input
type="file"
accept=".xlsx,.xls"
onChange={handleFileUpload}
disabled={disabled}
className="hidden"
/>
<Button
variant="outline"
size="sm"
disabled={disabled}
className="text-xs"
asChild
>
<span>
<Upload className="h-3 w-3 mr-1" />
</span>
</Button>
</label>
</div>
</div>
</div>
{/* 개소 목록 테이블 */}
<div className="flex-1 overflow-auto">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="w-[60px] text-center"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[100px] text-center"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[50px] text-center"></TableHead>
<TableHead className="w-[40px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{locations.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-gray-500 py-8">
</TableCell>
</TableRow>
) : (
locations.map((loc) => (
<TableRow
key={loc.id}
className={`cursor-pointer hover:bg-blue-50 ${
selectedLocationId === loc.id ? "bg-blue-100" : ""
}`}
onClick={() => onSelectLocation(loc.id)}
>
<TableCell className="text-center font-medium">{loc.floor}</TableCell>
<TableCell className="text-center">{loc.code}</TableCell>
<TableCell className="text-center text-sm">
{loc.openWidth}×{loc.openHeight}
</TableCell>
<TableCell className="text-center text-sm">{loc.productCode}</TableCell>
<TableCell className="text-center">{loc.quantity}</TableCell>
<TableCell className="text-center">
{!disabled && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-red-500 hover:text-red-600"
onClick={(e) => {
e.stopPropagation();
setDeleteTarget(loc.id);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* 추가 폼 */}
{!disabled && (
<div className="border-t border-blue-200 bg-blue-50 p-4 space-y-3">
{/* 1행: 층, 부호, 가로, 세로, 제품명, 수량 */}
<div className="grid grid-cols-6 gap-2">
<div>
<label className="text-xs text-gray-600"></label>
<Input
placeholder="1층"
value={formData.floor}
onChange={(e) => handleFormChange("floor", e.target.value)}
className="h-8 text-sm"
/>
</div>
<div>
<label className="text-xs text-gray-600"></label>
<Input
placeholder="FSS-01"
value={formData.code}
onChange={(e) => handleFormChange("code", e.target.value)}
className="h-8 text-sm"
/>
</div>
<div>
<label className="text-xs text-gray-600"></label>
<Input
type="number"
placeholder="5000"
value={formData.openWidth}
onChange={(e) => handleFormChange("openWidth", e.target.value)}
className="h-8 text-sm"
/>
</div>
<div>
<label className="text-xs text-gray-600"></label>
<Input
type="number"
placeholder="3000"
value={formData.openHeight}
onChange={(e) => handleFormChange("openHeight", e.target.value)}
className="h-8 text-sm"
/>
</div>
<div>
<label className="text-xs text-gray-600"></label>
<Select
value={formData.productCode}
onValueChange={(value) => handleFormChange("productCode", value)}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{finishedGoods.map((fg) => (
<SelectItem key={fg.item_code} value={fg.item_code}>
{fg.item_code}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-xs text-gray-600"></label>
<Input
type="number"
min="1"
value={formData.quantity}
onChange={(e) => handleFormChange("quantity", e.target.value)}
className="h-8 text-sm"
/>
</div>
</div>
{/* 2행: 가이드레일, 전원, 제어기, 버튼 */}
<div className="flex items-end gap-2">
<div className="flex-1">
<label className="text-xs text-gray-600 flex items-center gap-1">
🔧
</label>
<Select
value={formData.guideRailType}
onValueChange={(value) => handleFormChange("guideRailType", value)}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{GUIDE_RAIL_TYPES.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1">
<label className="text-xs text-gray-600 flex items-center gap-1">
</label>
<Select
value={formData.motorPower}
onValueChange={(value) => handleFormChange("motorPower", value)}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MOTOR_POWERS.map((power) => (
<SelectItem key={power.value} value={power.value}>
{power.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1">
<label className="text-xs text-gray-600 flex items-center gap-1">
📦
</label>
<Select
value={formData.controller}
onValueChange={(value) => handleFormChange("controller", value)}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONTROLLERS.map((ctrl) => (
<SelectItem key={ctrl.value} value={ctrl.value}>
{ctrl.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
onClick={handleAdd}
className="h-8 bg-green-500 hover:bg-green-600"
>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
)}
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (deleteTarget) {
onDeleteLocation(deleteTarget);
setDeleteTarget(null);
}
}}
className="bg-red-500 hover:bg-red-600"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,136 @@
/**
* 견적 푸터 바
*
* - 예상 전체 견적금액 표시
* - 버튼: 견적서 산출, 임시저장, 최종저장
* - 뒤로가기 버튼
*/
"use client";
import { Download, Save, Check, ArrowLeft, Loader2, Calculator, Eye } from "lucide-react";
import { Button } from "../ui/button";
// =============================================================================
// Props
// =============================================================================
interface QuoteFooterBarProps {
totalLocations: number;
totalAmount: number;
status: "draft" | "temporary" | "final";
onCalculate: () => void;
onPreview: () => void;
onSaveTemporary: () => void;
onSaveFinal: () => void;
onBack: () => void;
isCalculating?: boolean;
isSaving?: boolean;
disabled?: boolean;
}
// =============================================================================
// 컴포넌트
// =============================================================================
export function QuoteFooterBar({
totalLocations,
totalAmount,
status,
onCalculate,
onPreview,
onSaveTemporary,
onSaveFinal,
onBack,
isCalculating = false,
isSaving = false,
disabled = false,
}: QuoteFooterBarProps) {
return (
<div className="sticky bottom-0 bg-gradient-to-r from-blue-50 to-indigo-50 border-t border-blue-200 shadow-lg">
<div className="px-6 py-4 flex items-center justify-between">
{/* 왼쪽: 뒤로가기 + 금액 표시 */}
<div className="flex items-center gap-6">
<Button
variant="outline"
onClick={onBack}
className="gap-2"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<p className="text-sm text-gray-600"> </p>
<p className="text-3xl font-bold text-blue-600">
{totalAmount.toLocaleString()}
<span className="text-lg font-normal text-gray-500 ml-1"></span>
</p>
</div>
</div>
{/* 오른쪽: 버튼들 */}
<div className="flex items-center gap-3">
{/* 견적서 산출 */}
<Button
onClick={onCalculate}
disabled={disabled || isCalculating || totalLocations === 0}
className="bg-indigo-600 hover:bg-indigo-700 text-white gap-2 px-6"
>
{isCalculating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Calculator className="h-4 w-4" />
</>
)}
</Button>
{/* 미리보기 */}
<Button
onClick={onPreview}
disabled={disabled || totalLocations === 0}
variant="outline"
className="gap-2 px-6"
>
<Eye className="h-4 w-4" />
</Button>
{/* 임시저장 */}
<Button
onClick={onSaveTemporary}
disabled={disabled || isSaving}
className="bg-slate-500 hover:bg-slate-600 text-white gap-2 px-6"
>
{isSaving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
</Button>
{/* 최종저장 */}
<Button
onClick={onSaveFinal}
disabled={disabled || isSaving || totalAmount === 0}
className="bg-blue-600 hover:bg-blue-700 text-white gap-2 px-6"
>
{isSaving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Check className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,303 @@
/**
* 견적서 미리보기 모달
*
* - 견적서 문서 형식으로 미리보기
* - PDF, 이메일 전송 버튼
* - 인쇄 기능
*/
"use client";
import { Download, Mail, Printer, X as XIcon } from "lucide-react";
import {
Dialog,
DialogContent,
DialogTitle,
VisuallyHidden,
} from "../ui/dialog";
import { Button } from "../ui/button";
import type { QuoteFormDataV2 } from "./QuoteRegistrationV2";
// =============================================================================
// Props
// =============================================================================
interface QuotePreviewModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
quoteData: QuoteFormDataV2 | null;
}
// =============================================================================
// 컴포넌트
// =============================================================================
export function QuotePreviewModal({
open,
onOpenChange,
quoteData,
}: QuotePreviewModalProps) {
if (!quoteData) return null;
// 총 금액 계산
const totalAmount = quoteData.locations.reduce(
(sum, loc) => sum + (loc.totalPrice || 0),
0
);
// 부가세
const vat = Math.round(totalAmount * 0.1);
const grandTotal = totalAmount + vat;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] md:max-w-[800px] lg:max-w-[900px] h-[95vh] p-0 flex flex-col gap-0 overflow-hidden [&>button]:hidden">
<VisuallyHidden>
<DialogTitle> </DialogTitle>
</VisuallyHidden>
{/* 헤더 영역 - 제목 + 닫기 버튼 */}
<div className="flex-shrink-0 flex items-center justify-between px-6 py-4 border-b bg-white">
<h2 className="text-lg font-semibold"></h2>
<Button
variant="ghost"
size="icon"
onClick={() => onOpenChange(false)}
className="h-8 w-8"
>
<XIcon className="h-4 w-4" />
</Button>
</div>
{/* 버튼 영역 - PDF, 이메일, 인쇄 */}
<div className="flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
<Button
variant="outline"
size="sm"
className="bg-red-500 hover:bg-red-600 text-white border-red-500"
onClick={() => console.log("[테스트] PDF 다운로드")}
>
<Download className="h-4 w-4 mr-1" />
PDF
</Button>
<Button
variant="outline"
size="sm"
className="bg-yellow-500 hover:bg-yellow-600 text-white border-yellow-500"
onClick={() => console.log("[테스트] 이메일 전송")}
>
<Mail className="h-4 w-4 mr-1" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => console.log("[테스트] 인쇄")}
>
<Printer className="h-4 w-4 mr-1" />
</Button>
</div>
{/* 문서 영역 - 스크롤 */}
<div className="flex-1 overflow-y-auto bg-gray-100 p-6">
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg p-8">
{/* 제목 */}
<div className="text-center mb-6">
<h1 className="text-3xl font-bold tracking-widest"> </h1>
<p className="text-sm text-gray-500 mt-2">
: {quoteData.id || "-"} | : {quoteData.registrationDate || "-"}
</p>
</div>
{/* 수요자 정보 */}
<div className="border border-gray-300 mb-4">
<div className="bg-gray-100 px-3 py-2 font-semibold border-b border-gray-300">
</div>
<div className="p-3 grid grid-cols-2 gap-2 text-sm">
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span className="font-medium">{quoteData.clientName || "-"}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span className="font-medium">{quoteData.manager || "-"}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span className="font-medium">{quoteData.siteName || "-"}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span className="font-medium">{quoteData.contact || "-"}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span className="font-medium">{quoteData.registrationDate || "-"}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span className="font-medium">{quoteData.dueDate || "-"}</span>
</div>
</div>
</div>
{/* 공급자 정보 */}
<div className="border border-gray-300 mb-6">
<div className="bg-gray-100 px-3 py-2 font-semibold border-b border-gray-300">
</div>
<div className="p-3 grid grid-cols-2 gap-2 text-sm">
<div className="flex">
<span className="w-24 text-gray-600"></span>
<span className="font-medium">_테스트회사</span>
</div>
<div className="flex">
<span className="w-24 text-gray-600"></span>
<span className="font-medium">123-45-67890</span>
</div>
<div className="flex">
<span className="w-24 text-gray-600"></span>
<span className="font-medium"></span>
</div>
<div className="flex">
<span className="w-24 text-gray-600"></span>
<span className="font-medium"></span>
</div>
<div className="flex col-span-2">
<span className="w-24 text-gray-600"></span>
<span className="font-medium"></span>
</div>
<div className="flex col-span-2">
<span className="w-24 text-gray-600"></span>
<span className="font-medium">07547 583 B-1602</span>
</div>
<div className="flex">
<span className="w-24 text-gray-600"></span>
<span className="font-medium">01048209104</span>
</div>
<div className="flex">
<span className="w-24 text-gray-600"></span>
<span className="font-medium">codebridgex@codebridge-x.com</span>
</div>
</div>
</div>
{/* 총 견적금액 */}
<div className="border-2 border-gray-800 p-4 mb-6 text-center">
<p className="text-sm text-gray-600 mb-1"> </p>
<p className="text-3xl font-bold">
{grandTotal.toLocaleString()}
</p>
<p className="text-xs text-gray-500 mt-1"> </p>
</div>
{/* 제품 구성정보 */}
<div className="border border-gray-300 mb-6">
<div className="bg-gray-800 text-white px-3 py-2 font-semibold">
</div>
<div className="p-3 grid grid-cols-2 gap-2 text-sm">
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span className="font-medium">
{quoteData.locations[0]?.productCode || "-"}
</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"> </span>
<span className="font-medium">{quoteData.locations.length}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span className="font-medium">
{quoteData.locations[0]?.openWidth || "-"} × {quoteData.locations[0]?.openHeight || "-"}
</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span className="font-medium">-</span>
</div>
</div>
</div>
{/* 품목 내역 */}
<div className="border border-gray-300 mb-6">
<div className="bg-gray-800 text-white px-3 py-2 font-semibold">
</div>
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-100 border-b border-gray-300">
<th className="px-3 py-2 text-left">No.</th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-center"></th>
<th className="px-3 py-2 text-center"></th>
<th className="px-3 py-2 text-center"></th>
<th className="px-3 py-2 text-right"></th>
<th className="px-3 py-2 text-right"></th>
</tr>
</thead>
<tbody>
{quoteData.locations.map((loc, index) => (
<tr key={loc.id} className="border-b border-gray-200">
<td className="px-3 py-2">{index + 1}</td>
<td className="px-3 py-2">{loc.productCode}</td>
<td className="px-3 py-2 text-center">
{loc.openWidth}×{loc.openHeight}
</td>
<td className="px-3 py-2 text-center">{loc.quantity}</td>
<td className="px-3 py-2 text-center">EA</td>
<td className="px-3 py-2 text-right">
{(loc.unitPrice || 0).toLocaleString()}
</td>
<td className="px-3 py-2 text-right">
{(loc.totalPrice || 0).toLocaleString()}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 border-gray-400">
<td colSpan={5}></td>
<td className="px-3 py-2 text-right font-medium"> </td>
<td className="px-3 py-2 text-right font-bold">
{totalAmount.toLocaleString()}
</td>
</tr>
<tr>
<td colSpan={5}></td>
<td className="px-3 py-2 text-right font-medium"> (10%)</td>
<td className="px-3 py-2 text-right font-bold">
{vat.toLocaleString()}
</td>
</tr>
<tr className="bg-gray-100">
<td colSpan={5}></td>
<td className="px-3 py-2 text-right font-medium"> </td>
<td className="px-3 py-2 text-right font-bold text-lg">
{grandTotal.toLocaleString()}
</td>
</tr>
</tfoot>
</table>
</div>
{/* 비고사항 */}
<div className="border border-gray-300">
<div className="bg-gray-800 text-white px-3 py-2 font-semibold">
</div>
<div className="p-3 min-h-[80px] text-sm text-gray-600">
{quoteData.remarks || "비고 테스트"}
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,603 @@
/**
* 견적 등록/수정 컴포넌트 V2
*
* 새로운 레이아웃:
* - 좌우 분할: 발주 개소 목록 | 선택 개소 상세
* - 하단: 견적 금액 요약 (개소별 + 상세별)
* - 푸터: 총 금액 + 버튼들 (견적서 산출, 임시저장, 최종저장)
*/
"use client";
import { useState, useEffect, useMemo, useCallback } from "react";
import { FileText, Calculator, Download, Save, Check } from "lucide-react";
import { toast } from "sonner";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
import { Button } from "../ui/button";
import { Badge } from "../ui/badge";
import { Input } from "../ui/input";
import { Textarea } from "../ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import {
ResponsiveFormTemplate,
FormSection,
FormFieldGrid,
} from "../templates/ResponsiveFormTemplate";
import { FormField } from "../molecules/FormField";
import { LocationListPanel } from "./LocationListPanel";
import { LocationDetailPanel } from "./LocationDetailPanel";
import { QuoteSummaryPanel } from "./QuoteSummaryPanel";
import { QuoteFooterBar } from "./QuoteFooterBar";
import { QuotePreviewModal } from "./QuotePreviewModal";
import {
getFinishedGoods,
calculateBomBulk,
getSiteNames,
type FinishedGoods,
type BomCalculationResult,
} from "./actions";
import { getClients } from "../accounting/VendorManagement/actions";
import { isNextRedirectError } from "@/lib/utils/redirect-error";
import type { Vendor } from "../accounting/VendorManagement";
import type { BomMaterial, CalculationResults } from "./types";
// =============================================================================
// 타입 정의
// =============================================================================
// 발주 개소 항목
export interface LocationItem {
id: string;
floor: string; // 층
code: string; // 부호
openWidth: number; // 가로 (오픈사이즈 W)
openHeight: number; // 세로 (오픈사이즈 H)
productCode: string; // 제품코드
productName: string; // 제품명
quantity: number; // 수량
guideRailType: string; // 가이드레일 설치 유형
motorPower: string; // 모터 전원
controller: string; // 연동제어기
wingSize: number; // 마구리 날개치수
inspectionFee: number; // 검사비
// 계산 결과
manufactureWidth?: number; // 제작사이즈 W
manufactureHeight?: number; // 제작사이즈 H
weight?: number; // 산출중량 (kg)
area?: number; // 산출면적 (m²)
unitPrice?: number; // 단가
totalPrice?: number; // 합계
bomResult?: BomCalculationResult; // BOM 계산 결과
}
// 견적 폼 데이터 V2
export interface QuoteFormDataV2 {
id?: string;
registrationDate: string;
writer: string;
clientId: string;
clientName: string;
siteName: string;
manager: string;
contact: string;
dueDate: string;
remarks: string;
status: "draft" | "temporary" | "final"; // 작성중, 임시저장, 최종저장
locations: LocationItem[];
}
// =============================================================================
// 상수
// =============================================================================
// 초기 개소 항목
const createNewLocation = (): LocationItem => ({
id: `loc-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
floor: "",
code: "",
openWidth: 0,
openHeight: 0,
productCode: "",
productName: "",
quantity: 1,
guideRailType: "wall",
motorPower: "single",
controller: "basic",
wingSize: 50,
inspectionFee: 50000,
});
// 초기 폼 데이터
const INITIAL_FORM_DATA: QuoteFormDataV2 = {
registrationDate: new Date().toISOString().split("T")[0],
writer: "드미트리", // TODO: 로그인 사용자 정보
clientId: "",
clientName: "",
siteName: "",
manager: "",
contact: "",
dueDate: "",
remarks: "",
status: "draft",
locations: [],
};
// =============================================================================
// Props
// =============================================================================
interface QuoteRegistrationV2Props {
mode: "create" | "view" | "edit";
onBack: () => void;
onSave?: (data: QuoteFormDataV2, saveType: "temporary" | "final") => Promise<void>;
onCalculate?: () => void;
initialData?: QuoteFormDataV2 | null;
isLoading?: boolean;
}
// =============================================================================
// 메인 컴포넌트
// =============================================================================
export function QuoteRegistrationV2({
mode,
onBack,
onSave,
onCalculate,
initialData,
isLoading = false,
}: QuoteRegistrationV2Props) {
// ---------------------------------------------------------------------------
// 상태
// ---------------------------------------------------------------------------
const [formData, setFormData] = useState<QuoteFormDataV2>(
initialData || INITIAL_FORM_DATA
);
const [selectedLocationId, setSelectedLocationId] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [isCalculating, setIsCalculating] = useState(false);
const [previewModalOpen, setPreviewModalOpen] = useState(false);
// API 데이터
const [clients, setClients] = useState<Vendor[]>([]);
const [finishedGoods, setFinishedGoods] = useState<FinishedGoods[]>([]);
const [siteNames, setSiteNames] = useState<string[]>([]);
const [isLoadingClients, setIsLoadingClients] = useState(false);
const [isLoadingProducts, setIsLoadingProducts] = useState(false);
// ---------------------------------------------------------------------------
// 계산된 값
// ---------------------------------------------------------------------------
// 선택된 개소
const selectedLocation = useMemo(() => {
return formData.locations.find((loc) => loc.id === selectedLocationId) || null;
}, [formData.locations, selectedLocationId]);
// 총 금액
const totalAmount = useMemo(() => {
return formData.locations.reduce((sum, loc) => sum + (loc.totalPrice || 0), 0);
}, [formData.locations]);
// 개소별 합계
const locationTotals = useMemo(() => {
return formData.locations.map((loc) => ({
id: loc.id,
label: `${loc.floor} / ${loc.code}`,
productCode: loc.productCode,
quantity: loc.quantity,
unitPrice: loc.unitPrice || 0,
totalPrice: loc.totalPrice || 0,
}));
}, [formData.locations]);
// ---------------------------------------------------------------------------
// 초기 데이터 로드
// ---------------------------------------------------------------------------
useEffect(() => {
const loadInitialData = async () => {
// 거래처 로드
setIsLoadingClients(true);
try {
const result = await getClients();
if (result.success) {
setClients(result.data);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error("거래처 로드 실패:", error);
} finally {
setIsLoadingClients(false);
}
// 완제품 로드
setIsLoadingProducts(true);
try {
const result = await getFinishedGoods();
if (result.success) {
setFinishedGoods(result.data);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error("완제품 로드 실패:", error);
} finally {
setIsLoadingProducts(false);
}
// 현장명 로드
try {
const result = await getSiteNames();
if (result.success) {
setSiteNames(result.data);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
}
};
loadInitialData();
}, []);
// initialData 변경 시 formData 업데이트
useEffect(() => {
if (initialData) {
setFormData(initialData);
// 첫 번째 개소 자동 선택
if (initialData.locations.length > 0 && !selectedLocationId) {
setSelectedLocationId(initialData.locations[0].id);
}
}
}, [initialData]);
// ---------------------------------------------------------------------------
// 핸들러
// ---------------------------------------------------------------------------
// 기본 정보 변경
const handleFieldChange = useCallback((field: keyof QuoteFormDataV2, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
}, []);
// 발주처 선택
const handleClientChange = useCallback((clientId: string) => {
const client = clients.find((c) => c.id === clientId);
setFormData((prev) => ({
...prev,
clientId,
clientName: client?.vendorName || "",
}));
}, [clients]);
// 개소 추가
const handleAddLocation = useCallback((location: Omit<LocationItem, "id">) => {
const newLocation: LocationItem = {
...location,
id: `loc-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
};
setFormData((prev) => ({
...prev,
locations: [...prev.locations, newLocation],
}));
setSelectedLocationId(newLocation.id);
toast.success("개소가 추가되었습니다.");
}, []);
// 개소 삭제
const handleDeleteLocation = useCallback((locationId: string) => {
setFormData((prev) => ({
...prev,
locations: prev.locations.filter((loc) => loc.id !== locationId),
}));
if (selectedLocationId === locationId) {
setSelectedLocationId(formData.locations[0]?.id || null);
}
toast.success("개소가 삭제되었습니다.");
}, [selectedLocationId, formData.locations]);
// 개소 수정
const handleUpdateLocation = useCallback((locationId: string, updates: Partial<LocationItem>) => {
setFormData((prev) => ({
...prev,
locations: prev.locations.map((loc) =>
loc.id === locationId ? { ...loc, ...updates } : loc
),
}));
}, []);
// 엑셀 업로드
const handleExcelUpload = useCallback((locations: Omit<LocationItem, "id">[]) => {
const newLocations: LocationItem[] = locations.map((loc) => ({
...loc,
id: `loc-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
}));
setFormData((prev) => ({
...prev,
locations: [...prev.locations, ...newLocations],
}));
if (newLocations.length > 0) {
setSelectedLocationId(newLocations[0].id);
}
toast.success(`${newLocations.length}개 개소가 추가되었습니다.`);
}, []);
// 견적 산출
const handleCalculate = useCallback(async () => {
if (formData.locations.length === 0) {
toast.error("산출할 개소가 없습니다.");
return;
}
setIsCalculating(true);
try {
const bomItems = formData.locations.map((loc) => ({
finished_goods_code: loc.productCode,
openWidth: loc.openWidth,
openHeight: loc.openHeight,
quantity: loc.quantity,
guideRailType: loc.guideRailType,
motorPower: loc.motorPower,
controller: loc.controller,
wingSize: loc.wingSize,
inspectionFee: loc.inspectionFee,
}));
const result = await calculateBomBulk(bomItems);
if (result.success && result.data) {
const apiData = result.data as {
summary?: { grand_total: number };
items?: Array<{ index: number; result: BomCalculationResult }>;
};
// 결과 반영
const updatedLocations = formData.locations.map((loc, index) => {
const bomResult = apiData.items?.find((item) => item.index === index);
if (bomResult?.result) {
return {
...loc,
unitPrice: bomResult.result.grand_total,
totalPrice: bomResult.result.grand_total * loc.quantity,
bomResult: bomResult.result,
};
}
return loc;
});
setFormData((prev) => ({ ...prev, locations: updatedLocations }));
toast.success(`${formData.locations.length}개 개소의 견적이 산출되었습니다.`);
} else {
toast.error(`견적 산출 실패: ${result.error}`);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
toast.error("견적 산출 중 오류가 발생했습니다.");
} finally {
setIsCalculating(false);
}
}, [formData.locations]);
// 저장 (임시/최종)
const handleSave = useCallback(async (saveType: "temporary" | "final") => {
if (!onSave) return;
setIsSaving(true);
try {
const dataToSave: QuoteFormDataV2 = {
...formData,
status: saveType === "temporary" ? "temporary" : "final",
};
await onSave(dataToSave, saveType);
toast.success(saveType === "temporary" ? "임시 저장되었습니다." : "최종 저장되었습니다.");
} catch (error) {
if (isNextRedirectError(error)) throw error;
toast.error("저장 중 오류가 발생했습니다.");
} finally {
setIsSaving(false);
}
}, [formData, onSave]);
// ---------------------------------------------------------------------------
// 렌더링
// ---------------------------------------------------------------------------
const isViewMode = mode === "view";
const pageTitle = mode === "create" ? "견적 등록 (V2 테스트)" : mode === "edit" ? "견적 수정 (V2 테스트)" : "견적 상세 (V2 테스트)";
return (
<div className="flex flex-col h-full">
{/* 기본 정보 섹션 */}
<div className="p-4 md:p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold flex items-center gap-2">
<FileText className="h-6 w-6" />
{pageTitle}
</h1>
<Badge variant={formData.status === "final" ? "default" : formData.status === "temporary" ? "secondary" : "outline"}>
{formData.status === "final" ? "최종저장" : formData.status === "temporary" ? "임시저장" : "작성중"}
</Badge>
</div>
{/* 기본 정보 */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<FileText className="h-5 w-5" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="text-sm font-medium text-gray-700"></label>
<Input
type="date"
value={formData.registrationDate}
disabled
className="bg-gray-50"
/>
</div>
<div>
<label className="text-sm font-medium text-gray-700"></label>
<Input
value={formData.writer}
disabled
className="bg-gray-50"
/>
</div>
<div>
<label className="text-sm font-medium text-gray-700"> <span className="text-red-500">*</span></label>
<Select
value={formData.clientId}
onValueChange={handleClientChange}
disabled={isViewMode || isLoadingClients}
>
<SelectTrigger>
<SelectValue placeholder={isLoadingClients ? "로딩 중..." : "발주처를 선택하세요"} />
</SelectTrigger>
<SelectContent>
{clients.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.vendorName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="text-sm font-medium text-gray-700"></label>
<Input
list="siteNameList"
placeholder="현장명을 입력하세요"
value={formData.siteName}
onChange={(e) => handleFieldChange("siteName", e.target.value)}
disabled={isViewMode}
/>
<datalist id="siteNameList">
{siteNames.map((name) => (
<option key={name} value={name} />
))}
</datalist>
</div>
<div>
<label className="text-sm font-medium text-gray-700"> </label>
<Input
placeholder="담당자명을 입력하세요"
value={formData.manager}
onChange={(e) => handleFieldChange("manager", e.target.value)}
disabled={isViewMode}
/>
</div>
<div>
<label className="text-sm font-medium text-gray-700"></label>
<Input
placeholder="010-1234-5678"
value={formData.contact}
onChange={(e) => handleFieldChange("contact", e.target.value)}
disabled={isViewMode}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="text-sm font-medium text-gray-700"></label>
<Input
type="date"
value={formData.dueDate}
onChange={(e) => handleFieldChange("dueDate", e.target.value)}
disabled={isViewMode}
/>
</div>
<div className="md:col-span-2">
<label className="text-sm font-medium text-gray-700"></label>
<Textarea
placeholder="특이사항을 입력하세요"
value={formData.remarks}
onChange={(e) => handleFieldChange("remarks", e.target.value)}
disabled={isViewMode}
rows={2}
/>
</div>
</div>
</CardContent>
</Card>
{/* 자동 견적 산출 섹션 */}
<Card>
<CardHeader className="pb-3 bg-orange-50 border-b border-orange-200">
<CardTitle className="text-base font-semibold flex items-center gap-2 text-orange-800">
<Calculator className="h-5 w-5" />
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{/* 좌우 분할 레이아웃 */}
<div className="grid grid-cols-1 lg:grid-cols-2 min-h-[500px]">
{/* 왼쪽: 발주 개소 목록 + 추가 폼 */}
<LocationListPanel
locations={formData.locations}
selectedLocationId={selectedLocationId}
onSelectLocation={setSelectedLocationId}
onAddLocation={handleAddLocation}
onDeleteLocation={handleDeleteLocation}
onExcelUpload={handleExcelUpload}
finishedGoods={finishedGoods}
disabled={isViewMode}
/>
{/* 오른쪽: 선택 개소 상세 */}
<LocationDetailPanel
location={selectedLocation}
onUpdateLocation={handleUpdateLocation}
finishedGoods={finishedGoods}
disabled={isViewMode}
/>
</div>
</CardContent>
</Card>
{/* 견적 금액 요약 */}
<QuoteSummaryPanel
locations={formData.locations}
selectedLocationId={selectedLocationId}
onSelectLocation={setSelectedLocationId}
/>
</div>
{/* 푸터 바 (고정) */}
<QuoteFooterBar
totalLocations={formData.locations.length}
totalAmount={totalAmount}
status={formData.status}
onCalculate={handleCalculate}
onPreview={() => setPreviewModalOpen(true)}
onSaveTemporary={() => handleSave("temporary")}
onSaveFinal={() => handleSave("final")}
onBack={onBack}
isCalculating={isCalculating}
isSaving={isSaving}
disabled={isViewMode}
/>
{/* 견적서 미리보기 모달 */}
<QuotePreviewModal
open={previewModalOpen}
onOpenChange={setPreviewModalOpen}
quoteData={formData}
/>
</div>
);
}

View File

@@ -0,0 +1,311 @@
/**
* 견적 금액 요약 패널
*
* - 개소별 합계 (왼쪽) - 클릭하여 상세 확인
* - 상세별 합계 (오른쪽) - 선택 개소의 카테고리별 금액 및 품목 상세
* - 스크롤 가능한 상세 영역
*/
"use client";
import { useMemo } from "react";
import { Coins } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
import type { LocationItem } from "./QuoteRegistrationV2";
// =============================================================================
// 목데이터 - 상세별 합계 (공정별 + 품목 상세)
// =============================================================================
interface DetailItem {
name: string;
quantity: number;
unitPrice: number;
totalPrice: number;
}
interface DetailCategory {
label: string;
count: number;
amount: number;
items: DetailItem[];
}
const MOCK_DETAIL_TOTALS: DetailCategory[] = [
{
label: "본체 (스크린/슬랫)",
count: 1,
amount: 1061676,
items: [
{ name: "실리카 스크린", quantity: 1, unitPrice: 1061676, totalPrice: 1061676 },
]
},
{
label: "절곡품 - 가이드레일",
count: 2,
amount: 116556,
items: [
{ name: "벽면형 마감재", quantity: 2, unitPrice: 42024, totalPrice: 84048 },
{ name: "본체 가이드 레일", quantity: 2, unitPrice: 16254, totalPrice: 32508 },
]
},
{
label: "절곡품 - 케이스",
count: 1,
amount: 30348,
items: [
{ name: "전면부 케이스", quantity: 1, unitPrice: 30348, totalPrice: 30348 },
]
},
{
label: "절곡품 - 하단마감재",
count: 1,
amount: 15420,
items: [
{ name: "하단 하우징", quantity: 1, unitPrice: 15420, totalPrice: 15420 },
]
},
{
label: "모터 & 제어기",
count: 2,
amount: 400000,
items: [
{ name: "직류 모터", quantity: 1, unitPrice: 250000, totalPrice: 250000 },
{ name: "제어기", quantity: 1, unitPrice: 150000, totalPrice: 150000 },
]
},
{
label: "부자재",
count: 2,
amount: 21200,
items: [
{ name: "각파이프 25mm", quantity: 2, unitPrice: 8500, totalPrice: 17000 },
{ name: "플랫바 20mm", quantity: 1, unitPrice: 4200, totalPrice: 4200 },
]
},
];
// =============================================================================
// Props
// =============================================================================
interface QuoteSummaryPanelProps {
locations: LocationItem[];
selectedLocationId: string | null;
onSelectLocation: (id: string) => void;
}
// =============================================================================
// 컴포넌트
// =============================================================================
export function QuoteSummaryPanel({
locations,
selectedLocationId,
onSelectLocation,
}: QuoteSummaryPanelProps) {
// ---------------------------------------------------------------------------
// 계산된 값
// ---------------------------------------------------------------------------
// 선택된 개소
const selectedLocation = useMemo(() => {
return locations.find((loc) => loc.id === selectedLocationId) || null;
}, [locations, selectedLocationId]);
// 총 금액
const totalAmount = useMemo(() => {
return locations.reduce((sum, loc) => sum + (loc.totalPrice || 0), 0);
}, [locations]);
// 개소별 합계
const locationTotals = useMemo(() => {
return locations.map((loc) => ({
id: loc.id,
label: `${loc.floor} / ${loc.code}`,
productCode: loc.productCode,
quantity: loc.quantity,
unitPrice: loc.unitPrice || 0,
totalPrice: loc.totalPrice || 0,
}));
}, [locations]);
// 선택 개소의 상세별 합계 (공정별) - 목데이터 포함
const detailTotals = useMemo((): DetailCategory[] => {
// bomResult가 없으면 목데이터 사용
if (!selectedLocation?.bomResult?.subtotals) {
return selectedLocation ? MOCK_DETAIL_TOTALS : [];
}
const subtotals = selectedLocation.bomResult.subtotals;
const result: DetailCategory[] = [];
Object.entries(subtotals).forEach(([key, value]) => {
if (typeof value === "object" && value !== null) {
result.push({
label: value.name || key,
count: value.count || 0,
amount: value.subtotal || 0,
items: value.items || [],
});
} else if (typeof value === "number") {
result.push({
label: key,
count: 0,
amount: value,
items: [],
});
}
});
return result;
}, [selectedLocation]);
// ---------------------------------------------------------------------------
// 렌더링
// ---------------------------------------------------------------------------
return (
<Card className="border-gray-200">
<CardHeader className="pb-3 bg-gradient-to-r from-blue-50 to-indigo-50 border-b border-blue-200">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Coins className="h-5 w-5 text-blue-600" />
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{/* 좌우 분할 */}
<div className="grid grid-cols-1 lg:grid-cols-2 divide-y lg:divide-y-0 lg:divide-x divide-gray-200">
{/* 왼쪽: 개소별 합계 */}
<div className="p-4">
<div className="flex items-center gap-2 mb-3">
<span className="text-blue-500">📍</span>
<h4 className="font-semibold text-gray-700"> </h4>
</div>
{locations.length === 0 ? (
<div className="text-center text-gray-500 py-6">
<p className="text-sm"> </p>
</div>
) : (
<div className="space-y-2 max-h-[300px] overflow-y-auto pr-2">
{locationTotals.map((loc) => (
<div
key={loc.id}
className={`flex items-center justify-between p-3 rounded-lg cursor-pointer transition-colors ${
selectedLocationId === loc.id
? "bg-blue-100 border border-blue-300"
: "bg-gray-50 hover:bg-gray-100 border border-transparent"
}`}
onClick={() => onSelectLocation(loc.id)}
>
<div>
<p className="font-medium">{loc.label}</p>
<p className="text-xs text-gray-500">
{loc.productCode} × {loc.quantity}
</p>
</div>
<div className="text-right">
<p className="text-xs text-gray-500"></p>
<p className="font-bold text-blue-600">
{loc.totalPrice.toLocaleString()}
</p>
{loc.unitPrice > 0 && (
<p className="text-xs text-gray-400">
: {(loc.unitPrice * loc.quantity).toLocaleString()}
</p>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* 오른쪽: 상세별 합계 */}
<div className="p-4">
<div className="flex items-center gap-2 mb-3">
<span className="text-blue-500"></span>
<h4 className="font-semibold text-gray-700">
{selectedLocation && (
<span className="text-sm font-normal text-gray-500 ml-2">
({selectedLocation.floor} / {selectedLocation.code})
</span>
)}
</h4>
</div>
{!selectedLocation ? (
<div className="text-center text-gray-500 py-6">
<p className="text-sm"> </p>
</div>
) : detailTotals.length === 0 ? (
<div className="text-center text-gray-500 py-6">
<p className="text-sm"> </p>
</div>
) : (
<div className="space-y-3 max-h-[300px] overflow-y-auto pr-2">
{detailTotals.map((category, index) => (
<div key={index} className="bg-blue-50 rounded-lg overflow-hidden border border-blue-200">
{/* 카테고리 헤더 */}
<div className="flex items-center justify-between px-3 py-2 bg-blue-100/50">
<span className="font-semibold text-gray-700">{category.label}</span>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">({category.count})</span>
<span className="font-bold text-blue-600">
{category.amount.toLocaleString()}
</span>
</div>
</div>
{/* 품목 상세 목록 */}
<div className="divide-y divide-blue-100">
{category.items.map((item, itemIndex) => (
<div key={itemIndex} className="flex items-center justify-between px-3 py-2 bg-white">
<div>
<span className="text-sm text-gray-700">{item.name}</span>
<p className="text-xs text-gray-400">
: {item.quantity} × : {item.unitPrice.toLocaleString()}
</p>
</div>
<span className="text-sm font-medium text-blue-600">
{item.totalPrice.toLocaleString()}
</span>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* 하단 바: 총 개소 수, 예상 견적금액, 견적 상태 */}
<div className="bg-gray-900 text-white px-6 py-5 flex items-center justify-between">
<div className="flex items-center gap-10">
<div>
<p className="text-sm text-gray-400"> </p>
<p className="text-4xl font-bold">{locations.length}</p>
</div>
<div>
<p className="text-sm text-gray-400"> </p>
<p className="text-4xl font-bold text-blue-400">
{totalAmount.toLocaleString()}
<span className="text-xl ml-1"></span>
</p>
</div>
</div>
<div className="text-right">
<p className="text-sm text-gray-400"> </p>
<span className="inline-block bg-blue-500/20 text-blue-300 border border-blue-500/50 text-lg px-4 py-1 rounded">
</span>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -7,6 +7,13 @@ import { Tabs, TabsContent } from "@/components/ui/tabs";
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
AlertDialog,
AlertDialogAction,
@@ -23,6 +30,8 @@ import { StatCards } from "@/components/organisms/StatCards";
import { SearchFilter } from "@/components/organisms/SearchFilter";
import { ScreenVersionHistory } from "@/components/organisms/ScreenVersionHistory";
import { TabChip } from "@/components/atoms/TabChip";
import { MultiSelectCombobox } from "@/components/ui/multi-select-combobox";
import { MobileFilter, FilterFieldConfig, FilterValues } from "@/components/molecules/MobileFilter";
/**
* 기본 통합 목록_버젼2
@@ -119,6 +128,18 @@ export interface IntegratedListTemplateV2Props<T = any> {
// 테이블 헤더 액션 (탭 옆에 표시될 셀렉트박스 등)
tableHeaderActions?: ReactNode;
// 모바일/카드 뷰용 필터 슬롯 (xl 미만에서 카드 목록 위에 표시)
mobileFilterSlot?: ReactNode;
// ===== 새로운 통합 필터 시스템 (선택적 사용) =====
// filterConfig를 전달하면 PC는 인라인, 모바일은 바텀시트로 자동 분기
// 기존 tableHeaderActions, mobileFilterSlot과 함께 사용 가능
filterConfig?: FilterFieldConfig[];
filterValues?: FilterValues;
onFilterChange?: (key: string, value: string | string[]) => void;
onFilterReset?: () => void;
filterTitle?: string; // 모바일 필터 바텀시트 제목 (기본: "검색 필터")
// 테이블 앞에 표시될 컨텐츠 (계정과목명 + 저장 버튼 등)
beforeTableContent?: ReactNode;
@@ -184,6 +205,12 @@ export function IntegratedListTemplateV2<T = any>({
activeTab,
onTabChange,
tableHeaderActions,
mobileFilterSlot,
filterConfig,
filterValues,
onFilterChange,
onFilterReset,
filterTitle = "검색 필터",
beforeTableContent,
tableColumns,
tableTitle,
@@ -214,6 +241,75 @@ export function IntegratedListTemplateV2<T = any>({
const startIndex = (pagination.currentPage - 1) * pagination.itemsPerPage;
const allSelected = selectedItems.size === data.length && data.length > 0;
// ===== filterConfig 기반 자동 필터 렌더링 =====
// PC용 인라인 필터 (xl 이상에서 표시)
const renderAutoFilters = () => {
if (!filterConfig || !filterValues || !onFilterChange) return null;
return (
<div className="flex items-center gap-2 flex-wrap">
{filterConfig.map((field) => {
if (field.type === 'single') {
// 단일선택: Select
return (
<Select
key={field.key}
value={(filterValues[field.key] as string) || 'all'}
onValueChange={(value) => onFilterChange(field.key, value)}
>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder={field.allOptionLabel || field.label} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{field.allOptionLabel || '전체'}
</SelectItem>
{field.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
} else {
// 다중선택: MultiSelectCombobox
return (
<MultiSelectCombobox
key={field.key}
options={field.options.map((opt) => ({
value: opt.value,
label: opt.label,
}))}
value={(filterValues[field.key] as string[]) || []}
onChange={(value) => onFilterChange(field.key, value)}
placeholder={field.label}
searchPlaceholder={`${field.label} 검색...`}
className="w-[140px]"
/>
);
}
})}
</div>
);
};
// 모바일용 바텀시트 필터 (xl 미만에서 표시)
const renderAutoMobileFilter = () => {
if (!filterConfig || !filterValues || !onFilterChange || !onFilterReset) return null;
return (
<MobileFilter
fields={filterConfig}
values={filterValues}
onChange={onFilterChange}
onReset={onFilterReset}
buttonLabel="필터"
title={filterTitle}
/>
);
};
// 일괄삭제 확인 핸들러
const handleBulkDeleteClick = () => {
setShowDeleteDialog(true);
@@ -316,7 +412,9 @@ export function IntegratedListTemplateV2<T = any>({
{selectedItems.size}
</span>
)}
{/* 테이블 헤더 액션 (필터/정렬 셀렉트박스 등) */}
{/* filterConfig 기반 자동 필터 (PC) */}
{renderAutoFilters()}
{/* 테이블 헤더 액션 (필터/정렬 셀렉트박스 등) - 기존 방식 */}
{tableHeaderActions}
{selectedItems.size >= 1 && onBulkDelete && (
<Button
@@ -351,6 +449,16 @@ export function IntegratedListTemplateV2<T = any>({
</div>
)}
{/* 모바일/카드 뷰용 필터 - filterConfig 자동 생성 또는 기존 mobileFilterSlot */}
{(filterConfig || mobileFilterSlot) && (
<div className="xl:hidden mb-4">
{/* filterConfig가 있으면 자동 생성된 MobileFilter 사용 */}
{renderAutoMobileFilter()}
{/* 기존 방식: mobileFilterSlot 직접 전달 */}
{mobileFilterSlot}
</div>
)}
{/* 모바일/태블릿/소형 노트북 (~1279px) 카드 뷰 */}
<div className="xl:hidden space-y-4 md:space-y-0 md:grid md:grid-cols-2 md:gap-4 lg:grid-cols-3">
{(allData && allData.length > 0 ? allData : data).length === 0 ? (
@@ -554,4 +662,7 @@ export function IntegratedListTemplateV2<T = any>({
</AlertDialog>
</PageLayout>
);
}
}
// 필터 관련 타입 재export (다른 페이지에서 사용 가능)
export type { FilterFieldConfig, FilterValues } from "@/components/molecules/MobileFilter";