feat: 급여관리 개선 + 설비관리 신규 + 팝업관리/카드관리/가격표 개선
- 급여관리: 상세/등록 다이얼로그 리팩토링, actions/types 확장 - 설비관리: 설비현황/점검/수리 4개 페이지 신규 추가 - 팝업관리: PopupDetail/PopupForm 개선 - 카드관리: CardForm 개선 - IntegratedListTemplateV2, SearchFilter, useColumnSettings 개선 - CLAUDE.md: 페이지 모드 라우팅 패턴 규칙 추가 - 공통 페이지 패턴 가이드 확장
This commit is contained in:
85
CLAUDE.md
85
CLAUDE.md
@@ -271,6 +271,89 @@ function buildCoverageMap(items, spanKey) {
|
||||
|
||||
---
|
||||
|
||||
## 페이지 모드 라우팅 패턴 (mode=new/edit/view)
|
||||
**Priority**: 🔴
|
||||
|
||||
### 라우팅 규칙
|
||||
- **별도 `/new` 경로 금지** → `?mode=new` 쿼리파라미터 사용
|
||||
- **별도 `/edit` 경로 금지** → `?mode=edit` 쿼리파라미터 사용
|
||||
- 목록과 등록/수정을 **같은 page.tsx에서 분기**
|
||||
|
||||
```typescript
|
||||
// ✅ 올바른 패턴: page.tsx에서 mode 분기
|
||||
export default function SomePage() {
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
if (mode === 'new') return <SomeForm />;
|
||||
return <SomeList />;
|
||||
}
|
||||
|
||||
// ✅ 상세+수정: [id] 경로에서 mode 분기
|
||||
export default function SomeDetailPage() {
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
|
||||
|
||||
return <SomeDetail id={params.id} mode={mode} />;
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ❌ 금지 패턴
|
||||
router.push('/some-page/new') // → router.push('/some-page?mode=new')
|
||||
router.push('/some-page/123/edit') // → router.push('/some-page/123?mode=edit')
|
||||
```
|
||||
|
||||
### 등록/수정/상세 페이지 헤더
|
||||
| 위치 | 요소 |
|
||||
|------|------|
|
||||
| 상단 좌측 | 페이지 제목 (`<h1>`) |
|
||||
| 상단 우측 | `← 목록으로` 링크 (`Button variant="link"`) |
|
||||
|
||||
```typescript
|
||||
// ✅ 표준 헤더
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold">페이지 제목</h1>
|
||||
<Button variant="link" className="text-muted-foreground"
|
||||
onClick={() => router.push(listPath)}>
|
||||
← 목록으로
|
||||
</Button>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 하단 Sticky 액션 바 (필수)
|
||||
폼 콘텐츠 아래에 **sticky bottom bar**로 버튼 배치. 취소는 좌측, 주요 액션은 우측.
|
||||
|
||||
| 모드 | 좌측 | 우측 |
|
||||
|------|------|------|
|
||||
| 등록 (new) | `X 취소` | `💾 저장` |
|
||||
| 상세 (view) | `X 취소` (목록으로) | `✏️ 수정` |
|
||||
| 수정 (edit) | `X 취소` | `💾 저장` |
|
||||
|
||||
```typescript
|
||||
// ✅ 표준 하단 Sticky 액션 바
|
||||
<div className="sticky bottom-0 bg-white border-t shadow-sm">
|
||||
<div className="px-3 py-3 md:px-6 md:py-4 flex items-center justify-between">
|
||||
<Button variant="outline" onClick={() => router.push(listPath)}>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||
{isSubmitting ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Save className="h-4 w-4 mr-1" />}
|
||||
{isNewMode ? '저장' : '저장'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**규칙:**
|
||||
- Card 내부에 버튼 넣지 않음 → sticky 하단 바 사용
|
||||
- 아이콘 포함: 취소(`X`), 저장(`Save`), 수정(`Pencil`)
|
||||
- 상세(view) 모드에서 "취소"는 목록으로 이동, "수정"은 `?mode=edit` 전환
|
||||
|
||||
---
|
||||
|
||||
## Design Popup Policy
|
||||
**Priority**: 🟡
|
||||
|
||||
@@ -448,6 +531,7 @@ url: `${API_URL}/api/v1/items?${params.toString()}`
|
||||
|-----------|----------|
|
||||
| 검색 모달/선택 팝업 | `claudedocs/guides/[GUIDE] common-page-patterns.md` → "검색 모달" 섹션 |
|
||||
| 리스트/목록 페이지 | `claudedocs/guides/[GUIDE] common-page-patterns.md` → "리스트 페이지" 섹션 |
|
||||
| **IntegratedListTemplateV2 적용/리팩토링** | `claudedocs/guides/[GUIDE] common-page-patterns.md` → **"IntegratedListTemplateV2 표준 적용"** 섹션 |
|
||||
| 상세/수정/등록 페이지 | `claudedocs/guides/[GUIDE] common-page-patterns.md` → "상세/폼 페이지" 섹션 |
|
||||
| 새 organisms 필요 | `src/components/organisms/index.ts` 먼저 확인 → 없으면 생성 |
|
||||
|
||||
@@ -455,6 +539,7 @@ url: `${API_URL}/api/v1/items?${params.toString()}`
|
||||
- 새 파일 만들기 전 `organisms/`, `molecules/` export 목록 확인
|
||||
- 검색+선택 모달 → `SearchableSelectionModal<T>` 사용 (직접 Dialog 조합 금지)
|
||||
- 리스트 페이지 → `UniversalListPage` 또는 organisms 조합
|
||||
- **IntegratedListTemplateV2 사용 시 → 컬럼 설정(`useColumnSettings` + `ColumnSettingsPopover`), 모바일 카드(`renderMobileCard`), 체크박스(`Set<string>`), 테이블 내 필터(`tableHeaderActions`) 필수 적용**
|
||||
- 상세/폼 → Card + 기존 패턴 따르기
|
||||
|
||||
---
|
||||
|
||||
@@ -136,10 +136,18 @@ http://localhost:3000/ko/material/stock-status # 🆕 재고현황
|
||||
|--------|-----|------|
|
||||
| **검사관리** | `/ko/quality/inspections` | 🆕 NEW |
|
||||
| **실적신고관리** | `/ko/quality/performance-reports` | 🆕 NEW |
|
||||
| **설비 등록대장** | `/ko/quality/equipment` | 🆕 NEW |
|
||||
| **설비 현황** | `/ko/quality/equipment-status` | 🆕 NEW |
|
||||
| **일상점검표** | `/ko/quality/equipment-inspections` | 🆕 NEW |
|
||||
| **수리이력** | `/ko/quality/equipment-repairs` | 🆕 NEW |
|
||||
|
||||
```
|
||||
http://localhost:3000/ko/quality/inspections # 🆕 검사관리
|
||||
http://localhost:3000/ko/quality/performance-reports # 🆕 실적신고관리
|
||||
http://localhost:3000/ko/quality/equipment # 🆕 설비 등록대장
|
||||
http://localhost:3000/ko/quality/equipment-status # 🆕 설비 현황
|
||||
http://localhost:3000/ko/quality/equipment-inspections # 🆕 일상점검표
|
||||
http://localhost:3000/ko/quality/equipment-repairs # 🆕 수리이력
|
||||
```
|
||||
|
||||
---
|
||||
@@ -519,6 +527,10 @@ http://localhost:3000/ko/dev/editable-table # Editable Table 테스트
|
||||
// Quality (품질관리)
|
||||
'/quality/inspections' // 검사관리 (🆕 NEW)
|
||||
'/quality/performance-reports' // 실적신고관리 (🆕 NEW)
|
||||
'/quality/equipment' // 설비 등록대장 (🆕 NEW)
|
||||
'/quality/equipment-status' // 설비 현황 (🆕 NEW)
|
||||
'/quality/equipment-inspections' // 일상점검표 (🆕 NEW)
|
||||
'/quality/equipment-repairs' // 수리이력 (🆕 NEW)
|
||||
|
||||
// Outbound (출고관리)
|
||||
'/outbound/shipments' // 출하관리 (🆕 NEW)
|
||||
|
||||
@@ -9,9 +9,10 @@
|
||||
1. [공통 컴포넌트 맵](#1-공통-컴포넌트-맵)
|
||||
2. [검색 모달 (SearchableSelectionModal)](#2-검색-모달)
|
||||
3. [리스트 페이지](#3-리스트-페이지)
|
||||
4. [상세/폼 페이지](#4-상세폼-페이지)
|
||||
5. [API 연동 패턴](#5-api-연동-패턴)
|
||||
6. [페이지 라우팅 구조](#6-페이지-라우팅-구조)
|
||||
4. [IntegratedListTemplateV2 표준 적용](#4-integratedlisttemplatev2-표준-적용)
|
||||
5. [상세/폼 페이지](#5-상세폼-페이지)
|
||||
6. [API 연동 패턴](#6-api-연동-패턴)
|
||||
7. [페이지 라우팅 구조](#7-페이지-라우팅-구조)
|
||||
|
||||
---
|
||||
|
||||
@@ -304,7 +305,320 @@ export function MyList() {
|
||||
|
||||
---
|
||||
|
||||
## 4. 상세/폼 페이지
|
||||
## 4. IntegratedListTemplateV2 표준 적용
|
||||
|
||||
### 개요
|
||||
|
||||
`IntegratedListTemplateV2`는 프로젝트의 표준 리스트 페이지 템플릿으로, 아래 기능을 한 번에 제공한다:
|
||||
- PageLayout + PageHeader (아이콘/제목/설명)
|
||||
- 날짜/검색/버튼 헤더 영역
|
||||
- 통계 카드
|
||||
- 테이블 (체크박스/번호/데이터/작업) + 페이지네이션
|
||||
- 모바일 카드 뷰 자동 전환
|
||||
- 컬럼 설정 (표시/숨기기/리사이즈)
|
||||
|
||||
**위치**: `src/components/templates/IntegratedListTemplateV2.tsx`
|
||||
|
||||
### 🔴 적용 시 필수 체크리스트
|
||||
|
||||
IntegratedListTemplateV2를 사용하는 페이지를 만들거나 리팩토링할 때, **아래 항목을 반드시 확인**한다.
|
||||
|
||||
| # | 항목 | 설명 | 필수 |
|
||||
|---|------|------|:----:|
|
||||
| 1 | **컬럼 설정** | `useColumnSettings` + `ColumnSettingsPopover` + `columnSettings` prop | ✅ |
|
||||
| 2 | **검색** | `searchValue` + `onSearchChange` + `searchPlaceholder` | ✅ |
|
||||
| 3 | **체크박스 선택** | `selectedItems (Set<string>)` + `onToggleSelection` + `onToggleSelectAll` + `getItemId` | ✅ |
|
||||
| 4 | **페이지네이션** | `pagination` (currentPage, totalPages, totalItems, itemsPerPage, onPageChange) | ✅ |
|
||||
| 5 | **모바일 카드** | `renderMobileCard` + `MobileCard` / `InfoField` 사용 | ✅ |
|
||||
| 6 | **테이블 행** | `renderTableRow` (TableRow + TableCell 조합) | ✅ |
|
||||
| 7 | **헤더 레이아웃** | 순서: `[검색] [날짜/연월] --- [액션버튼] [등록버튼]` | ✅ |
|
||||
| 8 | **통계 카드** | `stats` 배열 (label, value, icon, iconColor) | 권장 |
|
||||
| 9 | **테이블 내 필터** | `tableHeaderActions` (부서, 상태 등 Select) | 필요 시 |
|
||||
| 10 | **탭** | `tabsContent` (커스텀) 또는 `tabs` + `activeTab` + `onTabChange` | 필요 시 |
|
||||
|
||||
### 컬럼 설정 (필수 패턴)
|
||||
|
||||
**매번 빠뜨리지 않도록 3가지 세트로 기억한다:**
|
||||
|
||||
```typescript
|
||||
// 1️⃣ Hook 선언
|
||||
import { useColumnSettings } from '@/hooks/useColumnSettings';
|
||||
import { ColumnSettingsPopover } from '@/components/molecules/ColumnSettingsPopover';
|
||||
|
||||
const TABLE_COLUMNS: TableColumn[] = [
|
||||
{ key: 'no', label: '번호', className: 'text-center w-[60px]' },
|
||||
{ key: 'name', label: '이름', copyable: true },
|
||||
// ...
|
||||
{ key: 'actions', label: '작업', className: 'text-center w-[80px]' },
|
||||
];
|
||||
|
||||
const {
|
||||
visibleColumns, // → tableColumns prop에 전달
|
||||
allColumnsWithVisibility, // → ColumnSettingsPopover에 전달
|
||||
columnWidths, // → columnSettings.columnWidths
|
||||
setColumnWidth, // → columnSettings.onColumnResize
|
||||
toggleColumnVisibility, // → ColumnSettingsPopover.onToggle
|
||||
resetSettings, // → ColumnSettingsPopover.onReset
|
||||
hasHiddenColumns, // → ColumnSettingsPopover.hasHiddenColumns
|
||||
} = useColumnSettings({
|
||||
pageId: 'my-page-id', // Zustand 저장 키 (고유값)
|
||||
columns: TABLE_COLUMNS,
|
||||
alwaysVisibleKeys: ['no', 'name', 'actions'], // 숨기기 불가 컬럼
|
||||
});
|
||||
```
|
||||
|
||||
```tsx
|
||||
// 2️⃣ 템플릿에 전달
|
||||
<IntegratedListTemplateV2
|
||||
tableColumns={visibleColumns} // ← TABLE_COLUMNS 아닌 visibleColumns!
|
||||
columnSettings={{
|
||||
columnWidths,
|
||||
onColumnResize: setColumnWidth,
|
||||
settingsPopover: (
|
||||
<ColumnSettingsPopover
|
||||
columns={allColumnsWithVisibility}
|
||||
onToggle={toggleColumnVisibility}
|
||||
onReset={resetSettings}
|
||||
hasHiddenColumns={hasHiddenColumns}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
// ...
|
||||
/>
|
||||
```
|
||||
|
||||
### 헤더 레이아웃 순서
|
||||
|
||||
표준 레이아웃은 아래 순서를 따른다:
|
||||
|
||||
```
|
||||
[아이콘] 페이지 제목
|
||||
설명 텍스트
|
||||
|
||||
[검색창] [날짜/연월 셀렉트] --- [액션버튼들] [+ 등록 버튼]
|
||||
|
||||
[탭: 목록 | 설정] (tabsContent, 필요 시)
|
||||
|
||||
[통계카드 ...] (stats)
|
||||
|
||||
[전체 N건 | N개 선택됨] [부서 필터] [상태 필터] [컬럼 설정] (tableHeaderActions)
|
||||
[테이블]
|
||||
[페이지네이션]
|
||||
```
|
||||
|
||||
**날짜 대신 연월 셀렉트가 필요한 경우:**
|
||||
```typescript
|
||||
dateRangeSelector={{
|
||||
enabled: true,
|
||||
hideDateInputs: true, // 날짜 입력 숨김
|
||||
showPresets: false, // 프리셋 버튼 숨김
|
||||
extraActions: ( // 대신 연월 셀렉트 배치
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={String(year)} onValueChange={...}>...</Select>
|
||||
<Select value={String(month)} onValueChange={...}>...</Select>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
```
|
||||
|
||||
### 테이블 내 필터 (tableHeaderActions)
|
||||
|
||||
테이블 카드 내부 "전체 N건" 오른쪽에 필터 셀렉트를 배치한다:
|
||||
|
||||
```tsx
|
||||
const tableHeaderActionsNode = (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={filterDepartment} onValueChange={setFilterDepartment}>
|
||||
<SelectTrigger className="min-w-[120px] w-auto">
|
||||
<SelectValue placeholder="전체 부서" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체 부서</SelectItem>
|
||||
{departments.map(d => <SelectItem key={d} value={d}>{d}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={filterStatus} onValueChange={handleStatusChange}>
|
||||
<SelectTrigger className="min-w-[120px] w-auto">
|
||||
<SelectValue placeholder="전체 상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체 상태</SelectItem>
|
||||
<SelectItem value="draft">작성중</SelectItem>
|
||||
<SelectItem value="confirmed">확정</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 사용
|
||||
<IntegratedListTemplateV2
|
||||
tableHeaderActions={tableHeaderActionsNode}
|
||||
// ...
|
||||
/>
|
||||
```
|
||||
|
||||
### 모바일 카드 (renderMobileCard)
|
||||
|
||||
```tsx
|
||||
import { MobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
|
||||
const renderMobileCard = useCallback((
|
||||
item: MyItem,
|
||||
_index: number,
|
||||
_globalIndex: number,
|
||||
isSelected: boolean,
|
||||
onToggle: () => void,
|
||||
) => (
|
||||
<MobileCard
|
||||
key={item.id}
|
||||
title={item.name}
|
||||
subtitle={item.department || '-'}
|
||||
headerBadges={[
|
||||
{ text: STATUS_LABELS[item.status], variant: STATUS_VARIANTS[item.status] },
|
||||
]}
|
||||
infoGrid={[
|
||||
<InfoField key="amount" label="금액" value={formatCurrency(item.amount)} />,
|
||||
<InfoField key="date" label="날짜" value={item.date} />,
|
||||
]}
|
||||
isSelected={isSelected}
|
||||
onToggleSelection={onToggle}
|
||||
onClick={() => handleDetailOpen(item.id)}
|
||||
/>
|
||||
), []);
|
||||
```
|
||||
|
||||
### 체크박스 선택 (Set\<string\>)
|
||||
|
||||
IntegratedListTemplateV2는 **문자열 ID** (`Set<string>`)를 요구한다:
|
||||
|
||||
```typescript
|
||||
// ✅ 올바른 패턴
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleSelection = useCallback((id: string) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleSelectAll = useCallback(() => {
|
||||
setSelectedIds(prev =>
|
||||
prev.size === data.length
|
||||
? new Set()
|
||||
: new Set(data.map(item => String(item.id)))
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
// 사용
|
||||
<IntegratedListTemplateV2
|
||||
selectedItems={selectedIds}
|
||||
onToggleSelection={toggleSelection}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
getItemId={(item) => String(item.id)}
|
||||
/>
|
||||
```
|
||||
|
||||
### 전체 스켈레톤 예제
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { IntegratedListTemplateV2, type TableColumn } from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { useColumnSettings } from '@/hooks/useColumnSettings';
|
||||
import { ColumnSettingsPopover } from '@/components/molecules/ColumnSettingsPopover';
|
||||
import { MobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
import { TableRow, TableCell } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { MyIcon } from 'lucide-react';
|
||||
|
||||
const TABLE_COLUMNS: TableColumn[] = [
|
||||
{ key: 'no', label: '번호', className: 'text-center w-[60px]' },
|
||||
{ key: 'name', label: '이름', copyable: true },
|
||||
{ key: 'status', label: '상태', className: 'text-center w-[80px]' },
|
||||
{ key: 'actions', label: '작업', className: 'text-center w-[80px]' },
|
||||
];
|
||||
|
||||
export function MyListPage() {
|
||||
// 컬럼 설정 (필수)
|
||||
const {
|
||||
visibleColumns, allColumnsWithVisibility, columnWidths,
|
||||
setColumnWidth, toggleColumnVisibility, resetSettings, hasHiddenColumns,
|
||||
} = useColumnSettings({
|
||||
pageId: 'my-page',
|
||||
columns: TABLE_COLUMNS,
|
||||
alwaysVisibleKeys: ['no', 'name', 'actions'],
|
||||
});
|
||||
|
||||
// 선택 (Set<string>)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
// ... toggleSelection, toggleSelectAll 구현
|
||||
|
||||
return (
|
||||
<IntegratedListTemplateV2<MyItem>
|
||||
// 헤더
|
||||
title="페이지 제목"
|
||||
description="설명"
|
||||
icon={MyIcon}
|
||||
|
||||
// 헤더 액션
|
||||
headerActions={<Button>액션</Button>}
|
||||
createButton={{ label: '등록', onClick: handleCreate }}
|
||||
|
||||
// 검색
|
||||
searchValue={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="검색..."
|
||||
|
||||
// 통계
|
||||
stats={[{ label: '전체', value: totalCount, icon: Users, iconColor: 'text-blue-600' }]}
|
||||
|
||||
// 테이블 필터
|
||||
tableHeaderActions={filterNode}
|
||||
|
||||
// 테이블 + 컬럼 설정
|
||||
tableColumns={visibleColumns}
|
||||
columnSettings={{
|
||||
columnWidths,
|
||||
onColumnResize: setColumnWidth,
|
||||
settingsPopover: (
|
||||
<ColumnSettingsPopover
|
||||
columns={allColumnsWithVisibility}
|
||||
onToggle={toggleColumnVisibility}
|
||||
onReset={resetSettings}
|
||||
hasHiddenColumns={hasHiddenColumns}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
|
||||
// 데이터
|
||||
data={items}
|
||||
selectedItems={selectedIds}
|
||||
onToggleSelection={toggleSelection}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
getItemId={(item) => String(item.id)}
|
||||
|
||||
// 렌더링
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
|
||||
// 페이지네이션
|
||||
pagination={{
|
||||
currentPage, totalPages, totalItems: totalCount,
|
||||
itemsPerPage: PAGE_SIZE, onPageChange: setCurrentPage,
|
||||
}}
|
||||
|
||||
// 로딩
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 상세/폼 페이지
|
||||
|
||||
### 표준 구조
|
||||
|
||||
@@ -405,18 +719,37 @@ export function MyDetail({ id, mode }: DetailProps) {
|
||||
### 상세/폼 페이지 공통 규칙
|
||||
|
||||
- **모드**: `view` | `edit` | `new` 3가지
|
||||
- **라우팅**: `?mode=new` / `?mode=edit` 쿼리파라미터 사용 (별도 `/new`, `/edit` 경로 금지)
|
||||
- **page.tsx 분기**: 목록 page.tsx에서 `searchParams.get('mode')` 로 등록 폼 분기
|
||||
- **Hook 규칙**: 모든 hook은 최상단, 조건부 return은 그 아래
|
||||
- **레이아웃**: `Card > CardHeader + CardContent` 섹션 단위
|
||||
- **필드 그리드**: `grid grid-cols-1 md:grid-cols-2 gap-4`
|
||||
- **disabled**: view 모드에서 모든 입력 비활성화
|
||||
- **알림**: `toast.success()` / `toast.error()` (sonner)
|
||||
- **네비게이션**: `router.back()` 또는 `router.push()`
|
||||
- **로딩**: Skeleton 컴포넌트 사용
|
||||
- **Select 버그 대응**: `<Select key={...}>` 패턴 (CLAUDE.md 참조)
|
||||
|
||||
#### 헤더 배치 표준
|
||||
| 위치 | 요소 |
|
||||
|------|------|
|
||||
| 상단 좌측 | 페이지 제목 (`<h1>`) |
|
||||
| 상단 우측 | `← 목록으로` (Button variant="link") |
|
||||
|
||||
#### 하단 Sticky 액션 바
|
||||
Card 내부가 아닌 **sticky bottom bar**로 버튼 배치. 취소 좌측, 주요 액션 우측.
|
||||
|
||||
| 모드 | 좌측 | 우측 |
|
||||
|------|------|------|
|
||||
| 등록 (new) | `X 취소` | `💾 저장` |
|
||||
| 상세 (view) | `X 취소` (목록으로) | `✏️ 수정` |
|
||||
| 수정 (edit) | `X 취소` | `💾 저장` |
|
||||
|
||||
- 아이콘 포함: 취소(`X`), 저장(`Save`), 수정(`Pencil`)
|
||||
- 상세(view) "취소"는 목록 이동, "수정"은 `?mode=edit` 전환
|
||||
|
||||
---
|
||||
|
||||
## 5. API 연동 패턴
|
||||
## 6. API 연동 패턴
|
||||
|
||||
### Server Action 파일 구조
|
||||
|
||||
@@ -478,7 +811,7 @@ const handleFetchData = useCallback(async (query: string) => {
|
||||
|
||||
---
|
||||
|
||||
## 6. 페이지 라우팅 구조
|
||||
## 7. 페이지 라우팅 구조
|
||||
|
||||
```
|
||||
src/app/[locale]/(protected)/[domain]/
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"next": "^15.5.9",
|
||||
"next-intl": "^4.4.0",
|
||||
"puppeteer": "^24.37.2",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.3",
|
||||
"react-day-picker": "^9.11.1",
|
||||
"react-dom": "^19.2.3",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 일상점검표 - 그리드 매트릭스
|
||||
* URL: /quality/equipment-inspections
|
||||
*/
|
||||
|
||||
import { EquipmentInspectionGrid } from '@/components/quality/EquipmentInspection';
|
||||
|
||||
export default function EquipmentInspectionsPage() {
|
||||
return <EquipmentInspectionGrid />;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 레거시 리다이렉트: /quality/equipment-repairs/new → ?mode=new
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function RepairNewRedirect() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
router.replace('/quality/equipment-repairs?mode=new');
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 수리이력 목록/등록
|
||||
* URL: /quality/equipment-repairs
|
||||
* URL: /quality/equipment-repairs?mode=new (등록)
|
||||
*/
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { RepairList } from '@/components/quality/EquipmentRepair';
|
||||
import { RepairForm } from '@/components/quality/EquipmentRepair/RepairForm';
|
||||
|
||||
export default function EquipmentRepairsPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
if (mode === 'new') {
|
||||
return <RepairForm />;
|
||||
}
|
||||
|
||||
return <RepairList />;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 설비 현황 대시보드
|
||||
* URL: /quality/equipment-status
|
||||
*/
|
||||
|
||||
import { EquipmentStatusDashboard } from '@/components/quality/EquipmentStatus';
|
||||
|
||||
export default function EquipmentStatusPage() {
|
||||
return <EquipmentStatusDashboard />;
|
||||
}
|
||||
19
src/app/[locale]/(protected)/quality/equipment/[id]/page.tsx
Normal file
19
src/app/[locale]/(protected)/quality/equipment/[id]/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 설비 상세/수정 페이지
|
||||
* URL: /quality/equipment/[id]
|
||||
* 수정 모드: /quality/equipment/[id]?mode=edit
|
||||
*/
|
||||
|
||||
import { use } from 'react';
|
||||
import { EquipmentDetail } from '@/components/quality/EquipmentManagement/EquipmentDetail';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default function EquipmentDetailPage({ params }: Props) {
|
||||
const { id } = use(params);
|
||||
return <EquipmentDetail id={id} />;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 설비 엑셀 Import 페이지
|
||||
* URL: /quality/equipment/import
|
||||
*/
|
||||
|
||||
import { EquipmentImport } from '@/components/quality/EquipmentManagement/EquipmentImport';
|
||||
|
||||
export default function EquipmentImportPage() {
|
||||
return <EquipmentImport />;
|
||||
}
|
||||
22
src/app/[locale]/(protected)/quality/equipment/new/page.tsx
Normal file
22
src/app/[locale]/(protected)/quality/equipment/new/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 레거시 리다이렉트: /quality/equipment/new → ?mode=new
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function EquipmentNewRedirect() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
router.replace('/quality/equipment?mode=new');
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
src/app/[locale]/(protected)/quality/equipment/page.tsx
Normal file
22
src/app/[locale]/(protected)/quality/equipment/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 설비 등록대장 - 목록/등록
|
||||
* URL: /quality/equipment
|
||||
* URL: /quality/equipment?mode=new (등록)
|
||||
*/
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { EquipmentManagement } from '@/components/quality/EquipmentManagement';
|
||||
import { EquipmentForm } from '@/components/quality/EquipmentManagement/EquipmentForm';
|
||||
|
||||
export default function EquipmentPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
if (mode === 'new') {
|
||||
return <EquipmentForm />;
|
||||
}
|
||||
|
||||
return <EquipmentManagement />;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Image from "next/image";
|
||||
import { toast } from "sonner";
|
||||
@@ -20,6 +20,7 @@ import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
export function LoginPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const t = useTranslations('auth');
|
||||
const tCommon = useTranslations('common');
|
||||
const tValidation = useTranslations('validation');
|
||||
@@ -134,8 +135,9 @@ export function LoginPage() {
|
||||
// 메뉴 폴링 재시작 플래그 설정 (세션 만료 후 재로그인 시)
|
||||
sessionStorage.setItem('auth_just_logged_in', 'true');
|
||||
|
||||
// 대시보드로 이동
|
||||
router.push("/dashboard");
|
||||
// redirect 파라미터가 있으면 해당 페이지로, 없으면 대시보드로 이동
|
||||
const redirectTo = searchParams.get('redirect');
|
||||
router.push(redirectTo && redirectTo.startsWith('/') ? redirectTo : '/dashboard');
|
||||
} catch (err) {
|
||||
if (isNextRedirectError(err)) throw err;
|
||||
// 상세 에러 로깅
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { CreditCard, ArrowLeft, Save } from 'lucide-react';
|
||||
import { CreditCard, X, Save } from 'lucide-react';
|
||||
import { useMenuStore } from '@/stores/menuStore';
|
||||
import type { Card as CardType, CardFormData, CardCompany, CardStatus } from './types';
|
||||
import { CARD_COMPANIES, CARD_STATUS_LABELS } from './types';
|
||||
import { getActiveEmployees } from './actions';
|
||||
@@ -28,6 +29,7 @@ interface CardFormProps {
|
||||
|
||||
export function CardForm({ mode, card, onSubmit }: CardFormProps) {
|
||||
const router = useRouter();
|
||||
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
|
||||
const [formData, setFormData] = useState<CardFormData>({
|
||||
cardCompany: '',
|
||||
cardType: '',
|
||||
@@ -245,18 +247,19 @@ export function CardForm({ mode, card, onSubmit }: CardFormProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button type="button" variant="outline" onClick={handleBack}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{mode === 'create' ? '등록' : '저장'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* 하단 버튼 (sticky 하단 바) */}
|
||||
<div className={`fixed bottom-4 left-4 right-4 px-4 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 md:bottom-6 md:px-6 md:right-[48px] ${sidebarCollapsed ? 'md:left-[156px]' : 'md:left-[316px]'} flex items-center justify-between`}>
|
||||
<Button type="button" variant="outline" onClick={handleBack}>
|
||||
<X className="w-4 h-4 mr-1" />
|
||||
취소
|
||||
</Button>
|
||||
<Button type="button" onClick={() => onSubmit(formData)}>
|
||||
<Save className="w-4 h-4 mr-1" />
|
||||
{mode === 'create' ? '등록' : '저장'}
|
||||
</Button>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useMemo, useRef } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -9,9 +10,10 @@ import {
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -20,101 +22,30 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { SearchableSelectionModal } from '@/components/organisms/SearchableSelectionModal';
|
||||
import { Save, Search, UserPlus } from 'lucide-react';
|
||||
import { Save, Search, UserPlus, Plus, X, RefreshCw, Loader2 } from 'lucide-react';
|
||||
import { getEmployees } from '@/components/hr/EmployeeManagement/actions';
|
||||
import type { Employee } from '@/components/hr/EmployeeManagement/types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { createPayroll, getPayrollSettings } from './actions';
|
||||
import type { NameAmountItem, PayrollSettings } from './types';
|
||||
import { formatCurrency } from './types';
|
||||
|
||||
// ===== 기본값 상수 =====
|
||||
const DEFAULT_ALLOWANCES = {
|
||||
positionAllowance: 0,
|
||||
overtimeAllowance: 0,
|
||||
mealAllowance: 150000,
|
||||
transportAllowance: 100000,
|
||||
otherAllowance: 0,
|
||||
};
|
||||
|
||||
const DEFAULT_DEDUCTIONS = {
|
||||
nationalPension: 0,
|
||||
healthInsurance: 0,
|
||||
longTermCare: 0,
|
||||
employmentInsurance: 0,
|
||||
incomeTax: 0,
|
||||
localIncomeTax: 0,
|
||||
otherDeduction: 0,
|
||||
};
|
||||
|
||||
// 기본급 기준 4대보험 + 세금 자동 계산
|
||||
function calculateDefaultDeductions(baseSalary: number) {
|
||||
const nationalPension = Math.round(baseSalary * 0.045); // 국민연금 4.5%
|
||||
const healthInsurance = Math.round(baseSalary * 0.03545); // 건강보험 3.545%
|
||||
const longTermCare = Math.round(healthInsurance * 0.1281); // 장기요양 12.81% of 건강보험
|
||||
const employmentInsurance = Math.round(baseSalary * 0.009); // 고용보험 0.9%
|
||||
const totalIncome = baseSalary + DEFAULT_ALLOWANCES.mealAllowance + DEFAULT_ALLOWANCES.transportAllowance;
|
||||
const incomeTax = Math.round(totalIncome * 0.05); // 소득세 (간이세액 근사)
|
||||
const localIncomeTax = Math.round(incomeTax * 0.1); // 지방소득세 10% of 소득세
|
||||
|
||||
return {
|
||||
nationalPension,
|
||||
healthInsurance,
|
||||
longTermCare,
|
||||
employmentInsurance,
|
||||
incomeTax,
|
||||
localIncomeTax,
|
||||
otherDeduction: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 행 컴포넌트 =====
|
||||
function EditableRow({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
prefix: _prefix,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
prefix?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2">
|
||||
<span className="text-muted-foreground whitespace-nowrap text-xs sm:text-sm sm:w-24 sm:shrink-0">{label}</span>
|
||||
<div className="flex-1">
|
||||
<CurrencyInput
|
||||
value={value}
|
||||
onChange={(v) => onChange(v ?? 0)}
|
||||
className="w-full h-7 text-sm text-right"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Props =====
|
||||
interface SalaryRegistrationDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: (data: {
|
||||
employeeId: number;
|
||||
employeeName: string;
|
||||
department: string;
|
||||
position: string;
|
||||
rank: string;
|
||||
year: number;
|
||||
month: number;
|
||||
baseSalary: number;
|
||||
paymentDate: string;
|
||||
allowances: Record<string, number>;
|
||||
deductions: Record<string, number>;
|
||||
}) => void;
|
||||
defaultYear: number;
|
||||
defaultMonth: number;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
// ===== 컴포넌트 =====
|
||||
export function SalaryRegistrationDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
defaultYear,
|
||||
defaultMonth,
|
||||
onSaved,
|
||||
}: SalaryRegistrationDialogProps) {
|
||||
// 사원 선택
|
||||
const [employeeSearchOpen, setEmployeeSearchOpen] = useState(false);
|
||||
@@ -122,19 +53,40 @@ export function SalaryRegistrationDialog({
|
||||
const searchOpenRef = useRef(false);
|
||||
|
||||
// 급여 기본 정보
|
||||
const now = new Date();
|
||||
const [year, setYear] = useState(now.getFullYear());
|
||||
const [month, setMonth] = useState(now.getMonth() + 1);
|
||||
const [paymentDate, setPaymentDate] = useState('');
|
||||
const [year, setYear] = useState(defaultYear);
|
||||
const [month, setMonth] = useState(defaultMonth);
|
||||
const [baseSalary, setBaseSalary] = useState(0);
|
||||
const [overtimePay, setOvertimePay] = useState(0);
|
||||
const [mealAllowance, setMealAllowance] = useState(200000); // 식대(비과세) 기본 20만원
|
||||
|
||||
// 수당
|
||||
const [allowances, setAllowances] = useState({ ...DEFAULT_ALLOWANCES });
|
||||
// 추가 수당
|
||||
const [allowances, setAllowances] = useState<NameAmountItem[]>([]);
|
||||
|
||||
// 공제
|
||||
const [deductions, setDeductions] = useState({ ...DEFAULT_DEDUCTIONS });
|
||||
// 법정 공제
|
||||
const [pension, setPension] = useState(0);
|
||||
const [healthInsurance, setHealthInsurance] = useState(0);
|
||||
const [longTermCare, setLongTermCare] = useState(0);
|
||||
const [employmentInsurance, setEmploymentInsurance] = useState(0);
|
||||
const [incomeTax, setIncomeTax] = useState(0);
|
||||
const [residentTax, setResidentTax] = useState(0);
|
||||
|
||||
// 검색 모달 열기/닫기 (ref 동기화 - 닫힘 전파 방지를 위해 해제 지연)
|
||||
// 추가 공제
|
||||
const [deductions, setDeductions] = useState<NameAmountItem[]>([]);
|
||||
|
||||
// 공제대상가족수
|
||||
const [dependents, setDependents] = useState(1);
|
||||
|
||||
// 비고
|
||||
const [note, setNote] = useState('');
|
||||
|
||||
// 로딩
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isCalculating, setIsCalculating] = useState(false);
|
||||
|
||||
// 설정 (재계산용)
|
||||
const [settings, setSettings] = useState<PayrollSettings | null>(null);
|
||||
|
||||
// 검색 모달 열기/닫기
|
||||
const handleSearchOpenChange = useCallback((isOpen: boolean) => {
|
||||
if (isOpen) {
|
||||
searchOpenRef.current = true;
|
||||
@@ -144,8 +96,64 @@ export function SalaryRegistrationDialog({
|
||||
setEmployeeSearchOpen(isOpen);
|
||||
}, []);
|
||||
|
||||
// 설정 로드 (재계산용)
|
||||
const loadSettings = useCallback(async () => {
|
||||
if (settings) return settings;
|
||||
const result = await getPayrollSettings();
|
||||
if (result.success && result.data) {
|
||||
setSettings(result.data);
|
||||
return result.data;
|
||||
}
|
||||
return null;
|
||||
}, [settings]);
|
||||
|
||||
// 공제 자동 계산 (백엔드 PayrollSetting 계산 로직과 동일)
|
||||
const calculateDeductions = useCallback(async (salary: number) => {
|
||||
setIsCalculating(true);
|
||||
try {
|
||||
const s = await loadSettings();
|
||||
if (!s) {
|
||||
toast.error('급여 설정을 불러올 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 국민연금: baseSalary 기준, 상/하한액 반영 (round)
|
||||
const pensionBase = Math.min(Math.max(salary, s.pensionMinSalary), s.pensionMaxSalary);
|
||||
const newPension = Math.round(pensionBase * s.pensionRate / 100);
|
||||
|
||||
// 건강보험 (round)
|
||||
const newHealth = Math.round(salary * s.healthInsuranceRate / 100);
|
||||
|
||||
// 장기요양보험 (건강보험료의 %) — 화면에는 분리 표시
|
||||
const newLongTermCare = Math.round(newHealth * s.longTermCareRate / 100);
|
||||
|
||||
// 고용보험 (round)
|
||||
const newEmployment = Math.round(salary * s.employmentInsuranceRate / 100);
|
||||
|
||||
// 근로소득세 (기본 요율 0%, 수동 입력 방식)
|
||||
const newIncomeTax = Math.round(salary * s.incomeTaxRate / 100);
|
||||
|
||||
// 지방소득세 (근로소득세의 %)
|
||||
const newResidentTax = Math.round(newIncomeTax * s.residentTaxRate / 100);
|
||||
|
||||
setPension(newPension);
|
||||
setHealthInsurance(newHealth);
|
||||
setLongTermCare(newLongTermCare);
|
||||
setEmploymentInsurance(newEmployment);
|
||||
setIncomeTax(newIncomeTax);
|
||||
setResidentTax(newResidentTax);
|
||||
|
||||
toast.success('공제 항목이 재계산되었습니다.');
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
toast.error('공제 계산 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsCalculating(false);
|
||||
}
|
||||
}, [loadSettings]);
|
||||
|
||||
// 사원 선택 시 기본값 세팅
|
||||
const handleSelectEmployee = useCallback((employee: Employee) => {
|
||||
const handleSelectEmployee = useCallback(async (employee: Employee) => {
|
||||
setSelectedEmployee(employee);
|
||||
handleSearchOpenChange(false);
|
||||
|
||||
@@ -153,11 +161,11 @@ export function SalaryRegistrationDialog({
|
||||
const monthlySalary = employee.salary ? Math.round(employee.salary / 12) : 0;
|
||||
setBaseSalary(monthlySalary);
|
||||
|
||||
// 기본 공제 자동 계산
|
||||
// baseSalary 기준으로 공제 자동 계산 (백엔드 로직과 동일)
|
||||
if (monthlySalary > 0) {
|
||||
setDeductions(calculateDefaultDeductions(monthlySalary));
|
||||
await calculateDeductions(monthlySalary);
|
||||
}
|
||||
}, []);
|
||||
}, [handleSearchOpenChange, calculateDeductions]);
|
||||
|
||||
// 사원 검색 fetch
|
||||
const handleFetchEmployees = useCallback(async (query: string) => {
|
||||
@@ -175,99 +183,152 @@ export function SalaryRegistrationDialog({
|
||||
return /[a-zA-Z가-힣ㄱ-ㅎㅏ-ㅣ0-9]/.test(query);
|
||||
}, []);
|
||||
|
||||
// 수당 변경
|
||||
const handleAllowanceChange = useCallback((field: string, value: number) => {
|
||||
setAllowances(prev => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
// 공제 변경
|
||||
const handleDeductionChange = useCallback((field: string, value: number) => {
|
||||
setDeductions(prev => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
// 합계 계산
|
||||
const totalAllowance = useMemo(() =>
|
||||
Object.values(allowances).reduce((sum, v) => sum + v, 0),
|
||||
// ===== 합계 계산 =====
|
||||
const allowancesTotal = useMemo(
|
||||
() => allowances.reduce((sum, a) => sum + a.amount, 0),
|
||||
[allowances]
|
||||
);
|
||||
|
||||
const totalDeduction = useMemo(() =>
|
||||
Object.values(deductions).reduce((sum, v) => sum + v, 0),
|
||||
const grossSalary = useMemo(
|
||||
() => baseSalary + overtimePay + mealAllowance + allowancesTotal,
|
||||
[baseSalary, overtimePay, mealAllowance, allowancesTotal]
|
||||
);
|
||||
|
||||
const taxBase = useMemo(
|
||||
() => Math.max(0, grossSalary - mealAllowance),
|
||||
[grossSalary, mealAllowance]
|
||||
);
|
||||
|
||||
const legalDeductionsTotal = useMemo(
|
||||
() => pension + healthInsurance + longTermCare + employmentInsurance + incomeTax + residentTax,
|
||||
[pension, healthInsurance, longTermCare, employmentInsurance, incomeTax, residentTax]
|
||||
);
|
||||
|
||||
const extraDeductionsTotal = useMemo(
|
||||
() => deductions.reduce((sum, d) => sum + d.amount, 0),
|
||||
[deductions]
|
||||
);
|
||||
|
||||
const netPayment = useMemo(() =>
|
||||
baseSalary + totalAllowance - totalDeduction,
|
||||
[baseSalary, totalAllowance, totalDeduction]
|
||||
);
|
||||
const totalDeductions = legalDeductionsTotal + extraDeductionsTotal;
|
||||
const netSalary = grossSalary - totalDeductions;
|
||||
|
||||
// ===== 수당 추가/삭제 =====
|
||||
const addAllowance = useCallback(() => {
|
||||
setAllowances(prev => [...prev, { name: '', amount: 0 }]);
|
||||
}, []);
|
||||
|
||||
const removeAllowance = useCallback((idx: number) => {
|
||||
setAllowances(prev => prev.filter((_, i) => i !== idx));
|
||||
}, []);
|
||||
|
||||
const updateAllowance = useCallback((idx: number, field: 'name' | 'amount', value: string | number) => {
|
||||
setAllowances(prev => prev.map((item, i) =>
|
||||
i === idx ? { ...item, [field]: value } : item
|
||||
));
|
||||
}, []);
|
||||
|
||||
// ===== 공제 추가/삭제 =====
|
||||
const addDeduction = useCallback(() => {
|
||||
setDeductions(prev => [...prev, { name: '', amount: 0 }]);
|
||||
}, []);
|
||||
|
||||
const removeDeduction = useCallback((idx: number) => {
|
||||
setDeductions(prev => prev.filter((_, i) => i !== idx));
|
||||
}, []);
|
||||
|
||||
const updateDeduction = useCallback((idx: number, field: 'name' | 'amount', value: string | number) => {
|
||||
setDeductions(prev => prev.map((item, i) =>
|
||||
i === idx ? { ...item, [field]: value } : item
|
||||
));
|
||||
}, []);
|
||||
|
||||
// ===== 재계산 (baseSalary 기준 — 백엔드 동일) =====
|
||||
const handleRecalculate = useCallback(() => {
|
||||
calculateDeductions(baseSalary);
|
||||
}, [calculateDeductions, baseSalary]);
|
||||
|
||||
// 저장 가능 여부
|
||||
const canSave = selectedEmployee && baseSalary > 0 && year > 0 && month > 0;
|
||||
const canSave = selectedEmployee && baseSalary > 0;
|
||||
|
||||
// 저장
|
||||
const handleSave = useCallback(() => {
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!selectedEmployee || !canSave) return;
|
||||
setIsSaving(true);
|
||||
|
||||
const dept = selectedEmployee.departmentPositions?.[0];
|
||||
try {
|
||||
const result = await createPayroll({
|
||||
user_id: selectedEmployee.userId || parseInt(selectedEmployee.id, 10),
|
||||
pay_year: year,
|
||||
pay_month: month,
|
||||
base_salary: baseSalary,
|
||||
overtime_pay: overtimePay,
|
||||
bonus: mealAllowance,
|
||||
allowances: allowances.filter(a => a.name && a.amount > 0),
|
||||
pension,
|
||||
health_insurance: healthInsurance,
|
||||
long_term_care: longTermCare,
|
||||
employment_insurance: employmentInsurance,
|
||||
income_tax: incomeTax,
|
||||
resident_tax: residentTax,
|
||||
deductions: deductions.filter(d => d.name && d.amount > 0),
|
||||
note: note || undefined,
|
||||
});
|
||||
|
||||
onSave({
|
||||
employeeId: selectedEmployee.userId || parseInt(selectedEmployee.id, 10),
|
||||
employeeName: selectedEmployee.name,
|
||||
department: dept?.departmentName || '-',
|
||||
position: dept?.positionName || '-',
|
||||
rank: selectedEmployee.rank || '-',
|
||||
year,
|
||||
month,
|
||||
baseSalary,
|
||||
paymentDate,
|
||||
allowances: {
|
||||
position_allowance: allowances.positionAllowance,
|
||||
overtime_allowance: allowances.overtimeAllowance,
|
||||
meal_allowance: allowances.mealAllowance,
|
||||
transport_allowance: allowances.transportAllowance,
|
||||
other_allowance: allowances.otherAllowance,
|
||||
},
|
||||
deductions: {
|
||||
national_pension: deductions.nationalPension,
|
||||
health_insurance: deductions.healthInsurance,
|
||||
long_term_care: deductions.longTermCare,
|
||||
employment_insurance: deductions.employmentInsurance,
|
||||
income_tax: deductions.incomeTax,
|
||||
local_income_tax: deductions.localIncomeTax,
|
||||
other_deduction: deductions.otherDeduction,
|
||||
},
|
||||
});
|
||||
}, [selectedEmployee, canSave, year, month, baseSalary, paymentDate, allowances, deductions, onSave]);
|
||||
if (result.success) {
|
||||
toast.success('급여가 등록되었습니다.');
|
||||
onSaved();
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
toast.error(result.error || '급여 등록에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
toast.error('급여 등록에 실패했습니다.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [
|
||||
selectedEmployee, canSave, year, month, baseSalary, overtimePay, mealAllowance,
|
||||
allowances, pension, healthInsurance, longTermCare, employmentInsurance,
|
||||
incomeTax, residentTax, deductions, note, onSaved, onOpenChange,
|
||||
]);
|
||||
|
||||
// 다이얼로그 닫힐 때 초기화 (검색 모달 닫힘 전파 방지)
|
||||
// 다이얼로그 닫힐 때 초기화
|
||||
const handleOpenChange = useCallback((isOpen: boolean) => {
|
||||
if (!isOpen && searchOpenRef.current) return;
|
||||
if (!isOpen) {
|
||||
setSelectedEmployee(null);
|
||||
setBaseSalary(0);
|
||||
setPaymentDate('');
|
||||
setAllowances({ ...DEFAULT_ALLOWANCES });
|
||||
setDeductions({ ...DEFAULT_DEDUCTIONS });
|
||||
setOvertimePay(0);
|
||||
setMealAllowance(200000);
|
||||
setAllowances([]);
|
||||
setPension(0);
|
||||
setHealthInsurance(0);
|
||||
setLongTermCare(0);
|
||||
setEmploymentInsurance(0);
|
||||
setIncomeTax(0);
|
||||
setResidentTax(0);
|
||||
setDeductions([]);
|
||||
setDependents(1);
|
||||
setNote('');
|
||||
} else {
|
||||
setYear(defaultYear);
|
||||
setMonth(defaultMonth);
|
||||
}
|
||||
onOpenChange(isOpen);
|
||||
}, [onOpenChange]);
|
||||
}, [onOpenChange, defaultYear, defaultMonth]);
|
||||
|
||||
// 년도 옵션
|
||||
// 년도/월 옵션
|
||||
const yearOptions = useMemo(() => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
return Array.from({ length: 3 }, (_, i) => currentYear - 1 + i);
|
||||
const cy = new Date().getFullYear();
|
||||
return Array.from({ length: 3 }, (_, i) => cy - 1 + i);
|
||||
}, []);
|
||||
|
||||
// 월 옵션
|
||||
const monthOptions = useMemo(() =>
|
||||
Array.from({ length: 12 }, (_, i) => i + 1),
|
||||
[]
|
||||
);
|
||||
const monthOptions = Array.from({ length: 12 }, (_, i) => i + 1);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto p-4 sm:p-6">
|
||||
<DialogContent className="sm:max-w-[750px] max-h-[90vh] overflow-y-auto p-4 sm:p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<UserPlus className="h-5 w-5" />
|
||||
@@ -275,12 +336,12 @@ export function SalaryRegistrationDialog({
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 사원 선택 */}
|
||||
<div className="space-y-5">
|
||||
{/* ===== 기본 정보 ===== */}
|
||||
<div className="bg-muted/50 rounded-lg p-3 sm:p-4">
|
||||
<h3 className="font-semibold mb-3">기본 정보</h3>
|
||||
|
||||
{/* 사원 선택 버튼 */}
|
||||
{/* 사원 선택 */}
|
||||
{!selectedEmployee ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -311,12 +372,6 @@ export function SalaryRegistrationDialog({
|
||||
<span className="text-muted-foreground">직급</span>
|
||||
<p className="font-medium">{selectedEmployee.rank || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">직책</span>
|
||||
<p className="font-medium">
|
||||
{selectedEmployee.departmentPositions?.[0]?.positionName || '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -329,8 +384,8 @@ export function SalaryRegistrationDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 지급월 / 지급일 */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mt-4">
|
||||
{/* 지급 연월 */}
|
||||
<div className="grid grid-cols-2 gap-3 mt-4">
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground block mb-1">년도</span>
|
||||
<Select value={String(year)} onValueChange={(v) => setYear(Number(v))}>
|
||||
@@ -353,161 +408,242 @@ export function SalaryRegistrationDialog({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="col-span-2 sm:col-span-1">
|
||||
<span className="text-sm text-muted-foreground block mb-1">지급일</span>
|
||||
<DatePicker
|
||||
value={paymentDate}
|
||||
onChange={setPaymentDate}
|
||||
placeholder="지급일 선택"
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== 지급 항목 ===== */}
|
||||
<div className="border rounded-lg p-3 sm:p-4">
|
||||
<h3 className="font-semibold mb-3 text-blue-600">지급 항목</h3>
|
||||
<div className="space-y-3">
|
||||
{/* 기본급 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground w-32 shrink-0">기본급</span>
|
||||
<CurrencyInput
|
||||
value={baseSalary}
|
||||
onChange={(v) => setBaseSalary(v ?? 0)}
|
||||
className="flex-1 h-8 text-right"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기본급 */}
|
||||
<div className="mt-4">
|
||||
<span className="text-sm text-muted-foreground block mb-1">기본급 (월)</span>
|
||||
<CurrencyInput
|
||||
value={baseSalary}
|
||||
onChange={(v) => {
|
||||
const newSalary = v ?? 0;
|
||||
setBaseSalary(newSalary);
|
||||
if (newSalary > 0) {
|
||||
setDeductions(calculateDefaultDeductions(newSalary));
|
||||
}
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
{selectedEmployee?.salary && (
|
||||
<span className="text-xs text-muted-foreground mt-1 block">
|
||||
<p className="text-xs text-muted-foreground ml-32">
|
||||
연봉 {formatCurrency(selectedEmployee.salary)}원 기준
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 고정연장근로수당 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground w-32 shrink-0">고정연장근로수당</span>
|
||||
<CurrencyInput
|
||||
value={overtimePay}
|
||||
onChange={(v) => setOvertimePay(v ?? 0)}
|
||||
className="flex-1 h-8 text-right"
|
||||
/>
|
||||
</div>
|
||||
{/* 식대(비과세) */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground w-32 shrink-0">식대(비과세)</span>
|
||||
<CurrencyInput
|
||||
value={mealAllowance}
|
||||
onChange={(v) => setMealAllowance(v ?? 0)}
|
||||
className="flex-1 h-8 text-right"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 수당 / 공제 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 수당 내역 */}
|
||||
<div className="border rounded-lg p-3 sm:p-4 flex flex-col">
|
||||
<h3 className="font-semibold mb-3 text-blue-600">수당 내역</h3>
|
||||
<div className="space-y-2 text-sm flex-1">
|
||||
<EditableRow
|
||||
label="직책수당"
|
||||
value={allowances.positionAllowance}
|
||||
onChange={(v) => handleAllowanceChange('positionAllowance', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="초과근무수당"
|
||||
value={allowances.overtimeAllowance}
|
||||
onChange={(v) => handleAllowanceChange('overtimeAllowance', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="식대"
|
||||
value={allowances.mealAllowance}
|
||||
onChange={(v) => handleAllowanceChange('mealAllowance', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="교통비"
|
||||
value={allowances.transportAllowance}
|
||||
onChange={(v) => handleAllowanceChange('transportAllowance', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="기타수당"
|
||||
value={allowances.otherAllowance}
|
||||
onChange={(v) => handleAllowanceChange('otherAllowance', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-auto pt-2">
|
||||
<Separator />
|
||||
<div className="flex items-center gap-2 font-semibold text-blue-600 mt-2">
|
||||
<span className="w-20 sm:w-24 shrink-0">수당 합계</span>
|
||||
<span className="flex-1 text-right">{formatCurrency(totalAllowance)}원</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
{/* 공제 내역 */}
|
||||
<div className="border rounded-lg p-3 sm:p-4 flex flex-col">
|
||||
<h3 className="font-semibold mb-3 text-red-600">공제 내역</h3>
|
||||
<div className="space-y-2 text-sm flex-1">
|
||||
<EditableRow
|
||||
label="국민연금"
|
||||
value={deductions.nationalPension}
|
||||
onChange={(v) => handleDeductionChange('nationalPension', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="건강보험"
|
||||
value={deductions.healthInsurance}
|
||||
onChange={(v) => handleDeductionChange('healthInsurance', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="장기요양보험"
|
||||
value={deductions.longTermCare}
|
||||
onChange={(v) => handleDeductionChange('longTermCare', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="고용보험"
|
||||
value={deductions.employmentInsurance}
|
||||
onChange={(v) => handleDeductionChange('employmentInsurance', v)}
|
||||
/>
|
||||
<Separator />
|
||||
<EditableRow
|
||||
label="소득세"
|
||||
value={deductions.incomeTax}
|
||||
onChange={(v) => handleDeductionChange('incomeTax', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="지방소득세"
|
||||
value={deductions.localIncomeTax}
|
||||
onChange={(v) => handleDeductionChange('localIncomeTax', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="기타공제"
|
||||
value={deductions.otherDeduction}
|
||||
onChange={(v) => handleDeductionChange('otherDeduction', v)}
|
||||
/>
|
||||
{/* 추가 수당 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">추가 수당</span>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addAllowance}>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-auto pt-2">
|
||||
<Separator />
|
||||
<div className="flex items-center gap-2 font-semibold text-red-600 mt-2">
|
||||
<span className="w-20 sm:w-24 shrink-0">공제 합계</span>
|
||||
<span className="flex-1 text-right">-{formatCurrency(totalDeduction)}원</span>
|
||||
{allowances.map((item, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="수당명"
|
||||
value={item.name}
|
||||
onChange={(e) => updateAllowance(idx, 'name', e.target.value)}
|
||||
className="w-32 h-8 text-sm"
|
||||
/>
|
||||
<CurrencyInput
|
||||
value={item.amount}
|
||||
onChange={(v) => updateAllowance(idx, 'amount', v ?? 0)}
|
||||
className="flex-1 h-8 text-right"
|
||||
/>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => removeAllowance(idx)}>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 합계 */}
|
||||
<div className="flex items-center justify-between font-semibold text-blue-600">
|
||||
<span>총 지급액</span>
|
||||
<span>{formatCurrency(grossSalary)}원</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>과세표준 (식대 제외)</span>
|
||||
<span>{formatCurrency(taxBase)}원</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 합계 */}
|
||||
{/* ===== 공제 항목 ===== */}
|
||||
<div className="border rounded-lg p-3 sm:p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-semibold text-red-600">공제 항목</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={handleRecalculate}
|
||||
disabled={isCalculating || !selectedEmployee}
|
||||
>
|
||||
{isCalculating ? (
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
재계산
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* 공제대상가족수 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground w-32 shrink-0">공제대상 가족수</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={dependents}
|
||||
onChange={(e) => setDependents(parseInt(e.target.value) || 1)}
|
||||
className="w-20 h-8 text-right text-sm"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">명 (본인 포함)</span>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 4대보험 */}
|
||||
<p className="text-xs text-muted-foreground font-medium">4대보험</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground w-32 shrink-0">국민연금</span>
|
||||
<CurrencyInput value={pension} onChange={(v) => setPension(v ?? 0)} className="flex-1 h-8 text-right" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground w-32 shrink-0">건강보험</span>
|
||||
<CurrencyInput value={healthInsurance} onChange={(v) => setHealthInsurance(v ?? 0)} className="flex-1 h-8 text-right" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground w-32 shrink-0">장기요양보험</span>
|
||||
<CurrencyInput value={longTermCare} onChange={(v) => setLongTermCare(v ?? 0)} className="flex-1 h-8 text-right" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground w-32 shrink-0">고용보험</span>
|
||||
<CurrencyInput value={employmentInsurance} onChange={(v) => setEmploymentInsurance(v ?? 0)} className="flex-1 h-8 text-right" />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 세금 */}
|
||||
<p className="text-xs text-muted-foreground font-medium">세금</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground w-32 shrink-0">근로소득세</span>
|
||||
<CurrencyInput value={incomeTax} onChange={(v) => setIncomeTax(v ?? 0)} className="flex-1 h-8 text-right" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground w-32 shrink-0">지방소득세</span>
|
||||
<CurrencyInput value={residentTax} onChange={(v) => setResidentTax(v ?? 0)} className="flex-1 h-8 text-right" />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 추가 공제 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">추가 공제</span>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addDeduction}>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
{deductions.map((item, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="공제명"
|
||||
value={item.name}
|
||||
onChange={(e) => updateDeduction(idx, 'name', e.target.value)}
|
||||
className="w-32 h-8 text-sm"
|
||||
/>
|
||||
<CurrencyInput
|
||||
value={item.amount}
|
||||
onChange={(v) => updateDeduction(idx, 'amount', v ?? 0)}
|
||||
className="flex-1 h-8 text-right"
|
||||
/>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => removeDeduction(idx)}>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 공제 합계 */}
|
||||
<div className="flex items-center justify-between font-semibold text-red-600">
|
||||
<span>총 공제액</span>
|
||||
<span>-{formatCurrency(totalDeductions)}원</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== 합계 ===== */}
|
||||
<div className="bg-primary/5 border-2 border-primary/20 rounded-lg p-3 sm:p-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 sm:gap-4 text-center">
|
||||
<div>
|
||||
<span className="text-xs sm:text-sm text-muted-foreground block">급여 총액</span>
|
||||
<span className="text-xs sm:text-sm text-muted-foreground block">총 지급액</span>
|
||||
<span className="text-sm sm:text-lg font-semibold text-blue-600">
|
||||
{formatCurrency(baseSalary + totalAllowance)}원
|
||||
{formatCurrency(grossSalary)}원
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs sm:text-sm text-muted-foreground block">공제 총액</span>
|
||||
<span className="text-xs sm:text-sm text-muted-foreground block">총 공제액</span>
|
||||
<span className="text-sm sm:text-lg font-semibold text-red-600">
|
||||
-{formatCurrency(totalDeduction)}원
|
||||
-{formatCurrency(totalDeductions)}원
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs sm:text-sm text-muted-foreground block">실지급액</span>
|
||||
<span className="text-xs sm:text-sm text-muted-foreground block">실수령액</span>
|
||||
<span className="text-base sm:text-xl font-bold text-primary">
|
||||
{formatCurrency(netPayment)}원
|
||||
{formatCurrency(netSalary)}원
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== 비고 ===== */}
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground block mb-1">비고</span>
|
||||
<Textarea
|
||||
placeholder="비고 입력..."
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!canSave} className="gap-2">
|
||||
<Save className="h-4 w-4" />
|
||||
<Button onClick={handleSave} disabled={!canSave || isSaving} className="gap-2">
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
등록
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -1,156 +1,204 @@
|
||||
'use server';
|
||||
|
||||
|
||||
import { executeServerAction } from '@/lib/api/execute-server-action';
|
||||
import { buildApiUrl } from '@/lib/api/query-params';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { cookies } from 'next/headers';
|
||||
import type { SalaryRecord, SalaryDetail, PaymentStatus } from './types';
|
||||
import type {
|
||||
PayrollRecord,
|
||||
PayrollDetail,
|
||||
PayrollSummary,
|
||||
PayrollSettings,
|
||||
PayrollStatus,
|
||||
NameAmountItem,
|
||||
} from './types';
|
||||
|
||||
// API 응답 타입
|
||||
interface SalaryApiData {
|
||||
// ===== API 응답 타입 =====
|
||||
interface PayrollApiData {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
employee_id: number;
|
||||
year: number;
|
||||
month: number;
|
||||
base_salary: string;
|
||||
total_allowance: string;
|
||||
total_overtime: string;
|
||||
total_bonus: string;
|
||||
total_deduction: string;
|
||||
net_payment: string;
|
||||
allowance_details: Record<string, number> | null;
|
||||
deduction_details: Record<string, number> | null;
|
||||
payment_date: string | null;
|
||||
status: 'scheduled' | 'completed';
|
||||
employee?: {
|
||||
user_id: number;
|
||||
pay_year: number;
|
||||
pay_month: number;
|
||||
base_salary: string | number;
|
||||
overtime_pay: string | number;
|
||||
bonus: string | number;
|
||||
allowances: { name: string; amount: number }[] | null;
|
||||
gross_salary: string | number;
|
||||
income_tax: string | number;
|
||||
resident_tax: string | number;
|
||||
health_insurance: string | number;
|
||||
long_term_care: string | number;
|
||||
pension: string | number;
|
||||
employment_insurance: string | number;
|
||||
deductions: { name: string; amount: number }[] | null;
|
||||
total_deductions: string | number;
|
||||
net_salary: string | number;
|
||||
status: PayrollStatus;
|
||||
confirmed_at: string | null;
|
||||
confirmed_by: number | null;
|
||||
paid_at: string | null;
|
||||
withdrawal_id: number | null;
|
||||
note: string | null;
|
||||
options: Record<string, unknown> | null;
|
||||
user?: {
|
||||
id: number;
|
||||
name: string;
|
||||
user_id?: string;
|
||||
email?: string;
|
||||
} | null;
|
||||
employee_profile?: {
|
||||
id: number;
|
||||
department_id: number | null;
|
||||
position_key: string | null;
|
||||
job_title_key: string | null;
|
||||
position_label: string | null;
|
||||
job_title_label: string | null;
|
||||
rank: string | null;
|
||||
department?: {
|
||||
id: number;
|
||||
name: string;
|
||||
employee_profile?: {
|
||||
department?: { id: number; name: string } | null;
|
||||
position_label?: string | null;
|
||||
job_title_label?: string | null;
|
||||
rank?: string | null;
|
||||
} | null;
|
||||
} | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface SalaryPaginationData {
|
||||
data: SalaryApiData[];
|
||||
interface PaginationApiData {
|
||||
data: PayrollApiData[];
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface StatisticsApiData {
|
||||
total_net_payment: number;
|
||||
total_base_salary: number;
|
||||
total_allowance: number;
|
||||
total_overtime: number;
|
||||
total_bonus: number;
|
||||
total_deduction: number;
|
||||
count: number;
|
||||
scheduled_count: number;
|
||||
completed_count: number;
|
||||
interface SummaryApiData {
|
||||
year: number;
|
||||
month: number;
|
||||
total_count: number;
|
||||
draft_count: number;
|
||||
confirmed_count: number;
|
||||
paid_count: number;
|
||||
total_gross: number;
|
||||
total_deductions: number;
|
||||
total_net: number;
|
||||
}
|
||||
|
||||
// API → Frontend 변환 (목록용)
|
||||
function transformApiToFrontend(apiData: SalaryApiData): SalaryRecord {
|
||||
const profile = apiData.employee_profile;
|
||||
interface SettingsApiData {
|
||||
id?: number;
|
||||
health_insurance_rate: number;
|
||||
long_term_care_rate: number;
|
||||
pension_rate: number;
|
||||
employment_insurance_rate: number;
|
||||
income_tax_rate: number;
|
||||
resident_tax_rate: number;
|
||||
pension_max_salary: number;
|
||||
pension_min_salary: number;
|
||||
pay_day: number;
|
||||
auto_calculate: boolean;
|
||||
allowance_types: { code: string; name: string; is_taxable?: boolean }[] | null;
|
||||
deduction_types: { code: string; name: string }[] | null;
|
||||
}
|
||||
|
||||
// ===== 변환 함수 =====
|
||||
function toNum(v: string | number | null | undefined): number {
|
||||
if (v == null) return 0;
|
||||
return typeof v === 'string' ? parseFloat(v) || 0 : v;
|
||||
}
|
||||
|
||||
function transformToRecord(d: PayrollApiData): PayrollRecord {
|
||||
const profile = d.user?.employee_profile;
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
employeeId: apiData.employee?.user_id || `EMP${String(apiData.employee_id).padStart(3, '0')}`,
|
||||
employeeName: apiData.employee?.name || '-',
|
||||
id: d.id,
|
||||
userId: d.user_id,
|
||||
userName: d.user?.name || '-',
|
||||
department: profile?.department?.name || '-',
|
||||
position: profile?.job_title_label || '-',
|
||||
rank: profile?.rank || '-',
|
||||
baseSalary: parseFloat(apiData.base_salary),
|
||||
allowance: parseFloat(apiData.total_allowance),
|
||||
overtime: parseFloat(apiData.total_overtime),
|
||||
bonus: parseFloat(apiData.total_bonus),
|
||||
deduction: parseFloat(apiData.total_deduction),
|
||||
netPayment: parseFloat(apiData.net_payment),
|
||||
paymentDate: apiData.payment_date || '',
|
||||
status: apiData.status as PaymentStatus,
|
||||
year: apiData.year,
|
||||
month: apiData.month,
|
||||
createdAt: apiData.created_at,
|
||||
updatedAt: apiData.updated_at,
|
||||
baseSalary: toNum(d.base_salary),
|
||||
overtimePay: toNum(d.overtime_pay),
|
||||
bonus: toNum(d.bonus),
|
||||
allowancesTotal: (d.allowances || []).reduce((s, a) => s + a.amount, 0),
|
||||
grossSalary: toNum(d.gross_salary),
|
||||
totalDeductions: toNum(d.total_deductions),
|
||||
netSalary: toNum(d.net_salary),
|
||||
status: d.status,
|
||||
payYear: d.pay_year,
|
||||
payMonth: d.pay_month,
|
||||
note: d.note,
|
||||
createdAt: d.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
// API → Frontend 변환 (상세용)
|
||||
function transformApiToDetail(apiData: SalaryApiData): SalaryDetail {
|
||||
const allowanceDetails = apiData.allowance_details || {};
|
||||
const deductionDetails = apiData.deduction_details || {};
|
||||
const profile = apiData.employee_profile;
|
||||
|
||||
function transformToDetail(d: PayrollApiData): PayrollDetail {
|
||||
const profile = d.user?.employee_profile;
|
||||
return {
|
||||
employeeId: apiData.employee?.user_id || `EMP${String(apiData.employee_id).padStart(3, '0')}`,
|
||||
employeeName: apiData.employee?.name || '-',
|
||||
id: d.id,
|
||||
userId: d.user_id,
|
||||
userName: d.user?.name || '-',
|
||||
userEmail: d.user?.email || '',
|
||||
department: profile?.department?.name || '-',
|
||||
position: profile?.job_title_label || '-',
|
||||
position: profile?.job_title_label || profile?.position_label || '-',
|
||||
rank: profile?.rank || '-',
|
||||
baseSalary: parseFloat(apiData.base_salary),
|
||||
allowances: {
|
||||
positionAllowance: allowanceDetails.position_allowance || 0,
|
||||
overtimeAllowance: allowanceDetails.overtime_allowance || 0,
|
||||
mealAllowance: allowanceDetails.meal_allowance || 0,
|
||||
transportAllowance: allowanceDetails.transport_allowance || 0,
|
||||
otherAllowance: allowanceDetails.other_allowance || 0,
|
||||
},
|
||||
deductions: {
|
||||
nationalPension: deductionDetails.national_pension || 0,
|
||||
healthInsurance: deductionDetails.health_insurance || 0,
|
||||
longTermCare: deductionDetails.long_term_care || 0,
|
||||
employmentInsurance: deductionDetails.employment_insurance || 0,
|
||||
incomeTax: deductionDetails.income_tax || 0,
|
||||
localIncomeTax: deductionDetails.local_income_tax || 0,
|
||||
otherDeduction: deductionDetails.other_deduction || 0,
|
||||
},
|
||||
totalAllowance: parseFloat(apiData.total_allowance),
|
||||
totalDeduction: parseFloat(apiData.total_deduction),
|
||||
netPayment: parseFloat(apiData.net_payment),
|
||||
paymentDate: apiData.payment_date || '',
|
||||
status: apiData.status as PaymentStatus,
|
||||
year: apiData.year,
|
||||
month: apiData.month,
|
||||
baseSalary: toNum(d.base_salary),
|
||||
overtimePay: toNum(d.overtime_pay),
|
||||
bonus: toNum(d.bonus),
|
||||
allowances: d.allowances || [],
|
||||
grossSalary: toNum(d.gross_salary),
|
||||
pension: toNum(d.pension),
|
||||
healthInsurance: toNum(d.health_insurance),
|
||||
longTermCare: toNum(d.long_term_care),
|
||||
employmentInsurance: toNum(d.employment_insurance),
|
||||
incomeTax: toNum(d.income_tax),
|
||||
residentTax: toNum(d.resident_tax),
|
||||
deductions: d.deductions || [],
|
||||
totalDeductions: toNum(d.total_deductions),
|
||||
netSalary: toNum(d.net_salary),
|
||||
status: d.status,
|
||||
payYear: d.pay_year,
|
||||
payMonth: d.pay_month,
|
||||
note: d.note,
|
||||
confirmedAt: d.confirmed_at,
|
||||
paidAt: d.paid_at,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 급여 목록 조회 =====
|
||||
export async function getSalaries(params?: {
|
||||
search?: string; year?: number; month?: number; status?: string;
|
||||
employee_id?: number; start_date?: string; end_date?: string;
|
||||
page?: number; per_page?: number;
|
||||
function transformSettings(d: SettingsApiData): PayrollSettings {
|
||||
return {
|
||||
id: d.id,
|
||||
healthInsuranceRate: d.health_insurance_rate,
|
||||
longTermCareRate: d.long_term_care_rate,
|
||||
pensionRate: d.pension_rate,
|
||||
employmentInsuranceRate: d.employment_insurance_rate,
|
||||
incomeTaxRate: d.income_tax_rate,
|
||||
residentTaxRate: d.resident_tax_rate,
|
||||
pensionMaxSalary: d.pension_max_salary,
|
||||
pensionMinSalary: d.pension_min_salary,
|
||||
payDay: d.pay_day,
|
||||
autoCalculate: d.auto_calculate,
|
||||
allowanceTypes: d.allowance_types || [],
|
||||
deductionTypes: d.deduction_types || [],
|
||||
};
|
||||
}
|
||||
|
||||
// ============================
|
||||
// 급여 목록 조회
|
||||
// ============================
|
||||
export async function getPayrolls(params?: {
|
||||
year?: number;
|
||||
month?: number;
|
||||
search?: string;
|
||||
status?: string;
|
||||
user_id?: number;
|
||||
sort_by?: string;
|
||||
sort_dir?: string;
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data?: SalaryRecord[];
|
||||
data?: PayrollRecord[];
|
||||
pagination?: { total: number; currentPage: number; lastPage: number };
|
||||
error?: string
|
||||
error?: string;
|
||||
}> {
|
||||
const result = await executeServerAction<SalaryPaginationData>({
|
||||
url: buildApiUrl('/api/v1/salaries', {
|
||||
search: params?.search,
|
||||
const result = await executeServerAction<PaginationApiData>({
|
||||
url: buildApiUrl('/api/v1/payrolls', {
|
||||
year: params?.year,
|
||||
month: params?.month,
|
||||
search: params?.search,
|
||||
status: params?.status && params.status !== 'all' ? params.status : undefined,
|
||||
employee_id: params?.employee_id,
|
||||
start_date: params?.start_date,
|
||||
end_date: params?.end_date,
|
||||
user_id: params?.user_id,
|
||||
sort_by: params?.sort_by,
|
||||
sort_dir: params?.sort_dir,
|
||||
page: params?.page,
|
||||
per_page: params?.per_page,
|
||||
}),
|
||||
@@ -161,7 +209,7 @@ export async function getSalaries(params?: {
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.data.data.map(transformApiToFrontend),
|
||||
data: result.data.data.map(transformToRecord),
|
||||
pagination: {
|
||||
total: result.data.total,
|
||||
currentPage: result.data.current_page,
|
||||
@@ -170,138 +218,400 @@ export async function getSalaries(params?: {
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 급여 상세 조회 =====
|
||||
export async function getSalary(id: string): Promise<{
|
||||
success: boolean; data?: SalaryDetail; error?: string
|
||||
// ============================
|
||||
// 급여 상세 조회
|
||||
// ============================
|
||||
export async function getPayroll(id: number): Promise<{
|
||||
success: boolean;
|
||||
data?: PayrollDetail;
|
||||
error?: string;
|
||||
}> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/salaries/${id}`),
|
||||
transform: (data: SalaryApiData) => transformApiToDetail(data),
|
||||
errorMessage: '급여 정보를 불러오는데 실패했습니다.',
|
||||
url: buildApiUrl(`/api/v1/payrolls/${id}`),
|
||||
transform: (d: PayrollApiData) => transformToDetail(d),
|
||||
errorMessage: '급여 상세를 불러오는데 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
// ===== 급여 상태 변경 =====
|
||||
export async function updateSalaryStatus(
|
||||
id: string,
|
||||
status: PaymentStatus
|
||||
): Promise<{ success: boolean; data?: SalaryRecord; error?: string }> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/salaries/${id}/status`),
|
||||
method: 'PATCH',
|
||||
body: { status },
|
||||
transform: (data: SalaryApiData) => transformApiToFrontend(data),
|
||||
errorMessage: '상태 변경에 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
// ===== 급여 일괄 상태 변경 =====
|
||||
export async function bulkUpdateSalaryStatus(
|
||||
ids: string[],
|
||||
status: PaymentStatus
|
||||
): Promise<{ success: boolean; updatedCount?: number; error?: string }> {
|
||||
const result = await executeServerAction<{ updated_count: number }>({
|
||||
url: buildApiUrl('/api/v1/salaries/bulk-update-status'),
|
||||
method: 'POST',
|
||||
body: { ids: ids.map(id => parseInt(id, 10)), status },
|
||||
errorMessage: '일괄 상태 변경에 실패했습니다.',
|
||||
});
|
||||
if (!result.success || !result.data) return { success: false, error: result.error };
|
||||
return { success: true, updatedCount: result.data.updated_count };
|
||||
}
|
||||
|
||||
// ===== 급여 수정 =====
|
||||
export async function updateSalary(
|
||||
id: string,
|
||||
data: {
|
||||
base_salary?: number;
|
||||
allowance_details?: Record<string, number>;
|
||||
deduction_details?: Record<string, number>;
|
||||
status?: PaymentStatus;
|
||||
payment_date?: string;
|
||||
}
|
||||
): Promise<{ success: boolean; data?: SalaryDetail; error?: string }> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/salaries/${id}`),
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
transform: (d: SalaryApiData) => transformApiToDetail(d),
|
||||
errorMessage: '급여 수정에 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
// ===== 급여 등록 =====
|
||||
export async function createSalary(data: {
|
||||
employee_id: number;
|
||||
// ============================
|
||||
// 월간 요약
|
||||
// ============================
|
||||
export async function getPayrollSummary(params: {
|
||||
year: number;
|
||||
month: number;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data?: PayrollSummary;
|
||||
error?: string;
|
||||
}> {
|
||||
const result = await executeServerAction<SummaryApiData>({
|
||||
url: buildApiUrl('/api/v1/payrolls/summary', {
|
||||
year: params.year,
|
||||
month: params.month,
|
||||
}),
|
||||
errorMessage: '급여 요약을 불러오는데 실패했습니다.',
|
||||
});
|
||||
|
||||
if (!result.success || !result.data) return { success: false, error: result.error };
|
||||
|
||||
const d = result.data;
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
year: d.year,
|
||||
month: d.month,
|
||||
totalCount: d.total_count,
|
||||
draftCount: d.draft_count,
|
||||
confirmedCount: d.confirmed_count,
|
||||
paidCount: d.paid_count,
|
||||
totalGross: d.total_gross,
|
||||
totalDeductions: d.total_deductions,
|
||||
totalNet: d.total_net,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ============================
|
||||
// 급여 등록
|
||||
// ============================
|
||||
export async function createPayroll(data: {
|
||||
user_id: number;
|
||||
pay_year: number;
|
||||
pay_month: number;
|
||||
base_salary: number;
|
||||
allowance_details?: Record<string, number>;
|
||||
deduction_details?: Record<string, number>;
|
||||
payment_date?: string;
|
||||
status?: PaymentStatus;
|
||||
}): Promise<{ success: boolean; data?: SalaryDetail; error?: string }> {
|
||||
overtime_pay?: number;
|
||||
bonus?: number;
|
||||
allowances?: NameAmountItem[];
|
||||
income_tax?: number;
|
||||
resident_tax?: number;
|
||||
health_insurance?: number;
|
||||
long_term_care?: number;
|
||||
pension?: number;
|
||||
employment_insurance?: number;
|
||||
deductions?: NameAmountItem[];
|
||||
note?: string;
|
||||
}): Promise<{ success: boolean; data?: PayrollDetail; error?: string }> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl('/api/v1/salaries'),
|
||||
url: buildApiUrl('/api/v1/payrolls'),
|
||||
method: 'POST',
|
||||
body: data,
|
||||
transform: (d: SalaryApiData) => transformApiToDetail(d),
|
||||
transform: (d: PayrollApiData) => transformToDetail(d),
|
||||
errorMessage: '급여 등록에 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
// ===== 급여 통계 조회 =====
|
||||
export async function getSalaryStatistics(params?: {
|
||||
year?: number; month?: number; start_date?: string; end_date?: string;
|
||||
}): Promise<{
|
||||
// ============================
|
||||
// 급여 수정 (draft만)
|
||||
// ============================
|
||||
export async function updatePayroll(
|
||||
id: number,
|
||||
data: {
|
||||
base_salary?: number;
|
||||
overtime_pay?: number;
|
||||
bonus?: number;
|
||||
allowances?: NameAmountItem[];
|
||||
income_tax?: number;
|
||||
resident_tax?: number;
|
||||
health_insurance?: number;
|
||||
long_term_care?: number;
|
||||
pension?: number;
|
||||
employment_insurance?: number;
|
||||
deductions?: NameAmountItem[];
|
||||
note?: string;
|
||||
}
|
||||
): Promise<{ success: boolean; data?: PayrollDetail; error?: string }> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/payrolls/${id}`),
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
transform: (d: PayrollApiData) => transformToDetail(d),
|
||||
errorMessage: '급여 수정에 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
// ============================
|
||||
// 급여 삭제 (draft만)
|
||||
// ============================
|
||||
export async function deletePayroll(id: number): Promise<{
|
||||
success: boolean;
|
||||
data?: {
|
||||
totalNetPayment: number; totalBaseSalary: number; totalAllowance: number;
|
||||
totalOvertime: number; totalBonus: number; totalDeduction: number;
|
||||
count: number; scheduledCount: number; completedCount: number;
|
||||
};
|
||||
error?: string
|
||||
error?: string;
|
||||
}> {
|
||||
const result = await executeServerAction<StatisticsApiData>({
|
||||
url: buildApiUrl('/api/v1/salaries/statistics', {
|
||||
year: params?.year,
|
||||
month: params?.month,
|
||||
start_date: params?.start_date,
|
||||
end_date: params?.end_date,
|
||||
}),
|
||||
errorMessage: '통계 정보를 불러오는데 실패했습니다.',
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/payrolls/${id}`),
|
||||
method: 'DELETE',
|
||||
errorMessage: '급여 삭제에 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, error: result.error };
|
||||
}
|
||||
|
||||
// ============================
|
||||
// 확정
|
||||
// ============================
|
||||
export async function confirmPayroll(id: number): Promise<{
|
||||
success: boolean;
|
||||
data?: PayrollDetail;
|
||||
error?: string;
|
||||
}> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/payrolls/${id}/confirm`),
|
||||
method: 'POST',
|
||||
transform: (d: PayrollApiData) => transformToDetail(d),
|
||||
errorMessage: '급여 확정에 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
// ============================
|
||||
// 확정 취소
|
||||
// ============================
|
||||
export async function unconfirmPayroll(id: number): Promise<{
|
||||
success: boolean;
|
||||
data?: PayrollDetail;
|
||||
error?: string;
|
||||
}> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/payrolls/${id}/unconfirm`),
|
||||
method: 'POST',
|
||||
transform: (d: PayrollApiData) => transformToDetail(d),
|
||||
errorMessage: '확정 취소에 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
// ============================
|
||||
// 지급 처리
|
||||
// ============================
|
||||
export async function payPayroll(
|
||||
id: number,
|
||||
withdrawalId?: number
|
||||
): Promise<{ success: boolean; data?: PayrollDetail; error?: string }> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/payrolls/${id}/pay`),
|
||||
method: 'POST',
|
||||
body: withdrawalId ? { withdrawal_id: withdrawalId } : undefined,
|
||||
transform: (d: PayrollApiData) => transformToDetail(d),
|
||||
errorMessage: '지급 처리에 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
// ============================
|
||||
// 지급 취소 (슈퍼관리자)
|
||||
// ============================
|
||||
export async function unpayPayroll(id: number): Promise<{
|
||||
success: boolean;
|
||||
data?: PayrollDetail;
|
||||
error?: string;
|
||||
}> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/payrolls/${id}/unpay`),
|
||||
method: 'POST',
|
||||
transform: (d: PayrollApiData) => transformToDetail(d),
|
||||
errorMessage: '지급 취소에 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
// ============================
|
||||
// 일괄 계산 (draft 재계산)
|
||||
// ============================
|
||||
export async function calculatePayrolls(params: {
|
||||
year: number;
|
||||
month: number;
|
||||
user_ids?: number[];
|
||||
}): Promise<{ success: boolean; data?: PayrollRecord[]; error?: string }> {
|
||||
const result = await executeServerAction<PayrollApiData[]>({
|
||||
url: buildApiUrl('/api/v1/payrolls/calculate'),
|
||||
method: 'POST',
|
||||
body: params,
|
||||
errorMessage: '일괄 계산에 실패했습니다.',
|
||||
});
|
||||
|
||||
if (!result.success || !result.data) return { success: false, error: result.error };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
totalNetPayment: result.data.total_net_payment,
|
||||
totalBaseSalary: result.data.total_base_salary,
|
||||
totalAllowance: result.data.total_allowance,
|
||||
totalOvertime: result.data.total_overtime,
|
||||
totalBonus: result.data.total_bonus,
|
||||
totalDeduction: result.data.total_deduction,
|
||||
count: result.data.count,
|
||||
scheduledCount: result.data.scheduled_count,
|
||||
completedCount: result.data.completed_count,
|
||||
},
|
||||
};
|
||||
return { success: true, data: result.data.map(transformToRecord) };
|
||||
}
|
||||
|
||||
// ===== 급여 엑셀 내보내기 (native fetch - keep as-is) =====
|
||||
export async function exportSalaryExcel(params?: {
|
||||
// ============================
|
||||
// 일괄 확정
|
||||
// ============================
|
||||
export async function bulkConfirmPayrolls(params: {
|
||||
year: number;
|
||||
month: number;
|
||||
}): Promise<{ success: boolean; count?: number; error?: string }> {
|
||||
const result = await executeServerAction<{ count: number }>({
|
||||
url: buildApiUrl('/api/v1/payrolls/bulk-confirm'),
|
||||
method: 'POST',
|
||||
body: params,
|
||||
errorMessage: '일괄 확정에 실패했습니다.',
|
||||
});
|
||||
if (!result.success || !result.data) return { success: false, error: result.error };
|
||||
return { success: true, count: result.data.count };
|
||||
}
|
||||
|
||||
// ============================
|
||||
// 재직사원 일괄 생성
|
||||
// ============================
|
||||
export async function bulkGeneratePayrolls(params: {
|
||||
year: number;
|
||||
month: number;
|
||||
}): Promise<{ success: boolean; count?: number; error?: string }> {
|
||||
const result = await executeServerAction<{ count: number } | PayrollApiData[]>({
|
||||
url: buildApiUrl('/api/v1/payrolls/bulk-generate'),
|
||||
method: 'POST',
|
||||
body: params,
|
||||
errorMessage: '일괄 생성에 실패했습니다.',
|
||||
});
|
||||
if (!result.success || !result.data) return { success: false, error: result.error };
|
||||
|
||||
const count = Array.isArray(result.data)
|
||||
? result.data.length
|
||||
: (result.data as { count: number }).count;
|
||||
return { success: true, count };
|
||||
}
|
||||
|
||||
// ============================
|
||||
// 전월 급여 복사
|
||||
// ============================
|
||||
export async function copyFromPrevious(params: {
|
||||
year: number;
|
||||
month: number;
|
||||
}): Promise<{ success: boolean; count?: number; error?: string }> {
|
||||
const result = await executeServerAction<{ count: number } | PayrollApiData[]>({
|
||||
url: buildApiUrl('/api/v1/payrolls/copy-from-previous'),
|
||||
method: 'POST',
|
||||
body: params,
|
||||
errorMessage: '전월 복사에 실패했습니다.',
|
||||
});
|
||||
if (!result.success || !result.data) return { success: false, error: result.error };
|
||||
|
||||
const count = Array.isArray(result.data)
|
||||
? result.data.length
|
||||
: (result.data as { count: number }).count;
|
||||
return { success: true, count };
|
||||
}
|
||||
|
||||
// ============================
|
||||
// 급여명세서
|
||||
// ============================
|
||||
export async function getPayslip(id: number): Promise<{
|
||||
success: boolean;
|
||||
data?: {
|
||||
period: string;
|
||||
employee: { id: number; name: string; email: string };
|
||||
earnings: {
|
||||
base_salary: number;
|
||||
overtime_pay: number;
|
||||
bonus: number;
|
||||
allowances: NameAmountItem[];
|
||||
allowances_total: number;
|
||||
gross_total: number;
|
||||
};
|
||||
deductions: {
|
||||
income_tax: number;
|
||||
resident_tax: number;
|
||||
health_insurance: number;
|
||||
pension: number;
|
||||
employment_insurance: number;
|
||||
other_deductions: NameAmountItem[];
|
||||
other_total: number;
|
||||
total: number;
|
||||
};
|
||||
net_salary: number;
|
||||
status: string;
|
||||
status_label: string;
|
||||
paid_at: string | null;
|
||||
};
|
||||
error?: string;
|
||||
}> {
|
||||
interface PayslipApiData {
|
||||
period: string;
|
||||
employee: { id: number; name: string; email: string };
|
||||
earnings: {
|
||||
base_salary: number;
|
||||
overtime_pay: number;
|
||||
bonus: number;
|
||||
allowances: NameAmountItem[];
|
||||
allowances_total: number;
|
||||
gross_total: number;
|
||||
};
|
||||
deductions: {
|
||||
income_tax: number;
|
||||
resident_tax: number;
|
||||
health_insurance: number;
|
||||
pension: number;
|
||||
employment_insurance: number;
|
||||
other_deductions: NameAmountItem[];
|
||||
other_total: number;
|
||||
total: number;
|
||||
};
|
||||
net_salary: number;
|
||||
status: string;
|
||||
status_label: string;
|
||||
paid_at: string | null;
|
||||
}
|
||||
|
||||
const result = await executeServerAction<PayslipApiData>({
|
||||
url: buildApiUrl(`/api/v1/payrolls/${id}/payslip`),
|
||||
errorMessage: '급여명세서를 불러오는데 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, data: result.data ?? undefined, error: result.error };
|
||||
}
|
||||
|
||||
// ============================
|
||||
// 급여 설정 조회
|
||||
// ============================
|
||||
export async function getPayrollSettings(): Promise<{
|
||||
success: boolean;
|
||||
data?: PayrollSettings;
|
||||
error?: string;
|
||||
}> {
|
||||
const result = await executeServerAction<SettingsApiData>({
|
||||
url: buildApiUrl('/api/v1/settings/payroll'),
|
||||
errorMessage: '급여 설정을 불러오는데 실패했습니다.',
|
||||
});
|
||||
|
||||
if (!result.success || !result.data) return { success: false, error: result.error };
|
||||
return { success: true, data: transformSettings(result.data) };
|
||||
}
|
||||
|
||||
// ============================
|
||||
// 급여 설정 수정
|
||||
// ============================
|
||||
export async function updatePayrollSettings(data: {
|
||||
health_insurance_rate?: number;
|
||||
long_term_care_rate?: number;
|
||||
pension_rate?: number;
|
||||
employment_insurance_rate?: number;
|
||||
income_tax_rate?: number;
|
||||
resident_tax_rate?: number;
|
||||
pension_max_salary?: number;
|
||||
pension_min_salary?: number;
|
||||
pay_day?: number;
|
||||
auto_calculate?: boolean;
|
||||
allowance_types?: { code: string; name: string; is_taxable?: boolean }[];
|
||||
deduction_types?: { code: string; name: string }[];
|
||||
}): Promise<{ success: boolean; data?: PayrollSettings; error?: string }> {
|
||||
const result = await executeServerAction<SettingsApiData>({
|
||||
url: buildApiUrl('/api/v1/settings/payroll'),
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
errorMessage: '급여 설정 저장에 실패했습니다.',
|
||||
});
|
||||
|
||||
if (!result.success || !result.data) return { success: false, error: result.error };
|
||||
return { success: true, data: transformSettings(result.data) };
|
||||
}
|
||||
|
||||
// ============================
|
||||
// 엑셀 내보내기
|
||||
// ============================
|
||||
export async function exportPayrollExcel(params?: {
|
||||
year?: number;
|
||||
month?: number;
|
||||
status?: string;
|
||||
employee_id?: number;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data?: Blob;
|
||||
@@ -318,19 +628,13 @@ export async function exportSalaryExcel(params?: {
|
||||
'X-API-KEY': process.env.API_KEY || '',
|
||||
};
|
||||
|
||||
const url = buildApiUrl('/api/v1/salaries/export', {
|
||||
const url = buildApiUrl('/api/v1/payrolls/export', {
|
||||
year: params?.year,
|
||||
month: params?.month,
|
||||
status: params?.status && params.status !== 'all' ? params.status : undefined,
|
||||
employee_id: params?.employee_id,
|
||||
start_date: params?.start_date,
|
||||
end_date: params?.end_date,
|
||||
});
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
});
|
||||
const response = await fetch(url, { method: 'GET', headers });
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false, error: `API 오류: ${response.status}` };
|
||||
@@ -339,7 +643,7 @@ export async function exportSalaryExcel(params?: {
|
||||
const blob = await response.blob();
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
const filenameMatch = contentDisposition?.match(/filename="?(.+)"?/);
|
||||
const filename = filenameMatch?.[1] || `급여명세_${params?.year || 'all'}_${params?.month || 'all'}.xlsx`;
|
||||
const filename = filenameMatch?.[1] || `급여_${params?.year || 'all'}_${params?.month || 'all'}.xlsx`;
|
||||
|
||||
return { success: true, data: blob, filename };
|
||||
} catch (error) {
|
||||
@@ -347,3 +651,19 @@ export async function exportSalaryExcel(params?: {
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================
|
||||
// 전표 생성
|
||||
// ============================
|
||||
export async function createJournalEntries(params: {
|
||||
year: number;
|
||||
month: number;
|
||||
entry_date?: string;
|
||||
}): Promise<{ success: boolean; error?: string; data?: unknown }> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/payrolls/journal-entries'),
|
||||
method: 'POST',
|
||||
body: params,
|
||||
errorMessage: '전표 생성에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,100 +1,144 @@
|
||||
/**
|
||||
* 급여관리 타입 정의
|
||||
* 급여관리 타입 정의 (payrolls API 기준)
|
||||
*/
|
||||
|
||||
// 급여 상태 타입
|
||||
export type PaymentStatus = 'scheduled' | 'completed';
|
||||
// ===== 상태 =====
|
||||
export type PayrollStatus = 'draft' | 'confirmed' | 'paid';
|
||||
|
||||
// 정렬 옵션 타입
|
||||
export type SortOption = 'rank' | 'name' | 'department' | 'paymentDate';
|
||||
|
||||
// 급여 레코드 인터페이스
|
||||
export interface SalaryRecord {
|
||||
id: string;
|
||||
employeeId: string;
|
||||
employeeName: string;
|
||||
department: string;
|
||||
position: string;
|
||||
rank: string;
|
||||
baseSalary: number; // 기본급
|
||||
allowance: number; // 수당
|
||||
overtime: number; // 초과근무
|
||||
bonus: number; // 상여
|
||||
deduction: number; // 공제
|
||||
netPayment: number; // 실지급액
|
||||
paymentDate: string; // 지급일
|
||||
status: PaymentStatus; // 상태
|
||||
year: number; // 년도
|
||||
month: number; // 월
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
// ===== 동적 항목 (수당/공제) =====
|
||||
export interface NameAmountItem {
|
||||
name: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
// 급여 상세 정보 인터페이스
|
||||
export interface SalaryDetail {
|
||||
// 기본 정보
|
||||
employeeId: string;
|
||||
employeeName: string;
|
||||
// ===== 급여 목록 레코드 =====
|
||||
export interface PayrollRecord {
|
||||
id: number;
|
||||
userId: number;
|
||||
userName: string;
|
||||
department: string;
|
||||
baseSalary: number;
|
||||
overtimePay: number;
|
||||
bonus: number;
|
||||
allowancesTotal: number;
|
||||
grossSalary: number;
|
||||
totalDeductions: number;
|
||||
netSalary: number;
|
||||
status: PayrollStatus;
|
||||
payYear: number;
|
||||
payMonth: number;
|
||||
note: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// ===== 급여 상세 =====
|
||||
export interface PayrollDetail {
|
||||
id: number;
|
||||
userId: number;
|
||||
userName: string;
|
||||
userEmail: string;
|
||||
department: string;
|
||||
position: string;
|
||||
rank: string;
|
||||
// 지급 항목
|
||||
baseSalary: number;
|
||||
overtimePay: number;
|
||||
bonus: number;
|
||||
allowances: NameAmountItem[];
|
||||
grossSalary: number;
|
||||
// 법정 공제
|
||||
pension: number;
|
||||
healthInsurance: number;
|
||||
longTermCare: number;
|
||||
employmentInsurance: number;
|
||||
incomeTax: number;
|
||||
residentTax: number;
|
||||
// 추가 공제
|
||||
deductions: NameAmountItem[];
|
||||
totalDeductions: number;
|
||||
netSalary: number;
|
||||
// 상태/메타
|
||||
status: PayrollStatus;
|
||||
payYear: number;
|
||||
payMonth: number;
|
||||
note: string | null;
|
||||
confirmedAt: string | null;
|
||||
paidAt: string | null;
|
||||
}
|
||||
|
||||
// 급여 정보
|
||||
baseSalary: number; // 본봉
|
||||
|
||||
// 수당 내역
|
||||
allowances: {
|
||||
positionAllowance: number; // 직책수당
|
||||
overtimeAllowance: number; // 초과근무수당
|
||||
mealAllowance: number; // 식대
|
||||
transportAllowance: number; // 교통비
|
||||
otherAllowance: number; // 기타수당
|
||||
};
|
||||
|
||||
// 공제 내역
|
||||
deductions: {
|
||||
nationalPension: number; // 국민연금
|
||||
healthInsurance: number; // 건강보험
|
||||
longTermCare: number; // 장기요양보험
|
||||
employmentInsurance: number; // 고용보험
|
||||
incomeTax: number; // 소득세
|
||||
localIncomeTax: number; // 지방소득세
|
||||
otherDeduction: number; // 기타공제
|
||||
};
|
||||
|
||||
// 합계
|
||||
totalAllowance: number; // 수당 합계
|
||||
totalDeduction: number; // 공제 합계
|
||||
netPayment: number; // 실지급액
|
||||
|
||||
// 추가 정보
|
||||
paymentDate: string;
|
||||
status: PaymentStatus;
|
||||
// ===== 월간 요약 =====
|
||||
export interface PayrollSummary {
|
||||
year: number;
|
||||
month: number;
|
||||
totalCount: number;
|
||||
draftCount: number;
|
||||
confirmedCount: number;
|
||||
paidCount: number;
|
||||
totalGross: number;
|
||||
totalDeductions: number;
|
||||
totalNet: number;
|
||||
}
|
||||
|
||||
// 상태 라벨
|
||||
export const PAYMENT_STATUS_LABELS: Record<PaymentStatus, string> = {
|
||||
scheduled: '지급예정',
|
||||
completed: '지급완료',
|
||||
// ===== 급여 설정 =====
|
||||
export interface PayrollSettings {
|
||||
id?: number;
|
||||
healthInsuranceRate: number;
|
||||
longTermCareRate: number;
|
||||
pensionRate: number;
|
||||
employmentInsuranceRate: number;
|
||||
incomeTaxRate: number;
|
||||
residentTaxRate: number;
|
||||
pensionMaxSalary: number;
|
||||
pensionMinSalary: number;
|
||||
payDay: number;
|
||||
autoCalculate: boolean;
|
||||
allowanceTypes: { code: string; name: string; is_taxable?: boolean }[];
|
||||
deductionTypes: { code: string; name: string }[];
|
||||
}
|
||||
|
||||
// ===== 급여명세서 =====
|
||||
export interface Payslip {
|
||||
payroll: PayrollDetail;
|
||||
period: string;
|
||||
employee: { id: number; name: string; email: string };
|
||||
earnings: {
|
||||
baseSalary: number;
|
||||
overtimePay: number;
|
||||
bonus: number;
|
||||
allowances: NameAmountItem[];
|
||||
allowancesTotal: number;
|
||||
grossTotal: number;
|
||||
};
|
||||
deductions: {
|
||||
incomeTax: number;
|
||||
residentTax: number;
|
||||
healthInsurance: number;
|
||||
pension: number;
|
||||
employmentInsurance: number;
|
||||
otherDeductions: NameAmountItem[];
|
||||
otherTotal: number;
|
||||
total: number;
|
||||
};
|
||||
netSalary: number;
|
||||
status: PayrollStatus;
|
||||
statusLabel: string;
|
||||
paidAt: string | null;
|
||||
}
|
||||
|
||||
// ===== 상수 =====
|
||||
export const PAYROLL_STATUS_LABELS: Record<PayrollStatus, string> = {
|
||||
draft: '작성중',
|
||||
confirmed: '확정',
|
||||
paid: '지급완료',
|
||||
};
|
||||
|
||||
// 상태 색상
|
||||
export const PAYMENT_STATUS_COLORS: Record<PaymentStatus, string> = {
|
||||
scheduled: 'bg-blue-100 text-blue-800',
|
||||
completed: 'bg-green-100 text-green-800',
|
||||
export const PAYROLL_STATUS_COLORS: Record<PayrollStatus, string> = {
|
||||
draft: 'bg-gray-100 text-gray-800',
|
||||
confirmed: 'bg-blue-100 text-blue-800',
|
||||
paid: 'bg-green-100 text-green-800',
|
||||
};
|
||||
|
||||
// 정렬 옵션 라벨
|
||||
export const SORT_OPTIONS: Record<SortOption, string> = {
|
||||
rank: '직급순',
|
||||
name: '이름순',
|
||||
department: '부서순',
|
||||
paymentDate: '지급일순',
|
||||
};
|
||||
|
||||
// 금액 포맷 유틸리티
|
||||
// ===== 유틸리티 =====
|
||||
export const formatCurrency = (amount: number): string => {
|
||||
return new Intl.NumberFormat('ko-KR').format(amount);
|
||||
};
|
||||
return new Intl.NumberFormat('ko-KR').format(Math.round(amount));
|
||||
};
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Save, Pencil, X } from 'lucide-react';
|
||||
import type { SalaryDetail, PaymentStatus } from './types';
|
||||
import {
|
||||
PAYMENT_STATUS_LABELS,
|
||||
PAYMENT_STATUS_COLORS,
|
||||
formatCurrency,
|
||||
} from './types';
|
||||
|
||||
interface AllowanceEdits {
|
||||
positionAllowance: number;
|
||||
overtimeAllowance: number;
|
||||
mealAllowance: number;
|
||||
transportAllowance: number;
|
||||
otherAllowance: number;
|
||||
}
|
||||
|
||||
interface DeductionEdits {
|
||||
nationalPension: number;
|
||||
healthInsurance: number;
|
||||
longTermCare: number;
|
||||
employmentInsurance: number;
|
||||
incomeTax: number;
|
||||
localIncomeTax: number;
|
||||
otherDeduction: number;
|
||||
}
|
||||
|
||||
interface SalaryDetailDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
salaryDetail: SalaryDetail | null;
|
||||
onSave?: (updatedDetail: SalaryDetail, allowanceDetails?: Record<string, number>, deductionDetails?: Record<string, number>) => void;
|
||||
}
|
||||
|
||||
// 행 컴포넌트: 라벨 고정폭 + 값/인풋 오른쪽 정렬
|
||||
function DetailRow({
|
||||
label,
|
||||
value,
|
||||
isEditing,
|
||||
editValue,
|
||||
onChange,
|
||||
color,
|
||||
prefix,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
isEditing?: boolean;
|
||||
editValue?: number;
|
||||
onChange?: (value: number) => void;
|
||||
color?: string;
|
||||
prefix?: string;
|
||||
}) {
|
||||
const editing = isEditing && onChange !== undefined;
|
||||
|
||||
return (
|
||||
<div className={editing ? 'flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2' : 'flex items-center gap-2'}>
|
||||
<span className={editing ? 'text-muted-foreground whitespace-nowrap text-xs sm:text-sm sm:w-24 sm:shrink-0' : 'text-muted-foreground whitespace-nowrap w-20 sm:w-24 shrink-0'}>{label}</span>
|
||||
<div className="flex-1 text-right">
|
||||
{editing ? (
|
||||
<CurrencyInput
|
||||
value={editValue ?? 0}
|
||||
onChange={(v) => onChange!(v ?? 0)}
|
||||
className="w-full h-7 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<span className={color}>{prefix}{value}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SalaryDetailDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
salaryDetail,
|
||||
onSave,
|
||||
}: SalaryDetailDialogProps) {
|
||||
const [editedStatus, setEditedStatus] = useState<PaymentStatus>('scheduled');
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editedAllowances, setEditedAllowances] = useState<AllowanceEdits>({
|
||||
positionAllowance: 0,
|
||||
overtimeAllowance: 0,
|
||||
mealAllowance: 0,
|
||||
transportAllowance: 0,
|
||||
otherAllowance: 0,
|
||||
});
|
||||
const [editedDeductions, setEditedDeductions] = useState<DeductionEdits>({
|
||||
nationalPension: 0,
|
||||
healthInsurance: 0,
|
||||
longTermCare: 0,
|
||||
employmentInsurance: 0,
|
||||
incomeTax: 0,
|
||||
localIncomeTax: 0,
|
||||
otherDeduction: 0,
|
||||
});
|
||||
|
||||
// 다이얼로그가 열릴 때 상태 초기화
|
||||
useEffect(() => {
|
||||
if (salaryDetail) {
|
||||
setEditedStatus(salaryDetail.status);
|
||||
setEditedAllowances({
|
||||
positionAllowance: salaryDetail.allowances.positionAllowance,
|
||||
overtimeAllowance: salaryDetail.allowances.overtimeAllowance,
|
||||
mealAllowance: salaryDetail.allowances.mealAllowance,
|
||||
transportAllowance: salaryDetail.allowances.transportAllowance,
|
||||
otherAllowance: salaryDetail.allowances.otherAllowance,
|
||||
});
|
||||
setEditedDeductions({
|
||||
nationalPension: salaryDetail.deductions.nationalPension,
|
||||
healthInsurance: salaryDetail.deductions.healthInsurance,
|
||||
longTermCare: salaryDetail.deductions.longTermCare,
|
||||
employmentInsurance: salaryDetail.deductions.employmentInsurance,
|
||||
incomeTax: salaryDetail.deductions.incomeTax,
|
||||
localIncomeTax: salaryDetail.deductions.localIncomeTax,
|
||||
otherDeduction: salaryDetail.deductions.otherDeduction,
|
||||
});
|
||||
setHasChanges(false);
|
||||
setIsEditing(false);
|
||||
}
|
||||
}, [salaryDetail]);
|
||||
|
||||
// 변경 사항 확인
|
||||
const checkForChanges = useCallback(() => {
|
||||
if (!salaryDetail) return false;
|
||||
|
||||
const statusChanged = editedStatus !== salaryDetail.status;
|
||||
const allowancesChanged =
|
||||
editedAllowances.positionAllowance !== salaryDetail.allowances.positionAllowance ||
|
||||
editedAllowances.overtimeAllowance !== salaryDetail.allowances.overtimeAllowance ||
|
||||
editedAllowances.mealAllowance !== salaryDetail.allowances.mealAllowance ||
|
||||
editedAllowances.transportAllowance !== salaryDetail.allowances.transportAllowance ||
|
||||
editedAllowances.otherAllowance !== salaryDetail.allowances.otherAllowance;
|
||||
const deductionsChanged =
|
||||
editedDeductions.nationalPension !== salaryDetail.deductions.nationalPension ||
|
||||
editedDeductions.healthInsurance !== salaryDetail.deductions.healthInsurance ||
|
||||
editedDeductions.longTermCare !== salaryDetail.deductions.longTermCare ||
|
||||
editedDeductions.employmentInsurance !== salaryDetail.deductions.employmentInsurance ||
|
||||
editedDeductions.incomeTax !== salaryDetail.deductions.incomeTax ||
|
||||
editedDeductions.localIncomeTax !== salaryDetail.deductions.localIncomeTax ||
|
||||
editedDeductions.otherDeduction !== salaryDetail.deductions.otherDeduction;
|
||||
|
||||
return statusChanged || allowancesChanged || deductionsChanged;
|
||||
}, [salaryDetail, editedStatus, editedAllowances, editedDeductions]);
|
||||
|
||||
useEffect(() => {
|
||||
setHasChanges(checkForChanges());
|
||||
}, [checkForChanges]);
|
||||
|
||||
if (!salaryDetail) return null;
|
||||
|
||||
const handleAllowanceChange = (field: keyof AllowanceEdits, value: number) => {
|
||||
setEditedAllowances(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleDeductionChange = (field: keyof DeductionEdits, value: number) => {
|
||||
setEditedDeductions(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
// 수당 합계 계산
|
||||
const calculateTotalAllowance = () => {
|
||||
return Object.values(editedAllowances).reduce((sum, val) => sum + val, 0);
|
||||
};
|
||||
|
||||
// 공제 합계 계산
|
||||
const calculateTotalDeduction = () => {
|
||||
return Object.values(editedDeductions).reduce((sum, val) => sum + val, 0);
|
||||
};
|
||||
|
||||
// 실지급액 계산
|
||||
const calculateNetPayment = () => {
|
||||
return salaryDetail.baseSalary + calculateTotalAllowance() - calculateTotalDeduction();
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (onSave && salaryDetail) {
|
||||
const allowanceDetails = {
|
||||
position_allowance: editedAllowances.positionAllowance,
|
||||
overtime_allowance: editedAllowances.overtimeAllowance,
|
||||
meal_allowance: editedAllowances.mealAllowance,
|
||||
transport_allowance: editedAllowances.transportAllowance,
|
||||
other_allowance: editedAllowances.otherAllowance,
|
||||
};
|
||||
|
||||
const deductionDetails = {
|
||||
national_pension: editedDeductions.nationalPension,
|
||||
health_insurance: editedDeductions.healthInsurance,
|
||||
long_term_care: editedDeductions.longTermCare,
|
||||
employment_insurance: editedDeductions.employmentInsurance,
|
||||
income_tax: editedDeductions.incomeTax,
|
||||
local_income_tax: editedDeductions.localIncomeTax,
|
||||
other_deduction: editedDeductions.otherDeduction,
|
||||
};
|
||||
|
||||
const updatedDetail: SalaryDetail = {
|
||||
...salaryDetail,
|
||||
status: editedStatus,
|
||||
allowances: editedAllowances,
|
||||
deductions: editedDeductions,
|
||||
totalAllowance: calculateTotalAllowance(),
|
||||
totalDeduction: calculateTotalDeduction(),
|
||||
netPayment: calculateNetPayment(),
|
||||
};
|
||||
|
||||
onSave(updatedDetail, allowanceDetails, deductionDetails);
|
||||
}
|
||||
setHasChanges(false);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleToggleEdit = () => {
|
||||
if (isEditing) {
|
||||
// 편집 취소 - 원래 값으로 복원
|
||||
setEditedAllowances({
|
||||
positionAllowance: salaryDetail.allowances.positionAllowance,
|
||||
overtimeAllowance: salaryDetail.allowances.overtimeAllowance,
|
||||
mealAllowance: salaryDetail.allowances.mealAllowance,
|
||||
transportAllowance: salaryDetail.allowances.transportAllowance,
|
||||
otherAllowance: salaryDetail.allowances.otherAllowance,
|
||||
});
|
||||
setEditedDeductions({
|
||||
nationalPension: salaryDetail.deductions.nationalPension,
|
||||
healthInsurance: salaryDetail.deductions.healthInsurance,
|
||||
longTermCare: salaryDetail.deductions.longTermCare,
|
||||
employmentInsurance: salaryDetail.deductions.employmentInsurance,
|
||||
incomeTax: salaryDetail.deductions.incomeTax,
|
||||
localIncomeTax: salaryDetail.deductions.localIncomeTax,
|
||||
otherDeduction: salaryDetail.deductions.otherDeduction,
|
||||
});
|
||||
}
|
||||
setIsEditing(!isEditing);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto p-4 sm:p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||
<span className="truncate">{salaryDetail.employeeName} 급여</span>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
variant={isEditing ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={handleToggleEdit}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
편집 취소
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pencil className="h-3 w-3 mr-1" />
|
||||
급여 수정
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Select
|
||||
value={editedStatus}
|
||||
onValueChange={(value) => setEditedStatus(value as PaymentStatus)}
|
||||
>
|
||||
<SelectTrigger className="min-w-[100px] sm:min-w-[140px] w-auto">
|
||||
<SelectValue>
|
||||
<Badge className={PAYMENT_STATUS_COLORS[editedStatus]}>
|
||||
{PAYMENT_STATUS_LABELS[editedStatus]}
|
||||
</Badge>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="scheduled">
|
||||
<Badge className={PAYMENT_STATUS_COLORS.scheduled}>
|
||||
{PAYMENT_STATUS_LABELS.scheduled}
|
||||
</Badge>
|
||||
</SelectItem>
|
||||
<SelectItem value="completed">
|
||||
<Badge className={PAYMENT_STATUS_COLORS.completed}>
|
||||
{PAYMENT_STATUS_LABELS.completed}
|
||||
</Badge>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
<div className="bg-muted/50 rounded-lg p-3 sm:p-4">
|
||||
<h3 className="font-semibold mb-3">기본 정보</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">사번</span>
|
||||
<p className="font-medium">{salaryDetail.employeeId}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">이름</span>
|
||||
<p className="font-medium">{salaryDetail.employeeName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">부서</span>
|
||||
<p className="font-medium">{salaryDetail.department}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">직급</span>
|
||||
<p className="font-medium">{salaryDetail.rank}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">직책</span>
|
||||
<p className="font-medium">{salaryDetail.position}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">지급월</span>
|
||||
<p className="font-medium">{salaryDetail.year}년 {salaryDetail.month}월</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">지급일</span>
|
||||
<p className="font-medium">{salaryDetail.paymentDate}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 급여 항목 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 수당 내역 */}
|
||||
<div className="border rounded-lg p-3 sm:p-4 flex flex-col">
|
||||
<h3 className="font-semibold mb-3 text-blue-600">수당 내역</h3>
|
||||
<div className="space-y-2 text-sm flex-1">
|
||||
<DetailRow
|
||||
label="본봉"
|
||||
value={`${formatCurrency(salaryDetail.baseSalary)}원`}
|
||||
/>
|
||||
<Separator />
|
||||
<DetailRow
|
||||
label="직책수당"
|
||||
value={`${formatCurrency(editedAllowances.positionAllowance)}원`}
|
||||
isEditing={isEditing}
|
||||
editValue={editedAllowances.positionAllowance}
|
||||
onChange={(v) => handleAllowanceChange('positionAllowance', v)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="초과근무수당"
|
||||
value={`${formatCurrency(editedAllowances.overtimeAllowance)}원`}
|
||||
isEditing={isEditing}
|
||||
editValue={editedAllowances.overtimeAllowance}
|
||||
onChange={(v) => handleAllowanceChange('overtimeAllowance', v)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="식대"
|
||||
value={`${formatCurrency(editedAllowances.mealAllowance)}원`}
|
||||
isEditing={isEditing}
|
||||
editValue={editedAllowances.mealAllowance}
|
||||
onChange={(v) => handleAllowanceChange('mealAllowance', v)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="교통비"
|
||||
value={`${formatCurrency(editedAllowances.transportAllowance)}원`}
|
||||
isEditing={isEditing}
|
||||
editValue={editedAllowances.transportAllowance}
|
||||
onChange={(v) => handleAllowanceChange('transportAllowance', v)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="기타수당"
|
||||
value={`${formatCurrency(editedAllowances.otherAllowance)}원`}
|
||||
isEditing={isEditing}
|
||||
editValue={editedAllowances.otherAllowance}
|
||||
onChange={(v) => handleAllowanceChange('otherAllowance', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-auto pt-2">
|
||||
<Separator />
|
||||
<div className="flex items-center gap-2 font-semibold text-blue-600 mt-2">
|
||||
<span className="w-20 sm:w-24 shrink-0">수당 합계</span>
|
||||
<span className="flex-1 text-right">{formatCurrency(calculateTotalAllowance())}원</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 공제 내역 */}
|
||||
<div className="border rounded-lg p-3 sm:p-4 flex flex-col">
|
||||
<h3 className="font-semibold mb-3 text-red-600">공제 내역</h3>
|
||||
<div className="space-y-2 text-sm flex-1">
|
||||
<DetailRow
|
||||
label="국민연금"
|
||||
value={`${formatCurrency(editedDeductions.nationalPension)}원`}
|
||||
color="text-red-600"
|
||||
prefix="-"
|
||||
isEditing={isEditing}
|
||||
editValue={editedDeductions.nationalPension}
|
||||
onChange={(v) => handleDeductionChange('nationalPension', v)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="건강보험"
|
||||
value={`${formatCurrency(editedDeductions.healthInsurance)}원`}
|
||||
color="text-red-600"
|
||||
prefix="-"
|
||||
isEditing={isEditing}
|
||||
editValue={editedDeductions.healthInsurance}
|
||||
onChange={(v) => handleDeductionChange('healthInsurance', v)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="장기요양보험"
|
||||
value={`${formatCurrency(editedDeductions.longTermCare)}원`}
|
||||
color="text-red-600"
|
||||
prefix="-"
|
||||
isEditing={isEditing}
|
||||
editValue={editedDeductions.longTermCare}
|
||||
onChange={(v) => handleDeductionChange('longTermCare', v)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="고용보험"
|
||||
value={`${formatCurrency(editedDeductions.employmentInsurance)}원`}
|
||||
color="text-red-600"
|
||||
prefix="-"
|
||||
isEditing={isEditing}
|
||||
editValue={editedDeductions.employmentInsurance}
|
||||
onChange={(v) => handleDeductionChange('employmentInsurance', v)}
|
||||
/>
|
||||
<Separator />
|
||||
<DetailRow
|
||||
label="소득세"
|
||||
value={`${formatCurrency(editedDeductions.incomeTax)}원`}
|
||||
color="text-red-600"
|
||||
prefix="-"
|
||||
isEditing={isEditing}
|
||||
editValue={editedDeductions.incomeTax}
|
||||
onChange={(v) => handleDeductionChange('incomeTax', v)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="지방소득세"
|
||||
value={`${formatCurrency(editedDeductions.localIncomeTax)}원`}
|
||||
color="text-red-600"
|
||||
prefix="-"
|
||||
isEditing={isEditing}
|
||||
editValue={editedDeductions.localIncomeTax}
|
||||
onChange={(v) => handleDeductionChange('localIncomeTax', v)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="기타공제"
|
||||
value={`${formatCurrency(editedDeductions.otherDeduction)}원`}
|
||||
color="text-red-600"
|
||||
prefix="-"
|
||||
isEditing={isEditing}
|
||||
editValue={editedDeductions.otherDeduction}
|
||||
onChange={(v) => handleDeductionChange('otherDeduction', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-auto pt-2">
|
||||
<Separator />
|
||||
<div className="flex items-center gap-2 font-semibold text-red-600 mt-2">
|
||||
<span className="w-20 sm:w-24 shrink-0">공제 합계</span>
|
||||
<span className="flex-1 text-right">-{formatCurrency(calculateTotalDeduction())}원</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 지급 합계 */}
|
||||
<div className="bg-primary/5 border-2 border-primary/20 rounded-lg p-3 sm:p-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 sm:gap-4 text-center">
|
||||
<div>
|
||||
<span className="text-xs sm:text-sm text-muted-foreground block">급여 총액</span>
|
||||
<span className="text-sm sm:text-lg font-semibold text-blue-600">
|
||||
{formatCurrency(salaryDetail.baseSalary + calculateTotalAllowance())}원
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs sm:text-sm text-muted-foreground block">공제 총액</span>
|
||||
<span className="text-sm sm:text-lg font-semibold text-red-600">
|
||||
-{formatCurrency(calculateTotalDeduction())}원
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs sm:text-sm text-muted-foreground block">실지급액</span>
|
||||
<span className="text-base sm:text-xl font-bold text-primary">
|
||||
{formatCurrency(calculateNetPayment())}원
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 저장 버튼 */}
|
||||
<DialogFooter className="mt-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges}
|
||||
className="gap-2"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useMemo, useRef } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { SearchableSelectionModal } from '@/components/organisms/SearchableSelectionModal';
|
||||
import { Save, Search, UserPlus } from 'lucide-react';
|
||||
import { getEmployees } from '@/components/hr/EmployeeManagement/actions';
|
||||
import type { Employee } from '@/components/hr/EmployeeManagement/types';
|
||||
import { formatCurrency } from './types';
|
||||
|
||||
// ===== 기본값 상수 =====
|
||||
const DEFAULT_ALLOWANCES = {
|
||||
positionAllowance: 0,
|
||||
overtimeAllowance: 0,
|
||||
mealAllowance: 150000,
|
||||
transportAllowance: 100000,
|
||||
otherAllowance: 0,
|
||||
};
|
||||
|
||||
const DEFAULT_DEDUCTIONS = {
|
||||
nationalPension: 0,
|
||||
healthInsurance: 0,
|
||||
longTermCare: 0,
|
||||
employmentInsurance: 0,
|
||||
incomeTax: 0,
|
||||
localIncomeTax: 0,
|
||||
otherDeduction: 0,
|
||||
};
|
||||
|
||||
// 기본급 기준 4대보험 + 세금 자동 계산
|
||||
function calculateDefaultDeductions(baseSalary: number) {
|
||||
const nationalPension = Math.round(baseSalary * 0.045); // 국민연금 4.5%
|
||||
const healthInsurance = Math.round(baseSalary * 0.03545); // 건강보험 3.545%
|
||||
const longTermCare = Math.round(healthInsurance * 0.1281); // 장기요양 12.81% of 건강보험
|
||||
const employmentInsurance = Math.round(baseSalary * 0.009); // 고용보험 0.9%
|
||||
const totalIncome = baseSalary + DEFAULT_ALLOWANCES.mealAllowance + DEFAULT_ALLOWANCES.transportAllowance;
|
||||
const incomeTax = Math.round(totalIncome * 0.05); // 소득세 (간이세액 근사)
|
||||
const localIncomeTax = Math.round(incomeTax * 0.1); // 지방소득세 10% of 소득세
|
||||
|
||||
return {
|
||||
nationalPension,
|
||||
healthInsurance,
|
||||
longTermCare,
|
||||
employmentInsurance,
|
||||
incomeTax,
|
||||
localIncomeTax,
|
||||
otherDeduction: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 행 컴포넌트 =====
|
||||
function EditableRow({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
prefix: _prefix,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
prefix?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2">
|
||||
<span className="text-muted-foreground whitespace-nowrap text-xs sm:text-sm sm:w-24 sm:shrink-0">{label}</span>
|
||||
<div className="flex-1">
|
||||
<CurrencyInput
|
||||
value={value}
|
||||
onChange={(v) => onChange(v ?? 0)}
|
||||
className="w-full h-7 text-sm text-right"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Props =====
|
||||
interface SalaryRegistrationDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: (data: {
|
||||
employeeId: number;
|
||||
employeeName: string;
|
||||
department: string;
|
||||
position: string;
|
||||
rank: string;
|
||||
year: number;
|
||||
month: number;
|
||||
baseSalary: number;
|
||||
paymentDate: string;
|
||||
allowances: Record<string, number>;
|
||||
deductions: Record<string, number>;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
// ===== 컴포넌트 =====
|
||||
export function SalaryRegistrationDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
}: SalaryRegistrationDialogProps) {
|
||||
// 사원 선택
|
||||
const [employeeSearchOpen, setEmployeeSearchOpen] = useState(false);
|
||||
const [selectedEmployee, setSelectedEmployee] = useState<Employee | null>(null);
|
||||
const searchOpenRef = useRef(false);
|
||||
|
||||
// 급여 기본 정보
|
||||
const now = new Date();
|
||||
const [year, setYear] = useState(now.getFullYear());
|
||||
const [month, setMonth] = useState(now.getMonth() + 1);
|
||||
const [paymentDate, setPaymentDate] = useState('');
|
||||
const [baseSalary, setBaseSalary] = useState(0);
|
||||
|
||||
// 수당
|
||||
const [allowances, setAllowances] = useState({ ...DEFAULT_ALLOWANCES });
|
||||
|
||||
// 공제
|
||||
const [deductions, setDeductions] = useState({ ...DEFAULT_DEDUCTIONS });
|
||||
|
||||
// 검색 모달 열기/닫기 (ref 동기화 - 닫힘 전파 방지를 위해 해제 지연)
|
||||
const handleSearchOpenChange = useCallback((isOpen: boolean) => {
|
||||
if (isOpen) {
|
||||
searchOpenRef.current = true;
|
||||
} else {
|
||||
setTimeout(() => { searchOpenRef.current = false; }, 300);
|
||||
}
|
||||
setEmployeeSearchOpen(isOpen);
|
||||
}, []);
|
||||
|
||||
// 사원 선택 시 기본값 세팅
|
||||
const handleSelectEmployee = useCallback((employee: Employee) => {
|
||||
setSelectedEmployee(employee);
|
||||
handleSearchOpenChange(false);
|
||||
|
||||
// 기본급 세팅 (연봉 / 12)
|
||||
const monthlySalary = employee.salary ? Math.round(employee.salary / 12) : 0;
|
||||
setBaseSalary(monthlySalary);
|
||||
|
||||
// 기본 공제 자동 계산
|
||||
if (monthlySalary > 0) {
|
||||
setDeductions(calculateDefaultDeductions(monthlySalary));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 사원 검색 fetch
|
||||
const handleFetchEmployees = useCallback(async (query: string) => {
|
||||
const result = await getEmployees({
|
||||
q: query || undefined,
|
||||
status: 'active',
|
||||
per_page: 50,
|
||||
});
|
||||
return result.data || [];
|
||||
}, []);
|
||||
|
||||
// 검색어 유효성
|
||||
const isValidSearch = useCallback((query: string) => {
|
||||
if (!query || !query.trim()) return false;
|
||||
return /[a-zA-Z가-힣ㄱ-ㅎㅏ-ㅣ0-9]/.test(query);
|
||||
}, []);
|
||||
|
||||
// 수당 변경
|
||||
const handleAllowanceChange = useCallback((field: string, value: number) => {
|
||||
setAllowances(prev => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
// 공제 변경
|
||||
const handleDeductionChange = useCallback((field: string, value: number) => {
|
||||
setDeductions(prev => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
// 합계 계산
|
||||
const totalAllowance = useMemo(() =>
|
||||
Object.values(allowances).reduce((sum, v) => sum + v, 0),
|
||||
[allowances]
|
||||
);
|
||||
|
||||
const totalDeduction = useMemo(() =>
|
||||
Object.values(deductions).reduce((sum, v) => sum + v, 0),
|
||||
[deductions]
|
||||
);
|
||||
|
||||
const netPayment = useMemo(() =>
|
||||
baseSalary + totalAllowance - totalDeduction,
|
||||
[baseSalary, totalAllowance, totalDeduction]
|
||||
);
|
||||
|
||||
// 저장 가능 여부
|
||||
const canSave = selectedEmployee && baseSalary > 0 && year > 0 && month > 0;
|
||||
|
||||
// 저장
|
||||
const handleSave = useCallback(() => {
|
||||
if (!selectedEmployee || !canSave) return;
|
||||
|
||||
const dept = selectedEmployee.departmentPositions?.[0];
|
||||
|
||||
onSave({
|
||||
employeeId: selectedEmployee.userId || parseInt(selectedEmployee.id, 10),
|
||||
employeeName: selectedEmployee.name,
|
||||
department: dept?.departmentName || '-',
|
||||
position: dept?.positionName || '-',
|
||||
rank: selectedEmployee.rank || '-',
|
||||
year,
|
||||
month,
|
||||
baseSalary,
|
||||
paymentDate,
|
||||
allowances: {
|
||||
position_allowance: allowances.positionAllowance,
|
||||
overtime_allowance: allowances.overtimeAllowance,
|
||||
meal_allowance: allowances.mealAllowance,
|
||||
transport_allowance: allowances.transportAllowance,
|
||||
other_allowance: allowances.otherAllowance,
|
||||
},
|
||||
deductions: {
|
||||
national_pension: deductions.nationalPension,
|
||||
health_insurance: deductions.healthInsurance,
|
||||
long_term_care: deductions.longTermCare,
|
||||
employment_insurance: deductions.employmentInsurance,
|
||||
income_tax: deductions.incomeTax,
|
||||
local_income_tax: deductions.localIncomeTax,
|
||||
other_deduction: deductions.otherDeduction,
|
||||
},
|
||||
});
|
||||
}, [selectedEmployee, canSave, year, month, baseSalary, paymentDate, allowances, deductions, onSave]);
|
||||
|
||||
// 다이얼로그 닫힐 때 초기화 (검색 모달 닫힘 전파 방지)
|
||||
const handleOpenChange = useCallback((isOpen: boolean) => {
|
||||
if (!isOpen && searchOpenRef.current) return;
|
||||
if (!isOpen) {
|
||||
setSelectedEmployee(null);
|
||||
setBaseSalary(0);
|
||||
setPaymentDate('');
|
||||
setAllowances({ ...DEFAULT_ALLOWANCES });
|
||||
setDeductions({ ...DEFAULT_DEDUCTIONS });
|
||||
}
|
||||
onOpenChange(isOpen);
|
||||
}, [onOpenChange]);
|
||||
|
||||
// 년도 옵션
|
||||
const yearOptions = useMemo(() => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
return Array.from({ length: 3 }, (_, i) => currentYear - 1 + i);
|
||||
}, []);
|
||||
|
||||
// 월 옵션
|
||||
const monthOptions = useMemo(() =>
|
||||
Array.from({ length: 12 }, (_, i) => i + 1),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto p-4 sm:p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<UserPlus className="h-5 w-5" />
|
||||
급여 등록
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 사원 선택 */}
|
||||
<div className="bg-muted/50 rounded-lg p-3 sm:p-4">
|
||||
<h3 className="font-semibold mb-3">기본 정보</h3>
|
||||
|
||||
{/* 사원 선택 버튼 */}
|
||||
{!selectedEmployee ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-12 border-dashed"
|
||||
onClick={() => handleSearchOpenChange(true)}
|
||||
>
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
사원 검색 (클릭하여 선택)
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">사번</span>
|
||||
<p className="font-medium">{selectedEmployee.employeeCode || selectedEmployee.id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">이름</span>
|
||||
<p className="font-medium">{selectedEmployee.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">부서</span>
|
||||
<p className="font-medium">
|
||||
{selectedEmployee.departmentPositions?.[0]?.departmentName || '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">직급</span>
|
||||
<p className="font-medium">{selectedEmployee.rank || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">직책</span>
|
||||
<p className="font-medium">
|
||||
{selectedEmployee.departmentPositions?.[0]?.positionName || '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-muted-foreground"
|
||||
onClick={() => handleSearchOpenChange(true)}
|
||||
>
|
||||
다른 사원 선택
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 지급월 / 지급일 */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mt-4">
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground block mb-1">년도</span>
|
||||
<Select value={String(year)} onValueChange={(v) => setYear(Number(v))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{yearOptions.map(y => (
|
||||
<SelectItem key={y} value={String(y)}>{y}년</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground block mb-1">월</span>
|
||||
<Select value={String(month)} onValueChange={(v) => setMonth(Number(v))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{monthOptions.map(m => (
|
||||
<SelectItem key={m} value={String(m)}>{m}월</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="col-span-2 sm:col-span-1">
|
||||
<span className="text-sm text-muted-foreground block mb-1">지급일</span>
|
||||
<DatePicker
|
||||
value={paymentDate}
|
||||
onChange={setPaymentDate}
|
||||
placeholder="지급일 선택"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기본급 */}
|
||||
<div className="mt-4">
|
||||
<span className="text-sm text-muted-foreground block mb-1">기본급 (월)</span>
|
||||
<CurrencyInput
|
||||
value={baseSalary}
|
||||
onChange={(v) => {
|
||||
const newSalary = v ?? 0;
|
||||
setBaseSalary(newSalary);
|
||||
if (newSalary > 0) {
|
||||
setDeductions(calculateDefaultDeductions(newSalary));
|
||||
}
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
{selectedEmployee?.salary && (
|
||||
<span className="text-xs text-muted-foreground mt-1 block">
|
||||
연봉 {formatCurrency(selectedEmployee.salary)}원 기준
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 수당 / 공제 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 수당 내역 */}
|
||||
<div className="border rounded-lg p-3 sm:p-4 flex flex-col">
|
||||
<h3 className="font-semibold mb-3 text-blue-600">수당 내역</h3>
|
||||
<div className="space-y-2 text-sm flex-1">
|
||||
<EditableRow
|
||||
label="직책수당"
|
||||
value={allowances.positionAllowance}
|
||||
onChange={(v) => handleAllowanceChange('positionAllowance', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="초과근무수당"
|
||||
value={allowances.overtimeAllowance}
|
||||
onChange={(v) => handleAllowanceChange('overtimeAllowance', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="식대"
|
||||
value={allowances.mealAllowance}
|
||||
onChange={(v) => handleAllowanceChange('mealAllowance', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="교통비"
|
||||
value={allowances.transportAllowance}
|
||||
onChange={(v) => handleAllowanceChange('transportAllowance', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="기타수당"
|
||||
value={allowances.otherAllowance}
|
||||
onChange={(v) => handleAllowanceChange('otherAllowance', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-auto pt-2">
|
||||
<Separator />
|
||||
<div className="flex items-center gap-2 font-semibold text-blue-600 mt-2">
|
||||
<span className="w-20 sm:w-24 shrink-0">수당 합계</span>
|
||||
<span className="flex-1 text-right">{formatCurrency(totalAllowance)}원</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 공제 내역 */}
|
||||
<div className="border rounded-lg p-3 sm:p-4 flex flex-col">
|
||||
<h3 className="font-semibold mb-3 text-red-600">공제 내역</h3>
|
||||
<div className="space-y-2 text-sm flex-1">
|
||||
<EditableRow
|
||||
label="국민연금"
|
||||
value={deductions.nationalPension}
|
||||
onChange={(v) => handleDeductionChange('nationalPension', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="건강보험"
|
||||
value={deductions.healthInsurance}
|
||||
onChange={(v) => handleDeductionChange('healthInsurance', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="장기요양보험"
|
||||
value={deductions.longTermCare}
|
||||
onChange={(v) => handleDeductionChange('longTermCare', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="고용보험"
|
||||
value={deductions.employmentInsurance}
|
||||
onChange={(v) => handleDeductionChange('employmentInsurance', v)}
|
||||
/>
|
||||
<Separator />
|
||||
<EditableRow
|
||||
label="소득세"
|
||||
value={deductions.incomeTax}
|
||||
onChange={(v) => handleDeductionChange('incomeTax', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="지방소득세"
|
||||
value={deductions.localIncomeTax}
|
||||
onChange={(v) => handleDeductionChange('localIncomeTax', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="기타공제"
|
||||
value={deductions.otherDeduction}
|
||||
onChange={(v) => handleDeductionChange('otherDeduction', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-auto pt-2">
|
||||
<Separator />
|
||||
<div className="flex items-center gap-2 font-semibold text-red-600 mt-2">
|
||||
<span className="w-20 sm:w-24 shrink-0">공제 합계</span>
|
||||
<span className="flex-1 text-right">-{formatCurrency(totalDeduction)}원</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 합계 */}
|
||||
<div className="bg-primary/5 border-2 border-primary/20 rounded-lg p-3 sm:p-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 sm:gap-4 text-center">
|
||||
<div>
|
||||
<span className="text-xs sm:text-sm text-muted-foreground block">급여 총액</span>
|
||||
<span className="text-sm sm:text-lg font-semibold text-blue-600">
|
||||
{formatCurrency(baseSalary + totalAllowance)}원
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs sm:text-sm text-muted-foreground block">공제 총액</span>
|
||||
<span className="text-sm sm:text-lg font-semibold text-red-600">
|
||||
-{formatCurrency(totalDeduction)}원
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs sm:text-sm text-muted-foreground block">실지급액</span>
|
||||
<span className="text-base sm:text-xl font-bold text-primary">
|
||||
{formatCurrency(netPayment)}원
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!canSave} className="gap-2">
|
||||
<Save className="h-4 w-4" />
|
||||
등록
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 사원 검색 모달 */}
|
||||
<SearchableSelectionModal<Employee>
|
||||
open={employeeSearchOpen}
|
||||
onOpenChange={handleSearchOpenChange}
|
||||
title="사원 검색"
|
||||
searchPlaceholder="사원명, 사원코드 검색..."
|
||||
fetchData={handleFetchEmployees}
|
||||
keyExtractor={(emp) => emp.id}
|
||||
validateSearch={isValidSearch}
|
||||
invalidSearchMessage="한글, 영문 또는 숫자 1자 이상 입력하세요"
|
||||
emptyQueryMessage="사원명 또는 사원코드를 입력하세요"
|
||||
loadingMessage="사원 검색 중..."
|
||||
dialogClassName="sm:max-w-[500px]"
|
||||
infoText={(items, isLoading) =>
|
||||
!isLoading ? (
|
||||
<span className="text-xs text-gray-400 text-right block">
|
||||
총 {items.length}명
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
mode="single"
|
||||
onSelect={handleSelectEmployee}
|
||||
renderItem={(employee) => (
|
||||
<div className="p-3 hover:bg-blue-50 transition-colors">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-semibold text-gray-900">{employee.name}</span>
|
||||
{employee.employeeCode && (
|
||||
<span className="text-xs text-gray-400 ml-2">({employee.employeeCode})</span>
|
||||
)}
|
||||
</div>
|
||||
{employee.rank && (
|
||||
<span className="text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded">
|
||||
{employee.rank}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{employee.departmentPositions.length > 0 && (
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{employee.departmentPositions
|
||||
.map(dp => `${dp.departmentName} / ${dp.positionName}`)
|
||||
.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{employee.salary && (
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
연봉: {Number(employee.salary).toLocaleString()}원
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
349
src/components/hr/SalaryManagement_backup_20260312/actions.ts
Normal file
349
src/components/hr/SalaryManagement_backup_20260312/actions.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
'use server';
|
||||
|
||||
|
||||
import { executeServerAction } from '@/lib/api/execute-server-action';
|
||||
import { buildApiUrl } from '@/lib/api/query-params';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { cookies } from 'next/headers';
|
||||
import type { SalaryRecord, SalaryDetail, PaymentStatus } from './types';
|
||||
|
||||
// API 응답 타입
|
||||
interface SalaryApiData {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
employee_id: number;
|
||||
year: number;
|
||||
month: number;
|
||||
base_salary: string;
|
||||
total_allowance: string;
|
||||
total_overtime: string;
|
||||
total_bonus: string;
|
||||
total_deduction: string;
|
||||
net_payment: string;
|
||||
allowance_details: Record<string, number> | null;
|
||||
deduction_details: Record<string, number> | null;
|
||||
payment_date: string | null;
|
||||
status: 'scheduled' | 'completed';
|
||||
employee?: {
|
||||
id: number;
|
||||
name: string;
|
||||
user_id?: string;
|
||||
email?: string;
|
||||
} | null;
|
||||
employee_profile?: {
|
||||
id: number;
|
||||
department_id: number | null;
|
||||
position_key: string | null;
|
||||
job_title_key: string | null;
|
||||
position_label: string | null;
|
||||
job_title_label: string | null;
|
||||
rank: string | null;
|
||||
department?: {
|
||||
id: number;
|
||||
name: string;
|
||||
} | null;
|
||||
} | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface SalaryPaginationData {
|
||||
data: SalaryApiData[];
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface StatisticsApiData {
|
||||
total_net_payment: number;
|
||||
total_base_salary: number;
|
||||
total_allowance: number;
|
||||
total_overtime: number;
|
||||
total_bonus: number;
|
||||
total_deduction: number;
|
||||
count: number;
|
||||
scheduled_count: number;
|
||||
completed_count: number;
|
||||
}
|
||||
|
||||
// API → Frontend 변환 (목록용)
|
||||
function transformApiToFrontend(apiData: SalaryApiData): SalaryRecord {
|
||||
const profile = apiData.employee_profile;
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
employeeId: apiData.employee?.user_id || `EMP${String(apiData.employee_id).padStart(3, '0')}`,
|
||||
employeeName: apiData.employee?.name || '-',
|
||||
department: profile?.department?.name || '-',
|
||||
position: profile?.job_title_label || '-',
|
||||
rank: profile?.rank || '-',
|
||||
baseSalary: parseFloat(apiData.base_salary),
|
||||
allowance: parseFloat(apiData.total_allowance),
|
||||
overtime: parseFloat(apiData.total_overtime),
|
||||
bonus: parseFloat(apiData.total_bonus),
|
||||
deduction: parseFloat(apiData.total_deduction),
|
||||
netPayment: parseFloat(apiData.net_payment),
|
||||
paymentDate: apiData.payment_date || '',
|
||||
status: apiData.status as PaymentStatus,
|
||||
year: apiData.year,
|
||||
month: apiData.month,
|
||||
createdAt: apiData.created_at,
|
||||
updatedAt: apiData.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
// API → Frontend 변환 (상세용)
|
||||
function transformApiToDetail(apiData: SalaryApiData): SalaryDetail {
|
||||
const allowanceDetails = apiData.allowance_details || {};
|
||||
const deductionDetails = apiData.deduction_details || {};
|
||||
const profile = apiData.employee_profile;
|
||||
|
||||
return {
|
||||
employeeId: apiData.employee?.user_id || `EMP${String(apiData.employee_id).padStart(3, '0')}`,
|
||||
employeeName: apiData.employee?.name || '-',
|
||||
department: profile?.department?.name || '-',
|
||||
position: profile?.job_title_label || '-',
|
||||
rank: profile?.rank || '-',
|
||||
baseSalary: parseFloat(apiData.base_salary),
|
||||
allowances: {
|
||||
positionAllowance: allowanceDetails.position_allowance || 0,
|
||||
overtimeAllowance: allowanceDetails.overtime_allowance || 0,
|
||||
mealAllowance: allowanceDetails.meal_allowance || 0,
|
||||
transportAllowance: allowanceDetails.transport_allowance || 0,
|
||||
otherAllowance: allowanceDetails.other_allowance || 0,
|
||||
},
|
||||
deductions: {
|
||||
nationalPension: deductionDetails.national_pension || 0,
|
||||
healthInsurance: deductionDetails.health_insurance || 0,
|
||||
longTermCare: deductionDetails.long_term_care || 0,
|
||||
employmentInsurance: deductionDetails.employment_insurance || 0,
|
||||
incomeTax: deductionDetails.income_tax || 0,
|
||||
localIncomeTax: deductionDetails.local_income_tax || 0,
|
||||
otherDeduction: deductionDetails.other_deduction || 0,
|
||||
},
|
||||
totalAllowance: parseFloat(apiData.total_allowance),
|
||||
totalDeduction: parseFloat(apiData.total_deduction),
|
||||
netPayment: parseFloat(apiData.net_payment),
|
||||
paymentDate: apiData.payment_date || '',
|
||||
status: apiData.status as PaymentStatus,
|
||||
year: apiData.year,
|
||||
month: apiData.month,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 급여 목록 조회 =====
|
||||
export async function getSalaries(params?: {
|
||||
search?: string; year?: number; month?: number; status?: string;
|
||||
employee_id?: number; start_date?: string; end_date?: string;
|
||||
page?: number; per_page?: number;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data?: SalaryRecord[];
|
||||
pagination?: { total: number; currentPage: number; lastPage: number };
|
||||
error?: string
|
||||
}> {
|
||||
const result = await executeServerAction<SalaryPaginationData>({
|
||||
url: buildApiUrl('/api/v1/salaries', {
|
||||
search: params?.search,
|
||||
year: params?.year,
|
||||
month: params?.month,
|
||||
status: params?.status && params.status !== 'all' ? params.status : undefined,
|
||||
employee_id: params?.employee_id,
|
||||
start_date: params?.start_date,
|
||||
end_date: params?.end_date,
|
||||
page: params?.page,
|
||||
per_page: params?.per_page,
|
||||
}),
|
||||
errorMessage: '급여 목록을 불러오는데 실패했습니다.',
|
||||
});
|
||||
|
||||
if (!result.success || !result.data) return { success: false, error: result.error };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.data.data.map(transformApiToFrontend),
|
||||
pagination: {
|
||||
total: result.data.total,
|
||||
currentPage: result.data.current_page,
|
||||
lastPage: result.data.last_page,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 급여 상세 조회 =====
|
||||
export async function getSalary(id: string): Promise<{
|
||||
success: boolean; data?: SalaryDetail; error?: string
|
||||
}> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/salaries/${id}`),
|
||||
transform: (data: SalaryApiData) => transformApiToDetail(data),
|
||||
errorMessage: '급여 정보를 불러오는데 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
// ===== 급여 상태 변경 =====
|
||||
export async function updateSalaryStatus(
|
||||
id: string,
|
||||
status: PaymentStatus
|
||||
): Promise<{ success: boolean; data?: SalaryRecord; error?: string }> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/salaries/${id}/status`),
|
||||
method: 'PATCH',
|
||||
body: { status },
|
||||
transform: (data: SalaryApiData) => transformApiToFrontend(data),
|
||||
errorMessage: '상태 변경에 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
// ===== 급여 일괄 상태 변경 =====
|
||||
export async function bulkUpdateSalaryStatus(
|
||||
ids: string[],
|
||||
status: PaymentStatus
|
||||
): Promise<{ success: boolean; updatedCount?: number; error?: string }> {
|
||||
const result = await executeServerAction<{ updated_count: number }>({
|
||||
url: buildApiUrl('/api/v1/salaries/bulk-update-status'),
|
||||
method: 'POST',
|
||||
body: { ids: ids.map(id => parseInt(id, 10)), status },
|
||||
errorMessage: '일괄 상태 변경에 실패했습니다.',
|
||||
});
|
||||
if (!result.success || !result.data) return { success: false, error: result.error };
|
||||
return { success: true, updatedCount: result.data.updated_count };
|
||||
}
|
||||
|
||||
// ===== 급여 수정 =====
|
||||
export async function updateSalary(
|
||||
id: string,
|
||||
data: {
|
||||
base_salary?: number;
|
||||
allowance_details?: Record<string, number>;
|
||||
deduction_details?: Record<string, number>;
|
||||
status?: PaymentStatus;
|
||||
payment_date?: string;
|
||||
}
|
||||
): Promise<{ success: boolean; data?: SalaryDetail; error?: string }> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/salaries/${id}`),
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
transform: (d: SalaryApiData) => transformApiToDetail(d),
|
||||
errorMessage: '급여 수정에 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
// ===== 급여 등록 =====
|
||||
export async function createSalary(data: {
|
||||
employee_id: number;
|
||||
year: number;
|
||||
month: number;
|
||||
base_salary: number;
|
||||
allowance_details?: Record<string, number>;
|
||||
deduction_details?: Record<string, number>;
|
||||
payment_date?: string;
|
||||
status?: PaymentStatus;
|
||||
}): Promise<{ success: boolean; data?: SalaryDetail; error?: string }> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl('/api/v1/salaries'),
|
||||
method: 'POST',
|
||||
body: data,
|
||||
transform: (d: SalaryApiData) => transformApiToDetail(d),
|
||||
errorMessage: '급여 등록에 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
// ===== 급여 통계 조회 =====
|
||||
export async function getSalaryStatistics(params?: {
|
||||
year?: number; month?: number; start_date?: string; end_date?: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data?: {
|
||||
totalNetPayment: number; totalBaseSalary: number; totalAllowance: number;
|
||||
totalOvertime: number; totalBonus: number; totalDeduction: number;
|
||||
count: number; scheduledCount: number; completedCount: number;
|
||||
};
|
||||
error?: string
|
||||
}> {
|
||||
const result = await executeServerAction<StatisticsApiData>({
|
||||
url: buildApiUrl('/api/v1/salaries/statistics', {
|
||||
year: params?.year,
|
||||
month: params?.month,
|
||||
start_date: params?.start_date,
|
||||
end_date: params?.end_date,
|
||||
}),
|
||||
errorMessage: '통계 정보를 불러오는데 실패했습니다.',
|
||||
});
|
||||
|
||||
if (!result.success || !result.data) return { success: false, error: result.error };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
totalNetPayment: result.data.total_net_payment,
|
||||
totalBaseSalary: result.data.total_base_salary,
|
||||
totalAllowance: result.data.total_allowance,
|
||||
totalOvertime: result.data.total_overtime,
|
||||
totalBonus: result.data.total_bonus,
|
||||
totalDeduction: result.data.total_deduction,
|
||||
count: result.data.count,
|
||||
scheduledCount: result.data.scheduled_count,
|
||||
completedCount: result.data.completed_count,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 급여 엑셀 내보내기 (native fetch - keep as-is) =====
|
||||
export async function exportSalaryExcel(params?: {
|
||||
year?: number;
|
||||
month?: number;
|
||||
status?: string;
|
||||
employee_id?: number;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data?: Blob;
|
||||
filename?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('access_token')?.value;
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Accept': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
'X-API-KEY': process.env.API_KEY || '',
|
||||
};
|
||||
|
||||
const url = buildApiUrl('/api/v1/salaries/export', {
|
||||
year: params?.year,
|
||||
month: params?.month,
|
||||
status: params?.status && params.status !== 'all' ? params.status : undefined,
|
||||
employee_id: params?.employee_id,
|
||||
start_date: params?.start_date,
|
||||
end_date: params?.end_date,
|
||||
});
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false, error: `API 오류: ${response.status}` };
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
const filenameMatch = contentDisposition?.match(/filename="?(.+)"?/);
|
||||
const filename = filenameMatch?.[1] || `급여명세_${params?.year || 'all'}_${params?.month || 'all'}.xlsx`;
|
||||
|
||||
return { success: true, data: blob, filename };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
793
src/components/hr/SalaryManagement_backup_20260312/index.tsx
Normal file
793
src/components/hr/SalaryManagement_backup_20260312/index.tsx
Normal file
@@ -0,0 +1,793 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import { useDateRange } from '@/hooks';
|
||||
import {
|
||||
DollarSign,
|
||||
Check,
|
||||
Clock,
|
||||
Banknote,
|
||||
Briefcase,
|
||||
Timer,
|
||||
Gift,
|
||||
MinusCircle,
|
||||
Loader2,
|
||||
Plus,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TableRow, TableCell } from '@/components/ui/table';
|
||||
import {
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
type StatCard,
|
||||
type FilterFieldConfig,
|
||||
type FilterValues,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
import { SalaryDetailDialog } from './SalaryDetailDialog';
|
||||
import { SalaryRegistrationDialog } from './SalaryRegistrationDialog';
|
||||
import {
|
||||
getSalaries,
|
||||
getSalary,
|
||||
createSalary,
|
||||
bulkUpdateSalaryStatus,
|
||||
updateSalaryStatus,
|
||||
updateSalary,
|
||||
} from './actions';
|
||||
import type {
|
||||
SalaryRecord,
|
||||
SalaryDetail,
|
||||
SortOption,
|
||||
} from './types';
|
||||
import {
|
||||
PAYMENT_STATUS_LABELS,
|
||||
PAYMENT_STATUS_COLORS,
|
||||
SORT_OPTIONS,
|
||||
formatCurrency,
|
||||
} from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
// ===== 목 데이터 (API 연동 전 테스트용) =====
|
||||
const MOCK_SALARY_RECORDS: SalaryRecord[] = [
|
||||
{
|
||||
id: 'mock-1',
|
||||
employeeId: 'EMP001',
|
||||
employeeName: '김철수',
|
||||
department: '개발팀',
|
||||
position: '팀장',
|
||||
rank: '과장',
|
||||
baseSalary: 3500000,
|
||||
allowance: 850000,
|
||||
overtime: 320000,
|
||||
bonus: 0,
|
||||
deduction: 542000,
|
||||
netPayment: 4128000,
|
||||
paymentDate: '2026-02-25',
|
||||
status: 'scheduled',
|
||||
year: 2026,
|
||||
month: 2,
|
||||
createdAt: '2026-02-01',
|
||||
updatedAt: '2026-02-01',
|
||||
},
|
||||
{
|
||||
id: 'mock-2',
|
||||
employeeId: 'EMP002',
|
||||
employeeName: '이영희',
|
||||
department: '경영지원팀',
|
||||
position: '사원',
|
||||
rank: '대리',
|
||||
baseSalary: 3000000,
|
||||
allowance: 550000,
|
||||
overtime: 0,
|
||||
bonus: 500000,
|
||||
deduction: 468000,
|
||||
netPayment: 3582000,
|
||||
paymentDate: '2026-02-25',
|
||||
status: 'completed',
|
||||
year: 2026,
|
||||
month: 2,
|
||||
createdAt: '2026-02-01',
|
||||
updatedAt: '2026-02-20',
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_SALARY_DETAILS: Record<string, SalaryDetail> = {
|
||||
'mock-1': {
|
||||
employeeId: 'EMP001',
|
||||
employeeName: '김철수',
|
||||
department: '개발팀',
|
||||
position: '팀장',
|
||||
rank: '과장',
|
||||
baseSalary: 3500000,
|
||||
allowances: {
|
||||
positionAllowance: 300000,
|
||||
overtimeAllowance: 320000,
|
||||
mealAllowance: 150000,
|
||||
transportAllowance: 100000,
|
||||
otherAllowance: 0,
|
||||
},
|
||||
deductions: {
|
||||
nationalPension: 157500,
|
||||
healthInsurance: 121450,
|
||||
longTermCare: 14820,
|
||||
employmentInsurance: 31500,
|
||||
incomeTax: 185230,
|
||||
localIncomeTax: 18520,
|
||||
otherDeduction: 12980,
|
||||
},
|
||||
totalAllowance: 870000,
|
||||
totalDeduction: 542000,
|
||||
netPayment: 4128000,
|
||||
paymentDate: '2026-02-25',
|
||||
status: 'scheduled',
|
||||
year: 2026,
|
||||
month: 2,
|
||||
},
|
||||
'mock-2': {
|
||||
employeeId: 'EMP002',
|
||||
employeeName: '이영희',
|
||||
department: '경영지원팀',
|
||||
position: '사원',
|
||||
rank: '대리',
|
||||
baseSalary: 3000000,
|
||||
allowances: {
|
||||
positionAllowance: 200000,
|
||||
overtimeAllowance: 0,
|
||||
mealAllowance: 150000,
|
||||
transportAllowance: 100000,
|
||||
otherAllowance: 100000,
|
||||
},
|
||||
deductions: {
|
||||
nationalPension: 135000,
|
||||
healthInsurance: 104100,
|
||||
longTermCare: 12700,
|
||||
employmentInsurance: 27000,
|
||||
incomeTax: 160200,
|
||||
localIncomeTax: 16020,
|
||||
otherDeduction: 12980,
|
||||
},
|
||||
totalAllowance: 550000,
|
||||
totalDeduction: 468000,
|
||||
netPayment: 3582000,
|
||||
paymentDate: '2026-02-25',
|
||||
status: 'completed',
|
||||
year: 2026,
|
||||
month: 2,
|
||||
},
|
||||
};
|
||||
|
||||
export function SalaryManagement() {
|
||||
const { canExport: _canExport } = usePermission();
|
||||
// ===== 상태 관리 =====
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sortOption, setSortOption] = useState<SortOption>('rank');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 날짜 범위 상태
|
||||
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentMonth');
|
||||
|
||||
// 다이얼로그 상태
|
||||
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
|
||||
const [selectedSalaryDetail, setSelectedSalaryDetail] = useState<SalaryDetail | null>(null);
|
||||
const [selectedSalaryId, setSelectedSalaryId] = useState<string | null>(null);
|
||||
const [registrationDialogOpen, setRegistrationDialogOpen] = useState(false);
|
||||
|
||||
// 데이터 상태
|
||||
const [salaryData, setSalaryData] = useState<SalaryRecord[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const isInitialLoadDone = useRef(false);
|
||||
const [isActionLoading, setIsActionLoading] = useState(false);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
// ===== 데이터 로드 =====
|
||||
const loadSalaries = useCallback(async () => {
|
||||
if (!isInitialLoadDone.current) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
try {
|
||||
const result = await getSalaries({
|
||||
search: searchQuery || undefined,
|
||||
start_date: startDate || undefined,
|
||||
end_date: endDate || undefined,
|
||||
page: currentPage,
|
||||
per_page: itemsPerPage,
|
||||
});
|
||||
|
||||
if (result.success && result.data && result.data.length > 0) {
|
||||
setSalaryData(result.data);
|
||||
setTotalCount(result.pagination?.total || result.data.length);
|
||||
setTotalPages(result.pagination?.lastPage || 1);
|
||||
} else {
|
||||
// API 데이터가 없으면 목 데이터 사용
|
||||
setSalaryData(MOCK_SALARY_RECORDS);
|
||||
setTotalCount(MOCK_SALARY_RECORDS.length);
|
||||
setTotalPages(1);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('loadSalaries error:', error);
|
||||
// API 실패 시에도 목 데이터 사용
|
||||
setSalaryData(MOCK_SALARY_RECORDS);
|
||||
setTotalCount(MOCK_SALARY_RECORDS.length);
|
||||
setTotalPages(1);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
isInitialLoadDone.current = true;
|
||||
}
|
||||
}, [searchQuery, startDate, endDate, currentPage, itemsPerPage]);
|
||||
|
||||
// 초기 데이터 로드 및 검색/필터 변경 시 재로드
|
||||
useEffect(() => {
|
||||
loadSalaries();
|
||||
}, [loadSalaries]);
|
||||
|
||||
// ===== 체크박스 핸들러 =====
|
||||
const toggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) newSet.delete(id);
|
||||
else newSet.add(id);
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === salaryData.length && salaryData.length > 0) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(salaryData.map(item => item.id)));
|
||||
}
|
||||
}, [selectedItems.size, salaryData]);
|
||||
|
||||
// ===== 지급완료 핸들러 =====
|
||||
const handleMarkCompleted = useCallback(async () => {
|
||||
if (selectedItems.size === 0) return;
|
||||
|
||||
setIsActionLoading(true);
|
||||
try {
|
||||
const result = await bulkUpdateSalaryStatus(
|
||||
Array.from(selectedItems),
|
||||
'completed'
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
toast.success(`${result.updatedCount || selectedItems.size}건이 지급완료 처리되었습니다.`);
|
||||
setSelectedItems(new Set());
|
||||
await loadSalaries();
|
||||
} else {
|
||||
toast.error(result.error || '상태 변경에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('handleMarkCompleted error:', error);
|
||||
toast.error('상태 변경에 실패했습니다.');
|
||||
} finally {
|
||||
setIsActionLoading(false);
|
||||
}
|
||||
}, [selectedItems, loadSalaries]);
|
||||
|
||||
// ===== 지급예정 핸들러 =====
|
||||
const handleMarkScheduled = useCallback(async () => {
|
||||
if (selectedItems.size === 0) return;
|
||||
|
||||
setIsActionLoading(true);
|
||||
try {
|
||||
const result = await bulkUpdateSalaryStatus(
|
||||
Array.from(selectedItems),
|
||||
'scheduled'
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
toast.success(`${result.updatedCount || selectedItems.size}건이 지급예정 처리되었습니다.`);
|
||||
setSelectedItems(new Set());
|
||||
await loadSalaries();
|
||||
} else {
|
||||
toast.error(result.error || '상태 변경에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('handleMarkScheduled error:', error);
|
||||
toast.error('상태 변경에 실패했습니다.');
|
||||
} finally {
|
||||
setIsActionLoading(false);
|
||||
}
|
||||
}, [selectedItems, loadSalaries]);
|
||||
|
||||
// ===== 상세보기 핸들러 =====
|
||||
const handleViewDetail = useCallback(async (record: SalaryRecord) => {
|
||||
setSelectedSalaryId(record.id);
|
||||
setIsActionLoading(true);
|
||||
try {
|
||||
// 목 데이터인 경우 목 상세 데이터 사용
|
||||
if (record.id.startsWith('mock-')) {
|
||||
const mockDetail = MOCK_SALARY_DETAILS[record.id];
|
||||
if (mockDetail) {
|
||||
setSelectedSalaryDetail(mockDetail);
|
||||
setDetailDialogOpen(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await getSalary(record.id);
|
||||
if (result.success && result.data) {
|
||||
setSelectedSalaryDetail(result.data);
|
||||
setDetailDialogOpen(true);
|
||||
} else {
|
||||
toast.error(result.error || '급여 상세 정보를 불러오는데 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('handleViewDetail error:', error);
|
||||
toast.error('급여 상세 정보를 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setIsActionLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ===== 급여 상세 저장 핸들러 =====
|
||||
const handleSaveDetail = useCallback(async (
|
||||
updatedDetail: SalaryDetail,
|
||||
allowanceDetails?: Record<string, number>,
|
||||
deductionDetails?: Record<string, number>
|
||||
) => {
|
||||
if (!selectedSalaryId) return;
|
||||
|
||||
setIsActionLoading(true);
|
||||
try {
|
||||
// 목 데이터인 경우 로컬 상태만 업데이트
|
||||
if (selectedSalaryId.startsWith('mock-')) {
|
||||
setSalaryData(prev => prev.map(item =>
|
||||
item.id === selectedSalaryId
|
||||
? {
|
||||
...item,
|
||||
status: updatedDetail.status,
|
||||
allowance: updatedDetail.totalAllowance,
|
||||
deduction: updatedDetail.totalDeduction,
|
||||
netPayment: updatedDetail.netPayment,
|
||||
}
|
||||
: item
|
||||
));
|
||||
toast.success('급여 정보가 저장되었습니다. (목 데이터)');
|
||||
setDetailDialogOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 수당/공제 정보가 변경된 경우 updateSalary API 호출
|
||||
if (allowanceDetails || deductionDetails) {
|
||||
const result = await updateSalary(selectedSalaryId, {
|
||||
allowance_details: allowanceDetails,
|
||||
deduction_details: deductionDetails,
|
||||
status: updatedDetail.status,
|
||||
});
|
||||
if (result.success) {
|
||||
toast.success('급여 정보가 저장되었습니다.');
|
||||
setDetailDialogOpen(false);
|
||||
await loadSalaries();
|
||||
} else {
|
||||
toast.error(result.error || '저장에 실패했습니다.');
|
||||
}
|
||||
} else {
|
||||
// 상태만 변경된 경우 기존 API 호출
|
||||
const result = await updateSalaryStatus(selectedSalaryId, updatedDetail.status);
|
||||
if (result.success) {
|
||||
toast.success('급여 정보가 저장되었습니다.');
|
||||
setDetailDialogOpen(false);
|
||||
await loadSalaries();
|
||||
} else {
|
||||
toast.error(result.error || '저장에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('handleSaveDetail error:', error);
|
||||
toast.error('저장에 실패했습니다.');
|
||||
} finally {
|
||||
setIsActionLoading(false);
|
||||
}
|
||||
}, [selectedSalaryId, loadSalaries]);
|
||||
|
||||
// ===== 지급항목 추가 핸들러 =====
|
||||
const handleAddPaymentItem = useCallback(() => {
|
||||
toast.info('지급항목 추가 기능은 준비 중입니다.');
|
||||
}, []);
|
||||
|
||||
// ===== 급여 등록 핸들러 =====
|
||||
const handleCreateSalary = useCallback(async (data: {
|
||||
employeeId: number;
|
||||
employeeName: string;
|
||||
department: string;
|
||||
position: string;
|
||||
rank: string;
|
||||
year: number;
|
||||
month: number;
|
||||
baseSalary: number;
|
||||
paymentDate: string;
|
||||
allowances: Record<string, number>;
|
||||
deductions: Record<string, number>;
|
||||
}) => {
|
||||
setIsActionLoading(true);
|
||||
try {
|
||||
const result = await createSalary({
|
||||
employee_id: data.employeeId,
|
||||
year: data.year,
|
||||
month: data.month,
|
||||
base_salary: data.baseSalary,
|
||||
allowance_details: data.allowances,
|
||||
deduction_details: data.deductions,
|
||||
payment_date: data.paymentDate || undefined,
|
||||
status: 'scheduled',
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success('급여가 등록되었습니다.');
|
||||
setRegistrationDialogOpen(false);
|
||||
await loadSalaries();
|
||||
} else {
|
||||
// API 실패 시 목 데이터로 로컬 추가
|
||||
const totalAllowance = Object.values(data.allowances).reduce((s, v) => s + v, 0);
|
||||
const totalDeduction = Object.values(data.deductions).reduce((s, v) => s + v, 0);
|
||||
const mockId = `mock-${Date.now()}`;
|
||||
const newRecord: SalaryRecord = {
|
||||
id: mockId,
|
||||
employeeId: String(data.employeeId),
|
||||
employeeName: data.employeeName,
|
||||
department: data.department,
|
||||
position: data.position,
|
||||
rank: data.rank,
|
||||
baseSalary: data.baseSalary,
|
||||
allowance: totalAllowance,
|
||||
overtime: data.allowances.overtime_allowance || 0,
|
||||
bonus: 0,
|
||||
deduction: totalDeduction,
|
||||
netPayment: data.baseSalary + totalAllowance - totalDeduction,
|
||||
paymentDate: data.paymentDate,
|
||||
status: 'scheduled',
|
||||
year: data.year,
|
||||
month: data.month,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
setSalaryData(prev => [...prev, newRecord]);
|
||||
setTotalCount(prev => prev + 1);
|
||||
toast.success('급여가 등록되었습니다. (목 데이터)');
|
||||
setRegistrationDialogOpen(false);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('handleCreateSalary error:', error);
|
||||
toast.error('급여 등록에 실패했습니다.');
|
||||
} finally {
|
||||
setIsActionLoading(false);
|
||||
}
|
||||
}, [loadSalaries]);
|
||||
|
||||
// ===== 통계 카드 (총 실지급액, 총 기본급, 총 수당, 초과근무, 상여, 총공제) =====
|
||||
const statCards: StatCard[] = useMemo(() => {
|
||||
const totalNetPayment = salaryData.reduce((sum, s) => sum + s.netPayment, 0);
|
||||
const totalBaseSalary = salaryData.reduce((sum, s) => sum + s.baseSalary, 0);
|
||||
const totalAllowance = salaryData.reduce((sum, s) => sum + s.allowance, 0);
|
||||
const totalOvertime = salaryData.reduce((sum, s) => sum + s.overtime, 0);
|
||||
const totalBonus = salaryData.reduce((sum, s) => sum + s.bonus, 0);
|
||||
const totalDeduction = salaryData.reduce((sum, s) => sum + s.deduction, 0);
|
||||
|
||||
return [
|
||||
{
|
||||
label: '총 실지급액',
|
||||
value: `${formatCurrency(totalNetPayment)}원`,
|
||||
icon: DollarSign,
|
||||
iconColor: 'text-green-500',
|
||||
},
|
||||
{
|
||||
label: '총 기본급',
|
||||
value: `${formatCurrency(totalBaseSalary)}원`,
|
||||
icon: Banknote,
|
||||
iconColor: 'text-blue-500',
|
||||
},
|
||||
{
|
||||
label: '총 수당',
|
||||
value: `${formatCurrency(totalAllowance)}원`,
|
||||
icon: Briefcase,
|
||||
iconColor: 'text-purple-500',
|
||||
},
|
||||
{
|
||||
label: '초과근무',
|
||||
value: `${formatCurrency(totalOvertime)}원`,
|
||||
icon: Timer,
|
||||
iconColor: 'text-orange-500',
|
||||
},
|
||||
{
|
||||
label: '상여',
|
||||
value: `${formatCurrency(totalBonus)}원`,
|
||||
icon: Gift,
|
||||
iconColor: 'text-pink-500',
|
||||
},
|
||||
{
|
||||
label: '총 공제',
|
||||
value: `${formatCurrency(totalDeduction)}원`,
|
||||
icon: MinusCircle,
|
||||
iconColor: 'text-red-500',
|
||||
},
|
||||
];
|
||||
}, [salaryData]);
|
||||
|
||||
// ===== 테이블 컬럼 (부서, 직책, 이름, 직급, 기본급, 수당, 초과근무, 상여, 공제, 실지급액, 일자, 상태, 작업) =====
|
||||
const tableColumns = useMemo(() => [
|
||||
{ key: 'department', label: '부서', sortable: true, copyable: true },
|
||||
{ key: 'position', label: '직책', sortable: true, copyable: true },
|
||||
{ key: 'name', label: '이름', sortable: true, copyable: true },
|
||||
{ key: 'rank', label: '직급', sortable: true, copyable: true },
|
||||
{ key: 'baseSalary', label: '기본급', className: 'text-right', sortable: true, copyable: true },
|
||||
{ key: 'allowance', label: '수당', className: 'text-right', sortable: true, copyable: true },
|
||||
{ key: 'overtime', label: '초과근무', className: 'text-right', sortable: true, copyable: true },
|
||||
{ key: 'bonus', label: '상여', className: 'text-right', sortable: true, copyable: true },
|
||||
{ key: 'deduction', label: '공제', className: 'text-right', sortable: true, copyable: true },
|
||||
{ key: 'netPayment', label: '실지급액', className: 'text-right', sortable: true, copyable: true },
|
||||
{ key: 'paymentDate', label: '일자', className: 'text-center', sortable: true, copyable: true },
|
||||
{ key: 'status', label: '상태', className: 'text-center', sortable: true },
|
||||
], []);
|
||||
|
||||
// ===== filterConfig 기반 통합 필터 시스템 =====
|
||||
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
||||
{
|
||||
key: 'sort',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: Object.entries(SORT_OPTIONS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
})),
|
||||
},
|
||||
], []);
|
||||
|
||||
const filterValues: FilterValues = useMemo(() => ({
|
||||
sort: sortOption,
|
||||
}), [sortOption]);
|
||||
|
||||
const _handleFilterChange = useCallback((key: string, value: string | string[]) => {
|
||||
switch (key) {
|
||||
case 'sort':
|
||||
setSortOption(value as SortOption);
|
||||
break;
|
||||
}
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const _handleFilterReset = useCallback(() => {
|
||||
setSortOption('rank');
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
// ===== 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,
|
||||
|
||||
// 엑셀 다운로드 설정
|
||||
excelDownload: {
|
||||
columns: [
|
||||
{ header: '부서', key: 'department' },
|
||||
{ header: '직책', key: 'position' },
|
||||
{ header: '이름', key: 'employeeName' },
|
||||
{ header: '직급', key: 'rank' },
|
||||
{ header: '기본급', key: 'baseSalary' },
|
||||
{ header: '수당', key: 'allowance' },
|
||||
{ header: '초과근무', key: 'overtime' },
|
||||
{ header: '상여', key: 'bonus' },
|
||||
{ header: '공제', key: 'deduction' },
|
||||
{ header: '실지급액', key: 'netPayment' },
|
||||
{ header: '지급일', key: 'paymentDate' },
|
||||
{ header: '상태', key: 'status', transform: (value: unknown) => value === 'completed' ? '지급완료' : '지급예정' },
|
||||
],
|
||||
filename: '급여명세',
|
||||
sheetName: '급여',
|
||||
},
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
// 날짜 범위 선택 (DateRangeSelector 사용)
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
showPresets: false,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
|
||||
selectionActions: () => (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
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
|
||||
size="sm"
|
||||
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>
|
||||
</>
|
||||
),
|
||||
|
||||
headerActions: () => (
|
||||
<Button size="sm" onClick={() => setRegistrationDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
급여 등록
|
||||
</Button>
|
||||
),
|
||||
|
||||
renderTableRow: (item, index, globalIndex, handlers) => {
|
||||
const { isSelected, onToggle } = handlers;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleViewDetail(item)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<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>
|
||||
</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}
|
||||
onClick={() => handleViewDetail(item)}
|
||||
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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
renderDialogs: (_params) => (
|
||||
<>
|
||||
<SalaryDetailDialog
|
||||
open={detailDialogOpen}
|
||||
onOpenChange={setDetailDialogOpen}
|
||||
salaryDetail={selectedSalaryDetail}
|
||||
onSave={handleSaveDetail}
|
||||
/>
|
||||
<SalaryRegistrationDialog
|
||||
open={registrationDialogOpen}
|
||||
onOpenChange={setRegistrationDialogOpen}
|
||||
onSave={handleCreateSalary}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
}), [
|
||||
salaryData,
|
||||
totalCount,
|
||||
totalPages,
|
||||
tableColumns,
|
||||
filterConfig,
|
||||
filterValues,
|
||||
statCards,
|
||||
startDate,
|
||||
endDate,
|
||||
handleMarkCompleted,
|
||||
handleMarkScheduled,
|
||||
isActionLoading,
|
||||
handleViewDetail,
|
||||
detailDialogOpen,
|
||||
selectedSalaryDetail,
|
||||
handleSaveDetail,
|
||||
handleAddPaymentItem,
|
||||
registrationDialogOpen,
|
||||
handleCreateSalary,
|
||||
]);
|
||||
|
||||
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}
|
||||
externalIsLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
100
src/components/hr/SalaryManagement_backup_20260312/types.ts
Normal file
100
src/components/hr/SalaryManagement_backup_20260312/types.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 급여관리 타입 정의
|
||||
*/
|
||||
|
||||
// 급여 상태 타입
|
||||
export type PaymentStatus = 'scheduled' | 'completed';
|
||||
|
||||
// 정렬 옵션 타입
|
||||
export type SortOption = 'rank' | 'name' | 'department' | 'paymentDate';
|
||||
|
||||
// 급여 레코드 인터페이스
|
||||
export interface SalaryRecord {
|
||||
id: string;
|
||||
employeeId: string;
|
||||
employeeName: string;
|
||||
department: string;
|
||||
position: string;
|
||||
rank: string;
|
||||
baseSalary: number; // 기본급
|
||||
allowance: number; // 수당
|
||||
overtime: number; // 초과근무
|
||||
bonus: number; // 상여
|
||||
deduction: number; // 공제
|
||||
netPayment: number; // 실지급액
|
||||
paymentDate: string; // 지급일
|
||||
status: PaymentStatus; // 상태
|
||||
year: number; // 년도
|
||||
month: number; // 월
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// 급여 상세 정보 인터페이스
|
||||
export interface SalaryDetail {
|
||||
// 기본 정보
|
||||
employeeId: string;
|
||||
employeeName: string;
|
||||
department: string;
|
||||
position: string;
|
||||
rank: string;
|
||||
|
||||
// 급여 정보
|
||||
baseSalary: number; // 본봉
|
||||
|
||||
// 수당 내역
|
||||
allowances: {
|
||||
positionAllowance: number; // 직책수당
|
||||
overtimeAllowance: number; // 초과근무수당
|
||||
mealAllowance: number; // 식대
|
||||
transportAllowance: number; // 교통비
|
||||
otherAllowance: number; // 기타수당
|
||||
};
|
||||
|
||||
// 공제 내역
|
||||
deductions: {
|
||||
nationalPension: number; // 국민연금
|
||||
healthInsurance: number; // 건강보험
|
||||
longTermCare: number; // 장기요양보험
|
||||
employmentInsurance: number; // 고용보험
|
||||
incomeTax: number; // 소득세
|
||||
localIncomeTax: number; // 지방소득세
|
||||
otherDeduction: number; // 기타공제
|
||||
};
|
||||
|
||||
// 합계
|
||||
totalAllowance: number; // 수당 합계
|
||||
totalDeduction: number; // 공제 합계
|
||||
netPayment: number; // 실지급액
|
||||
|
||||
// 추가 정보
|
||||
paymentDate: string;
|
||||
status: PaymentStatus;
|
||||
year: number;
|
||||
month: number;
|
||||
}
|
||||
|
||||
// 상태 라벨
|
||||
export const PAYMENT_STATUS_LABELS: Record<PaymentStatus, string> = {
|
||||
scheduled: '지급예정',
|
||||
completed: '지급완료',
|
||||
};
|
||||
|
||||
// 상태 색상
|
||||
export const PAYMENT_STATUS_COLORS: Record<PaymentStatus, string> = {
|
||||
scheduled: 'bg-blue-100 text-blue-800',
|
||||
completed: 'bg-green-100 text-green-800',
|
||||
};
|
||||
|
||||
// 정렬 옵션 라벨
|
||||
export const SORT_OPTIONS: Record<SortOption, string> = {
|
||||
rank: '직급순',
|
||||
name: '이름순',
|
||||
department: '부서순',
|
||||
paymentDate: '지급일순',
|
||||
};
|
||||
|
||||
// 금액 포맷 유틸리티
|
||||
export const formatCurrency = (amount: number): string => {
|
||||
return new Intl.NumberFormat('ko-KR').format(amount);
|
||||
};
|
||||
@@ -38,8 +38,8 @@ export function SearchFilter({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row gap-3 sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<div className={`flex gap-3 ${extraActions ? 'flex-col xl:flex-row xl:items-center xl:flex-wrap' : 'flex-col sm:flex-row sm:items-center'}`}>
|
||||
<div className={`relative ${extraActions ? 'xl:flex-1 xl:min-w-0 xl:max-w-xs' : 'flex-1'}`}>
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
|
||||
<Input
|
||||
placeholder={isMobile ? "내용을 검색해주세요." : searchPlaceholder}
|
||||
@@ -49,7 +49,7 @@ export function SearchFilter({
|
||||
/>
|
||||
</div>
|
||||
{extraActions && (
|
||||
<div className="flex gap-2 sm:ml-auto">
|
||||
<div className="flex flex-col gap-2 xl:flex-row xl:items-center xl:flex-wrap">
|
||||
{extraActions}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { formatNumber } from '@/lib/utils/amount';
|
||||
import {
|
||||
DollarSign,
|
||||
Package,
|
||||
ArrowLeft,
|
||||
X,
|
||||
Save,
|
||||
Calculator,
|
||||
TrendingUp,
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
CheckCircle2,
|
||||
Lock,
|
||||
} from 'lucide-react';
|
||||
import { useMenuStore } from '@/stores/menuStore';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -90,6 +91,7 @@ export function PricingFormClient({
|
||||
onFinalize,
|
||||
}: PricingFormClientProps) {
|
||||
const router = useRouter();
|
||||
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
// 품목 정보 (신규: itemInfo, 수정: initialData)
|
||||
@@ -711,40 +713,38 @@ export function PricingFormClient({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="flex gap-2 justify-between">
|
||||
{/* 하단 버튼 (sticky 하단 바) */}
|
||||
<div className={`fixed bottom-4 left-4 right-4 px-4 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 md:bottom-6 md:px-6 md:right-[48px] ${sidebarCollapsed ? 'md:left-[156px]' : 'md:left-[316px]'} flex items-center justify-between`}>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push('/sales/pricing-management')}
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
취소
|
||||
</Button>
|
||||
{isEditMode && initialData?.revisions && initialData.revisions.length > 0 && (
|
||||
<Button variant="outline" onClick={() => setShowHistoryDialog(true)}>
|
||||
<History className="h-4 w-4 mr-2" />
|
||||
<History className="h-4 w-4 mr-1" />
|
||||
이력 조회 ({initialData.currentRevision}차)
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push('/sales/pricing-management')}
|
||||
className="min-w-[100px]"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
취소
|
||||
</Button>
|
||||
{isEditMode && initialData && !initialData.isFinal && (
|
||||
<Button
|
||||
onClick={() => setShowFinalizeDialog(true)}
|
||||
className="min-w-[100px] bg-purple-600 hover:bg-purple-700"
|
||||
className="bg-purple-600 hover:bg-purple-700"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||
<CheckCircle2 className="h-4 w-4 mr-1" />
|
||||
최종 확정
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => handleSave()}
|
||||
className="min-w-[100px] bg-blue-600 hover:bg-blue-700"
|
||||
disabled={initialData?.isFinal || isSaving}
|
||||
>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
{initialData?.isFinal ? '확정됨' : '저장'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
481
src/components/quality/EquipmentInspection/index.tsx
Normal file
481
src/components/quality/EquipmentInspection/index.tsx
Normal file
@@ -0,0 +1,481 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 일상점검표 - 그리드 매트릭스 뷰
|
||||
*
|
||||
* 설비별 점검항목 × 날짜 매트릭스
|
||||
* 셀 클릭으로 ○/X/△ 토글
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import {
|
||||
getInspectionGrid,
|
||||
toggleInspectionResult,
|
||||
resetInspections,
|
||||
getEquipmentOptions,
|
||||
} from '@/components/quality/EquipmentManagement/actions';
|
||||
import type {
|
||||
EquipmentOptions,
|
||||
InspectionCycle,
|
||||
InspectionResult,
|
||||
} from '@/components/quality/EquipmentManagement/types';
|
||||
import {
|
||||
INSPECTION_CYCLE_LABEL,
|
||||
INSPECTION_CYCLES,
|
||||
INSPECTION_RESULT_SYMBOL,
|
||||
} from '@/components/quality/EquipmentManagement/types';
|
||||
|
||||
interface GridEquipment {
|
||||
id: number;
|
||||
equipmentCode: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface GridTemplate {
|
||||
id: number;
|
||||
itemNo: string;
|
||||
checkPoint: string;
|
||||
checkItem: string;
|
||||
}
|
||||
|
||||
interface GridRow {
|
||||
equipment: GridEquipment;
|
||||
templates: GridTemplate[];
|
||||
details: Record<string, InspectionResult>;
|
||||
canInspect: boolean;
|
||||
overallJudgment: string | null;
|
||||
}
|
||||
|
||||
interface GridData {
|
||||
rows: GridRow[];
|
||||
labels: string[];
|
||||
nonWorkingDays: string[];
|
||||
}
|
||||
|
||||
export function EquipmentInspectionGrid() {
|
||||
const searchParams = useSearchParams();
|
||||
const [cycle, setCycle] = useState<InspectionCycle>('daily');
|
||||
const [period, setPeriod] = useState<string>(() => {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||||
});
|
||||
const [lineFilter, setLineFilter] = useState<string>('all');
|
||||
const [equipmentFilter, setEquipmentFilter] = useState<string>(() => {
|
||||
if (typeof window === 'undefined') return 'all';
|
||||
const eqId = new URLSearchParams(window.location.search).get('equipment_id');
|
||||
return eqId || 'all';
|
||||
});
|
||||
const [options, setOptions] = useState<EquipmentOptions | null>(null);
|
||||
const [gridData, setGridData] = useState<GridData>({ rows: [], labels: [], nonWorkingDays: [] });
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showReset, setShowReset] = useState(false);
|
||||
|
||||
// 옵션 로드
|
||||
useEffect(() => {
|
||||
getEquipmentOptions().then((r) => {
|
||||
if (r.success && r.data) setOptions(r.data);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 그리드 데이터 로드
|
||||
const loadGrid = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
const result = await getInspectionGrid({
|
||||
cycle,
|
||||
period,
|
||||
productionLine: lineFilter,
|
||||
equipmentId: equipmentFilter !== 'all' ? Number(equipmentFilter) : undefined,
|
||||
});
|
||||
if (result.success && result.data) {
|
||||
// API는 배열을 반환: [{ equipment, templates, inspection, details, labels, can_inspect }, ...]
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const apiItems = Array.isArray(result.data) ? result.data as any[] : [];
|
||||
const rows: GridRow[] = apiItems.map((item) => {
|
||||
// details: API는 "templateItemId_date" 키의 배열 or 빈 배열 → Record<string, InspectionResult> 변환
|
||||
const detailMap: Record<string, InspectionResult> = {};
|
||||
if (item.details && typeof item.details === 'object' && !Array.isArray(item.details)) {
|
||||
for (const [key, arr] of Object.entries(item.details)) {
|
||||
if (Array.isArray(arr) && arr.length > 0) {
|
||||
detailMap[key] = (arr[0] as { result: InspectionResult }).result;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
equipment: {
|
||||
id: item.equipment?.id,
|
||||
equipmentCode: item.equipment?.equipment_code || '',
|
||||
name: item.equipment?.name || '',
|
||||
},
|
||||
templates: (item.templates || []).map((t: Record<string, unknown>) => ({
|
||||
id: t.id,
|
||||
itemNo: String(t.item_no ?? ''),
|
||||
checkPoint: t.check_point || '',
|
||||
checkItem: t.check_item || '',
|
||||
})),
|
||||
details: detailMap,
|
||||
canInspect: item.can_inspect ?? false,
|
||||
overallJudgment: item.inspection?.overall_judgment ?? null,
|
||||
};
|
||||
});
|
||||
// labels: 첫 번째 아이템의 labels (object {"1":"1",...} → string[] 변환)
|
||||
const rawLabels = apiItems.length > 0 ? apiItems[0].labels : {};
|
||||
const labels: string[] = typeof rawLabels === 'object' && !Array.isArray(rawLabels)
|
||||
? Object.keys(rawLabels).sort((a, b) => Number(a) - Number(b))
|
||||
: Array.isArray(rawLabels) ? rawLabels : [];
|
||||
// 주말(토/일) 계산
|
||||
const [y, m] = period.split('-').map(Number);
|
||||
const weekends: string[] = [];
|
||||
const daysInMonth = new Date(y, m, 0).getDate();
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const dow = new Date(y, m - 1, d).getDay();
|
||||
if (dow === 0 || dow === 6) weekends.push(String(d));
|
||||
}
|
||||
setGridData({ rows, labels, nonWorkingDays: weekends });
|
||||
} else {
|
||||
setGridData({ rows: [], labels: [], nonWorkingDays: [] });
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, [cycle, period, lineFilter, equipmentFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
loadGrid();
|
||||
}, [loadGrid]);
|
||||
|
||||
// 셀 클릭 토글
|
||||
const handleCellClick = useCallback(async (
|
||||
equipmentId: number,
|
||||
templateItemId: number,
|
||||
dayLabel: string
|
||||
) => {
|
||||
// label("1","2"...) → full date("2026-03-01")
|
||||
const fullDate = `${period}-${dayLabel.padStart(2, '0')}`;
|
||||
const result = await toggleInspectionResult({
|
||||
equipmentId,
|
||||
templateItemId,
|
||||
checkDate: fullDate,
|
||||
cycle,
|
||||
});
|
||||
if (result.success && result.data) {
|
||||
setGridData((prev) => {
|
||||
if (!prev) return prev;
|
||||
const newRows = prev.rows.map((row) => {
|
||||
if (row.equipment.id !== equipmentId) return row;
|
||||
const key = `${templateItemId}_${dayLabel}`;
|
||||
return {
|
||||
...row,
|
||||
details: {
|
||||
...row.details,
|
||||
[key]: result.data!.result,
|
||||
},
|
||||
};
|
||||
});
|
||||
return { ...prev, rows: newRows };
|
||||
});
|
||||
} else {
|
||||
toast.error(result.error || '점검 결과 변경에 실패했습니다.');
|
||||
}
|
||||
}, [cycle, period]);
|
||||
|
||||
// 전체 초기화
|
||||
const handleReset = useCallback(async () => {
|
||||
const result = await resetInspections({ cycle, period });
|
||||
if (result.success) {
|
||||
toast.success(`${result.data?.deleted_count || 0}건의 점검 데이터가 초기화되었습니다.`);
|
||||
setShowReset(false);
|
||||
await loadGrid();
|
||||
} else {
|
||||
toast.error(result.error || '초기화에 실패했습니다.');
|
||||
}
|
||||
}, [cycle, period, loadGrid]);
|
||||
|
||||
const getResultSymbol = (result: InspectionResult): string => {
|
||||
if (!result) return '';
|
||||
return INSPECTION_RESULT_SYMBOL[result] || '';
|
||||
};
|
||||
|
||||
const getResultColor = (result: InspectionResult): string => {
|
||||
if (!result) return '';
|
||||
if (result === 'good') return 'text-green-600';
|
||||
if (result === 'bad') return 'text-red-600';
|
||||
if (result === 'repaired') return 'text-yellow-600';
|
||||
return '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-3 md:p-6">
|
||||
<h1 className="text-xl font-bold">일상점검표</h1>
|
||||
|
||||
{/* 주기 탭 버튼 */}
|
||||
<div className="grid grid-cols-3 gap-1 border-b pb-1 md:flex md:items-center md:gap-1 md:pb-0">
|
||||
{INSPECTION_CYCLES.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors text-center ${
|
||||
cycle === c
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
onClick={() => setCycle(c)}
|
||||
>
|
||||
{INSPECTION_CYCLE_LABEL[c]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 필터: 점검년월 / 생산라인 / 설비 / 조회 / 전체 초기화 */}
|
||||
<Card>
|
||||
<CardContent className="py-5 px-6">
|
||||
<div className="flex flex-wrap items-end gap-6">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">점검년월</Label>
|
||||
<DatePicker
|
||||
value={period ? `${period}-01` : ''}
|
||||
onChange={(v) => {
|
||||
if (v) setPeriod(v.substring(0, 7));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">생산라인</Label>
|
||||
<Select value={lineFilter} onValueChange={setLineFilter}>
|
||||
<SelectTrigger className="w-[180px] h-10">
|
||||
<SelectValue placeholder="전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
{(options?.productionLines || []).map((line) => (
|
||||
<SelectItem key={line} value={line}>
|
||||
{line}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">설비</Label>
|
||||
<Select value={equipmentFilter} onValueChange={setEquipmentFilter}>
|
||||
<SelectTrigger className="w-[180px] h-10">
|
||||
<SelectValue placeholder="전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
{(options?.equipmentList || []).map((eq) => (
|
||||
<SelectItem key={eq.id} value={String(eq.id)}>
|
||||
{eq.equipmentCode} {eq.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-10 text-red-600 border-red-300 hover:bg-red-50"
|
||||
onClick={() => setShowReset(true)}
|
||||
>
|
||||
전체 초기화
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 그리드 */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : gridData.rows.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="flex items-center justify-center" style={{ minHeight: 240 }}>
|
||||
<div className="text-center space-y-3">
|
||||
<p className="text-red-500 font-bold text-base">점검 가능한 설비가 없습니다.</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
설비 등록대장에서 해당 주기의 점검항목을 추가해주세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{INSPECTION_CYCLE_LABEL[cycle]} 점검표 - {period}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 md:px-6">
|
||||
{(() => {
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 640;
|
||||
const COL1 = isMobile ? 70 : 120; // 설비
|
||||
const COL2 = isMobile ? 56 : 100; // 점검부위
|
||||
const COL3 = isMobile ? 56 : 100; // 점검항목
|
||||
const bc = '#e5e7eb'; // border color
|
||||
const stickyHead = (left: number, w: number) => ({
|
||||
position: 'sticky' as const, left, width: w, minWidth: w, zIndex: 20,
|
||||
background: '#f3f4f6',
|
||||
borderRight: `1px solid ${bc}`, borderBottom: `1px solid ${bc}`,
|
||||
});
|
||||
const stickyBody = (left: number, w: number) => ({
|
||||
position: 'sticky' as const, left, width: w, minWidth: w, zIndex: 10,
|
||||
background: '#fff',
|
||||
borderRight: `1px solid ${bc}`, borderBottom: `1px solid ${bc}`,
|
||||
});
|
||||
const normalHead = (w: number) => ({
|
||||
width: w, minWidth: w,
|
||||
background: '#f3f4f6',
|
||||
borderRight: `1px solid ${bc}`, borderBottom: `1px solid ${bc}`,
|
||||
});
|
||||
const normalBody = (w: number) => ({
|
||||
width: w, minWidth: w,
|
||||
borderRight: `1px solid ${bc}`, borderBottom: `1px solid ${bc}`,
|
||||
});
|
||||
const lastStickyHead = (left: number, w: number) => ({
|
||||
...stickyHead(left, w),
|
||||
boxShadow: '4px 0 6px -2px rgba(0,0,0,0.08)',
|
||||
});
|
||||
const lastStickyBody = (left: number, w: number) => ({
|
||||
...stickyBody(left, w),
|
||||
boxShadow: '4px 0 6px -2px rgba(0,0,0,0.08)',
|
||||
});
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs" style={{ borderCollapse: 'separate', borderSpacing: 0 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="p-1.5 text-left" style={{ ...(isMobile ? lastStickyHead(0, COL1) : stickyHead(0, COL1)), borderLeft: `1px solid ${bc}`, borderTop: `1px solid ${bc}` }}>설비</th>
|
||||
<th className="p-1.5 text-left" style={{ ...(isMobile ? { ...normalHead(COL2), borderTop: `1px solid ${bc}` } : { ...stickyHead(COL1, COL2), borderTop: `1px solid ${bc}` }) }}>점검부위</th>
|
||||
<th className="p-1.5 text-left" style={{ ...(isMobile ? { ...normalHead(COL3), borderTop: `1px solid ${bc}` } : { ...lastStickyHead(COL1 + COL2, COL3), borderTop: `1px solid ${bc}` }) }}>점검항목</th>
|
||||
{gridData.labels.map((label) => {
|
||||
const isHoliday = gridData.nonWorkingDays.includes(label);
|
||||
return (
|
||||
<th
|
||||
key={label}
|
||||
className="p-1.5 text-center min-w-[36px]"
|
||||
style={{
|
||||
background: isHoliday ? '#fef2f2' : '#f3f4f6',
|
||||
color: isHoliday ? '#dc2626' : undefined,
|
||||
fontWeight: isHoliday ? 700 : undefined,
|
||||
borderRight: `1px solid ${bc}`, borderBottom: `1px solid ${bc}`, borderTop: `1px solid ${bc}`,
|
||||
}}
|
||||
>
|
||||
{label.split('-').pop()}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
<th className="p-1.5 text-center min-w-[40px]" style={{ background: '#f3f4f6', borderRight: `1px solid ${bc}`, borderBottom: `1px solid ${bc}`, borderTop: `1px solid ${bc}` }}>판정</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{gridData.rows.map((row) =>
|
||||
row.templates.length === 0 ? (
|
||||
<tr key={row.equipment.id}>
|
||||
<td className="p-1.5" style={{ ...(isMobile ? lastStickyBody(0, COL1) : stickyBody(0, COL1)), borderLeft: `1px solid ${bc}` }}>
|
||||
<div className="font-medium">{row.equipment.equipmentCode}</div>
|
||||
<div className="text-muted-foreground">{row.equipment.name}</div>
|
||||
</td>
|
||||
<td className="p-1.5 text-center text-muted-foreground" colSpan={2 + gridData.labels.length + 1} style={{ borderRight: `1px solid ${bc}`, borderBottom: `1px solid ${bc}` }}>
|
||||
점검항목 없음
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
row.templates.map((template, tIdx) => (
|
||||
<tr key={`${row.equipment.id}-${template.id}`}>
|
||||
{tIdx === 0 && (
|
||||
<td
|
||||
className="p-1.5"
|
||||
rowSpan={row.templates.length}
|
||||
style={{ ...(isMobile ? lastStickyBody(0, COL1) : stickyBody(0, COL1)), borderLeft: `1px solid ${bc}` }}
|
||||
>
|
||||
<div className="font-medium">{row.equipment.equipmentCode}</div>
|
||||
<div className="text-muted-foreground">{row.equipment.name}</div>
|
||||
</td>
|
||||
)}
|
||||
<td className="p-1.5" style={isMobile ? normalBody(COL2) : stickyBody(COL1, COL2)}>{template.checkPoint}</td>
|
||||
<td className="p-1.5" style={isMobile ? normalBody(COL3) : lastStickyBody(COL1 + COL2, COL3)}>{template.checkItem}</td>
|
||||
{gridData.labels.map((label) => {
|
||||
const key = `${template.id}_${label}`;
|
||||
const result = row.details[key];
|
||||
const isNonWorking = gridData.nonWorkingDays.includes(label);
|
||||
return (
|
||||
<td
|
||||
key={label}
|
||||
className={`p-0 text-center cursor-pointer hover:bg-blue-50 transition-colors ${getResultColor(result)}`}
|
||||
style={{
|
||||
borderRight: `1px solid ${bc}`, borderBottom: `1px solid ${bc}`,
|
||||
...(isNonWorking ? { background: '#fef2f2' } : {}),
|
||||
}}
|
||||
onClick={() =>
|
||||
row.canInspect &&
|
||||
handleCellClick(row.equipment.id, template.id, label)
|
||||
}
|
||||
>
|
||||
<span className="text-sm font-bold">{getResultSymbol(result)}</span>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
{tIdx === 0 && (
|
||||
<td
|
||||
className="p-1.5 text-center font-bold"
|
||||
rowSpan={row.templates.length}
|
||||
style={{ borderRight: `1px solid ${bc}`, borderBottom: `1px solid ${bc}` }}
|
||||
>
|
||||
{row.overallJudgment || '-'}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))
|
||||
)
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 범례 */}
|
||||
<div className="flex items-center gap-4 mt-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="text-green-600 font-bold">○</span> 양호
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="text-red-600 font-bold">X</span> 불량
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="text-yellow-600 font-bold">△</span> 수리
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block w-3 h-3 bg-red-50 border"></span> 휴일
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 전체 초기화 확인 */}
|
||||
<ConfirmDialog
|
||||
open={showReset}
|
||||
onOpenChange={setShowReset}
|
||||
title="점검 데이터 전체 초기화"
|
||||
description={`${period} ${INSPECTION_CYCLE_LABEL[cycle]} 점검 데이터를 모두 초기화하시겠습니까? 이 작업은 되돌릴 수 없습니다.`}
|
||||
confirmText="전체 초기화"
|
||||
variant="destructive"
|
||||
onConfirm={handleReset}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1164
src/components/quality/EquipmentManagement/EquipmentDetail.tsx
Normal file
1164
src/components/quality/EquipmentManagement/EquipmentDetail.tsx
Normal file
File diff suppressed because it is too large
Load Diff
561
src/components/quality/EquipmentManagement/EquipmentForm.tsx
Normal file
561
src/components/quality/EquipmentManagement/EquipmentForm.tsx
Normal file
@@ -0,0 +1,561 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 설비 등록/수정 폼
|
||||
*
|
||||
* 섹션: 기본정보 / 제조사 정보 / 설치 정보 / 관리자·비고
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Loader2, Save, X, ImagePlus, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useMenuStore } from '@/stores/menuStore';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { FormField } from '@/components/molecules/FormField';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
createEquipment,
|
||||
updateEquipment,
|
||||
getEquipmentOptions,
|
||||
getEquipmentDetail,
|
||||
getManagerOptions,
|
||||
uploadEquipmentPhoto,
|
||||
} from './actions';
|
||||
import type {
|
||||
EquipmentFormData,
|
||||
EquipmentOptions,
|
||||
Equipment,
|
||||
ManagerOption,
|
||||
} from './types';
|
||||
import { EQUIPMENT_STATUS_LABEL } from './types';
|
||||
|
||||
const initialFormData: EquipmentFormData = {
|
||||
equipmentCode: '',
|
||||
name: '',
|
||||
equipmentType: '',
|
||||
specification: '',
|
||||
manufacturer: '',
|
||||
modelName: '',
|
||||
serialNo: '',
|
||||
location: '',
|
||||
productionLine: '',
|
||||
purchaseDate: '',
|
||||
installDate: '',
|
||||
purchasePrice: '',
|
||||
usefulLife: '',
|
||||
status: 'active',
|
||||
managerId: '',
|
||||
subManagerId: '',
|
||||
memo: '',
|
||||
};
|
||||
|
||||
interface EquipmentFormProps {
|
||||
/** 수정 모드일 때 설비 ID */
|
||||
equipmentId?: string;
|
||||
/** 수정 모드일 때 기존 데이터 */
|
||||
initialData?: Equipment;
|
||||
/** 저장 후 콜백 */
|
||||
onSaveSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function EquipmentForm({ equipmentId, initialData, onSaveSuccess }: EquipmentFormProps) {
|
||||
const router = useRouter();
|
||||
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
|
||||
const isEditMode = !!equipmentId;
|
||||
|
||||
const [formData, setFormData] = useState<EquipmentFormData>(initialFormData);
|
||||
const [options, setOptions] = useState<EquipmentOptions | null>(null);
|
||||
const [managers, setManagers] = useState<ManagerOption[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(!!equipmentId && !initialData);
|
||||
const [pendingPhotos, setPendingPhotos] = useState<File[]>([]);
|
||||
const [photoPreviews, setPhotoPreviews] = useState<string[]>([]);
|
||||
const photoInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 옵션 + 직원 목록 로드
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
getEquipmentOptions(),
|
||||
getManagerOptions(),
|
||||
]).then(([optResult, mgrResult]) => {
|
||||
if (optResult.success && optResult.data) {
|
||||
setOptions(optResult.data);
|
||||
}
|
||||
setManagers(mgrResult);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 수정 모드: 기존 데이터 세팅
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
setFormData({
|
||||
equipmentCode: initialData.equipmentCode,
|
||||
name: initialData.name,
|
||||
equipmentType: initialData.equipmentType,
|
||||
specification: initialData.specification,
|
||||
manufacturer: initialData.manufacturer,
|
||||
modelName: initialData.modelName,
|
||||
serialNo: initialData.serialNo,
|
||||
location: initialData.location,
|
||||
productionLine: initialData.productionLine,
|
||||
purchaseDate: initialData.purchaseDate,
|
||||
installDate: initialData.installDate,
|
||||
purchasePrice: initialData.purchasePrice,
|
||||
usefulLife: initialData.usefulLife ? String(initialData.usefulLife) : '',
|
||||
status: initialData.status,
|
||||
managerId: initialData.managerId ? String(initialData.managerId) : '',
|
||||
subManagerId: initialData.subManagerId ? String(initialData.subManagerId) : '',
|
||||
memo: initialData.memo,
|
||||
});
|
||||
} else if (equipmentId) {
|
||||
setIsLoading(true);
|
||||
getEquipmentDetail(equipmentId).then((result) => {
|
||||
if (result.success && result.data) {
|
||||
const d = result.data;
|
||||
setFormData({
|
||||
equipmentCode: d.equipmentCode,
|
||||
name: d.name,
|
||||
equipmentType: d.equipmentType,
|
||||
specification: d.specification,
|
||||
manufacturer: d.manufacturer,
|
||||
modelName: d.modelName,
|
||||
serialNo: d.serialNo,
|
||||
location: d.location,
|
||||
productionLine: d.productionLine,
|
||||
purchaseDate: d.purchaseDate,
|
||||
installDate: d.installDate,
|
||||
purchasePrice: d.purchasePrice,
|
||||
usefulLife: d.usefulLife ? String(d.usefulLife) : '',
|
||||
status: d.status,
|
||||
managerId: d.managerId ? String(d.managerId) : '',
|
||||
subManagerId: d.subManagerId ? String(d.subManagerId) : '',
|
||||
memo: d.memo,
|
||||
});
|
||||
} else {
|
||||
toast.error(result.error || '설비 데이터를 불러올 수 없습니다.');
|
||||
router.push('/quality/equipment');
|
||||
}
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
}, [equipmentId, initialData, router]);
|
||||
|
||||
const handleChange = useCallback((field: keyof EquipmentFormData, value: string) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
const handlePhotoSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (pendingPhotos.length >= 10) {
|
||||
toast.error('최대 10장까지 등록 가능합니다.');
|
||||
return;
|
||||
}
|
||||
setPendingPhotos((prev) => [...prev, file]);
|
||||
setPhotoPreviews((prev) => [...prev, URL.createObjectURL(file)]);
|
||||
if (photoInputRef.current) photoInputRef.current.value = '';
|
||||
}, [pendingPhotos.length]);
|
||||
|
||||
const handlePhotoRemove = useCallback((index: number) => {
|
||||
URL.revokeObjectURL(photoPreviews[index]);
|
||||
setPendingPhotos((prev) => prev.filter((_, i) => i !== index));
|
||||
setPhotoPreviews((prev) => prev.filter((_, i) => i !== index));
|
||||
}, [photoPreviews]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!formData.equipmentCode.trim()) {
|
||||
toast.error('설비코드를 입력하세요.');
|
||||
return;
|
||||
}
|
||||
if (!formData.name.trim()) {
|
||||
toast.error('설비명을 입력하세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = isEditMode
|
||||
? await updateEquipment(equipmentId!, formData)
|
||||
: await createEquipment(formData);
|
||||
|
||||
if (result.success) {
|
||||
// 신규 등록 시 대기 중인 사진 업로드
|
||||
if (!isEditMode && pendingPhotos.length > 0 && result.data?.id) {
|
||||
const newId = String(result.data.id);
|
||||
let uploadedCount = 0;
|
||||
for (const file of pendingPhotos) {
|
||||
const photoResult = await uploadEquipmentPhoto(newId, file);
|
||||
if (photoResult.success) {
|
||||
uploadedCount++;
|
||||
} else {
|
||||
toast.error(photoResult.error || '사진 업로드에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
if (uploadedCount > 0) {
|
||||
toast.success(`사진 ${uploadedCount}장이 업로드되었습니다.`);
|
||||
}
|
||||
// preview URL 정리
|
||||
photoPreviews.forEach((url) => URL.revokeObjectURL(url));
|
||||
}
|
||||
toast.success(isEditMode ? '설비가 수정되었습니다.' : '설비가 등록되었습니다.');
|
||||
if (onSaveSuccess) {
|
||||
onSaveSuccess();
|
||||
} else {
|
||||
router.push('/quality/equipment');
|
||||
}
|
||||
} else {
|
||||
toast.error(result.error || '저장에 실패했습니다.');
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [formData, isEditMode, equipmentId, onSaveSuccess, router, pendingPhotos, photoPreviews]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-3 md:p-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold">{isEditMode ? '설비 수정' : '설비 등록'}</h1>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => router.push('/quality/equipment')}
|
||||
>
|
||||
← 목록으로
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 기본정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">기본정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<FormField
|
||||
label="설비코드"
|
||||
required
|
||||
value={formData.equipmentCode}
|
||||
onChange={(v) => handleChange('equipmentCode', v)}
|
||||
placeholder="KD-M-001"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">예: KD-M-001, KD-S-002</p>
|
||||
</div>
|
||||
<FormField
|
||||
label="설비명"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(v) => handleChange('name', v)}
|
||||
placeholder="포밍기#1"
|
||||
/>
|
||||
<div>
|
||||
<Label>설비유형</Label>
|
||||
<div className="mt-1">
|
||||
<Select
|
||||
value={formData.equipmentType || '_none'}
|
||||
onValueChange={(v) => handleChange('equipmentType', v === '_none' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_none">선택</SelectItem>
|
||||
{(options?.equipmentTypes || []).map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<FormField
|
||||
label="규격"
|
||||
value={formData.specification}
|
||||
onChange={(v) => handleChange('specification', v)}
|
||||
placeholder="규격"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 제조사 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">제조사 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<FormField
|
||||
label="제조사"
|
||||
value={formData.manufacturer}
|
||||
onChange={(v) => handleChange('manufacturer', v)}
|
||||
placeholder="제조사"
|
||||
/>
|
||||
<FormField
|
||||
label="모델명"
|
||||
value={formData.modelName}
|
||||
onChange={(v) => handleChange('modelName', v)}
|
||||
placeholder="모델명"
|
||||
/>
|
||||
<FormField
|
||||
label="제조번호"
|
||||
value={formData.serialNo}
|
||||
onChange={(v) => handleChange('serialNo', v)}
|
||||
placeholder="제조번호"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 설치 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">설치 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
label="위치"
|
||||
value={formData.location}
|
||||
onChange={(v) => handleChange('location', v)}
|
||||
placeholder="1공장-1F"
|
||||
/>
|
||||
<div>
|
||||
<Label>생산라인</Label>
|
||||
<div className="mt-1">
|
||||
<Select
|
||||
value={formData.productionLine || '_none'}
|
||||
onValueChange={(v) => handleChange('productionLine', v === '_none' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_none">선택</SelectItem>
|
||||
{(options?.productionLines || []).map((line) => (
|
||||
<SelectItem key={line} value={line}>
|
||||
{line}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<FormField
|
||||
label="구입일"
|
||||
type="date"
|
||||
value={formData.purchaseDate}
|
||||
onChange={(v) => handleChange('purchaseDate', v)}
|
||||
/>
|
||||
<FormField
|
||||
label="설치일"
|
||||
type="date"
|
||||
value={formData.installDate}
|
||||
onChange={(v) => handleChange('installDate', v)}
|
||||
/>
|
||||
<FormField
|
||||
label="구입가격 (원)"
|
||||
type="number"
|
||||
value={formData.purchasePrice}
|
||||
onChange={(v) => handleChange('purchasePrice', v)}
|
||||
placeholder="구입가격"
|
||||
/>
|
||||
<FormField
|
||||
label="내용연수 (년)"
|
||||
type="number"
|
||||
value={formData.usefulLife}
|
||||
onChange={(v) => handleChange('usefulLife', v)}
|
||||
placeholder="내용연수"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 관리자 / 비고 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">관리자 / 비고</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>관리자 정</Label>
|
||||
<div className="mt-1">
|
||||
<Select
|
||||
value={formData.managerId || '_none'}
|
||||
onValueChange={(v) => handleChange('managerId', v === '_none' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_none">선택</SelectItem>
|
||||
{managers.map((mgr) => (
|
||||
<SelectItem key={mgr.id} value={mgr.id}>
|
||||
{mgr.department ? `${mgr.department} / ` : ''}{mgr.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>관리자 부</Label>
|
||||
<div className="mt-1">
|
||||
<Select
|
||||
value={formData.subManagerId || '_none'}
|
||||
onValueChange={(v) => handleChange('subManagerId', v === '_none' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_none">선택</SelectItem>
|
||||
{managers.map((mgr) => (
|
||||
<SelectItem key={mgr.id} value={mgr.id}>
|
||||
{mgr.department ? `${mgr.department} / ` : ''}{mgr.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>상태</Label>
|
||||
<div className="mt-1">
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(v) => handleChange('status', v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="상태 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(EQUIPMENT_STATUS_LABEL).map(([val, label]) => (
|
||||
<SelectItem key={val} value={val}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>비고</Label>
|
||||
<div className="mt-1">
|
||||
<Textarea
|
||||
value={formData.memo}
|
||||
onChange={(e) => handleChange('memo', e.target.value)}
|
||||
placeholder="비고를 입력하세요"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 설비 사진 */}
|
||||
{!isEditMode && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">설비 사진</CardTitle>
|
||||
<div>
|
||||
<input
|
||||
ref={photoInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handlePhotoSelect}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => photoInputRef.current?.click()}
|
||||
disabled={pendingPhotos.length >= 10}
|
||||
>
|
||||
<ImagePlus className="h-4 w-4 mr-1" />
|
||||
사진 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{pendingPhotos.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||
등록할 사진을 추가하세요. (저장 시 함께 업로드됩니다)
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{pendingPhotos.map((file, index) => (
|
||||
<div key={`photo-${index}`} className="space-y-2">
|
||||
<div className="relative border rounded-lg overflow-hidden bg-gray-50 group">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={photoPreviews[index]}
|
||||
alt={file.name}
|
||||
className="w-full h-auto max-h-[400px] object-contain"
|
||||
/>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
type="button"
|
||||
className="absolute top-2 right-2 h-7 px-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={() => handlePhotoRemove(index)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{file.name}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{pendingPhotos.length >= 10 && (
|
||||
<p className="text-xs text-muted-foreground mt-2">최대 10장까지 등록 가능합니다.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 하단 버튼 (sticky 하단 바) */}
|
||||
<div className={`fixed bottom-4 left-4 right-4 px-4 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 md:bottom-6 md:px-6 md:right-[48px] ${sidebarCollapsed ? 'md:left-[156px]' : 'md:left-[316px]'} flex items-center justify-between`}>
|
||||
<Button variant="outline" onClick={() => router.push('/quality/equipment')}>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||
{isSubmitting ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Save className="h-4 w-4 mr-1" />}
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
211
src/components/quality/EquipmentManagement/EquipmentImport.tsx
Normal file
211
src/components/quality/EquipmentManagement/EquipmentImport.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 설비 엑셀 Import 페이지
|
||||
*
|
||||
* 엑셀 파일 업로드 → 미리보기 → 일괄 등록
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Upload, FileSpreadsheet, Loader2, Download } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
|
||||
interface ImportRow {
|
||||
equipmentCode: string;
|
||||
name: string;
|
||||
equipmentType: string;
|
||||
specification: string;
|
||||
manufacturer: string;
|
||||
location: string;
|
||||
productionLine: string;
|
||||
status: string;
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
export function EquipmentImport() {
|
||||
const router = useRouter();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [previewData, setPreviewData] = useState<ImportRow[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
|
||||
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selected = e.target.files?.[0];
|
||||
if (!selected) return;
|
||||
|
||||
if (!selected.name.match(/\.(xlsx|xls|csv)$/i)) {
|
||||
toast.error('엑셀 파일(.xlsx, .xls) 또는 CSV 파일만 업로드 가능합니다.');
|
||||
return;
|
||||
}
|
||||
setFile(selected);
|
||||
setPreviewData([]);
|
||||
}, []);
|
||||
|
||||
const handleUploadPreview = useCallback(async () => {
|
||||
if (!file) {
|
||||
toast.error('파일을 선택하세요.');
|
||||
return;
|
||||
}
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch('/api/proxy/equipment/import/preview', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.data) {
|
||||
setPreviewData(result.data);
|
||||
toast.success(`${result.data.length}건의 데이터가 확인되었습니다.`);
|
||||
} else {
|
||||
toast.error(result.message || '파일 읽기에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('파일 업로드 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}, [file]);
|
||||
|
||||
const handleImport = useCallback(async () => {
|
||||
if (previewData.length === 0) return;
|
||||
setIsImporting(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file!);
|
||||
|
||||
const response = await fetch('/api/proxy/equipment/import', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
toast.success(`${result.data?.imported || previewData.length}건이 등록되었습니다.`);
|
||||
router.push('/quality/equipment');
|
||||
} else {
|
||||
toast.error(result.message || 'Import에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('Import 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
}, [file, previewData, router]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-3 md:p-6">
|
||||
{/* 헤더 */}
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">설비 엑셀 Import</h1>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-muted-foreground p-0 h-auto"
|
||||
onClick={() => router.push('/quality/equipment')}
|
||||
>
|
||||
← 목록으로
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 파일 업로드 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">파일 업로드</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Input
|
||||
type="file"
|
||||
accept=".xlsx,.xls,.csv"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<Button onClick={handleUploadPreview} disabled={!file || isUploading} className="w-full">
|
||||
{isUploading ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
미리보기
|
||||
</Button>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<FileSpreadsheet className="h-4 w-4 shrink-0" />
|
||||
<span>지원 형식: .xlsx, .xls, .csv</span>
|
||||
</div>
|
||||
<Button variant="link" size="sm" className="text-blue-600 p-0 h-auto">
|
||||
<Download className="h-3.5 w-3.5 mr-1" />
|
||||
양식 다운로드
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 미리보기 */}
|
||||
{previewData.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">
|
||||
미리보기 ({previewData.length}건)
|
||||
</CardTitle>
|
||||
<Button onClick={handleImport} disabled={isImporting}>
|
||||
{isImporting ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
일괄 등록
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px] text-center">No.</TableHead>
|
||||
<TableHead>설비번호</TableHead>
|
||||
<TableHead>설비명</TableHead>
|
||||
<TableHead>유형</TableHead>
|
||||
<TableHead>규격</TableHead>
|
||||
<TableHead>제조사</TableHead>
|
||||
<TableHead>위치</TableHead>
|
||||
<TableHead>생산라인</TableHead>
|
||||
<TableHead>상태</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{previewData.map((row, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell className="text-center">{idx + 1}</TableCell>
|
||||
<TableCell>{row.equipmentCode || '-'}</TableCell>
|
||||
<TableCell>{row.name || '-'}</TableCell>
|
||||
<TableCell>{row.equipmentType || '-'}</TableCell>
|
||||
<TableCell>{row.specification || '-'}</TableCell>
|
||||
<TableCell>{row.manufacturer || '-'}</TableCell>
|
||||
<TableCell>{row.location || '-'}</TableCell>
|
||||
<TableCell>{row.productionLine || '-'}</TableCell>
|
||||
<TableCell>{row.status || '-'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
588
src/components/quality/EquipmentManagement/actions.ts
Normal file
588
src/components/quality/EquipmentManagement/actions.ts
Normal file
@@ -0,0 +1,588 @@
|
||||
'use server';
|
||||
|
||||
/**
|
||||
* 설비관리 Server Actions
|
||||
*
|
||||
* API Endpoints:
|
||||
* - GET /api/v1/equipment - 목록 조회
|
||||
* - POST /api/v1/equipment - 등록
|
||||
* - GET /api/v1/equipment/{id} - 상세 조회
|
||||
* - PUT /api/v1/equipment/{id} - 수정
|
||||
* - DELETE /api/v1/equipment/{id} - 삭제
|
||||
* - GET /api/v1/equipment/options - 드롭다운 옵션
|
||||
* - GET /api/v1/equipment/stats - 통계
|
||||
* - GET /api/v1/equipment/{id}/templates - 점검 템플릿 조회
|
||||
* - POST /api/v1/equipment/{id}/templates - 점검항목 추가
|
||||
* - PUT /api/v1/equipment/templates/{id} - 점검항목 수정
|
||||
* - DELETE /api/v1/equipment/templates/{id} - 점검항목 삭제
|
||||
* - POST /api/v1/equipment/{id}/templates/copy - 주기 복사
|
||||
* - GET /api/v1/equipment/inspections - 점검 그리드 데이터
|
||||
* - PATCH /api/v1/equipment/inspections/toggle - 셀 클릭 토글
|
||||
* - PATCH /api/v1/equipment/inspections/set-result - 결과 직접 설정
|
||||
* - DELETE /api/v1/equipment/inspections/reset - 점검 초기화
|
||||
* - PATCH /api/v1/equipment/inspections/notes - 점검 메모 수정
|
||||
* - GET /api/v1/equipment/repairs - 수리이력 목록
|
||||
* - POST /api/v1/equipment/repairs - 수리이력 등록
|
||||
* - DELETE /api/v1/equipment/repairs/{id} - 수리이력 삭제
|
||||
*/
|
||||
|
||||
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
||||
import { buildApiUrl } from '@/lib/api/query-params';
|
||||
import type {
|
||||
EquipmentApiData,
|
||||
EquipmentPhotoApi,
|
||||
EquipmentOptionsApi,
|
||||
EquipmentStatsApi,
|
||||
EquipmentRepairApi,
|
||||
InspectionTemplateApi,
|
||||
Equipment,
|
||||
EquipmentOptions,
|
||||
EquipmentStats,
|
||||
EquipmentRepair,
|
||||
InspectionTemplate,
|
||||
EquipmentFormData,
|
||||
RepairFormData,
|
||||
InspectionTemplateFormData,
|
||||
EquipmentStatus,
|
||||
InspectionCycle,
|
||||
InspectionResult,
|
||||
PaginationMeta,
|
||||
ManagerOption,
|
||||
} from './types';
|
||||
|
||||
// ===== API → Frontend 변환 =====
|
||||
|
||||
function transformEquipment(api: EquipmentApiData): Equipment {
|
||||
return {
|
||||
id: String(api.id),
|
||||
equipmentCode: api.equipment_code || '',
|
||||
name: api.name || '',
|
||||
equipmentType: api.equipment_type || '',
|
||||
specification: api.specification || '',
|
||||
manufacturer: api.manufacturer || '',
|
||||
modelName: api.model_name || '',
|
||||
serialNo: api.serial_no || '',
|
||||
location: api.location || '',
|
||||
productionLine: api.production_line || '',
|
||||
purchaseDate: api.purchase_date || '',
|
||||
installDate: api.install_date || '',
|
||||
purchasePrice: api.purchase_price || '',
|
||||
usefulLife: api.useful_life,
|
||||
status: api.status || 'active',
|
||||
disposedDate: api.disposed_date || '',
|
||||
managerId: api.manager_id,
|
||||
subManagerId: api.sub_manager_id,
|
||||
managerName: api.manager?.name || '',
|
||||
subManagerName: api.subManager?.name || '',
|
||||
memo: api.memo || '',
|
||||
isActive: api.is_active,
|
||||
sortOrder: api.sort_order,
|
||||
photos: (api.photos || []).map((p) => ({
|
||||
id: p.id,
|
||||
displayName: p.display_name,
|
||||
filePath: p.file_path,
|
||||
fileSize: p.file_size,
|
||||
mimeType: p.mime_type,
|
||||
createdAt: p.created_at,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function transformRepair(api: EquipmentRepairApi): EquipmentRepair {
|
||||
return {
|
||||
id: String(api.id),
|
||||
equipmentId: api.equipment_id,
|
||||
repairDate: api.repair_date || '',
|
||||
repairType: api.repair_type,
|
||||
repairHours: api.repair_hours,
|
||||
description: api.description || '',
|
||||
cost: api.cost || '',
|
||||
vendor: api.vendor || '',
|
||||
repairedBy: api.repaired_by,
|
||||
repairerName: api.repairer?.name || '',
|
||||
memo: api.memo || '',
|
||||
equipmentCode: api.equipment?.equipment_code || '',
|
||||
equipmentName: api.equipment?.name || '',
|
||||
};
|
||||
}
|
||||
|
||||
function transformTemplate(api: InspectionTemplateApi): InspectionTemplate {
|
||||
return {
|
||||
id: api.id,
|
||||
equipmentId: api.equipment_id,
|
||||
inspectionCycle: api.inspection_cycle,
|
||||
itemNo: api.item_no || '',
|
||||
checkPoint: api.check_point || '',
|
||||
checkItem: api.check_item || '',
|
||||
checkTiming: api.check_timing || '',
|
||||
checkFrequency: api.check_frequency || '',
|
||||
checkMethod: api.check_method || '',
|
||||
sortOrder: api.sort_order,
|
||||
isActive: api.is_active,
|
||||
};
|
||||
}
|
||||
|
||||
function transformOptions(api: EquipmentOptionsApi): EquipmentOptions {
|
||||
return {
|
||||
equipmentTypes: api.equipment_types || [],
|
||||
productionLines: api.production_lines || [],
|
||||
statuses: api.statuses || {},
|
||||
equipmentList: (api.equipment_list || []).map((e) => ({
|
||||
id: e.id,
|
||||
equipmentCode: e.equipment_code,
|
||||
name: e.name,
|
||||
equipmentType: e.equipment_type,
|
||||
productionLine: e.production_line,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// ===== Frontend → API 변환 =====
|
||||
|
||||
function transformFormToApi(data: EquipmentFormData): Record<string, unknown> {
|
||||
return {
|
||||
equipment_code: data.equipmentCode,
|
||||
name: data.name,
|
||||
equipment_type: data.equipmentType || null,
|
||||
specification: data.specification || null,
|
||||
manufacturer: data.manufacturer || null,
|
||||
model_name: data.modelName || null,
|
||||
serial_no: data.serialNo || null,
|
||||
location: data.location || null,
|
||||
production_line: data.productionLine || null,
|
||||
purchase_date: data.purchaseDate || null,
|
||||
install_date: data.installDate || null,
|
||||
purchase_price: data.purchasePrice ? Number(data.purchasePrice) : null,
|
||||
useful_life: data.usefulLife ? Number(data.usefulLife) : null,
|
||||
status: data.status || 'active',
|
||||
manager_id: data.managerId ? Number(data.managerId) : null,
|
||||
sub_manager_id: data.subManagerId ? Number(data.subManagerId) : null,
|
||||
memo: data.memo || null,
|
||||
};
|
||||
}
|
||||
|
||||
function transformRepairFormToApi(data: RepairFormData): Record<string, unknown> {
|
||||
return {
|
||||
equipment_id: Number(data.equipmentId),
|
||||
repair_date: data.repairDate,
|
||||
repair_type: data.repairType || null,
|
||||
repair_hours: data.repairHours ? Number(data.repairHours) : null,
|
||||
description: data.description || null,
|
||||
cost: data.cost ? Number(data.cost) : null,
|
||||
vendor: data.vendor || null,
|
||||
repaired_by: data.repairedBy ? Number(data.repairedBy) : null,
|
||||
memo: data.memo || null,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 설비 CRUD =====
|
||||
|
||||
interface PaginatedEquipmentResponse {
|
||||
data: EquipmentApiData[];
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export async function getEquipmentList(params?: {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
search?: string;
|
||||
status?: EquipmentStatus | 'all';
|
||||
productionLine?: string;
|
||||
equipmentType?: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data: Equipment[];
|
||||
pagination: PaginationMeta;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
const defaultPagination: PaginationMeta = { currentPage: 1, lastPage: 1, perPage: 20, total: 0 };
|
||||
|
||||
const result = await executeServerAction<PaginatedEquipmentResponse>({
|
||||
url: buildApiUrl('/api/v1/equipment', {
|
||||
page: params?.page,
|
||||
per_page: params?.perPage || 20,
|
||||
search: params?.search,
|
||||
status: params?.status !== 'all' ? params?.status : undefined,
|
||||
production_line: params?.productionLine !== 'all' ? params?.productionLine : undefined,
|
||||
equipment_type: params?.equipmentType !== 'all' ? params?.equipmentType : undefined,
|
||||
}),
|
||||
errorMessage: '설비 목록 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, data: [], pagination: defaultPagination, error: result.error, __authError: result.__authError };
|
||||
}
|
||||
|
||||
const d = result.data;
|
||||
return {
|
||||
success: true,
|
||||
data: (d?.data || []).map(transformEquipment),
|
||||
pagination: {
|
||||
currentPage: d?.current_page || 1,
|
||||
lastPage: d?.last_page || 1,
|
||||
perPage: d?.per_page || 20,
|
||||
total: d?.total || 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function getEquipmentDetail(id: string): Promise<ActionResult<Equipment>> {
|
||||
const result = await executeServerAction<EquipmentApiData>({
|
||||
url: buildApiUrl(`/api/v1/equipment/${id}`),
|
||||
errorMessage: '설비 상세 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
if (!result.success) return { success: false, error: result.error, __authError: result.__authError };
|
||||
return result.data
|
||||
? { success: true, data: transformEquipment(result.data) }
|
||||
: { success: false, error: '설비 데이터를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
export async function createEquipment(data: EquipmentFormData): Promise<ActionResult<Equipment>> {
|
||||
const result = await executeServerAction<EquipmentApiData>({
|
||||
url: buildApiUrl('/api/v1/equipment'),
|
||||
method: 'POST',
|
||||
body: transformFormToApi(data),
|
||||
errorMessage: '설비 등록에 실패했습니다.',
|
||||
});
|
||||
|
||||
if (!result.success) return { success: false, error: result.error, fieldErrors: result.fieldErrors, __authError: result.__authError };
|
||||
return result.data
|
||||
? { success: true, data: transformEquipment(result.data) }
|
||||
: { success: true };
|
||||
}
|
||||
|
||||
export async function updateEquipment(id: string, data: EquipmentFormData): Promise<ActionResult<Equipment>> {
|
||||
const result = await executeServerAction<EquipmentApiData>({
|
||||
url: buildApiUrl(`/api/v1/equipment/${id}`),
|
||||
method: 'PUT',
|
||||
body: transformFormToApi(data),
|
||||
errorMessage: '설비 수정에 실패했습니다.',
|
||||
});
|
||||
|
||||
if (!result.success) return { success: false, error: result.error, fieldErrors: result.fieldErrors, __authError: result.__authError };
|
||||
return result.data
|
||||
? { success: true, data: transformEquipment(result.data) }
|
||||
: { success: true };
|
||||
}
|
||||
|
||||
export async function deleteEquipment(id: string): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/equipment/${id}`),
|
||||
method: 'DELETE',
|
||||
errorMessage: '설비 삭제에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 옵션 / 통계 =====
|
||||
|
||||
export async function getEquipmentOptions(): Promise<ActionResult<EquipmentOptions>> {
|
||||
const result = await executeServerAction<EquipmentOptionsApi>({
|
||||
url: buildApiUrl('/api/v1/equipment/options'),
|
||||
errorMessage: '설비 옵션 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
if (!result.success) return { success: false, error: result.error, __authError: result.__authError };
|
||||
return result.data
|
||||
? { success: true, data: transformOptions(result.data) }
|
||||
: { success: false, error: '옵션 데이터를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
export async function getEquipmentStats(): Promise<ActionResult<EquipmentStats>> {
|
||||
const result = await executeServerAction<EquipmentStatsApi>({
|
||||
url: buildApiUrl('/api/v1/equipment/stats'),
|
||||
errorMessage: '설비 통계 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
if (!result.success) return { success: false, error: result.error, __authError: result.__authError };
|
||||
if (!result.data) return { success: false, error: '통계 데이터를 찾을 수 없습니다.' };
|
||||
|
||||
const d = result.data;
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
total: d.total,
|
||||
active: d.active,
|
||||
idle: d.idle,
|
||||
disposed: d.disposed,
|
||||
inspectionStats: d.inspection_stats
|
||||
? {
|
||||
targetCount: d.inspection_stats.target_count,
|
||||
completedCount: d.inspection_stats.completed_count,
|
||||
issueCount: d.inspection_stats.issue_count,
|
||||
}
|
||||
: undefined,
|
||||
typeDistribution: d.type_distribution
|
||||
? d.type_distribution.map((t) => ({
|
||||
equipmentType: t.equipment_type,
|
||||
count: t.count,
|
||||
}))
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 점검 템플릿 =====
|
||||
|
||||
export async function getInspectionTemplates(
|
||||
equipmentId: string,
|
||||
cycle?: InspectionCycle
|
||||
): Promise<ActionResult<InspectionTemplate[]>> {
|
||||
const result = await executeServerAction<InspectionTemplateApi[]>({
|
||||
url: buildApiUrl(`/api/v1/equipment/${equipmentId}/templates`, {
|
||||
cycle,
|
||||
}),
|
||||
errorMessage: '점검항목 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
if (!result.success) return { success: false, error: result.error, __authError: result.__authError };
|
||||
// API가 객체 배열이 아닌 경우 방어 (예: 문자열 배열 반환 시)
|
||||
const rawData = result.data || [];
|
||||
const validData = rawData.filter((item): item is InspectionTemplateApi => typeof item === 'object' && item !== null && 'id' in item);
|
||||
return {
|
||||
success: true,
|
||||
data: validData.map(transformTemplate),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createInspectionTemplate(
|
||||
equipmentId: string,
|
||||
data: InspectionTemplateFormData
|
||||
): Promise<ActionResult<InspectionTemplate>> {
|
||||
const result = await executeServerAction<InspectionTemplateApi>({
|
||||
url: buildApiUrl(`/api/v1/equipment/${equipmentId}/templates`),
|
||||
method: 'POST',
|
||||
body: {
|
||||
inspection_cycle: data.inspectionCycle,
|
||||
item_no: data.itemNo,
|
||||
check_point: data.checkPoint,
|
||||
check_item: data.checkItem,
|
||||
check_timing: data.checkTiming || null,
|
||||
check_frequency: data.checkFrequency || null,
|
||||
check_method: data.checkMethod || null,
|
||||
},
|
||||
errorMessage: '점검항목 추가에 실패했습니다.',
|
||||
});
|
||||
|
||||
if (!result.success) return { success: false, error: result.error, fieldErrors: result.fieldErrors, __authError: result.__authError };
|
||||
return result.data
|
||||
? { success: true, data: transformTemplate(result.data) }
|
||||
: { success: true };
|
||||
}
|
||||
|
||||
export async function deleteInspectionTemplate(templateId: number): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/equipment/templates/${templateId}`),
|
||||
method: 'DELETE',
|
||||
errorMessage: '점검항목 삭제에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
export async function copyInspectionTemplates(
|
||||
equipmentId: string,
|
||||
sourceCycle: InspectionCycle,
|
||||
targetCycles: InspectionCycle[]
|
||||
): Promise<ActionResult<{ copied: number; skipped: number }>> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/equipment/${equipmentId}/templates/copy`),
|
||||
method: 'POST',
|
||||
body: {
|
||||
source_cycle: sourceCycle,
|
||||
target_cycles: targetCycles,
|
||||
},
|
||||
errorMessage: '점검항목 복사에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 점검 그리드 =====
|
||||
|
||||
export async function getInspectionGrid(params?: {
|
||||
cycle?: InspectionCycle;
|
||||
period?: string;
|
||||
productionLine?: string;
|
||||
equipmentId?: number;
|
||||
}): Promise<ActionResult<unknown>> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/equipment/inspections', {
|
||||
cycle: params?.cycle || 'daily',
|
||||
period: params?.period,
|
||||
production_line: params?.productionLine !== 'all' ? params?.productionLine : undefined,
|
||||
equipment_id: params?.equipmentId,
|
||||
}),
|
||||
errorMessage: '점검 데이터 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
export async function toggleInspectionResult(params: {
|
||||
equipmentId: number;
|
||||
templateItemId: number;
|
||||
checkDate: string;
|
||||
cycle?: InspectionCycle;
|
||||
}): Promise<ActionResult<{ result: InspectionResult; symbol: string }>> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/equipment/inspections/toggle'),
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
equipment_id: params.equipmentId,
|
||||
template_item_id: params.templateItemId,
|
||||
check_date: params.checkDate,
|
||||
cycle: params.cycle || 'daily',
|
||||
},
|
||||
errorMessage: '점검 결과 변경에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
export async function resetInspections(params: {
|
||||
equipmentId?: number;
|
||||
cycle?: InspectionCycle;
|
||||
period?: string;
|
||||
}): Promise<ActionResult<{ deleted_count: number }>> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/equipment/inspections/reset'),
|
||||
method: 'DELETE',
|
||||
body: {
|
||||
equipment_id: params.equipmentId,
|
||||
cycle: params.cycle || 'daily',
|
||||
period: params.period,
|
||||
},
|
||||
errorMessage: '점검 초기화에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 수리이력 =====
|
||||
|
||||
interface PaginatedRepairResponse {
|
||||
data: EquipmentRepairApi[];
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export async function getRepairList(params?: {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
search?: string;
|
||||
equipmentId?: string;
|
||||
repairType?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data: EquipmentRepair[];
|
||||
pagination: PaginationMeta;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
const defaultPagination: PaginationMeta = { currentPage: 1, lastPage: 1, perPage: 20, total: 0 };
|
||||
|
||||
const result = await executeServerAction<PaginatedRepairResponse>({
|
||||
url: buildApiUrl('/api/v1/equipment/repairs', {
|
||||
page: params?.page,
|
||||
per_page: params?.perPage || 20,
|
||||
search: params?.search,
|
||||
equipment_id: params?.equipmentId !== 'all' ? params?.equipmentId : undefined,
|
||||
repair_type: params?.repairType !== 'all' ? params?.repairType : undefined,
|
||||
date_from: params?.dateFrom,
|
||||
date_to: params?.dateTo,
|
||||
}),
|
||||
errorMessage: '수리이력 목록 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, data: [], pagination: defaultPagination, error: result.error, __authError: result.__authError };
|
||||
}
|
||||
|
||||
const d = result.data;
|
||||
return {
|
||||
success: true,
|
||||
data: (d?.data || []).map(transformRepair),
|
||||
pagination: {
|
||||
currentPage: d?.current_page || 1,
|
||||
lastPage: d?.last_page || 1,
|
||||
perPage: d?.per_page || 20,
|
||||
total: d?.total || 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function createRepair(data: RepairFormData): Promise<ActionResult<EquipmentRepair>> {
|
||||
const result = await executeServerAction<EquipmentRepairApi>({
|
||||
url: buildApiUrl('/api/v1/equipment/repairs'),
|
||||
method: 'POST',
|
||||
body: transformRepairFormToApi(data),
|
||||
errorMessage: '수리이력 등록에 실패했습니다.',
|
||||
});
|
||||
|
||||
if (!result.success) return { success: false, error: result.error, fieldErrors: result.fieldErrors, __authError: result.__authError };
|
||||
return result.data
|
||||
? { success: true, data: transformRepair(result.data) }
|
||||
: { success: true };
|
||||
}
|
||||
|
||||
export async function deleteRepair(id: string): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/equipment/repairs/${id}`),
|
||||
method: 'DELETE',
|
||||
errorMessage: '수리이력 삭제에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 설비 사진 =====
|
||||
|
||||
export async function uploadEquipmentPhoto(equipmentId: string, file: File): Promise<ActionResult<EquipmentPhotoApi>> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
return executeServerAction<EquipmentPhotoApi>({
|
||||
url: buildApiUrl(`/api/v1/equipment/${equipmentId}/photos`),
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
errorMessage: '사진 업로드에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteEquipmentPhoto(equipmentId: string, fileId: number): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/equipment/${equipmentId}/photos/${fileId}`),
|
||||
method: 'DELETE',
|
||||
errorMessage: '사진 삭제에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 직원 목록 (관리자 선택용) =====
|
||||
|
||||
interface EmployeeApiData {
|
||||
user_id?: number;
|
||||
user?: { id: number; name: string };
|
||||
name?: string;
|
||||
department?: { name: string };
|
||||
tenant_user_profile?: { department?: { name: string }; position?: { name: string } };
|
||||
position_key?: string;
|
||||
}
|
||||
|
||||
interface PaginatedEmployeeResponse {
|
||||
data: EmployeeApiData[];
|
||||
}
|
||||
|
||||
export async function getManagerOptions(): Promise<ManagerOption[]> {
|
||||
const result = await executeServerAction<PaginatedEmployeeResponse>({
|
||||
url: buildApiUrl('/api/v1/employees', { per_page: 100, status: 'active' }),
|
||||
errorMessage: '직원 목록 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
if (!result.success || !result.data?.data) return [];
|
||||
|
||||
return result.data.data
|
||||
.map((emp) => ({
|
||||
id: String(emp.user?.id || emp.user_id),
|
||||
name: emp.user?.name || emp.name || '',
|
||||
department: emp.department?.name || emp.tenant_user_profile?.department?.name || '',
|
||||
position: emp.position_key || emp.tenant_user_profile?.position?.name || '',
|
||||
}))
|
||||
.filter((emp) => emp.name && emp.id && emp.id !== 'undefined');
|
||||
}
|
||||
343
src/components/quality/EquipmentManagement/index.tsx
Normal file
343
src/components/quality/EquipmentManagement/index.tsx
Normal file
@@ -0,0 +1,343 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 설비 등록대장 - 목록 페이지
|
||||
*
|
||||
* 필터: 검색(설비번호/설비명), 상태, 라인, 유형
|
||||
* 테이블: 설비번호, 설비명, 유형, 위치, 생산라인, 상태, 관리자 정/부, QR, 액션
|
||||
* 액션: 엑셀 Import, 설비 등록
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Wrench,
|
||||
Plus,
|
||||
FileUp,
|
||||
SquarePen,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
type SelectionHandlers,
|
||||
type RowClickHandlers,
|
||||
type ListParams,
|
||||
type FilterFieldConfig,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import {
|
||||
getEquipmentList,
|
||||
getEquipmentOptions,
|
||||
deleteEquipment,
|
||||
} from './actions';
|
||||
import type { Equipment, EquipmentOptions, EquipmentStatus } from './types';
|
||||
import {
|
||||
EQUIPMENT_STATUS_LABEL,
|
||||
EQUIPMENT_STATUS_COLOR,
|
||||
} from './types';
|
||||
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
|
||||
export function EquipmentManagement() {
|
||||
const router = useRouter();
|
||||
|
||||
// ===== 필터 옵션 (동적) =====
|
||||
const [options, setOptions] = useState<EquipmentOptions | null>(null);
|
||||
|
||||
// ===== 삭제 확인 =====
|
||||
const [deleteTarget, setDeleteTarget] = useState<Equipment | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
// ===== 옵션 로드 =====
|
||||
useEffect(() => {
|
||||
getEquipmentOptions().then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setOptions(result.data);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ===== filterConfig (공용 모바일 필터 지원) =====
|
||||
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'single' as const,
|
||||
options: [
|
||||
{ value: 'active', label: '가동' },
|
||||
{ value: 'idle', label: '유휴' },
|
||||
{ value: 'disposed', label: '폐기' },
|
||||
],
|
||||
allOptionLabel: '상태 전체',
|
||||
},
|
||||
{
|
||||
key: 'productionLine',
|
||||
label: '라인',
|
||||
type: 'single' as const,
|
||||
options: (options?.productionLines || []).map((line) => ({
|
||||
value: line,
|
||||
label: line,
|
||||
})),
|
||||
allOptionLabel: '라인 전체',
|
||||
},
|
||||
{
|
||||
key: 'equipmentType',
|
||||
label: '유형',
|
||||
type: 'single' as const,
|
||||
options: (options?.equipmentTypes || []).map((type) => ({
|
||||
value: type,
|
||||
label: type,
|
||||
})),
|
||||
allOptionLabel: '유형 전체',
|
||||
},
|
||||
], [options]);
|
||||
|
||||
// ===== 행 클릭 =====
|
||||
const handleRowClick = useCallback(
|
||||
(item: Equipment) => {
|
||||
router.push(`/quality/equipment/${item.id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
// ===== 삭제 =====
|
||||
const handleDeleteClick = useCallback((e: React.MouseEvent, item: Equipment) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTarget(item);
|
||||
}, []);
|
||||
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
if (!deleteTarget) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const result = await deleteEquipment(deleteTarget.id);
|
||||
if (result.success) {
|
||||
toast.success('설비가 삭제되었습니다.');
|
||||
setRefreshKey((prev) => prev + 1);
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}, [deleteTarget]);
|
||||
|
||||
// ===== UniversalListPage Config =====
|
||||
const config: UniversalListConfig<Equipment> = useMemo(
|
||||
() => ({
|
||||
title: '설비 등록대장',
|
||||
description: '설비 정보를 관리합니다',
|
||||
icon: Wrench,
|
||||
basePath: '/quality/equipment',
|
||||
idField: 'id',
|
||||
|
||||
// API 액션
|
||||
actions: {
|
||||
getList: async (params?: ListParams) => {
|
||||
const filters = params?.filters || {};
|
||||
const result = await getEquipmentList({
|
||||
page: params?.page || 1,
|
||||
perPage: params?.pageSize || ITEMS_PER_PAGE,
|
||||
search: params?.search || undefined,
|
||||
status: (filters.status as EquipmentStatus) || undefined,
|
||||
productionLine: (filters.productionLine as string) || undefined,
|
||||
equipmentType: (filters.equipmentType as string) || undefined,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
totalCount: result.pagination.total,
|
||||
totalPages: result.pagination.lastPage,
|
||||
};
|
||||
}
|
||||
return { success: false, error: result.error };
|
||||
},
|
||||
},
|
||||
|
||||
// 헤더 액션 버튼 (함수 형태)
|
||||
headerActions: () => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => router.push('/quality/equipment/import')}
|
||||
>
|
||||
<FileUp className="h-4 w-4 mr-1" />
|
||||
엑셀 Import
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => router.push('/quality/equipment?mode=new')}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
설비 등록
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
|
||||
// 필터 (공용 MobileFilter 연동)
|
||||
filterConfig,
|
||||
filterTitle: '설비 필터',
|
||||
|
||||
// 테이블 컬럼
|
||||
columns: [
|
||||
{ key: 'no', label: 'No.', className: 'w-[50px] text-center' },
|
||||
{ key: 'equipmentCode', label: '설비번호', className: 'min-w-[110px]', copyable: true },
|
||||
{ key: 'name', label: '설비명', className: 'min-w-[100px]', copyable: true },
|
||||
{ key: 'equipmentType', label: '유형', className: 'w-[80px] text-center' },
|
||||
{ key: 'location', label: '위치', className: 'w-[80px] text-center' },
|
||||
{ key: 'productionLine', label: '생산라인', className: 'w-[80px] text-center' },
|
||||
{ key: 'status', label: '상태', className: 'w-[70px] text-center' },
|
||||
{ key: 'managerName', label: '관리자 정', className: 'w-[80px] text-center' },
|
||||
{ key: 'subManagerName', label: '관리자 부', className: 'w-[80px] text-center' },
|
||||
{ key: 'purchaseDate', label: '구입일', className: 'w-[100px] text-center' },
|
||||
{ key: 'qr', label: 'QR', className: 'w-[60px] text-center' },
|
||||
{ key: 'actions', label: '액션', className: 'w-[100px] text-center' },
|
||||
],
|
||||
|
||||
// 서버 사이드 페이지네이션
|
||||
clientSideFiltering: false,
|
||||
itemsPerPage: ITEMS_PER_PAGE,
|
||||
|
||||
// 검색 + 필터 (hideSearch: false → 검색 카드 안에 extraFilters 표시)
|
||||
hideSearch: false,
|
||||
searchPlaceholder: '설비번호/설비명 검색...',
|
||||
|
||||
// 테이블 행 렌더링
|
||||
renderTableRow: (
|
||||
item: Equipment,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<Equipment>
|
||||
) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handlers.onRowClick?.()}
|
||||
>
|
||||
<TableCell className="text-center">
|
||||
<Checkbox
|
||||
checked={handlers.isSelected}
|
||||
onCheckedChange={() => handlers.onToggle()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{globalIndex}</TableCell>
|
||||
<TableCell className="font-medium text-blue-600">{item.equipmentCode}</TableCell>
|
||||
<TableCell>{item.name}</TableCell>
|
||||
<TableCell className="text-center">{item.equipmentType || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.location || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.productionLine || '-'}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge className={EQUIPMENT_STATUS_COLOR[item.status] || ''} variant="secondary">
|
||||
{EQUIPMENT_STATUS_LABEL[item.status] || item.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.managerName || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.subManagerName || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.purchaseDate || '-'}</TableCell>
|
||||
<TableCell className="text-center">-</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-blue-600 hover:text-blue-800"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/quality/equipment/${item.id}?mode=edit`);
|
||||
}}
|
||||
>
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-red-600 hover:text-red-800"
|
||||
onClick={(e) => handleDeleteClick(e, item)}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
),
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
renderMobileCard: (
|
||||
item: Equipment,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<Equipment>
|
||||
) => (
|
||||
<ListMobileCard
|
||||
key={item.id}
|
||||
title={`${item.equipmentCode} ${item.name}`}
|
||||
headerBadges={
|
||||
<Badge className={EQUIPMENT_STATUS_COLOR[item.status]} variant="secondary">
|
||||
{EQUIPMENT_STATUS_LABEL[item.status]}
|
||||
</Badge>
|
||||
}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<InfoField label="유형" value={item.equipmentType || '-'} />
|
||||
<InfoField label="위치" value={item.location || '-'} />
|
||||
<InfoField label="생산라인" value={item.productionLine || '-'} />
|
||||
<InfoField label="관리자" value={item.managerName || '-'} />
|
||||
</div>
|
||||
}
|
||||
showCheckbox
|
||||
isSelected={handlers.isSelected}
|
||||
onToggleSelection={() => handlers.onToggle()}
|
||||
onClick={() => handlers.onRowClick?.()}
|
||||
actions={[
|
||||
{
|
||||
label: '수정',
|
||||
icon: SquarePen,
|
||||
onClick: () => router.push(`/quality/equipment/${item.id}?mode=edit`),
|
||||
},
|
||||
{
|
||||
label: '삭제',
|
||||
icon: Trash2,
|
||||
variant: 'destructive' as const,
|
||||
onClick: () => setDeleteTarget(item),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
|
||||
// 행 클릭
|
||||
onRowClick: handleRowClick,
|
||||
}),
|
||||
[filterConfig, handleRowClick, handleDeleteClick, router]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<UniversalListPage
|
||||
key={refreshKey}
|
||||
config={config}
|
||||
/>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open: boolean) => !open && setDeleteTarget(null)}
|
||||
itemName={deleteTarget?.name}
|
||||
description={`"${deleteTarget?.name}" 설비를 삭제하시겠습니까?`}
|
||||
loading={isDeleting}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
352
src/components/quality/EquipmentManagement/types.ts
Normal file
352
src/components/quality/EquipmentManagement/types.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
// 설비 등록대장 타입 정의
|
||||
|
||||
// ===== 설비 상태 =====
|
||||
export type EquipmentStatus = 'active' | 'idle' | 'disposed';
|
||||
|
||||
export const EQUIPMENT_STATUS_LABEL: Record<EquipmentStatus, string> = {
|
||||
active: '가동',
|
||||
idle: '유휴',
|
||||
disposed: '폐기',
|
||||
};
|
||||
|
||||
export const EQUIPMENT_STATUS_COLOR: Record<EquipmentStatus, string> = {
|
||||
active: 'bg-green-100 text-green-700',
|
||||
idle: 'bg-yellow-100 text-yellow-700',
|
||||
disposed: 'bg-red-100 text-red-700',
|
||||
};
|
||||
|
||||
// ===== 점검 주기 =====
|
||||
export type InspectionCycle = 'daily' | 'weekly' | 'monthly' | 'bimonthly' | 'quarterly' | 'semiannual';
|
||||
|
||||
export const INSPECTION_CYCLE_LABEL: Record<InspectionCycle, string> = {
|
||||
daily: '일일',
|
||||
weekly: '주간',
|
||||
monthly: '월간',
|
||||
bimonthly: '2개월',
|
||||
quarterly: '분기',
|
||||
semiannual: '반년',
|
||||
};
|
||||
|
||||
export const INSPECTION_CYCLES: InspectionCycle[] = [
|
||||
'daily', 'weekly', 'monthly', 'bimonthly', 'quarterly', 'semiannual',
|
||||
];
|
||||
|
||||
// ===== 점검 결과 =====
|
||||
export type InspectionResult = 'good' | 'bad' | 'repaired' | null;
|
||||
|
||||
export const INSPECTION_RESULT_SYMBOL: Record<string, string> = {
|
||||
good: '○',
|
||||
bad: 'X',
|
||||
repaired: '△',
|
||||
};
|
||||
|
||||
// ===== 보전 구분 =====
|
||||
export type RepairType = 'internal' | 'external';
|
||||
|
||||
export const REPAIR_TYPE_LABEL: Record<RepairType, string> = {
|
||||
internal: '사내',
|
||||
external: '외주',
|
||||
};
|
||||
|
||||
export const REPAIR_TYPE_COLOR: Record<RepairType, string> = {
|
||||
internal: 'bg-yellow-100 text-yellow-700',
|
||||
external: 'bg-blue-100 text-blue-700',
|
||||
};
|
||||
|
||||
// ===== API 응답 타입 =====
|
||||
|
||||
export interface EquipmentApiData {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
equipment_code: string;
|
||||
name: string;
|
||||
equipment_type: string | null;
|
||||
specification: string | null;
|
||||
manufacturer: string | null;
|
||||
model_name: string | null;
|
||||
serial_no: string | null;
|
||||
location: string | null;
|
||||
production_line: string | null;
|
||||
purchase_date: string | null;
|
||||
install_date: string | null;
|
||||
purchase_price: string | null;
|
||||
useful_life: number | null;
|
||||
status: EquipmentStatus;
|
||||
disposed_date: string | null;
|
||||
manager_id: number | null;
|
||||
sub_manager_id: number | null;
|
||||
memo: string | null;
|
||||
is_active: boolean;
|
||||
sort_order: number;
|
||||
manager: { id: number; name: string } | null;
|
||||
subManager: { id: number; name: string } | null;
|
||||
photos?: EquipmentPhotoApi[];
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface EquipmentPhotoApi {
|
||||
id: number;
|
||||
display_name: string;
|
||||
stored_name: string;
|
||||
file_path: string;
|
||||
file_size: number;
|
||||
mime_type: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface InspectionTemplateApi {
|
||||
id: number;
|
||||
equipment_id: number;
|
||||
inspection_cycle: InspectionCycle;
|
||||
item_no: string;
|
||||
check_point: string;
|
||||
check_item: string;
|
||||
check_timing: string | null;
|
||||
check_frequency: string | null;
|
||||
check_method: string | null;
|
||||
sort_order: number;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface EquipmentRepairApi {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
equipment_id: number;
|
||||
repair_date: string;
|
||||
repair_type: RepairType | null;
|
||||
repair_hours: number | null;
|
||||
description: string | null;
|
||||
cost: string | null;
|
||||
vendor: string | null;
|
||||
repaired_by: number | null;
|
||||
memo: string | null;
|
||||
equipment: { id: number; equipment_code: string; name: string } | null;
|
||||
repairer: { id: number; name: string } | null;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface EquipmentOptionsApi {
|
||||
equipment_types: string[];
|
||||
production_lines: string[];
|
||||
statuses: Record<EquipmentStatus, string>;
|
||||
equipment_list: Array<{
|
||||
id: number;
|
||||
equipment_code: string;
|
||||
name: string;
|
||||
equipment_type: string;
|
||||
production_line: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface EquipmentStatsApi {
|
||||
total: number;
|
||||
active: number;
|
||||
idle: number;
|
||||
disposed: number;
|
||||
inspection_stats?: {
|
||||
target_count: number;
|
||||
completed_count: number;
|
||||
issue_count: number;
|
||||
};
|
||||
type_distribution?: Array<{
|
||||
equipment_type: string;
|
||||
count: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ===== 프론트엔드 타입 =====
|
||||
|
||||
export interface Equipment {
|
||||
id: string;
|
||||
equipmentCode: string;
|
||||
name: string;
|
||||
equipmentType: string;
|
||||
specification: string;
|
||||
manufacturer: string;
|
||||
modelName: string;
|
||||
serialNo: string;
|
||||
location: string;
|
||||
productionLine: string;
|
||||
purchaseDate: string;
|
||||
installDate: string;
|
||||
purchasePrice: string;
|
||||
usefulLife: number | null;
|
||||
status: EquipmentStatus;
|
||||
disposedDate: string;
|
||||
managerId: number | null;
|
||||
subManagerId: number | null;
|
||||
managerName: string;
|
||||
subManagerName: string;
|
||||
memo: string;
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
photos: EquipmentPhoto[];
|
||||
}
|
||||
|
||||
export interface EquipmentPhoto {
|
||||
id: number;
|
||||
displayName: string;
|
||||
filePath: string;
|
||||
fileSize: number;
|
||||
mimeType: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface InspectionTemplate {
|
||||
id: number;
|
||||
equipmentId: number;
|
||||
inspectionCycle: InspectionCycle;
|
||||
itemNo: string;
|
||||
checkPoint: string;
|
||||
checkItem: string;
|
||||
checkTiming: string;
|
||||
checkFrequency: string;
|
||||
checkMethod: string;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface EquipmentRepair {
|
||||
id: string;
|
||||
equipmentId: number;
|
||||
repairDate: string;
|
||||
repairType: RepairType | null;
|
||||
repairHours: number | null;
|
||||
description: string;
|
||||
cost: string;
|
||||
vendor: string;
|
||||
repairedBy: number | null;
|
||||
repairerName: string;
|
||||
memo: string;
|
||||
equipmentCode: string;
|
||||
equipmentName: string;
|
||||
}
|
||||
|
||||
export interface EquipmentOptions {
|
||||
equipmentTypes: string[];
|
||||
productionLines: string[];
|
||||
statuses: Record<EquipmentStatus, string>;
|
||||
equipmentList: Array<{
|
||||
id: number;
|
||||
equipmentCode: string;
|
||||
name: string;
|
||||
equipmentType: string;
|
||||
productionLine: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface EquipmentStats {
|
||||
total: number;
|
||||
active: number;
|
||||
idle: number;
|
||||
disposed: number;
|
||||
inspectionStats?: {
|
||||
targetCount: number;
|
||||
completedCount: number;
|
||||
issueCount: number;
|
||||
};
|
||||
typeDistribution?: Array<{
|
||||
equipmentType: string;
|
||||
count: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ===== 폼 데이터 =====
|
||||
|
||||
export interface EquipmentFormData {
|
||||
equipmentCode: string;
|
||||
name: string;
|
||||
equipmentType: string;
|
||||
specification: string;
|
||||
manufacturer: string;
|
||||
modelName: string;
|
||||
serialNo: string;
|
||||
location: string;
|
||||
productionLine: string;
|
||||
purchaseDate: string;
|
||||
installDate: string;
|
||||
purchasePrice: string;
|
||||
usefulLife: string;
|
||||
status: EquipmentStatus;
|
||||
managerId: string;
|
||||
subManagerId: string;
|
||||
memo: string;
|
||||
}
|
||||
|
||||
export interface RepairFormData {
|
||||
equipmentId: string;
|
||||
repairDate: string;
|
||||
repairType: string;
|
||||
repairHours: string;
|
||||
description: string;
|
||||
cost: string;
|
||||
vendor: string;
|
||||
repairedBy: string;
|
||||
memo: string;
|
||||
}
|
||||
|
||||
export interface InspectionTemplateFormData {
|
||||
inspectionCycle: InspectionCycle;
|
||||
itemNo: string;
|
||||
checkPoint: string;
|
||||
checkItem: string;
|
||||
checkTiming: string;
|
||||
checkFrequency: string;
|
||||
checkMethod: string;
|
||||
}
|
||||
|
||||
// ===== 점검 그리드 =====
|
||||
|
||||
export interface InspectionGridRow {
|
||||
equipment: {
|
||||
id: number;
|
||||
equipmentCode: string;
|
||||
name: string;
|
||||
};
|
||||
templates: InspectionTemplate[];
|
||||
details: Record<string, InspectionResult>; // key: "{templateId}_{date}"
|
||||
canInspect: boolean;
|
||||
overallJudgment: string | null;
|
||||
}
|
||||
|
||||
export interface InspectionGridData {
|
||||
rows: InspectionGridRow[];
|
||||
labels: string[]; // 날짜 라벨 배열
|
||||
nonWorkingDays: string[]; // 주말/공휴일 날짜
|
||||
}
|
||||
|
||||
// ===== 직원 옵션 (관리자 선택용) =====
|
||||
|
||||
export interface ManagerOption {
|
||||
id: string;
|
||||
name: string;
|
||||
department: string;
|
||||
position: string;
|
||||
}
|
||||
|
||||
// ===== 페이지네이션 =====
|
||||
|
||||
export interface PaginationMeta {
|
||||
currentPage: number;
|
||||
lastPage: number;
|
||||
perPage: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ===== 필터 =====
|
||||
|
||||
export interface EquipmentFilter {
|
||||
search: string;
|
||||
status: EquipmentStatus | 'all';
|
||||
productionLine: string;
|
||||
equipmentType: string;
|
||||
}
|
||||
|
||||
export interface RepairFilter {
|
||||
search: string;
|
||||
equipmentId: string;
|
||||
repairType: string;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
}
|
||||
268
src/components/quality/EquipmentRepair/RepairForm.tsx
Normal file
268
src/components/quality/EquipmentRepair/RepairForm.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 수리이력 등록/수정 폼
|
||||
* URL: /quality/equipment-repairs?mode=new
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Loader2, Save, X, Zap } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useMenuStore } from '@/stores/menuStore';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { FormField } from '@/components/molecules/FormField';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
createRepair,
|
||||
getEquipmentOptions,
|
||||
getManagerOptions,
|
||||
} from '@/components/quality/EquipmentManagement/actions';
|
||||
import type {
|
||||
RepairFormData,
|
||||
EquipmentOptions,
|
||||
ManagerOption,
|
||||
} from '@/components/quality/EquipmentManagement/types';
|
||||
import { REPAIR_TYPE_LABEL } from '@/components/quality/EquipmentManagement/types';
|
||||
|
||||
const initialFormData: RepairFormData = {
|
||||
equipmentId: '',
|
||||
repairDate: '',
|
||||
repairType: '',
|
||||
repairHours: '',
|
||||
description: '',
|
||||
cost: '',
|
||||
vendor: '',
|
||||
repairedBy: '',
|
||||
memo: '',
|
||||
};
|
||||
|
||||
export function RepairForm() {
|
||||
const router = useRouter();
|
||||
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
|
||||
const listPath = '/quality/equipment-repairs';
|
||||
|
||||
const [formData, setFormData] = useState<RepairFormData>(initialFormData);
|
||||
const [options, setOptions] = useState<EquipmentOptions | null>(null);
|
||||
const [managers, setManagers] = useState<ManagerOption[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
getEquipmentOptions(),
|
||||
getManagerOptions(),
|
||||
]).then(([optResult, mgrResult]) => {
|
||||
if (optResult.success && optResult.data) {
|
||||
setOptions(optResult.data);
|
||||
}
|
||||
setManagers(mgrResult);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleChange = useCallback((field: keyof RepairFormData, value: string) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!formData.equipmentId) {
|
||||
toast.error('설비를 선택하세요.');
|
||||
return;
|
||||
}
|
||||
if (!formData.repairDate) {
|
||||
toast.error('수리일을 입력하세요.');
|
||||
return;
|
||||
}
|
||||
if (!formData.repairType) {
|
||||
toast.error('보전구분을 선택하세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await createRepair(formData);
|
||||
if (result.success) {
|
||||
toast.success('수리이력이 등록되었습니다.');
|
||||
router.push(listPath);
|
||||
} else {
|
||||
toast.error(result.error || '등록에 실패했습니다.');
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [formData, router, listPath]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-3 md:p-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold flex items-center gap-1">
|
||||
수리이력 등록
|
||||
<Zap className="h-5 w-5 text-yellow-500" />
|
||||
</h1>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => router.push(listPath)}
|
||||
>
|
||||
← 목록으로
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 폼 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-6">
|
||||
{/* Row 1: 설비 | 수리일 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>설비 <span className="text-red-500">*</span></Label>
|
||||
<div className="mt-1">
|
||||
<Select
|
||||
value={formData.equipmentId || '_none'}
|
||||
onValueChange={(v) => handleChange('equipmentId', v === '_none' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_none">선택</SelectItem>
|
||||
{(options?.equipmentList || []).map((eq) => (
|
||||
<SelectItem key={eq.id} value={String(eq.id)}>
|
||||
{eq.equipmentCode} {eq.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<FormField
|
||||
label="수리일"
|
||||
type="date"
|
||||
required
|
||||
value={formData.repairDate}
|
||||
onChange={(v) => handleChange('repairDate', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Row 2: 보전구분 | 수리시간 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>보전구분 <span className="text-red-500">*</span></Label>
|
||||
<div className="mt-1">
|
||||
<Select
|
||||
value={formData.repairType || '_none'}
|
||||
onValueChange={(v) => handleChange('repairType', v === '_none' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_none">선택</SelectItem>
|
||||
{Object.entries(REPAIR_TYPE_LABEL).map(([val, label]) => (
|
||||
<SelectItem key={val} value={val}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<FormField
|
||||
label="수리시간 (h)"
|
||||
type="number"
|
||||
value={formData.repairHours}
|
||||
onChange={(v) => handleChange('repairHours', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Row 3: 수리내용 */}
|
||||
<div>
|
||||
<Label>수리내용</Label>
|
||||
<div className="mt-1">
|
||||
<Textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => handleChange('description', e.target.value)}
|
||||
placeholder="수리 내용을 입력하세요"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 4: 수리비용 | 외주업체 | 수리자 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<FormField
|
||||
label="수리비용 (원)"
|
||||
type="number"
|
||||
value={formData.cost}
|
||||
onChange={(v) => handleChange('cost', v)}
|
||||
/>
|
||||
<FormField
|
||||
label="외주업체"
|
||||
value={formData.vendor}
|
||||
onChange={(v) => handleChange('vendor', v)}
|
||||
/>
|
||||
<div>
|
||||
<Label>수리자</Label>
|
||||
<div className="mt-1">
|
||||
<Select
|
||||
value={formData.repairedBy || '_none'}
|
||||
onValueChange={(v) => handleChange('repairedBy', v === '_none' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_none">선택</SelectItem>
|
||||
{managers.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name} ({m.department})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 5: 비고 */}
|
||||
<div>
|
||||
<Label>비고</Label>
|
||||
<div className="mt-1">
|
||||
<Textarea
|
||||
value={formData.memo}
|
||||
onChange={(e) => handleChange('memo', e.target.value)}
|
||||
placeholder="비고를 입력하세요"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 하단 버튼 (sticky 하단 바) */}
|
||||
<div className={`fixed bottom-4 left-4 right-4 px-4 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 md:bottom-6 md:px-6 md:right-[48px] ${sidebarCollapsed ? 'md:left-[156px]' : 'md:left-[316px]'} flex items-center justify-between`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push(listPath)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||
{isSubmitting ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Save className="h-4 w-4 mr-1" />}
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
284
src/components/quality/EquipmentRepair/index.tsx
Normal file
284
src/components/quality/EquipmentRepair/index.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 수리이력 목록 페이지
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Wrench,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
type SelectionHandlers,
|
||||
type RowClickHandlers,
|
||||
type ListParams,
|
||||
type FilterFieldConfig,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import {
|
||||
getRepairList,
|
||||
deleteRepair,
|
||||
getEquipmentOptions,
|
||||
} from '@/components/quality/EquipmentManagement/actions';
|
||||
import type {
|
||||
EquipmentRepair,
|
||||
EquipmentOptions,
|
||||
} from '@/components/quality/EquipmentManagement/types';
|
||||
import {
|
||||
REPAIR_TYPE_LABEL,
|
||||
REPAIR_TYPE_COLOR,
|
||||
} from '@/components/quality/EquipmentManagement/types';
|
||||
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
|
||||
export function RepairList() {
|
||||
const router = useRouter();
|
||||
|
||||
const [dateFrom, setDateFrom] = useState<string>('');
|
||||
const [dateTo, setDateTo] = useState<string>('');
|
||||
const [options, setOptions] = useState<EquipmentOptions | null>(null);
|
||||
|
||||
const [deleteTarget, setDeleteTarget] = useState<EquipmentRepair | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
getEquipmentOptions().then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setOptions(result.data);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ===== filterConfig (공용 모바일 필터 지원) =====
|
||||
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
||||
{
|
||||
key: 'equipmentId',
|
||||
label: '설비',
|
||||
type: 'single' as const,
|
||||
options: (options?.equipmentList || []).map((eq) => ({
|
||||
value: String(eq.id),
|
||||
label: `${eq.equipmentCode} ${eq.name}`,
|
||||
})),
|
||||
allOptionLabel: '설비 전체',
|
||||
},
|
||||
{
|
||||
key: 'repairType',
|
||||
label: '보전구분',
|
||||
type: 'single' as const,
|
||||
options: [
|
||||
{ value: 'internal', label: '사내' },
|
||||
{ value: 'external', label: '외주' },
|
||||
],
|
||||
allOptionLabel: '구분 전체',
|
||||
},
|
||||
], [options]);
|
||||
|
||||
const handleDeleteClick = useCallback((e: React.MouseEvent, item: EquipmentRepair) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTarget(item);
|
||||
}, []);
|
||||
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
if (!deleteTarget) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const result = await deleteRepair(deleteTarget.id);
|
||||
if (result.success) {
|
||||
toast.success('수리이력이 삭제되었습니다.');
|
||||
setRefreshKey((prev) => prev + 1);
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}, [deleteTarget]);
|
||||
|
||||
const config: UniversalListConfig<EquipmentRepair> = useMemo(
|
||||
() => ({
|
||||
title: '수리이력',
|
||||
description: '설비 수리이력을 관리합니다',
|
||||
icon: Wrench,
|
||||
basePath: '/quality/equipment-repairs',
|
||||
idField: 'id',
|
||||
|
||||
actions: {
|
||||
getList: async (params?: ListParams) => {
|
||||
const filters = params?.filters || {};
|
||||
const result = await getRepairList({
|
||||
page: params?.page || 1,
|
||||
perPage: params?.pageSize || ITEMS_PER_PAGE,
|
||||
search: params?.search || undefined,
|
||||
equipmentId: (filters.equipmentId as string) || undefined,
|
||||
repairType: (filters.repairType as string) || undefined,
|
||||
dateFrom: dateFrom || undefined,
|
||||
dateTo: dateTo || undefined,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
totalCount: result.pagination.total,
|
||||
totalPages: result.pagination.lastPage,
|
||||
};
|
||||
}
|
||||
return { success: false, error: result.error };
|
||||
},
|
||||
},
|
||||
|
||||
headerActions: () => (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => router.push('/quality/equipment-repairs?mode=new')}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
수리이력 등록
|
||||
</Button>
|
||||
),
|
||||
|
||||
// 필터 (공용 MobileFilter 연동)
|
||||
filterConfig,
|
||||
filterTitle: '수리이력 필터',
|
||||
|
||||
// 날짜 범위 선택기
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
startDate: dateFrom,
|
||||
endDate: dateTo,
|
||||
onStartDateChange: setDateFrom,
|
||||
onEndDateChange: setDateTo,
|
||||
presets: ['thisMonth', 'lastMonth', 'twoMonthsAgo'] as import('@/components/molecules/DateRangeSelector').DatePreset[],
|
||||
},
|
||||
|
||||
columns: [
|
||||
{ key: 'repairDate', label: '수리일', className: 'w-[110px] text-center' },
|
||||
{ key: 'equipment', label: '설비', className: 'min-w-[180px]' },
|
||||
{ key: 'repairType', label: '보전구분', className: 'w-[80px] text-center' },
|
||||
{ key: 'repairHours', label: '수리시간', className: 'w-[80px] text-center' },
|
||||
{ key: 'description', label: '수리내용', className: 'min-w-[250px]' },
|
||||
{ key: 'cost', label: '비용', className: 'w-[110px] text-right' },
|
||||
{ key: 'vendor', label: '외주업체', className: 'w-[100px]' },
|
||||
{ key: 'actions', label: '액션', className: 'w-[60px] text-center' },
|
||||
],
|
||||
|
||||
clientSideFiltering: false,
|
||||
itemsPerPage: ITEMS_PER_PAGE,
|
||||
hideSearch: false,
|
||||
searchPlaceholder: '설비명/수리내용 검색...',
|
||||
|
||||
renderTableRow: (
|
||||
item: EquipmentRepair,
|
||||
_index: number,
|
||||
_globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<EquipmentRepair>
|
||||
) => (
|
||||
<TableRow key={item.id} className="hover:bg-muted/50">
|
||||
<TableCell className="text-center">
|
||||
<Checkbox
|
||||
checked={handlers.isSelected}
|
||||
onCheckedChange={() => handlers.onToggle()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.repairDate}</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-medium text-blue-600">{item.equipmentCode}</span>
|
||||
{' '}{item.equipmentName}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{item.repairType ? (
|
||||
<Badge className={REPAIR_TYPE_COLOR[item.repairType]} variant="secondary">
|
||||
{REPAIR_TYPE_LABEL[item.repairType]}
|
||||
</Badge>
|
||||
) : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.repairHours ? `${item.repairHours}h` : '-'}</TableCell>
|
||||
<TableCell className="truncate max-w-[250px]">{item.description || '-'}</TableCell>
|
||||
<TableCell className="text-right">{item.cost ? `${Number(item.cost).toLocaleString()}원` : '-'}</TableCell>
|
||||
<TableCell>{item.vendor || '-'}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-red-600 hover:text-red-800"
|
||||
onClick={(e) => handleDeleteClick(e, item)}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
),
|
||||
|
||||
renderMobileCard: (
|
||||
item: EquipmentRepair,
|
||||
_index: number,
|
||||
_globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<EquipmentRepair>
|
||||
) => (
|
||||
<ListMobileCard
|
||||
key={item.id}
|
||||
title={`${item.equipmentCode} ${item.equipmentName}`}
|
||||
headerBadges={
|
||||
item.repairType ? (
|
||||
<Badge className={REPAIR_TYPE_COLOR[item.repairType]} variant="secondary">
|
||||
{REPAIR_TYPE_LABEL[item.repairType]}
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-1 gap-1">
|
||||
<InfoField label="수리일" value={item.repairDate} />
|
||||
<InfoField label="비용" value={item.cost ? `${Number(item.cost).toLocaleString()}원` : '-'} />
|
||||
<InfoField label="수리시간" value={item.repairHours ? `${item.repairHours}h` : '-'} />
|
||||
<InfoField label="외주업체" value={item.vendor || '-'} />
|
||||
</div>
|
||||
}
|
||||
showCheckbox
|
||||
isSelected={handlers.isSelected}
|
||||
onToggleSelection={() => handlers.onToggle()}
|
||||
actions={[
|
||||
{
|
||||
label: '삭제',
|
||||
icon: Trash2,
|
||||
variant: 'destructive' as const,
|
||||
onClick: () => setDeleteTarget(item),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
[filterConfig, dateFrom, dateTo, handleDeleteClick, router]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<UniversalListPage
|
||||
key={`${refreshKey}-${dateFrom}-${dateTo}`}
|
||||
config={config}
|
||||
/>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open: boolean) => !open && setDeleteTarget(null)}
|
||||
itemName={deleteTarget ? `${deleteTarget.equipmentCode} ${deleteTarget.repairDate}` : undefined}
|
||||
description={`"${deleteTarget?.equipmentCode} ${deleteTarget?.repairDate}" 수리이력을 삭제하시겠습니까?`}
|
||||
loading={isDeleting}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
202
src/components/quality/EquipmentStatus/index.tsx
Normal file
202
src/components/quality/EquipmentStatus/index.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 설비 현황 대시보드
|
||||
*
|
||||
* 통계 카드 + 이번달 점검 현황 / 설비 유형별 현황 + 최근 수리이력
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Loader2, ArrowRight } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
getEquipmentStats,
|
||||
getRepairList,
|
||||
} from '@/components/quality/EquipmentManagement/actions';
|
||||
import type { EquipmentStats, EquipmentRepair } from '@/components/quality/EquipmentManagement/types';
|
||||
import {
|
||||
REPAIR_TYPE_LABEL,
|
||||
REPAIR_TYPE_COLOR,
|
||||
} from '@/components/quality/EquipmentManagement/types';
|
||||
|
||||
export function EquipmentStatusDashboard() {
|
||||
const router = useRouter();
|
||||
const [stats, setStats] = useState<EquipmentStats | null>(null);
|
||||
const [recentRepairs, setRecentRepairs] = useState<EquipmentRepair[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
getEquipmentStats(),
|
||||
getRepairList({ perPage: 5 }),
|
||||
]).then(([statsResult, repairsResult]) => {
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStats(statsResult.data);
|
||||
}
|
||||
if (repairsResult.success) {
|
||||
setRecentRepairs(repairsResult.data);
|
||||
}
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const currentYear = now.getFullYear();
|
||||
const currentMonth = String(now.getMonth() + 1).padStart(2, '0');
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-3 md:p-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">설비 현황</h1>
|
||||
<p className="text-sm text-muted-foreground">{currentYear}년 {currentMonth}월 {String(now.getDate()).padStart(2, '0')}일 기준</p>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 4개 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: '총 설비', value: stats?.total ?? 0, color: '' },
|
||||
{ label: '가동 중', value: stats?.active ?? 0, color: 'text-green-600' },
|
||||
{ label: '유휴', value: stats?.idle ?? 0, color: 'text-yellow-600' },
|
||||
{ label: '폐기', value: stats?.disposed ?? 0, color: 'text-red-600' },
|
||||
].map((card) => (
|
||||
<Card key={card.label}>
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<p className="text-sm text-muted-foreground">{card.label}</p>
|
||||
<p className={`text-3xl font-bold ${card.color}`}>{card.value}</p>
|
||||
<p className="text-xs text-muted-foreground">대</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 이번달 점검 현황 + 설비 유형별 현황 (2컬럼) */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 이번달 점검 현황 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">이번달 점검 현황</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">{currentYear}년 {currentMonth}월</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm">점검 대상</span>
|
||||
<span className="text-sm font-bold">{stats?.inspectionStats?.targetCount ?? 0}대</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm">점검 완료</span>
|
||||
<span className="text-sm font-bold text-green-600">{stats?.inspectionStats?.completedCount ?? 0}대</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm">이상 발견</span>
|
||||
<span className="text-sm font-bold">{stats?.inspectionStats?.issueCount ?? 0}건</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 설비 유형별 현황 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">설비 유형별 현황</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(!stats?.typeDistribution || stats.typeDistribution.length === 0) ? (
|
||||
<div className="text-center py-6 text-muted-foreground text-sm">
|
||||
데이터가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>유형</TableHead>
|
||||
<TableHead className="w-[100px] text-right">설비 수</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{stats.typeDistribution.map((item) => (
|
||||
<TableRow key={item.equipmentType}>
|
||||
<TableCell>{item.equipmentType}</TableCell>
|
||||
<TableCell className="text-right">{item.count}대</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 최근 수리이력 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">최근 수리이력</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-blue-600"
|
||||
onClick={() => router.push('/quality/equipment-repairs')}
|
||||
>
|
||||
전체보기
|
||||
<ArrowRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recentRepairs.length === 0 ? (
|
||||
<div className="text-center py-6 text-muted-foreground text-sm">
|
||||
최근 수리이력이 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>수리일</TableHead>
|
||||
<TableHead>설비</TableHead>
|
||||
<TableHead>내용</TableHead>
|
||||
<TableHead className="w-[80px] text-center">구분</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{recentRepairs.map((repair) => (
|
||||
<TableRow key={repair.id}>
|
||||
<TableCell>{repair.repairDate}</TableCell>
|
||||
<TableCell>{repair.equipmentCode} {repair.equipmentName}</TableCell>
|
||||
<TableCell className="truncate max-w-[200px]">{repair.description || '-'}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{repair.repairType ? (
|
||||
<Badge className={REPAIR_TYPE_COLOR[repair.repairType]} variant="secondary">
|
||||
{REPAIR_TYPE_LABEL[repair.repairType]}
|
||||
</Badge>
|
||||
) : '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,8 @@
|
||||
*/
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Megaphone, ArrowLeft, Edit, Trash2 } from 'lucide-react';
|
||||
import { Megaphone, X, Pencil, Trash2 } from 'lucide-react';
|
||||
import { useMenuStore } from '@/stores/menuStore';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -27,6 +28,7 @@ interface PopupDetailProps {
|
||||
|
||||
export function PopupDetail({ popup, onEdit, onDelete }: PopupDetailProps) {
|
||||
const router = useRouter();
|
||||
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
|
||||
|
||||
const handleBack = () => {
|
||||
router.push('/ko/settings/popup-management');
|
||||
@@ -100,22 +102,23 @@ export function PopupDetail({ popup, onEdit, onDelete }: PopupDetailProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
목록으로
|
||||
</div>
|
||||
|
||||
{/* 하단 버튼 (sticky 하단 바) */}
|
||||
<div className={`fixed bottom-4 left-4 right-4 px-4 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 md:bottom-6 md:px-6 md:right-[48px] ${sidebarCollapsed ? 'md:left-[156px]' : 'md:left-[316px]'} flex items-center justify-between`}>
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<X className="w-4 h-4 mr-1" />
|
||||
취소
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={onDelete} className="text-destructive hover:bg-destructive hover:text-destructive-foreground">
|
||||
<Trash2 className="w-4 h-4 mr-1" />
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={onEdit}>
|
||||
<Pencil className="w-4 h-4 mr-1" />
|
||||
수정
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={onDelete} className="text-destructive hover:bg-destructive hover:text-destructive-foreground">
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={onEdit}>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { format } from 'date-fns';
|
||||
import { Megaphone, ArrowLeft, Save, Loader2 } from 'lucide-react';
|
||||
import { Megaphone, X, Save, Loader2 } from 'lucide-react';
|
||||
import { useMenuStore } from '@/stores/menuStore';
|
||||
import { createPopup, updatePopup } from './actions';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
@@ -62,6 +63,7 @@ function getLoggedInUserName(): string {
|
||||
|
||||
export function PopupForm({ mode, initialData }: PopupFormProps) {
|
||||
const router = useRouter();
|
||||
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
|
||||
|
||||
// ===== 폼 상태 =====
|
||||
const [target, setTarget] = useState<PopupTarget>(initialData?.target || 'all');
|
||||
@@ -301,21 +303,22 @@ export function PopupForm({ mode, initialData }: PopupFormProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button type="button" variant="outline" onClick={handleCancel} disabled={isSubmitting}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
취소
|
||||
</Button>
|
||||
<Button type="button" onClick={handleSubmit} disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{isSubmitting ? '저장 중...' : (mode === 'create' ? '등록' : '저장')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 하단 버튼 (sticky 하단 바) */}
|
||||
<div className={`fixed bottom-4 left-4 right-4 px-4 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 md:bottom-6 md:px-6 md:right-[48px] ${sidebarCollapsed ? 'md:left-[156px]' : 'md:left-[316px]'} flex items-center justify-between`}>
|
||||
<Button type="button" variant="outline" onClick={handleCancel} disabled={isSubmitting}>
|
||||
<X className="w-4 h-4 mr-1" />
|
||||
취소
|
||||
</Button>
|
||||
<Button type="button" onClick={handleSubmit} disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-4 h-4 mr-1" />
|
||||
)}
|
||||
{isSubmitting ? '저장 중...' : (mode === 'create' ? '등록' : '저장')}
|
||||
</Button>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
|
||||
@@ -29,6 +29,18 @@ import { MobileFilter, FilterFieldConfig, FilterValues } from "@/components/mole
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
import { CopyableCell } from '@/components/molecules';
|
||||
|
||||
// 렌더링된 React 노드에서 텍스트만 추출 (fallback 표시값 복사 대응)
|
||||
function extractTextFromNode(node: ReactNode): string {
|
||||
if (typeof node === 'string') return node;
|
||||
if (typeof node === 'number') return String(node);
|
||||
if (!node) return '';
|
||||
if (Array.isArray(node)) return node.map(extractTextFromNode).join('');
|
||||
if (isValidElement(node)) {
|
||||
return extractTextFromNode((node.props as { children?: ReactNode }).children);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 통합 목록_버젼2
|
||||
*
|
||||
@@ -96,7 +108,7 @@ export interface DevMetadata {
|
||||
|
||||
export interface IntegratedListTemplateV2Props<T = any> {
|
||||
// 페이지 헤더
|
||||
title: string;
|
||||
title: string | ReactNode;
|
||||
description?: string;
|
||||
icon?: LucideIcon;
|
||||
headerActions?: ReactNode;
|
||||
@@ -339,8 +351,12 @@ export function IntegratedListTemplateV2<T = any>({
|
||||
if (!colKey || !isValidElement(cell)) return cell;
|
||||
|
||||
const rawValue = (item as Record<string, unknown>)[colKey];
|
||||
const copyValue = rawValue != null ? String(rawValue) : '';
|
||||
if (!copyValue) return cell;
|
||||
let copyValue = rawValue != null ? String(rawValue) : '';
|
||||
// 데이터 키가 비어있으면 렌더링된 셀 텍스트를 복사값으로 사용 (fallback 표시값 대응)
|
||||
if (!copyValue) {
|
||||
copyValue = extractTextFromNode(cell).trim();
|
||||
}
|
||||
if (!copyValue || copyValue === '-') return cell;
|
||||
|
||||
const cellEl = cell as React.ReactElement<{ children?: ReactNode }>;
|
||||
return cloneElement(cellEl, {},
|
||||
@@ -1015,7 +1031,9 @@ export function IntegratedListTemplateV2<T = any>({
|
||||
key={column.key}
|
||||
className={`${column.className || ''} ${columnSettings ? 'relative' : ''}`}
|
||||
>
|
||||
{column.key === "actions" && selectedItems.size === 0 ? "" : (
|
||||
{column.key === "actions" ? (
|
||||
<span>{column.label}</span>
|
||||
) : (
|
||||
<div className={`flex items-center ${(column.className || '').includes('text-right') ? 'justify-end' : (column.className || '').includes('text-center') ? 'justify-center' : ''}`}>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 ${isSortable ? 'group cursor-pointer select-none rounded px-1 hover:bg-muted/50' : ''}`}
|
||||
|
||||
@@ -20,13 +20,15 @@ export function useColumnSettings({ pageId, columns, alwaysVisibleKeys = [] }: U
|
||||
const resetPageSettingsAction = useTableColumnStore((s) => s.resetPageSettings);
|
||||
|
||||
const visibleColumns = useMemo(() => {
|
||||
return columns.filter((col) => !settings.hiddenColumns.includes(col.key));
|
||||
}, [columns, settings.hiddenColumns]);
|
||||
return columns.filter((col) =>
|
||||
alwaysVisibleKeys.includes(col.key) || !settings.hiddenColumns.includes(col.key)
|
||||
);
|
||||
}, [columns, settings.hiddenColumns, alwaysVisibleKeys]);
|
||||
|
||||
const allColumnsWithVisibility = useMemo((): ColumnWithVisibility[] => {
|
||||
return columns.map((col) => ({
|
||||
...col,
|
||||
visible: !settings.hiddenColumns.includes(col.key),
|
||||
visible: alwaysVisibleKeys.includes(col.key) || !settings.hiddenColumns.includes(col.key),
|
||||
locked: alwaysVisibleKeys.includes(col.key),
|
||||
}));
|
||||
}, [columns, settings.hiddenColumns, alwaysVisibleKeys]);
|
||||
|
||||
Reference in New Issue
Block a user