feat(WEB): 부실채권, 재고, 입고, 수주 UI 개선
- BadDebtCollection 액션/타입 리팩토링 - ReceivingProcessDialog 입고처리 개선 - StockStatusList 재고현황 UI 개선 - OrderSalesDetailView 수주 상세 수정 - UniversalListPage 범용 리스트 개선 - production-order 페이지 수정
This commit is contained in:
@@ -3,8 +3,8 @@
|
||||
/**
|
||||
* 입고처리 다이얼로그
|
||||
* - 발주 정보 표시
|
||||
* - 입고LOT*, 공급업체LOT, 입고수량*, 입고위치* 입력
|
||||
* - 비고 입력
|
||||
* - 입고LOT*, 공급업체LOT, 입고수량* 입력 (필수)
|
||||
* - 입고위치, 비고 입력 (선택)
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
@@ -64,13 +64,11 @@ export function ReceivingProcessDialog({ open, onOpenChange, detail, onComplete
|
||||
errors.push('입고수량은 필수 입력 항목입니다. 유효한 숫자를 입력해주세요.');
|
||||
}
|
||||
|
||||
if (!receivingLocation.trim()) {
|
||||
errors.push('입고위치는 필수 입력 항목입니다.');
|
||||
}
|
||||
// 입고위치는 선택 항목 (필수 검사 제거)
|
||||
|
||||
setValidationErrors(errors);
|
||||
return errors.length === 0;
|
||||
}, [receivingLot, receivingQty, receivingLocation]);
|
||||
}, [receivingLot, receivingQty]);
|
||||
|
||||
// 입고 처리
|
||||
const handleSubmit = useCallback(async () => {
|
||||
@@ -84,7 +82,7 @@ export function ReceivingProcessDialog({ open, onOpenChange, detail, onComplete
|
||||
receivingQty: Number(receivingQty),
|
||||
receivingLot,
|
||||
supplierLot: supplierLot || undefined,
|
||||
receivingLocation,
|
||||
receivingLocation: receivingLocation || undefined,
|
||||
remark: remark || undefined,
|
||||
};
|
||||
|
||||
@@ -197,15 +195,10 @@ export function ReceivingProcessDialog({ open, onOpenChange, detail, onComplete
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">
|
||||
입고위치 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Label className="text-sm text-muted-foreground">입고위치</Label>
|
||||
<Input
|
||||
value={receivingLocation}
|
||||
onChange={(e) => {
|
||||
setReceivingLocation(e.target.value);
|
||||
setValidationErrors([]);
|
||||
}}
|
||||
onChange={(e) => setReceivingLocation(e.target.value)}
|
||||
placeholder="예: A-01"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -94,11 +94,11 @@ export interface InspectionFormData {
|
||||
|
||||
// 입고처리 폼 데이터
|
||||
export interface ReceivingProcessFormData {
|
||||
receivingLot: string; // 입고LOT *
|
||||
supplierLot?: string; // 공급업체LOT
|
||||
receivingQty: number; // 입고수량 *
|
||||
receivingLocation: string; // 입고위치 *
|
||||
remark?: string; // 비고
|
||||
receivingLot: string; // 입고LOT *
|
||||
supplierLot?: string; // 공급업체LOT
|
||||
receivingQty: number; // 입고수량 *
|
||||
receivingLocation?: string; // 입고위치 (선택)
|
||||
remark?: string; // 비고
|
||||
}
|
||||
|
||||
// 통계 데이터
|
||||
|
||||
@@ -119,14 +119,12 @@ export function StockStatusList() {
|
||||
|
||||
// ===== 탭 옵션 (기본 탭 + 품목유형별 통계) =====
|
||||
const tabs: TabOption[] = useMemo(() => {
|
||||
// 기본 탭 정의 (API 데이터 없어도 항상 표시)
|
||||
// 기본 탭 정의 (Item 모델의 MATERIAL_TYPES: RM, SM, CS)
|
||||
const defaultTabs: { value: string; label: string }[] = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'raw_material', label: '원자재' },
|
||||
{ value: 'bent_part', label: '절곡부품' },
|
||||
{ value: 'purchased_part', label: '구매부품' },
|
||||
{ value: 'sub_material', label: '부자재' },
|
||||
{ value: 'consumable', label: '소모품' },
|
||||
{ value: 'RM', label: '원자재' },
|
||||
{ value: 'SM', label: '부자재' },
|
||||
{ value: 'CS', label: '소모품' },
|
||||
];
|
||||
|
||||
return defaultTabs.map((tab) => {
|
||||
@@ -287,9 +285,13 @@ export function StockStatusList() {
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={item.status === 'low' ? 'text-orange-600 font-medium' : ''}>
|
||||
{STOCK_STATUS_LABELS[item.status]}
|
||||
</span>
|
||||
{item.status ? (
|
||||
<span className={item.status === 'low' ? 'text-orange-600 font-medium' : ''}>
|
||||
{STOCK_STATUS_LABELS[item.status]}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.location}</TableCell>
|
||||
</TableRow>
|
||||
@@ -334,7 +336,7 @@ export function StockStatusList() {
|
||||
/>
|
||||
<InfoField
|
||||
label="상태"
|
||||
value={STOCK_STATUS_LABELS[item.status]}
|
||||
value={item.status ? STOCK_STATUS_LABELS[item.status] : '-'}
|
||||
className={item.status === 'low' ? 'text-orange-600' : ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* 재고 현황 서버 액션
|
||||
*
|
||||
* API Endpoints:
|
||||
* - GET /api/v1/stocks - 목록 조회
|
||||
* API Endpoints (Item 기준):
|
||||
* - GET /api/v1/stocks - 목록 조회 (Item + Stock LEFT JOIN)
|
||||
* - GET /api/v1/stocks/stats - 통계 조회
|
||||
* - GET /api/v1/stocks/stats-by-type - 품목유형별 통계 조회
|
||||
* - GET /api/v1/stocks/{id} - 상세 조회 (LOT 포함)
|
||||
* - GET /api/v1/stocks/{id} - 상세 조회 (Item 기준, LOT 포함)
|
||||
*/
|
||||
|
||||
'use server';
|
||||
@@ -23,16 +23,12 @@ import type {
|
||||
LotStatusType,
|
||||
} from './types';
|
||||
|
||||
// ===== API 데이터 타입 =====
|
||||
interface StockApiData {
|
||||
// ===== API 데이터 타입 (Item 기준) =====
|
||||
|
||||
// Stock 관계 데이터
|
||||
interface StockRelationData {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
item_code: string;
|
||||
item_name: string;
|
||||
item_type: ItemType;
|
||||
item_type_label?: string;
|
||||
specification?: string;
|
||||
unit: string;
|
||||
item_id: number;
|
||||
stock_qty: string | number;
|
||||
safety_stock: string | number;
|
||||
reserved_qty: string | number;
|
||||
@@ -42,12 +38,30 @@ interface StockApiData {
|
||||
days_elapsed?: number;
|
||||
location?: string;
|
||||
status: StockStatusType;
|
||||
status_label?: string;
|
||||
last_receipt_date?: string;
|
||||
last_issue_date?: string;
|
||||
lots?: StockLotApiData[];
|
||||
}
|
||||
|
||||
// Item API 응답 데이터
|
||||
interface ItemApiData {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
code: string; // Item.code (기존 item_code)
|
||||
name: string; // Item.name (기존 item_name)
|
||||
item_type: ItemType; // Item.item_type (RM, SM, CS)
|
||||
unit: string;
|
||||
category_id?: number;
|
||||
category?: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
description?: string;
|
||||
attributes?: Record<string, unknown>;
|
||||
is_active: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
lots?: StockLotApiData[];
|
||||
stock?: StockRelationData | null; // Stock 관계 (없으면 null)
|
||||
}
|
||||
|
||||
interface StockLotApiData {
|
||||
@@ -71,8 +85,8 @@ interface StockLotApiData {
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
interface StockApiPaginatedResponse {
|
||||
data: StockApiData[];
|
||||
interface ItemApiPaginatedResponse {
|
||||
data: ItemApiData[];
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
@@ -84,6 +98,7 @@ interface StockApiStatsResponse {
|
||||
normal_count: number;
|
||||
low_count: number;
|
||||
out_count: number;
|
||||
no_stock_count: number;
|
||||
}
|
||||
|
||||
interface StockApiStatsByTypeResponse {
|
||||
@@ -95,19 +110,23 @@ interface StockApiStatsByTypeResponse {
|
||||
}
|
||||
|
||||
// ===== API → Frontend 변환 (목록용) =====
|
||||
function transformApiToListItem(data: StockApiData): StockItem {
|
||||
function transformApiToListItem(data: ItemApiData): StockItem {
|
||||
const stock = data.stock;
|
||||
const hasStock = !!stock;
|
||||
|
||||
return {
|
||||
id: String(data.id),
|
||||
itemCode: data.item_code,
|
||||
itemName: data.item_name,
|
||||
itemCode: data.code,
|
||||
itemName: data.name,
|
||||
itemType: data.item_type,
|
||||
unit: data.unit || 'EA',
|
||||
stockQty: parseFloat(String(data.stock_qty)) || 0,
|
||||
safetyStock: parseFloat(String(data.safety_stock)) || 0,
|
||||
lotCount: data.lot_count || 0,
|
||||
lotDaysElapsed: data.days_elapsed || 0,
|
||||
status: data.status,
|
||||
location: data.location || '-',
|
||||
stockQty: hasStock ? (parseFloat(String(stock.stock_qty)) || 0) : 0,
|
||||
safetyStock: hasStock ? (parseFloat(String(stock.safety_stock)) || 0) : 0,
|
||||
lotCount: hasStock ? (stock.lot_count || 0) : 0,
|
||||
lotDaysElapsed: hasStock ? (stock.days_elapsed || 0) : 0,
|
||||
status: hasStock ? stock.status : null,
|
||||
location: hasStock ? (stock.location || '-') : '-',
|
||||
hasStock,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -129,22 +148,38 @@ function transformApiToLot(data: StockLotApiData): LotDetail {
|
||||
}
|
||||
|
||||
// ===== API → Frontend 변환 (상세용) =====
|
||||
function transformApiToDetail(data: StockApiData): StockDetail {
|
||||
function transformApiToDetail(data: ItemApiData): StockDetail {
|
||||
const stock = data.stock;
|
||||
const hasStock = !!stock;
|
||||
|
||||
// description 또는 attributes에서 규격 정보 추출
|
||||
let specification = '-';
|
||||
if (data.description) {
|
||||
specification = data.description;
|
||||
} else if (data.attributes && typeof data.attributes === 'object') {
|
||||
// attributes에서 규격 관련 정보 추출 시도
|
||||
const attrs = data.attributes as Record<string, unknown>;
|
||||
if (attrs.specification) {
|
||||
specification = String(attrs.specification);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: String(data.id),
|
||||
itemCode: data.item_code,
|
||||
itemName: data.item_name,
|
||||
itemCode: data.code,
|
||||
itemName: data.name,
|
||||
itemType: data.item_type,
|
||||
category: '-', // API에서 category 제공 안 함
|
||||
specification: data.specification || '-',
|
||||
category: data.category?.name || '-',
|
||||
specification,
|
||||
unit: data.unit || 'EA',
|
||||
currentStock: parseFloat(String(data.stock_qty)) || 0,
|
||||
safetyStock: parseFloat(String(data.safety_stock)) || 0,
|
||||
location: data.location || '-',
|
||||
lotCount: data.lot_count || 0,
|
||||
lastReceiptDate: data.last_receipt_date || '-',
|
||||
status: data.status,
|
||||
lots: (data.lots || []).map(transformApiToLot),
|
||||
currentStock: hasStock ? (parseFloat(String(stock.stock_qty)) || 0) : 0,
|
||||
safetyStock: hasStock ? (parseFloat(String(stock.safety_stock)) || 0) : 0,
|
||||
location: hasStock ? (stock.location || '-') : '-',
|
||||
lotCount: hasStock ? (stock.lot_count || 0) : 0,
|
||||
lastReceiptDate: hasStock ? (stock.last_receipt_date || '-') : '-',
|
||||
status: hasStock ? stock.status : null,
|
||||
hasStock,
|
||||
lots: hasStock && stock.lots ? stock.lots.map(transformApiToLot) : [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -155,6 +190,7 @@ function transformApiToStats(data: StockApiStatsResponse): StockStats {
|
||||
normalCount: data.normal_count,
|
||||
lowCount: data.low_count,
|
||||
outCount: data.out_count,
|
||||
noStockCount: data.no_stock_count || 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -238,7 +274,7 @@ export async function getStocks(params?: {
|
||||
};
|
||||
}
|
||||
|
||||
const paginatedData: StockApiPaginatedResponse = result.data || {
|
||||
const paginatedData: ItemApiPaginatedResponse = result.data || {
|
||||
data: [],
|
||||
current_page: 1,
|
||||
last_page: 1,
|
||||
@@ -340,7 +376,7 @@ export async function getStockStatsByType(): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 재고 상세 조회 (LOT 포함) =====
|
||||
// ===== 재고 상세 조회 (Item 기준, LOT 포함) =====
|
||||
export async function getStockById(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: StockDetail;
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
/**
|
||||
* 재고현황 타입 정의
|
||||
*
|
||||
* Item 모델 기준 (MATERIAL_TYPES: SM, RM, CS)
|
||||
*/
|
||||
|
||||
// 품목유형
|
||||
export type ItemType = 'raw_material' | 'bent_part' | 'purchased_part' | 'sub_material' | 'consumable';
|
||||
// 품목유형 (Item 모델의 MATERIAL_TYPES)
|
||||
export type ItemType = 'RM' | 'SM' | 'CS';
|
||||
|
||||
// 품목유형 라벨
|
||||
export const ITEM_TYPE_LABELS: Record<ItemType, string> = {
|
||||
raw_material: '원자재',
|
||||
bent_part: '절곡부품',
|
||||
purchased_part: '구매부품',
|
||||
sub_material: '부자재',
|
||||
consumable: '소모품',
|
||||
RM: '원자재',
|
||||
SM: '부자재',
|
||||
CS: '소모품',
|
||||
};
|
||||
|
||||
// 품목유형 스타일 (뱃지용)
|
||||
export const ITEM_TYPE_STYLES: Record<ItemType, string> = {
|
||||
raw_material: 'bg-blue-100 text-blue-800',
|
||||
bent_part: 'bg-purple-100 text-purple-800',
|
||||
purchased_part: 'bg-gray-100 text-gray-800',
|
||||
sub_material: 'bg-green-100 text-green-800',
|
||||
consumable: 'bg-orange-100 text-orange-800',
|
||||
RM: 'bg-blue-100 text-blue-800',
|
||||
SM: 'bg-green-100 text-green-800',
|
||||
CS: 'bg-orange-100 text-orange-800',
|
||||
};
|
||||
|
||||
// 재고 상태
|
||||
@@ -42,19 +40,20 @@ export const LOT_STATUS_LABELS: Record<LotStatusType, string> = {
|
||||
used: '사용완료',
|
||||
};
|
||||
|
||||
// 재고 목록 아이템
|
||||
// 재고 목록 아이템 (Item 기준 + Stock 정보)
|
||||
export interface StockItem {
|
||||
id: string;
|
||||
itemCode: string; // 품목코드
|
||||
itemName: string; // 품목명
|
||||
itemType: ItemType; // 품목유형
|
||||
unit: string; // 단위 (EA, M, m² 등)
|
||||
stockQty: number; // 재고량
|
||||
safetyStock: number; // 안전재고
|
||||
lotCount: number; // LOT 개수
|
||||
lotDaysElapsed: number; // 경과일 (가장 오래된 LOT 기준)
|
||||
status: StockStatusType; // 상태
|
||||
location: string; // 위치
|
||||
itemCode: string; // Item.code
|
||||
itemName: string; // Item.name
|
||||
itemType: ItemType; // Item.item_type (RM, SM, CS)
|
||||
unit: string; // Item.unit
|
||||
stockQty: number; // Stock.stock_qty (없으면 0)
|
||||
safetyStock: number; // Stock.safety_stock (없으면 0)
|
||||
lotCount: number; // Stock.lot_count (없으면 0)
|
||||
lotDaysElapsed: number; // Stock.days_elapsed (없으면 0)
|
||||
status: StockStatusType | null; // Stock.status (없으면 null)
|
||||
location: string; // Stock.location (없으면 '-')
|
||||
hasStock: boolean; // Stock 데이터 존재 여부
|
||||
}
|
||||
|
||||
// LOT별 상세 재고
|
||||
@@ -72,24 +71,25 @@ export interface LotDetail {
|
||||
status: LotStatusType; // 상태
|
||||
}
|
||||
|
||||
// 재고 상세 정보
|
||||
// 재고 상세 정보 (Item 기준)
|
||||
export interface StockDetail {
|
||||
// 기본 정보
|
||||
// 기본 정보 (Item)
|
||||
id: string;
|
||||
itemCode: string; // 품목코드
|
||||
itemName: string; // 품목명
|
||||
itemType: ItemType; // 품목유형
|
||||
category: string; // 카테고리
|
||||
specification: string; // 규격
|
||||
unit: string; // 단위
|
||||
itemCode: string; // Item.code
|
||||
itemName: string; // Item.name
|
||||
itemType: ItemType; // Item.item_type (RM, SM, CS)
|
||||
category: string; // Item.category?.name
|
||||
specification: string; // Item.attributes 또는 description
|
||||
unit: string; // Item.unit
|
||||
|
||||
// 재고 현황
|
||||
currentStock: number; // 현재 재고량
|
||||
safetyStock: number; // 안전 재고
|
||||
location: string; // 재고 위치
|
||||
lotCount: number; // LOT 개수
|
||||
lastReceiptDate: string; // 최근 입고일
|
||||
status: StockStatusType; // 재고 상태
|
||||
// 재고 현황 (Stock - 없으면 기본값)
|
||||
currentStock: number; // Stock.stock_qty
|
||||
safetyStock: number; // Stock.safety_stock
|
||||
location: string; // Stock.location
|
||||
lotCount: number; // Stock.lot_count
|
||||
lastReceiptDate: string; // Stock.last_receipt_date
|
||||
status: StockStatusType | null; // Stock.status
|
||||
hasStock: boolean; // Stock 데이터 존재 여부
|
||||
|
||||
// LOT별 상세 재고
|
||||
lots: LotDetail[];
|
||||
@@ -97,10 +97,11 @@ export interface StockDetail {
|
||||
|
||||
// 통계 데이터
|
||||
export interface StockStats {
|
||||
totalItems: number; // 전체 품목 수
|
||||
totalItems: number; // 전체 자재 품목 수 (Item 기준)
|
||||
normalCount: number; // 정상 재고 수
|
||||
lowCount: number; // 재고 부족 수
|
||||
outCount: number; // 재고 없음 수
|
||||
noStockCount: number; // 재고 정보 없는 품목 수
|
||||
}
|
||||
|
||||
// 필터 탭
|
||||
@@ -108,4 +109,4 @@ export interface FilterTab {
|
||||
key: 'all' | ItemType;
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user