feat(WEB): 부실채권, 재고, 입고, 수주 UI 개선
- BadDebtCollection 액션/타입 리팩토링 - ReceivingProcessDialog 입고처리 개선 - StockStatusList 재고현황 UI 개선 - OrderSalesDetailView 수주 상세 수정 - UniversalListPage 범용 리스트 개선 - production-order 페이지 수정
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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: '',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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[];
|
||||
// 메모
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 || '데이터를 불러오는데 실패했습니다.');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user