feat(WEB): 리스트 페이지 UI 레이아웃 표준화
- 공통 레이아웃 패턴 적용: [달력] → [프리셋] → [검색창] → [버튼들] - beforeTableContent → headerActions + createButton 마이그레이션 - DateRangeSelector extraActions prop 활용하여 검색창 통합 - PricingListClient 테이블 행 클릭 → 상세 이동 기능 추가 - 회계 관련 페이지 (입금/출금/매입/매출/어음/카드/예상지출 등) 정리 - 건설 관련 페이지 검색 영역 정리 - 부모 메뉴 리다이렉트 컴포넌트 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -242,6 +242,7 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
|
||||
|
||||
// 커스텀 필터 함수
|
||||
customFilterFn: (items) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
let result = [...items];
|
||||
|
||||
// 거래처 필터
|
||||
|
||||
@@ -48,7 +48,8 @@ interface PaginationMeta {
|
||||
// ===== API → Frontend 변환 =====
|
||||
function transformItem(item: BankTransactionApiItem): BankTransaction {
|
||||
return {
|
||||
id: String(item.id),
|
||||
// 입금/출금 테이블이 별도이므로 type을 접두어로 붙여 고유 ID 생성
|
||||
id: `${item.type}-${item.id}`,
|
||||
bankName: item.bank_name,
|
||||
accountName: item.account_name,
|
||||
transactionDate: item.transaction_date,
|
||||
|
||||
@@ -279,18 +279,16 @@ export function BankTransactionInquiry({
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
|
||||
// 테이블 상단 콘텐츠 (새로고침 버튼)
|
||||
beforeTableContent: (
|
||||
<div className="flex items-center justify-end w-full">
|
||||
<Button variant="outline" onClick={handleRefresh} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
// 헤더 액션: 새로고침 버튼
|
||||
headerActions: () => (
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
{isLoading ? '조회중...' : '새로고침'}
|
||||
</Button>
|
||||
),
|
||||
|
||||
// 테이블 헤더 액션 (3개 필터)
|
||||
|
||||
@@ -429,6 +429,42 @@ export function BillManagementClient({
|
||||
icon: Plus,
|
||||
},
|
||||
|
||||
// 헤더 액션: 상태 선택 + 저장 + 수취/발행 라디오
|
||||
headerActions: () => (
|
||||
<div className="flex items-center gap-3">
|
||||
<RadioGroup
|
||||
value={billTypeFilter}
|
||||
onValueChange={(value) => { setBillTypeFilter(value); loadData(1); }}
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<RadioGroupItem value="received" id="received" />
|
||||
<Label htmlFor="received" className="cursor-pointer text-sm">수취</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<RadioGroupItem value="issued" id="issued" />
|
||||
<Label htmlFor="issued" className="cursor-pointer text-sm">발행</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BILL_STATUS_FILTER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={handleSave} size="sm" disabled={isLoading}>
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
|
||||
// 테이블 헤더 액션 (필터)
|
||||
tableHeaderActions: (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
@@ -473,44 +509,6 @@ export function BillManagementClient({
|
||||
</div>
|
||||
),
|
||||
|
||||
// beforeTableContent: 상태 선택 + 저장 + 수취/발행 라디오
|
||||
beforeTableContent: (
|
||||
<div className="flex items-center gap-4">
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[110px]">
|
||||
<SelectValue placeholder="보관중" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BILL_STATUS_FILTER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
저장
|
||||
</Button>
|
||||
|
||||
<RadioGroup
|
||||
value={billTypeFilter}
|
||||
onValueChange={(value) => { setBillTypeFilter(value); loadData(1); }}
|
||||
className="flex items-center gap-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="received" id="received" />
|
||||
<Label htmlFor="received" className="cursor-pointer">수취</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="issued" id="issued" />
|
||||
<Label htmlFor="issued" className="cursor-pointer">발행</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
),
|
||||
|
||||
// 렌더링 함수
|
||||
renderTableRow,
|
||||
renderMobileCard,
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { format, startOfMonth, endOfMonth } from 'date-fns';
|
||||
import { CreditCard, Plus, RefreshCw, Save, Loader2 } from 'lucide-react';
|
||||
import { CreditCard, Plus, RefreshCw, Save, Loader2, Search } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -380,18 +380,66 @@ export function CardTransactionInquiry({
|
||||
},
|
||||
filterTitle: '카드 필터',
|
||||
|
||||
// 헤더 액션 (등록 버튼)
|
||||
// 헤더 액션: 계정과목명 Select + 저장 + 새로고침
|
||||
headerActions: () => (
|
||||
<Button className="ml-auto" onClick={() => router.push('/ko/accounting/card-transactions?mode=new')}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
카드내역 등록
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700 whitespace-nowrap">계정과목명</span>
|
||||
<Select value={selectedAccountSubject} onValueChange={setSelectedAccountSubject}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCOUNT_SUBJECT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={handleSaveAccountSubject} size="sm">
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
저장
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
{isLoading ? '조회중...' : '새로고침'}
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
|
||||
// 등록 버튼
|
||||
createButton: {
|
||||
label: '카드내역 등록',
|
||||
icon: Plus,
|
||||
onClick: () => router.push('/ko/accounting/card-transactions?mode=new'),
|
||||
},
|
||||
|
||||
// 커스텀 필터 함수
|
||||
customFilterFn: (items) => {
|
||||
if (cardFilter === 'all') return items;
|
||||
return items.filter((item) => item.cardName === cardFilter);
|
||||
if (!items || items.length === 0) return items;
|
||||
let result = [...items];
|
||||
|
||||
// 검색어 필터
|
||||
if (searchQuery) {
|
||||
const search = searchQuery.toLowerCase();
|
||||
result = result.filter((item) =>
|
||||
item.card.toLowerCase().includes(search) ||
|
||||
item.cardName.toLowerCase().includes(search) ||
|
||||
item.user.toLowerCase().includes(search) ||
|
||||
item.merchantName.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
// 카드명 필터
|
||||
if (cardFilter !== 'all') {
|
||||
result = result.filter((item) => item.cardName === cardFilter);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
// 커스텀 정렬 함수
|
||||
@@ -417,8 +465,15 @@ export function CardTransactionInquiry({
|
||||
},
|
||||
|
||||
// 날짜 선택기 (헤더 액션)
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
searchPlaceholder: '카드, 카드명, 사용자, 가맹점명 검색...',
|
||||
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
showPresets: true,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
@@ -428,42 +483,6 @@ export function CardTransactionInquiry({
|
||||
// 선택 항목 변경 콜백
|
||||
onSelectionChange: setSelectedItems,
|
||||
|
||||
// 테이블 상단 콘텐츠 (계정과목명 + 저장 + 새로고침)
|
||||
beforeTableContent: (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700">계정과목명</span>
|
||||
<Select value={selectedAccountSubject} onValueChange={setSelectedAccountSubject}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="계정과목명 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCOUNT_SUBJECT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
onClick={handleSaveAccountSubject}
|
||||
className="bg-blue-500 hover:bg-blue-600"
|
||||
>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handleRefresh} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
|
||||
// 테이블 헤더 액션 (2개 필터)
|
||||
tableHeaderActions: () => (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
|
||||
@@ -22,8 +22,10 @@ import {
|
||||
Save,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
@@ -103,6 +105,7 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
|
||||
const [endDate, setEndDate] = useState('2025-09-03');
|
||||
const [depositData, setDepositData] = useState<DepositRecord[]>(initialData);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// 인라인 필터 상태 (tableHeaderActions에서 사용)
|
||||
const [vendorFilter, setVendorFilter] = useState<string>('all');
|
||||
@@ -262,7 +265,19 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
|
||||
|
||||
// 커스텀 필터 함수 (인라인 필터 사용)
|
||||
customFilterFn: (items, filterValues) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
return items.filter((item) => {
|
||||
// 검색어 필터
|
||||
if (searchQuery) {
|
||||
const search = searchQuery.toLowerCase();
|
||||
const matchesSearch =
|
||||
item.depositorName.toLowerCase().includes(search) ||
|
||||
item.accountName.toLowerCase().includes(search) ||
|
||||
(item.note?.toLowerCase().includes(search) || false) ||
|
||||
(item.vendorName?.toLowerCase().includes(search) || false);
|
||||
if (!matchesSearch) return false;
|
||||
}
|
||||
|
||||
// 거래처 필터
|
||||
if (vendorFilter !== 'all' && item.vendorName !== vendorFilter) {
|
||||
return false;
|
||||
@@ -295,9 +310,16 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
|
||||
return sorted;
|
||||
},
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
searchPlaceholder: '입금자명, 계좌명, 적요, 거래처 검색...',
|
||||
|
||||
// 공통 헤더 옵션
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
showPresets: true,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
@@ -325,14 +347,45 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
|
||||
},
|
||||
filterTitle: '입금 필터',
|
||||
|
||||
// 헤더 액션 (등록 버튼)
|
||||
headerActions: () => (
|
||||
<Button className="ml-auto" onClick={() => router.push('/ko/accounting/deposits?mode=new')}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
입금등록
|
||||
</Button>
|
||||
// 헤더 액션: 계정과목명 Select + 저장 + 새로고침
|
||||
headerActions: ({ selectedItems }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700 whitespace-nowrap">계정과목명</span>
|
||||
<Select value={selectedAccountSubject} onValueChange={setSelectedAccountSubject}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCOUNT_SUBJECT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={() => handleSaveAccountSubject(selectedItems)} size="sm">
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
저장
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-1 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
{isRefreshing ? '조회중...' : '새로고침'}
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
|
||||
// 등록 버튼
|
||||
createButton: {
|
||||
label: '입금등록',
|
||||
icon: Plus,
|
||||
onClick: () => router.push('/ko/accounting/deposits?mode=new'),
|
||||
},
|
||||
|
||||
// Stats 카드
|
||||
computeStats: (): StatCard[] => [
|
||||
{ label: '총 입금', value: `${stats.totalDeposit.toLocaleString()}원`, icon: Banknote, iconColor: 'text-blue-500' },
|
||||
@@ -341,44 +394,9 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
|
||||
{ label: '입금유형 미설정', value: `${stats.depositTypeUnsetCount}건`, icon: Banknote, iconColor: 'text-red-500' },
|
||||
],
|
||||
|
||||
// beforeTableContent: 계정과목명 Select + 저장 버튼 + 새로고침
|
||||
beforeTableContent: (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700">계정과목명</span>
|
||||
<Select value={selectedAccountSubject} onValueChange={setSelectedAccountSubject}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="계정과목명 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCOUNT_SUBJECT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
{isRefreshing ? '조회중...' : '새로고침'}
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
|
||||
// tableHeaderActions: 3개 인라인 필터
|
||||
tableHeaderActions: ({ selectedItems }) => (
|
||||
tableHeaderActions: (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* 저장 버튼 */}
|
||||
<Button onClick={() => handleSaveAccountSubject(selectedItems)} className="bg-blue-500 hover:bg-blue-600">
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
저장
|
||||
</Button>
|
||||
|
||||
{/* 거래처 필터 */}
|
||||
<Select value={vendorFilter} onValueChange={setVendorFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
@@ -556,6 +574,7 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
|
||||
vendorOptions,
|
||||
tableTotals,
|
||||
isRefreshing,
|
||||
searchQuery,
|
||||
handleRowClick,
|
||||
handleEdit,
|
||||
handleRefresh,
|
||||
|
||||
@@ -902,51 +902,9 @@ export function ExpectedExpenseManagement({
|
||||
},
|
||||
filterTitle: '예상비용 필터',
|
||||
|
||||
// 테이블 헤더 액션 (거래처/정렬 필터)
|
||||
tableHeaderActions: () => (
|
||||
// 헤더 액션: 선택 기반 액션 버튼들
|
||||
headerActions: () => (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 거래처 필터 */}
|
||||
<Select value={vendorFilter} onValueChange={setVendorFilter}>
|
||||
<SelectTrigger className="w-[140px] h-8 text-sm">
|
||||
<SelectValue placeholder="전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{vendorFilterOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 정렬 필터 (최신순/등록순) */}
|
||||
<Select value={sortOption} onValueChange={(value) => setSortOption(value as SortOption)}>
|
||||
<SelectTrigger className="w-[100px] h-8 text-sm">
|
||||
<SelectValue placeholder="최신순" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
),
|
||||
|
||||
// 테이블 앞 컨텐츠 (액션 버튼)
|
||||
beforeTableContent: (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* 등록 버튼 */}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleOpenCreateDialog}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
등록
|
||||
</Button>
|
||||
|
||||
{/* 예상 지급일 변경 버튼 - 1개 이상 선택 시 활성화 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -983,6 +941,46 @@ export function ExpectedExpenseManagement({
|
||||
</div>
|
||||
),
|
||||
|
||||
// 등록 버튼
|
||||
createButton: {
|
||||
label: '등록',
|
||||
icon: Plus,
|
||||
onClick: handleOpenCreateDialog,
|
||||
},
|
||||
|
||||
// 테이블 헤더 액션 (거래처/정렬 필터)
|
||||
tableHeaderActions: () => (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 거래처 필터 */}
|
||||
<Select value={vendorFilter} onValueChange={setVendorFilter}>
|
||||
<SelectTrigger className="w-[140px] h-8 text-sm">
|
||||
<SelectValue placeholder="전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{vendorFilterOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 정렬 필터 (최신순/등록순) */}
|
||||
<Select value={sortOption} onValueChange={(value) => setSortOption(value as SortOption)}>
|
||||
<SelectTrigger className="w-[100px] h-8 text-sm">
|
||||
<SelectValue placeholder="최신순" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
),
|
||||
|
||||
// Stats 카드
|
||||
computeStats: (): StatCard[] => {
|
||||
const totalExpense = filteredRawData.reduce((sum, d) => sum + d.amount, 0);
|
||||
@@ -1011,7 +1009,7 @@ export function ExpectedExpenseManagement({
|
||||
vendorFilter,
|
||||
vendorFilterOptions,
|
||||
sortOption,
|
||||
selectedItems,
|
||||
selectedItems.size,
|
||||
handleOpenCreateDialog,
|
||||
handleOpenDateChangeDialog,
|
||||
handleElectronicApproval,
|
||||
|
||||
@@ -22,8 +22,10 @@ import {
|
||||
Pencil,
|
||||
Save,
|
||||
Trash2,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
@@ -85,6 +87,7 @@ export function PurchaseManagement() {
|
||||
const [endDate, setEndDate] = useState('2025-12-31');
|
||||
const [purchaseData, setPurchaseData] = useState<PurchaseRecord[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// 통합 필터 상태 (filterConfig 기반)
|
||||
const [filterValues, setFilterValues] = useState<Record<string, string | string[]>>({
|
||||
@@ -295,7 +298,17 @@ export function PurchaseManagement() {
|
||||
|
||||
// 커스텀 필터 함수 (filterValues 파라미터 사용)
|
||||
customFilterFn: (items, fv) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
return items.filter((item) => {
|
||||
// 검색어 필터
|
||||
if (searchQuery) {
|
||||
const search = searchQuery.toLowerCase();
|
||||
const matchesSearch =
|
||||
item.purchaseNo.toLowerCase().includes(search) ||
|
||||
item.vendorName.toLowerCase().includes(search);
|
||||
if (!matchesSearch) return false;
|
||||
}
|
||||
|
||||
const vendorVal = fv.vendor as string;
|
||||
const purchaseTypeVal = fv.purchaseType as string;
|
||||
const issuanceVal = fv.issuance as string;
|
||||
@@ -336,15 +349,45 @@ export function PurchaseManagement() {
|
||||
return sorted;
|
||||
},
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
searchPlaceholder: '매입번호, 거래처명 검색...',
|
||||
|
||||
// 공통 헤더 옵션
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
showPresets: true,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
|
||||
// 헤더 액션: 계정과목명 Select + 저장 버튼
|
||||
headerActions: ({ selectedItems }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700 whitespace-nowrap">계정과목명</span>
|
||||
<Select value={selectedAccountSubject} onValueChange={setSelectedAccountSubject}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCOUNT_SUBJECT_SELECTOR_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={() => handleSaveAccountSubject(selectedItems)} size="sm">
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
|
||||
// 통합 필터 시스템 (PC: 인라인, 모바일: 바텀시트 자동 분기)
|
||||
filterConfig,
|
||||
initialFilters: filterValues,
|
||||
@@ -358,29 +401,6 @@ export function PurchaseManagement() {
|
||||
{ label: '세금계산서 수취 미확인', value: `${stats.taxInvoicePendingCount}건`, icon: Receipt, iconColor: 'text-red-500' },
|
||||
],
|
||||
|
||||
// beforeTableContent: 계정과목명 Select + 저장 버튼 (테이블 밖에 위치)
|
||||
beforeTableContent: ({ selectedItems }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700">계정과목명</span>
|
||||
<Select value={selectedAccountSubject} onValueChange={setSelectedAccountSubject}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="계정과목명 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCOUNT_SUBJECT_SELECTOR_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={() => handleSaveAccountSubject(selectedItems)} className="bg-blue-500 hover:bg-blue-600">
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
|
||||
// 테이블 하단 합계 행
|
||||
tableFooter: (
|
||||
<TableRow className="bg-muted/50 font-medium">
|
||||
@@ -532,6 +552,7 @@ export function PurchaseManagement() {
|
||||
filterValues,
|
||||
selectedAccountSubject,
|
||||
tableTotals,
|
||||
searchQuery,
|
||||
handleRowClick,
|
||||
handleEdit,
|
||||
handleTaxInvoiceToggle,
|
||||
|
||||
@@ -22,8 +22,10 @@ import {
|
||||
Pencil,
|
||||
Save,
|
||||
Trash2,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
@@ -97,6 +99,7 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
|
||||
const [startDate, setStartDate] = useState('2025-01-01');
|
||||
const [endDate, setEndDate] = useState('2025-12-31');
|
||||
const [salesData, setSalesData] = useState<SalesRecord[]>(initialData);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// 통합 필터 상태 (filterConfig 사용)
|
||||
const [filterValues, setFilterValues] = useState<Record<string, string | string[]>>({
|
||||
@@ -297,7 +300,18 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
|
||||
|
||||
// 커스텀 필터 함수 (filterConfig 사용)
|
||||
customFilterFn: (items, fv) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
return items.filter((item) => {
|
||||
// 검색어 필터
|
||||
if (searchQuery) {
|
||||
const search = searchQuery.toLowerCase();
|
||||
const matchesSearch =
|
||||
item.salesNo.toLowerCase().includes(search) ||
|
||||
item.vendorName.toLowerCase().includes(search) ||
|
||||
item.note.toLowerCase().includes(search);
|
||||
if (!matchesSearch) return false;
|
||||
}
|
||||
|
||||
const vendorVal = fv.vendor as string;
|
||||
const salesTypeVal = fv.salesType as string;
|
||||
const issuanceVal = fv.issuance as string;
|
||||
@@ -342,14 +356,43 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
|
||||
return sorted;
|
||||
},
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
searchPlaceholder: '매출번호, 거래처명, 비고 검색...',
|
||||
|
||||
// 공통 헤더 옵션
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
showPresets: true,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
// 헤더 액션 (계정과목명 Select + 저장 버튼)
|
||||
headerActions: ({ selectedItems }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700 whitespace-nowrap">계정과목명</span>
|
||||
<Select value={selectedAccountSubject} onValueChange={setSelectedAccountSubject}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCOUNT_SUBJECT_SELECTOR_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={() => handleSaveAccountSubject(selectedItems)} size="sm">
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
createButton: {
|
||||
label: '매출 등록',
|
||||
onClick: handleCreate,
|
||||
@@ -368,29 +411,6 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
|
||||
initialFilters: filterValues,
|
||||
filterTitle: '매출 필터',
|
||||
|
||||
// beforeTableContent: 계정과목명 Select + 저장 버튼 (테이블 밖에 위치)
|
||||
beforeTableContent: ({ selectedItems }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700">계정과목명</span>
|
||||
<Select value={selectedAccountSubject} onValueChange={setSelectedAccountSubject}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="계정과목명 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCOUNT_SUBJECT_SELECTOR_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={() => handleSaveAccountSubject(selectedItems)} className="bg-blue-500 hover:bg-blue-600">
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
|
||||
// 테이블 하단 합계 행
|
||||
tableFooter: (
|
||||
<TableRow className="bg-muted/50 font-medium">
|
||||
@@ -534,6 +554,7 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
|
||||
filterValues,
|
||||
selectedAccountSubject,
|
||||
tableTotals,
|
||||
searchQuery,
|
||||
handleRowClick,
|
||||
handleEdit,
|
||||
handleCreate,
|
||||
|
||||
@@ -211,6 +211,7 @@ export function VendorManagement({ initialData, initialTotal }: VendorManagement
|
||||
|
||||
// 커스텀 필터 함수
|
||||
customFilterFn: (items, filterValues) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
return items.filter((item) => {
|
||||
// 구분 필터
|
||||
const categoryFilter = filterValues.category as string;
|
||||
|
||||
@@ -73,14 +73,13 @@ import { toast } from 'sonner';
|
||||
|
||||
// ===== 테이블 컬럼 정의 =====
|
||||
const tableColumns = [
|
||||
{ key: 'withdrawalDate', label: '출금일' },
|
||||
{ key: 'accountName', label: '출금계좌' },
|
||||
{ key: 'recipientName', label: '수취인명' },
|
||||
{ key: 'withdrawalAmount', label: '출금금액', className: 'text-right' },
|
||||
{ key: 'vendorName', label: '거래처' },
|
||||
{ key: 'note', label: '적요' },
|
||||
{ key: 'withdrawalType', label: '출금유형', className: 'text-center' },
|
||||
{ key: 'actions', label: '작업', className: 'text-center w-[80px]' },
|
||||
{ key: 'withdrawalDate', label: '출금일', className: 'w-[100px]' },
|
||||
{ key: 'accountName', label: '출금계좌', className: 'min-w-[120px]' },
|
||||
{ key: 'recipientName', label: '수취인명', className: 'min-w-[100px]' },
|
||||
{ key: 'withdrawalAmount', label: '출금금액', className: 'text-right w-[110px]' },
|
||||
{ key: 'vendorName', label: '거래처', className: 'min-w-[100px]' },
|
||||
{ key: 'note', label: '적요', className: 'min-w-[150px]' },
|
||||
{ key: 'withdrawalType', label: '출금유형', className: 'text-center w-[90px]' },
|
||||
];
|
||||
|
||||
// ===== 컴포넌트 Props =====
|
||||
@@ -112,6 +111,9 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
|
||||
// 상단 계정과목명 선택 (저장용)
|
||||
const [selectedAccountSubject, setSelectedAccountSubject] = useState<string>('unset');
|
||||
|
||||
// 검색어 상태 (헤더에서 직접 관리)
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// 로딩 상태
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
@@ -297,17 +299,23 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
|
||||
},
|
||||
filterTitle: '출금 필터',
|
||||
|
||||
// 헤더 액션 (등록 버튼)
|
||||
headerActions: () => (
|
||||
<Button className="ml-auto" onClick={() => router.push('/ko/accounting/withdrawals?mode=new')}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
출금등록
|
||||
</Button>
|
||||
),
|
||||
// 검색창 숨김 (dateRangeSelector extraActions로 렌더링)
|
||||
hideSearch: true,
|
||||
|
||||
// 커스텀 필터 함수
|
||||
// 커스텀 필터 함수 (검색 + 필터)
|
||||
customFilterFn: (items) => {
|
||||
return items.filter((item) => {
|
||||
// 검색어 필터
|
||||
if (searchQuery) {
|
||||
const search = searchQuery.toLowerCase();
|
||||
const matchesSearch =
|
||||
item.recipientName.toLowerCase().includes(search) ||
|
||||
item.accountName.toLowerCase().includes(search) ||
|
||||
item.note.toLowerCase().includes(search) ||
|
||||
item.vendorName.toLowerCase().includes(search);
|
||||
if (!matchesSearch) return false;
|
||||
}
|
||||
|
||||
// 거래처 필터
|
||||
if (vendorFilter !== 'all' && item.vendorName !== vendorFilter) {
|
||||
return false;
|
||||
@@ -342,23 +350,30 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
|
||||
return sorted;
|
||||
},
|
||||
|
||||
// 날짜 범위 선택기
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
// 날짜 범위 선택기 (달력 | 프리셋버튼 | 검색창(자동) - 한 줄)
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
showPresets: true,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
|
||||
// beforeTableContent: 계정과목명 + 저장 + 새로고침
|
||||
beforeTableContent: (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
// 헤더 액션: 계정과목명 Select + 저장 + 새로고침
|
||||
headerActions: ({ selectedItems }) => {
|
||||
const selectedArray = withdrawalData.filter(item => selectedItems.has(item.id));
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700">계정과목명</span>
|
||||
<span className="text-sm font-medium text-gray-700 whitespace-nowrap">계정과목명</span>
|
||||
<Select value={selectedAccountSubject} onValueChange={setSelectedAccountSubject}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="계정과목명 선택" />
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCOUNT_SUBJECT_OPTIONS.map((option) => (
|
||||
@@ -368,29 +383,33 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={() => handleSaveAccountSubject(selectedArray)} size="sm">
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
저장
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-1 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
{isRefreshing ? '조회중...' : '새로고침'}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
{isRefreshing ? '조회중...' : '새로고침'}
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
// tableHeaderActions: 저장 버튼 + 인라인 필터들
|
||||
tableHeaderActions: ({ selectedItems }) => (
|
||||
// 등록 버튼
|
||||
createButton: {
|
||||
label: '출금등록',
|
||||
icon: Plus,
|
||||
onClick: () => router.push('/ko/accounting/withdrawals?mode=new'),
|
||||
},
|
||||
|
||||
// tableHeaderActions: 필터만 (거래처, 출금유형, 정렬)
|
||||
tableHeaderActions: () => (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
onClick={() => handleSaveAccountSubject(selectedItems)}
|
||||
className="bg-blue-500 hover:bg-blue-600"
|
||||
>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
저장
|
||||
</Button>
|
||||
|
||||
{/* 거래처 필터 */}
|
||||
<Select value={vendorFilter} onValueChange={setVendorFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
@@ -446,7 +465,6 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
|
||||
<TableCell></TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell></TableCell>
|
||||
</TableRow>
|
||||
),
|
||||
|
||||
@@ -506,29 +524,6 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
|
||||
{WITHDRAWAL_TYPE_LABELS[item.withdrawalType]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
{/* 작업 */}
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
{handlers.isSelected && (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-gray-600 hover:text-gray-700 hover:bg-gray-50"
|
||||
onClick={() => handleEdit(item)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||
onClick={() => handlers.onDelete?.(item)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
@@ -576,9 +571,11 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
|
||||
}),
|
||||
[
|
||||
initialData,
|
||||
withdrawalData,
|
||||
stats,
|
||||
startDate,
|
||||
endDate,
|
||||
searchQuery,
|
||||
vendorFilter,
|
||||
withdrawalTypeFilter,
|
||||
sortOption,
|
||||
|
||||
@@ -510,6 +510,11 @@ export function ApprovalBox() {
|
||||
tabs: tabs,
|
||||
defaultTab: activeTab,
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
showPresets: false,
|
||||
|
||||
@@ -464,6 +464,11 @@ export function DraftBox() {
|
||||
{ key: 'actions', label: '작업', className: 'text-center' },
|
||||
],
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
showPresets: false,
|
||||
|
||||
@@ -445,6 +445,11 @@ export function ReferenceBox() {
|
||||
|
||||
computeStats: () => statCards,
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
showPresets: false,
|
||||
|
||||
@@ -14,8 +14,10 @@ export interface TabChipProps {
|
||||
label: string;
|
||||
/** 카운트 숫자 */
|
||||
count?: number;
|
||||
/** 활성 상태 */
|
||||
/** 활성 상태 (active 또는 isActive 둘 다 지원) */
|
||||
active?: boolean;
|
||||
/** 활성 상태 (active의 별칭) */
|
||||
isActive?: boolean;
|
||||
/** 클릭 이벤트 */
|
||||
onClick?: () => void;
|
||||
/** 색상 테마 */
|
||||
@@ -28,26 +30,30 @@ export function TabChip({
|
||||
label,
|
||||
count,
|
||||
active = false,
|
||||
isActive,
|
||||
onClick,
|
||||
color = "blue",
|
||||
className = "",
|
||||
}: TabChipProps) {
|
||||
// isActive가 전달되면 isActive 사용, 아니면 active 사용
|
||||
const isActiveState = isActive ?? active;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`
|
||||
flex items-center gap-2 px-4 py-2.5 rounded-full border transition-all
|
||||
${
|
||||
active
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-gray-200 hover:border-gray-300 hover:bg-gray-50"
|
||||
isActiveState
|
||||
? "border-primary bg-primary text-white shadow-sm"
|
||||
: "border-gray-200 bg-white hover:border-gray-300 hover:bg-gray-50"
|
||||
}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
<span
|
||||
className={`text-sm ${
|
||||
active ? "text-primary font-medium" : "text-gray-600 font-normal"
|
||||
isActiveState ? "text-white font-medium" : "text-gray-600 font-normal"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
@@ -55,7 +61,7 @@ export function TabChip({
|
||||
{count !== undefined && (
|
||||
<span
|
||||
className={`text-sm font-semibold ${
|
||||
active ? "text-primary" : "text-gray-900"
|
||||
isActiveState ? "text-white" : "text-gray-900"
|
||||
}`}
|
||||
>
|
||||
{count}
|
||||
|
||||
@@ -97,6 +97,8 @@ export default function BiddingListClient({ initialData = [], initialStats }: Bi
|
||||
// 날짜 범위
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
// 검색어
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
// Stats 데이터
|
||||
const [stats, setStats] = useState<BiddingStats | null>(initialStats || null);
|
||||
|
||||
@@ -220,6 +222,7 @@ export default function BiddingListClient({ initialData = [], initialStats }: Bi
|
||||
|
||||
// 커스텀 필터 함수 (activeStatTab + filterValues 기반)
|
||||
customFilterFn: (items, filterValues) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
return items.filter((item) => {
|
||||
// Stats 탭 필터
|
||||
if (activeStatTab === 'waiting' && item.status !== 'waiting') return false;
|
||||
@@ -291,6 +294,11 @@ export default function BiddingListClient({ initialData = [], initialStats }: Bi
|
||||
return sorted;
|
||||
},
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
// 공통 헤더 옵션: 날짜 선택기
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
@@ -410,8 +418,8 @@ export default function BiddingListClient({ initialData = [], initialStats }: Bi
|
||||
/>
|
||||
),
|
||||
}),
|
||||
[startDate, endDate, activeStatTab, stats, handleRowClick, handleEdit]
|
||||
[startDate, endDate, searchQuery, activeStatTab, stats, handleRowClick, handleEdit]
|
||||
);
|
||||
|
||||
return <UniversalListPage config={config} initialData={initialData} />;
|
||||
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@ export default function ContractListClient({ initialData = [], initialStats }: C
|
||||
const [activeStatTab, setActiveStatTab] = useState<'all' | 'pending' | 'completed'>('all');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [stats, setStats] = useState<ContractStats | null>(initialStats || null);
|
||||
|
||||
// Stats 로드
|
||||
@@ -235,6 +236,7 @@ export default function ContractListClient({ initialData = [], initialStats }: C
|
||||
|
||||
// 커스텀 필터 함수
|
||||
customFilterFn: (items, filterValues) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
return items.filter((item) => {
|
||||
// Stats 탭 필터
|
||||
if (activeStatTab === 'pending' && item.status !== 'pending') return false;
|
||||
@@ -304,6 +306,11 @@ export default function ContractListClient({ initialData = [], initialStats }: C
|
||||
return sorted;
|
||||
},
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
// 공통 헤더 옵션
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
@@ -433,8 +440,8 @@ export default function ContractListClient({ initialData = [], initialStats }: C
|
||||
/>
|
||||
),
|
||||
}),
|
||||
[startDate, endDate, activeStatTab, stats, handleRowClick, handleEdit]
|
||||
[startDate, endDate, searchQuery, activeStatTab, stats, handleRowClick, handleEdit]
|
||||
);
|
||||
|
||||
return <UniversalListPage config={config} initialData={initialData} />;
|
||||
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
|
||||
}
|
||||
|
||||
@@ -68,6 +68,8 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
||||
// 날짜 범위
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
// 검색어
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
// Stats 데이터
|
||||
const [stats, setStats] = useState<EstimateStats | null>(initialStats || null);
|
||||
// 필터 옵션 데이터
|
||||
@@ -210,6 +212,7 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
||||
|
||||
// 커스텀 필터 함수 (activeStatTab + filterValues 기반)
|
||||
customFilterFn: (items, filterValues) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
return items.filter((item) => {
|
||||
// Stats 탭 필터
|
||||
if (activeStatTab === 'pending' && item.status !== 'pending') return false;
|
||||
@@ -274,6 +277,11 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
||||
return sorted;
|
||||
},
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
// 공통 헤더 옵션: 날짜 선택기
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
@@ -389,8 +397,8 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
||||
/>
|
||||
),
|
||||
}),
|
||||
[startDate, endDate, activeStatTab, stats, partnerOptions, estimatorOptions, handleRowClick, handleEdit]
|
||||
[startDate, endDate, searchQuery, activeStatTab, stats, partnerOptions, estimatorOptions, handleRowClick, handleEdit]
|
||||
);
|
||||
|
||||
return <UniversalListPage config={config} initialData={initialData} />;
|
||||
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
|
||||
}
|
||||
@@ -113,6 +113,7 @@ export default function HandoverReportListClient({
|
||||
const [activeStatTab, setActiveStatTab] = useState<'all' | 'pending' | 'completed'>('all');
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [stats, setStats] = useState<HandoverReportStats | null>(initialStats || null);
|
||||
|
||||
// Stats 로드
|
||||
@@ -234,6 +235,7 @@ export default function HandoverReportListClient({
|
||||
|
||||
// 커스텀 필터 함수
|
||||
customFilterFn: (items, filterValues) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
return items.filter((item) => {
|
||||
// Stats 탭 필터
|
||||
if (activeStatTab === 'pending' && item.status !== 'pending') return false;
|
||||
@@ -297,6 +299,11 @@ export default function HandoverReportListClient({
|
||||
return sorted;
|
||||
},
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
// 공통 헤더 옵션
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
@@ -410,8 +417,8 @@ export default function HandoverReportListClient({
|
||||
/>
|
||||
),
|
||||
}),
|
||||
[startDate, endDate, activeStatTab, stats, handleRowClick, handleEdit]
|
||||
[startDate, endDate, searchQuery, activeStatTab, stats, handleRowClick, handleEdit]
|
||||
);
|
||||
|
||||
return <UniversalListPage config={config} initialData={initialData} />;
|
||||
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
|
||||
}
|
||||
@@ -89,6 +89,7 @@ export default function IssueManagementListClient({
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [stats, setStats] = useState<IssueStats | null>(initialStats || null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [withdrawDialogOpen, setWithdrawDialogOpen] = useState(false);
|
||||
const [itemsToWithdraw, setItemsToWithdraw] = useState<Set<string>>(new Set());
|
||||
const [clearSelectionFn, setClearSelectionFn] = useState<(() => void) | null>(null);
|
||||
@@ -271,6 +272,7 @@ export default function IssueManagementListClient({
|
||||
|
||||
// 커스텀 필터 함수
|
||||
customFilterFn: (items, filterValues) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
return items.filter((item) => {
|
||||
// Stats 탭 필터
|
||||
if (activeStatTab !== 'all' && item.status !== activeStatTab) return false;
|
||||
@@ -346,6 +348,11 @@ export default function IssueManagementListClient({
|
||||
return sorted;
|
||||
},
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
// 공통 헤더 옵션
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
@@ -494,12 +501,12 @@ export default function IssueManagementListClient({
|
||||
/>
|
||||
),
|
||||
}),
|
||||
[startDate, endDate, activeStatTab, stats, handleRowClick, handleEdit, handleCreate, handleWithdrawClick]
|
||||
[startDate, endDate, searchQuery, activeStatTab, stats, handleRowClick, handleEdit, handleCreate, handleWithdrawClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<UniversalListPage config={config} initialData={initialData} />
|
||||
<UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />
|
||||
|
||||
{/* 철회 확인 다이얼로그 */}
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -555,6 +555,7 @@ export default function ItemManagementClient({
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
hideSearch: true,
|
||||
|
||||
// 등록 버튼
|
||||
createButton: {
|
||||
|
||||
@@ -59,6 +59,7 @@ export default function LaborManagementClient({
|
||||
const [startDate, setStartDate] = useState(format(startOfYear(today), 'yyyy-MM-dd'));
|
||||
const [endDate, setEndDate] = useState(format(endOfYear(today), 'yyyy-MM-dd'));
|
||||
const [stats, setStats] = useState<LaborStats>(initialStats ?? { total: 0, active: 0 });
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Stats 로드
|
||||
useEffect(() => {
|
||||
@@ -211,6 +212,7 @@ export default function LaborManagementClient({
|
||||
|
||||
// 커스텀 필터 함수
|
||||
customFilterFn: (items, filterValues) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
return items.filter((item) => {
|
||||
// 구분 필터
|
||||
const categoryFilter = filterValues.category as string;
|
||||
@@ -242,6 +244,11 @@ export default function LaborManagementClient({
|
||||
return sorted;
|
||||
},
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
// 공통 헤더 옵션
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
@@ -361,6 +368,7 @@ export default function LaborManagementClient({
|
||||
[
|
||||
startDate,
|
||||
endDate,
|
||||
searchQuery,
|
||||
stats,
|
||||
handleRowClick,
|
||||
handleEdit,
|
||||
@@ -371,5 +379,5 @@ export default function LaborManagementClient({
|
||||
]
|
||||
);
|
||||
|
||||
return <UniversalListPage config={config} initialData={initialData} />;
|
||||
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
|
||||
}
|
||||
@@ -82,6 +82,7 @@ export default function ConstructionManagementListClient({
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [stats, setStats] = useState<ConstructionManagementStats | null>(initialStats || null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// 달력 관련 상태
|
||||
const [selectedCalendarDate, setSelectedCalendarDate] = useState<Date | null>(null);
|
||||
@@ -289,6 +290,7 @@ export default function ConstructionManagementListClient({
|
||||
|
||||
// 커스텀 필터 함수
|
||||
customFilterFn: (items, filterValues) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
return items.filter((item) => {
|
||||
// Stats 탭 필터
|
||||
if (activeStatTab === 'in_progress' && item.status !== 'in_progress') return false;
|
||||
@@ -379,6 +381,11 @@ export default function ConstructionManagementListClient({
|
||||
return sorted;
|
||||
},
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
// 공통 헤더 옵션
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
@@ -530,6 +537,7 @@ export default function ConstructionManagementListClient({
|
||||
[
|
||||
startDate,
|
||||
endDate,
|
||||
searchQuery,
|
||||
activeStatTab,
|
||||
stats,
|
||||
selectedCalendarDate,
|
||||
@@ -550,5 +558,5 @@ export default function ConstructionManagementListClient({
|
||||
]
|
||||
);
|
||||
|
||||
return <UniversalListPage config={config} initialData={initialData} />;
|
||||
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
|
||||
}
|
||||
@@ -104,12 +104,23 @@ export default function ProjectDetailClient({ projectId }: ProjectDetailClientPr
|
||||
icon={FolderKanban}
|
||||
/>
|
||||
|
||||
{/* 기간 선택 (달력 + 프리셋 버튼) */}
|
||||
{/* 기간 선택 + 검색 영역 */}
|
||||
<DateRangeSelector
|
||||
startDate={filterStartDate}
|
||||
endDate={filterEndDate}
|
||||
onStartDateChange={setFilterStartDate}
|
||||
onEndDateChange={setFilterEndDate}
|
||||
extraActions={
|
||||
<div className="relative w-full sm:w-[300px]">
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 상태 카드 */}
|
||||
@@ -155,17 +166,6 @@ export default function ProjectDetailClient({ projectId }: ProjectDetailClientPr
|
||||
</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]">
|
||||
|
||||
@@ -89,6 +89,7 @@ export default function OrderManagementListClient({
|
||||
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// 달력 관련 상태
|
||||
const [selectedCalendarDate, setSelectedCalendarDate] = useState<Date | null>(null);
|
||||
@@ -304,6 +305,7 @@ export default function OrderManagementListClient({
|
||||
|
||||
// 커스텀 필터 함수
|
||||
customFilterFn: (items, filterValues) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
return items.filter((item) => {
|
||||
// 거래처 필터 (다중선택)
|
||||
const partnerFilters = filterValues.partners as string[];
|
||||
@@ -423,6 +425,11 @@ export default function OrderManagementListClient({
|
||||
return sorted;
|
||||
},
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
// 공통 헤더 옵션
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
@@ -584,6 +591,7 @@ export default function OrderManagementListClient({
|
||||
[
|
||||
startDate,
|
||||
endDate,
|
||||
searchQuery,
|
||||
selectedCalendarDate,
|
||||
calendarEvents,
|
||||
calendarBadges,
|
||||
@@ -606,5 +614,5 @@ export default function OrderManagementListClient({
|
||||
]
|
||||
);
|
||||
|
||||
return <UniversalListPage config={config} initialData={initialData} />;
|
||||
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
|
||||
}
|
||||
|
||||
@@ -188,6 +188,7 @@ export default function PartnerListClient({ initialData = [], initialStats }: Pa
|
||||
|
||||
// 커스텀 필터 함수
|
||||
customFilterFn: (items, filterValues) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
return items.filter((item) => {
|
||||
// 악성채권 필터
|
||||
const badDebtFilter = filterValues.badDebt as string;
|
||||
|
||||
@@ -75,6 +75,7 @@ export default function ProgressBillingManagementListClient({
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [stats, setStats] = useState<ProgressBillingStats | null>(initialStats || null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Stats 로드
|
||||
useEffect(() => {
|
||||
@@ -188,6 +189,7 @@ export default function ProgressBillingManagementListClient({
|
||||
|
||||
// 커스텀 필터 함수
|
||||
customFilterFn: (items, filterValues) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
return items.filter((item) => {
|
||||
// Stats 탭 필터
|
||||
if (activeStatTab === 'contractWaiting' &&
|
||||
@@ -239,6 +241,11 @@ export default function ProgressBillingManagementListClient({
|
||||
return sorted;
|
||||
},
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
// 공통 헤더 옵션
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
@@ -350,8 +357,8 @@ export default function ProgressBillingManagementListClient({
|
||||
/>
|
||||
),
|
||||
}),
|
||||
[startDate, endDate, activeStatTab, stats, handleRowClick, handleEdit]
|
||||
[startDate, endDate, searchQuery, activeStatTab, stats, handleRowClick, handleEdit]
|
||||
);
|
||||
|
||||
return <UniversalListPage config={config} initialData={initialData} />;
|
||||
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin
|
||||
// 날짜 범위
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// ===== 핸들러 =====
|
||||
const handleRowClick = useCallback(
|
||||
@@ -187,6 +188,7 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin
|
||||
|
||||
// 커스텀 필터 함수 (activeStatTab 필터링 포함)
|
||||
customFilterFn: (items, filterValues) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
return items.filter((item) => {
|
||||
// Stats 탭 필터
|
||||
if (activeStatTab === 'scheduled' && item.attendanceStatus !== 'scheduled') return false;
|
||||
@@ -216,6 +218,11 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin
|
||||
return sorted;
|
||||
},
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
// 날짜 범위 선택기
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
@@ -350,8 +357,8 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin
|
||||
);
|
||||
},
|
||||
}),
|
||||
[handleRowClick, handleEdit, handleCreate, activeStatTab, startDate, endDate]
|
||||
[handleRowClick, handleEdit, handleCreate, activeStatTab, startDate, endDate, searchQuery]
|
||||
);
|
||||
|
||||
return <UniversalListPage config={config} initialData={initialData} />;
|
||||
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ export default function SiteManagementListClient({
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [stats, setStats] = useState<SiteStats | null>(initialStats || null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Stats 로드
|
||||
useEffect(() => {
|
||||
@@ -183,6 +184,7 @@ export default function SiteManagementListClient({
|
||||
|
||||
// 커스텀 필터 함수
|
||||
customFilterFn: (items, filterValues) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
return items.filter((item) => {
|
||||
// Stats 탭 필터
|
||||
if (activeStatTab === 'construction' && item.status !== 'active') return false;
|
||||
@@ -228,6 +230,11 @@ export default function SiteManagementListClient({
|
||||
return sorted;
|
||||
},
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
// 공통 헤더 옵션
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
@@ -337,8 +344,8 @@ export default function SiteManagementListClient({
|
||||
/>
|
||||
),
|
||||
}),
|
||||
[startDate, endDate, activeStatTab, stats, handleRowClick, handleEdit]
|
||||
[startDate, endDate, activeStatTab, stats, handleRowClick, handleEdit, searchQuery]
|
||||
);
|
||||
|
||||
return <UniversalListPage config={config} initialData={initialData} />;
|
||||
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ export default function StructureReviewListClient({
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [stats, setStats] = useState<StructureReviewStats | null>(initialStats || null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Stats 로드
|
||||
useEffect(() => {
|
||||
@@ -201,6 +202,7 @@ export default function StructureReviewListClient({
|
||||
|
||||
// 커스텀 필터 함수
|
||||
customFilterFn: (items, filterValues) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
return items.filter((item) => {
|
||||
// Stats 탭 필터
|
||||
if (activeStatTab === 'pending' && item.status !== 'pending') return false;
|
||||
@@ -246,6 +248,11 @@ export default function StructureReviewListClient({
|
||||
return sorted;
|
||||
},
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
// 공통 헤더 옵션
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
@@ -376,8 +383,8 @@ export default function StructureReviewListClient({
|
||||
/>
|
||||
),
|
||||
}),
|
||||
[startDate, endDate, activeStatTab, stats, handleRowClick, handleEdit, handleCreate]
|
||||
[startDate, endDate, activeStatTab, stats, handleRowClick, handleEdit, handleCreate, searchQuery]
|
||||
);
|
||||
|
||||
return <UniversalListPage config={config} initialData={initialData} />;
|
||||
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ export default function UtilityManagementListClient({
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [stats, setStats] = useState<UtilityStats | null>(initialStats || null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Stats 로드
|
||||
useEffect(() => {
|
||||
@@ -212,6 +213,7 @@ export default function UtilityManagementListClient({
|
||||
|
||||
// 커스텀 필터 함수
|
||||
customFilterFn: (items, filterValues) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
return items.filter((item) => {
|
||||
// Stats 탭 필터
|
||||
if (activeStatTab === 'waiting' && item.status !== 'scheduled' && item.status !== 'issued') return false;
|
||||
@@ -279,6 +281,11 @@ export default function UtilityManagementListClient({
|
||||
return sorted;
|
||||
},
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
// 공통 헤더 옵션
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
@@ -389,8 +396,8 @@ export default function UtilityManagementListClient({
|
||||
/>
|
||||
),
|
||||
}),
|
||||
[startDate, endDate, activeStatTab, stats]
|
||||
[startDate, endDate, activeStatTab, stats, searchQuery]
|
||||
);
|
||||
|
||||
return <UniversalListPage config={config} initialData={initialData} />;
|
||||
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ export default function WorkerStatusListClient({
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [stats, setStats] = useState<WorkerStatusStats | null>(initialStats || null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Stats 로드
|
||||
useEffect(() => {
|
||||
@@ -220,6 +221,7 @@ export default function WorkerStatusListClient({
|
||||
|
||||
// 커스텀 필터 함수
|
||||
customFilterFn: (items, filterValues) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
return items.filter((item) => {
|
||||
// Stats 탭 필터 (계약상태)
|
||||
if (activeStatTab !== 'all' && item.contractStatus !== activeStatTab) return false;
|
||||
@@ -300,6 +302,11 @@ export default function WorkerStatusListClient({
|
||||
return sorted;
|
||||
},
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
// 공통 헤더 옵션
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
@@ -415,8 +422,8 @@ export default function WorkerStatusListClient({
|
||||
/>
|
||||
),
|
||||
}),
|
||||
[startDate, endDate, activeStatTab, stats, handleRowClick, handleViewDetail]
|
||||
[startDate, endDate, activeStatTab, stats, handleRowClick, handleViewDetail, searchQuery]
|
||||
);
|
||||
|
||||
return <UniversalListPage config={config} initialData={initialData} />;
|
||||
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
|
||||
}
|
||||
|
||||
80
src/components/common/ParentMenuRedirect.tsx
Normal file
80
src/components/common/ParentMenuRedirect.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
|
||||
interface ParentMenuRedirectProps {
|
||||
/** 현재 부모 메뉴 경로 (예: '/accounting') */
|
||||
parentPath: string;
|
||||
/** 메뉴 데이터를 찾지 못했을 때 사용할 기본 첫 번째 자식 경로 */
|
||||
fallbackPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 부모 메뉴 URL 접근 시 첫 번째 자식 메뉴로 동적 리다이렉트
|
||||
*
|
||||
* localStorage에 저장된 메뉴 구조를 읽어서 해당 부모의 첫 번째 자식으로 이동합니다.
|
||||
* 메뉴 구조가 변경되어도 자동으로 대응됩니다.
|
||||
*/
|
||||
export function ParentMenuRedirect({ parentPath, fallbackPath }: ParentMenuRedirectProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
// localStorage에서 user 데이터 읽기
|
||||
const userData = localStorage.getItem('user');
|
||||
if (!userData) {
|
||||
router.replace(fallbackPath);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(userData);
|
||||
const menuItems = parsed.menu;
|
||||
|
||||
if (!menuItems || !Array.isArray(menuItems)) {
|
||||
router.replace(fallbackPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// 현재 부모 메뉴 찾기 (재귀적으로 검색)
|
||||
const findParentMenu = (items: any[], targetPath: string): any | null => {
|
||||
for (const item of items) {
|
||||
// 경로가 일치하는지 확인 (locale prefix 제거 후 비교)
|
||||
const itemPath = item.path?.replace(/^\/[a-z]{2}\//, '/') || '';
|
||||
if (itemPath === targetPath || item.path === targetPath) {
|
||||
return item;
|
||||
}
|
||||
// 자식 메뉴에서 검색
|
||||
if (item.children && item.children.length > 0) {
|
||||
const found = findParentMenu(item.children, targetPath);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const parentMenu = findParentMenu(menuItems, parentPath);
|
||||
|
||||
if (parentMenu && parentMenu.children && parentMenu.children.length > 0) {
|
||||
// 첫 번째 자식 메뉴의 경로로 리다이렉트
|
||||
const firstChild = parentMenu.children[0];
|
||||
const firstChildPath = firstChild.path?.replace(/^\/[a-z]{2}\//, '/') || fallbackPath;
|
||||
router.replace(firstChildPath);
|
||||
} else {
|
||||
// 자식이 없으면 fallback으로 이동
|
||||
router.replace(fallbackPath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ParentMenuRedirect] Error:', error);
|
||||
router.replace(fallbackPath);
|
||||
}
|
||||
}, [router, parentPath, fallbackPath]);
|
||||
|
||||
// 리다이렉트 중 로딩 표시
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[200px]">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -130,6 +130,7 @@ export function EventList() {
|
||||
|
||||
// 커스텀 필터 (날짜)
|
||||
customFilterFn: (items, filterValues) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
if (!startDate || !endDate) return items;
|
||||
return items.filter((item) => {
|
||||
// 이벤트 기간이 선택한 기간과 겹치는지 확인
|
||||
|
||||
@@ -124,6 +124,7 @@ export function InquiryList() {
|
||||
|
||||
// 커스텀 필터 (날짜 + 카테고리 + 상태)
|
||||
customFilterFn: (items, filterValues) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
let result = [...items];
|
||||
|
||||
// 날짜 필터
|
||||
|
||||
@@ -97,6 +97,7 @@ export function NoticeList() {
|
||||
|
||||
// 커스텀 필터 (날짜)
|
||||
customFilterFn: (items, filterValues) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
// 날짜 필터는 외부 상태 사용
|
||||
if (!startDate || !endDate) return items;
|
||||
return items.filter((item) => {
|
||||
|
||||
@@ -11,9 +11,11 @@ import {
|
||||
Plus,
|
||||
FileText,
|
||||
Edit,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { TableRow, TableCell } from '@/components/ui/table';
|
||||
import { format } from 'date-fns';
|
||||
@@ -434,6 +436,11 @@ export function AttendanceManagement() {
|
||||
|
||||
computeStats: () => statCards,
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchValue,
|
||||
onSearchChange: setSearchValue,
|
||||
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
showPresets: true,
|
||||
@@ -482,6 +489,7 @@ export function AttendanceManagement() {
|
||||
},
|
||||
|
||||
customFilterFn: (items, filterValues) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
let filtered = items;
|
||||
const filterOption = filterValues.filter as string;
|
||||
if (filterOption && filterOption !== 'all') {
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { CreditCard, Edit, Trash2, Plus } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { CreditCard, Edit, Trash2, Plus, Search, RefreshCw } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { TableRow, TableCell } from '@/components/ui/table';
|
||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
@@ -58,7 +59,7 @@ export function CardManagement({ initialData }: CardManagementProps) {
|
||||
};
|
||||
|
||||
// 검색 및 필터 상태
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<string>('all');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 20;
|
||||
@@ -78,8 +79,8 @@ export function CardManagement({ initialData }: CardManagementProps) {
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (searchValue) {
|
||||
const search = searchValue.toLowerCase();
|
||||
if (searchQuery) {
|
||||
const search = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(c =>
|
||||
c.cardName.toLowerCase().includes(search) ||
|
||||
c.cardNumber.includes(search) ||
|
||||
@@ -89,7 +90,7 @@ export function CardManagement({ initialData }: CardManagementProps) {
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [cards, activeTab, searchValue]);
|
||||
}, [cards, activeTab, searchQuery]);
|
||||
|
||||
// 페이지네이션된 데이터
|
||||
const paginatedData = useMemo(() => {
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Users, Edit, Trash2, UserCheck, UserX, Clock, Calendar, Mail, Plus, Upload, Loader2 } from 'lucide-react';
|
||||
import { Users, Edit, Trash2, UserCheck, UserX, Clock, Calendar, Mail, Plus, Upload, Loader2, Search } from 'lucide-react';
|
||||
import { getEmployees, deleteEmployee, deleteEmployees, getEmployeeStats } from './actions';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { TableRow, TableCell } from '@/components/ui/table';
|
||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
@@ -437,6 +438,11 @@ export function EmployeeManagement() {
|
||||
|
||||
computeStats: () => statCards,
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchValue,
|
||||
onSearchChange: setSearchValue,
|
||||
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
showPresets: true,
|
||||
@@ -489,6 +495,7 @@ export function EmployeeManagement() {
|
||||
},
|
||||
|
||||
customFilterFn: (items, filterValues) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
let filtered = items;
|
||||
const filterOption = filterValues.filter as FilterOption;
|
||||
if (filterOption && filterOption !== 'all') {
|
||||
|
||||
@@ -13,9 +13,11 @@ import {
|
||||
Gift,
|
||||
MinusCircle,
|
||||
Loader2,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TableRow, TableCell } from '@/components/ui/table';
|
||||
@@ -374,6 +376,11 @@ export function SalaryManagement() {
|
||||
|
||||
itemsPerPage: itemsPerPage,
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
// 날짜 범위 선택 (DateRangeSelector 사용)
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
type FilterFieldConfig,
|
||||
type FilterValues,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
import { VacationGrantDialog } from './VacationGrantDialog';
|
||||
import { VacationRequestDialog } from './VacationRequestDialog';
|
||||
@@ -587,48 +586,40 @@ export function VacationManagement() {
|
||||
}
|
||||
}, [mainTab, handleApproveClick, handleRejectClick]);
|
||||
|
||||
// ===== 헤더 액션 (DateRangeSelector + 버튼들) =====
|
||||
// ===== 헤더 액션 (탭별 버튼들만 - DateRangeSelector와 검색창은 공통 옵션 사용) =====
|
||||
const headerActions = useCallback(({ selectedItems: selected }: { selectedItems: Set<string>; onClearSelection?: () => void; onRefresh?: () => void }) => (
|
||||
<>
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
/>
|
||||
<div className="ml-auto flex gap-2">
|
||||
{/* 탭별 액션 버튼 */}
|
||||
{mainTab === 'grant' && (
|
||||
<Button onClick={() => setGrantDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
부여등록
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 탭별 액션 버튼 */}
|
||||
{mainTab === 'grant' && (
|
||||
<Button onClick={() => setGrantDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
부여등록
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{mainTab === 'request' && (
|
||||
<>
|
||||
{/* 버튼 순서: 승인 → 거절 → 휴가신청 (휴가신청 버튼 위치 고정) */}
|
||||
{selected.size > 0 && (
|
||||
<>
|
||||
<Button variant="default" onClick={() => handleApproveClick(selected)}>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
승인
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={() => handleRejectClick(selected)}>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
거절
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button onClick={() => setRequestDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
휴가신청
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
), [startDate, endDate, mainTab, handleApproveClick, handleRejectClick]);
|
||||
{mainTab === 'request' && (
|
||||
<>
|
||||
{/* 버튼 순서: 승인 → 거절 → 휴가신청 (휴가신청 버튼 위치 고정) */}
|
||||
{selected.size > 0 && (
|
||||
<>
|
||||
<Button variant="default" onClick={() => handleApproveClick(selected)}>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
승인
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={() => handleRejectClick(selected)}>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
거절
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button onClick={() => setRequestDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
휴가신청
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
), [mainTab, handleApproveClick, handleRejectClick]);
|
||||
|
||||
// ===== filterConfig 기반 통합 필터 시스템 =====
|
||||
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
||||
@@ -693,6 +684,15 @@ export function VacationManagement() {
|
||||
|
||||
columns: tableColumns,
|
||||
|
||||
// 공통 패턴: dateRangeSelector
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
|
||||
tabs: tabs,
|
||||
defaultTab: mainTab,
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { TableRow, TableCell } from '@/components/ui/table';
|
||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { Search, Plus, Edit, Trash2, Package } from 'lucide-react';
|
||||
import { Search, Plus, Edit, Trash2, Package, Download, FileDown, Upload } from 'lucide-react';
|
||||
import { downloadExcel, downloadSelectedExcel, downloadExcelTemplate, parseExcelFile, type ExcelColumn, type TemplateColumn } from '@/lib/utils/excel-download';
|
||||
import { useItemList } from '@/hooks/useItemList';
|
||||
import { handleApiError } from '@/lib/api/error-handler';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
@@ -237,6 +238,120 @@ export default function ItemListClient() {
|
||||
}
|
||||
};
|
||||
|
||||
// 엑셀 다운로드용 컬럼 정의
|
||||
const excelColumns: ExcelColumn<ItemMaster>[] = [
|
||||
{ header: '품목코드', key: 'itemCode', width: 15 },
|
||||
{ header: '품목유형', key: 'itemType', width: 10, transform: (v) => ITEM_TYPE_LABELS[v as keyof typeof ITEM_TYPE_LABELS] || String(v) },
|
||||
{ header: '품목명', key: 'itemName', width: 30 },
|
||||
{ header: '규격', key: 'specification', width: 20 },
|
||||
{ header: '단위', key: 'unit', width: 8 },
|
||||
{ header: '대분류', key: 'category1', width: 12 },
|
||||
{ header: '중분류', key: 'category2', width: 12 },
|
||||
{ header: '소분류', key: 'category3', width: 12 },
|
||||
{ header: '구매단가', key: 'purchasePrice', width: 12 },
|
||||
{ header: '판매단가', key: 'salesPrice', width: 12 },
|
||||
{ header: '활성상태', key: 'isActive', width: 10, transform: (v) => v ? '활성' : '비활성' },
|
||||
];
|
||||
|
||||
// 전체 엑셀 다운로드
|
||||
const handleExcelDownload = () => {
|
||||
if (items.length === 0) {
|
||||
alert('다운로드할 데이터가 없습니다.');
|
||||
return;
|
||||
}
|
||||
downloadExcel({
|
||||
data: items,
|
||||
columns: excelColumns,
|
||||
filename: '품목목록',
|
||||
sheetName: '품목',
|
||||
});
|
||||
};
|
||||
|
||||
// 선택 항목 엑셀 다운로드
|
||||
const handleSelectedExcelDownload = (selectedIds: string[]) => {
|
||||
if (selectedIds.length === 0) {
|
||||
alert('선택된 항목이 없습니다.');
|
||||
return;
|
||||
}
|
||||
downloadSelectedExcel({
|
||||
data: items,
|
||||
columns: excelColumns,
|
||||
selectedIds,
|
||||
idField: 'id',
|
||||
filename: '품목목록_선택',
|
||||
sheetName: '품목',
|
||||
});
|
||||
};
|
||||
|
||||
// 업로드용 템플릿 컬럼 정의
|
||||
const templateColumns: TemplateColumn[] = [
|
||||
{ header: '품목코드', key: 'itemCode', required: true, type: 'text', sampleValue: 'KD-FG-001', description: '고유 코드', width: 15 },
|
||||
{ header: '품목유형', key: 'itemType', required: true, type: 'select', options: ['FG', 'PT', 'SM', 'RM', 'CS'], sampleValue: 'FG', description: 'FG:제품/PT:부품/SM:부자재/RM:원자재/CS:소모품', width: 12 },
|
||||
{ header: '품목명', key: 'itemName', required: true, type: 'text', sampleValue: '스크린도어 본체', width: 25 },
|
||||
{ header: '규격', key: 'specification', type: 'text', sampleValue: '1800x2100', width: 15 },
|
||||
{ header: '단위', key: 'unit', required: true, type: 'select', options: ['EA', 'SET', 'KG', 'M', 'M2', 'BOX'], sampleValue: 'EA', width: 10 },
|
||||
{ header: '대분류', key: 'category1', type: 'text', sampleValue: '스크린도어', width: 12 },
|
||||
{ header: '중분류', key: 'category2', type: 'text', sampleValue: '본체류', width: 12 },
|
||||
{ header: '소분류', key: 'category3', type: 'text', sampleValue: '프레임', width: 12 },
|
||||
{ header: '구매단가', key: 'purchasePrice', type: 'number', sampleValue: 150000, width: 12 },
|
||||
{ header: '판매단가', key: 'salesPrice', type: 'number', sampleValue: 200000, width: 12 },
|
||||
{ header: '활성상태', key: 'isActive', type: 'boolean', sampleValue: 'Y', description: 'Y:활성/N:비활성', width: 10 },
|
||||
];
|
||||
|
||||
// 양식 다운로드
|
||||
const handleTemplateDownload = () => {
|
||||
downloadExcelTemplate({
|
||||
columns: templateColumns,
|
||||
filename: '품목등록_양식',
|
||||
sheetName: '품목등록',
|
||||
includeSampleRow: true,
|
||||
includeGuideRow: true,
|
||||
});
|
||||
};
|
||||
|
||||
// 파일 업로드 input ref
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 양식 업로드 핸들러
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const result = await parseExcelFile(file, {
|
||||
columns: templateColumns,
|
||||
skipRows: 2, // 헤더 + 안내 행 스킵
|
||||
});
|
||||
|
||||
if (!result.success || result.errors.length > 0) {
|
||||
const errorMessages = result.errors.slice(0, 5).map(
|
||||
(err) => `${err.row}행: ${err.message}`
|
||||
).join('\n');
|
||||
alert(`업로드 오류:\n${errorMessages}${result.errors.length > 5 ? `\n... 외 ${result.errors.length - 5}건` : ''}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.data.length === 0) {
|
||||
alert('업로드할 데이터가 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: 실제 API 호출로 데이터 저장
|
||||
// 지금은 파싱 결과만 확인
|
||||
console.log('[Excel Upload] 파싱 결과:', result.data);
|
||||
alert(`${result.data.length}건의 데이터가 파싱되었습니다.\n(실제 등록 기능은 추후 구현 예정)`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Excel Upload] 오류:', error);
|
||||
alert('파일 업로드에 실패했습니다.');
|
||||
} finally {
|
||||
// input 초기화 (같은 파일 재선택 가능하도록)
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 탭 옵션 (품목 유형별)
|
||||
const tabs: TabOption[] = [
|
||||
{ value: 'all', label: '전체', count: totalStats.totalAll, color: 'gray' },
|
||||
@@ -301,6 +416,56 @@ export default function ItemListClient() {
|
||||
icon: Plus,
|
||||
},
|
||||
|
||||
// 헤더 액션 (엑셀 다운로드)
|
||||
headerActions: ({ selectedItems }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 양식 다운로드 버튼 - 추후 활성화
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleTemplateDownload}
|
||||
className="gap-2"
|
||||
>
|
||||
<FileDown className="h-4 w-4" />
|
||||
양식 다운로드
|
||||
</Button>
|
||||
*/}
|
||||
{/* 양식 업로드 버튼 - 추후 활성화
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="gap-2"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
양식 업로드
|
||||
</Button>
|
||||
*/}
|
||||
{/* 엑셀 데이터 다운로드 버튼 */}
|
||||
{selectedItems.size > 0 ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleSelectedExcelDownload(Array.from(selectedItems))}
|
||||
className="gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
선택 다운로드 ({selectedItems.size})
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleExcelDownload}
|
||||
className="gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
엑셀 다운로드
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
|
||||
// API 액션 (일괄 삭제 포함)
|
||||
actions: {
|
||||
getList: async () => ({ success: true, data: items }),
|
||||
@@ -488,6 +653,15 @@ export default function ItemListClient() {
|
||||
externalIsLoading={isLoading}
|
||||
/>
|
||||
|
||||
{/* 숨겨진 파일 업로드 input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* 개별 삭제 확인 다이얼로그 */}
|
||||
<DeleteConfirmDialog
|
||||
open={deleteDialogOpen}
|
||||
|
||||
@@ -46,6 +46,8 @@ interface DateRangeSelectorProps {
|
||||
hideDateInputs?: boolean;
|
||||
/** 날짜 입력 너비 */
|
||||
dateInputWidth?: string;
|
||||
/** 프리셋 버튼 위치: 'inline' (날짜 옆), 'below' (별도 줄) */
|
||||
presetsPosition?: 'inline' | 'below';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,6 +81,7 @@ export function DateRangeSelector({
|
||||
hidePresets = false,
|
||||
hideDateInputs = false,
|
||||
dateInputWidth = 'w-[140px]',
|
||||
presetsPosition = 'inline',
|
||||
}: DateRangeSelectorProps) {
|
||||
|
||||
// 프리셋 클릭 핸들러
|
||||
@@ -119,59 +122,94 @@ export function DateRangeSelector({
|
||||
}
|
||||
}, [onStartDateChange, onEndDateChange]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{/* 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">
|
||||
<Input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => onStartDateChange(e.target.value)}
|
||||
className="w-[165px]"
|
||||
/>
|
||||
<span className="text-muted-foreground shrink-0">~</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => onEndDateChange(e.target.value)}
|
||||
className="w-[165px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 기간 버튼들 - 모바일에서 가로 스크롤 */}
|
||||
{!hidePresets && presets.length > 0 && (
|
||||
<div
|
||||
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">
|
||||
{presets.map((preset) => (
|
||||
<Button
|
||||
key={preset}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePresetClick(preset)}
|
||||
className="shrink-0 text-xs sm:text-sm px-2 sm:px-3"
|
||||
>
|
||||
{PRESET_LABELS[preset]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
// 프리셋 버튼 렌더링
|
||||
const renderPresets = () => {
|
||||
if (hidePresets || presets.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
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">
|
||||
{presets.map((preset) => (
|
||||
<Button
|
||||
key={preset}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePresetClick(preset)}
|
||||
className="shrink-0 text-xs sm:text-sm px-2 sm:px-3"
|
||||
>
|
||||
{PRESET_LABELS[preset]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
{/* 2줄: 추가 액션 버튼들 - 항상 별도 줄, 오른쪽 정렬 */}
|
||||
{extraActions && (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
// presetsPosition이 'below'일 때: 달력+extraActions 같은 줄, 프리셋은 아래 줄
|
||||
if (presetsPosition === 'below') {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{/* 1줄: 날짜 + extraActions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 날짜 범위 선택 */}
|
||||
{!hideDateInputs && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => onStartDateChange(e.target.value)}
|
||||
className="w-[165px]"
|
||||
/>
|
||||
<span className="text-muted-foreground shrink-0">~</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => onEndDateChange(e.target.value)}
|
||||
className="w-[165px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* extraActions (검색창 등) */}
|
||||
{extraActions}
|
||||
</div>
|
||||
|
||||
{/* 2줄: 프리셋 버튼들 */}
|
||||
{renderPresets()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// presetsPosition이 'inline' (기본값)
|
||||
// PC(1280px+): 달력 | 프리셋버튼 | 검색창 (한 줄)
|
||||
// 태블릿: 달력 / 프리셋버튼 / 검색창 (세 줄)
|
||||
return (
|
||||
<div className="flex flex-col xl:flex-row xl:items-center gap-2 w-full">
|
||||
{/* 날짜 범위 선택 */}
|
||||
{!hideDateInputs && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => onStartDateChange(e.target.value)}
|
||||
className="w-[165px]"
|
||||
/>
|
||||
<span className="text-muted-foreground shrink-0">~</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => onEndDateChange(e.target.value)}
|
||||
className="w-[165px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 기간 버튼들 - 달력 바로 옆 */}
|
||||
{renderPresets()}
|
||||
|
||||
{/* extraActions (검색창 등) - 마지막에 배치 */}
|
||||
{extraActions}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ interface StatCardsProps {
|
||||
|
||||
export function StatCards({ stats }: StatCardsProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 md:gap-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-4 xl:grid-cols-4 gap-2">
|
||||
{stats.map((stat, index) => {
|
||||
const Icon = stat.icon;
|
||||
const isClickable = !!stat.onClick;
|
||||
@@ -37,24 +37,24 @@ export function StatCards({ stats }: StatCardsProps) {
|
||||
}`}
|
||||
onClick={stat.onClick}
|
||||
>
|
||||
<CardContent className="p-3 md:p-4 lg:p-6">
|
||||
<CardContent className="p-2 md:p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="sm:text-xs md:text-sm text-muted-foreground mb-1 md:mb-2 uppercase tracking-wide text-[12px]">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[10px] md:text-xs text-muted-foreground mb-0.5 uppercase tracking-wide truncate">
|
||||
{stat.label}
|
||||
</p>
|
||||
<p className="font-bold text-[24px]">
|
||||
<p className="font-bold text-base md:text-lg truncate">
|
||||
{stat.value}
|
||||
</p>
|
||||
{stat.trend && (
|
||||
<p className={`text-[10px] sm:text-xs md:text-sm mt-1 md:mt-2 font-medium ${stat.trend.isPositive ? 'text-green-600' : 'text-red-600'}`}>
|
||||
<p className={`text-[9px] md:text-[10px] mt-0.5 font-medium ${stat.trend.isPositive ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{stat.trend.value}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{Icon && (
|
||||
<Icon
|
||||
className={`w-8 h-8 sm:w-9 sm:h-9 md:w-10 md:h-10 lg:w-12 lg:h-12 opacity-15 ${stat.iconColor || 'text-blue-600'}`}
|
||||
className={`w-6 h-6 md:w-8 md:h-8 opacity-15 flex-shrink-0 ${stat.iconColor || 'text-blue-600'}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -223,9 +223,22 @@ export function PricingListClient({
|
||||
) => {
|
||||
const { isSelected, onToggle } = handlers;
|
||||
|
||||
// 행 클릭 핸들러: 등록되지 않은 항목은 등록, 등록된 항목은 수정
|
||||
const handleRowClick = () => {
|
||||
if (item.status === 'not_registered') {
|
||||
handleRegister(item);
|
||||
} else {
|
||||
handleEdit(item);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TableRow key={item.id} className="hover:bg-muted/50">
|
||||
<TableCell className="text-center">
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="hover:bg-muted/50 cursor-pointer"
|
||||
onClick={handleRowClick}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={onToggle}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Wrench, Plus, Pencil, Trash2, Loader2 } from 'lucide-react';
|
||||
import { Wrench, Plus, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -45,6 +45,13 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
|
||||
// 날짜 범위 상태
|
||||
const [startDate, setStartDate] = useState('2025-01-01');
|
||||
const [endDate, setEndDate] = useState('2025-12-31');
|
||||
|
||||
// 검색어 상태
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// ===== 데이터 로드 =====
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
@@ -249,36 +256,48 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr
|
||||
clientSideFiltering: true,
|
||||
itemsPerPage: 20,
|
||||
|
||||
// 탭 필터 함수
|
||||
tabFilter: (item: Process, activeTab: string) => {
|
||||
if (activeTab === 'all') return true;
|
||||
return item.status === activeTab;
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
// 날짜 범위 선택기
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
showPresets: true,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
|
||||
// 검색 필터 함수
|
||||
searchFilter: (item: Process, searchValue: string) => {
|
||||
const search = searchValue.toLowerCase();
|
||||
// 탭 필터 (공통 컴포넌트에서 처리)
|
||||
tabFilter: (item, tabValue) => {
|
||||
if (tabValue === 'all') return true;
|
||||
return item.status === tabValue;
|
||||
},
|
||||
|
||||
// 검색 필터
|
||||
searchFilter: (item, searchValue) => {
|
||||
if (!searchValue || !searchValue.trim()) return true;
|
||||
const search = searchValue.toLowerCase().trim();
|
||||
return (
|
||||
item.processCode.toLowerCase().includes(search) ||
|
||||
item.processName.toLowerCase().includes(search) ||
|
||||
item.department.toLowerCase().includes(search)
|
||||
(item.processCode || '').toLowerCase().includes(search) ||
|
||||
(item.processName || '').toLowerCase().includes(search) ||
|
||||
(item.department || '').toLowerCase().includes(search)
|
||||
);
|
||||
},
|
||||
|
||||
// 탭 설정
|
||||
// 탭 (공통 컴포넌트에서 Card 안에 렌더링)
|
||||
tabs,
|
||||
defaultTab: 'all',
|
||||
|
||||
// 검색
|
||||
searchPlaceholder: '공정코드, 공정명, 담당부서 검색',
|
||||
|
||||
// 헤더 액션
|
||||
headerActions: () => (
|
||||
<Button onClick={handleCreate} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
공정 등록
|
||||
</Button>
|
||||
),
|
||||
// 등록 버튼 (공통 컴포넌트에서 오른쪽에 렌더링)
|
||||
createButton: {
|
||||
label: '공정 등록',
|
||||
onClick: handleCreate,
|
||||
icon: Plus,
|
||||
},
|
||||
|
||||
// 일괄 삭제 핸들러
|
||||
onBulkDelete: handleBulkDelete,
|
||||
@@ -448,12 +467,12 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr
|
||||
);
|
||||
},
|
||||
}),
|
||||
[tabs, handleCreate, handleRowClick, handleEdit, handleDeleteClick, handleToggleStatus, handleBulkDelete]
|
||||
[tabs, handleCreate, handleRowClick, handleEdit, handleDeleteClick, handleToggleStatus, handleBulkDelete, startDate, endDate, searchQuery]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<UniversalListPage config={config} initialData={allProcesses} />
|
||||
<UniversalListPage config={config} initialData={allProcesses} onSearchChange={setSearchQuery} />
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<DeleteConfirmDialog
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, Fragment, useState, useEffect, useRef, useCallback } from "react";
|
||||
import { LucideIcon, Trash2, Plus, Loader2, ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
|
||||
import { LucideIcon, Trash2, Plus, Loader2, ArrowUpDown, ArrowUp, ArrowDown, Search } from "lucide-react";
|
||||
import { DateRangeSelector } from "@/components/molecules/DateRangeSelector";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { PageLayout } from "@/components/organisms/PageLayout";
|
||||
import { PageHeader } from "@/components/organisms/PageHeader";
|
||||
import { StatCards } from "@/components/organisms/StatCards";
|
||||
@@ -106,10 +107,14 @@ export interface IntegratedListTemplateV2Props<T = any> {
|
||||
dateRangeSelector?: {
|
||||
enabled: boolean;
|
||||
showPresets?: boolean;
|
||||
/** 날짜 입력 숨김 (검색창만 표시하고 싶을 때) */
|
||||
hideDateInputs?: boolean;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
onStartDateChange?: (date: string) => void;
|
||||
onEndDateChange?: (date: string) => void;
|
||||
/** 추가 액션 (검색창 등) - 프리셋 버튼 옆에 배치 */
|
||||
extraActions?: ReactNode;
|
||||
};
|
||||
/**
|
||||
* 등록 버튼 (오른쪽 끝 배치)
|
||||
@@ -237,7 +242,7 @@ export function IntegratedListTemplateV2<T = any>({
|
||||
onSearchChange,
|
||||
searchPlaceholder = "검색...",
|
||||
extraFilters,
|
||||
hideSearch = false,
|
||||
hideSearch = true, // 기본값: 타이틀 아래에 검색창 표시 (Card 안 SearchFilter 숨김)
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
@@ -536,32 +541,71 @@ export function IntegratedListTemplateV2<T = any>({
|
||||
/>
|
||||
|
||||
{/* 헤더 액션 (달력, 버튼 등) - 타이틀 아래 배치 */}
|
||||
{/* 레이아웃: [달력 (왼쪽)] -------------- [등록 버튼 (오른쪽 끝)] */}
|
||||
{(dateRangeSelector?.enabled || createButton || headerActions) && (
|
||||
{/* 레이아웃: [달력] [프리셋버튼] [검색창] -------------- [추가버튼들] [등록버튼] (오른쪽 끝) */}
|
||||
{(dateRangeSelector?.enabled || createButton || headerActions || (hideSearch && onSearchChange)) && (
|
||||
isLoading ? renderHeaderActionSkeleton() : (
|
||||
<div className="flex items-center gap-2 flex-wrap w-full">
|
||||
{/* 날짜 범위 선택기 (왼쪽) */}
|
||||
{dateRangeSelector?.enabled && (
|
||||
<div className="flex flex-col xl:flex-row xl:items-center gap-2 w-full">
|
||||
{/* 날짜 범위 선택기 + 검색창 (왼쪽) */}
|
||||
{dateRangeSelector?.enabled ? (
|
||||
<DateRangeSelector
|
||||
startDate={dateRangeSelector.startDate || ''}
|
||||
endDate={dateRangeSelector.endDate || ''}
|
||||
onStartDateChange={dateRangeSelector.onStartDateChange}
|
||||
onEndDateChange={dateRangeSelector.onEndDateChange}
|
||||
hidePresets={dateRangeSelector.showPresets === false}
|
||||
hideDateInputs={dateRangeSelector.hideDateInputs}
|
||||
extraActions={
|
||||
<>
|
||||
{/* hideSearch=true면 검색창 자동 추가 (extraActions 앞에) */}
|
||||
{hideSearch && onSearchChange && (
|
||||
<div className="relative w-full xl:w-[300px]">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchValue || ''}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-9 w-full bg-gray-50 border-gray-200"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* 기존 extraActions (추가 버튼 등) */}
|
||||
{dateRangeSelector.extraActions}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
/* dateRangeSelector 없어도 hideSearch=true면 검색창 표시 */
|
||||
hideSearch && onSearchChange && (
|
||||
<div className="relative w-full xl:w-[300px]">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchValue || ''}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-9 w-full bg-gray-50 border-gray-200"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{/* 레거시 헤더 액션 (기존 호환성 유지) */}
|
||||
{headerActions}
|
||||
{/* 등록 버튼 (오른쪽 끝) */}
|
||||
{createButton && (
|
||||
<Button className="ml-auto" onClick={createButton.onClick}>
|
||||
{createButton.icon ? (
|
||||
<createButton.icon className="h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{/* 버튼 영역 (오른쪽 끝으로 통합) */}
|
||||
{(headerActions || createButton) && (
|
||||
<div className="flex items-center gap-2 ml-auto shrink-0">
|
||||
{/* 헤더 액션 (엑셀 다운로드 등 추가 버튼들) */}
|
||||
{headerActions}
|
||||
{/* 등록 버튼 */}
|
||||
{createButton && (
|
||||
<Button onClick={createButton.onClick}>
|
||||
{createButton.icon ? (
|
||||
<createButton.icon className="h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{createButton.label}
|
||||
</Button>
|
||||
)}
|
||||
{createButton.label}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -578,7 +578,7 @@ export function UniversalListPage<T>({
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedListTemplateV2<T>
|
||||
<IntegratedListTemplateV2
|
||||
// 페이지 헤더
|
||||
title={config.title}
|
||||
description={config.description}
|
||||
|
||||
@@ -232,10 +232,16 @@ export interface UniversalListConfig<T> {
|
||||
dateRangeSelector?: {
|
||||
enabled: boolean;
|
||||
showPresets?: boolean;
|
||||
/** 날짜 입력 숨김 (검색창만 표시하고 싶을 때) */
|
||||
hideDateInputs?: boolean;
|
||||
/** 프리셋 버튼 위치: 'inline' (날짜 옆), 'below' (별도 줄) */
|
||||
presetsPosition?: 'inline' | 'below';
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
onStartDateChange?: (date: string) => void;
|
||||
onEndDateChange?: (date: string) => void;
|
||||
/** 추가 액션 (검색창 등) - presetsPosition이 'below'일 때 달력 옆에 배치됨 */
|
||||
extraActions?: ReactNode;
|
||||
};
|
||||
/**
|
||||
* 등록 버튼 (오른쪽 끝 배치)
|
||||
|
||||
Reference in New Issue
Block a user