feat(WEB): 부실채권, 재고, 입고, 수주 UI 개선

- BadDebtCollection 액션/타입 리팩토링
- ReceivingProcessDialog 입고처리 개선
- StockStatusList 재고현황 UI 개선
- OrderSalesDetailView 수주 상세 수정
- UniversalListPage 범용 리스트 개선
- production-order 페이지 수정
This commit is contained in:
2026-01-23 21:32:24 +09:00
parent 9fb5c171eb
commit a0343eec93
12 changed files with 315 additions and 251 deletions

View File

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

View File

@@ -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; // 비고
}
// 통계 데이터

View File

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

View File

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

View File

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