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

@@ -27,9 +27,9 @@ import {
TableRow,
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Factory, ArrowLeft, BarChart3, CheckCircle2, AlertCircle, User } from "lucide-react";
import { AssigneeSelectModal } from "@/components/production/WorkOrders/AssigneeSelectModal";
import { PageLayout } from "@/components/organisms/PageLayout";
import {
AlertDialog,
@@ -50,8 +50,6 @@ import {
type CreateProductionOrderData,
} from "@/components/orders/actions";
import { getProcessList } from "@/components/process-management/actions";
import { getEmployees } from "@/components/hr/EmployeeManagement/actions";
import type { Employee } from "@/components/hr/EmployeeManagement/types";
import type { Process } from "@/types/process";
import { formatAmount } from "@/utils/formatAmount";
@@ -352,28 +350,28 @@ export default function ProductionOrderCreatePage() {
const [error, setError] = useState<string | null>(null);
const [order, setOrder] = useState<Order | null>(null);
const [processes, setProcesses] = useState<Process[]>([]);
const [employees, setEmployees] = useState<Employee[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
// 우선순위 상태
const [selectedPriority, setSelectedPriority] = useState<PriorityLevel>("normal");
const [selectedAssignee, setSelectedAssignee] = useState<string>("");
const [selectedAssignees, setSelectedAssignees] = useState<string[]>([]);
const [assigneeNames, setAssigneeNames] = useState<string[]>([]);
const [isAssigneeModalOpen, setIsAssigneeModalOpen] = useState(false);
const [memo, setMemo] = useState("");
// 성공 다이얼로그 상태
const [showSuccessDialog, setShowSuccessDialog] = useState(false);
const [generatedWorkOrders, setGeneratedWorkOrders] = useState<Array<{ workOrderNo: string; processName?: string }>>([]);
// 수주 데이터, 공정 목록, 직원 목록 로드
// 수주 데이터, 공정 목록 로드
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
// 수주 정보, 공정 목록, 직원 목록을 병렬로 로드
const [orderResult, processResult, employeeResult] = await Promise.all([
// 수주 정보, 공정 목록을 병렬로 로드
const [orderResult, processResult] = await Promise.all([
getOrderById(orderId),
getProcessList({ status: "사용중" }),
getEmployees({ status: "active", per_page: 100 }),
]);
if (orderResult.success && orderResult.data) {
@@ -385,10 +383,6 @@ export default function ProductionOrderCreatePage() {
if (processResult.success && processResult.data) {
setProcesses(processResult.data.items);
}
if (employeeResult.data) {
setEmployees(employeeResult.data);
}
} catch {
setError("서버 오류가 발생했습니다.");
} finally {
@@ -412,7 +406,7 @@ export default function ProductionOrderCreatePage() {
if (!order) return;
// 담당자 필수 검증
if (!selectedAssignee) {
if (selectedAssignees.length === 0) {
setError("담당자를 선택해주세요.");
return;
}
@@ -436,9 +430,14 @@ export default function ProductionOrderCreatePage() {
.map((g) => parseInt(g.process.id, 10))
.filter((id) => !isNaN(id));
// 담당자 ID 배열 변환 (string[] → number[])
const assigneeIds = selectedAssignees
.map(id => parseInt(id, 10))
.filter(id => !isNaN(id));
const productionData: CreateProductionOrderData = {
priority: selectedPriority,
assigneeId: parseInt(selectedAssignee, 10),
assigneeIds: assigneeIds.length > 0 ? assigneeIds : undefined,
memo: memo || undefined,
processIds: allProcessIds.length > 0 ? allProcessIds : undefined,
};
@@ -726,27 +725,26 @@ export default function ProductionOrderCreatePage() {
)}
</div>
{/* 담당자 선택 */}
{/* 담당자 선택 (다중 선택) */}
<div>
<Label className="text-sm font-medium flex items-center gap-2 mb-2">
<User className="h-4 w-4" />
<span className="text-red-500">*</span>
( ) <span className="text-red-500">*</span>
</Label>
<Select value={selectedAssignee} onValueChange={setSelectedAssignee}>
<SelectTrigger className={cn("w-full md:w-[300px]", !selectedAssignee && "border-red-300")}>
<SelectValue placeholder="담당자를 선택하세요" />
</SelectTrigger>
<SelectContent>
{employees
.filter((emp) => emp.userId) // 시스템 계정이 있는 직원만
.map((emp) => (
<SelectItem key={emp.id} value={String(emp.userId)}>
{emp.name}
</SelectItem>
))}
</SelectContent>
</Select>
{!selectedAssignee && (
<div
onClick={() => setIsAssigneeModalOpen(true)}
className={cn(
"flex min-h-10 w-full md:w-[400px] cursor-pointer items-center rounded-md border bg-white px-3 py-2 text-sm ring-offset-background hover:bg-accent/50",
selectedAssignees.length === 0 ? "border-red-300" : "border-input"
)}
>
{assigneeNames.length > 0 ? (
<span>{assigneeNames.join(', ')}</span>
) : (
<span className="text-muted-foreground"> (/)</span>
)}
</div>
{selectedAssignees.length === 0 && (
<p className="text-xs text-red-500 mt-1"> .</p>
)}
</div>
@@ -1024,6 +1022,17 @@ export default function ProductionOrderCreatePage() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 담당자 선택 모달 */}
<AssigneeSelectModal
open={isAssigneeModalOpen}
onOpenChange={setIsAssigneeModalOpen}
selectedIds={selectedAssignees}
onSelect={(ids, names) => {
setSelectedAssignees(ids);
setAssigneeNames(names);
}}
/>
</PageLayout>
);
}

View File

@@ -494,7 +494,7 @@ export default function OrderManagementSalesPage() {
<TableCell>{order.siteName}</TableCell>
<TableCell>{getOrderStatusBadge(order.status)}</TableCell>
<TableCell>{order.expectedShipDate || "-"}</TableCell>
<TableCell>{order.deliveryMethod || "-"}</TableCell>
<TableCell>{order.deliveryMethodLabel || "-"}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
{isSelected && (
<div className="flex gap-1">
@@ -564,7 +564,7 @@ export default function OrderManagementSalesPage() {
<InfoField label="현장명" value={order.siteName} />
<InfoField label="견적번호" value={order.quoteNumber} />
<InfoField label="출고예정일" value={order.expectedShipDate || "-"} />
<InfoField label="배송방식" value={order.deliveryMethod || "-"} />
<InfoField label="배송방식" value={order.deliveryMethodLabel || "-"} />
<InfoField
label="금액"
value={formatAmount(order.amount)}

View File

@@ -17,7 +17,7 @@
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { revalidatePath } from 'next/cache';
import { serverFetch } from '@/lib/api/fetch-wrapper';
import type { BadDebtRecord, CollectionStatus } from './types';
import type { BadDebtRecord, BadDebtItem, CollectionStatus } from './types';
// ============================================
// API 응답 타입 정의
@@ -37,69 +37,47 @@ interface PaginatedResponse<T> {
last_page: number;
}
// API 악성채권 데이터 타입
interface BadDebtApiData {
// API 개별 악성채권 타입
interface BadDebtItemApiData {
id: number;
tenant_id: number;
client_id: number;
debt_amount: string;
debt_amount: number;
status: 'collecting' | 'legal_action' | 'recovered' | 'bad_debt';
overdue_days: number;
occurred_at: string;
closed_at: string | null;
assigned_manager_id: number | null;
is_active: boolean;
note: string | null;
created_at: string;
updated_at: string;
deleted_at: string | null;
client?: {
id: number;
code: string;
name: string;
business_number: string | null;
representative_name: string | null;
client_type: string | null;
business_type: string | null;
business_category: string | null;
zip_code: string | null;
address1: string | null;
address2: string | null;
phone: string | null;
mobile: string | null;
fax: string | null;
email: string | null;
contact_name: string | null;
contact_phone: string | null;
};
assigned_manager?: {
occurred_at: string | null;
assigned_user?: {
id: number;
name: string;
department?: {
id: number;
name: string;
};
position: string | null;
phone: string | null;
};
documents?: Array<{
} | null;
}
// API 악성채권 데이터 타입 (거래처 기준)
interface BadDebtApiData {
id: number;
client_id: number;
client_code: string;
client_name: string;
business_no: string | null;
contact_person: string | null;
phone: string | null;
mobile: string | null;
email: string | null;
address: string | null;
client_type: string | null;
// 집계 데이터
total_debt_amount: number;
max_overdue_days: number;
bad_debt_count: number;
// 대표 상태
status: 'collecting' | 'legal_action' | 'recovered' | 'bad_debt';
is_active: boolean;
// 담당자
assigned_user?: {
id: number;
file_name: string;
file_path: string;
file_size: number;
mime_type: string;
created_at: string;
}>;
memos?: Array<{
id: number;
content: string;
created_at: string;
created_by: number;
created_by_user?: {
id: number;
name: string;
};
}>;
name: string;
} | null;
// 개별 악성채권 목록
bad_debts: BadDebtItemApiData[];
}
// 통계 API 응답 타입
@@ -165,61 +143,66 @@ function mapClientTypeToVendorType(clientType?: string | null): 'sales' | 'purch
}
/**
* API 데이터 → 프론트엔드 타입 변환
* API 데이터 → 프론트엔드 타입 변환 (거래처 기준)
*/
function transformApiToFrontend(apiData: BadDebtApiData): BadDebtRecord {
const client = apiData.client;
const manager = apiData.assigned_manager;
const manager = apiData.assigned_user;
const firstBadDebt = apiData.bad_debts?.[0];
return {
id: String(apiData.id),
id: String(apiData.id), // Client ID
vendorId: String(apiData.client_id),
vendorCode: client?.code || '',
vendorName: client?.name || '거래처 없음',
businessNumber: client?.business_number || '',
representativeName: client?.representative_name || '',
vendorType: mapClientTypeToVendorType(client?.client_type),
businessType: client?.business_type || '',
businessCategory: client?.business_category || '',
zipCode: client?.zip_code || '',
address1: client?.address1 || '',
address2: client?.address2 || '',
phone: client?.phone || '',
mobile: client?.mobile || '',
fax: client?.fax || '',
email: client?.email || '',
contactName: client?.contact_name || '',
contactPhone: client?.contact_phone || '',
vendorCode: apiData.client_code || '',
vendorName: apiData.client_name || '거래처 없음',
businessNumber: apiData.business_no || '',
representativeName: '',
vendorType: mapClientTypeToVendorType(apiData.client_type),
businessType: '',
businessCategory: '',
zipCode: '',
address1: apiData.address || '',
address2: '',
phone: apiData.phone || '',
mobile: apiData.mobile || '',
fax: '',
email: apiData.email || '',
contactName: apiData.contact_person || '',
contactPhone: '',
systemManager: '',
debtAmount: parseFloat(apiData.debt_amount) || 0,
// 집계 데이터
debtAmount: apiData.total_debt_amount || 0,
badDebtCount: apiData.bad_debt_count || 0,
status: mapApiStatusToFrontend(apiData.status),
overdueDays: apiData.overdue_days || 0,
overdueDays: apiData.max_overdue_days || 0,
overdueToggle: apiData.is_active,
occurrenceDate: apiData.occurred_at,
endDate: apiData.closed_at,
assignedManagerId: apiData.assigned_manager_id ? String(apiData.assigned_manager_id) : null,
occurrenceDate: firstBadDebt?.occurred_at || '',
endDate: null,
assignedManagerId: manager ? String(manager.id) : null,
assignedManager: manager ? {
id: String(manager.id),
departmentName: manager.department?.name || '',
departmentName: '',
name: manager.name,
position: manager.position || '',
phone: manager.phone || '',
position: '',
phone: '',
} : null,
settingToggle: apiData.is_active,
files: apiData.documents?.map(doc => ({
id: String(doc.id),
name: doc.file_name,
url: doc.file_path,
type: 'additional' as const,
})) || [],
memos: apiData.memos?.map(memo => ({
id: String(memo.id),
content: memo.content,
createdAt: memo.created_at,
createdBy: memo.created_by_user?.name || `User ${memo.created_by}`,
})) || [],
createdAt: apiData.created_at,
updatedAt: apiData.updated_at,
// 개별 악성채권 목록
badDebts: (apiData.bad_debts || []).map(bd => ({
id: String(bd.id),
debtAmount: bd.debt_amount || 0,
status: mapApiStatusToFrontend(bd.status),
overdueDays: bd.overdue_days || 0,
isActive: bd.is_active,
occurredAt: bd.occurred_at,
assignedManager: bd.assigned_user ? {
id: String(bd.assigned_user.id),
name: bd.assigned_user.name,
} : null,
})),
files: [],
memos: [],
createdAt: '',
updatedAt: '',
};
}

View File

@@ -31,7 +31,18 @@ export interface AttachedFile {
type: 'businessRegistration' | 'taxInvoice' | 'additional';
}
// 악성채권 레코드
// 개별 악성채권 항목 (거래처별 하위 목록)
export interface BadDebtItem {
id: string;
debtAmount: number;
status: CollectionStatus;
overdueDays: number;
isActive: boolean;
occurredAt: string | null;
assignedManager: { id: string; name: string } | null;
}
// 악성채권 레코드 (거래처 기준)
export interface BadDebtRecord {
id: string;
// 거래처 기본 정보
@@ -55,16 +66,19 @@ export interface BadDebtRecord {
contactName: string;
contactPhone: string;
systemManager: string;
// 악성채권 정보
debtAmount: number;
status: CollectionStatus;
overdueDays: number;
// 악성채권 집계 정보 (거래처 기준)
debtAmount: number; // 총 미수금액
badDebtCount: number; // 악성채권 건수
status: CollectionStatus; // 대표 상태 (가장 최근)
overdueDays: number; // 최대 연체일수
overdueToggle: boolean;
occurrenceDate: string;
endDate: string | null;
assignedManagerId: string | null;
assignedManager: Manager | null;
settingToggle: boolean;
// 개별 악성채권 목록
badDebts: BadDebtItem[];
// 첨부 파일
files: AttachedFile[];
// 메모

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

View File

@@ -350,7 +350,7 @@ export function OrderSalesDetailView({ orderId }: OrderSalesDetailViewProps) {
<InfoItem label="수주일자" value={order.orderDate} />
<InfoItem label="출고예정일" value={order.expectedShipDate || "미정"} />
<InfoItem label="납품요청일" value={order.deliveryRequestDate} />
<InfoItem label="배송방식" value={order.deliveryMethod} />
<InfoItem label="배송방식" value={order.deliveryMethodLabel} />
<InfoItem label="운임비용" value={order.shippingCost} />
<InfoItem label="수신(반장/업체)" value={order.receiver} />
<InfoItem label="수신처 연락처" value={order.receiverContact} />
@@ -485,7 +485,7 @@ export function OrderSalesDetailView({ orderId }: OrderSalesDetailViewProps) {
managerContact: order.contact,
deliveryRequestDate: order.deliveryRequestDate,
expectedShipDate: order.expectedShipDate,
deliveryMethod: order.deliveryMethod,
deliveryMethod: order.deliveryMethodLabel,
address: order.address,
items: order.items,
subtotal: order.subtotal,

View File

@@ -26,6 +26,7 @@ interface ApiOrder {
discount_amount: number;
delivery_date: string | null;
delivery_method_code: string | null;
delivery_method_label?: string; // API에서 조회한 배송방식 라벨
received_at: string | null;
memo: string | null;
remarks: string | null;
@@ -219,6 +220,7 @@ export interface Order {
statusCode: string; // 원본 status_code
expectedShipDate?: string; // delivery_date
deliveryMethod?: string; // delivery_method_code
deliveryMethodLabel?: string; // 배송방식 라벨 (API에서 조회)
amount: number; // total_amount
supplyAmount: number;
taxAmount: number;
@@ -334,6 +336,7 @@ export interface CreateProductionOrderData {
processIds?: number[]; // 공정별 다중 작업지시 생성용
priority?: 'urgent' | 'high' | 'normal' | 'low';
assigneeId?: number;
assigneeIds?: number[]; // 다중 담당자 선택용
teamId?: number;
scheduledDate?: string;
memo?: string;
@@ -444,6 +447,7 @@ function transformApiToFrontend(apiData: ApiOrder): Order {
statusCode: apiData.status_code,
expectedShipDate: apiData.delivery_date ?? undefined,
deliveryMethod: apiData.delivery_method_code ?? undefined,
deliveryMethodLabel: apiData.delivery_method_label ?? apiData.delivery_method_code ?? undefined,
amount: apiData.total_amount,
supplyAmount: apiData.supply_amount,
taxAmount: apiData.tax_amount,
@@ -1046,7 +1050,12 @@ export async function createProductionOrder(
apiData.process_id = data.processId;
}
if (data?.priority) apiData.priority = data.priority;
if (data?.assigneeId) apiData.assignee_id = data.assigneeId;
// 다중 담당자 ID (우선) 또는 단일 담당자 ID
if (data?.assigneeIds && data.assigneeIds.length > 0) {
apiData.assignee_ids = data.assigneeIds;
} else if (data?.assigneeId) {
apiData.assignee_id = data.assigneeId;
}
if (data?.teamId) apiData.team_id = data.teamId;
if (data?.scheduledDate) apiData.scheduled_date = data.scheduledDate;
if (data?.memo) apiData.memo = data.memo;

View File

@@ -71,6 +71,10 @@ export function UniversalListPage<T>({
// 모바일 인피니티 스크롤 로딩 상태 (서버 사이드 페이지네이션 시)
const [isMobileLoading, setIsMobileLoading] = useState(false);
// 서버 사이드 페이지네이션 상태 (API에서 반환하는 값)
const [serverTotalCount, setServerTotalCount] = useState<number>(initialTotalCount || 0);
const [serverTotalPages, setServerTotalPages] = useState<number>(1);
// ===== ID 추출 헬퍼 =====
const getItemId = useCallback(
(item: T): string => {
@@ -125,8 +129,12 @@ export function UniversalListPage<T>({
}, [config.clientSideFiltering, filteredData, currentPage, itemsPerPage, rawData]);
// 총 개수 및 페이지 수
const totalCount = config.clientSideFiltering ? filteredData.length : rawData.length;
const totalPages = Math.ceil(totalCount / itemsPerPage);
// 서버 사이드 페이지네이션: API에서 반환한 값 사용
// 클라이언트 사이드 페이지네이션: 로컬 데이터 길이 사용
const totalCount = config.clientSideFiltering ? filteredData.length : serverTotalCount;
const totalPages = config.clientSideFiltering
? Math.ceil(totalCount / itemsPerPage)
: serverTotalPages;
// 표시할 데이터
const displayData = config.clientSideFiltering ? paginatedData : rawData;
@@ -171,6 +179,15 @@ export function UniversalListPage<T>({
if (result.success && result.data) {
setRawData(result.data);
// 서버 사이드 페이지네이션: API에서 반환한 totalCount, totalPages 저장
if (!config.clientSideFiltering) {
if (typeof result.totalCount === 'number') {
setServerTotalCount(result.totalCount);
}
if (typeof result.totalPages === 'number') {
setServerTotalPages(result.totalPages);
}
}
} else {
toast.error(result.error || '데이터를 불러오는데 실패했습니다.');
}