feat: [stocks] 재고 관리 페이지 신규 + 회계 모듈 타입/코드 정리

- 재고 관리 페이지 및 컴포넌트 신규 추가
- 회계 6개 모듈 중복 타입 제거, 코드 간소화 (-212줄)
- AccountSubjectSelect 마이너 수정
- Popover 외부 클릭 다이얼로그 이슈 가이드 문서
This commit is contained in:
유병철
2026-03-17 10:32:18 +09:00
parent 897d44603b
commit 9dac7bf37e
20 changed files with 2065 additions and 212 deletions

View File

@@ -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 메이저 버전 업그레이드 시 재검증 필요

View 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} />;
}

View 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 />;
}

View File

@@ -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개) =====

View File

@@ -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 },

View File

@@ -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">

View File

@@ -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;

View File

@@ -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: '미신청',

View File

@@ -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">

View File

@@ -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: '기타용역비' },
];

View File

@@ -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">

View File

@@ -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';

View File

@@ -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'];

View File

@@ -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">

View File

@@ -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';

View File

@@ -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,

View 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="이 재고생산을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다."
/>
</>
);
}

View 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}
/>
</>
);
}

View 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}
/>
);
}

View 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) };
}