diff --git a/claudedocs/guides/[FIX-2026-03-17] popover-outside-click-dialog-cascade.md b/claudedocs/guides/[FIX-2026-03-17] popover-outside-click-dialog-cascade.md
new file mode 100644
index 00000000..2d9a27b9
--- /dev/null
+++ b/claudedocs/guides/[FIX-2026-03-17] popover-outside-click-dialog-cascade.md
@@ -0,0 +1,81 @@
+# Popover 외부 클릭 시 Dialog cascade 문제 해결
+
+**작성일**: 2026-03-17
+**문제 유형**: UI 컴포넌트 이벤트 처리
+**적용 범위**: Popover 기반 UI 컴포넌트 5종
+
+---
+
+## 문제 현상
+
+### 원래 버그 (2026-02-26 이전)
+DatePicker, SearchableSelect 등이 **Dialog(모달) 안에서** 사용될 때, 날짜/옵션 선택 시 **모달 자체가 닫히는 문제** 발생.
+
+**원인**: Popover가 Portal로 `
`에 렌더링되어, 클릭 이벤트가 Dialog의 `onInteractOutside`까지 cascade → Dialog 닫힘
+
+### 1차 수정의 부작용 (2026-02-26 ~ 2026-03-17)
+`e.preventDefault()`로 cascade를 차단했으나, Popover 자체도 외부 클릭으로 닫히지 않게 됨.
+
+**증상**: 달력/검색 셀렉트를 열면 해당 컴포넌트를 직접 클릭하거나 ESC를 눌러야만 닫힘. 화면 아무 곳을 클릭해도 열린 채로 유지.
+
+---
+
+## 해결책
+
+`e.preventDefault()`로 Dialog cascade는 차단하되, `setOpen(false)`로 Popover를 수동으로 닫기.
+
+```tsx
+ {
+ // 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 메이저 버전 업그레이드 시 재검증 필요
diff --git a/src/app/[locale]/(protected)/sales/stocks/[id]/page.tsx b/src/app/[locale]/(protected)/sales/stocks/[id]/page.tsx
new file mode 100644
index 00000000..f0071ca5
--- /dev/null
+++ b/src/app/[locale]/(protected)/sales/stocks/[id]/page.tsx
@@ -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(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 (
+
+ );
+ }
+
+ if (!order) {
+ return (
+
+ );
+ }
+
+ return ;
+}
+
+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 ;
+ }
+
+ return ;
+}
diff --git a/src/app/[locale]/(protected)/sales/stocks/page.tsx b/src/app/[locale]/(protected)/sales/stocks/page.tsx
new file mode 100644
index 00000000..a4b1cb73
--- /dev/null
+++ b/src/app/[locale]/(protected)/sales/stocks/page.tsx
@@ -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 ;
+}
+
+export default function StocksPage() {
+ const searchParams = useSearchParams();
+ const mode = searchParams.get('mode');
+
+ if (mode === 'new') {
+ return ;
+ }
+
+ return ;
+}
diff --git a/src/components/accounting/CardTransactionInquiry/index.tsx b/src/components/accounting/CardTransactionInquiry/index.tsx
index 53a93980..83bce790 100644
--- a/src/components/accounting/CardTransactionInquiry/index.tsx
+++ b/src/components/accounting/CardTransactionInquiry/index.tsx
@@ -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[] = [
{ 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개) =====
diff --git a/src/components/accounting/CardTransactionInquiry/types.ts b/src/components/accounting/CardTransactionInquiry/types.ts
index 515483fb..5bb8c96e 100644
--- a/src/components/accounting/CardTransactionInquiry/types.ts
+++ b/src/components/accounting/CardTransactionInquiry/types.ts
@@ -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 },
diff --git a/src/components/accounting/DepositManagement/index.tsx b/src/components/accounting/DepositManagement/index.tsx
index a10b2f64..99d5457d 100644
--- a/src/components/accounting/DepositManagement/index.tsx
+++ b/src/components/accounting/DepositManagement/index.tsx
@@ -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('latest');
// 계정과목명 저장 다이얼로그
- const [selectedAccountSubject, setSelectedAccountSubject] = useState('unset');
+ const [selectedAccountSubject, setSelectedAccountSubject] = useState('');
const [showSaveDialog, setShowSaveDialog] = useState(false);
const [selectedItemsForSave, setSelectedItemsForSave] = useState>(new Set());
@@ -312,22 +305,17 @@ export function DepositManagement({ initialData, initialPagination: _initialPagi
},
filterTitle: '입금 필터',
- // 헤더 액션: 계정과목명 Select + 저장 + 새로고침
+ // 헤더 액션: 계정과목명 검색 Select + 저장 + 새로고침
headerActions: ({ selectedItems }) => (
계정과목명
-
-
-
-
-
- {ACCOUNT_SUBJECT_OPTIONS.map((option) => (
-
- {option.label}
-
- ))}
-
-
+
handleSaveAccountSubject(selectedItems)} size="sm">
저장
@@ -479,11 +467,7 @@ export function DepositManagement({ initialData, initialPagination: _initialPagi
계정과목명 변경
- {selectedItemsForSave.size}개의 입금 유형을{' '}
-
- {ACCOUNT_SUBJECT_OPTIONS.find(o => o.value === selectedAccountSubject)?.label}
-
- (으)로 모두 변경하시겠습니까?
+ {selectedItemsForSave.size}개의 입금 유형을 선택한 계정과목으로 모두 변경하시겠습니까?
diff --git a/src/components/accounting/DepositManagement/types.ts b/src/components/accounting/DepositManagement/types.ts
index 3efdeda7..3c323263 100644
--- a/src/components/accounting/DepositManagement/types.ts
+++ b/src/components/accounting/DepositManagement/types.ts
@@ -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;
diff --git a/src/components/accounting/ExpectedExpenseManagement/types.ts b/src/components/accounting/ExpectedExpenseManagement/types.ts
index a49677bf..f16a3471 100644
--- a/src/components/accounting/ExpectedExpenseManagement/types.ts
+++ b/src/components/accounting/ExpectedExpenseManagement/types.ts
@@ -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 = {
none: '미신청',
diff --git a/src/components/accounting/PurchaseManagement/index.tsx b/src/components/accounting/PurchaseManagement/index.tsx
index b7f7d21a..52621d26 100644
--- a/src/components/accounting/PurchaseManagement/index.tsx
+++ b/src/components/accounting/PurchaseManagement/index.tsx
@@ -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('unset');
+ const [selectedAccountSubject, setSelectedAccountSubject] = useState('');
const [showSaveDialog, setShowSaveDialog] = useState(false);
const [selectedItemsForSave, setSelectedItemsForSave] = useState>(new Set());
@@ -346,22 +339,18 @@ export function PurchaseManagement() {
onEndDateChange: setEndDate,
},
- // 헤더 액션: 계정과목명 Select + 저장 버튼
+ // 헤더 액션: 계정과목명 검색 Select + 저장 버튼
headerActions: ({ selectedItems }) => (
계정과목명
-
-
-
-
-
- {ACCOUNT_SUBJECT_SELECTOR_OPTIONS.map((option) => (
-
- {option.label}
-
- ))}
-
-
+
handleSaveAccountSubject(selectedItems)} size="sm">
저장
@@ -484,11 +473,7 @@ export function PurchaseManagement() {
계정과목명 변경
- {selectedItemsForSave.size}개의 매입유형을{' '}
-
- {ACCOUNT_SUBJECT_SELECTOR_OPTIONS.find(o => o.value === selectedAccountSubject)?.label}
-
- (으)로 모두 변경하시겠습니까?
+ {selectedItemsForSave.size}개의 매입유형을 선택한 계정과목으로 모두 변경하시겠습니까?
diff --git a/src/components/accounting/PurchaseManagement/types.ts b/src/components/accounting/PurchaseManagement/types.ts
index 55e0a6ff..b0c6d3c1 100644
--- a/src/components/accounting/PurchaseManagement/types.ts
+++ b/src/components/accounting/PurchaseManagement/types.ts
@@ -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: '기타용역비' },
-];
\ No newline at end of file
diff --git a/src/components/accounting/SalesManagement/index.tsx b/src/components/accounting/SalesManagement/index.tsx
index 932571ae..4799d38d 100644
--- a/src/components/accounting/SalesManagement/index.tsx
+++ b/src/components/accounting/SalesManagement/index.tsx
@@ -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('unset');
+ const [selectedAccountSubject, setSelectedAccountSubject] = useState('');
const [showSaveDialog, setShowSaveDialog] = useState(false);
const [selectedItemsForSave, setSelectedItemsForSave] = useState>(new Set());
@@ -357,22 +350,18 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
onStartDateChange: setStartDate,
onEndDateChange: setEndDate,
},
- // 헤더 액션 (계정과목명 Select + 저장 버튼)
+ // 헤더 액션 (계정과목명 검색 Select + 저장 버튼)
headerActions: ({ selectedItems }) => (
계정과목명
-
-
-
-
-
- {ACCOUNT_SUBJECT_SELECTOR_OPTIONS.map((option) => (
-
- {option.label}
-
- ))}
-
-
+
handleSaveAccountSubject(selectedItems)} size="sm">
저장
@@ -523,11 +512,7 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
계정과목명 변경
- {selectedItemsForSave.size}개의 매출유형을{' '}
-
- {ACCOUNT_SUBJECT_SELECTOR_OPTIONS.find(o => o.value === selectedAccountSubject)?.label}
-
- (으)로 모두 변경하시겠습니까?
+ {selectedItemsForSave.size}개의 매출유형을 선택한 계정과목으로 모두 변경하시겠습니까?
diff --git a/src/components/accounting/SalesManagement/types.ts b/src/components/accounting/SalesManagement/types.ts
index 0d7d2689..13942421 100644
--- a/src/components/accounting/SalesManagement/types.ts
+++ b/src/components/accounting/SalesManagement/types.ts
@@ -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';
diff --git a/src/components/accounting/TaxInvoiceManagement/types.ts b/src/components/accounting/TaxInvoiceManagement/types.ts
index 0db94ca8..565abdcc 100644
--- a/src/components/accounting/TaxInvoiceManagement/types.ts
+++ b/src/components/accounting/TaxInvoiceManagement/types.ts
@@ -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'];
diff --git a/src/components/accounting/WithdrawalManagement/index.tsx b/src/components/accounting/WithdrawalManagement/index.tsx
index e3cfb13d..2994c123 100644
--- a/src/components/accounting/WithdrawalManagement/index.tsx
+++ b/src/components/accounting/WithdrawalManagement/index.tsx
@@ -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('latest');
// 상단 계정과목명 선택 (저장용)
- const [selectedAccountSubject, setSelectedAccountSubject] = useState('unset');
+ const [selectedAccountSubject, setSelectedAccountSubject] = useState('');
// 검색어 상태 (헤더에서 직접 관리)
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 (
계정과목명
-
-
-
-
-
- {ACCOUNT_SUBJECT_OPTIONS.map((option) => (
-
- {option.label}
-
- ))}
-
-
+
handleSaveAccountSubject(selectedArray)} size="sm">
저장
@@ -531,11 +519,7 @@ export function WithdrawalManagement({ initialData, initialPagination: _initialP
계정과목명 변경
- {saveTargetIds.length}개의 출금 유형을{' '}
-
- {ACCOUNT_SUBJECT_OPTIONS.find(o => o.value === selectedAccountSubject)?.label}
-
- (으)로 모두 변경하시겠습니까?
+ {saveTargetIds.length}개의 출금 유형을 선택한 계정과목으로 모두 변경하시겠습니까?
diff --git a/src/components/accounting/WithdrawalManagement/types.ts b/src/components/accounting/WithdrawalManagement/types.ts
index 60c1b3c4..1116bce5 100644
--- a/src/components/accounting/WithdrawalManagement/types.ts
+++ b/src/components/accounting/WithdrawalManagement/types.ts
@@ -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';
diff --git a/src/components/accounting/common/AccountSubjectSelect.tsx b/src/components/accounting/common/AccountSubjectSelect.tsx
index 38a96d2e..ec1c407c 100644
--- a/src/components/accounting/common/AccountSubjectSelect.tsx
+++ b/src/components/accounting/common/AccountSubjectSelect.tsx
@@ -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,
diff --git a/src/components/stocks/StockProductionDetail.tsx b/src/components/stocks/StockProductionDetail.tsx
new file mode 100644
index 00000000..02710136
--- /dev/null
+++ b/src/components/stocks/StockProductionDetail.tsx
@@ -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 = {
+ 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 {config.label} ;
+}
+
+// 정보 표시 컴포넌트
+function InfoItem({ label, value }: { label: string; value: string | number }) {
+ return (
+
+
{label}
+
{value || '-'}
+
+ );
+}
+
+// ============================================================================
+// 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(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) => (
+
+ {/* 기본 정보 */}
+
+
+
+
+ 기본 정보
+
+
+
+
+
+
+
상태
+
{getStatusBadge(data.status)}
+
+
+
+
+
+
+
+
+
+ {/* 비고 */}
+ {(data.memo || data.remarks) && (
+
+
+
+
+ 비고
+
+
+
+
+ {data.memo && }
+ {data.remarks && }
+
+
+
+ )}
+
+ {/* 품목 내역 */}
+
+
+
+
+ 품목 내역 ({data.items.length}건)
+
+
+
+ {data.items.length === 0 ? (
+
+ 품목이 없습니다.
+
+ ) : (
+
+
+
+
+ No
+ 품목코드
+ 품목명
+ 규격
+ 수량
+ 단위
+ 단가
+ 금액
+
+
+
+ {data.items.map((item, index) => (
+
+ {index + 1}
+
+ {item.itemCode ? (
+
+ {item.itemCode}
+
+ ) : '-'}
+
+ {item.itemName}
+
+ {item.specification || '-'}
+
+ {item.quantity}
+ {item.unit}
+
+ {formatAmount(item.unitPrice)}
+
+
+ {formatAmount(item.totalAmount)}
+
+
+ ))}
+
+
+
+ )}
+
+
+
+ ),
+ []
+ );
+
+ return (
+ <>
+ }
+ itemId={orderId}
+ isLoading={loading}
+ onCancel={() => router.push(basePath)}
+ headerActions={headerActions}
+ headerActionItems={headerActionItems}
+ renderView={(data) => renderViewContent(data as unknown as StockOrder)}
+ />
+
+
+ >
+ );
+}
diff --git a/src/components/stocks/StockProductionForm.tsx b/src/components/stocks/StockProductionForm.tsx
new file mode 100644
index 00000000..5bf4c0d8
--- /dev/null
+++ b/src/components/stocks/StockProductionForm.tsx
@@ -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(() => {
+ 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>({});
+
+ // 필드 에러 초기화
+ 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 = {};
+
+ 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(
+ () =>
+ () => (
+
+ {/* 기본 정보 */}
+
+
+
+ 생산사유
+ setForm((prev) => ({ ...prev, productionReason: e.target.value }))}
+ />
+
+
+ 목표재고수량
+ setForm((prev) => ({ ...prev, targetStockQty: String(value ?? 0) }))}
+ min={0}
+ />
+
+
+
+
+ {/* 비고 */}
+
+
+
+
+ {/* 품목 내역 */}
+
setIsItemDialogOpen(true)}
+ >
+
+ 품목 추가
+
+ }
+ >
+ {fieldErrors.items && (
+ {fieldErrors.items}
+ )}
+
+ {form.items.length === 0 ? (
+
+
+
품목을 추가해주세요
+
setIsItemDialogOpen(true)}
+ >
+
+ 품목 추가
+
+
+ ) : (
+
+
+
+
+ No
+ 품목명
+ 규격
+ 수량
+ 단위
+ 단가
+ 금액
+ 삭제
+
+
+
+ {form.items.map((item, index) => (
+
+ {index + 1}
+ {item.itemName}
+
+ {item.specification || item.spec || '-'}
+
+
+ handleQuantityChange(item.id, value ?? 1)}
+ min={1}
+ className="w-20"
+ />
+
+
+ {item.unit || 'EA'}
+
+
+ {formatAmount(item.unitPrice)}
+
+
+ {formatAmount((item.amount ?? item.unitPrice * item.quantity))}
+
+
+ handleRemoveItem(item.id)}
+ >
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+ ),
+ [form, fieldErrors, handleQuantityChange, handleRemoveItem]
+ );
+
+ return (
+ <>
+ handleSave()}
+ renderForm={renderFormContent}
+ />
+
+
+ >
+ );
+}
diff --git a/src/components/stocks/StockProductionList.tsx b/src/components/stocks/StockProductionList.tsx
new file mode 100644
index 00000000..aebe51be
--- /dev/null
+++ b/src/components/stocks/StockProductionList.tsx
@@ -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 = {
+ 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 {c.label} ;
+}
+
+export function StockProductionList() {
+ const router = useRouter();
+ const [searchTerm, setSearchTerm] = useState("");
+ const [selectedItems, setSelectedItems] = useState>(new Set());
+ const [currentPage, setCurrentPage] = useState(1);
+ const itemsPerPage = 20;
+
+ // 날짜 범위 필터
+ const [startDate, setStartDate] = useState("");
+ const [endDate, setEndDate] = useState("");
+
+ // 필터
+ const [filterValues, setFilterValues] = useState>({
+ 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([]);
+ const [isDeleting, setIsDeleting] = useState(false);
+
+ // API 데이터
+ const [orders, setOrders] = useState([]);
+ const [apiStats, setApiStats] = useState(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 (
+ handleView(order)}
+ >
+ e.stopPropagation()} className="text-center">
+
+
+ {globalIndex}
+
+
+ {order.orderNo}
+
+
+ {order.itemSummary || "-"}
+ {order.quantity || "-"}
+ {order.productionReason || "-"}
+ {order.createdAt || "-"}
+ {getStatusBadge(order.status)}
+ {order.memo || "-"}
+
+ );
+ };
+
+ // 모바일 카드
+ const renderMobileCard = (
+ order: StockOrder,
+ _index: number,
+ globalIndex: number,
+ handlers: { isSelected: boolean; onToggle: () => void }
+ ) => {
+ const { isSelected, onToggle } = handlers;
+ return (
+ handleView(order)}
+ headerBadges={
+ <>
+
+ {globalIndex}
+
+
+ {order.orderNo}
+
+ >
+ }
+ title={order.itemSummary}
+ statusBadge={getStatusBadge(order.status)}
+ infoGrid={
+
+
+
+
+
+
+ }
+ actions={
+ isSelected ? (
+
+ { e.stopPropagation(); handleView(order); }}
+ >
+
+ 상세
+
+ { e.stopPropagation(); handleEdit(order); }}
+ >
+
+ 수정
+
+ {(order.status === "draft" || order.status === "confirmed") && (
+ { e.stopPropagation(); handleDelete(order.id); }}
+ >
+
+ 삭제
+
+ )}
+
+ ) : undefined
+ }
+ />
+ );
+ };
+
+ // UniversalListPage config
+ const listConfig: UniversalListConfig = {
+ 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: () => (
+
+ router.push("/sales/stocks?mode=new")}>
+
+ 재고생산 등록
+
+
+ ),
+
+ filterConfig,
+ initialFilters: filterValues,
+ filterTitle: '재고생산 필터',
+
+ renderTableRow,
+ renderMobileCard,
+
+ renderDialogs: () => (
+
+ 선택한 {deleteTargetIds.length}개 의 재고생산을 삭제하시겠습니까?
+
+
+ 삭제된 재고생산은 복구할 수 없습니다.
+
+ >
+ }
+ loading={isDeleting}
+ />
+ ),
+ };
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ return (
+
+ 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}
+ />
+ );
+}
diff --git a/src/components/stocks/actions.ts b/src/components/stocks/actions.ts
new file mode 100644
index 00000000..a441cbf0
--- /dev/null
+++ b/src/components/stocks/actions.ts
@@ -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 = {
+ 'DRAFT': 'draft',
+ 'CONFIRMED': 'confirmed',
+ 'IN_PROGRESS': 'in_progress',
+ 'IN_PRODUCTION': 'in_production',
+ 'PRODUCED': 'produced',
+ 'COMPLETED': 'completed',
+ 'CANCELLED': 'cancelled',
+};
+
+const FRONTEND_TO_API_STATUS: Record = {
+ '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 {
+ 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>({
+ 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({
+ 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({
+ 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 = {};
+ 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) };
+}