refactor(WEB): 공사관리 리스트 페이지 모바일 필터 마이그레이션

- BiddingListClient: MobileFilter 컴포넌트 적용
- ContractListClient: MobileFilter 컴포넌트 적용
- EstimateListClient: MobileFilter 컴포넌트 적용
- HandoverReportListClient: MobileFilter 컴포넌트 적용
- IssueManagementListClient: MobileFilter 컴포넌트 적용
- ItemManagementClient: MobileFilter 컴포넌트 적용
- LaborManagementClient: MobileFilter 컴포넌트 적용
- PricingListClient: MobileFilter 컴포넌트 적용
- SiteBriefingListClient: MobileFilter 컴포넌트 적용
- SiteManagementListClient: MobileFilter 컴포넌트 적용
- StructureReviewListClient: MobileFilter 컴포넌트 적용
- WorkerStatusListClient: MobileFilter 컴포넌트 적용
- TodayIssueSection: CEO 대시보드 이슈 섹션 개선
- EmployeeForm: 사원등록 폼 개선

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2026-01-13 18:33:39 +09:00
parent db47a15544
commit ea8d701a8d
16 changed files with 1224 additions and 843 deletions

View File

@@ -16,29 +16,29 @@
--- ---
## 🏗️ 건설 도메인 (12개) ## 🏗️ 건설 도메인 (12개) ✅ 완료
### 입찰관리 ### 입찰관리
- [ ] 현장설명회관리 (`SiteBriefingListClient.tsx`) - [x] 현장설명회관리 (`SiteBriefingListClient.tsx`)
- [ ] 견적관리 (`EstimateListClient.tsx`) - [x] 견적관리 (`EstimateListClient.tsx`)
- [ ] 입찰관리 (`BiddingListClient.tsx`) - [x] 입찰관리 (`BiddingListClient.tsx`)
### 계약관리 ### 계약관리
- [ ] 계약관리 (`ContractListClient.tsx`) - [x] 계약관리 (`ContractListClient.tsx`)
- [ ] 인수인계보고서 (`HandoverReportListClient.tsx`) - [x] 인수인계보고서 (`HandoverReportListClient.tsx`)
### 발주관리 ### 발주관리
- [ ] 현장관리 (`SiteManagementListClient.tsx`) - [x] 현장관리 (`SiteManagementListClient.tsx`)
- [ ] 구조검토관리 (`StructureReviewListClient.tsx`) - [x] 구조검토관리 (`StructureReviewListClient.tsx`)
### 공사관리 ### 공사관리
- [ ] 이슈관리 (`IssueManagementListClient.tsx`) - [x] 이슈관리 (`IssueManagementListClient.tsx`)
- [ ] 작업인력현황 (`WorkerStatusListClient.tsx`) - [x] 작업인력현황 (`WorkerStatusListClient.tsx`)
### 기준정보 ### 기준정보
- [ ] 품목관리 (`ItemManagementClient.tsx`) - [x] 품목관리 (`ItemManagementClient.tsx`)
- [ ] 단가관리 (`PricingListClient.tsx`) - [x] 단가관리 (`PricingListClient.tsx`)
- [ ] 노임관리 (`LaborManagementClient.tsx`) - [x] 노임관리 (`LaborManagementClient.tsx`)
--- ---
@@ -114,15 +114,15 @@
| 도메인 | 완료 | 전체 | 진행률 | | 도메인 | 완료 | 전체 | 진행률 |
|--------|------|------|--------| |--------|------|------|--------|
| 건설 (완료) | 6 | 6 | 100% | | 건설 (완료) | 6 | 6 | 100% |
| 건설 (미완료) | 0 | 12 | 0% | | 건설 (마이그레이션) | 12 | 12 | 100% ✅ |
| HR | 0 | 5 | 0% | | HR | 0 | 5 | 0% |
| 회계 | 0 | 11 | 0% | | 회계 | 0 | 11 | 0% |
| 생산/자재/품질/출고 | 0 | 6 | 0% | | 생산/자재/품질/출고 | 0 | 6 | 0% |
| 전자결재 | 0 | 3 | 0% | | 전자결재 | 0 | 3 | 0% |
| 설정 | 0 | 4 | 0% | | 설정 | 0 | 4 | 0% |
| 기타 | 0 | 9 | 0% | | 기타 | 0 | 9 | 0% |
| **총계** | **6** | **56** | **11%** | | **총계** | **18** | **56** | **32%** |
--- ---
@@ -178,3 +178,4 @@ const handleFilterReset = useCallback(() => {
|------|----------| |------|----------|
| 2026-01-13 | 체크리스트 문서 생성, MobileFilter 스크롤 버그 수정 | | 2026-01-13 | 체크리스트 문서 생성, MobileFilter 스크롤 버그 수정 |
| 2026-01-13 | 시공관리 mobileFilterSlot → filterConfig 방식으로 변경, 협력업체관리 filterConfig 적용 | | 2026-01-13 | 시공관리 mobileFilterSlot → filterConfig 방식으로 변경, 협력업체관리 filterConfig 적용 |
| 2026-01-13 | 건설 도메인 12개 파일 마이그레이션 완료 (SiteBriefing, Estimate, Bidding, Contract, HandoverReport, SiteManagement, StructureReview, IssueManagement, WorkerStatus, ItemManagement, Pricing, LaborManagement) |

View File

@@ -1,22 +1,28 @@
'use client'; 'use client';
import { useRouter } from 'next/navigation'; import { useRouter, useParams } from 'next/navigation';
import { toast } from 'sonner';
import { EmployeeForm } from '@/components/hr/EmployeeManagement/EmployeeForm'; import { EmployeeForm } from '@/components/hr/EmployeeManagement/EmployeeForm';
import { createEmployee } from '@/components/hr/EmployeeManagement/actions'; import { createEmployee } from '@/components/hr/EmployeeManagement/actions';
import type { EmployeeFormData } from '@/components/hr/EmployeeManagement/types'; import type { EmployeeFormData } from '@/components/hr/EmployeeManagement/types';
export default function EmployeeNewPage() { export default function EmployeeNewPage() {
const router = useRouter(); const router = useRouter();
const params = useParams();
const locale = params.locale as string || 'ko';
const handleSave = async (data: EmployeeFormData) => { const handleSave = async (data: EmployeeFormData) => {
try { try {
const result = await createEmployee(data); const result = await createEmployee(data);
if (result.success) { if (result.success) {
router.push('/ko/hr/employee-management'); toast.success('사원이 등록되었습니다.');
router.push(`/${locale}/hr/employee-management`);
} else { } else {
toast.error(result.error || '사원 등록에 실패했습니다.');
console.error('[EmployeeNewPage] Create failed:', result.error); console.error('[EmployeeNewPage] Create failed:', result.error);
} }
} catch (error) { } catch (error) {
toast.error('서버 오류가 발생했습니다.');
console.error('[EmployeeNewPage] Create error:', error); console.error('[EmployeeNewPage] Create error:', error);
} }
}; };

View File

@@ -44,11 +44,15 @@ interface TodayIssueSectionProps {
export function TodayIssueSection({ items }: TodayIssueSectionProps) { export function TodayIssueSection({ items }: TodayIssueSectionProps) {
const router = useRouter(); const router = useRouter();
const [filter, setFilter] = useState<string>('all'); const [filter, setFilter] = useState<string>('all');
const [dismissedIds, setDismissedIds] = useState<Set<string>>(new Set());
// 확인되지 않은 아이템만 필터링
const activeItems = items.filter((item) => !dismissedIds.has(item.id));
// 필터링된 아이템 // 필터링된 아이템
const filteredItems = filter === 'all' const filteredItems = filter === 'all'
? items ? activeItems
: items.filter((item) => item.badge === filter); : activeItems.filter((item) => item.badge === filter);
// 아이템 클릭 // 아이템 클릭
const handleItemClick = (item: TodayIssueListItem) => { const handleItemClick = (item: TodayIssueListItem) => {
@@ -57,13 +61,21 @@ export function TodayIssueSection({ items }: TodayIssueSectionProps) {
} }
}; };
// 확인 버튼 클릭 (목록에서 제거)
const handleDismiss = (item: TodayIssueListItem) => {
setDismissedIds((prev) => new Set(prev).add(item.id));
toast.success(`"${item.content}" 확인 완료`);
};
// 승인 버튼 클릭 // 승인 버튼 클릭
const handleApprove = (item: TodayIssueListItem) => { const handleApprove = (item: TodayIssueListItem) => {
setDismissedIds((prev) => new Set(prev).add(item.id));
toast.success(`"${item.content}" 승인 처리되었습니다.`); toast.success(`"${item.content}" 승인 처리되었습니다.`);
}; };
// 반려 버튼 클릭 // 반려 버튼 클릭
const handleReject = (item: TodayIssueListItem) => { const handleReject = (item: TodayIssueListItem) => {
setDismissedIds((prev) => new Set(prev).add(item.id));
toast.error(`"${item.content}" 반려 처리되었습니다.`); toast.error(`"${item.content}" 반려 처리되었습니다.`);
}; };
@@ -114,12 +126,12 @@ export function TodayIssueSection({ items }: TodayIssueSectionProps) {
</div> </div>
{/* 우측: 시간 + 버튼 */} {/* 우측: 시간 + 버튼 */}
<div className="flex items-center gap-3 shrink-0 ml-4"> <div className="flex items-center gap-3 shrink-0 ml-4" onClick={(e) => e.stopPropagation()}>
<span className="text-xs text-gray-500 whitespace-nowrap"> <span className="text-xs text-gray-500 whitespace-nowrap">
{item.time} {item.time}
</span> </span>
{item.needsApproval && ( {item.needsApproval ? (
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}> <div className="flex items-center gap-2">
<Button <Button
size="sm" size="sm"
variant="default" variant="default"
@@ -137,6 +149,15 @@ export function TodayIssueSection({ items }: TodayIssueSectionProps) {
</Button> </Button>
</div> </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> </div>

View File

@@ -6,15 +6,8 @@ import { FileText, Clock, Trophy, Pencil } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table'; import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { import { MultiSelectOption } from '@/components/ui/multi-select-combobox';
Select, import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
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 { MobileCard } from '@/components/molecules/MobileCard'; import { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector'; import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -335,6 +328,81 @@ export default function BiddingListClient({ initialData = [], initialStats }: Bi
} }
}, [selectedItems, loadData]); }, [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( const renderTableRow = useCallback(
(bidding: Bidding, index: number, globalIndex: number) => { (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 ( return (
<> <>
<IntegratedListTemplateV2 <IntegratedListTemplateV2
@@ -515,7 +526,11 @@ export default function BiddingListClient({ initialData = [], initialStats }: Bi
icon={FileText} icon={FileText}
headerActions={headerActions} headerActions={headerActions}
stats={statsCardsData} stats={statsCardsData}
tableHeaderActions={tableHeaderActions} filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="입찰 필터"
searchValue={searchValue} searchValue={searchValue}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
searchPlaceholder="입찰번호, 거래처, 현장명 검색" searchPlaceholder="입찰번호, 거래처, 현장명 검색"

View File

@@ -6,15 +6,8 @@ import { FileText, Clock, CheckCircle, Pencil, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table'; import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { import { MultiSelectOption } from '@/components/ui/multi-select-combobox';
Select, import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
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 { MobileCard } from '@/components/molecules/MobileCard'; import { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector'; import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -350,6 +343,95 @@ export default function ContractListClient({
} }
}, [selectedItems, loadData]); }, [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, 총 개소, 계약금액, 계약기간, 상태, 작업 // 순서: 체크박스, 번호, 계약번호, 거래처, 현장명, 계약담당자, 공사PM, 총 개소, 계약금액, 계약기간, 상태, 작업
const renderTableRow = useCallback( 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 ( return (
<> <>
<IntegratedListTemplateV2 <IntegratedListTemplateV2
@@ -550,7 +565,11 @@ export default function ContractListClient({
icon={FileText} icon={FileText}
headerActions={headerActions} headerActions={headerActions}
stats={statsCardsData} stats={statsCardsData}
tableHeaderActions={tableHeaderActions} filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="계약 필터"
searchValue={searchValue} searchValue={searchValue}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
searchPlaceholder="계약번호, 거래처, 현장명 검색" searchPlaceholder="계약번호, 거래처, 현장명 검색"

View File

@@ -6,15 +6,8 @@ import { FileText, FileTextIcon, Clock, FileCheck, Pencil } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table'; import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { import { MultiSelectOption } from '@/components/ui/multi-select-combobox';
Select, import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
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 { MobileCard } from '@/components/molecules/MobileCard'; import { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector'; import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -315,6 +308,81 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
} }
}, [selectedItems, loadData]); }, [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( const renderTableRow = useCallback(
(estimate: Estimate, index: number, globalIndex: number) => { (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 ( return (
<> <>
<IntegratedListTemplateV2 <IntegratedListTemplateV2
@@ -493,7 +504,11 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
icon={FileText} icon={FileText}
headerActions={headerActions} headerActions={headerActions}
stats={statsCardsData} stats={statsCardsData}
tableHeaderActions={tableHeaderActions} filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="견적 필터"
searchValue={searchValue} searchValue={searchValue}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
searchPlaceholder="견적번호, 거래처, 현장명 검색" searchPlaceholder="견적번호, 거래처, 현장명 검색"

View File

@@ -6,15 +6,8 @@ import { FileText, Clock, CheckCircle, Pencil } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table'; import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { import { MultiSelectOption } from '@/components/ui/multi-select-combobox';
Select, import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
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 { MobileCard } from '@/components/molecules/MobileCard'; import { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector'; import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -272,6 +265,95 @@ export default function HandoverReportListClient({
[router] [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( const renderTableRow = useCallback(
(report: HandoverReport, index: number, globalIndex: number) => { (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 ( return (
<> <>
<IntegratedListTemplateV2 <IntegratedListTemplateV2
@@ -464,7 +479,11 @@ export default function HandoverReportListClient({
icon={FileText} icon={FileText}
headerActions={headerActions} headerActions={headerActions}
stats={statsCardsData} stats={statsCardsData}
tableHeaderActions={tableHeaderActions} filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="인수인계보고서 필터"
searchValue={searchValue} searchValue={searchValue}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
searchPlaceholder="보고서번호, 거래처, 현장명 검색" searchPlaceholder="보고서번호, 거래처, 현장명 검색"

View File

@@ -6,15 +6,8 @@ import { AlertTriangle, Pencil, Plus, Inbox, Clock, CheckCircle, XCircle, Undo2
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table'; import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { import { MultiSelectOption } from '@/components/ui/multi-select-combobox';
Select, import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
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 { MobileCard } from '@/components/molecules/MobileCard'; import { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector'; import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { import {
@@ -479,116 +472,150 @@ export default function IssueManagementListClient({
}, },
]; ];
// 테이블 헤더 액션 // ===== filterConfig 기반 통합 필터 시스템 =====
// 철회 버튼 (선택 시), 거래처(다중), 현장명(다중), 구분(다중), 보고자(다중), 담당자(다중), 중요도(일반), 상태(일반), 정렬 const filterConfig: FilterFieldConfig[] = useMemo(() => [
const tableHeaderActions = ( {
<div className="flex items-center gap-2 flex-wrap justify-end w-full"> key: 'partner',
{/* 철회 버튼 (선택된 항목이 있을 때만 표시) */} label: '거래처',
{selectedItems.size > 0 && ( type: 'multi',
<Button options: partnerOptions.map(o => ({
variant="outline" value: o.value,
size="sm" label: o.label,
onClick={handleWithdrawClick} })),
className="text-orange-600 border-orange-300 hover:bg-orange-50" },
> {
<Undo2 className="mr-2 h-4 w-4" /> key: 'site',
({selectedItems.size}) label: '현장명',
</Button> 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]);
{/* 1. 거래처 필터 (다중선택) */} const filterValues: FilterValues = useMemo(() => ({
<MultiSelectCombobox partner: partnerFilters,
options={partnerOptions} site: siteFilters,
value={partnerFilters} category: categoryFilters,
onChange={setPartnerFilters} reporter: reporterFilters,
placeholder="거래처" assignee: assigneeFilters,
searchPlaceholder="거래처 검색..." priority: priorityFilter,
className="w-[120px]" status: statusFilter,
/> sortBy: sortBy,
}), [partnerFilters, siteFilters, categoryFilters, reporterFilters, assigneeFilters, priorityFilter, statusFilter, sortBy]);
{/* 2. 현장명 필터 (다중선택) */} const handleFilterChange = useCallback((key: string, value: string | string[]) => {
<MultiSelectCombobox switch (key) {
options={siteOptions} case 'partner':
value={siteFilters} setPartnerFilters(value as string[]);
onChange={setSiteFilters} break;
placeholder="현장명" case 'site':
searchPlaceholder="현장명 검색..." setSiteFilters(value as string[]);
className="w-[120px]" 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);
}, []);
{/* 3. 구분 필터 (다중선택) */} const handleFilterReset = useCallback(() => {
<MultiSelectCombobox setPartnerFilters([]);
options={categoryOptions} setSiteFilters([]);
value={categoryFilters} setCategoryFilters([]);
onChange={setCategoryFilters} setReporterFilters([]);
placeholder="구분" setAssigneeFilters([]);
searchPlaceholder="구분 검색..." setPriorityFilter('all');
className="w-[100px]" setStatusFilter('all');
/> setSortBy('latest');
setCurrentPage(1);
}, []);
{/* 4. 보고자 필터 (다중선택) */} // 철회 버튼 (bulkActions용)
<MultiSelectCombobox const bulkActions = selectedItems.size > 0 ? (
options={reporterOptions} <Button
value={reporterFilters} variant="outline"
onChange={setReporterFilters} size="sm"
placeholder="보고자" onClick={handleWithdrawClick}
searchPlaceholder="보고자 검색..." className="text-orange-600 border-orange-300 hover:bg-orange-50"
className="w-[100px]" >
/> <Undo2 className="mr-2 h-4 w-4" />
({selectedItems.size})
{/* 5. 담당자 필터 (다중선택) */} </Button>
<MultiSelectCombobox ) : null;
options={assigneeOptions}
value={assigneeFilters}
onChange={setAssigneeFilters}
placeholder="담당자"
searchPlaceholder="담당자 검색..."
className="w-[100px]"
/>
{/* 6. 중요도 필터 (단일선택) */}
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="중요도" />
</SelectTrigger>
<SelectContent>
{ISSUE_PRIORITY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 7. 상태 필터 (단일선택) */}
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{ISSUE_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 8. 정렬 (단일선택) */}
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
{ISSUE_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
return ( return (
<> <>
@@ -598,7 +625,12 @@ export default function IssueManagementListClient({
icon={AlertTriangle} icon={AlertTriangle}
headerActions={headerActions} headerActions={headerActions}
stats={statsCardsData} stats={statsCardsData}
tableHeaderActions={tableHeaderActions} filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="이슈 필터"
bulkActions={bulkActions}
searchValue={searchValue} searchValue={searchValue}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
searchPlaceholder="이슈번호, 시공번호, 거래처, 현장, 제목, 보고자, 담당자 검색" searchPlaceholder="이슈번호, 시공번호, 거래처, 현장, 제목, 보고자, 담당자 검색"

View File

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

View File

@@ -15,7 +15,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } 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 { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector'; import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -369,6 +369,66 @@ export default function LaborManagementClient({
[handleRowClick] [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 = ( const headerActions = (
<DateRangeSelector <DateRangeSelector
@@ -471,6 +531,11 @@ export default function LaborManagementClient({
}, },
]} ]}
tableHeaderActions={tableHeaderActions} tableHeaderActions={tableHeaderActions}
filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="노임 필터"
searchValue={searchValue} searchValue={searchValue}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
searchPlaceholder="노임번호, 구분 검색" searchPlaceholder="노임번호, 구분 검색"

View File

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

View File

@@ -6,15 +6,8 @@ import { Calendar, CalendarDays, CalendarCheck, CalendarClock, Plus, Pencil, Tra
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table'; import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { import { MultiSelectOption } from '@/components/ui/multi-select-combobox';
Select, import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
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 { MobileCard } from '@/components/molecules/MobileCard'; import { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector'; import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -344,6 +337,96 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin
} }
}, [selectedItems, loadData]); }, [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( const renderTableRow = useCallback(
(briefing: SiteBriefing, index: number, globalIndex: number) => { (briefing: SiteBriefing, index: number, globalIndex: number) => {
@@ -475,77 +558,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 ( return (
<> <>
<IntegratedListTemplateV2 <IntegratedListTemplateV2
@@ -554,7 +566,11 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin
icon={Calendar} icon={Calendar}
headerActions={headerActions} headerActions={headerActions}
stats={statsCardsData} stats={statsCardsData}
tableHeaderActions={tableHeaderActions} filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="현장설명회 필터"
searchValue={searchValue} searchValue={searchValue}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
searchPlaceholder="현장번호, 거래처, 현장명 검색" searchPlaceholder="현장번호, 거래처, 현장명 검색"

View File

@@ -14,7 +14,7 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox'; 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 { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector'; import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { toast } from 'sonner'; 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 = ( const tableHeaderActions = (
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
@@ -445,6 +505,11 @@ export default function SiteManagementListClient({
headerActions={headerActions} headerActions={headerActions}
stats={statsCardsData} stats={statsCardsData}
tableHeaderActions={tableHeaderActions} tableHeaderActions={tableHeaderActions}
filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="현장 필터"
searchValue={searchValue} searchValue={searchValue}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
searchPlaceholder="현장번호, 거래처, 현장명, 위치 검색" searchPlaceholder="현장번호, 거래처, 현장명, 위치 검색"

View File

@@ -14,7 +14,7 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox'; 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 { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector'; import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { toast } from 'sonner'; 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 = ( const tableHeaderActions = (
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
@@ -478,6 +538,11 @@ export default function StructureReviewListClient({
headerActions={headerActions} headerActions={headerActions}
stats={statsCardsData} stats={statsCardsData}
tableHeaderActions={tableHeaderActions} tableHeaderActions={tableHeaderActions}
filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="구조검토 필터"
searchValue={searchValue} searchValue={searchValue}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
searchPlaceholder="검토번호, 거래처, 현장명, 검토회사 검색" searchPlaceholder="검토번호, 거래처, 현장명, 검토회사 검색"

View File

@@ -6,15 +6,8 @@ import { Users, Eye, FileText, Clock, CheckCircle } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table'; import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { import { MultiSelectOption } from '@/components/ui/multi-select-combobox';
Select, import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
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 { MobileCard } from '@/components/molecules/MobileCard'; import { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector'; import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -396,92 +389,123 @@ export default function WorkerStatusListClient({
}, },
]; ];
// 테이블 헤더 액션 (7개 필터) // ===== filterConfig 기반 통합 필터 시스템 =====
const tableHeaderActions = ( const filterConfig: FilterFieldConfig[] = useMemo(() => [
<div className="flex items-center gap-2 flex-wrap justify-end w-full"> {
{/* 1. 거래처 필터 (다중선택) */} key: 'partner',
<MultiSelectCombobox label: '거래처',
options={partnerOptions} type: 'multi',
value={partnerFilters} options: partnerOptions.map(o => ({
onChange={setPartnerFilters} value: o.value,
placeholder="거래처" label: o.label,
searchPlaceholder="거래처 검색..." })),
className="w-[120px]" },
/> {
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]);
{/* 2. 현장명 필터 (다중선택) */} const filterValues: FilterValues = useMemo(() => ({
<MultiSelectCombobox partner: partnerFilters,
options={siteOptions} site: siteFilters,
value={siteFilters} category: categoryFilter,
onChange={setSiteFilters} department: departmentFilters,
placeholder="현장명" name: nameFilters,
searchPlaceholder="현장명 검색..." status: statusFilter,
className="w-[120px]" sortBy: sortBy,
/> }), [partnerFilters, siteFilters, categoryFilter, departmentFilters, nameFilters, statusFilter, sortBy]);
{/* 3. 구분 필터 (단일선택) */} const handleFilterChange = useCallback((key: string, value: string | string[]) => {
<Select value={categoryFilter} onValueChange={setCategoryFilter}> switch (key) {
<SelectTrigger className="w-[100px]"> case 'partner':
<SelectValue placeholder="구분" /> setPartnerFilters(value as string[]);
</SelectTrigger> break;
<SelectContent> case 'site':
{WORKER_CATEGORY_OPTIONS.map((option) => ( setSiteFilters(value as string[]);
<SelectItem key={option.value} value={option.value}> break;
{option.label} case 'category':
</SelectItem> setCategoryFilter(value as string);
))} break;
</SelectContent> case 'department':
</Select> 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);
}, []);
{/* 4. 부서 필터 (다중선택) */} const handleFilterReset = useCallback(() => {
<MultiSelectCombobox setPartnerFilters([]);
options={departmentOptions} setSiteFilters([]);
value={departmentFilters} setCategoryFilter('all');
onChange={setDepartmentFilters} setDepartmentFilters([]);
placeholder="부서" setNameFilters([]);
searchPlaceholder="부서 검색..." setStatusFilter('all');
className="w-[100px]" setSortBy('latest');
/> setCurrentPage(1);
}, []);
{/* 5. 이름 필터 (다중선택) */}
<MultiSelectCombobox
options={nameOptions}
value={nameFilters}
onChange={setNameFilters}
placeholder="이름"
searchPlaceholder="이름 검색..."
className="w-[100px]"
/>
{/* 6. 상태 필터 (단일선택) */}
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{WORKER_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 7. 정렬 (단일선택) */}
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
{WORKER_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
return ( return (
<IntegratedListTemplateV2 <IntegratedListTemplateV2
@@ -490,7 +514,11 @@ export default function WorkerStatusListClient({
icon={Users} icon={Users}
headerActions={headerActions} headerActions={headerActions}
stats={statsCardsData} stats={statsCardsData}
tableHeaderActions={tableHeaderActions} filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="작업인력 필터"
searchValue={searchValue} searchValue={searchValue}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
searchPlaceholder="거래처, 현장, 부서, 이름, 시공번호 검색" searchPlaceholder="거래처, 현장, 부서, 이름, 시공번호 검색"

View File

@@ -193,9 +193,33 @@ export function EmployeeForm({
} }
}, [employee, mode]); }, [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) => { 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]) { if (errors[field as keyof ValidationErrors]) {
setErrors(prev => ({ ...prev, [field]: undefined })); setErrors(prev => ({ ...prev, [field]: undefined }));
@@ -233,8 +257,8 @@ export function EmployeeForm({
if (mode === 'create') { if (mode === 'create') {
if (!formData.password) { if (!formData.password) {
newErrors.password = '비밀번호를 입력해주세요.'; newErrors.password = '비밀번호를 입력해주세요.';
} else if (formData.password.length < 6) { } else if (formData.password.length < 8) {
newErrors.password = '비밀번호는 6자 이상이어야 합니다.'; newErrors.password = '비밀번호는 8자 이상이어야 합니다.';
} }
if (formData.password !== formData.confirmPassword) { if (formData.password !== formData.confirmPassword) {
@@ -331,8 +355,8 @@ export function EmployeeForm({
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
{/* 사원 정보 - 프로필 사진 + 기본 정보 */} {/* 사원 정보 - 프로필 사진 + 기본 정보 */}
<Card> <Card>
<CardHeader className="bg-black text-white rounded-t-lg"> <CardHeader>
<CardTitle className="text-base font-medium"> </CardTitle> <CardTitle className="text-base"> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="pt-6"> <CardContent className="pt-6">
{/* 기본 정보 필드들 */} {/* 기본 정보 필드들 */}
@@ -429,8 +453,8 @@ export function EmployeeForm({
{/* 사원 상세 */} {/* 사원 상세 */}
{(fieldSettings.showProfileImage || fieldSettings.showEmployeeCode || fieldSettings.showGender || fieldSettings.showAddress) && ( {(fieldSettings.showProfileImage || fieldSettings.showEmployeeCode || fieldSettings.showGender || fieldSettings.showAddress) && (
<Card> <Card>
<CardHeader className="bg-black text-white rounded-t-lg"> <CardHeader>
<CardTitle className="text-base font-medium"> </CardTitle> <CardTitle className="text-base"> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="pt-6 space-y-4"> <CardContent className="pt-6 space-y-4">
{/* 프로필 사진 + 사원코드/성별 */} {/* 프로필 사진 + 사원코드/성별 */}
@@ -559,8 +583,8 @@ export function EmployeeForm({
{/* 인사 정보 */} {/* 인사 정보 */}
{(fieldSettings.showHireDate || fieldSettings.showEmploymentType || fieldSettings.showRank || fieldSettings.showStatus || fieldSettings.showDepartment || fieldSettings.showPosition || fieldSettings.showClockInLocation || fieldSettings.showClockOutLocation || fieldSettings.showResignationDate || fieldSettings.showResignationReason) && ( {(fieldSettings.showHireDate || fieldSettings.showEmploymentType || fieldSettings.showRank || fieldSettings.showStatus || fieldSettings.showDepartment || fieldSettings.showPosition || fieldSettings.showClockInLocation || fieldSettings.showClockOutLocation || fieldSettings.showResignationDate || fieldSettings.showResignationReason) && (
<Card> <Card>
<CardHeader className="bg-black text-white rounded-t-lg"> <CardHeader>
<CardTitle className="text-base font-medium"> </CardTitle> <CardTitle className="text-base"> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="pt-6 space-y-4"> <CardContent className="pt-6 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -770,8 +794,8 @@ export function EmployeeForm({
{/* 사용자 정보 */} {/* 사용자 정보 */}
<Card> <Card>
<CardHeader className="bg-black text-white rounded-t-lg"> <CardHeader>
<CardTitle className="text-base font-medium"> </CardTitle> <CardTitle className="text-base"> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">