Merge remote-tracking branch 'origin/master'

# Conflicts:
#	src/components/hr/SalaryManagement/index.tsx
#	src/components/production/WorkResults/WorkResultList.tsx
#	tsconfig.tsbuildinfo
This commit is contained in:
2026-01-16 15:47:13 +09:00
91 changed files with 21969 additions and 20128 deletions

View File

@@ -18,16 +18,14 @@ import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { TableRow, TableCell } from '@/components/ui/table';
import {
IntegratedListTemplateV2,
type TableColumn,
UniversalListPage,
type UniversalListConfig,
type StatCard,
type TabOption,
type FilterFieldConfig,
type FilterValues,
} from '@/components/templates/IntegratedListTemplateV2';
} from '@/components/templates/UniversalListPage';
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
import { SalaryDetailDialog } from './SalaryDetailDialog';
import {
@@ -243,6 +241,12 @@ export function SalaryManagement() {
}
}, [selectedSalaryId, loadSalaries]);
// ===== 지급항목 추가 핸들러 =====
const handleAddPaymentItem = useCallback(() => {
// TODO: 지급항목 추가 다이얼로그 또는 로직 구현
toast.info('지급항목 추가 기능은 준비 중입니다.');
}, []);
// ===== 탭 (단일 탭) =====
const [activeTab, setActiveTab] = useState('all');
const tabs: TabOption[] = useMemo(() => [
@@ -299,7 +303,7 @@ export function SalaryManagement() {
}, [salaryData]);
// ===== 테이블 컬럼 (부서, 직책, 이름, 직급, 기본급, 수당, 초과근무, 상여, 공제, 실지급액, 일자, 상태, 작업) =====
const tableColumns: TableColumn[] = useMemo(() => [
const tableColumns = useMemo(() => [
{ key: 'department', label: '부서' },
{ key: 'position', label: '직책' },
{ key: 'name', label: '이름' },
@@ -315,151 +319,6 @@ export function SalaryManagement() {
{ key: 'action', label: '작업', className: 'text-center w-[80px]' },
], []);
// ===== 테이블 행 렌더링 =====
const renderTableRow = useCallback((item: SalaryRecord, index: number, globalIndex: number) => {
const isSelected = selectedItems.has(item.id);
return (
<TableRow key={item.id} className="hover:bg-muted/50">
<TableCell className="text-center">
<Checkbox checked={isSelected} onCheckedChange={() => toggleSelection(item.id)} />
</TableCell>
<TableCell>{item.department}</TableCell>
<TableCell>{item.position}</TableCell>
<TableCell className="font-medium">{item.employeeName}</TableCell>
<TableCell>{item.rank}</TableCell>
<TableCell className="text-right">{formatCurrency(item.baseSalary)}</TableCell>
<TableCell className="text-right">{formatCurrency(item.allowance)}</TableCell>
<TableCell className="text-right">{formatCurrency(item.overtime)}</TableCell>
<TableCell className="text-right">{formatCurrency(item.bonus)}</TableCell>
<TableCell className="text-right text-red-600">-{formatCurrency(item.deduction)}</TableCell>
<TableCell className="text-right font-medium text-green-600">{formatCurrency(item.netPayment)}</TableCell>
<TableCell className="text-center">{item.paymentDate}</TableCell>
<TableCell className="text-center">
<Badge className={PAYMENT_STATUS_COLORS[item.status]}>
{PAYMENT_STATUS_LABELS[item.status]}
</Badge>
</TableCell>
<TableCell className="text-center">
<Button
variant="ghost"
size="sm"
onClick={() => handleViewDetail(item)}
title="수정"
disabled={isActionLoading}
>
<Pencil className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
);
}, [selectedItems, toggleSelection, handleViewDetail, isActionLoading]);
// ===== 모바일 카드 렌더링 =====
const renderMobileCard = useCallback((
item: SalaryRecord,
index: number,
globalIndex: number,
isSelected: boolean,
onToggle: () => void
) => {
return (
<ListMobileCard
id={item.id}
title={item.employeeName}
headerBadges={
<Badge className={PAYMENT_STATUS_COLORS[item.status]}>
{PAYMENT_STATUS_LABELS[item.status]}
</Badge>
}
isSelected={isSelected}
onToggleSelection={onToggle}
infoGrid={
<div className="grid grid-cols-2 gap-3">
<InfoField label="부서" value={item.department} />
<InfoField label="직급" value={item.rank} />
<InfoField label="기본급" value={`${formatCurrency(item.baseSalary)}`} />
<InfoField label="수당" value={`${formatCurrency(item.allowance)}`} />
<InfoField label="초과근무" value={`${formatCurrency(item.overtime)}`} />
<InfoField label="상여" value={`${formatCurrency(item.bonus)}`} />
<InfoField label="공제" value={`-${formatCurrency(item.deduction)}`} />
<InfoField label="실지급액" value={`${formatCurrency(item.netPayment)}`} />
<InfoField label="지급일" value={item.paymentDate} />
</div>
}
actions={
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => handleViewDetail(item)}
disabled={isActionLoading}
>
<Pencil className="h-4 w-4 mr-2" />
</Button>
}
/>
);
}, [handleViewDetail, isActionLoading]);
// ===== 헤더 액션 =====
const headerActions = (
<div className="flex items-center gap-2 flex-wrap">
{/* 날짜 범위 선택 */}
<div className="flex items-center gap-1">
<Input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-[140px]"
/>
<span className="text-muted-foreground">~</span>
<Input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-[140px]"
/>
</div>
{/* 지급완료/지급예정 버튼 - 선택된 항목이 있을 때만 표시 */}
{selectedItems.size > 0 && (
<>
<Button
variant="default"
onClick={handleMarkCompleted}
disabled={isActionLoading}
>
{isActionLoading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Check className="h-4 w-4 mr-2" />
)}
</Button>
<Button
variant="outline"
onClick={handleMarkScheduled}
disabled={isActionLoading}
>
{isActionLoading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Clock className="h-4 w-4 mr-2" />
)}
</Button>
</>
)}
<Button variant="outline" onClick={() => toast.info('엑셀 다운로드 기능은 준비 중입니다.')}>
<Download className="h-4 w-4 mr-2" />
</Button>
</div>
);
// ===== filterConfig 기반 통합 필터 시스템 =====
const filterConfig: FilterFieldConfig[] = useMemo(() => [
{
@@ -491,52 +350,215 @@ export function SalaryManagement() {
setCurrentPage(1);
}, []);
return (
<>
<IntegratedListTemplateV2
title="급여관리"
description="직원들의 급여 현황을 관리합니다"
icon={DollarSign}
headerActions={headerActions}
stats={statCards}
searchValue={searchQuery}
onSearchChange={setSearchQuery}
searchPlaceholder="이름, 부서 검색..."
filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="급여 필터"
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
tableColumns={tableColumns}
data={salaryData}
totalCount={totalCount}
allData={salaryData}
selectedItems={selectedItems}
onToggleSelection={toggleSelection}
onToggleSelectAll={toggleSelectAll}
getItemId={(item: SalaryRecord) => item.id}
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
isLoading={isLoading}
pagination={{
currentPage,
totalPages,
totalItems: totalCount,
itemsPerPage,
onPageChange: setCurrentPage,
}}
/>
// ===== UniversalListPage 설정 =====
const salaryConfig: UniversalListConfig<SalaryRecord> = useMemo(() => ({
title: '급여관리',
description: '직원들의 급여 현황을 관리합니다',
icon: DollarSign,
basePath: '/hr/salary-management',
{/* 급여 상세 다이얼로그 */}
idField: 'id',
actions: {
getList: async () => ({
success: true,
data: salaryData,
totalCount: totalCount,
totalPages: totalPages,
}),
},
columns: tableColumns,
filterConfig: filterConfig,
initialFilters: filterValues,
filterTitle: '급여 필터',
computeStats: () => statCards,
searchPlaceholder: '이름, 부서 검색...',
itemsPerPage: itemsPerPage,
// 날짜 범위 선택 (DateRangeSelector 사용)
dateRangeSelector: {
enabled: true,
showPresets: false,
startDate,
endDate,
onStartDateChange: setStartDate,
onEndDateChange: setEndDate,
},
headerActions: ({ selectedItems: selected }) => (
<div className="flex items-center gap-2 flex-wrap">
{/* 지급완료/지급예정 버튼 - 선택된 항목이 있을 때만 표시 */}
{selected.size > 0 && (
<>
<Button
variant="default"
onClick={handleMarkCompleted}
disabled={isActionLoading}
>
{isActionLoading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Check className="h-4 w-4 mr-2" />
)}
</Button>
<Button
variant="outline"
onClick={handleMarkScheduled}
disabled={isActionLoading}
>
{isActionLoading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Clock className="h-4 w-4 mr-2" />
)}
</Button>
</>
)}
<Button variant="outline" onClick={() => toast.info('엑셀 다운로드 기능은 준비 중입니다.')}>
<Download className="h-4 w-4 mr-2" />
</Button>
</div>
),
renderTableRow: (item, index, globalIndex, handlers) => {
const { isSelected, onToggle } = handlers;
return (
<TableRow key={item.id} className="hover:bg-muted/50">
<TableCell className="text-center">
<Checkbox checked={isSelected} onCheckedChange={onToggle} />
</TableCell>
<TableCell>{item.department}</TableCell>
<TableCell>{item.position}</TableCell>
<TableCell className="font-medium">{item.employeeName}</TableCell>
<TableCell>{item.rank}</TableCell>
<TableCell className="text-right">{formatCurrency(item.baseSalary)}</TableCell>
<TableCell className="text-right">{formatCurrency(item.allowance)}</TableCell>
<TableCell className="text-right">{formatCurrency(item.overtime)}</TableCell>
<TableCell className="text-right">{formatCurrency(item.bonus)}</TableCell>
<TableCell className="text-right text-red-600">-{formatCurrency(item.deduction)}</TableCell>
<TableCell className="text-right font-medium text-green-600">{formatCurrency(item.netPayment)}</TableCell>
<TableCell className="text-center">{item.paymentDate}</TableCell>
<TableCell className="text-center">
<Badge className={PAYMENT_STATUS_COLORS[item.status]}>
{PAYMENT_STATUS_LABELS[item.status]}
</Badge>
</TableCell>
<TableCell className="text-center">
<Button
variant="ghost"
size="sm"
onClick={() => handleViewDetail(item)}
title="수정"
disabled={isActionLoading}
>
<Pencil className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
);
},
renderMobileCard: (item, index, globalIndex, handlers) => {
const { isSelected, onToggle } = handlers;
return (
<ListMobileCard
id={item.id}
title={item.employeeName}
headerBadges={
<Badge className={PAYMENT_STATUS_COLORS[item.status]}>
{PAYMENT_STATUS_LABELS[item.status]}
</Badge>
}
isSelected={isSelected}
onToggleSelection={onToggle}
infoGrid={
<div className="grid grid-cols-2 gap-3">
<InfoField label="부서" value={item.department} />
<InfoField label="직급" value={item.rank} />
<InfoField label="기본급" value={`${formatCurrency(item.baseSalary)}`} />
<InfoField label="수당" value={`${formatCurrency(item.allowance)}`} />
<InfoField label="초과근무" value={`${formatCurrency(item.overtime)}`} />
<InfoField label="상여" value={`${formatCurrency(item.bonus)}`} />
<InfoField label="공제" value={`-${formatCurrency(item.deduction)}`} />
<InfoField label="실지급액" value={`${formatCurrency(item.netPayment)}`} />
<InfoField label="지급일" value={item.paymentDate} />
</div>
}
actions={
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => handleViewDetail(item)}
disabled={isActionLoading}
>
<Pencil className="h-4 w-4 mr-2" />
</Button>
}
/>
);
},
renderDialogs: () => (
<SalaryDetailDialog
open={detailDialogOpen}
onOpenChange={setDetailDialogOpen}
salaryDetail={selectedSalaryDetail}
onSave={handleSaveDetail}
onAddPaymentItem={handleAddPaymentItem}
/>
</>
),
}), [
salaryData,
totalCount,
totalPages,
tableColumns,
filterConfig,
filterValues,
statCards,
startDate,
endDate,
handleMarkCompleted,
handleMarkScheduled,
isActionLoading,
handleViewDetail,
detailDialogOpen,
selectedSalaryDetail,
handleSaveDetail,
handleAddPaymentItem,
]);
return (
<UniversalListPage<SalaryRecord>
config={salaryConfig}
initialData={salaryData}
initialTotalCount={totalCount}
externalPagination={{
currentPage,
totalPages,
totalItems: totalCount,
itemsPerPage,
onPageChange: setCurrentPage,
}}
externalSelection={{
selectedItems,
onToggleSelection: toggleSelection,
onToggleSelectAll: toggleSelectAll,
getItemId: (item) => item.id,
}}
onSearchChange={setSearchQuery}
/>
);
}