15 Commits

Author SHA1 Message Date
03d129c32c fix: [outbound] 출하관리 캘린더 기본 뷰 week-time으로 변경 2026-03-05 11:01:27 +09:00
d6e3131c6a fix: [production] 절곡 중간검사 데이터 새로고침 시 초기화 버그 수정
- InspectionInputModal: 이전 형식 데이터(products 배열 없음) 로드 시 judgment 기반 제품별 상태 추론
- InspectionInputModal: skipAutoJudgmentRef로 이전 형식 로드 시 auto-judgment 덮어쓰기 방지
- BendingInspectionContent: products/bendingStatus 없을 때 judgment 기반 fallback 추가
2026-03-05 10:39:45 +09:00
1d3805781c feat: [outbound] 배차차량관리 목업→API 연동 전환
- mockData 제거, executePaginatedAction/executeServerAction 사용
- buildApiUrl로 쿼리 파라미터 빌드
- API 응답(snake_case) → 프론트 타입(camelCase) 변환 함수 추가
2026-03-04 23:36:20 +09:00
b45c35a5e8 fix: [production] 절곡 중간검사 수주 단위 데이터 공유 모델 적용
- 로드 경로: 절곡 공정 시 어떤 item이든 inspection_data 있으면 모든 개소에 공유
- 저장 경로: 절곡 검사 완료 시 inspectionDataMap에 모든 workItem 동기화
- TemplateInspectionContent: products 배열 우선 복원 (EAV 문서 데이터보다 우선)
- workOrderId prop 추가 (절곡 gap_points API 동적 로딩)
2026-03-04 23:27:12 +09:00
b05e19e9f8 fix: [quality] QMS mockData에 productCode 필드 누락 수정
WorkOrder 타입 필수 필드 productCode 추가하여 빌드 오류 해결
2026-03-04 22:40:23 +09:00
4331b84a63 feat: [production] 절곡 중간검사 입력 모달 — 7개 제품 항목 통합 및 성적서 데이터 연동
- InspectionInputModal: 절곡 전용 7개 제품별 입력 폼 (절곡상태/길이/너비/간격)
- TemplateInspectionContent: products 배열 → bending cellValues 자동 매핑
- 제품 ID 3단계 매칭 (정규화→키워드→인덱스 폴백)
- 절곡 작업지시서 bending 섹션 개선
2026-03-04 22:28:16 +09:00
0b81e9c1dd feat: [process] 공정 단계에 검사범위(InspectionScope) 설정 추가
- 전수검사/샘플링/그룹 유형 선택 UI
- 샘플링 시 샘플 크기(n) 입력
- options JSON으로 API 저장/복원
2026-03-04 22:28:16 +09:00
f653960a30 fix: [shipment] 배차 상세/수정 기본정보 그리드 레이아웃 개선 (1열→2x4열) 2026-03-04 22:28:16 +09:00
888fae119f chore: next dev에서 --turbo 플래그 제거 2026-03-04 22:28:16 +09:00
f503e20030 fix: [production] 작업자 화면 하드코딩 도면 이미지 영역 제거
- BendingExtraInfo, WipExtraInfo에서 drawingUrl 도면 이미지 div 제거
- types.ts에서 drawingUrl 필드 제거
- actions.ts, index.tsx에서 drawing_url 매핑 제거
2026-03-04 22:28:16 +09:00
0166601be8 fix: [production] 자재투입 모달 — 동일 자재 다중 BOM 그룹 LOT 독립 관리
- getLotKey에 groupKey 포함하여 그룹별 LOT 선택/배정 독립 처리
- physicalUsed 맵으로 물리LOT 교차그룹 가용량 추적
- handleAutoFill FIFO 자동입력 (교차그룹 가용량 고려)
- handleSubmit 그룹별 개별 엔트리 전송 (bom_group_key 포함, replace 모드)
- 기투입 LOT 자동 선택 및 배지 표시, 수량 수동 편집 input
- allGroupsFulfilled 조건으로 투입 버튼 활성화 제어
- actions.ts: lotInputtedQty 필드 + bom_group_key/replace 파라미터 추가
2026-03-04 22:28:16 +09:00
83a23701a7 feat: [shipment] 배차정보 다중 행 API 연동 — actions.ts transform 함수 수정
- ShipmentApiData에 vehicle_dispatches 타입 추가
- transformApiToDetail: vehicle_dispatches 배열 매핑 (레거시 단일필드 fallback 유지)
- transformCreateFormToApi/transformEditFormToApi: vehicleDispatches → vehicle_dispatches 변환 추가
- transformApiToListItem: 첫 번째 배차의 arrival_datetime 반영
2026-03-04 22:28:16 +09:00
bedfd1f559 fix: [production] 작업자 화면 제품명 표시 간소화 — productCode만 표시
작업목록, 상세카드, 자재투입, 중간검사 모달에서 부품 목록까지 길게
표시되던 제품명을 productCode만 표시하도록 변경
2026-03-04 22:28:16 +09:00
8bcabafd08 fix: [production] 자재투입 모달 — bomGroupKey 그룹핑, 카테고리 순서 정렬, 번호 표시
- bomGroupKey 기반 그룹핑 (같은 item_id라도 category+partType별 분리)
- 카테고리 순서 정렬 (가이드레일→하단마감재→셔터박스→연기차단재)
- 카테고리 내 원형번호(①②③) 표시
- partType 배지 추가
- MaterialForItemInput에 bomGroupKey 필드 추가
2026-03-04 22:28:16 +09:00
5ff5093d7b fix: [출고관리] 목록 테이블 수신자/수신주소/수신처/작성자/출고일 API 매핑 연동
- OrderInfoApiData에 writer_name, writer_id, delivery_date 필드 추가
- transformApiToListItem에서 5개 필드 매핑 누락 수정
2026-03-04 22:28:16 +09:00
25 changed files with 1249 additions and 399 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 변경 시 캘린더 월 자동 이동

View File

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

View File

@@ -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: '배차차량 수정에 실패했습니다.',
});
}

View File

@@ -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,
]
);

View File

@@ -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}`),

View File

@@ -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,
})));
}
}
// 부적합 내용 로드

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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] : '-';
}

View File

@@ -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>
)}
{/* 부적합 내용 */}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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[]; // 세부부품
}

View File

@@ -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: '',