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

View File

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

View File

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

View File

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

View File

@@ -3,8 +3,8 @@
/** /**
* 입고처리 다이얼로그 * 입고처리 다이얼로그
* - 발주 정보 표시 * - 발주 정보 표시
* - 입고LOT*, 공급업체LOT, 입고수량*, 입고위치* 입력 * - 입고LOT*, 공급업체LOT, 입고수량* 입력 (필수)
* - 비고 입력 * - 입고위치, 비고 입력 (선택)
*/ */
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
@@ -64,13 +64,11 @@ export function ReceivingProcessDialog({ open, onOpenChange, detail, onComplete
errors.push('입고수량은 필수 입력 항목입니다. 유효한 숫자를 입력해주세요.'); errors.push('입고수량은 필수 입력 항목입니다. 유효한 숫자를 입력해주세요.');
} }
if (!receivingLocation.trim()) { // 입고위치는 선택 항목 (필수 검사 제거)
errors.push('입고위치는 필수 입력 항목입니다.');
}
setValidationErrors(errors); setValidationErrors(errors);
return errors.length === 0; return errors.length === 0;
}, [receivingLot, receivingQty, receivingLocation]); }, [receivingLot, receivingQty]);
// 입고 처리 // 입고 처리
const handleSubmit = useCallback(async () => { const handleSubmit = useCallback(async () => {
@@ -84,7 +82,7 @@ export function ReceivingProcessDialog({ open, onOpenChange, detail, onComplete
receivingQty: Number(receivingQty), receivingQty: Number(receivingQty),
receivingLot, receivingLot,
supplierLot: supplierLot || undefined, supplierLot: supplierLot || undefined,
receivingLocation, receivingLocation: receivingLocation || undefined,
remark: remark || undefined, remark: remark || undefined,
}; };
@@ -197,15 +195,10 @@ export function ReceivingProcessDialog({ open, onOpenChange, detail, onComplete
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm"> <Label className="text-sm text-muted-foreground"></Label>
<span className="text-red-500">*</span>
</Label>
<Input <Input
value={receivingLocation} value={receivingLocation}
onChange={(e) => { onChange={(e) => setReceivingLocation(e.target.value)}
setReceivingLocation(e.target.value);
setValidationErrors([]);
}}
placeholder="예: A-01" placeholder="예: A-01"
/> />
</div> </div>

View File

@@ -94,11 +94,11 @@ export interface InspectionFormData {
// 입고처리 폼 데이터 // 입고처리 폼 데이터
export interface ReceivingProcessFormData { export interface ReceivingProcessFormData {
receivingLot: string; // 입고LOT * receivingLot: string; // 입고LOT *
supplierLot?: string; // 공급업체LOT supplierLot?: string; // 공급업체LOT
receivingQty: number; // 입고수량 * receivingQty: number; // 입고수량 *
receivingLocation: string; // 입고위치 * receivingLocation?: string; // 입고위치 (선택)
remark?: string; // 비고 remark?: string; // 비고
} }
// 통계 데이터 // 통계 데이터

View File

@@ -119,14 +119,12 @@ export function StockStatusList() {
// ===== 탭 옵션 (기본 탭 + 품목유형별 통계) ===== // ===== 탭 옵션 (기본 탭 + 품목유형별 통계) =====
const tabs: TabOption[] = useMemo(() => { const tabs: TabOption[] = useMemo(() => {
// 기본 탭 정의 (API 데이터 없어도 항상 표시) // 기본 탭 정의 (Item 모델의 MATERIAL_TYPES: RM, SM, CS)
const defaultTabs: { value: string; label: string }[] = [ const defaultTabs: { value: string; label: string }[] = [
{ value: 'all', label: '전체' }, { value: 'all', label: '전체' },
{ value: 'raw_material', label: '원자재' }, { value: 'RM', label: '원자재' },
{ value: 'bent_part', label: '절곡부품' }, { value: 'SM', label: '부자재' },
{ value: 'purchased_part', label: '구매부품' }, { value: 'CS', label: '소모품' },
{ value: 'sub_material', label: '부자재' },
{ value: 'consumable', label: '소모품' },
]; ];
return defaultTabs.map((tab) => { return defaultTabs.map((tab) => {
@@ -287,9 +285,13 @@ export function StockStatusList() {
</div> </div>
</TableCell> </TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
<span className={item.status === 'low' ? 'text-orange-600 font-medium' : ''}> {item.status ? (
{STOCK_STATUS_LABELS[item.status]} <span className={item.status === 'low' ? 'text-orange-600 font-medium' : ''}>
</span> {STOCK_STATUS_LABELS[item.status]}
</span>
) : (
<span className="text-gray-400">-</span>
)}
</TableCell> </TableCell>
<TableCell className="text-center">{item.location}</TableCell> <TableCell className="text-center">{item.location}</TableCell>
</TableRow> </TableRow>
@@ -334,7 +336,7 @@ export function StockStatusList() {
/> />
<InfoField <InfoField
label="상태" label="상태"
value={STOCK_STATUS_LABELS[item.status]} value={item.status ? STOCK_STATUS_LABELS[item.status] : '-'}
className={item.status === 'low' ? 'text-orange-600' : ''} className={item.status === 'low' ? 'text-orange-600' : ''}
/> />
</div> </div>

View File

@@ -1,11 +1,11 @@
/** /**
* 재고 현황 서버 액션 * 재고 현황 서버 액션
* *
* API Endpoints: * API Endpoints (Item 기준):
* - GET /api/v1/stocks - 목록 조회 * - GET /api/v1/stocks - 목록 조회 (Item + Stock LEFT JOIN)
* - GET /api/v1/stocks/stats - 통계 조회 * - GET /api/v1/stocks/stats - 통계 조회
* - GET /api/v1/stocks/stats-by-type - 품목유형별 통계 조회 * - GET /api/v1/stocks/stats-by-type - 품목유형별 통계 조회
* - GET /api/v1/stocks/{id} - 상세 조회 (LOT 포함) * - GET /api/v1/stocks/{id} - 상세 조회 (Item 기준, LOT 포함)
*/ */
'use server'; 'use server';
@@ -23,16 +23,12 @@ import type {
LotStatusType, LotStatusType,
} from './types'; } from './types';
// ===== API 데이터 타입 ===== // ===== API 데이터 타입 (Item 기준) =====
interface StockApiData {
// Stock 관계 데이터
interface StockRelationData {
id: number; id: number;
tenant_id: number; item_id: number;
item_code: string;
item_name: string;
item_type: ItemType;
item_type_label?: string;
specification?: string;
unit: string;
stock_qty: string | number; stock_qty: string | number;
safety_stock: string | number; safety_stock: string | number;
reserved_qty: string | number; reserved_qty: string | number;
@@ -42,12 +38,30 @@ interface StockApiData {
days_elapsed?: number; days_elapsed?: number;
location?: string; location?: string;
status: StockStatusType; status: StockStatusType;
status_label?: string;
last_receipt_date?: string; last_receipt_date?: string;
last_issue_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; created_at?: string;
updated_at?: string; updated_at?: string;
lots?: StockLotApiData[]; stock?: StockRelationData | null; // Stock 관계 (없으면 null)
} }
interface StockLotApiData { interface StockLotApiData {
@@ -71,8 +85,8 @@ interface StockLotApiData {
updated_at?: string; updated_at?: string;
} }
interface StockApiPaginatedResponse { interface ItemApiPaginatedResponse {
data: StockApiData[]; data: ItemApiData[];
current_page: number; current_page: number;
last_page: number; last_page: number;
per_page: number; per_page: number;
@@ -84,6 +98,7 @@ interface StockApiStatsResponse {
normal_count: number; normal_count: number;
low_count: number; low_count: number;
out_count: number; out_count: number;
no_stock_count: number;
} }
interface StockApiStatsByTypeResponse { interface StockApiStatsByTypeResponse {
@@ -95,19 +110,23 @@ interface StockApiStatsByTypeResponse {
} }
// ===== API → Frontend 변환 (목록용) ===== // ===== API → Frontend 변환 (목록용) =====
function transformApiToListItem(data: StockApiData): StockItem { function transformApiToListItem(data: ItemApiData): StockItem {
const stock = data.stock;
const hasStock = !!stock;
return { return {
id: String(data.id), id: String(data.id),
itemCode: data.item_code, itemCode: data.code,
itemName: data.item_name, itemName: data.name,
itemType: data.item_type, itemType: data.item_type,
unit: data.unit || 'EA', unit: data.unit || 'EA',
stockQty: parseFloat(String(data.stock_qty)) || 0, stockQty: hasStock ? (parseFloat(String(stock.stock_qty)) || 0) : 0,
safetyStock: parseFloat(String(data.safety_stock)) || 0, safetyStock: hasStock ? (parseFloat(String(stock.safety_stock)) || 0) : 0,
lotCount: data.lot_count || 0, lotCount: hasStock ? (stock.lot_count || 0) : 0,
lotDaysElapsed: data.days_elapsed || 0, lotDaysElapsed: hasStock ? (stock.days_elapsed || 0) : 0,
status: data.status, status: hasStock ? stock.status : null,
location: data.location || '-', location: hasStock ? (stock.location || '-') : '-',
hasStock,
}; };
} }
@@ -129,22 +148,38 @@ function transformApiToLot(data: StockLotApiData): LotDetail {
} }
// ===== API → Frontend 변환 (상세용) ===== // ===== 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 { return {
id: String(data.id), id: String(data.id),
itemCode: data.item_code, itemCode: data.code,
itemName: data.item_name, itemName: data.name,
itemType: data.item_type, itemType: data.item_type,
category: '-', // API에서 category 제공 안 함 category: data.category?.name || '-',
specification: data.specification || '-', specification,
unit: data.unit || 'EA', unit: data.unit || 'EA',
currentStock: parseFloat(String(data.stock_qty)) || 0, currentStock: hasStock ? (parseFloat(String(stock.stock_qty)) || 0) : 0,
safetyStock: parseFloat(String(data.safety_stock)) || 0, safetyStock: hasStock ? (parseFloat(String(stock.safety_stock)) || 0) : 0,
location: data.location || '-', location: hasStock ? (stock.location || '-') : '-',
lotCount: data.lot_count || 0, lotCount: hasStock ? (stock.lot_count || 0) : 0,
lastReceiptDate: data.last_receipt_date || '-', lastReceiptDate: hasStock ? (stock.last_receipt_date || '-') : '-',
status: data.status, status: hasStock ? stock.status : null,
lots: (data.lots || []).map(transformApiToLot), hasStock,
lots: hasStock && stock.lots ? stock.lots.map(transformApiToLot) : [],
}; };
} }
@@ -155,6 +190,7 @@ function transformApiToStats(data: StockApiStatsResponse): StockStats {
normalCount: data.normal_count, normalCount: data.normal_count,
lowCount: data.low_count, lowCount: data.low_count,
outCount: data.out_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: [], data: [],
current_page: 1, current_page: 1,
last_page: 1, last_page: 1,
@@ -340,7 +376,7 @@ export async function getStockStatsByType(): Promise<{
} }
} }
// ===== 재고 상세 조회 (LOT 포함) ===== // ===== 재고 상세 조회 (Item 기준, LOT 포함) =====
export async function getStockById(id: string): Promise<{ export async function getStockById(id: string): Promise<{
success: boolean; success: boolean;
data?: StockDetail; data?: StockDetail;

View File

@@ -1,26 +1,24 @@
/** /**
* 재고현황 타입 정의 * 재고현황 타입 정의
*
* Item 모델 기준 (MATERIAL_TYPES: SM, RM, CS)
*/ */
// 품목유형 // 품목유형 (Item 모델의 MATERIAL_TYPES)
export type ItemType = 'raw_material' | 'bent_part' | 'purchased_part' | 'sub_material' | 'consumable'; export type ItemType = 'RM' | 'SM' | 'CS';
// 품목유형 라벨 // 품목유형 라벨
export const ITEM_TYPE_LABELS: Record<ItemType, string> = { export const ITEM_TYPE_LABELS: Record<ItemType, string> = {
raw_material: '원자재', RM: '원자재',
bent_part: '절곡부품', SM: '부자재',
purchased_part: '구매부품', CS: '소모품',
sub_material: '부자재',
consumable: '소모품',
}; };
// 품목유형 스타일 (뱃지용) // 품목유형 스타일 (뱃지용)
export const ITEM_TYPE_STYLES: Record<ItemType, string> = { export const ITEM_TYPE_STYLES: Record<ItemType, string> = {
raw_material: 'bg-blue-100 text-blue-800', RM: 'bg-blue-100 text-blue-800',
bent_part: 'bg-purple-100 text-purple-800', SM: 'bg-green-100 text-green-800',
purchased_part: 'bg-gray-100 text-gray-800', CS: 'bg-orange-100 text-orange-800',
sub_material: 'bg-green-100 text-green-800',
consumable: 'bg-orange-100 text-orange-800',
}; };
// 재고 상태 // 재고 상태
@@ -42,19 +40,20 @@ export const LOT_STATUS_LABELS: Record<LotStatusType, string> = {
used: '사용완료', used: '사용완료',
}; };
// 재고 목록 아이템 // 재고 목록 아이템 (Item 기준 + Stock 정보)
export interface StockItem { export interface StockItem {
id: string; id: string;
itemCode: string; // 품목코드 itemCode: string; // Item.code
itemName: string; // 품목명 itemName: string; // Item.name
itemType: ItemType; // 품목유형 itemType: ItemType; // Item.item_type (RM, SM, CS)
unit: string; // 단위 (EA, M, m² 등) unit: string; // Item.unit
stockQty: number; // 재고량 stockQty: number; // Stock.stock_qty (없으면 0)
safetyStock: number; // 안전재고 safetyStock: number; // Stock.safety_stock (없으면 0)
lotCount: number; // LOT 개수 lotCount: number; // Stock.lot_count (없으면 0)
lotDaysElapsed: number; // 경과일 (가장 오래된 LOT 기준) lotDaysElapsed: number; // Stock.days_elapsed (없으면 0)
status: StockStatusType; // 상태 status: StockStatusType | null; // Stock.status (없으면 null)
location: string; // 위치 location: string; // Stock.location (없으면 '-')
hasStock: boolean; // Stock 데이터 존재 여부
} }
// LOT별 상세 재고 // LOT별 상세 재고
@@ -72,24 +71,25 @@ export interface LotDetail {
status: LotStatusType; // 상태 status: LotStatusType; // 상태
} }
// 재고 상세 정보 // 재고 상세 정보 (Item 기준)
export interface StockDetail { export interface StockDetail {
// 기본 정보 // 기본 정보 (Item)
id: string; id: string;
itemCode: string; // 품목코드 itemCode: string; // Item.code
itemName: string; // 품목명 itemName: string; // Item.name
itemType: ItemType; // 품목유형 itemType: ItemType; // Item.item_type (RM, SM, CS)
category: string; // 카테고리 category: string; // Item.category?.name
specification: string; // 규격 specification: string; // Item.attributes 또는 description
unit: string; // 단위 unit: string; // Item.unit
// 재고 현황 // 재고 현황 (Stock - 없으면 기본값)
currentStock: number; // 현재 재고량 currentStock: number; // Stock.stock_qty
safetyStock: number; // 안전 재고 safetyStock: number; // Stock.safety_stock
location: string; // 재고 위치 location: string; // Stock.location
lotCount: number; // LOT 개수 lotCount: number; // Stock.lot_count
lastReceiptDate: string; // 최근 입고일 lastReceiptDate: string; // Stock.last_receipt_date
status: StockStatusType; // 재고 상태 status: StockStatusType | null; // Stock.status
hasStock: boolean; // Stock 데이터 존재 여부
// LOT별 상세 재고 // LOT별 상세 재고
lots: LotDetail[]; lots: LotDetail[];
@@ -97,10 +97,11 @@ export interface StockDetail {
// 통계 데이터 // 통계 데이터
export interface StockStats { export interface StockStats {
totalItems: number; // 전체 품목 수 totalItems: number; // 전체 자재 품목 수 (Item 기준)
normalCount: number; // 정상 재고 수 normalCount: number; // 정상 재고 수
lowCount: number; // 재고 부족 수 lowCount: number; // 재고 부족 수
outCount: number; // 재고 없음 수 outCount: number; // 재고 없음 수
noStockCount: number; // 재고 정보 없는 품목 수
} }
// 필터 탭 // 필터 탭
@@ -108,4 +109,4 @@ export interface FilterTab {
key: 'all' | ItemType; key: 'all' | ItemType;
label: string; label: string;
count: number; count: number;
} }

View File

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

View File

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

View File

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