feat: [stocks] 재고 관리 페이지 신규 + 회계 모듈 타입/코드 정리
- 재고 관리 페이지 및 컴포넌트 신규 추가 - 회계 6개 모듈 중복 타입 제거, 코드 간소화 (-212줄) - AccountSubjectSelect 마이너 수정 - Popover 외부 클릭 다이얼로그 이슈 가이드 문서
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
# Popover 외부 클릭 시 Dialog cascade 문제 해결
|
||||
|
||||
**작성일**: 2026-03-17
|
||||
**문제 유형**: UI 컴포넌트 이벤트 처리
|
||||
**적용 범위**: Popover 기반 UI 컴포넌트 5종
|
||||
|
||||
---
|
||||
|
||||
## 문제 현상
|
||||
|
||||
### 원래 버그 (2026-02-26 이전)
|
||||
DatePicker, SearchableSelect 등이 **Dialog(모달) 안에서** 사용될 때, 날짜/옵션 선택 시 **모달 자체가 닫히는 문제** 발생.
|
||||
|
||||
**원인**: Popover가 Portal로 `<body>`에 렌더링되어, 클릭 이벤트가 Dialog의 `onInteractOutside`까지 cascade → Dialog 닫힘
|
||||
|
||||
### 1차 수정의 부작용 (2026-02-26 ~ 2026-03-17)
|
||||
`e.preventDefault()`로 cascade를 차단했으나, Popover 자체도 외부 클릭으로 닫히지 않게 됨.
|
||||
|
||||
**증상**: 달력/검색 셀렉트를 열면 해당 컴포넌트를 직접 클릭하거나 ESC를 눌러야만 닫힘. 화면 아무 곳을 클릭해도 열린 채로 유지.
|
||||
|
||||
---
|
||||
|
||||
## 해결책
|
||||
|
||||
`e.preventDefault()`로 Dialog cascade는 차단하되, `setOpen(false)`로 Popover를 수동으로 닫기.
|
||||
|
||||
```tsx
|
||||
<PopoverContent
|
||||
onPointerDownOutside={(e) => {
|
||||
// Dialog cascade 방지 (Portal 렌더링으로 인한 모달 닫힘 방지)
|
||||
e.preventDefault();
|
||||
// Popover는 수동으로 닫기
|
||||
setOpen(false);
|
||||
}}
|
||||
onInteractOutside={(e) => {
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 동작 비교
|
||||
|
||||
| 시나리오 | 수정 전 (원래 버그) | 1차 수정 (부작용) | 최종 수정 |
|
||||
|---------|-------------------|-----------------|----------|
|
||||
| 일반 페이지 외부 클릭 | Popover 닫힘 | Popover 안 닫힘 | Popover 닫힘 ✅ |
|
||||
| 모달 내 외부 클릭 | Popover + 모달 닫힘 | 아무것도 안 닫힘 | Popover만 닫힘 ✅ |
|
||||
| 모달 내 날짜/옵션 선택 | 모달이 같이 닫힘 | 정상 | 정상 ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 적용 파일 (5개)
|
||||
|
||||
| 컴포넌트 | 파일 |
|
||||
|---------|------|
|
||||
| DatePicker | `src/components/ui/date-picker.tsx` |
|
||||
| DateRangePicker | `src/components/ui/date-range-picker.tsx` |
|
||||
| TimePicker | `src/components/ui/time-picker.tsx` |
|
||||
| SearchableSelect | `src/components/ui/searchable-select.tsx` |
|
||||
| MultiSelectCombobox | `src/components/ui/multi-select-combobox.tsx` |
|
||||
|
||||
---
|
||||
|
||||
## 히스토리
|
||||
|
||||
| 날짜 | 커밋 | 내용 |
|
||||
|------|------|------|
|
||||
| 2026-02-06 | `c2ed7154` | DatePicker 최초 생성 (preventDefault 없음) |
|
||||
| 2026-01-29 | `576da0c9` | SearchableSelect 최초 생성 (preventDefault 없음) |
|
||||
| 2026-02-26 | `b1686aaf` | 5개 컴포넌트에 `e.preventDefault()` 일괄 추가 (모달 닫힘 방지) |
|
||||
| 2026-03-17 | - | `e.preventDefault()` + `setOpen(false)` 패턴으로 수정 |
|
||||
|
||||
---
|
||||
|
||||
## 주의사항
|
||||
|
||||
- 새로운 Popover 기반 컴포넌트 생성 시 이 패턴 적용 필수
|
||||
- `e.preventDefault()`만 단독 사용 금지 → 반드시 `setOpen(false)` 함께 호출
|
||||
- 이 패턴은 Radix UI의 DismissableLayer cascade 동작에 의존하므로 Radix 메이저 버전 업그레이드 시 재검증 필요
|
||||
75
src/app/[locale]/(protected)/sales/stocks/[id]/page.tsx
Normal file
75
src/app/[locale]/(protected)/sales/stocks/[id]/page.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 재고생산 상세/수정 페이지
|
||||
*
|
||||
* - 기본: 상세 보기 (StockProductionDetail)
|
||||
* - ?mode=edit: 수정 (StockProductionForm)
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { StockProductionDetail } from '@/components/stocks/StockProductionDetail';
|
||||
import { StockProductionForm } from '@/components/stocks/StockProductionForm';
|
||||
import { getStockOrderById, type StockOrder } from '@/components/stocks/actions';
|
||||
|
||||
function EditStockContent({ id }: { id: string }) {
|
||||
const [order, setOrder] = useState<StockOrder | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const result = await getStockOrderById(id);
|
||||
if (result.__authError) {
|
||||
toast.error('인증이 만료되었습니다.');
|
||||
return;
|
||||
}
|
||||
if (result.success && result.data) {
|
||||
setOrder(result.data);
|
||||
} else {
|
||||
toast.error(result.error || '데이터를 불러오는데 실패했습니다.');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, [id]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-600" />
|
||||
<p className="text-muted-foreground">데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!order) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<p className="text-muted-foreground">데이터를 찾을 수 없습니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <StockProductionForm initialData={order} isEditMode />;
|
||||
}
|
||||
|
||||
export default function StockDetailPage() {
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const id = params.id as string;
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
if (mode === 'edit') {
|
||||
return <EditStockContent id={id} />;
|
||||
}
|
||||
|
||||
return <StockProductionDetail orderId={id} />;
|
||||
}
|
||||
27
src/app/[locale]/(protected)/sales/stocks/page.tsx
Normal file
27
src/app/[locale]/(protected)/sales/stocks/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 재고생산관리 페이지
|
||||
*
|
||||
* - 기본: 목록 (StockProductionList)
|
||||
* - ?mode=new: 등록 (StockProductionForm)
|
||||
*/
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { StockProductionList } from '@/components/stocks/StockProductionList';
|
||||
import { StockProductionForm } from '@/components/stocks/StockProductionForm';
|
||||
|
||||
function CreateStockContent() {
|
||||
return <StockProductionForm />;
|
||||
}
|
||||
|
||||
export default function StocksPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
if (mode === 'new') {
|
||||
return <CreateStockContent />;
|
||||
}
|
||||
|
||||
return <StockProductionList />;
|
||||
}
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import type { CardTransaction, InlineEditData, SortOption } from './types';
|
||||
import {
|
||||
SORT_OPTIONS, DEDUCTION_OPTIONS, ACCOUNT_SUBJECT_OPTIONS,
|
||||
SORT_OPTIONS, DEDUCTION_OPTIONS,
|
||||
} from './types';
|
||||
import { AccountSubjectSelect } from '@/components/accounting/common';
|
||||
import {
|
||||
@@ -74,10 +74,7 @@ const excelColumns: ExcelColumn<CardTransaction>[] = [
|
||||
{ header: '공급가액', key: 'supplyAmount', width: 12 },
|
||||
{ header: '세액', key: 'taxAmount', width: 10 },
|
||||
{ header: '계정과목', key: 'accountSubject', width: 12,
|
||||
transform: (v) => {
|
||||
const found = ACCOUNT_SUBJECT_OPTIONS.find(o => o.value === v);
|
||||
return found?.label || String(v || '');
|
||||
}},
|
||||
transform: (v) => String(v || '') },
|
||||
];
|
||||
|
||||
// ===== 테이블 컬럼 정의 (체크박스/No. 제외 15개) =====
|
||||
|
||||
@@ -118,26 +118,6 @@ export const USAGE_TYPE_OPTIONS = [
|
||||
{ value: 'miscellaneous', label: '잡비' },
|
||||
];
|
||||
|
||||
// ===== 계정과목 옵션 =====
|
||||
export const ACCOUNT_SUBJECT_OPTIONS = [
|
||||
{ value: '', label: '선택' },
|
||||
{ value: 'purchasePayment', label: '매입대금' },
|
||||
{ value: 'advance', label: '선급금' },
|
||||
{ value: 'suspense', label: '가지급금' },
|
||||
{ value: 'rent', label: '임대료' },
|
||||
{ value: 'interestExpense', label: '이자비용' },
|
||||
{ value: 'depositPayment', label: '보증금 지급' },
|
||||
{ value: 'loanRepayment', label: '차입금 상환' },
|
||||
{ value: 'dividendPayment', label: '배당금 지급' },
|
||||
{ value: 'vatPayment', label: '부가세 납부' },
|
||||
{ value: 'salary', label: '급여' },
|
||||
{ value: 'insurance', label: '4대보험' },
|
||||
{ value: 'tax', label: '세금' },
|
||||
{ value: 'utilities', label: '공과금' },
|
||||
{ value: 'expenses', label: '경비' },
|
||||
{ value: 'other', label: '기타' },
|
||||
];
|
||||
|
||||
// ===== 월 프리셋 옵션 =====
|
||||
export const MONTH_PRESETS = [
|
||||
{ label: '이번달', value: 0 },
|
||||
|
||||
@@ -42,13 +42,6 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { TableRow, TableCell } from '@/components/ui/table';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
@@ -57,6 +50,7 @@ import {
|
||||
type StatCard,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import { MobileCard } from '@/components/organisms/MobileCard';
|
||||
import { AccountSubjectSelect } from '@/components/accounting/common';
|
||||
import type {
|
||||
DepositRecord,
|
||||
SortOption,
|
||||
@@ -65,7 +59,6 @@ import {
|
||||
SORT_OPTIONS,
|
||||
DEPOSIT_TYPE_LABELS,
|
||||
DEPOSIT_TYPE_FILTER_OPTIONS,
|
||||
ACCOUNT_SUBJECT_OPTIONS,
|
||||
} from './types';
|
||||
import { deleteDeposit, updateDepositTypes, getDeposits } from './actions';
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
@@ -117,7 +110,7 @@ export function DepositManagement({ initialData, initialPagination: _initialPagi
|
||||
const [sortOption, setSortOption] = useState<SortOption>('latest');
|
||||
|
||||
// 계정과목명 저장 다이얼로그
|
||||
const [selectedAccountSubject, setSelectedAccountSubject] = useState<string>('unset');
|
||||
const [selectedAccountSubject, setSelectedAccountSubject] = useState<string>('');
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||
const [selectedItemsForSave, setSelectedItemsForSave] = useState<Set<string>>(new Set());
|
||||
|
||||
@@ -312,22 +305,17 @@ export function DepositManagement({ initialData, initialPagination: _initialPagi
|
||||
},
|
||||
filterTitle: '입금 필터',
|
||||
|
||||
// 헤더 액션: 계정과목명 Select + 저장 + 새로고침
|
||||
// 헤더 액션: 계정과목명 검색 Select + 저장 + 새로고침
|
||||
headerActions: ({ selectedItems }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700 whitespace-nowrap">계정과목명</span>
|
||||
<Select value={selectedAccountSubject} onValueChange={setSelectedAccountSubject}>
|
||||
<SelectTrigger className="min-w-[120px] w-auto">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCOUNT_SUBJECT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<AccountSubjectSelect
|
||||
value={selectedAccountSubject}
|
||||
onValueChange={setSelectedAccountSubject}
|
||||
placeholder="계정과목 선택"
|
||||
size="sm"
|
||||
className="min-w-[180px] w-auto"
|
||||
/>
|
||||
<Button onClick={() => handleSaveAccountSubject(selectedItems)} size="sm">
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
저장
|
||||
@@ -479,11 +467,7 @@ export function DepositManagement({ initialData, initialPagination: _initialPagi
|
||||
<DialogHeader>
|
||||
<DialogTitle>계정과목명 변경</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedItemsForSave.size}개의 입금 유형을{' '}
|
||||
<span className="font-semibold text-orange-500">
|
||||
{ACCOUNT_SUBJECT_OPTIONS.find(o => o.value === selectedAccountSubject)?.label}
|
||||
</span>
|
||||
(으)로 모두 변경하시겠습니까?
|
||||
{selectedItemsForSave.size}개의 입금 유형을 선택한 계정과목으로 모두 변경하시겠습니까?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-3">
|
||||
|
||||
@@ -95,9 +95,6 @@ export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
||||
{ value: 'amountLow', label: '금액 낮은순' },
|
||||
];
|
||||
|
||||
// ===== 계정과목명 옵션 (상단 셀렉트) =====
|
||||
export const ACCOUNT_SUBJECT_OPTIONS = DEPOSIT_TYPE_OPTIONS.filter(o => o.value !== 'all');
|
||||
|
||||
// ===== 입금 레코드 =====
|
||||
export interface DepositRecord {
|
||||
id: string;
|
||||
|
||||
@@ -88,18 +88,6 @@ export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
||||
{ value: 'oldest', label: '등록순' },
|
||||
];
|
||||
|
||||
// 계정과목 옵션
|
||||
export const ACCOUNT_SUBJECT_OPTIONS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'purchase', label: '매입비용' },
|
||||
{ value: 'salary', label: '급여' },
|
||||
{ value: 'rent', label: '임차료' },
|
||||
{ value: 'utilities', label: '공과금' },
|
||||
{ value: 'insurance', label: '보험료' },
|
||||
{ value: 'tax', label: '세금과공과' },
|
||||
{ value: 'other', label: '기타비용' },
|
||||
];
|
||||
|
||||
// 전자결재 상태 레이블
|
||||
export const APPROVAL_STATUS_LABELS: Record<ApprovalStatus, string> = {
|
||||
none: '미신청',
|
||||
|
||||
@@ -35,13 +35,6 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { TableRow, TableCell } from '@/components/ui/table';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
@@ -51,11 +44,11 @@ import {
|
||||
type FilterFieldConfig,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import { MobileCard } from '@/components/organisms/MobileCard';
|
||||
import { AccountSubjectSelect } from '@/components/accounting/common';
|
||||
import type { PurchaseRecord } from './types';
|
||||
import {
|
||||
SORT_OPTIONS,
|
||||
TAX_INVOICE_RECEIVED_FILTER_OPTIONS,
|
||||
ACCOUNT_SUBJECT_SELECTOR_OPTIONS,
|
||||
} from './types';
|
||||
import { getPurchases, togglePurchaseTaxInvoice, deletePurchase } from './actions';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
@@ -91,7 +84,7 @@ export function PurchaseManagement() {
|
||||
});
|
||||
|
||||
// 계정과목명 저장 다이얼로그
|
||||
const [selectedAccountSubject, setSelectedAccountSubject] = useState<string>('unset');
|
||||
const [selectedAccountSubject, setSelectedAccountSubject] = useState<string>('');
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||
const [selectedItemsForSave, setSelectedItemsForSave] = useState<Set<string>>(new Set());
|
||||
|
||||
@@ -346,22 +339,18 @@ export function PurchaseManagement() {
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
|
||||
// 헤더 액션: 계정과목명 Select + 저장 버튼
|
||||
// 헤더 액션: 계정과목명 검색 Select + 저장 버튼
|
||||
headerActions: ({ selectedItems }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700 whitespace-nowrap">계정과목명</span>
|
||||
<Select value={selectedAccountSubject} onValueChange={setSelectedAccountSubject}>
|
||||
<SelectTrigger className="min-w-[120px] w-auto">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCOUNT_SUBJECT_SELECTOR_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<AccountSubjectSelect
|
||||
value={selectedAccountSubject}
|
||||
onValueChange={setSelectedAccountSubject}
|
||||
category="expense"
|
||||
placeholder="계정과목 선택"
|
||||
size="sm"
|
||||
className="min-w-[180px] w-auto"
|
||||
/>
|
||||
<Button onClick={() => handleSaveAccountSubject(selectedItems)} size="sm">
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
저장
|
||||
@@ -484,11 +473,7 @@ export function PurchaseManagement() {
|
||||
<DialogHeader>
|
||||
<DialogTitle>계정과목명 변경</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedItemsForSave.size}개의 매입유형을{' '}
|
||||
<span className="font-semibold text-orange-500">
|
||||
{ACCOUNT_SUBJECT_SELECTOR_OPTIONS.find(o => o.value === selectedAccountSubject)?.label}
|
||||
</span>
|
||||
(으)로 모두 변경하시겠습니까?
|
||||
{selectedItemsForSave.size}개의 매입유형을 선택한 계정과목으로 모두 변경하시겠습니까?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-3">
|
||||
|
||||
@@ -161,22 +161,3 @@ export const TAX_INVOICE_RECEIVED_FILTER_OPTIONS: { value: TaxInvoiceReceivedFil
|
||||
{ value: 'notReceived', label: '수취 미확인' },
|
||||
];
|
||||
|
||||
// 계정과목명 셀렉터 옵션 (상단 일괄 변경용)
|
||||
export const ACCOUNT_SUBJECT_SELECTOR_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: 'unset', label: '미설정' },
|
||||
{ value: 'raw_material', label: '원재료매입' },
|
||||
{ value: 'subsidiary_material', label: '부재료매입' },
|
||||
{ value: 'product', label: '상품매입' },
|
||||
{ value: 'outsourcing', label: '외주가공비' },
|
||||
{ value: 'consumables', label: '소모품비' },
|
||||
{ value: 'repair', label: '수선비' },
|
||||
{ value: 'transportation', label: '운반비' },
|
||||
{ value: 'office_supplies', label: '사무용품비' },
|
||||
{ value: 'rent', label: '임차료' },
|
||||
{ value: 'utilities', label: '수도광열비' },
|
||||
{ value: 'communication', label: '통신비' },
|
||||
{ value: 'vehicle', label: '차량유지비' },
|
||||
{ value: 'entertainment', label: '접대비' },
|
||||
{ value: 'insurance', label: '보험료' },
|
||||
{ value: 'other_service', label: '기타용역비' },
|
||||
];
|
||||
@@ -33,13 +33,6 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { TableRow, TableCell } from '@/components/ui/table';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
@@ -49,12 +42,12 @@ import {
|
||||
type FilterFieldConfig,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import { MobileCard } from '@/components/organisms/MobileCard';
|
||||
import { AccountSubjectSelect } from '@/components/accounting/common';
|
||||
import type { SalesRecord } from './types';
|
||||
import {
|
||||
SORT_OPTIONS,
|
||||
TAX_INVOICE_FILTER_OPTIONS,
|
||||
TRANSACTION_STATEMENT_FILTER_OPTIONS,
|
||||
ACCOUNT_SUBJECT_SELECTOR_OPTIONS,
|
||||
} from './types';
|
||||
import { getSales, deleteSale, toggleSaleIssuance } from './actions';
|
||||
import { useDateRange } from '@/hooks';
|
||||
@@ -112,7 +105,7 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
|
||||
};
|
||||
|
||||
// 계정과목명 저장 다이얼로그
|
||||
const [selectedAccountSubject, setSelectedAccountSubject] = useState<string>('unset');
|
||||
const [selectedAccountSubject, setSelectedAccountSubject] = useState<string>('');
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||
const [selectedItemsForSave, setSelectedItemsForSave] = useState<Set<string>>(new Set());
|
||||
|
||||
@@ -357,22 +350,18 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
// 헤더 액션 (계정과목명 Select + 저장 버튼)
|
||||
// 헤더 액션 (계정과목명 검색 Select + 저장 버튼)
|
||||
headerActions: ({ selectedItems }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700 whitespace-nowrap">계정과목명</span>
|
||||
<Select value={selectedAccountSubject} onValueChange={setSelectedAccountSubject}>
|
||||
<SelectTrigger className="min-w-[120px] w-auto">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCOUNT_SUBJECT_SELECTOR_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<AccountSubjectSelect
|
||||
value={selectedAccountSubject}
|
||||
onValueChange={setSelectedAccountSubject}
|
||||
category="revenue"
|
||||
placeholder="계정과목 선택"
|
||||
size="sm"
|
||||
className="min-w-[180px] w-auto"
|
||||
/>
|
||||
<Button onClick={() => handleSaveAccountSubject(selectedItems)} size="sm">
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
저장
|
||||
@@ -523,11 +512,7 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
|
||||
<DialogHeader>
|
||||
<DialogTitle>계정과목명 변경</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedItemsForSave.size}개의 매출유형을{' '}
|
||||
<span className="font-semibold text-orange-500">
|
||||
{ACCOUNT_SUBJECT_SELECTOR_OPTIONS.find(o => o.value === selectedAccountSubject)?.label}
|
||||
</span>
|
||||
(으)로 모두 변경하시겠습니까?
|
||||
{selectedItemsForSave.size}개의 매출유형을 선택한 계정과목으로 모두 변경하시겠습니까?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-3">
|
||||
|
||||
@@ -108,31 +108,6 @@ export interface SalesRecord {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ===== 계정과목 옵션 =====
|
||||
export const ACCOUNT_SUBJECT_OPTIONS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'unset', label: '미설정' },
|
||||
{ value: 'product', label: '제품 매출' },
|
||||
{ value: 'goods', label: '상품 매출' },
|
||||
{ value: 'parts', label: '부품 매출' },
|
||||
{ value: 'service', label: '용역 매출' },
|
||||
{ value: 'construction', label: '공사 매출' },
|
||||
{ value: 'rental', label: '임대수익' },
|
||||
{ value: 'other', label: '기타매출' },
|
||||
];
|
||||
|
||||
// 상단 계정과목명 셀렉트박스용 옵션 (전체 제외)
|
||||
export const ACCOUNT_SUBJECT_SELECTOR_OPTIONS = [
|
||||
{ value: 'unset', label: '미설정' },
|
||||
{ value: 'product', label: '제품 매출' },
|
||||
{ value: 'goods', label: '상품 매출' },
|
||||
{ value: 'parts', label: '부품 매출' },
|
||||
{ value: 'service', label: '용역 매출' },
|
||||
{ value: 'construction', label: '공사 매출' },
|
||||
{ value: 'rental', label: '임대수익' },
|
||||
{ value: 'other', label: '기타매출' },
|
||||
];
|
||||
|
||||
// ===== 세금계산서 발행여부 필터 =====
|
||||
export type TaxInvoiceFilter = 'all' | 'issued' | 'notIssued';
|
||||
|
||||
|
||||
@@ -195,21 +195,6 @@ export interface ManualEntryFormData {
|
||||
memo: string;
|
||||
}
|
||||
|
||||
// ===== 계정과목 옵션 =====
|
||||
export const ACCOUNT_SUBJECT_OPTIONS = [
|
||||
{ value: '', label: '선택' },
|
||||
{ value: 'sales', label: '매출' },
|
||||
{ value: 'purchasePayment', label: '매입대금' },
|
||||
{ value: 'salesVat', label: '부가세예수금' },
|
||||
{ value: 'purchaseVat', label: '부가세대급금' },
|
||||
{ value: 'accountsReceivable', label: '외상매출금' },
|
||||
{ value: 'accountsPayable', label: '외상매입금' },
|
||||
{ value: 'cashAndDeposits', label: '현금및예금' },
|
||||
{ value: 'advance', label: '선급금' },
|
||||
{ value: 'advanceReceived', label: '선수금' },
|
||||
{ value: 'other', label: '기타' },
|
||||
];
|
||||
|
||||
// ===== API → Frontend 변환 =====
|
||||
const VALID_STATUSES: InvoiceStatus[] = ['draft', 'issued', 'sent', 'cancelled', 'failed'];
|
||||
|
||||
|
||||
@@ -43,13 +43,6 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { TableRow, TableCell } from '@/components/ui/table';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
@@ -58,6 +51,7 @@ import {
|
||||
type StatCard,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import { MobileCard } from '@/components/organisms/MobileCard';
|
||||
import { AccountSubjectSelect } from '@/components/accounting/common';
|
||||
import type {
|
||||
WithdrawalRecord,
|
||||
SortOption,
|
||||
@@ -66,7 +60,6 @@ import {
|
||||
SORT_OPTIONS,
|
||||
WITHDRAWAL_TYPE_LABELS,
|
||||
WITHDRAWAL_TYPE_FILTER_OPTIONS,
|
||||
ACCOUNT_SUBJECT_OPTIONS,
|
||||
} from './types';
|
||||
import { deleteWithdrawal, updateWithdrawalTypes, getWithdrawals } from './actions';
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
@@ -118,7 +111,7 @@ export function WithdrawalManagement({ initialData, initialPagination: _initialP
|
||||
const [sortOption, setSortOption] = useState<SortOption>('latest');
|
||||
|
||||
// 상단 계정과목명 선택 (저장용)
|
||||
const [selectedAccountSubject, setSelectedAccountSubject] = useState<string>('unset');
|
||||
const [selectedAccountSubject, setSelectedAccountSubject] = useState<string>('');
|
||||
|
||||
// 검색어 상태 (헤더에서 직접 관리)
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -334,24 +327,19 @@ export function WithdrawalManagement({ initialData, initialPagination: _initialP
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
|
||||
// 헤더 액션: 계정과목명 Select + 저장 + 새로고침
|
||||
// 헤더 액션: 계정과목명 검색 Select + 저장 + 새로고침
|
||||
headerActions: ({ selectedItems }) => {
|
||||
const selectedArray = withdrawalData.filter(item => selectedItems.has(item.id));
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700 whitespace-nowrap">계정과목명</span>
|
||||
<Select value={selectedAccountSubject} onValueChange={setSelectedAccountSubject}>
|
||||
<SelectTrigger className="min-w-[120px] w-auto">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCOUNT_SUBJECT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<AccountSubjectSelect
|
||||
value={selectedAccountSubject}
|
||||
onValueChange={setSelectedAccountSubject}
|
||||
placeholder="계정과목 선택"
|
||||
size="sm"
|
||||
className="min-w-[180px] w-auto"
|
||||
/>
|
||||
<Button onClick={() => handleSaveAccountSubject(selectedArray)} size="sm">
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
저장
|
||||
@@ -531,11 +519,7 @@ export function WithdrawalManagement({ initialData, initialPagination: _initialP
|
||||
<DialogHeader>
|
||||
<DialogTitle>계정과목명 변경</DialogTitle>
|
||||
<DialogDescription>
|
||||
{saveTargetIds.length}개의 출금 유형을{' '}
|
||||
<span className="font-semibold text-orange-500">
|
||||
{ACCOUNT_SUBJECT_OPTIONS.find(o => o.value === selectedAccountSubject)?.label}
|
||||
</span>
|
||||
(으)로 모두 변경하시겠습니까?
|
||||
{saveTargetIds.length}개의 출금 유형을 선택한 계정과목으로 모두 변경하시겠습니까?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-3">
|
||||
|
||||
@@ -79,9 +79,6 @@ export const WITHDRAWAL_TYPE_FILTER_OPTIONS = [
|
||||
// 상세 페이지 출금 유형 옵션 (전체 제외)
|
||||
export const WITHDRAWAL_TYPE_SELECTOR_OPTIONS = WITHDRAWAL_TYPE_OPTIONS;
|
||||
|
||||
// ===== 계정과목명 옵션 (상단 셀렉트) =====
|
||||
export const ACCOUNT_SUBJECT_OPTIONS = WITHDRAWAL_TYPE_OPTIONS.filter(o => o.value !== 'all');
|
||||
|
||||
// ===== 정렬 옵션 =====
|
||||
export type SortOption = 'latest' | 'oldest' | 'amountHigh' | 'amountLow';
|
||||
|
||||
|
||||
@@ -75,8 +75,7 @@ export function AccountSubjectSelect({
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await getAccountSubjects({
|
||||
selectable: true,
|
||||
isActive: true,
|
||||
depth: 3,
|
||||
category: category || undefined,
|
||||
subCategory: subCategory || undefined,
|
||||
departmentType: departmentType || undefined,
|
||||
|
||||
418
src/components/stocks/StockProductionDetail.tsx
Normal file
418
src/components/stocks/StockProductionDetail.tsx
Normal file
@@ -0,0 +1,418 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 재고생산 상세 보기 컴포넌트
|
||||
*
|
||||
* - 기본 정보 (생산번호, 상태, 생산사유, 목표재고수량, 메모, 비고)
|
||||
* - 품목 내역 테이블
|
||||
* - 상태 변경 / 수정 / 생산지시 생성 버튼
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Package,
|
||||
Pencil,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Factory,
|
||||
ClipboardList,
|
||||
MessageSquare,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { BadgeSm } from '@/components/atoms/BadgeSm';
|
||||
import { formatAmount } from '@/lib/utils/amount';
|
||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import {
|
||||
getStockOrderById,
|
||||
updateStockOrderStatus,
|
||||
deleteStockOrder,
|
||||
createStockProductionOrder,
|
||||
type StockOrder,
|
||||
type StockStatus,
|
||||
} from './actions';
|
||||
import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate/types';
|
||||
import type { ActionItem } from '@/components/templates/IntegratedDetailTemplate/types';
|
||||
|
||||
// ============================================================================
|
||||
// Config
|
||||
// ============================================================================
|
||||
|
||||
const stockDetailConfig: DetailConfig = {
|
||||
title: '재고생산',
|
||||
description: '재고생산 정보를 조회합니다',
|
||||
icon: Package,
|
||||
basePath: '/sales/stocks',
|
||||
fields: [],
|
||||
actions: {
|
||||
showBack: true,
|
||||
showEdit: false,
|
||||
showDelete: false,
|
||||
backLabel: '목록',
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 상태 뱃지
|
||||
// ============================================================================
|
||||
|
||||
const STATUS_CONFIG: Record<string, { label: string; className: string }> = {
|
||||
draft: { label: '등록', className: 'bg-gray-100 text-gray-700 border-gray-200' },
|
||||
confirmed: { label: '확정', className: 'bg-blue-100 text-blue-700 border-blue-200' },
|
||||
in_progress: { label: '진행중', className: 'bg-green-100 text-green-700 border-green-200' },
|
||||
in_production: { label: '생산중', className: 'bg-green-100 text-green-700 border-green-200' },
|
||||
produced: { label: '생산완료', className: 'bg-blue-600 text-white border-blue-600' },
|
||||
completed: { label: '완료', className: 'bg-gray-500 text-white border-gray-500' },
|
||||
cancelled: { label: '취소', className: 'bg-red-100 text-red-700 border-red-200' },
|
||||
};
|
||||
|
||||
function getStatusBadge(status: string) {
|
||||
const config = STATUS_CONFIG[status] || { label: status, className: 'bg-gray-100 text-gray-700 border-gray-200' };
|
||||
return <BadgeSm className={config.className}>{config.label}</BadgeSm>;
|
||||
}
|
||||
|
||||
// 정보 표시 컴포넌트
|
||||
function InfoItem({ label, value }: { label: string; value: string | number }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
<p className="font-medium">{value || '-'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Props
|
||||
// ============================================================================
|
||||
|
||||
interface StockProductionDetailProps {
|
||||
orderId: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
// ============================================================================
|
||||
|
||||
export function StockProductionDetail({ orderId }: StockProductionDetailProps) {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const locale = (params.locale as string) || 'ko';
|
||||
const basePath = `/${locale}/sales/stocks`;
|
||||
|
||||
const [order, setOrder] = useState<StockOrder | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
async function loadOrder() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await getStockOrderById(orderId);
|
||||
if (result.__authError) {
|
||||
toast.error('인증이 만료되었습니다.');
|
||||
return;
|
||||
}
|
||||
if (result.success && result.data) {
|
||||
setOrder(result.data);
|
||||
} else {
|
||||
toast.error(result.error || '재고생산 정보를 불러오는데 실패했습니다.');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
loadOrder();
|
||||
}, [orderId]);
|
||||
|
||||
// 상태 변경
|
||||
const handleStatusChange = useCallback(async (newStatus: StockStatus) => {
|
||||
if (!order) return;
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const result = await updateStockOrderStatus(order.id, newStatus);
|
||||
if (result.__authError) {
|
||||
toast.error('인증이 만료되었습니다.');
|
||||
return;
|
||||
}
|
||||
if (result.success && result.data) {
|
||||
setOrder(result.data);
|
||||
toast.success('상태가 변경되었습니다.');
|
||||
} else {
|
||||
toast.error(result.error || '상태 변경에 실패했습니다.');
|
||||
}
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [order]);
|
||||
|
||||
// 삭제
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!order) return;
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const result = await deleteStockOrder(order.id);
|
||||
if (result.__authError) {
|
||||
toast.error('인증이 만료되었습니다.');
|
||||
return;
|
||||
}
|
||||
if (result.success) {
|
||||
toast.success('재고생산이 삭제되었습니다.');
|
||||
router.push(basePath);
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setIsDeleteDialogOpen(false);
|
||||
}
|
||||
}, [order, router, basePath]);
|
||||
|
||||
// 생산지시 생성
|
||||
const handleCreateProductionOrder = useCallback(async () => {
|
||||
if (!order) return;
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const result = await createStockProductionOrder(order.id);
|
||||
if (result.__authError) {
|
||||
toast.error('인증이 만료되었습니다.');
|
||||
return;
|
||||
}
|
||||
if (result.success) {
|
||||
toast.success('생산지시가 생성되었습니다.');
|
||||
// 상태 갱신
|
||||
const refreshResult = await getStockOrderById(orderId);
|
||||
if (refreshResult.success && refreshResult.data) {
|
||||
setOrder(refreshResult.data);
|
||||
}
|
||||
} else {
|
||||
toast.error(result.error || '생산지시 생성에 실패했습니다.');
|
||||
}
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [order, orderId]);
|
||||
|
||||
// 수정 이동
|
||||
const handleEdit = useCallback(() => {
|
||||
router.push(`${basePath}/${orderId}?mode=edit`);
|
||||
}, [router, basePath, orderId]);
|
||||
|
||||
// 헤더 액션 버튼
|
||||
const headerActionItems = useMemo((): ActionItem[] => {
|
||||
if (!order) return [];
|
||||
const items: ActionItem[] = [];
|
||||
|
||||
// draft → 확정 가능
|
||||
if (order.status === 'draft') {
|
||||
items.push({
|
||||
icon: CheckCircle2,
|
||||
label: '확정',
|
||||
onClick: () => handleStatusChange('confirmed'),
|
||||
className: 'bg-blue-600 hover:bg-blue-700 text-white',
|
||||
disabled: isProcessing,
|
||||
});
|
||||
items.push({
|
||||
icon: Pencil,
|
||||
label: '수정',
|
||||
onClick: handleEdit,
|
||||
variant: 'outline',
|
||||
});
|
||||
}
|
||||
|
||||
// confirmed → 생산지시 생성 + 수정 불가 안내
|
||||
if (order.status === 'confirmed') {
|
||||
items.push({
|
||||
icon: Factory,
|
||||
label: '생산지시 생성',
|
||||
onClick: handleCreateProductionOrder,
|
||||
className: 'bg-green-600 hover:bg-green-700 text-white',
|
||||
disabled: isProcessing,
|
||||
});
|
||||
items.push({
|
||||
icon: Pencil,
|
||||
label: '수정',
|
||||
onClick: () => toast.warning('확정 상태에서는 수정이 불가합니다.'),
|
||||
variant: 'outline',
|
||||
disabled: false,
|
||||
className: 'opacity-50',
|
||||
});
|
||||
}
|
||||
|
||||
// draft/confirmed → 취소 가능
|
||||
if (order.status === 'draft' || order.status === 'confirmed') {
|
||||
items.push({
|
||||
icon: XCircle,
|
||||
label: '취소',
|
||||
onClick: () => handleStatusChange('cancelled'),
|
||||
variant: 'destructive',
|
||||
disabled: isProcessing,
|
||||
});
|
||||
}
|
||||
|
||||
// cancelled → 수정 불가 안내
|
||||
if (order.status === 'cancelled') {
|
||||
items.push({
|
||||
icon: Pencil,
|
||||
label: '수정',
|
||||
onClick: () => toast.warning('취소 상태에서는 수정이 불가합니다.'),
|
||||
variant: 'outline',
|
||||
disabled: false,
|
||||
className: 'opacity-50',
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [order, isProcessing, handleStatusChange, handleEdit, handleCreateProductionOrder]);
|
||||
|
||||
// 상태 뱃지 — 기본 정보 카드에 이미 표시되므로 하단 바에는 미표시
|
||||
const headerActions = useMemo(() => {
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
// renderView
|
||||
const renderViewContent = useMemo(
|
||||
() =>
|
||||
(data: StockOrder) => (
|
||||
<div className="space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ClipboardList className="h-5 w-5 text-primary" />
|
||||
기본 정보
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<InfoItem label="생산번호" value={data.orderNo} />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">상태</p>
|
||||
<div className="mt-1">{getStatusBadge(data.status)}</div>
|
||||
</div>
|
||||
<InfoItem label="생산사유" value={data.productionReason} />
|
||||
<InfoItem label="목표재고수량" value={data.targetStockQty ? String(data.targetStockQty) : '-'} />
|
||||
<InfoItem label="등록일" value={data.createdAt} />
|
||||
<InfoItem label="담당자" value={data.manager} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 비고 */}
|
||||
{(data.memo || data.remarks) && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<MessageSquare className="h-5 w-5 text-primary" />
|
||||
비고
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{data.memo && <InfoItem label="메모" value={data.memo} />}
|
||||
{data.remarks && <InfoItem label="비고" value={data.remarks} />}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 품목 내역 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Package className="h-5 w-5 text-primary" />
|
||||
품목 내역 ({data.items.length}건)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data.items.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
품목이 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12 text-center">No</TableHead>
|
||||
<TableHead>품목코드</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
<TableHead>규격</TableHead>
|
||||
<TableHead className="text-center">수량</TableHead>
|
||||
<TableHead className="text-center">단위</TableHead>
|
||||
<TableHead className="text-right">단가</TableHead>
|
||||
<TableHead className="text-right">금액</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.items.map((item, index) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-center">{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
{item.itemCode ? (
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{item.itemCode}
|
||||
</code>
|
||||
) : '-'}
|
||||
</TableCell>
|
||||
<TableCell>{item.itemName}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{item.specification || '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.quantity}</TableCell>
|
||||
<TableCell className="text-center">{item.unit}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatAmount(item.unitPrice)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{formatAmount(item.totalAmount)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedDetailTemplate
|
||||
config={stockDetailConfig}
|
||||
mode="view"
|
||||
initialData={order as unknown as Record<string, unknown>}
|
||||
itemId={orderId}
|
||||
isLoading={loading}
|
||||
onCancel={() => router.push(basePath)}
|
||||
headerActions={headerActions}
|
||||
headerActionItems={headerActionItems}
|
||||
renderView={(data) => renderViewContent(data as unknown as StockOrder)}
|
||||
/>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={setIsDeleteDialogOpen}
|
||||
onConfirm={handleDelete}
|
||||
title="재고생산 삭제"
|
||||
description="이 재고생산을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
423
src/components/stocks/StockProductionForm.tsx
Normal file
423
src/components/stocks/StockProductionForm.tsx
Normal file
@@ -0,0 +1,423 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 재고생산 등록/수정 폼
|
||||
*
|
||||
* - 생산사유, 목표재고수량, 메모, 비고
|
||||
* - 품목 내역 (ItemAddDialog 사용)
|
||||
* - IntegratedDetailTemplate + renderForm 패턴
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { QuantityInput } from '@/components/ui/quantity-input';
|
||||
import { NumberInput } from '@/components/ui/number-input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Package, Plus, Trash2, MessageSquare, ClipboardList } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { FormSection } from '@/components/organisms/FormSection';
|
||||
import { ItemAddDialog, type OrderItem } from '@/components/orders/ItemAddDialog';
|
||||
import { formatAmount } from '@/lib/utils/amount';
|
||||
import {
|
||||
createStockOrder,
|
||||
updateStockOrder,
|
||||
type StockOrder,
|
||||
type StockOrderFormData,
|
||||
type StockOrderItemFormData,
|
||||
} from './actions';
|
||||
import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate/types';
|
||||
|
||||
// ============================================================================
|
||||
// Config
|
||||
// ============================================================================
|
||||
|
||||
const stockCreateConfig: DetailConfig = {
|
||||
title: '재고생산',
|
||||
description: '재고생산을 등록합니다',
|
||||
icon: Package,
|
||||
basePath: '/sales/stocks',
|
||||
fields: [],
|
||||
actions: {
|
||||
showBack: true,
|
||||
showSave: true,
|
||||
submitLabel: '저장',
|
||||
backLabel: '취소',
|
||||
},
|
||||
};
|
||||
|
||||
const stockEditConfig: DetailConfig = {
|
||||
title: '재고생산',
|
||||
description: '재고생산 정보를 수정합니다',
|
||||
icon: Package,
|
||||
basePath: '/sales/stocks',
|
||||
fields: [],
|
||||
actions: {
|
||||
showBack: true,
|
||||
showSave: true,
|
||||
submitLabel: '저장',
|
||||
backLabel: '취소',
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 폼 데이터
|
||||
// ============================================================================
|
||||
|
||||
interface StockFormData {
|
||||
productionReason: string;
|
||||
targetStockQty: string;
|
||||
memo: string;
|
||||
remarks: string;
|
||||
items: OrderItem[];
|
||||
}
|
||||
|
||||
const INITIAL_FORM: StockFormData = {
|
||||
productionReason: '',
|
||||
targetStockQty: '',
|
||||
memo: '',
|
||||
remarks: '',
|
||||
items: [],
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Props
|
||||
// ============================================================================
|
||||
|
||||
interface StockProductionFormProps {
|
||||
initialData?: StockOrder;
|
||||
isEditMode?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
// ============================================================================
|
||||
|
||||
export function StockProductionForm({
|
||||
initialData,
|
||||
isEditMode = false,
|
||||
}: StockProductionFormProps) {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const locale = (params.locale as string) || 'ko';
|
||||
const basePath = `/${locale}/sales/stocks`;
|
||||
|
||||
const config = isEditMode ? stockEditConfig : stockCreateConfig;
|
||||
|
||||
// 초기 데이터 변환
|
||||
const [form, setForm] = useState<StockFormData>(() => {
|
||||
if (initialData) {
|
||||
return {
|
||||
productionReason: initialData.productionReason || '',
|
||||
targetStockQty: initialData.targetStockQty ? String(initialData.targetStockQty) : '',
|
||||
memo: initialData.memo || '',
|
||||
remarks: initialData.remarks || '',
|
||||
items: initialData.items.map((item) => ({
|
||||
id: item.id,
|
||||
itemId: item.itemId,
|
||||
itemCode: item.itemCode,
|
||||
itemName: item.itemName,
|
||||
specification: item.specification,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unitPrice: item.unitPrice,
|
||||
amount: item.totalAmount,
|
||||
})),
|
||||
};
|
||||
}
|
||||
return INITIAL_FORM;
|
||||
});
|
||||
|
||||
const [isItemDialogOpen, setIsItemDialogOpen] = useState(false);
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// 필드 에러 초기화
|
||||
const clearFieldError = useCallback((field: string) => {
|
||||
setFieldErrors((prev) => {
|
||||
if (prev[field]) {
|
||||
const { [field]: _, ...rest } = prev;
|
||||
return rest;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 품목 추가
|
||||
const handleAddItem = useCallback((item: OrderItem) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
items: [...prev.items, item],
|
||||
}));
|
||||
clearFieldError('items');
|
||||
toast.success('품목이 추가되었습니다.');
|
||||
}, [clearFieldError]);
|
||||
|
||||
// 품목 삭제
|
||||
const handleRemoveItem = useCallback((itemId: string) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
items: prev.items.filter((item) => item.id !== itemId),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 품목 수량 변경
|
||||
const handleQuantityChange = useCallback((itemId: string, quantity: number) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
items: prev.items.map((item) =>
|
||||
item.id === itemId
|
||||
? { ...item, quantity, amount: item.unitPrice * quantity }
|
||||
: item
|
||||
),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 유효성 검사
|
||||
const validate = useCallback((): boolean => {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
if (form.items.length === 0) {
|
||||
errors.items = '품목을 1개 이상 추가해주세요';
|
||||
}
|
||||
|
||||
setFieldErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
}, [form.items]);
|
||||
|
||||
// 저장
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!validate()) {
|
||||
toast.error('입력 정보를 확인해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData: StockOrderFormData = {
|
||||
orderTypeCode: 'STOCK',
|
||||
memo: form.memo,
|
||||
remarks: form.remarks,
|
||||
productionReason: form.productionReason,
|
||||
targetStockQty: Number(form.targetStockQty) || 0,
|
||||
items: form.items.map((item): StockOrderItemFormData => ({
|
||||
itemId: item.itemId,
|
||||
itemCode: item.itemCode,
|
||||
itemName: item.itemName,
|
||||
specification: item.specification,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit || 'EA',
|
||||
unitPrice: item.unitPrice,
|
||||
})),
|
||||
};
|
||||
|
||||
const result = isEditMode && initialData
|
||||
? await updateStockOrder(initialData.id, formData)
|
||||
: await createStockOrder(formData);
|
||||
|
||||
if (result.__authError) {
|
||||
toast.error('인증이 만료되었습니다. 다시 로그인해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(result.error || '저장에 실패했습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(isEditMode ? '재고생산이 수정되었습니다.' : '재고생산이 등록되었습니다.');
|
||||
|
||||
if (result.data?.id) {
|
||||
router.push(`${basePath}/${result.data.id}`);
|
||||
} else {
|
||||
router.push(basePath);
|
||||
}
|
||||
}, [form, isEditMode, initialData, validate, router, basePath]);
|
||||
|
||||
// 취소
|
||||
const handleCancel = useCallback(() => {
|
||||
if (isEditMode && initialData) {
|
||||
router.push(`${basePath}/${initialData.id}`);
|
||||
} else {
|
||||
router.push(basePath);
|
||||
}
|
||||
}, [isEditMode, initialData, router, basePath]);
|
||||
|
||||
// renderForm
|
||||
const renderFormContent = useMemo(
|
||||
() =>
|
||||
() => (
|
||||
<div className="space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
<FormSection title="기본 정보" icon={ClipboardList}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="productionReason">생산사유</Label>
|
||||
<Input
|
||||
id="productionReason"
|
||||
placeholder="생산사유를 입력하세요"
|
||||
value={form.productionReason}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, productionReason: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="targetStockQty">목표재고수량</Label>
|
||||
<NumberInput
|
||||
id="targetStockQty"
|
||||
placeholder="0"
|
||||
value={Number(form.targetStockQty) || 0}
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, targetStockQty: String(value ?? 0) }))}
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* 비고 */}
|
||||
<FormSection title="비고" icon={MessageSquare}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="memo">메모</Label>
|
||||
<Textarea
|
||||
id="memo"
|
||||
placeholder="메모를 입력하세요"
|
||||
value={form.memo}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, memo: e.target.value }))}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="remarks">비고</Label>
|
||||
<Textarea
|
||||
id="remarks"
|
||||
placeholder="비고를 입력하세요"
|
||||
value={form.remarks}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, remarks: e.target.value }))}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* 품목 내역 */}
|
||||
<FormSection
|
||||
title="품목 내역"
|
||||
icon={Package}
|
||||
headerAction={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setIsItemDialogOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
품목 추가
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{fieldErrors.items && (
|
||||
<p className="text-sm text-red-500 mb-3">{fieldErrors.items}</p>
|
||||
)}
|
||||
|
||||
{form.items.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Package className="h-12 w-12 mx-auto mb-3 opacity-30" />
|
||||
<p>품목을 추가해주세요</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
onClick={() => setIsItemDialogOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
품목 추가
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">No</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
<TableHead>규격</TableHead>
|
||||
<TableHead className="w-24 text-center">수량</TableHead>
|
||||
<TableHead className="w-20 text-center">단위</TableHead>
|
||||
<TableHead className="w-28 text-right">단가</TableHead>
|
||||
<TableHead className="w-28 text-right">금액</TableHead>
|
||||
<TableHead className="w-16 text-center">삭제</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{form.items.map((item, index) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-center">{index + 1}</TableCell>
|
||||
<TableCell>{item.itemName}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{item.specification || item.spec || '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<QuantityInput
|
||||
value={item.quantity}
|
||||
onChange={(value) => handleQuantityChange(item.id, value ?? 1)}
|
||||
min={1}
|
||||
className="w-20"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{item.unit || 'EA'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatAmount(item.unitPrice)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatAmount((item.amount ?? item.unitPrice * item.quantity))}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-red-500 hover:text-red-700"
|
||||
onClick={() => handleRemoveItem(item.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</FormSection>
|
||||
</div>
|
||||
),
|
||||
[form, fieldErrors, handleQuantityChange, handleRemoveItem]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedDetailTemplate
|
||||
config={config}
|
||||
mode={isEditMode ? 'edit' : 'create'}
|
||||
isLoading={false}
|
||||
onCancel={handleCancel}
|
||||
onSubmit={() => handleSave()}
|
||||
renderForm={renderFormContent}
|
||||
/>
|
||||
|
||||
<ItemAddDialog
|
||||
open={isItemDialogOpen}
|
||||
onOpenChange={setIsItemDialogOpen}
|
||||
onAdd={handleAddItem}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
512
src/components/stocks/StockProductionList.tsx
Normal file
512
src/components/stocks/StockProductionList.tsx
Normal file
@@ -0,0 +1,512 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 재고생산관리 - 목록 페이지
|
||||
*
|
||||
* 수주관리(order-management-sales)를 기반으로 단순화
|
||||
* - 거래처/배송/할인/개소 제거
|
||||
* - order_type=STOCK 필터
|
||||
* - UniversalListPage 패턴 사용
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Factory,
|
||||
Trash2,
|
||||
Edit,
|
||||
Eye,
|
||||
Loader2,
|
||||
ClipboardList,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Archive,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { BadgeSm } from "@/components/atoms/BadgeSm";
|
||||
import {
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
type TableColumn,
|
||||
type FilterFieldConfig,
|
||||
} from "@/components/templates/UniversalListPage";
|
||||
import { toast } from "sonner";
|
||||
import { TableRow, TableCell } from "@/components/ui/table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ListMobileCard, InfoField } from "@/components/organisms/MobileCard";
|
||||
import { DeleteConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import {
|
||||
getStockOrders,
|
||||
getStockOrderStats,
|
||||
deleteStockOrder,
|
||||
deleteStockOrders,
|
||||
type StockOrder,
|
||||
type StockStatus,
|
||||
type StockOrderStats,
|
||||
} from "./actions";
|
||||
|
||||
// 상태 뱃지
|
||||
function getStatusBadge(status: StockStatus) {
|
||||
const config: Record<StockStatus, { label: string; className: string }> = {
|
||||
draft: { label: "등록", className: "bg-gray-100 text-gray-700 border-gray-200" },
|
||||
confirmed: { label: "확정", className: "bg-blue-100 text-blue-700 border-blue-200" },
|
||||
in_progress: { label: "생산지시완료", className: "bg-indigo-100 text-indigo-700 border-indigo-200" },
|
||||
in_production: { label: "생산중", className: "bg-green-100 text-green-700 border-green-200" },
|
||||
produced: { label: "생산완료", className: "bg-teal-100 text-teal-700 border-teal-200" },
|
||||
completed: { label: "완료", className: "bg-gray-500 text-white border-gray-500" },
|
||||
cancelled: { label: "취소", className: "bg-red-100 text-red-700 border-red-200" },
|
||||
};
|
||||
const c = config[status] || config.draft;
|
||||
return <BadgeSm className={c.className}>{c.label}</BadgeSm>;
|
||||
}
|
||||
|
||||
export function StockProductionList() {
|
||||
const router = useRouter();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 날짜 범위 필터
|
||||
const [startDate, setStartDate] = useState<string>("");
|
||||
const [endDate, setEndDate] = useState<string>("");
|
||||
|
||||
// 필터
|
||||
const [filterValues, setFilterValues] = useState<Record<string, string | string[]>>({
|
||||
status: 'all',
|
||||
});
|
||||
|
||||
const filterConfig: FilterFieldConfig[] = [
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'single',
|
||||
options: [
|
||||
{ value: 'draft', label: '등록' },
|
||||
{ value: 'confirmed', label: '확정' },
|
||||
{ value: 'in_progress', label: '생산지시완료' },
|
||||
{ value: 'in_production', label: '생산중' },
|
||||
{ value: 'completed', label: '완료' },
|
||||
],
|
||||
allOptionLabel: '전체',
|
||||
},
|
||||
];
|
||||
|
||||
// 삭제 다이얼로그
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [deleteTargetIds, setDeleteTargetIds] = useState<string[]>([]);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// API 데이터
|
||||
const [orders, setOrders] = useState<StockOrder[]>([]);
|
||||
const [apiStats, setApiStats] = useState<StockOrderStats | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const [ordersResult, statsResult] = await Promise.all([
|
||||
getStockOrders(),
|
||||
getStockOrderStats(),
|
||||
]);
|
||||
|
||||
if (ordersResult.success && ordersResult.data) {
|
||||
setOrders(ordersResult.data.items);
|
||||
} else {
|
||||
toast.error(ordersResult.error || "재고생산 목록을 불러오는데 실패했습니다.");
|
||||
}
|
||||
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setApiStats(statsResult.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading stock orders:", error);
|
||||
toast.error("데이터를 불러오는 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// 필터링 및 정렬
|
||||
const filteredOrders = orders
|
||||
.filter((order) => {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
const matchesSearch =
|
||||
!searchTerm ||
|
||||
order.orderNo.toLowerCase().includes(searchLower) ||
|
||||
order.itemSummary.toLowerCase().includes(searchLower) ||
|
||||
order.productionReason.toLowerCase().includes(searchLower);
|
||||
|
||||
const statusFilter = filterValues.status as string;
|
||||
const matchesFilter = !statusFilter || statusFilter === "all" || order.status === statusFilter;
|
||||
|
||||
return matchesSearch && matchesFilter;
|
||||
})
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
|
||||
// 통계
|
||||
const stats = [
|
||||
{
|
||||
label: "전체",
|
||||
value: `${apiStats?.total ?? orders.length}건`,
|
||||
icon: Archive,
|
||||
iconColor: "text-gray-600",
|
||||
},
|
||||
{
|
||||
label: "등록",
|
||||
value: `${apiStats?.draft ?? 0}건`,
|
||||
icon: Clock,
|
||||
iconColor: "text-gray-600",
|
||||
},
|
||||
{
|
||||
label: "확정",
|
||||
value: `${apiStats?.confirmed ?? 0}건`,
|
||||
icon: CheckCircle,
|
||||
iconColor: "text-blue-600",
|
||||
},
|
||||
{
|
||||
label: "생산중",
|
||||
value: `${apiStats?.inProgress ?? 0}건`,
|
||||
icon: ClipboardList,
|
||||
iconColor: "text-green-600",
|
||||
},
|
||||
];
|
||||
|
||||
// 핸들러
|
||||
const handleView = (order: StockOrder) => {
|
||||
router.push(`/sales/stocks/${order.id}?mode=view`);
|
||||
};
|
||||
|
||||
const handleEdit = (order: StockOrder) => {
|
||||
router.push(`/sales/stocks/${order.id}?mode=edit`);
|
||||
};
|
||||
|
||||
const handleDelete = (orderId: string) => {
|
||||
setDeleteTargetIds([orderId]);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (deleteTargetIds.length === 0) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
if (deleteTargetIds.length === 1) {
|
||||
const result = await deleteStockOrder(deleteTargetIds[0]);
|
||||
if (result.success) {
|
||||
setOrders(orders.filter((o) => o.id !== deleteTargetIds[0]));
|
||||
setSelectedItems(new Set());
|
||||
toast.success("재고생산이 삭제되었습니다.");
|
||||
const statsResult = await getStockOrderStats();
|
||||
if (statsResult.success && statsResult.data) setApiStats(statsResult.data);
|
||||
} else {
|
||||
toast.error(result.error || "삭제에 실패했습니다.");
|
||||
}
|
||||
} else {
|
||||
const result = await deleteStockOrders(deleteTargetIds);
|
||||
if (result.success) {
|
||||
const skippedSet = new Set((result.skippedIds ?? []).map(String));
|
||||
setOrders(orders.filter((o) => skippedSet.has(o.id) || !deleteTargetIds.includes(o.id)));
|
||||
setSelectedItems(new Set());
|
||||
if (result.deletedCount) toast.success(`${result.deletedCount}개 삭제되었습니다.`);
|
||||
if (result.skippedCount) toast.warning(`${result.skippedCount}개는 삭제할 수 없습니다.`);
|
||||
const statsResult = await getStockOrderStats();
|
||||
if (statsResult.success && statsResult.data) setApiStats(statsResult.data);
|
||||
} else {
|
||||
toast.error(result.error || "삭제에 실패했습니다.");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting:", error);
|
||||
toast.error("삭제 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setIsDeleteDialogOpen(false);
|
||||
setDeleteTargetIds([]);
|
||||
}
|
||||
};
|
||||
|
||||
// 체크박스 선택
|
||||
const paginatedOrders = filteredOrders.slice(
|
||||
(currentPage - 1) * itemsPerPage,
|
||||
currentPage * itemsPerPage
|
||||
);
|
||||
|
||||
const toggleSelection = (id: string) => {
|
||||
const next = new Set(selectedItems);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
setSelectedItems(next);
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedItems.size === paginatedOrders.length && paginatedOrders.length > 0) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedOrders.map((o) => o.id)));
|
||||
}
|
||||
};
|
||||
|
||||
// 테이블 컬럼
|
||||
const tableColumns: TableColumn[] = useMemo(() => [
|
||||
{ key: "rowNumber", label: "번호", className: "px-2 text-center w-[60px]" },
|
||||
{ key: "orderNo", label: "생산번호", className: "px-2", sortable: true, copyable: true },
|
||||
{ key: "itemSummary", label: "품목", className: "px-2", sortable: true, copyable: true },
|
||||
{ key: "quantity", label: "수량", className: "px-2 text-center", sortable: true },
|
||||
{ key: "productionReason", label: "생산사유", className: "px-2", copyable: true },
|
||||
{ key: "createdAt", label: "등록일", className: "px-2", sortable: true, copyable: true },
|
||||
{ key: "status", label: "상태", className: "px-2 text-center", sortable: true },
|
||||
{ key: "memo", label: "비고", className: "px-2", copyable: true },
|
||||
], []);
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = (
|
||||
order: StockOrder,
|
||||
_index: number,
|
||||
globalIndex: number,
|
||||
handlers: { isSelected: boolean; onToggle: () => void }
|
||||
) => {
|
||||
const { isSelected, onToggle } = handlers;
|
||||
return (
|
||||
<TableRow
|
||||
key={order.id}
|
||||
className={`cursor-pointer hover:bg-muted/50 ${isSelected ? "bg-blue-50" : ""}`}
|
||||
onClick={() => handleView(order)}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()} className="text-center">
|
||||
<Checkbox checked={isSelected} onCheckedChange={onToggle} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{globalIndex}</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 text-gray-700 px-2 py-1 rounded font-mono">
|
||||
{order.orderNo}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>{order.itemSummary || "-"}</TableCell>
|
||||
<TableCell className="text-center">{order.quantity || "-"}</TableCell>
|
||||
<TableCell>{order.productionReason || "-"}</TableCell>
|
||||
<TableCell>{order.createdAt || "-"}</TableCell>
|
||||
<TableCell className="text-center">{getStatusBadge(order.status)}</TableCell>
|
||||
<TableCell className="max-w-[150px] truncate">{order.memo || "-"}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
// 모바일 카드
|
||||
const renderMobileCard = (
|
||||
order: StockOrder,
|
||||
_index: number,
|
||||
globalIndex: number,
|
||||
handlers: { isSelected: boolean; onToggle: () => void }
|
||||
) => {
|
||||
const { isSelected, onToggle } = handlers;
|
||||
return (
|
||||
<ListMobileCard
|
||||
key={order.id}
|
||||
id={order.id}
|
||||
isSelected={isSelected}
|
||||
onToggleSelection={onToggle}
|
||||
onCardClick={() => handleView(order)}
|
||||
headerBadges={
|
||||
<>
|
||||
<Badge variant="outline" className="bg-gray-100 text-gray-700 font-mono text-xs">
|
||||
{globalIndex}
|
||||
</Badge>
|
||||
<code className="inline-block text-xs bg-gray-100 text-gray-700 px-2.5 py-0.5 rounded-md font-mono whitespace-nowrap">
|
||||
{order.orderNo}
|
||||
</code>
|
||||
</>
|
||||
}
|
||||
title={order.itemSummary}
|
||||
statusBadge={getStatusBadge(order.status)}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="수량" value={`${order.quantity}`} />
|
||||
<InfoField label="등록일" value={order.createdAt || "-"} />
|
||||
<InfoField label="생산사유" value={order.productionReason || "-"} />
|
||||
<InfoField label="품목 수" value={`${order.itemCount}개`} />
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
isSelected ? (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11"
|
||||
onClick={(e) => { e.stopPropagation(); handleView(order); }}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
상세
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11"
|
||||
onClick={(e) => { e.stopPropagation(); handleEdit(order); }}
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
{(order.status === "draft" || order.status === "confirmed") && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600"
|
||||
onClick={(e) => { e.stopPropagation(); handleDelete(order.id); }}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// UniversalListPage config
|
||||
const listConfig: UniversalListConfig<StockOrder> = {
|
||||
title: "재고생산 목록",
|
||||
description: "재고생산 관리 및 생산지시 연동",
|
||||
icon: Factory,
|
||||
basePath: "/sales/stocks",
|
||||
idField: "id",
|
||||
|
||||
actions: {
|
||||
getList: async () => ({
|
||||
success: true,
|
||||
data: filteredOrders,
|
||||
totalCount: filteredOrders.length,
|
||||
}),
|
||||
deleteItem: async (id) => {
|
||||
const result = await deleteStockOrder(id);
|
||||
if (result.success) {
|
||||
setOrders((prev) => prev.filter((o) => o.id !== id));
|
||||
const statsResult = await getStockOrderStats();
|
||||
if (statsResult.success && statsResult.data) setApiStats(statsResult.data);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
deleteBulk: async (ids) => {
|
||||
const result = await deleteStockOrders(ids);
|
||||
if (result.success) {
|
||||
const skippedSet = new Set((result.skippedIds ?? []).map(String));
|
||||
setOrders((prev) => prev.filter((o) => skippedSet.has(o.id) || !ids.includes(o.id)));
|
||||
const statsResult = await getStockOrderStats();
|
||||
if (statsResult.success && statsResult.data) setApiStats(statsResult.data);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
},
|
||||
|
||||
columns: tableColumns,
|
||||
|
||||
computeStats: () => stats,
|
||||
|
||||
searchPlaceholder: "생산번호, 품목명, 생산사유 검색...",
|
||||
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
showPresets: true,
|
||||
presetsPosition: 'inline',
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
dateField: "createdAt",
|
||||
},
|
||||
|
||||
itemsPerPage,
|
||||
clientSideFiltering: true,
|
||||
|
||||
searchFilter: (order, searchValue) => {
|
||||
const s = searchValue.toLowerCase();
|
||||
return (
|
||||
order.orderNo.toLowerCase().includes(s) ||
|
||||
order.itemSummary.toLowerCase().includes(s) ||
|
||||
order.productionReason.toLowerCase().includes(s)
|
||||
);
|
||||
},
|
||||
|
||||
customFilterFn: (items, fv) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
return items.filter((item) => {
|
||||
const statusVal = fv.status as string;
|
||||
if (statusVal && statusVal !== 'all' && item.status !== statusVal) return false;
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
headerActions: () => (
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<Button onClick={() => router.push("/sales/stocks?mode=new")}>
|
||||
<Factory className="w-4 h-4 mr-2" />
|
||||
재고생산 등록
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
|
||||
filterConfig,
|
||||
initialFilters: filterValues,
|
||||
filterTitle: '재고생산 필터',
|
||||
|
||||
renderTableRow,
|
||||
renderMobileCard,
|
||||
|
||||
renderDialogs: () => (
|
||||
<DeleteConfirmDialog
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={setIsDeleteDialogOpen}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="삭제 확인"
|
||||
description={
|
||||
<>
|
||||
선택한 <strong>{deleteTargetIds.length}개</strong>의 재고생산을 삭제하시겠습니까?
|
||||
<br />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
삭제된 재고생산은 복구할 수 없습니다.
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
loading={isDeleting}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-600" />
|
||||
<p className="text-muted-foreground">재고생산 목록을 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<UniversalListPage<StockOrder>
|
||||
config={listConfig}
|
||||
initialData={filteredOrders}
|
||||
initialTotalCount={filteredOrders.length}
|
||||
externalSelection={{
|
||||
selectedItems,
|
||||
onToggleSelection: toggleSelection,
|
||||
onToggleSelectAll: toggleSelectAll,
|
||||
setSelectedItems,
|
||||
getItemId: (item: StockOrder) => item.id,
|
||||
}}
|
||||
onFilterChange={(newFilters) => {
|
||||
setFilterValues(newFilters);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
onSearchChange={setSearchTerm}
|
||||
/>
|
||||
);
|
||||
}
|
||||
480
src/components/stocks/actions.ts
Normal file
480
src/components/stocks/actions.ts
Normal file
@@ -0,0 +1,480 @@
|
||||
'use server';
|
||||
|
||||
import { executeServerAction } from '@/lib/api/execute-server-action';
|
||||
import { buildApiUrl } from '@/lib/api/query-params';
|
||||
import type { PaginatedApiResponse } from '@/lib/api/types';
|
||||
import { formatDate } from '@/lib/utils/date';
|
||||
|
||||
// ============================================================================
|
||||
// API 타입 정의
|
||||
// ============================================================================
|
||||
|
||||
interface ApiStockOrder {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
order_no: string;
|
||||
order_type_code: string;
|
||||
status_code: string;
|
||||
site_name: string | null;
|
||||
quantity: number;
|
||||
supply_amount: number;
|
||||
tax_amount: number;
|
||||
total_amount: number;
|
||||
memo: string | null;
|
||||
remarks: string | null;
|
||||
options: {
|
||||
production_reason?: string;
|
||||
target_stock_qty?: number;
|
||||
manager_name?: string;
|
||||
} | null;
|
||||
created_by: number | null;
|
||||
updated_by: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
items?: ApiStockOrderItem[];
|
||||
}
|
||||
|
||||
interface ApiStockOrderItem {
|
||||
id: number;
|
||||
order_id: number;
|
||||
item_id: number | null;
|
||||
item_code: string | null;
|
||||
item_name: string;
|
||||
specification: string | null;
|
||||
quantity: number;
|
||||
unit: string | null;
|
||||
unit_price: number;
|
||||
supply_amount: number;
|
||||
tax_amount: number;
|
||||
total_amount: number;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
interface ApiStockOrderStats {
|
||||
total: number;
|
||||
draft: number;
|
||||
confirmed: number;
|
||||
in_progress: number;
|
||||
completed: number;
|
||||
cancelled: number;
|
||||
total_amount: number;
|
||||
confirmed_amount: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Frontend 타입 정의
|
||||
// ============================================================================
|
||||
|
||||
export type StockStatus =
|
||||
| 'draft'
|
||||
| 'confirmed'
|
||||
| 'in_progress'
|
||||
| 'in_production'
|
||||
| 'produced'
|
||||
| 'completed'
|
||||
| 'cancelled';
|
||||
|
||||
export interface StockOrder {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
statusCode: string;
|
||||
status: StockStatus;
|
||||
siteName: string;
|
||||
quantity: number;
|
||||
memo: string;
|
||||
remarks: string;
|
||||
productionReason: string;
|
||||
targetStockQty: number;
|
||||
manager: string;
|
||||
itemCount: number;
|
||||
itemSummary: string;
|
||||
createdAt: string;
|
||||
items: StockOrderItem[];
|
||||
}
|
||||
|
||||
export interface StockOrderItem {
|
||||
id: string;
|
||||
itemId?: number;
|
||||
itemCode: string;
|
||||
itemName: string;
|
||||
specification: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unitPrice: number;
|
||||
supplyAmount: number;
|
||||
taxAmount: number;
|
||||
totalAmount: number;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface StockOrderFormData {
|
||||
orderTypeCode: string;
|
||||
memo: string;
|
||||
remarks: string;
|
||||
productionReason: string;
|
||||
targetStockQty: number;
|
||||
items: StockOrderItemFormData[];
|
||||
}
|
||||
|
||||
export interface StockOrderItemFormData {
|
||||
itemId?: number;
|
||||
itemCode?: string;
|
||||
itemName: string;
|
||||
specification?: string;
|
||||
quantity: number;
|
||||
unit?: string;
|
||||
unitPrice: number;
|
||||
}
|
||||
|
||||
export interface StockOrderStats {
|
||||
total: number;
|
||||
draft: number;
|
||||
confirmed: number;
|
||||
inProgress: number;
|
||||
completed: number;
|
||||
cancelled: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 상태 매핑
|
||||
// ============================================================================
|
||||
|
||||
const API_TO_FRONTEND_STATUS: Record<string, StockStatus> = {
|
||||
'DRAFT': 'draft',
|
||||
'CONFIRMED': 'confirmed',
|
||||
'IN_PROGRESS': 'in_progress',
|
||||
'IN_PRODUCTION': 'in_production',
|
||||
'PRODUCED': 'produced',
|
||||
'COMPLETED': 'completed',
|
||||
'CANCELLED': 'cancelled',
|
||||
};
|
||||
|
||||
const FRONTEND_TO_API_STATUS: Record<StockStatus, string> = {
|
||||
'draft': 'DRAFT',
|
||||
'confirmed': 'CONFIRMED',
|
||||
'in_progress': 'IN_PROGRESS',
|
||||
'in_production': 'IN_PRODUCTION',
|
||||
'produced': 'PRODUCED',
|
||||
'completed': 'COMPLETED',
|
||||
'cancelled': 'CANCELLED',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 데이터 변환 함수
|
||||
// ============================================================================
|
||||
|
||||
function transformApiToFrontend(apiData: ApiStockOrder): StockOrder {
|
||||
const items = apiData.items?.map(transformItemApiToFrontend) || [];
|
||||
const firstItemName = items[0]?.itemName || '';
|
||||
const extraCount = items.length > 1 ? ` 외 ${items.length - 1}건` : '';
|
||||
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
orderNo: apiData.order_no,
|
||||
statusCode: apiData.status_code,
|
||||
status: API_TO_FRONTEND_STATUS[apiData.status_code] || 'draft',
|
||||
siteName: apiData.site_name || '재고생산',
|
||||
quantity: apiData.quantity,
|
||||
memo: apiData.memo || '',
|
||||
remarks: apiData.remarks || '',
|
||||
productionReason: apiData.options?.production_reason || '',
|
||||
targetStockQty: apiData.options?.target_stock_qty || 0,
|
||||
manager: apiData.options?.manager_name || '',
|
||||
itemCount: items.length,
|
||||
itemSummary: firstItemName ? `${firstItemName}${extraCount}` : '-',
|
||||
createdAt: formatDate(apiData.created_at),
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
function transformItemApiToFrontend(apiItem: ApiStockOrderItem): StockOrderItem {
|
||||
return {
|
||||
id: String(apiItem.id),
|
||||
itemId: apiItem.item_id ?? undefined,
|
||||
itemCode: apiItem.item_code || '',
|
||||
itemName: apiItem.item_name,
|
||||
specification: apiItem.specification || '',
|
||||
quantity: apiItem.quantity,
|
||||
unit: apiItem.unit || 'EA',
|
||||
unitPrice: apiItem.unit_price,
|
||||
supplyAmount: apiItem.supply_amount,
|
||||
taxAmount: apiItem.tax_amount,
|
||||
totalAmount: apiItem.total_amount,
|
||||
sortOrder: apiItem.sort_order,
|
||||
};
|
||||
}
|
||||
|
||||
function transformFrontendToApi(data: StockOrderFormData): Record<string, unknown> {
|
||||
return {
|
||||
order_type_code: 'STOCK',
|
||||
memo: data.memo || null,
|
||||
remarks: data.remarks || null,
|
||||
options: {
|
||||
production_reason: data.productionReason || null,
|
||||
target_stock_qty: data.targetStockQty || null,
|
||||
},
|
||||
// STOCK 전용: 불필요 필드 명시적 null
|
||||
client_id: null,
|
||||
client_name: null,
|
||||
site_name: null, // API 자동 설정 '재고생산'
|
||||
delivery_date: null,
|
||||
delivery_method_code: null,
|
||||
discount_rate: 0,
|
||||
discount_amount: 0,
|
||||
supply_amount: 0,
|
||||
tax_amount: 0,
|
||||
total_amount: 0,
|
||||
items: data.items.map((item) => {
|
||||
const quantity = Number(item.quantity) || 0;
|
||||
const unitPrice = Number(item.unitPrice) || 0;
|
||||
const supplyAmount = quantity * unitPrice;
|
||||
const taxAmount = Math.round(supplyAmount * 0.1);
|
||||
return {
|
||||
item_id: item.itemId || null,
|
||||
item_code: item.itemCode || null,
|
||||
item_name: item.itemName,
|
||||
specification: item.specification || null,
|
||||
quantity,
|
||||
unit: item.unit || 'EA',
|
||||
unit_price: unitPrice,
|
||||
supply_amount: supplyAmount,
|
||||
tax_amount: taxAmount,
|
||||
total_amount: supplyAmount + taxAmount,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API 함수
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 재고생산 목록 조회
|
||||
*/
|
||||
export async function getStockOrders(params?: {
|
||||
page?: number;
|
||||
size?: number;
|
||||
q?: string;
|
||||
status?: string;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data?: { items: StockOrder[]; total: number; page: number; totalPages: number };
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
const apiStatus = params?.status ? FRONTEND_TO_API_STATUS[params.status as StockStatus] : undefined;
|
||||
const result = await executeServerAction<PaginatedApiResponse<ApiStockOrder>>({
|
||||
url: buildApiUrl('/api/v1/orders', {
|
||||
order_type: 'STOCK',
|
||||
page: params?.page,
|
||||
size: params?.size,
|
||||
q: params?.q,
|
||||
status: apiStatus,
|
||||
date_from: params?.date_from,
|
||||
date_to: params?.date_to,
|
||||
}),
|
||||
errorMessage: '재고생산 목록 조회에 실패했습니다.',
|
||||
});
|
||||
if (result.__authError) return { success: false, __authError: true };
|
||||
if (!result.success || !result.data) return { success: false, error: result.error };
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: result.data.data.map(transformApiToFrontend),
|
||||
total: result.data.total,
|
||||
page: result.data.current_page,
|
||||
totalPages: result.data.last_page,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 재고생산 상세 조회
|
||||
*/
|
||||
export async function getStockOrderById(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: StockOrder;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/orders/${id}`),
|
||||
transform: (data: ApiStockOrder) => transformApiToFrontend(data),
|
||||
errorMessage: '재고생산 조회에 실패했습니다.',
|
||||
});
|
||||
if (result.__authError) return { success: false, __authError: true };
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
/**
|
||||
* 재고생산 생성
|
||||
*/
|
||||
export async function createStockOrder(data: StockOrderFormData): Promise<{
|
||||
success: boolean;
|
||||
data?: StockOrder;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
const apiData = transformFrontendToApi(data);
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl('/api/v1/orders'),
|
||||
method: 'POST',
|
||||
body: apiData,
|
||||
transform: (d: ApiStockOrder) => transformApiToFrontend(d),
|
||||
errorMessage: '재고생산 등록에 실패했습니다.',
|
||||
});
|
||||
if (result.__authError) return { success: false, __authError: true };
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
/**
|
||||
* 재고생산 수정
|
||||
*/
|
||||
export async function updateStockOrder(id: string, data: StockOrderFormData): Promise<{
|
||||
success: boolean;
|
||||
data?: StockOrder;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
const apiData = transformFrontendToApi(data);
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/orders/${id}`),
|
||||
method: 'PUT',
|
||||
body: apiData,
|
||||
transform: (d: ApiStockOrder) => transformApiToFrontend(d),
|
||||
errorMessage: '재고생산 수정에 실패했습니다.',
|
||||
});
|
||||
if (result.__authError) return { success: false, __authError: true };
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
/**
|
||||
* 재고생산 삭제
|
||||
*/
|
||||
export async function deleteStockOrder(id: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/orders/${id}`),
|
||||
method: 'DELETE',
|
||||
errorMessage: '재고생산 삭제에 실패했습니다.',
|
||||
});
|
||||
if (result.__authError) return { success: false, __authError: true };
|
||||
return { success: result.success, error: result.error };
|
||||
}
|
||||
|
||||
/**
|
||||
* 재고생산 일괄 삭제
|
||||
*/
|
||||
export async function deleteStockOrders(ids: string[]): Promise<{
|
||||
success: boolean;
|
||||
deletedCount?: number;
|
||||
skippedCount?: number;
|
||||
skippedIds?: number[];
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
interface BulkDeleteResponse {
|
||||
deleted_count: number;
|
||||
skipped_count: number;
|
||||
skipped_ids: number[];
|
||||
}
|
||||
const result = await executeServerAction<BulkDeleteResponse>({
|
||||
url: buildApiUrl('/api/v1/orders/bulk'),
|
||||
method: 'DELETE',
|
||||
body: { ids: ids.map(Number) },
|
||||
errorMessage: '재고생산 일괄 삭제에 실패했습니다.',
|
||||
});
|
||||
if (result.__authError) return { success: false, __authError: true };
|
||||
if (!result.success || !result.data) return { success: false, error: result.error };
|
||||
return {
|
||||
success: true,
|
||||
deletedCount: result.data.deleted_count,
|
||||
skippedCount: result.data.skipped_count,
|
||||
skippedIds: result.data.skipped_ids,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 재고생산 상태 변경
|
||||
*/
|
||||
export async function updateStockOrderStatus(id: string, status: StockStatus): Promise<{
|
||||
success: boolean;
|
||||
data?: StockOrder;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
const apiStatus = FRONTEND_TO_API_STATUS[status];
|
||||
if (!apiStatus) {
|
||||
return { success: false, error: '유효하지 않은 상태입니다.' };
|
||||
}
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/orders/${id}/status`),
|
||||
method: 'PATCH',
|
||||
body: { status: apiStatus },
|
||||
transform: (d: ApiStockOrder) => transformApiToFrontend(d),
|
||||
errorMessage: '상태 변경에 실패했습니다.',
|
||||
});
|
||||
if (result.__authError) return { success: false, __authError: true };
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
/**
|
||||
* 재고생산 통계 조회
|
||||
*/
|
||||
export async function getStockOrderStats(): Promise<{
|
||||
success: boolean;
|
||||
data?: StockOrderStats;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
const result = await executeServerAction<ApiStockOrderStats>({
|
||||
url: buildApiUrl('/api/v1/orders/stats', { order_type: 'STOCK' }),
|
||||
errorMessage: '재고생산 통계 조회에 실패했습니다.',
|
||||
});
|
||||
if (result.__authError) return { success: false, __authError: true };
|
||||
if (!result.success || !result.data) return { success: false, error: result.error };
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
total: result.data.total,
|
||||
draft: result.data.draft,
|
||||
confirmed: result.data.confirmed,
|
||||
inProgress: result.data.in_progress,
|
||||
completed: result.data.completed,
|
||||
cancelled: result.data.cancelled,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 재고생산 → 생산지시 생성
|
||||
*/
|
||||
export async function createStockProductionOrder(
|
||||
orderId: string,
|
||||
data?: { priority?: string; memo?: string }
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: StockOrder;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
const apiData: Record<string, unknown> = {};
|
||||
if (data?.priority) apiData.priority = data.priority;
|
||||
if (data?.memo) apiData.memo = data.memo;
|
||||
|
||||
const result = await executeServerAction<{ order: ApiStockOrder }>({
|
||||
url: buildApiUrl(`/api/v1/orders/${orderId}/production-order`),
|
||||
method: 'POST',
|
||||
body: apiData,
|
||||
errorMessage: '생산지시 생성에 실패했습니다.',
|
||||
});
|
||||
if (result.__authError) return { success: false, __authError: true };
|
||||
if (!result.success || !result.data) return { success: false, error: result.error };
|
||||
return { success: true, data: transformApiToFrontend(result.data.order) };
|
||||
}
|
||||
Reference in New Issue
Block a user