Compare commits
15 Commits
23fa9c0ea2
...
03d129c32c
| Author | SHA1 | Date | |
|---|---|---|---|
| 03d129c32c | |||
| d6e3131c6a | |||
| 1d3805781c | |||
| b45c35a5e8 | |||
| b05e19e9f8 | |||
| 4331b84a63 | |||
| 0b81e9c1dd | |||
| f653960a30 | |||
| 888fae119f | |||
| f503e20030 | |||
| 0166601be8 | |||
| 83a23701a7 | |||
| bedfd1f559 | |||
| 8bcabafd08 | |||
| 5ff5093d7b |
@@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbo",
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"build:restart": "lsof -ti:3000 | xargs kill 2>/dev/null; next build && next start &",
|
||||
"start": "next start -H 0.0.0.0",
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ShipmentDetail } from '@/components/outbound/ShipmentManagement/ty
|
||||
export const MOCK_WORK_ORDER: WorkOrder = {
|
||||
id: 'wo-1',
|
||||
orderNo: 'KD-WO-240924-01',
|
||||
productCode: 'WY-SC780',
|
||||
productName: '스크린 셔터 (표준형)',
|
||||
processCode: 'screen',
|
||||
processName: 'screen',
|
||||
|
||||
@@ -328,7 +328,7 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
<CardTitle className="text-base">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
{renderInfoField('로트번호', detail.lotNo)}
|
||||
{renderInfoField('현장명', detail.siteName)}
|
||||
{renderInfoField('수주처', detail.customerName)}
|
||||
|
||||
@@ -391,7 +391,7 @@ export function ShipmentEdit({ id }: ShipmentEditProps) {
|
||||
<CardTitle className="text-base">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground">로트번호</Label>
|
||||
<div className="font-medium">{detail.lotNo}</div>
|
||||
|
||||
@@ -71,7 +71,7 @@ export function ShipmentList() {
|
||||
|
||||
// ===== 캘린더 상태 =====
|
||||
const [calendarDate, setCalendarDate] = useState(new Date());
|
||||
const [scheduleView, setScheduleView] = useState<CalendarView>('day-time');
|
||||
const [scheduleView, setScheduleView] = useState<CalendarView>('week-time');
|
||||
const [shipmentData, setShipmentData] = useState<ShipmentItem[]>([]);
|
||||
|
||||
// startDate 변경 시 캘린더 월 자동 이동
|
||||
|
||||
@@ -50,6 +50,9 @@ interface OrderInfoApiData {
|
||||
site_name?: string;
|
||||
delivery_address?: string;
|
||||
contact?: string;
|
||||
delivery_date?: string;
|
||||
writer_id?: number;
|
||||
writer_name?: string;
|
||||
}
|
||||
|
||||
interface ShipmentApiData {
|
||||
@@ -91,6 +94,16 @@ interface ShipmentApiData {
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
items?: ShipmentItemApiData[];
|
||||
vehicle_dispatches?: Array<{
|
||||
id: number;
|
||||
seq: number;
|
||||
logistics_company?: string;
|
||||
arrival_datetime?: string;
|
||||
tonnage?: string;
|
||||
vehicle_no?: string;
|
||||
driver_contact?: string;
|
||||
remarks?: string;
|
||||
}>;
|
||||
status_label?: string;
|
||||
priority_label?: string;
|
||||
delivery_method_label?: string;
|
||||
@@ -146,7 +159,13 @@ function transformApiToListItem(data: ShipmentApiData): ShipmentItem {
|
||||
canShip: data.can_ship,
|
||||
depositConfirmed: data.deposit_confirmed,
|
||||
invoiceIssued: data.invoice_issued,
|
||||
deliveryTime: data.expected_arrival,
|
||||
deliveryTime: data.vehicle_dispatches?.[0]?.arrival_datetime || data.expected_arrival,
|
||||
// 수신/작성자/출고일 매핑
|
||||
receiver: data.receiver || '',
|
||||
receiverAddress: data.order_info?.delivery_address || data.delivery_address || '',
|
||||
receiverCompany: data.order_info?.customer_name || data.customer_name || '',
|
||||
writer: data.order_info?.writer_name || data.creator?.name || '',
|
||||
shipmentDate: data.scheduled_date || '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -193,18 +212,28 @@ function transformApiToDetail(data: ShipmentApiData): ShipmentDetail {
|
||||
zipCode: (data as unknown as Record<string, unknown>).zip_code as string | undefined,
|
||||
address: (data as unknown as Record<string, unknown>).address as string | undefined,
|
||||
addressDetail: (data as unknown as Record<string, unknown>).address_detail as string | undefined,
|
||||
// 배차 정보 - 기존 단일 필드에서 구성 (다중 행 API 준비 전까지)
|
||||
vehicleDispatches: data.vehicle_no || data.logistics_company || data.driver_contact
|
||||
? [{
|
||||
id: `vd-${data.id}`,
|
||||
logisticsCompany: data.logistics_company || '-',
|
||||
arrivalDateTime: data.confirmed_arrival || data.expected_arrival || '-',
|
||||
tonnage: data.vehicle_tonnage || '-',
|
||||
vehicleNo: data.vehicle_no || '-',
|
||||
driverContact: data.driver_contact || '-',
|
||||
remarks: '',
|
||||
}]
|
||||
: [],
|
||||
// 배차 정보 - vehicle_dispatches 테이블에서 조회, 없으면 레거시 단일 필드 fallback
|
||||
vehicleDispatches: data.vehicle_dispatches && data.vehicle_dispatches.length > 0
|
||||
? data.vehicle_dispatches.map((vd) => ({
|
||||
id: String(vd.id),
|
||||
logisticsCompany: vd.logistics_company || '-',
|
||||
arrivalDateTime: vd.arrival_datetime || '-',
|
||||
tonnage: vd.tonnage || '-',
|
||||
vehicleNo: vd.vehicle_no || '-',
|
||||
driverContact: vd.driver_contact || '-',
|
||||
remarks: vd.remarks || '',
|
||||
}))
|
||||
: (data.vehicle_no || data.logistics_company || data.driver_contact
|
||||
? [{
|
||||
id: `vd-legacy-${data.id}`,
|
||||
logisticsCompany: data.logistics_company || '-',
|
||||
arrivalDateTime: data.confirmed_arrival || data.expected_arrival || '-',
|
||||
tonnage: data.vehicle_tonnage || '-',
|
||||
vehicleNo: data.vehicle_no || '-',
|
||||
driverContact: data.driver_contact || '-',
|
||||
remarks: '',
|
||||
}]
|
||||
: []),
|
||||
// 제품내용 (그룹핑) - 프론트엔드에서 그룹핑 처리
|
||||
productGroups: [],
|
||||
otherParts: [],
|
||||
@@ -256,7 +285,7 @@ function transformApiToStatsByStatus(data: ShipmentApiStatsByStatusResponse): Sh
|
||||
function transformCreateFormToApi(
|
||||
data: ShipmentCreateFormData
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
const result: Record<string, unknown> = {
|
||||
lot_no: data.lotNo,
|
||||
scheduled_date: data.scheduledDate,
|
||||
priority: data.priority,
|
||||
@@ -267,6 +296,20 @@ function transformCreateFormToApi(
|
||||
loading_manager: data.loadingManager,
|
||||
remarks: data.remarks,
|
||||
};
|
||||
|
||||
if (data.vehicleDispatches && data.vehicleDispatches.length > 0) {
|
||||
result.vehicle_dispatches = data.vehicleDispatches.map((vd, idx) => ({
|
||||
seq: idx + 1,
|
||||
logistics_company: vd.logisticsCompany || null,
|
||||
arrival_datetime: vd.arrivalDateTime || null,
|
||||
tonnage: vd.tonnage || null,
|
||||
vehicle_no: vd.vehicleNo || null,
|
||||
driver_contact: vd.driverContact || null,
|
||||
remarks: vd.remarks || null,
|
||||
}));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ===== Frontend → API 변환 (수정용) =====
|
||||
@@ -278,6 +321,17 @@ function transformEditFormToApi(
|
||||
if (data.scheduledDate !== undefined) result.scheduled_date = data.scheduledDate;
|
||||
if (data.priority !== undefined) result.priority = data.priority;
|
||||
if (data.deliveryMethod !== undefined) result.delivery_method = data.deliveryMethod;
|
||||
if (data.receiver !== undefined) result.receiver = data.receiver;
|
||||
if (data.receiverContact !== undefined) result.receiver_contact = data.receiverContact;
|
||||
// 주소: zipCode + address + addressDetail → delivery_address로 결합
|
||||
if (data.address !== undefined || data.zipCode !== undefined || data.addressDetail !== undefined) {
|
||||
const parts = [
|
||||
data.zipCode ? `[${data.zipCode}]` : '',
|
||||
data.address || '',
|
||||
data.addressDetail || '',
|
||||
].filter(Boolean);
|
||||
result.delivery_address = parts.join(' ');
|
||||
}
|
||||
if (data.loadingManager !== undefined) result.loading_manager = data.loadingManager;
|
||||
if (data.logisticsCompany !== undefined) result.logistics_company = data.logisticsCompany;
|
||||
if (data.vehicleTonnage !== undefined) result.vehicle_tonnage = data.vehicleTonnage;
|
||||
@@ -287,8 +341,21 @@ function transformEditFormToApi(
|
||||
if (data.driverContact !== undefined) result.driver_contact = data.driverContact;
|
||||
if (data.expectedArrival !== undefined) result.expected_arrival = data.expectedArrival;
|
||||
if (data.confirmedArrival !== undefined) result.confirmed_arrival = data.confirmedArrival;
|
||||
if (data.changeReason !== undefined) result.change_reason = data.changeReason;
|
||||
if (data.remarks !== undefined) result.remarks = data.remarks;
|
||||
|
||||
if (data.vehicleDispatches) {
|
||||
result.vehicle_dispatches = data.vehicleDispatches.map((vd, idx) => ({
|
||||
seq: idx + 1,
|
||||
logistics_company: vd.logisticsCompany || null,
|
||||
arrival_datetime: vd.arrivalDateTime || null,
|
||||
tonnage: vd.tonnage || null,
|
||||
vehicle_no: vd.vehicleNo || null,
|
||||
driver_contact: vd.driverContact || null,
|
||||
remarks: vd.remarks || null,
|
||||
}));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
/**
|
||||
* 배차차량관리 서버 액션
|
||||
*
|
||||
* 현재: Mock 데이터 반환
|
||||
* 추후: API 연동 시 serverFetch 사용
|
||||
*/
|
||||
|
||||
'use server';
|
||||
@@ -13,11 +10,9 @@ import type {
|
||||
VehicleDispatchStats,
|
||||
VehicleDispatchEditFormData,
|
||||
} from './types';
|
||||
import {
|
||||
mockVehicleDispatchItems,
|
||||
mockVehicleDispatchDetail,
|
||||
mockVehicleDispatchStats,
|
||||
} from './mockData';
|
||||
import { buildApiUrl } from '@/lib/api/query-params';
|
||||
import { executeServerAction } from '@/lib/api/execute-server-action';
|
||||
import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
|
||||
|
||||
// ===== 페이지네이션 타입 =====
|
||||
interface PaginationMeta {
|
||||
@@ -27,6 +22,59 @@ interface PaginationMeta {
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ===== API 응답 → 프론트 타입 변환 =====
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function transformToListItem(data: any): VehicleDispatchItem {
|
||||
const options = data.options || {};
|
||||
const shipment = data.shipment || {};
|
||||
return {
|
||||
id: String(data.id),
|
||||
dispatchNo: options.dispatch_no || `DC-${data.id}`,
|
||||
shipmentNo: shipment.shipment_no || '',
|
||||
lotNo: shipment.lot_no || '',
|
||||
siteName: shipment.site_name || '',
|
||||
orderCustomer: shipment.customer_name || '',
|
||||
logisticsCompany: data.logistics_company || '',
|
||||
tonnage: data.tonnage || '',
|
||||
supplyAmount: options.supply_amount || 0,
|
||||
vat: options.vat || 0,
|
||||
totalAmount: options.total_amount || 0,
|
||||
freightCostType: options.freight_cost_type || 'prepaid',
|
||||
vehicleNo: data.vehicle_no || '',
|
||||
driverContact: data.driver_contact || '',
|
||||
writer: options.writer || '',
|
||||
arrivalDateTime: data.arrival_datetime || '',
|
||||
status: options.status || 'draft',
|
||||
remarks: data.remarks || '',
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function transformToDetail(data: any): VehicleDispatchDetail {
|
||||
const options = data.options || {};
|
||||
const shipment = data.shipment || {};
|
||||
return {
|
||||
id: String(data.id),
|
||||
dispatchNo: options.dispatch_no || `DC-${data.id}`,
|
||||
shipmentNo: shipment.shipment_no || '',
|
||||
lotNo: shipment.lot_no || '',
|
||||
siteName: shipment.site_name || '',
|
||||
orderCustomer: shipment.customer_name || '',
|
||||
freightCostType: options.freight_cost_type || 'prepaid',
|
||||
status: options.status || 'draft',
|
||||
writer: options.writer || '',
|
||||
logisticsCompany: data.logistics_company || '',
|
||||
arrivalDateTime: data.arrival_datetime || '',
|
||||
tonnage: data.tonnage || '',
|
||||
vehicleNo: data.vehicle_no || '',
|
||||
driverContact: data.driver_contact || '',
|
||||
remarks: data.remarks || '',
|
||||
supplyAmount: options.supply_amount || 0,
|
||||
vat: options.vat || 0,
|
||||
totalAmount: options.total_amount || 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 배차차량 목록 조회 =====
|
||||
export async function getVehicleDispatches(params?: {
|
||||
page?: number;
|
||||
@@ -41,54 +89,18 @@ export async function getVehicleDispatches(params?: {
|
||||
pagination: PaginationMeta;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
let items = [...mockVehicleDispatchItems];
|
||||
|
||||
// 상태 필터
|
||||
if (params?.status && params.status !== 'all') {
|
||||
items = items.filter((item) => item.status === params.status);
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (params?.search) {
|
||||
const s = params.search.toLowerCase();
|
||||
items = items.filter(
|
||||
(item) =>
|
||||
item.dispatchNo.toLowerCase().includes(s) ||
|
||||
item.shipmentNo.toLowerCase().includes(s) ||
|
||||
item.siteName.toLowerCase().includes(s) ||
|
||||
item.orderCustomer.toLowerCase().includes(s) ||
|
||||
item.vehicleNo.toLowerCase().includes(s)
|
||||
);
|
||||
}
|
||||
|
||||
// 페이지네이션
|
||||
const page = params?.page || 1;
|
||||
const perPage = params?.perPage || 20;
|
||||
const total = items.length;
|
||||
const lastPage = Math.ceil(total / perPage);
|
||||
const startIndex = (page - 1) * perPage;
|
||||
const paginatedItems = items.slice(startIndex, startIndex + perPage);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: paginatedItems,
|
||||
pagination: {
|
||||
currentPage: page,
|
||||
lastPage,
|
||||
perPage,
|
||||
total,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[VehicleDispatchActions] getVehicleDispatches error:', error);
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
return executePaginatedAction({
|
||||
url: buildApiUrl('/api/v1/vehicle-dispatches', {
|
||||
search: params?.search,
|
||||
status: params?.status !== 'all' ? params?.status : undefined,
|
||||
start_date: params?.startDate,
|
||||
end_date: params?.endDate,
|
||||
page: params?.page,
|
||||
per_page: params?.perPage,
|
||||
}),
|
||||
transform: transformToListItem,
|
||||
errorMessage: '배차차량 목록 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 배차차량 통계 조회 =====
|
||||
@@ -97,12 +109,18 @@ export async function getVehicleDispatchStats(): Promise<{
|
||||
data?: VehicleDispatchStats;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
return { success: true, data: mockVehicleDispatchStats };
|
||||
} catch (error) {
|
||||
console.error('[VehicleDispatchActions] getVehicleDispatchStats error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
return executeServerAction<
|
||||
{ prepaid_amount: number; collect_amount: number; total_amount: number },
|
||||
VehicleDispatchStats
|
||||
>({
|
||||
url: buildApiUrl('/api/v1/vehicle-dispatches/stats'),
|
||||
transform: (data) => ({
|
||||
prepaidAmount: data.prepaid_amount,
|
||||
collectAmount: data.collect_amount,
|
||||
totalAmount: data.total_amount,
|
||||
}),
|
||||
errorMessage: '배차차량 통계 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 배차차량 상세 조회 =====
|
||||
@@ -111,51 +129,34 @@ export async function getVehicleDispatchById(id: string): Promise<{
|
||||
data?: VehicleDispatchDetail;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// Mock: ID로 목록에서 찾아서 상세 데이터 생성
|
||||
const item = mockVehicleDispatchItems.find((i) => i.id === id);
|
||||
if (!item) {
|
||||
// fallback으로 기본 상세 데이터 반환
|
||||
return { success: true, data: { ...mockVehicleDispatchDetail, id } };
|
||||
}
|
||||
|
||||
const detail: VehicleDispatchDetail = {
|
||||
id: item.id,
|
||||
dispatchNo: item.dispatchNo,
|
||||
shipmentNo: item.shipmentNo,
|
||||
siteName: item.siteName,
|
||||
orderCustomer: item.orderCustomer,
|
||||
freightCostType: item.freightCostType,
|
||||
status: item.status,
|
||||
writer: item.writer,
|
||||
logisticsCompany: item.logisticsCompany,
|
||||
arrivalDateTime: item.arrivalDateTime,
|
||||
tonnage: item.tonnage,
|
||||
vehicleNo: item.vehicleNo,
|
||||
driverContact: item.driverContact,
|
||||
remarks: item.remarks,
|
||||
supplyAmount: item.supplyAmount,
|
||||
vat: item.vat,
|
||||
totalAmount: item.totalAmount,
|
||||
};
|
||||
|
||||
return { success: true, data: detail };
|
||||
} catch (error) {
|
||||
console.error('[VehicleDispatchActions] getVehicleDispatchById error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/vehicle-dispatches/${id}`),
|
||||
transform: transformToDetail,
|
||||
errorMessage: '배차차량 상세 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 배차차량 수정 =====
|
||||
export async function updateVehicleDispatch(
|
||||
id: string,
|
||||
_data: VehicleDispatchEditFormData
|
||||
data: VehicleDispatchEditFormData
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
// Mock: 항상 성공 반환
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[VehicleDispatchActions] updateVehicleDispatch error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/vehicle-dispatches/${id}`),
|
||||
method: 'PUT',
|
||||
body: {
|
||||
freight_cost_type: data.freightCostType,
|
||||
logistics_company: data.logisticsCompany,
|
||||
arrival_datetime: data.arrivalDateTime,
|
||||
tonnage: data.tonnage,
|
||||
vehicle_no: data.vehicleNo,
|
||||
driver_contact: data.driverContact,
|
||||
remarks: data.remarks,
|
||||
supply_amount: data.supplyAmount,
|
||||
vat: data.vat,
|
||||
total_amount: data.totalAmount,
|
||||
status: undefined, // 상태는 별도로 관리
|
||||
},
|
||||
errorMessage: '배차차량 수정에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -30,12 +30,16 @@ import type {
|
||||
StepConnectionType,
|
||||
StepCompletionType,
|
||||
InspectionSetting,
|
||||
InspectionScope,
|
||||
InspectionScopeType,
|
||||
} from '@/types/process';
|
||||
import {
|
||||
STEP_CONNECTION_TYPE_OPTIONS,
|
||||
STEP_COMPLETION_TYPE_OPTIONS,
|
||||
STEP_CONNECTION_TARGET_OPTIONS,
|
||||
DEFAULT_INSPECTION_SETTING,
|
||||
DEFAULT_INSPECTION_SCOPE,
|
||||
INSPECTION_SCOPE_TYPE_OPTIONS,
|
||||
} from '@/types/process';
|
||||
import { createProcessStep, updateProcessStep } from './actions';
|
||||
import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate/types';
|
||||
@@ -108,6 +112,9 @@ export function StepForm({ mode, processId, initialData }: StepFormProps) {
|
||||
const [inspectionSetting, setInspectionSetting] = useState<InspectionSetting>(
|
||||
initialData?.inspectionSetting || DEFAULT_INSPECTION_SETTING
|
||||
);
|
||||
const [inspectionScope, setInspectionScope] = useState<InspectionScope>(
|
||||
initialData?.inspectionScope || DEFAULT_INSPECTION_SCOPE
|
||||
);
|
||||
|
||||
// 모달 상태
|
||||
const [isInspectionSettingOpen, setIsInspectionSettingOpen] = useState(false);
|
||||
@@ -137,6 +144,7 @@ export function StepForm({ mode, processId, initialData }: StepFormProps) {
|
||||
connectionTarget: connectionType === '팝업' ? connectionTarget : undefined,
|
||||
completionType,
|
||||
inspectionSetting: isInspectionEnabled ? inspectionSetting : undefined,
|
||||
inspectionScope: isInspectionEnabled ? inspectionScope : undefined,
|
||||
};
|
||||
|
||||
setIsLoading(true);
|
||||
@@ -237,6 +245,52 @@ export function StepForm({ mode, processId, initialData }: StepFormProps) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{isInspectionEnabled && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>검사범위</Label>
|
||||
<Select
|
||||
value={inspectionScope.type}
|
||||
onValueChange={(v) =>
|
||||
setInspectionScope((prev) => ({
|
||||
...prev,
|
||||
type: v as InspectionScopeType,
|
||||
...(v === 'all' ? { sampleSize: undefined, sampleBase: undefined } : {}),
|
||||
...(v === 'sampling' ? { sampleSize: prev.sampleSize || 1, sampleBase: prev.sampleBase || 'order' } : {}),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{INSPECTION_SCOPE_TYPE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{inspectionScope.type === 'sampling' && (
|
||||
<div className="space-y-2">
|
||||
<Label>샘플 크기 (n)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={inspectionScope.sampleSize ?? 1}
|
||||
onChange={(e) =>
|
||||
setInspectionScope((prev) => ({
|
||||
...prev,
|
||||
sampleSize: Math.max(1, parseInt(e.target.value) || 1),
|
||||
}))
|
||||
}
|
||||
placeholder="검사할 개소 수"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label>상태</Label>
|
||||
<Select value={isActive} onValueChange={setIsActive}>
|
||||
@@ -338,6 +392,7 @@ export function StepForm({ mode, processId, initialData }: StepFormProps) {
|
||||
completionType,
|
||||
initialData?.stepCode,
|
||||
isInspectionEnabled,
|
||||
inspectionScope,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -576,6 +576,15 @@ export async function getDocumentTemplates(): Promise<{
|
||||
// 공정 단계 (Process Step) API
|
||||
// ============================================================================
|
||||
|
||||
interface ApiProcessStepOptions {
|
||||
inspection_setting?: Record<string, unknown>;
|
||||
inspection_scope?: {
|
||||
type: string;
|
||||
sample_size?: number;
|
||||
sample_base?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ApiProcessStep {
|
||||
id: number;
|
||||
process_id: number;
|
||||
@@ -589,11 +598,13 @@ interface ApiProcessStep {
|
||||
connection_type: string | null;
|
||||
connection_target: string | null;
|
||||
completion_type: string | null;
|
||||
options: ApiProcessStepOptions | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
function transformStepApiToFrontend(apiStep: ApiProcessStep): ProcessStep {
|
||||
const opts = apiStep.options;
|
||||
return {
|
||||
id: String(apiStep.id),
|
||||
stepCode: apiStep.step_code,
|
||||
@@ -606,6 +617,12 @@ function transformStepApiToFrontend(apiStep: ApiProcessStep): ProcessStep {
|
||||
connectionType: (apiStep.connection_type as ProcessStep['connectionType']) || '없음',
|
||||
connectionTarget: apiStep.connection_target ?? undefined,
|
||||
completionType: (apiStep.completion_type as ProcessStep['completionType']) || 'click_complete',
|
||||
inspectionSetting: opts?.inspection_setting as ProcessStep['inspectionSetting'],
|
||||
inspectionScope: opts?.inspection_scope ? {
|
||||
type: opts.inspection_scope.type as 'all' | 'sampling' | 'group',
|
||||
sampleSize: opts.inspection_scope.sample_size,
|
||||
sampleBase: opts.inspection_scope.sample_base as 'order' | 'lot' | undefined,
|
||||
} : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -660,6 +677,14 @@ export async function createProcessStep(
|
||||
connection_type: data.connectionType || null,
|
||||
connection_target: data.connectionTarget || null,
|
||||
completion_type: data.completionType || null,
|
||||
options: (data.inspectionSetting || data.inspectionScope) ? {
|
||||
inspection_setting: data.inspectionSetting || null,
|
||||
inspection_scope: data.inspectionScope ? {
|
||||
type: data.inspectionScope.type,
|
||||
sample_size: data.inspectionScope.sampleSize,
|
||||
sample_base: data.inspectionScope.sampleBase,
|
||||
} : null,
|
||||
} : null,
|
||||
},
|
||||
transform: (d: ApiProcessStep) => transformStepApiToFrontend(d),
|
||||
errorMessage: '공정 단계 등록에 실패했습니다.',
|
||||
@@ -684,6 +709,16 @@ export async function updateProcessStep(
|
||||
if (data.connectionType !== undefined) apiData.connection_type = data.connectionType || null;
|
||||
if (data.connectionTarget !== undefined) apiData.connection_target = data.connectionTarget || null;
|
||||
if (data.completionType !== undefined) apiData.completion_type = data.completionType || null;
|
||||
if (data.inspectionSetting !== undefined || data.inspectionScope !== undefined) {
|
||||
apiData.options = {
|
||||
inspection_setting: data.inspectionSetting || null,
|
||||
inspection_scope: data.inspectionScope ? {
|
||||
type: data.inspectionScope.type,
|
||||
sample_size: data.inspectionScope.sampleSize,
|
||||
sample_base: data.inspectionScope.sampleBase,
|
||||
} : null,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/processes/${processId}/steps/${stepId}`),
|
||||
|
||||
@@ -213,6 +213,15 @@ export const BendingInspectionContent = forwardRef<InspectionContentRef, Bending
|
||||
...p,
|
||||
bendingStatus: bendingStatusValue,
|
||||
})));
|
||||
} else if (itemData.judgment) {
|
||||
// 이전 형식 호환: products/bendingStatus 없이 judgment만 있는 경우
|
||||
const inferredStatus: CheckStatus = itemData.judgment === 'pass' ? '양호' : itemData.judgment === 'fail' ? '불량' : null;
|
||||
if (inferredStatus) {
|
||||
setProducts(prev => prev.map(p => ({
|
||||
...p,
|
||||
bendingStatus: inferredStatus,
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
// 부적합 내용 로드
|
||||
|
||||
@@ -624,6 +624,124 @@ export const TemplateInspectionContent = forwardRef<InspectionContentRef, Templa
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [documentRecords, isBending, bendingProducts]);
|
||||
|
||||
// ===== Bending: inspectionDataMap의 products 배열에서 셀 값 복원 =====
|
||||
// InspectionInputModal이 저장한 products 배열 → bending 셀 키로 매핑
|
||||
// ★ inspectionDataMap의 products가 있으면 documentRecords(EAV)보다 우선
|
||||
// (입력 모달에서 방금 저장한 신규 데이터가 이전 문서 데이터보다 최신)
|
||||
useEffect(() => {
|
||||
if (!isBending || !inspectionDataMap || !workItems || bendingProducts.length === 0) return;
|
||||
|
||||
// inspectionDataMap에서 products 배열 찾기
|
||||
type SavedProduct = {
|
||||
id: string;
|
||||
bendingStatus: string | null;
|
||||
lengthMeasured: string;
|
||||
widthMeasured: string;
|
||||
gapPoints: Array<{ point: string; designValue: string; measured: string }>;
|
||||
};
|
||||
let savedProducts: SavedProduct[] | undefined;
|
||||
for (const wi of workItems) {
|
||||
const d = inspectionDataMap.get(wi.id) as Record<string, unknown> | undefined;
|
||||
if (d?.products && Array.isArray(d.products)) {
|
||||
savedProducts = d.products as SavedProduct[];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!savedProducts || savedProducts.length === 0) return;
|
||||
|
||||
const initial: Record<string, CellValue> = {};
|
||||
|
||||
// 컬럼 분류
|
||||
const checkColId = template.columns.find(c => c.column_type === 'check')?.id;
|
||||
const complexCols = template.columns.filter(c =>
|
||||
c.column_type === 'complex' && c.id !== gapColumnId
|
||||
);
|
||||
|
||||
// 각 template bendingProduct → 저장된 product 매핑
|
||||
bendingProducts.forEach((bp, productIdx) => {
|
||||
// 1. ID 정규화 매칭 (guide-rail-wall ↔ guide_rail_wall)
|
||||
const normalizedBpId = bp.id.replace(/[-_]/g, '').toLowerCase();
|
||||
let matched = savedProducts!.find(sp =>
|
||||
sp.id.replace(/[-_]/g, '').toLowerCase() === normalizedBpId
|
||||
);
|
||||
|
||||
// 2. 이름 키워드 매칭
|
||||
if (!matched) {
|
||||
const bpKey = `${bp.productName}${bp.productType}`.replace(/\s/g, '').toLowerCase();
|
||||
matched = savedProducts!.find(sp => {
|
||||
const spId = sp.id.toLowerCase();
|
||||
if (bpKey.includes('가이드레일') && bpKey.includes('벽면') && spId.includes('guide') && spId.includes('wall')) return true;
|
||||
if (bpKey.includes('가이드레일') && bpKey.includes('측면') && spId.includes('guide') && spId.includes('side')) return true;
|
||||
if (bpKey.includes('케이스') && spId.includes('case')) return true;
|
||||
if (bpKey.includes('하단마감') && (spId.includes('bottom-finish') || spId.includes('bottom_bar'))) return true;
|
||||
if (bpKey.includes('연기차단') && bpKey.includes('w50') && spId.includes('w50')) return true;
|
||||
if (bpKey.includes('연기차단') && bpKey.includes('w80') && spId.includes('w80')) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 인덱스 폴백
|
||||
if (!matched && productIdx < savedProducts!.length) {
|
||||
matched = savedProducts![productIdx];
|
||||
}
|
||||
if (!matched) return;
|
||||
|
||||
// check 컬럼 (절곡상태)
|
||||
if (checkColId) {
|
||||
const cellKey = `b-${productIdx}-${checkColId}`;
|
||||
if (matched.bendingStatus === '양호') {
|
||||
initial[cellKey] = { status: 'good' };
|
||||
} else if (matched.bendingStatus === '불량') {
|
||||
initial[cellKey] = { status: 'bad' };
|
||||
}
|
||||
}
|
||||
|
||||
// 간격 컬럼
|
||||
if (gapColumnId && matched.gapPoints) {
|
||||
matched.gapPoints.forEach((gp, pointIdx) => {
|
||||
if (gp.measured) {
|
||||
const cellKey = `b-${productIdx}-p${pointIdx}-${gapColumnId}`;
|
||||
initial[cellKey] = { measurements: [gp.measured, '', ''] };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// complex 컬럼 (길이/너비)
|
||||
// bending 렌더링은 measurements[si] (si = sub_label raw index)를 읽으므로
|
||||
// 측정값 sub_label의 실제 si 위치에 값을 넣어야 함
|
||||
for (const col of complexCols) {
|
||||
const label = col.label.trim();
|
||||
const cellKey = `b-${productIdx}-${col.id}`;
|
||||
|
||||
// 측정값 sub_label의 si 인덱스 찾기
|
||||
let measurementSi = 0;
|
||||
if (col.sub_labels) {
|
||||
for (let si = 0; si < col.sub_labels.length; si++) {
|
||||
const sl = col.sub_labels[si].toLowerCase();
|
||||
if (!sl.includes('도면') && !sl.includes('기준')) {
|
||||
measurementSi = si;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const measurements: [string, string, string] = ['', '', ''];
|
||||
if (label.includes('길이') && matched.lengthMeasured) {
|
||||
measurements[measurementSi] = matched.lengthMeasured;
|
||||
initial[cellKey] = { measurements };
|
||||
} else if ((label.includes('너비') || label.includes('폭') || label.includes('높이')) && matched.widthMeasured) {
|
||||
measurements[measurementSi] = matched.widthMeasured;
|
||||
initial[cellKey] = { measurements };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(initial).length > 0) {
|
||||
setCellValues(prev => ({ ...prev, ...initial }));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isBending, inspectionDataMap, workItems, bendingProducts, template.columns, gapColumnId]);
|
||||
|
||||
const updateCell = (key: string, update: Partial<CellValue>) => {
|
||||
setCellValues(prev => ({
|
||||
...prev,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import type { BendingInfoExtended, MaterialMapping } from './types';
|
||||
import { buildBottomBarRows, getBendingImageUrl, fmt, fmtWeight, lengthToCode } from './utils';
|
||||
import { buildBottomBarRows, getBendingImageUrl, fmt, fmtWeight, lookupLotNo } from './utils';
|
||||
|
||||
interface BottomBarSectionProps {
|
||||
bendingInfo: BendingInfoExtended;
|
||||
@@ -17,7 +17,7 @@ interface BottomBarSectionProps {
|
||||
}
|
||||
|
||||
export function BottomBarSection({ bendingInfo, mapping, lotNoMap }: BottomBarSectionProps) {
|
||||
const rows = buildBottomBarRows(bendingInfo.bottomBar, mapping);
|
||||
const rows = buildBottomBarRows(bendingInfo.bottomBar, mapping, bendingInfo.productCode);
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
return (
|
||||
@@ -57,7 +57,7 @@ export function BottomBarSection({ bendingInfo, mapping, lotNoMap }: BottomBarSe
|
||||
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmt(row.length)}</td>
|
||||
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmt(row.quantity)}</td>
|
||||
<td className="border border-gray-400 px-1 py-0.5 text-center">
|
||||
{lotNoMap?.[`BD-${row.lotPrefix}-${lengthToCode(row.length)}`] || '-'}
|
||||
{lookupLotNo(lotNoMap, row.lotPrefix, row.length)}
|
||||
</td>
|
||||
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmtWeight(row.weight)}</td>
|
||||
</tr>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import type { BendingInfoExtended, MaterialMapping, GuideRailPartRow } from './types';
|
||||
import { buildWallGuideRailRows, buildSideGuideRailRows, getBendingImageUrl, fmt, fmtWeight, lengthToCode } from './utils';
|
||||
import { buildWallGuideRailRows, buildSideGuideRailRows, getBendingImageUrl, fmt, fmtWeight, lookupLotNo } from './utils';
|
||||
|
||||
interface GuideRailSectionProps {
|
||||
bendingInfo: BendingInfoExtended;
|
||||
@@ -63,7 +63,7 @@ function PartTable({ title, rows, imageUrl, lotNo, baseSize, lotNoMap }: {
|
||||
</td>
|
||||
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmt(row.quantity)}</td>
|
||||
<td className="border border-gray-400 px-1 py-0.5 text-center">
|
||||
{lotNoMap?.[`BD-${row.lotPrefix}-${lengthToCode(row.length)}`] || '-'}
|
||||
{lookupLotNo(lotNoMap, row.lotPrefix, row.length)}
|
||||
</td>
|
||||
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmtWeight(row.weight)}</td>
|
||||
</tr>
|
||||
@@ -81,11 +81,11 @@ export function GuideRailSection({ bendingInfo, mapping, lotNo, lotNoMap }: Guid
|
||||
const productCode = bendingInfo.productCode;
|
||||
|
||||
const wallRows = wall
|
||||
? buildWallGuideRailRows(wall.lengthData, wall.baseDimension || '135*80', mapping)
|
||||
? buildWallGuideRailRows(wall.lengthData, wall.baseDimension || '135*80', mapping, productCode)
|
||||
: [];
|
||||
|
||||
const sideRows = side
|
||||
? buildSideGuideRailRows(side.lengthData, side.baseDimension || '135*130', mapping)
|
||||
? buildSideGuideRailRows(side.lengthData, side.baseDimension || '135*130', mapping, productCode)
|
||||
: [];
|
||||
|
||||
if (wallRows.length === 0 && sideRows.length === 0) return null;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import type { BendingInfoExtended, ShutterBoxData } from './types';
|
||||
import { buildShutterBoxRows, getBendingImageUrl, fmt, fmtWeight, lengthToCode } from './utils';
|
||||
import { buildShutterBoxRows, getBendingImageUrl, fmt, fmtWeight, lookupLotNo } from './utils';
|
||||
|
||||
interface ShutterBoxSectionProps {
|
||||
bendingInfo: BendingInfoExtended;
|
||||
@@ -75,9 +75,10 @@ function ShutterBoxSubSection({ box, index, lotNoMap }: { box: ShutterBoxData; i
|
||||
{(() => {
|
||||
const dimNum = parseInt(row.dimension);
|
||||
if (!isNaN(dimNum) && !row.dimension.includes('*')) {
|
||||
return lotNoMap?.[`BD-${row.lotPrefix}-${lengthToCode(dimNum)}`] || '-';
|
||||
return lookupLotNo(lotNoMap, row.lotPrefix, dimNum);
|
||||
}
|
||||
return '-';
|
||||
// 치수형(1219*539 등)도 prefix-only fallback
|
||||
return lookupLotNo(lotNoMap, row.lotPrefix);
|
||||
})()}
|
||||
</td>
|
||||
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmtWeight(row.weight)}</td>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import type { BendingInfoExtended } from './types';
|
||||
import { buildSmokeBarrierRows, getBendingImageUrl, fmt, fmtWeight } from './utils';
|
||||
import { buildSmokeBarrierRows, getBendingImageUrl, fmt, fmtWeight, lookupLotNo } from './utils';
|
||||
|
||||
interface SmokeBarrierSectionProps {
|
||||
bendingInfo: BendingInfoExtended;
|
||||
@@ -57,7 +57,14 @@ export function SmokeBarrierSection({ bendingInfo, lotNoMap }: SmokeBarrierSecti
|
||||
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmt(row.length)}</td>
|
||||
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmt(row.quantity)}</td>
|
||||
<td className="border border-gray-400 px-1 py-0.5 text-center">
|
||||
{lotNoMap?.[`BD-${row.lotCode}`] || '-'}
|
||||
{(() => {
|
||||
// 정확 매칭 (GI-83, GI-54 등)
|
||||
const exact = lotNoMap?.[`BD-${row.lotCode}`];
|
||||
if (exact) return exact;
|
||||
// Fallback: GI prefix로 검색
|
||||
const prefix = row.lotCode.split('-')[0];
|
||||
return lookupLotNo(lotNoMap, prefix, row.length);
|
||||
})()}
|
||||
</td>
|
||||
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmtWeight(row.weight)}</td>
|
||||
</tr>
|
||||
|
||||
@@ -181,22 +181,29 @@ export function buildWallGuideRailRows(
|
||||
lengthData: LengthQuantity[],
|
||||
baseDimension: string,
|
||||
mapping: MaterialMapping,
|
||||
productCode?: string,
|
||||
): GuideRailPartRow[] {
|
||||
const rows: GuideRailPartRow[] = [];
|
||||
const codePrefix = productCode?.replace(/\d+$/, '') || 'KSS';
|
||||
const isSteel = codePrefix === 'KTE';
|
||||
const isSUS = ['KSS', 'KQTS', 'KTE'].includes(codePrefix);
|
||||
const finishPrefix = isSUS ? 'RS' : 'RE';
|
||||
const bodyPrefix = isSteel ? 'RT' : 'RM';
|
||||
|
||||
for (const ld of lengthData) {
|
||||
if (ld.quantity <= 0) continue;
|
||||
|
||||
// ①②마감재
|
||||
const finishW = calcWeight(mapping.guideRailFinish, WALL_PART_WIDTH, ld.length);
|
||||
rows.push({
|
||||
partName: '①②마감재', lotPrefix: 'XX', material: mapping.guideRailFinish,
|
||||
partName: '①②마감재', lotPrefix: finishPrefix, material: mapping.guideRailFinish,
|
||||
length: ld.length, quantity: ld.quantity, weight: Math.round(finishW.weight * ld.quantity * 100) / 100,
|
||||
});
|
||||
|
||||
// ③본체
|
||||
const bodyW = calcWeight(mapping.bodyMaterial, WALL_PART_WIDTH, ld.length);
|
||||
rows.push({
|
||||
partName: '③본체', lotPrefix: 'RT', material: mapping.bodyMaterial,
|
||||
partName: '③본체', lotPrefix: bodyPrefix, material: mapping.bodyMaterial,
|
||||
length: ld.length, quantity: ld.quantity, weight: Math.round(bodyW.weight * ld.quantity * 100) / 100,
|
||||
});
|
||||
|
||||
@@ -216,7 +223,7 @@ export function buildWallGuideRailRows(
|
||||
if (mapping.guideRailExtraFinish) {
|
||||
const extraW = calcWeight(mapping.guideRailExtraFinish, WALL_PART_WIDTH, ld.length);
|
||||
rows.push({
|
||||
partName: '⑥별도마감', lotPrefix: 'RS', material: mapping.guideRailExtraFinish,
|
||||
partName: '⑥별도마감', lotPrefix: 'YY', material: mapping.guideRailExtraFinish,
|
||||
length: ld.length, quantity: ld.quantity, weight: Math.round(extraW.weight * ld.quantity * 100) / 100,
|
||||
});
|
||||
}
|
||||
@@ -244,21 +251,27 @@ export function buildSideGuideRailRows(
|
||||
lengthData: LengthQuantity[],
|
||||
baseDimension: string,
|
||||
mapping: MaterialMapping,
|
||||
productCode?: string,
|
||||
): GuideRailPartRow[] {
|
||||
const rows: GuideRailPartRow[] = [];
|
||||
const codePrefix = productCode?.replace(/\d+$/, '') || 'KSS';
|
||||
const isSteel = codePrefix === 'KTE';
|
||||
const isSUS = ['KSS', 'KQTS', 'KTE'].includes(codePrefix);
|
||||
const finishPrefix = isSUS ? 'SS' : 'SE';
|
||||
const bodyPrefix = isSteel ? 'ST' : 'SM';
|
||||
|
||||
for (const ld of lengthData) {
|
||||
if (ld.quantity <= 0) continue;
|
||||
|
||||
const finishW = calcWeight(mapping.guideRailFinish, SIDE_PART_WIDTH, ld.length);
|
||||
rows.push({
|
||||
partName: '①②마감재', lotPrefix: 'SS', material: mapping.guideRailFinish,
|
||||
partName: '①②마감재', lotPrefix: finishPrefix, material: mapping.guideRailFinish,
|
||||
length: ld.length, quantity: ld.quantity, weight: Math.round(finishW.weight * ld.quantity * 100) / 100,
|
||||
});
|
||||
|
||||
const bodyW = calcWeight(mapping.bodyMaterial, SIDE_PART_WIDTH, ld.length);
|
||||
rows.push({
|
||||
partName: '③본체', lotPrefix: 'ST', material: mapping.bodyMaterial,
|
||||
partName: '③본체', lotPrefix: bodyPrefix, material: mapping.bodyMaterial,
|
||||
length: ld.length, quantity: ld.quantity, weight: Math.round(bodyW.weight * ld.quantity * 100) / 100,
|
||||
});
|
||||
|
||||
@@ -295,9 +308,12 @@ export function buildSideGuideRailRows(
|
||||
export function buildBottomBarRows(
|
||||
bottomBar: BendingInfoExtended['bottomBar'],
|
||||
mapping: MaterialMapping,
|
||||
productCode?: string,
|
||||
): BottomBarPartRow[] {
|
||||
const rows: BottomBarPartRow[] = [];
|
||||
const lotPrefix = mapping.bottomBarFinish.includes('SUS') ? 'TS' : 'TE';
|
||||
const codePrefix = productCode?.replace(/\d+$/, '') || 'KSS';
|
||||
const isSteel = codePrefix === 'KTE';
|
||||
const lotPrefix = isSteel ? 'TS' : (mapping.bottomBarFinish.includes('SUS') ? 'BS' : 'BE');
|
||||
|
||||
// ①하단마감재 - 3000mm
|
||||
if (bottomBar.length3000Qty > 0) {
|
||||
@@ -321,7 +337,7 @@ export function buildBottomBarRows(
|
||||
|
||||
// ④별도마감재 (extraFinish !== '없음' 일 때만)
|
||||
if (mapping.bottomBarExtraFinish !== '없음' && mapping.bottomBarExtraFinish) {
|
||||
const extraLotPrefix = mapping.bottomBarExtraFinish.includes('SUS') ? 'TS' : 'TE';
|
||||
const extraLotPrefix = 'YY';
|
||||
|
||||
if (bottomBar.length3000Qty > 0) {
|
||||
const w = calcWeight(mapping.bottomBarExtraFinish, EXTRA_FINISH_WIDTH, 3000);
|
||||
@@ -570,3 +586,33 @@ export function fmtWeight(v: number): string {
|
||||
export function lengthToCode(lengthMm: number): string {
|
||||
return getSLengthCode(lengthMm, '') || String(lengthMm);
|
||||
}
|
||||
|
||||
/**
|
||||
* lotNoMap에서 LOT NO 조회
|
||||
*
|
||||
* bending_info의 길이와 실제 자재투입 길이가 다를 수 있으므로,
|
||||
* 정확한 매칭 실패 시 prefix만으로 fallback 매칭합니다.
|
||||
*
|
||||
* @param lotNoMap - item_code → lot_no 매핑 (e.g. 'BD-SS-35' → 'INIT-260221-BDSS35')
|
||||
* @param prefix - 세부품목 prefix (e.g. 'SS', 'SM', 'BS')
|
||||
* @param length - 길이(mm), optional
|
||||
*/
|
||||
export function lookupLotNo(
|
||||
lotNoMap: Record<string, string> | undefined,
|
||||
prefix: string,
|
||||
length?: number,
|
||||
): string {
|
||||
if (!lotNoMap) return '-';
|
||||
|
||||
// 1. 정확한 매칭 (prefix + lengthCode)
|
||||
if (length) {
|
||||
const code = lengthToCode(length);
|
||||
const exact = lotNoMap[`BD-${prefix}-${code}`];
|
||||
if (exact) return exact;
|
||||
}
|
||||
|
||||
// 2. Fallback: prefix만으로 매칭 (첫 번째 일치 항목)
|
||||
const prefixKey = `BD-${prefix}-`;
|
||||
const fallbackKey = Object.keys(lotNoMap).find(k => k.startsWith(prefixKey));
|
||||
return fallbackKey ? lotNoMap[fallbackKey] : '-';
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* - bending_wip: 재고생산(재공품) 중간검사
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -24,6 +24,8 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { InspectionTemplateData, InspectionTemplateSectionItem } from './types';
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
import { getInspectionConfig } from '@/components/production/WorkOrders/actions';
|
||||
import type { InspectionConfigData } from '@/components/production/WorkOrders/actions';
|
||||
|
||||
// 중간검사 공정 타입
|
||||
export type InspectionProcessType =
|
||||
@@ -71,6 +73,88 @@ interface InspectionInputModalProps {
|
||||
templateData?: InspectionTemplateData;
|
||||
/** 작업 아이템의 실제 치수 (reference_attribute 연동용) */
|
||||
workItemDimensions?: { width?: number; height?: number };
|
||||
/** 작업지시 ID (절곡 gap_points API 조회용) */
|
||||
workOrderId?: string;
|
||||
}
|
||||
|
||||
// ===== 절곡 7개 제품 검사 항목 (BendingInspectionContent의 INITIAL_PRODUCTS와 동일 구조) =====
|
||||
interface BendingGapPointDef {
|
||||
point: string;
|
||||
design: string;
|
||||
}
|
||||
|
||||
interface BendingProductDef {
|
||||
id: string;
|
||||
label: string;
|
||||
lengthDesign: string;
|
||||
widthDesign: string;
|
||||
gapPoints: BendingGapPointDef[];
|
||||
}
|
||||
|
||||
const BENDING_PRODUCTS: BendingProductDef[] = [
|
||||
{
|
||||
id: 'guide-rail-wall', label: '가이드레일 (벽면형)', lengthDesign: '3000', widthDesign: 'N/A',
|
||||
gapPoints: [
|
||||
{ point: '①', design: '30' }, { point: '②', design: '80' },
|
||||
{ point: '③', design: '45' }, { point: '④', design: '40' }, { point: '⑤', design: '34' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'guide-rail-side', label: '가이드레일 (측면형)', lengthDesign: '3000', widthDesign: 'N/A',
|
||||
gapPoints: [
|
||||
{ point: '①', design: '28' }, { point: '②', design: '75' },
|
||||
{ point: '③', design: '42' }, { point: '④', design: '38' }, { point: '⑤', design: '32' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'case', label: '케이스 (500X380)', lengthDesign: '3000', widthDesign: 'N/A',
|
||||
gapPoints: [
|
||||
{ point: '①', design: '380' }, { point: '②', design: '50' },
|
||||
{ point: '③', design: '240' }, { point: '④', design: '50' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'bottom-finish', label: '하단마감재 (60X40)', lengthDesign: '3000', widthDesign: 'N/A',
|
||||
gapPoints: [
|
||||
{ point: '②', design: '60' }, { point: '②', design: '64' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'bottom-l-bar', label: '하단L-BAR (17X60)', lengthDesign: '3000', widthDesign: 'N/A',
|
||||
gapPoints: [
|
||||
{ point: '①', design: '17' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'smoke-w50', label: '연기차단재 (W50)', lengthDesign: '3000', widthDesign: '',
|
||||
gapPoints: [
|
||||
{ point: '①', design: '50' }, { point: '②', design: '12' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'smoke-w80', label: '연기차단재 (W80)', lengthDesign: '3000', widthDesign: '',
|
||||
gapPoints: [
|
||||
{ point: '①', design: '80' }, { point: '②', design: '12' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface BendingProductState {
|
||||
id: string;
|
||||
bendingStatus: 'good' | 'bad' | null;
|
||||
lengthMeasured: string;
|
||||
widthMeasured: string;
|
||||
gapMeasured: string[];
|
||||
}
|
||||
|
||||
function createInitialBendingProducts(): BendingProductState[] {
|
||||
return BENDING_PRODUCTS.map(p => ({
|
||||
id: p.id,
|
||||
bendingStatus: null,
|
||||
lengthMeasured: '',
|
||||
widthMeasured: '',
|
||||
gapMeasured: p.gapPoints.map(() => ''),
|
||||
}));
|
||||
}
|
||||
|
||||
const PROCESS_TITLES: Record<InspectionProcessType, string> = {
|
||||
@@ -461,9 +545,11 @@ export function InspectionInputModal({
|
||||
onComplete,
|
||||
templateData,
|
||||
workItemDimensions,
|
||||
workOrderId,
|
||||
}: InspectionInputModalProps) {
|
||||
// 템플릿 모드 여부
|
||||
const useTemplateMode = !!(templateData?.has_template && templateData.template);
|
||||
// 절곡(bending)은 7제품 커스텀 폼 사용 → TemplateInspectionContent의 bending 셀 키와 연동
|
||||
const useTemplateMode = processType !== 'bending' && !!(templateData?.has_template && templateData.template);
|
||||
|
||||
const [formData, setFormData] = useState<InspectionData>({
|
||||
productName,
|
||||
@@ -475,11 +561,72 @@ export function InspectionInputModal({
|
||||
// 동적 폼 값 (템플릿 모드용)
|
||||
const [dynamicFormValues, setDynamicFormValues] = useState<Record<string, unknown>>({});
|
||||
|
||||
// 절곡용 간격 포인트 초기화
|
||||
// 이전 형식 데이터 로드 시 auto-judgment가 judgment를 덮어쓰지 않도록 보호
|
||||
const skipAutoJudgmentRef = useRef(false);
|
||||
|
||||
// 절곡용 간격 포인트 초기화 (레거시 — bending_wip 등에서 사용)
|
||||
const [gapPoints, setGapPoints] = useState<{ left: number | null; right: number | null }[]>(
|
||||
Array(5).fill(null).map(() => ({ left: null, right: null }))
|
||||
);
|
||||
|
||||
// 절곡 API 제품 정의 (gap_points 동적 로딩)
|
||||
const [apiProductDefs, setApiProductDefs] = useState<BendingProductDef[] | null>(null);
|
||||
const effectiveProductDefs = apiProductDefs || BENDING_PRODUCTS;
|
||||
|
||||
// 절곡 7개 제품별 상태 (bending 전용)
|
||||
const [bendingProducts, setBendingProducts] = useState<BendingProductState[]>(createInitialBendingProducts);
|
||||
|
||||
// API에서 절곡 제품 gap_points 동적 로딩
|
||||
useEffect(() => {
|
||||
if (!open || processType !== 'bending' || !workOrderId) return;
|
||||
let cancelled = false;
|
||||
getInspectionConfig(workOrderId).then(result => {
|
||||
if (cancelled) return;
|
||||
if (result.success && result.data?.items?.length) {
|
||||
const displayMap: Record<string, { label: string; len: string; wid: string }> = {
|
||||
guide_rail_wall: { label: '가이드레일 (벽면형)', len: '3000', wid: 'N/A' },
|
||||
guide_rail_side: { label: '가이드레일 (측면형)', len: '3000', wid: 'N/A' },
|
||||
case_box: { label: '케이스 (500X380)', len: '3000', wid: 'N/A' },
|
||||
bottom_bar: { label: '하단마감재 (60X40)', len: '3000', wid: 'N/A' },
|
||||
bottom_l_bar: { label: '하단L-BAR (17X60)', len: '3000', wid: 'N/A' },
|
||||
smoke_w50: { label: '연기차단재 (W50)', len: '3000', wid: '' },
|
||||
smoke_w80: { label: '연기차단재 (W80)', len: '3000', wid: '' },
|
||||
};
|
||||
const defs: BendingProductDef[] = result.data.items.map(item => {
|
||||
const d = displayMap[item.id] || { label: item.name, len: '-', wid: 'N/A' };
|
||||
return {
|
||||
id: item.id,
|
||||
label: d.label,
|
||||
lengthDesign: d.len,
|
||||
widthDesign: d.wid,
|
||||
gapPoints: item.gap_points.map(gp => ({ point: gp.point, design: gp.design_value })),
|
||||
};
|
||||
});
|
||||
setApiProductDefs(defs);
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [open, processType, workOrderId]);
|
||||
|
||||
// API 제품 정의 로딩 시 bendingProducts 갱신 (gap 개수 동기화)
|
||||
useEffect(() => {
|
||||
if (!apiProductDefs || processType !== 'bending') return;
|
||||
setBendingProducts(prev => {
|
||||
return apiProductDefs.map((def, idx) => {
|
||||
// 기존 입력값 보존 (ID 매칭 또는 인덱스 폴백)
|
||||
const existing = prev.find(p => p.id === def.id || p.id.replace(/[-_]/g, '') === def.id.replace(/[-_]/g, ''))
|
||||
|| (idx < prev.length ? prev[idx] : undefined);
|
||||
return {
|
||||
id: def.id,
|
||||
bendingStatus: existing?.bendingStatus ?? null,
|
||||
lengthMeasured: existing?.lengthMeasured ?? '',
|
||||
widthMeasured: existing?.widthMeasured ?? '',
|
||||
gapMeasured: def.gapPoints.map((_, gi) => existing?.gapMeasured?.[gi] ?? ''),
|
||||
};
|
||||
});
|
||||
});
|
||||
}, [apiProductDefs, processType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// initialData가 있으면 기존 저장 데이터로 복원
|
||||
@@ -495,6 +642,46 @@ export function InspectionInputModal({
|
||||
} else {
|
||||
setGapPoints(Array(5).fill(null).map(() => ({ left: null, right: null })));
|
||||
}
|
||||
// 절곡 제품별 데이터 복원
|
||||
const savedProducts = (initialData as unknown as Record<string, unknown>).products as Array<{
|
||||
id: string;
|
||||
bendingStatus: string;
|
||||
lengthMeasured: string;
|
||||
widthMeasured: string;
|
||||
gapPoints: Array<{ point: string; designValue: string; measured: string }>;
|
||||
}> | undefined;
|
||||
if (savedProducts && Array.isArray(savedProducts)) {
|
||||
setBendingProducts(effectiveProductDefs.map((def, idx) => {
|
||||
const saved = savedProducts.find(sp =>
|
||||
sp.id === def.id || sp.id.replace(/[-_]/g, '') === def.id.replace(/[-_]/g, '')
|
||||
) || (idx < savedProducts.length ? savedProducts[idx] : undefined);
|
||||
if (!saved) return { id: def.id, bendingStatus: null, lengthMeasured: '', widthMeasured: '', gapMeasured: def.gapPoints.map(() => '') };
|
||||
return {
|
||||
id: def.id,
|
||||
bendingStatus: saved.bendingStatus === '양호' ? 'good' : saved.bendingStatus === '불량' ? 'bad' : (saved.bendingStatus as 'good' | 'bad' | null),
|
||||
lengthMeasured: saved.lengthMeasured || '',
|
||||
widthMeasured: saved.widthMeasured || '',
|
||||
gapMeasured: def.gapPoints.map((_, gi) => saved.gapPoints?.[gi]?.measured || ''),
|
||||
};
|
||||
}));
|
||||
} else if (processType === 'bending' && initialData.judgment) {
|
||||
// 이전 형식 데이터 호환: products 배열 없이 저장된 경우
|
||||
// judgment 값으로 제품별 상태 추론 (pass → 전체 양호)
|
||||
const restoredStatus: 'good' | 'bad' | null =
|
||||
initialData.judgment === 'pass' ? 'good' : initialData.judgment === 'fail' ? 'bad' : null;
|
||||
setBendingProducts(effectiveProductDefs.map(def => ({
|
||||
id: def.id,
|
||||
bendingStatus: restoredStatus,
|
||||
lengthMeasured: '',
|
||||
widthMeasured: '',
|
||||
gapMeasured: def.gapPoints.map(() => ''),
|
||||
})));
|
||||
// 이전 형식은 lengthMeasured가 없어 autoJudgment가 null이 되므로
|
||||
// 로드된 judgment를 덮어쓰지 않도록 보호
|
||||
skipAutoJudgmentRef.current = true;
|
||||
} else {
|
||||
setBendingProducts(createInitialBendingProducts());
|
||||
}
|
||||
// 동적 폼 값 복원 (템플릿 기반 검사 데이터)
|
||||
if (initialData.templateValues) {
|
||||
setDynamicFormValues(initialData.templateValues);
|
||||
@@ -554,20 +741,37 @@ export function InspectionInputModal({
|
||||
}
|
||||
|
||||
setGapPoints(Array(5).fill(null).map(() => ({ left: null, right: null })));
|
||||
setBendingProducts(createInitialBendingProducts());
|
||||
setDynamicFormValues({});
|
||||
}
|
||||
}, [open, productName, specification, processType, initialData]);
|
||||
|
||||
// 자동 판정 계산 (템플릿 모드 vs 레거시 모드)
|
||||
// 자동 판정 계산 (템플릿 모드 vs 절곡 7제품 모드 vs 레거시 모드)
|
||||
const autoJudgment = useMemo(() => {
|
||||
if (useTemplateMode && templateData?.template) {
|
||||
return computeDynamicJudgment(templateData.template, dynamicFormValues, workItemDimensions);
|
||||
}
|
||||
// 절곡 7개 제품 전용 판정
|
||||
if (processType === 'bending') {
|
||||
let allGood = true;
|
||||
let allFilled = true;
|
||||
for (const p of bendingProducts) {
|
||||
if (p.bendingStatus === 'bad') return 'fail';
|
||||
if (p.bendingStatus !== 'good') { allGood = false; allFilled = false; }
|
||||
if (!p.lengthMeasured) allFilled = false;
|
||||
}
|
||||
if (allGood && allFilled) return 'pass';
|
||||
return null;
|
||||
}
|
||||
return computeJudgment(processType, formData);
|
||||
}, [useTemplateMode, templateData, dynamicFormValues, workItemDimensions, processType, formData]);
|
||||
}, [useTemplateMode, templateData, dynamicFormValues, workItemDimensions, processType, formData, bendingProducts]);
|
||||
|
||||
// 판정값 자동 동기화
|
||||
// 판정값 자동 동기화 (이전 형식 데이터 로드 시 첫 번째 동기화 건너뜀)
|
||||
useEffect(() => {
|
||||
if (skipAutoJudgmentRef.current) {
|
||||
skipAutoJudgmentRef.current = false;
|
||||
return;
|
||||
}
|
||||
setFormData((prev) => {
|
||||
if (prev.judgment === autoJudgment) return prev;
|
||||
return { ...prev, judgment: autoJudgment };
|
||||
@@ -575,13 +779,32 @@ export function InspectionInputModal({
|
||||
}, [autoJudgment]);
|
||||
|
||||
const handleComplete = () => {
|
||||
const data: InspectionData = {
|
||||
const baseData: InspectionData = {
|
||||
...formData,
|
||||
gapPoints: processType === 'bending' ? gapPoints : undefined,
|
||||
// 동적 폼 값을 templateValues로 병합
|
||||
...(useTemplateMode ? { templateValues: dynamicFormValues } : {}),
|
||||
};
|
||||
onComplete(data);
|
||||
|
||||
// 절곡: products 배열을 성적서와 동일 포맷으로 저장
|
||||
if (processType === 'bending') {
|
||||
const products = bendingProducts.map((p, idx) => ({
|
||||
id: p.id,
|
||||
bendingStatus: p.bendingStatus === 'good' ? '양호' : p.bendingStatus === 'bad' ? '불량' : null,
|
||||
lengthMeasured: p.lengthMeasured,
|
||||
widthMeasured: p.widthMeasured,
|
||||
gapPoints: (effectiveProductDefs[idx]?.gapPoints || []).map((gp, gi) => ({
|
||||
point: gp.point,
|
||||
designValue: gp.design,
|
||||
measured: p.gapMeasured[gi] || '',
|
||||
})),
|
||||
}));
|
||||
const data = { ...baseData, products } as unknown as InspectionData;
|
||||
onComplete(data);
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
onComplete(baseData);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
@@ -866,75 +1089,96 @@ export function InspectionInputModal({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ===== 절곡 검사 항목 ===== */}
|
||||
{/* ===== 절곡 검사 항목 (7개 제품별) ===== */}
|
||||
{!useTemplateMode && processType === 'bending' && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-sm font-bold">검모양 절곡상태</span>
|
||||
<StatusToggle
|
||||
value={formData.bendingStatus || null}
|
||||
onChange={(v) => setFormData((prev) => ({ ...prev, bendingStatus: v }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-sm font-bold">길이 ({formatDimension(workItemDimensions?.width)})</span>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={formatDimension(workItemDimensions?.width)}
|
||||
value={formData.length ?? ''}
|
||||
onChange={(e) => handleNumberChange('length', e.target.value)}
|
||||
className="h-11 rounded-lg border-gray-300"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-sm font-bold">너비 (N/A)</span>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="N/A"
|
||||
value={formData.width ?? 'N/A'}
|
||||
readOnly
|
||||
className="h-11 bg-gray-100 border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<span className="text-sm font-bold">간격</span>
|
||||
{gapPoints.map((point, index) => (
|
||||
<div key={index} className="grid grid-cols-3 gap-2 items-center">
|
||||
<span className="text-gray-500 text-sm font-medium">⑤{index + 1}</span>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={String(30 + index * 10)}
|
||||
value={point.left ?? ''}
|
||||
onChange={(e) => {
|
||||
const newPoints = [...gapPoints];
|
||||
newPoints[index] = {
|
||||
...newPoints[index],
|
||||
left: e.target.value === '' ? null : parseFloat(e.target.value),
|
||||
};
|
||||
setGapPoints(newPoints);
|
||||
}}
|
||||
className="h-11 rounded-lg border-gray-300"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={String(30 + index * 10)}
|
||||
value={point.right ?? ''}
|
||||
onChange={(e) => {
|
||||
const newPoints = [...gapPoints];
|
||||
newPoints[index] = {
|
||||
...newPoints[index],
|
||||
right: e.target.value === '' ? null : parseFloat(e.target.value),
|
||||
};
|
||||
setGapPoints(newPoints);
|
||||
}}
|
||||
className="h-11 rounded-lg border-gray-300"
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
{effectiveProductDefs.map((productDef, pIdx) => {
|
||||
const pState = bendingProducts[pIdx];
|
||||
if (!pState) return null;
|
||||
|
||||
const updateProduct = (updates: Partial<BendingProductState>) => {
|
||||
setBendingProducts(prev => prev.map((p, i) => i === pIdx ? { ...p, ...updates } : p));
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={productDef.id} className={cn(pIdx > 0 && 'border-t border-gray-200 pt-4')}>
|
||||
{/* 제품명 헤더 */}
|
||||
<div className="mb-3">
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{pIdx + 1}. {productDef.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 절곡상태 */}
|
||||
<div className="space-y-1.5 mb-3">
|
||||
<span className="text-xs text-gray-500 font-medium">절곡상태</span>
|
||||
<StatusToggle
|
||||
value={pState.bendingStatus}
|
||||
onChange={(v) => updateProduct({ bendingStatus: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 길이 / 너비 */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-3">
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs text-gray-500 font-medium">길이 ({productDef.lengthDesign})</span>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={productDef.lengthDesign}
|
||||
value={pState.lengthMeasured}
|
||||
onChange={(e) => updateProduct({ lengthMeasured: e.target.value })}
|
||||
className="h-10 rounded-lg border-gray-300 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs text-gray-500 font-medium">너비 ({productDef.widthDesign || '-'})</span>
|
||||
{productDef.widthDesign === 'N/A' ? (
|
||||
<Input
|
||||
type="text"
|
||||
value="N/A"
|
||||
readOnly
|
||||
className="h-10 bg-gray-100 border-gray-300 rounded-lg text-sm"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={productDef.widthDesign || '-'}
|
||||
value={pState.widthMeasured}
|
||||
onChange={(e) => updateProduct({ widthMeasured: e.target.value })}
|
||||
className="h-10 rounded-lg border-gray-300 text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 간격 포인트 */}
|
||||
{productDef.gapPoints.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs text-gray-500 font-medium">간격</span>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{productDef.gapPoints.map((gp, gi) => (
|
||||
<div key={gi} className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-gray-400 w-14 shrink-0">{gp.point} ({gp.design})</span>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={gp.design}
|
||||
value={pState.gapMeasured[gi] || ''}
|
||||
onChange={(e) => {
|
||||
const newGaps = [...pState.gapMeasured];
|
||||
newGaps[gi] = e.target.value;
|
||||
updateProduct({ gapMeasured: newGaps });
|
||||
}}
|
||||
className="h-9 rounded-lg border-gray-300 text-sm"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 부적합 내용 */}
|
||||
|
||||
@@ -5,10 +5,16 @@
|
||||
*
|
||||
* 로트를 체크박스로 선택하면 필요수량만큼 FIFO 순서로 자동 배분합니다.
|
||||
* 같은 품목의 여러 로트를 조합하여 필요수량을 충족시킬 수 있습니다.
|
||||
*
|
||||
* 기능:
|
||||
* - 기투입 LOT 표시 및 수정 (replace 모드)
|
||||
* - 선택완료 배지
|
||||
* - 필요수량 배정 완료 시에만 투입 가능
|
||||
* - FIFO 자동입력 버튼
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Loader2, Check } from 'lucide-react';
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { Loader2, Check, Zap } from 'lucide-react';
|
||||
import { ContentSkeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -78,8 +84,10 @@ export function MaterialInputModal({
|
||||
}: MaterialInputModalProps) {
|
||||
const [materials, setMaterials] = useState<MaterialForInput[]>([]);
|
||||
const [selectedLotKeys, setSelectedLotKeys] = useState<Set<string>>(new Set());
|
||||
const [manualAllocations, setManualAllocations] = useState<Map<string, number>>(new Map());
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const materialsLoadedRef = useRef(false);
|
||||
|
||||
// 목업 자재 데이터 (개발용)
|
||||
const MOCK_MATERIALS: MaterialForInput[] = Array.from({ length: 5 }, (_, i) => ({
|
||||
@@ -95,20 +103,34 @@ export function MaterialInputModal({
|
||||
fifoRank: i + 1,
|
||||
}));
|
||||
|
||||
// 로트 키 생성
|
||||
const getLotKey = (material: MaterialForInput) =>
|
||||
String(material.stockLotId ?? `item-${material.itemId}`);
|
||||
// 로트 키 생성 (그룹별 독립 — 같은 LOT가 여러 그룹에 있어도 구분)
|
||||
const getLotKey = useCallback((material: MaterialForInput, groupKey: string) =>
|
||||
`${String(material.stockLotId ?? `item-${material.itemId}`)}__${groupKey}`, []);
|
||||
|
||||
// 품목별 그룹핑
|
||||
// 기투입 LOT 존재 여부
|
||||
const hasPreInputted = useMemo(() => {
|
||||
return materials.some(m => {
|
||||
const itemInput = m as unknown as MaterialForItemInput;
|
||||
return (itemInput.lotInputtedQty ?? 0) > 0;
|
||||
});
|
||||
}, [materials]);
|
||||
|
||||
// 품목별 그룹핑 (BOM 엔트리별 고유키 사용 — 같은 item_id라도 category+partType 다르면 별도 그룹)
|
||||
const materialGroups: MaterialGroup[] = useMemo(() => {
|
||||
// dynamic_bom 항목은 (itemId, workOrderItemId) 쌍으로 그룹핑
|
||||
const groups = new Map<string, MaterialForInput[]>();
|
||||
for (const m of materials) {
|
||||
const groupKey = m.workOrderItemId ? `${m.itemId}_${m.workOrderItemId}` : String(m.itemId);
|
||||
const itemInput = m as unknown as MaterialForItemInput;
|
||||
const groupKey = itemInput.bomGroupKey
|
||||
?? (m.workOrderItemId ? `${m.itemId}_${m.workOrderItemId}` : String(m.itemId));
|
||||
const existing = groups.get(groupKey) || [];
|
||||
existing.push(m);
|
||||
groups.set(groupKey, existing);
|
||||
}
|
||||
// 작업일지와 동일한 카테고리 순서
|
||||
const categoryOrder: Record<string, number> = {
|
||||
guideRail: 0, bottomBar: 1, shutterBox: 2, smokeBarrier: 3,
|
||||
};
|
||||
|
||||
return Array.from(groups.entries()).map(([groupKey, lots]) => {
|
||||
const first = lots[0];
|
||||
const itemInput = first as unknown as MaterialForItemInput;
|
||||
@@ -129,37 +151,71 @@ export function MaterialInputModal({
|
||||
partType: first.partType,
|
||||
category: first.category,
|
||||
};
|
||||
}).sort((a, b) => {
|
||||
const catA = categoryOrder[a.category ?? ''] ?? 99;
|
||||
const catB = categoryOrder[b.category ?? ''] ?? 99;
|
||||
return catA - catB;
|
||||
});
|
||||
}, [materials]);
|
||||
|
||||
// 선택된 로트에 FIFO 순서로 자동 배분 계산
|
||||
// 그룹별 목표 수량 (기투입 있으면 전체 필요수량, 없으면 남은 필요수량)
|
||||
const getGroupTargetQty = useCallback((group: MaterialGroup) => {
|
||||
return group.alreadyInputted > 0 ? group.requiredQty : group.effectiveRequiredQty;
|
||||
}, []);
|
||||
|
||||
// 배정 수량 계산 (manual 우선 → 나머지 FIFO 자동배분, 물리LOT 교차그룹 추적)
|
||||
const allocations = useMemo(() => {
|
||||
const result = new Map<string, number>();
|
||||
const physicalUsed = new Map<number, number>(); // stockLotId → 그룹 간 누적 사용량
|
||||
|
||||
for (const group of materialGroups) {
|
||||
let remaining = group.effectiveRequiredQty;
|
||||
const targetQty = getGroupTargetQty(group);
|
||||
let remaining = targetQty;
|
||||
|
||||
// 1차: manual allocations 적용
|
||||
for (const lot of group.lots) {
|
||||
const lotKey = getLotKey(lot);
|
||||
if (selectedLotKeys.has(lotKey) && lot.stockLotId && remaining > 0) {
|
||||
const alloc = Math.min(lot.lotAvailableQty, remaining);
|
||||
const lotKey = getLotKey(lot, group.groupKey);
|
||||
if (selectedLotKeys.has(lotKey) && lot.stockLotId && manualAllocations.has(lotKey)) {
|
||||
const val = manualAllocations.get(lotKey)!;
|
||||
result.set(lotKey, val);
|
||||
remaining -= val;
|
||||
physicalUsed.set(lot.stockLotId, (physicalUsed.get(lot.stockLotId) || 0) + val);
|
||||
}
|
||||
}
|
||||
|
||||
// 2차: non-manual 선택 로트 FIFO 자동배분 (물리LOT 가용량 고려)
|
||||
for (const lot of group.lots) {
|
||||
const lotKey = getLotKey(lot, group.groupKey);
|
||||
if (selectedLotKeys.has(lotKey) && lot.stockLotId && !manualAllocations.has(lotKey)) {
|
||||
const itemInput = lot as unknown as MaterialForItemInput;
|
||||
const maxAvail = lot.lotAvailableQty + (itemInput.lotInputtedQty ?? 0);
|
||||
const used = physicalUsed.get(lot.stockLotId) || 0;
|
||||
const effectiveAvail = Math.max(0, maxAvail - used);
|
||||
const alloc = remaining > 0 ? Math.min(effectiveAvail, remaining) : 0;
|
||||
result.set(lotKey, alloc);
|
||||
remaining -= alloc;
|
||||
if (alloc > 0) {
|
||||
physicalUsed.set(lot.stockLotId, used + alloc);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [materialGroups, selectedLotKeys]);
|
||||
}, [materialGroups, selectedLotKeys, manualAllocations, getLotKey, getGroupTargetQty]);
|
||||
|
||||
// 전체 배정 완료 여부
|
||||
const allGroupsFulfilled = useMemo(() => {
|
||||
if (materialGroups.length === 0) return false;
|
||||
return materialGroups.every((group) => {
|
||||
const targetQty = getGroupTargetQty(group);
|
||||
if (targetQty <= 0) return true;
|
||||
const allocated = group.lots.reduce(
|
||||
(sum, lot) => sum + (allocations.get(getLotKey(lot)) || 0),
|
||||
(sum, lot) => sum + (allocations.get(getLotKey(lot, group.groupKey)) || 0),
|
||||
0
|
||||
);
|
||||
return group.effectiveRequiredQty <= 0 || allocated >= group.effectiveRequiredQty;
|
||||
return allocated >= targetQty;
|
||||
});
|
||||
}, [materialGroups, allocations]);
|
||||
}, [materialGroups, allocations, getLotKey, getGroupTargetQty]);
|
||||
|
||||
// 배정된 항목 존재 여부
|
||||
const hasAnyAllocation = useMemo(() => {
|
||||
@@ -172,6 +228,11 @@ export function MaterialInputModal({
|
||||
const next = new Set(prev);
|
||||
if (next.has(lotKey)) {
|
||||
next.delete(lotKey);
|
||||
setManualAllocations(prev => {
|
||||
const n = new Map(prev);
|
||||
n.delete(lotKey);
|
||||
return n;
|
||||
});
|
||||
} else {
|
||||
next.add(lotKey);
|
||||
}
|
||||
@@ -179,16 +240,60 @@ export function MaterialInputModal({
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 수량 수동 변경
|
||||
const handleAllocationChange = useCallback((lotKey: string, value: number, maxAvailable: number) => {
|
||||
const clamped = Math.max(0, Math.min(value, maxAvailable));
|
||||
setManualAllocations(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(lotKey, clamped);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// FIFO 자동입력 (물리LOT 교차그룹 가용량 추적)
|
||||
const handleAutoFill = useCallback(() => {
|
||||
const newSelected = new Set<string>();
|
||||
const newAllocations = new Map<string, number>();
|
||||
const physicalUsed = new Map<number, number>(); // stockLotId → 그룹 간 누적 사용량
|
||||
|
||||
for (const group of materialGroups) {
|
||||
const targetQty = getGroupTargetQty(group);
|
||||
if (targetQty <= 0) continue;
|
||||
|
||||
let remaining = targetQty;
|
||||
for (const lot of group.lots) {
|
||||
if (!lot.stockLotId || remaining <= 0) continue;
|
||||
const lotKey = getLotKey(lot, group.groupKey);
|
||||
const itemInput = lot as unknown as MaterialForItemInput;
|
||||
const maxAvail = lot.lotAvailableQty + (itemInput.lotInputtedQty ?? 0);
|
||||
const used = physicalUsed.get(lot.stockLotId) || 0;
|
||||
const effectiveAvail = Math.max(0, maxAvail - used);
|
||||
const alloc = Math.min(effectiveAvail, remaining);
|
||||
if (alloc > 0) {
|
||||
newSelected.add(lotKey);
|
||||
newAllocations.set(lotKey, alloc);
|
||||
remaining -= alloc;
|
||||
physicalUsed.set(lot.stockLotId, used + alloc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedLotKeys(newSelected);
|
||||
setManualAllocations(newAllocations);
|
||||
}, [materialGroups, getLotKey, getGroupTargetQty]);
|
||||
|
||||
// API로 자재 목록 로드
|
||||
const loadMaterials = useCallback(async () => {
|
||||
if (!order) return;
|
||||
|
||||
setIsLoading(true);
|
||||
materialsLoadedRef.current = false;
|
||||
try {
|
||||
// 목업 아이템인 경우 목업 자재 데이터 사용
|
||||
if (order.id.startsWith('mock-')) {
|
||||
setMaterials(MOCK_MATERIALS);
|
||||
setIsLoading(false);
|
||||
materialsLoadedRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -204,6 +309,7 @@ export function MaterialInputModal({
|
||||
workOrderItemId: m.workOrderItemId || itemId,
|
||||
}));
|
||||
setMaterials(tagged);
|
||||
materialsLoadedRef.current = true;
|
||||
} else {
|
||||
toast.error(result.error || '자재 목록 조회에 실패했습니다.');
|
||||
}
|
||||
@@ -212,6 +318,7 @@ export function MaterialInputModal({
|
||||
const result = await getMaterialsForWorkOrder(order.id);
|
||||
if (result.success) {
|
||||
setMaterials(result.data);
|
||||
materialsLoadedRef.current = true;
|
||||
} else {
|
||||
toast.error(result.error || '자재 목록 조회에 실패했습니다.');
|
||||
}
|
||||
@@ -230,27 +337,53 @@ export function MaterialInputModal({
|
||||
if (open && order) {
|
||||
loadMaterials();
|
||||
setSelectedLotKeys(new Set());
|
||||
setManualAllocations(new Map());
|
||||
}
|
||||
}, [open, order, loadMaterials]);
|
||||
|
||||
// 자재 로드 후 기투입 LOT 자동 선택 (그룹별 독립 처리)
|
||||
useEffect(() => {
|
||||
if (!materialsLoadedRef.current || materials.length === 0 || materialGroups.length === 0) return;
|
||||
|
||||
const preSelected = new Set<string>();
|
||||
const preAllocations = new Map<string, number>();
|
||||
|
||||
for (const group of materialGroups) {
|
||||
for (const m of group.lots) {
|
||||
const itemInput = m as unknown as MaterialForItemInput;
|
||||
const lotInputted = itemInput.lotInputtedQty ?? 0;
|
||||
if (lotInputted > 0 && m.stockLotId) {
|
||||
const lotKey = getLotKey(m, group.groupKey);
|
||||
preSelected.add(lotKey);
|
||||
preAllocations.set(lotKey, lotInputted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (preSelected.size > 0) {
|
||||
setSelectedLotKeys(prev => new Set([...prev, ...preSelected]));
|
||||
setManualAllocations(prev => new Map([...prev, ...preAllocations]));
|
||||
}
|
||||
// 한 번만 실행하도록 ref 초기화
|
||||
materialsLoadedRef.current = false;
|
||||
}, [materials, materialGroups, getLotKey]);
|
||||
|
||||
// 투입 등록
|
||||
const handleSubmit = async () => {
|
||||
if (!order) return;
|
||||
|
||||
// 배분된 로트만 추출 (dynamic_bom이면 work_order_item_id 포함)
|
||||
const inputs: { stock_lot_id: number; qty: number; work_order_item_id?: number }[] = [];
|
||||
for (const [lotKey, allocQty] of allocations) {
|
||||
if (allocQty > 0) {
|
||||
const material = materials.find((m) => getLotKey(m) === lotKey);
|
||||
if (material?.stockLotId) {
|
||||
const input: { stock_lot_id: number; qty: number; work_order_item_id?: number } = {
|
||||
stock_lot_id: material.stockLotId,
|
||||
// 배분된 로트를 그룹별 개별 엔트리로 추출 (bom_group_key 포함)
|
||||
const inputs: { stock_lot_id: number; qty: number; bom_group_key: string }[] = [];
|
||||
for (const group of materialGroups) {
|
||||
for (const lot of group.lots) {
|
||||
const lotKey = getLotKey(lot, group.groupKey);
|
||||
const allocQty = allocations.get(lotKey) || 0;
|
||||
if (allocQty > 0 && lot.stockLotId) {
|
||||
inputs.push({
|
||||
stock_lot_id: lot.stockLotId,
|
||||
qty: allocQty,
|
||||
};
|
||||
if (material.workOrderItemId) {
|
||||
input.work_order_item_id = material.workOrderItemId;
|
||||
}
|
||||
inputs.push(input);
|
||||
bom_group_key: group.groupKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -268,8 +401,8 @@ export function MaterialInputModal({
|
||||
?? (workOrderItemIds && workOrderItemIds.length > 0 ? workOrderItemIds[0] : null);
|
||||
|
||||
if (targetItemId) {
|
||||
const simpleInputs = inputs.map(({ stock_lot_id, qty }) => ({ stock_lot_id, qty }));
|
||||
result = await registerMaterialInputForItem(order.id, targetItemId, simpleInputs);
|
||||
// 기투입 LOT 있으면 replace 모드 (기존 투입 삭제 후 재등록)
|
||||
result = await registerMaterialInputForItem(order.id, targetItemId, inputs, hasPreInputted);
|
||||
} else {
|
||||
result = await registerMaterialInput(order.id, inputs);
|
||||
}
|
||||
@@ -278,18 +411,26 @@ export function MaterialInputModal({
|
||||
toast.success('자재 투입이 등록되었습니다.');
|
||||
|
||||
if (onSaveMaterials) {
|
||||
// 표시용: 같은 LOT는 합산 (자재투입목록 UI)
|
||||
const lotTotals = new Map<number, number>();
|
||||
for (const inp of inputs) {
|
||||
lotTotals.set(inp.stock_lot_id, (lotTotals.get(inp.stock_lot_id) || 0) + inp.qty);
|
||||
}
|
||||
const savedList: MaterialInput[] = [];
|
||||
for (const [lotKey, allocQty] of allocations) {
|
||||
if (allocQty > 0) {
|
||||
const material = materials.find((m) => getLotKey(m) === lotKey);
|
||||
if (material) {
|
||||
const processedLotIds = new Set<number>();
|
||||
for (const group of materialGroups) {
|
||||
for (const lot of group.lots) {
|
||||
if (!lot.stockLotId || processedLotIds.has(lot.stockLotId)) continue;
|
||||
const totalQty = lotTotals.get(lot.stockLotId) || 0;
|
||||
if (totalQty > 0) {
|
||||
processedLotIds.add(lot.stockLotId);
|
||||
savedList.push({
|
||||
id: String(material.stockLotId),
|
||||
lotNo: material.lotNo || '',
|
||||
materialName: material.materialName,
|
||||
quantity: material.lotAvailableQty,
|
||||
unit: material.unit,
|
||||
inputQuantity: allocQty,
|
||||
id: String(lot.stockLotId),
|
||||
lotNo: lot.lotNo || '',
|
||||
materialName: lot.materialName,
|
||||
quantity: lot.lotAvailableQty,
|
||||
unit: lot.unit,
|
||||
inputQuantity: totalQty,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -316,6 +457,7 @@ export function MaterialInputModal({
|
||||
|
||||
const resetAndClose = () => {
|
||||
setSelectedLotKeys(new Set());
|
||||
setManualAllocations(new Map());
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
@@ -329,9 +471,20 @@ export function MaterialInputModal({
|
||||
<DialogTitle className="text-xl font-semibold">
|
||||
자재 투입{workOrderItemName ? ` - ${workOrderItemName}` : ''}
|
||||
</DialogTitle>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
로트를 선택하면 필요수량만큼 자동 배분됩니다.
|
||||
</p>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<p className="text-sm text-gray-500">
|
||||
로트를 선택하면 필요수량만큼 자동 배분됩니다.
|
||||
</p>
|
||||
{!isLoading && materials.length > 0 && (
|
||||
<button
|
||||
onClick={handleAutoFill}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-semibold bg-blue-100 text-blue-700 rounded-full hover:bg-blue-200 transition-colors shrink-0"
|
||||
>
|
||||
<Zap className="h-3 w-3" />
|
||||
자동입력
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-6 pb-6 space-y-4 flex-1 min-h-0 flex flex-col">
|
||||
@@ -344,13 +497,22 @@ export function MaterialInputModal({
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 flex-1 overflow-y-auto min-h-0">
|
||||
{materialGroups.map((group) => {
|
||||
{materialGroups.map((group, groupIdx) => {
|
||||
// 같은 카테고리 내 순번 계산 (①②③...)
|
||||
const categoryIndex = group.category
|
||||
? materialGroups.slice(0, groupIdx).filter(g => g.category === group.category).length
|
||||
: -1;
|
||||
const circledNumbers = ['①','②','③','④','⑤','⑥','⑦','⑧','⑨','⑩'];
|
||||
const circledNum = categoryIndex >= 0 && categoryIndex < circledNumbers.length
|
||||
? circledNumbers[categoryIndex] : '';
|
||||
|
||||
const targetQty = getGroupTargetQty(group);
|
||||
const groupAllocated = group.lots.reduce(
|
||||
(sum, lot) => sum + (allocations.get(getLotKey(lot)) || 0),
|
||||
(sum, lot) => sum + (allocations.get(getLotKey(lot, group.groupKey)) || 0),
|
||||
0
|
||||
);
|
||||
const isAlreadyComplete = group.effectiveRequiredQty <= 0;
|
||||
const isFulfilled = isAlreadyComplete || groupAllocated >= group.effectiveRequiredQty;
|
||||
const isGroupComplete = targetQty <= 0 && group.alreadyInputted <= 0;
|
||||
const isFulfilled = isGroupComplete || groupAllocated >= targetQty;
|
||||
|
||||
return (
|
||||
<div key={group.groupKey} className="border rounded-lg overflow-hidden">
|
||||
@@ -372,6 +534,11 @@ export function MaterialInputModal({
|
||||
group.category}
|
||||
</span>
|
||||
)}
|
||||
{group.partType && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-200 text-gray-600 font-medium">
|
||||
{circledNum}{group.partType}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
{group.materialName}
|
||||
</span>
|
||||
@@ -387,10 +554,10 @@ export function MaterialInputModal({
|
||||
<>
|
||||
필요:{' '}
|
||||
<span className="font-semibold text-gray-900">
|
||||
{fmtQty(group.effectiveRequiredQty)}
|
||||
{fmtQty(group.requiredQty)}
|
||||
</span>{' '}
|
||||
{group.unit}
|
||||
<span className="ml-1 text-gray-400">
|
||||
<span className="ml-1 text-blue-500">
|
||||
(기투입: {fmtQty(group.alreadyInputted)})
|
||||
</span>
|
||||
</>
|
||||
@@ -406,27 +573,20 @@ export function MaterialInputModal({
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-semibold px-2 py-0.5 rounded-full flex items-center gap-1 ${
|
||||
isAlreadyComplete
|
||||
isFulfilled
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: isFulfilled
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: groupAllocated > 0
|
||||
? 'bg-amber-100 text-amber-700'
|
||||
: 'bg-gray-100 text-gray-500'
|
||||
: groupAllocated > 0
|
||||
? 'bg-amber-100 text-amber-700'
|
||||
: 'bg-gray-100 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{isAlreadyComplete ? (
|
||||
<>
|
||||
<Check className="h-3 w-3" />
|
||||
투입 완료
|
||||
</>
|
||||
) : isFulfilled ? (
|
||||
{isFulfilled ? (
|
||||
<>
|
||||
<Check className="h-3 w-3" />
|
||||
배정 완료
|
||||
</>
|
||||
) : (
|
||||
`${fmtQty(groupAllocated)} / ${fmtQty(group.effectiveRequiredQty)}`
|
||||
`${fmtQty(groupAllocated)} / ${fmtQty(targetQty)}`
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -437,7 +597,7 @@ export function MaterialInputModal({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-center w-20">선택</TableHead>
|
||||
<TableHead className="text-center w-24">선택</TableHead>
|
||||
<TableHead className="text-center">로트번호</TableHead>
|
||||
<TableHead className="text-center">가용수량</TableHead>
|
||||
<TableHead className="text-center">단위</TableHead>
|
||||
@@ -446,20 +606,24 @@ export function MaterialInputModal({
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{group.lots.map((lot, idx) => {
|
||||
const lotKey = getLotKey(lot);
|
||||
const lotKey = getLotKey(lot, group.groupKey);
|
||||
const hasStock = lot.stockLotId !== null;
|
||||
const isSelected = selectedLotKeys.has(lotKey);
|
||||
const allocated = allocations.get(lotKey) || 0;
|
||||
const canSelect = hasStock && !isAlreadyComplete && (!isFulfilled || isSelected);
|
||||
const itemInput = lot as unknown as MaterialForItemInput;
|
||||
const lotInputted = itemInput.lotInputtedQty ?? 0;
|
||||
const isPreInputted = lotInputted > 0;
|
||||
// 가용수량 = 현재 가용 + 기투입분 (replace 시 복원되므로)
|
||||
const effectiveAvailable = lot.lotAvailableQty + lotInputted;
|
||||
const canSelect = hasStock && (!isFulfilled || isSelected);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={`${lotKey}-${idx}`}
|
||||
className={
|
||||
isSelected && allocated > 0
|
||||
? 'bg-blue-50/50'
|
||||
: ''
|
||||
}
|
||||
className={cn(
|
||||
isSelected && allocated > 0 ? 'bg-blue-50/50' : '',
|
||||
isPreInputted && isSelected ? 'bg-blue-50/70' : ''
|
||||
)}
|
||||
>
|
||||
<TableCell className="text-center">
|
||||
{hasStock ? (
|
||||
@@ -467,7 +631,7 @@ export function MaterialInputModal({
|
||||
onClick={() => toggleLot(lotKey)}
|
||||
disabled={!canSelect}
|
||||
className={cn(
|
||||
'min-w-[56px] px-3 py-1.5 rounded-lg text-xs font-semibold transition-all',
|
||||
'min-w-[64px] px-3 py-1.5 rounded-lg text-xs font-semibold transition-all',
|
||||
isSelected
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: canSelect
|
||||
@@ -475,20 +639,34 @@ export function MaterialInputModal({
|
||||
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{isSelected ? '선택됨' : '선택'}
|
||||
{isSelected ? '선택완료' : '선택'}
|
||||
</button>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm">
|
||||
{lot.lotNo || (
|
||||
<span className="text-gray-400">
|
||||
재고 없음
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{lot.lotNo || (
|
||||
<span className="text-gray-400">
|
||||
재고 없음
|
||||
</span>
|
||||
)}
|
||||
{isPreInputted && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-100 text-blue-600 font-medium">
|
||||
기투입
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm">
|
||||
{hasStock ? (
|
||||
fmtQty(lot.lotAvailableQty)
|
||||
isPreInputted ? (
|
||||
<span>
|
||||
{fmtQty(lot.lotAvailableQty)}
|
||||
<span className="text-blue-500 text-xs ml-1">(+{fmtQty(lotInputted)})</span>
|
||||
</span>
|
||||
) : (
|
||||
fmtQty(lot.lotAvailableQty)
|
||||
)
|
||||
) : (
|
||||
<span className="text-red-500">0</span>
|
||||
)}
|
||||
@@ -497,7 +675,19 @@ export function MaterialInputModal({
|
||||
{lot.unit}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm font-medium">
|
||||
{allocated > 0 ? (
|
||||
{isSelected && hasStock ? (
|
||||
<input
|
||||
type="number"
|
||||
value={allocated || ''}
|
||||
onChange={(e) => {
|
||||
const val = parseFloat(e.target.value) || 0;
|
||||
handleAllocationChange(lotKey, val, effectiveAvailable);
|
||||
}}
|
||||
className="w-20 text-center text-blue-600 font-semibold border border-blue-200 rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-400 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
min={0}
|
||||
max={effectiveAvailable}
|
||||
/>
|
||||
) : allocated > 0 ? (
|
||||
<span className="text-blue-600">
|
||||
{fmtQty(allocated)}
|
||||
</span>
|
||||
@@ -529,7 +719,7 @@ export function MaterialInputModal({
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !hasAnyAllocation}
|
||||
disabled={isSubmitting || !allGroupsFulfilled || !hasAnyAllocation}
|
||||
className="flex-1 py-6 text-base font-medium bg-gray-900 hover:bg-gray-800"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
@@ -546,4 +736,4 @@ export function MaterialInputModal({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ export function WorkCard({
|
||||
{/* 헤더 박스: 품목명 + 수량 */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-100">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{order.productName}
|
||||
{order.productCode !== '-' ? order.productCode : order.productName}
|
||||
</h3>
|
||||
<div className="text-right">
|
||||
<span className="text-2xl font-bold text-gray-900">{order.quantity}</span>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useCallback, memo } from 'react';
|
||||
import { ChevronDown, ChevronUp, SquarePen, Trash2, ImageIcon } from 'lucide-react';
|
||||
import { ChevronDown, ChevronUp, SquarePen, Trash2 } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -291,45 +291,26 @@ import type { BendingInfo, WipInfo } from './types';
|
||||
function BendingExtraInfo({ info }: { info: BendingInfo }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 도면 + 공통사항 (가로 배치) */}
|
||||
<div className="flex gap-3">
|
||||
{/* 도면 이미지 */}
|
||||
<div className="flex-shrink-0 w-24 h-24 border rounded-lg bg-gray-50 flex items-center justify-center overflow-hidden">
|
||||
{info.drawingUrl ? (
|
||||
<img
|
||||
src={info.drawingUrl}
|
||||
alt="도면"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-1 text-gray-400">
|
||||
<ImageIcon className="h-6 w-6" />
|
||||
<span className="text-[10px]">도면</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 공통사항 */}
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<p className="text-xs font-medium text-gray-500">공통사항</p>
|
||||
<div className="space-y-1">
|
||||
<div className="flex gap-2 text-xs">
|
||||
<span className="text-gray-500 w-14">종류</span>
|
||||
<span className="text-gray-900 font-medium">{info.common.kind}</span>
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs">
|
||||
<span className="text-gray-500 w-14">유형</span>
|
||||
<span className="text-gray-900 font-medium">{info.common.type}</span>
|
||||
</div>
|
||||
{info.common.lengthQuantities.map((lq, i) => (
|
||||
<div key={i} className="flex gap-2 text-xs">
|
||||
<span className="text-gray-500 w-14">{i === 0 ? '길이별 수량' : ''}</span>
|
||||
<span className="text-gray-900 font-medium">
|
||||
{formatNumber(lq.length)}mm X {lq.quantity}개
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{/* 공통사항 */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500">공통사항</p>
|
||||
<div className="space-y-1 mt-1.5">
|
||||
<div className="flex gap-2 text-xs">
|
||||
<span className="text-gray-500 w-14">종류</span>
|
||||
<span className="text-gray-900 font-medium">{info.common.kind}</span>
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs">
|
||||
<span className="text-gray-500 w-14">유형</span>
|
||||
<span className="text-gray-900 font-medium">{info.common.type}</span>
|
||||
</div>
|
||||
{info.common.lengthQuantities.map((lq, i) => (
|
||||
<div key={i} className="flex gap-2 text-xs">
|
||||
<span className="text-gray-500 w-14">{i === 0 ? '길이별 수량' : ''}</span>
|
||||
<span className="text-gray-900 font-medium">
|
||||
{formatNumber(lq.length)}mm X {lq.quantity}개
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -361,35 +342,16 @@ function BendingExtraInfo({ info }: { info: BendingInfo }) {
|
||||
// ===== 재공품 전용: 도면 + 공통사항 (규격, 길이별 수량) =====
|
||||
function WipExtraInfo({ info }: { info: WipInfo }) {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
{/* 도면 이미지 (큰 영역) */}
|
||||
<div className="flex-1 min-h-[160px] border rounded-lg bg-gray-50 flex items-center justify-center overflow-hidden">
|
||||
{info.drawingUrl ? (
|
||||
<img
|
||||
src={info.drawingUrl}
|
||||
alt="도면"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-1 text-gray-400">
|
||||
<ImageIcon className="h-8 w-8" />
|
||||
<span className="text-xs">IMG</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 공통사항 */}
|
||||
<div className="flex-1 space-y-0">
|
||||
<p className="text-xs font-medium text-gray-500 mb-2">공통사항</p>
|
||||
<div className="border rounded-lg divide-y">
|
||||
<div className="flex">
|
||||
<span className="px-3 py-2.5 text-xs text-gray-500 bg-gray-50 w-20 flex-shrink-0 border-r">규격</span>
|
||||
<span className="px-3 py-2.5 text-xs font-semibold text-gray-900 flex-1 text-right">{info.specification}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="px-3 py-2.5 text-xs text-gray-500 bg-gray-50 w-20 flex-shrink-0 border-r">길이별 수량</span>
|
||||
<span className="px-3 py-2.5 text-xs font-semibold text-gray-900 flex-1 text-right">{info.lengthQuantity}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 mb-2">공통사항</p>
|
||||
<div className="border rounded-lg divide-y">
|
||||
<div className="flex">
|
||||
<span className="px-3 py-2.5 text-xs text-gray-500 bg-gray-50 w-20 flex-shrink-0 border-r">규격</span>
|
||||
<span className="px-3 py-2.5 text-xs font-semibold text-gray-900 flex-1 text-right">{info.specification}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="px-3 py-2.5 text-xs text-gray-500 bg-gray-50 w-20 flex-shrink-0 border-r">길이별 수량</span>
|
||||
<span className="px-3 py-2.5 text-xs font-semibold text-gray-900 flex-1 text-right">{info.lengthQuantity}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -74,8 +74,10 @@ export function WorkOrderListPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 제품코드 - 제품명 */}
|
||||
<p className="text-sm text-gray-600 truncate ml-8">{order.productCode} - {order.productName}</p>
|
||||
{/* 제품코드 (제품명) */}
|
||||
<p className="text-sm text-gray-600 truncate ml-8">
|
||||
{order.productCode !== '-' ? order.productCode : order.productName}
|
||||
</p>
|
||||
|
||||
{/* 현장명 + 수량 */}
|
||||
<div className="flex items-center justify-between mt-1.5 ml-8">
|
||||
|
||||
@@ -316,6 +316,8 @@ export async function registerMaterialInput(
|
||||
export interface MaterialForItemInput extends MaterialForInput {
|
||||
alreadyInputted: number; // 이미 투입된 수량
|
||||
remainingRequiredQty: number; // 남은 필요 수량
|
||||
lotInputtedQty: number; // 해당 LOT의 기투입 수량
|
||||
bomGroupKey?: string; // BOM 엔트리별 고유 그룹키 (category+partType 기반)
|
||||
}
|
||||
|
||||
export async function getMaterialsForItem(
|
||||
@@ -330,12 +332,13 @@ export async function getMaterialsForItem(
|
||||
stock_lot_id: number | null; item_id: number; lot_no: string | null;
|
||||
material_code: string; material_name: string; specification: string;
|
||||
unit: string; bom_qty: number; required_qty: number;
|
||||
already_inputted: number; remaining_required_qty: number;
|
||||
already_inputted: number; remaining_required_qty: number; lot_inputted_qty: number;
|
||||
lot_available_qty: number; fifo_rank: number;
|
||||
lot_qty: number; lot_reserved_qty: number;
|
||||
receipt_date: string | null; supplier: string | null;
|
||||
// dynamic_bom 추가 필드
|
||||
work_order_item_id?: number; lot_prefix?: string; part_type?: string; category?: string;
|
||||
bom_group_key?: string;
|
||||
}
|
||||
const result = await executeServerAction<MaterialItemApiItem[]>({
|
||||
url: `${API_URL}/api/v1/work-orders/${workOrderId}/items/${itemId}/materials`,
|
||||
@@ -352,8 +355,10 @@ export async function getMaterialsForItem(
|
||||
fifoRank: item.fifo_rank,
|
||||
alreadyInputted: item.already_inputted,
|
||||
remainingRequiredQty: item.remaining_required_qty,
|
||||
lotInputtedQty: item.lot_inputted_qty ?? 0,
|
||||
workOrderItemId: item.work_order_item_id, lotPrefix: item.lot_prefix,
|
||||
partType: item.part_type, category: item.category,
|
||||
bomGroupKey: item.bom_group_key,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -362,12 +367,13 @@ export async function getMaterialsForItem(
|
||||
export async function registerMaterialInputForItem(
|
||||
workOrderId: string,
|
||||
itemId: number,
|
||||
inputs: { stock_lot_id: number; qty: number }[]
|
||||
inputs: { stock_lot_id: number; qty: number; bom_group_key?: string }[],
|
||||
replace = false
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const result = await executeServerAction({
|
||||
url: `${API_URL}/api/v1/work-orders/${workOrderId}/items/${itemId}/material-inputs`,
|
||||
method: 'POST',
|
||||
body: { inputs },
|
||||
body: { inputs, replace },
|
||||
errorMessage: '개소별 자재 투입 등록에 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, error: result.error };
|
||||
@@ -672,9 +678,9 @@ export async function getWorkOrderDetail(
|
||||
}
|
||||
if (opts.is_wip) {
|
||||
workItem.isWip = true;
|
||||
const wi = opts.wip_info as { specification: string; length_quantity: string; drawing_url?: string } | undefined;
|
||||
const wi = opts.wip_info as { specification: string; length_quantity: string } | undefined;
|
||||
if (wi) {
|
||||
workItem.wipInfo = { specification: wi.specification, lengthQuantity: wi.length_quantity, drawingUrl: wi.drawing_url };
|
||||
workItem.wipInfo = { specification: wi.specification, lengthQuantity: wi.length_quantity };
|
||||
}
|
||||
}
|
||||
if (opts.is_joint_bar) {
|
||||
|
||||
@@ -45,7 +45,7 @@ import { getMyWorkOrders, completeWorkOrder, saveItemInspection, getWorkOrderIns
|
||||
import type { StepProgressItem, DepartmentOption, DepartmentUser } from './actions';
|
||||
import type { InspectionTemplateData } from './types';
|
||||
import { getProcessList } from '@/components/process-management/actions';
|
||||
import type { InspectionSetting, Process } from '@/types/process';
|
||||
import type { InspectionSetting, InspectionScope, Process } from '@/types/process';
|
||||
import type { WorkOrder } from '../ProductionDashboard/types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import type {
|
||||
@@ -416,6 +416,8 @@ export default function WorkerScreen() {
|
||||
const [isInspectionInputModalOpen, setIsInspectionInputModalOpen] = useState(false);
|
||||
// 공정의 중간검사 설정
|
||||
const [currentInspectionSetting, setCurrentInspectionSetting] = useState<InspectionSetting | undefined>();
|
||||
// 공정의 검사 범위 설정
|
||||
const [currentInspectionScope, setCurrentInspectionScope] = useState<InspectionScope | undefined>();
|
||||
// 문서 템플릿 데이터 (document_template 기반 동적 검사용)
|
||||
const [inspectionTemplateData, setInspectionTemplateData] = useState<InspectionTemplateData | undefined>();
|
||||
const [inspectionDimensions, setInspectionDimensions] = useState<{ width?: number; height?: number }>({});
|
||||
@@ -513,8 +515,10 @@ export default function WorkerScreen() {
|
||||
(step) => step.needsInspection && step.inspectionSetting
|
||||
);
|
||||
setCurrentInspectionSetting(inspectionStep?.inspectionSetting);
|
||||
setCurrentInspectionScope(inspectionStep?.inspectionScope);
|
||||
} else {
|
||||
setCurrentInspectionSetting(undefined);
|
||||
setCurrentInspectionScope(undefined);
|
||||
}
|
||||
}, [activeTab, processListCache]);
|
||||
|
||||
@@ -714,7 +718,7 @@ export default function WorkerScreen() {
|
||||
workOrderId: selectedOrder.id,
|
||||
itemNo: index + 1,
|
||||
itemCode: selectedOrder.orderNo || '-',
|
||||
itemName: `${selectedOrder.productCode !== '-' ? selectedOrder.productCode + ' - ' : ''}${itemSummary}`,
|
||||
itemName: selectedOrder.productCode !== '-' ? selectedOrder.productCode : itemSummary,
|
||||
floor: (opts.floor as string) || '-',
|
||||
code: (opts.code as string) || '-',
|
||||
width: (opts.width as number) || 0,
|
||||
@@ -740,15 +744,15 @@ export default function WorkerScreen() {
|
||||
detail_parts: { part_name: string; material: string; barcy_info: string }[];
|
||||
};
|
||||
workItem.bendingInfo = {
|
||||
common: { kind: bi.common.kind, type: bi.common.type, lengthQuantities: bi.common.length_quantities || [] },
|
||||
common: { kind: bi.common?.kind || '', type: bi.common?.type || '', lengthQuantities: bi.common?.length_quantities || [] },
|
||||
detailParts: (bi.detail_parts || []).map(dp => ({ partName: dp.part_name, material: dp.material, barcyInfo: dp.barcy_info })),
|
||||
};
|
||||
}
|
||||
if (opts.is_wip) {
|
||||
workItem.isWip = true;
|
||||
const wi = opts.wip_info as { specification: string; length_quantity: string; drawing_url?: string } | undefined;
|
||||
const wi = opts.wip_info as { specification: string; length_quantity: string } | undefined;
|
||||
if (wi) {
|
||||
workItem.wipInfo = { specification: wi.specification, lengthQuantity: wi.length_quantity, drawingUrl: wi.drawing_url };
|
||||
workItem.wipInfo = { specification: wi.specification, lengthQuantity: wi.length_quantity };
|
||||
}
|
||||
}
|
||||
if (opts.is_joint_bar) {
|
||||
@@ -774,7 +778,7 @@ export default function WorkerScreen() {
|
||||
workOrderId: selectedOrder.id,
|
||||
itemNo: 1,
|
||||
itemCode: selectedOrder.orderNo || '-',
|
||||
itemName: `${selectedOrder.productCode !== '-' ? selectedOrder.productCode + ' - ' : ''}${selectedOrder.productName || '-'}`,
|
||||
itemName: selectedOrder.productCode !== '-' ? selectedOrder.productCode : (selectedOrder.productName || '-'),
|
||||
floor: '-',
|
||||
code: '-',
|
||||
width: 0,
|
||||
@@ -809,6 +813,50 @@ export default function WorkerScreen() {
|
||||
return [...apiItems, ...mockItems];
|
||||
}, [filteredWorkOrders, selectedSidebarOrderId, activeProcessTabKey, stepCompletionMap, bendingSubMode, slatSubMode, activeProcessSteps, inputMaterialsMap, stepProgressMap]);
|
||||
|
||||
// ===== 검사 범위(scope) 기반 검사 단계 활성화/비활성화 =====
|
||||
// 수주 단위로 적용: API 아이템(실제 수주 개소)에만 scope 적용
|
||||
// 목업 아이템은 각각 독립 1개소이므로 항상 검사 버튼 유지
|
||||
const scopedWorkItems: WorkItemData[] = useMemo(() => {
|
||||
if (!currentInspectionScope || currentInspectionScope.type === 'all') {
|
||||
return workItems;
|
||||
}
|
||||
|
||||
// 실제 수주 아이템만 분리 (목업 제외)
|
||||
const apiItems = workItems.filter((item) => !item.id.startsWith('mock-'));
|
||||
const apiCount = apiItems.length;
|
||||
if (apiCount === 0) return workItems;
|
||||
|
||||
// 검사 단계를 아예 제거하는 헬퍼
|
||||
const removeInspectionSteps = (item: WorkItemData): WorkItemData => ({
|
||||
...item,
|
||||
steps: item.steps.filter((step) => !step.isInspection && !step.needsInspection),
|
||||
});
|
||||
|
||||
let realIdx = 0;
|
||||
|
||||
if (currentInspectionScope.type === 'sampling') {
|
||||
const sampleSize = currentInspectionScope.sampleSize || 1;
|
||||
return workItems.map((item) => {
|
||||
// 목업은 독립 1개소 → 검사 유지
|
||||
if (item.id.startsWith('mock-')) return item;
|
||||
const isInSampleRange = realIdx >= apiCount - sampleSize;
|
||||
realIdx++;
|
||||
return isInSampleRange ? item : removeInspectionSteps(item);
|
||||
});
|
||||
}
|
||||
|
||||
if (currentInspectionScope.type === 'group') {
|
||||
return workItems.map((item) => {
|
||||
if (item.id.startsWith('mock-')) return item;
|
||||
const isLast = realIdx === apiCount - 1;
|
||||
realIdx++;
|
||||
return isLast ? item : removeInspectionSteps(item);
|
||||
});
|
||||
}
|
||||
|
||||
return workItems;
|
||||
}, [workItems, currentInspectionScope]);
|
||||
|
||||
// ===== 작업지시 선택 시 기존 검사 데이터 로드 =====
|
||||
// workItems 선언 이후에 위치해야 workItems.length 참조 가능
|
||||
// workItems.length 의존성: selectedSidebarOrderId 변경 시점에 workItems가 아직 비어있을 수 있음
|
||||
@@ -827,20 +875,39 @@ export default function WorkerScreen() {
|
||||
const completionUpdates: Record<string, boolean> = {};
|
||||
setInspectionDataMap((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const apiItem of result.data!.items) {
|
||||
if (!apiItem.inspection_data) continue;
|
||||
// workItems에서 apiItemId가 일치하는 항목 찾기
|
||||
const match = workItems.find((w) => w.apiItemId === apiItem.item_id);
|
||||
if (match) {
|
||||
next.set(match.id, apiItem.inspection_data as unknown as InspectionData);
|
||||
// 검사 step 완료 처리 (실제 step name 사용)
|
||||
const inspStep = match.steps.find((s) => s.isInspection || s.needsInspection);
|
||||
if (inspStep) {
|
||||
const stepKey = `${match.id.replace('-node-', '-')}-${inspStep.name}`;
|
||||
completionUpdates[stepKey] = true;
|
||||
|
||||
// 절곡 공정: 수주 단위 검사 → 어떤 item이든 inspection_data 있으면 모든 개소가 공유
|
||||
const isBendingProcess = workItems.some(w => w.processType === 'bending');
|
||||
if (isBendingProcess) {
|
||||
const bendingItem = result.data!.items.find(i => i.inspection_data);
|
||||
if (bendingItem?.inspection_data) {
|
||||
for (const w of workItems) {
|
||||
next.set(w.id, bendingItem.inspection_data as unknown as InspectionData);
|
||||
const inspStep = w.steps.find((s) => s.isInspection || s.needsInspection);
|
||||
if (inspStep) {
|
||||
const stepKey = `${w.id.replace('-node-', '-')}-${inspStep.name}`;
|
||||
completionUpdates[stepKey] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 기존: item별 개별 매칭
|
||||
for (const apiItem of result.data!.items) {
|
||||
if (!apiItem.inspection_data) continue;
|
||||
// workItems에서 apiItemId가 일치하는 항목 찾기
|
||||
const match = workItems.find((w) => w.apiItemId === apiItem.item_id);
|
||||
if (match) {
|
||||
next.set(match.id, apiItem.inspection_data as unknown as InspectionData);
|
||||
// 검사 step 완료 처리 (실제 step name 사용)
|
||||
const inspStep = match.steps.find((s) => s.isInspection || s.needsInspection);
|
||||
if (inspStep) {
|
||||
const stepKey = `${match.id.replace('-node-', '-')}-${inspStep.name}`;
|
||||
completionUpdates[stepKey] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
// stepCompletionMap 일괄 업데이트
|
||||
@@ -1300,9 +1367,18 @@ export default function WorkerScreen() {
|
||||
`${selectedOrder.id.replace('-node-', '-')}-${stepName}`;
|
||||
|
||||
// 메모리에 즉시 반영
|
||||
// 절곡: 수주 단위 검사 → 모든 개소가 동일한 검사 데이터 공유
|
||||
const inspProcessType = getInspectionProcessType();
|
||||
const isBendingInsp = inspProcessType === 'bending' || inspProcessType === 'bending_wip';
|
||||
setInspectionDataMap((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(selectedOrder.id, data);
|
||||
if (isBendingInsp) {
|
||||
for (const w of workItems) {
|
||||
next.set(w.id, data);
|
||||
}
|
||||
} else {
|
||||
next.set(selectedOrder.id, data);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
@@ -1601,14 +1677,14 @@ export default function WorkerScreen() {
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
{workItems.map((item, index) => {
|
||||
{scopedWorkItems.map((item, index) => {
|
||||
const isFirstMock = item.id.startsWith('mock-') &&
|
||||
(index === 0 || !workItems[index - 1]?.id.startsWith('mock-'));
|
||||
(index === 0 || !scopedWorkItems[index - 1]?.id.startsWith('mock-'));
|
||||
return (
|
||||
<div key={item.id}>
|
||||
{isFirstMock && (
|
||||
<div className="mb-3 pt-1 space-y-2">
|
||||
{workItems.some((i) => !i.id.startsWith('mock-')) && <div className="border-t border-dashed border-gray-300" />}
|
||||
{scopedWorkItems.some((i) => !i.id.startsWith('mock-')) && <div className="border-t border-dashed border-gray-300" />}
|
||||
<span className="text-[10px] font-semibold text-orange-600 bg-orange-50 px-2 py-0.5 rounded inline-block">
|
||||
목업 데이터
|
||||
</span>
|
||||
@@ -1763,12 +1839,13 @@ export default function WorkerScreen() {
|
||||
open={isInspectionInputModalOpen}
|
||||
onOpenChange={setIsInspectionInputModalOpen}
|
||||
processType={getInspectionProcessType()}
|
||||
productName={selectedOrder?.productName || workItems[0]?.itemName || ''}
|
||||
productName={selectedOrder?.productCode && selectedOrder.productCode !== '-' ? selectedOrder.productCode : (selectedOrder?.productName || workItems[0]?.itemName || '')}
|
||||
specification={workItems[0]?.slatJointBarInfo?.specification || workItems[0]?.wipInfo?.specification || ''}
|
||||
initialData={selectedOrder ? inspectionDataMap.get(selectedOrder.id) : undefined}
|
||||
onComplete={handleInspectionComplete}
|
||||
templateData={inspectionTemplateData}
|
||||
workItemDimensions={inspectionDimensions}
|
||||
workOrderId={workItems.find(w => w.id === selectedOrder?.id)?.workOrderId}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
|
||||
@@ -62,7 +62,6 @@ export interface WorkItemData {
|
||||
|
||||
// ===== 재공품 전용 정보 =====
|
||||
export interface WipInfo {
|
||||
drawingUrl?: string; // 도면 이미지 URL
|
||||
specification: string; // 규격 (EGI 1.55T (W576))
|
||||
lengthQuantity: string; // 길이별 수량 (4,000mm X 6개)
|
||||
}
|
||||
@@ -90,7 +89,6 @@ export interface SlatJointBarInfo {
|
||||
|
||||
// ===== 절곡 전용 정보 =====
|
||||
export interface BendingInfo {
|
||||
drawingUrl?: string; // 도면 이미지 URL
|
||||
common: BendingCommonInfo; // 공통사항
|
||||
detailParts: BendingDetailPart[]; // 세부부품
|
||||
}
|
||||
|
||||
@@ -192,6 +192,8 @@ export interface ProcessStep {
|
||||
completionType: StepCompletionType;
|
||||
// 검사 설정 (검사여부가 true일 때)
|
||||
inspectionSetting?: InspectionSetting;
|
||||
// 검사 범위 (검사여부가 true일 때)
|
||||
inspectionScope?: InspectionScope;
|
||||
}
|
||||
|
||||
// 연결 유형 옵션
|
||||
@@ -295,6 +297,35 @@ export const INSPECTION_METHOD_OPTIONS: { value: InspectionMethodType; label: st
|
||||
{ value: '양자택일', label: '양자택일' },
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// 검사 범위 (Inspection Scope) 타입 정의
|
||||
// ============================================================================
|
||||
|
||||
// 검사 범위 유형
|
||||
export type InspectionScopeType = 'all' | 'sampling' | 'group';
|
||||
|
||||
// 샘플 기준
|
||||
export type InspectionSampleBase = 'order' | 'lot';
|
||||
|
||||
// 검사 범위 설정
|
||||
export interface InspectionScope {
|
||||
type: InspectionScopeType; // 전수검사 | 샘플링 | 그룹
|
||||
sampleSize?: number; // 샘플 크기 (n값, sampling일 때만)
|
||||
sampleBase?: InspectionSampleBase; // 샘플 기준 (order | lot)
|
||||
}
|
||||
|
||||
// 검사 범위 유형 옵션
|
||||
export const INSPECTION_SCOPE_TYPE_OPTIONS: { value: InspectionScopeType; label: string; description: string }[] = [
|
||||
{ value: 'all', label: '전수검사', description: '모든 개소 검사' },
|
||||
{ value: 'sampling', label: '샘플링', description: '마지막 N개 개소만 검사' },
|
||||
{ value: 'group', label: '그룹', description: '그룹 마지막 개소만 검사' },
|
||||
];
|
||||
|
||||
// 기본 검사 범위
|
||||
export const DEFAULT_INSPECTION_SCOPE: InspectionScope = {
|
||||
type: 'all',
|
||||
};
|
||||
|
||||
// 기본 검사 설정값
|
||||
export const DEFAULT_INSPECTION_SETTING: InspectionSetting = {
|
||||
standardName: '',
|
||||
|
||||
Reference in New Issue
Block a user