feat: 견적확정 밸리데이션, 수주등록 개소그룹, 작업지시 개선

- 견적확정 시 업체명/현장명/담당자/연락처 프론트 밸리데이션 추가
- 견적확정 후 수주등록 버튼 동적 전환
- 수주등록 품목 개소별(floor+code) 그룹핑 수정
- 작업지시 상세 quantity 문자열→숫자 변환 (formatQuantity)
- 작업지시 탭 카운트 초기 로딩 시 전체 표시 (by_process 활용)
- 작업지시 상세 개소별/품목별 합산 테이블 추가
- 작업자 화면 API 연동 및 목업 데이터 분리
- 입고관리 완료건 수정, 재고현황 개선
This commit is contained in:
2026-02-07 03:27:23 +09:00
parent b2085a84ca
commit a8591c438e
29 changed files with 3238 additions and 700 deletions

View File

@@ -20,7 +20,7 @@ import { Upload, FileText, Search, X, Plus, ClipboardCheck } from 'lucide-react'
import { FileDropzone } from '@/components/ui/file-dropzone';
import { ItemSearchModal } from '@/components/quotes/ItemSearchModal';
import { InspectionModalV2 } from '@/app/[locale]/(protected)/quality/qms/components/InspectionModalV2';
import { ImportInspectionInputModal, type ImportInspectionData } from './ImportInspectionInputModal';
import { ImportInspectionInputModal } from './ImportInspectionInputModal';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
@@ -41,6 +41,7 @@ import {
getReceivingById,
createReceiving,
updateReceiving,
checkInspectionTemplate,
} from './actions';
import {
Table,
@@ -68,6 +69,7 @@ interface Props {
// 초기 폼 데이터
const INITIAL_FORM_DATA: Partial<ReceivingDetailType> = {
materialNo: '',
supplierMaterialNo: '',
lotNo: '',
itemCode: '',
itemName: '',
@@ -96,19 +98,23 @@ function generateLotNo(): string {
return `${yy}${mm}${dd}-${seq}`;
}
// localStorage에서 로그인 사용자 가져오기
function getLoggedInUserName(): string {
if (typeof window === 'undefined') return '';
// localStorage에서 로그인 사용자 정보 가져오기
function getLoggedInUser(): { name: string; department: string } {
if (typeof window === 'undefined') return { name: '', department: '' };
try {
const userData = localStorage.getItem('user');
if (userData) {
const parsed = JSON.parse(userData);
return parsed.name || '';
return { name: parsed.name || '', department: parsed.department || '' };
}
} catch {
// ignore
}
return '';
return { name: '', department: '' };
}
function getLoggedInUserName(): string {
return getLoggedInUser().name;
}
export function ReceivingDetail({ id, mode = 'view' }: Props) {
@@ -136,6 +142,17 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
const [isItemSearchOpen, setIsItemSearchOpen] = useState(false);
const [isSupplierSearchOpen, setIsSupplierSearchOpen] = useState(false);
// 수입검사 성적서 템플릿 존재 여부
const [hasInspectionTemplate, setHasInspectionTemplate] = useState(false);
// 수입검사 첨부파일 (document_attachments)
const [inspectionAttachments, setInspectionAttachments] = useState<Array<{
id: number;
file_id: number;
attachment_type: string;
file?: { id: number; display_name?: string; original_name?: string; file_path: string; file_size: number; mime_type?: string };
}>>([]);
// 재고 조정 이력 상태
const [adjustments, setAdjustments] = useState<InventoryAdjustmentRecord[]>([]);
@@ -185,6 +202,7 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
if (isEditMode) {
setFormData({
materialNo: result.data.materialNo || '',
supplierMaterialNo: result.data.supplierMaterialNo || '',
lotNo: result.data.lotNo || '',
itemCode: result.data.itemCode,
itemName: result.data.itemName,
@@ -202,6 +220,17 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
certificateFile: result.data.certificateFile,
});
}
// 수입검사 성적서 템플릿 존재 여부 + 첨부파일 확인
if (result.data.itemId) {
const templateCheck = await checkInspectionTemplate(result.data.itemId);
setHasInspectionTemplate(templateCheck.hasTemplate);
if (templateCheck.attachments && templateCheck.attachments.length > 0) {
setInspectionAttachments(templateCheck.attachments);
}
} else {
setHasInspectionTemplate(false);
}
} else {
setError(result.error || '입고 정보를 찾을 수 없습니다.');
}
@@ -227,41 +256,37 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
}));
};
// 저장 핸들러 - IntegratedDetailTemplate의 onSubmit에서 호출
// 반환값으로 성공/실패를 전달하여 템플릿이 toast/navigation 처리
// 저장 핸들러 - 결과 반환
const handleSave = async (): Promise<{ success: boolean; error?: string }> => {
// 클라이언트 사이드 필수 필드 검증
const errors: string[] = [];
if (!formData.itemCode) errors.push('품목코드');
if (!formData.supplier) errors.push('발주처');
if (!formData.receivingQty) errors.push('입고수량');
if (!formData.receivingDate) errors.push('입고일');
if (errors.length > 0) {
return { success: false, error: `필수 항목을 입력해주세요: ${errors.join(', ')}` };
}
setIsSaving(true);
try {
if (isNewMode) {
const result = await createReceiving(formData);
if (!result.success) {
return { success: false, error: result.error || '등록에 실패했습니다.' };
if (result.success) {
toast.success('입고가 등록되었습니다.');
router.push('/ko/material/receiving-management');
return { success: true };
} else {
toast.error(result.error || '등록에 실패했습니다.');
return { success: false, error: result.error };
}
return { success: true };
} else if (isEditMode) {
const result = await updateReceiving(id, formData);
if (!result.success) {
return { success: false, error: result.error || '수정에 실패했습니다.' };
if (result.success) {
toast.success('입고 정보가 수정되었습니다.');
router.push(`/ko/material/receiving-management/${id}?mode=view`);
return { success: true };
} else {
toast.error(result.error || '수정에 실패했습니다.');
return { success: false, error: result.error };
}
return { success: true };
}
return { success: false, error: '알 수 없는 모드입니다.' };
} catch (err) {
if (isNextRedirectError(err)) throw err;
console.error('[ReceivingDetail] handleSave error:', err);
const errorMessage = err instanceof Error ? err.message : '저장 중 오류가 발생했습니다.';
return { success: false, error: errorMessage };
toast.error('저장 중 오류가 발생했습니다.');
return { success: false, error: '저장 중 오류가 발생했습니다.' };
} finally {
setIsSaving(false);
}
@@ -277,11 +302,9 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
setIsInspectionModalOpen(true);
};
// 수입검사 완료 핸들러
const handleImportInspectionComplete = (data: ImportInspectionData) => {
console.log('수입검사 완료:', data);
toast.success('수입검사가 완료되었습니다.');
// TODO: API 호출하여 검사 결과 저장
// 수입검사 저장 완료 핸들러 → 데이터 새로고침
const handleImportInspectionSave = () => {
loadData();
};
// 재고 조정 행 추가
@@ -346,8 +369,9 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{renderReadOnlyField('자재번호', detail.materialNo)}
{renderReadOnlyField('로트번호', detail.lotNo)}
{renderReadOnlyField('입고번호', detail.materialNo)}
{renderReadOnlyField('자재번호', detail.supplierMaterialNo)}
{renderReadOnlyField('원자재로트', detail.lotNo)}
{renderReadOnlyField('품목코드', detail.itemCode)}
{renderReadOnlyField('품목명', detail.itemName)}
{renderReadOnlyField('규격', detail.specification)}
@@ -382,17 +406,52 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
{renderReadOnlyField('검사결과', detail.inspectionResult)}
</div>
<div className="mt-4">
<Label className="text-sm text-muted-foreground"> </Label>
<div className="mt-1.5 px-4 py-8 border-2 border-dashed rounded-md bg-gray-50 text-center text-sm text-muted-foreground">
{detail.certificateFileName ? (
<div className="flex items-center justify-center gap-2">
<FileText className="w-5 h-5" />
<span>{detail.certificateFileName}</span>
</div>
) : (
'클릭하여 파일을 찾거나, 마우스로 파일을 끌어오세요.'
)}
</div>
<Label className="text-sm text-muted-foreground"> </Label>
{inspectionAttachments.length > 0 ? (
<div className="mt-1.5 space-y-2">
{inspectionAttachments.map((att) => {
const fileName = att.file?.display_name || att.file?.original_name || `file-${att.file_id}`;
const fileSize = att.file?.file_size;
const isImage = att.file?.mime_type?.startsWith('image/');
const downloadUrl = `/api/proxy/files/${att.file_id}/download`;
return (
<div key={att.id} className="flex items-center gap-3 p-2 rounded-md border bg-muted/30">
{isImage && att.file?.file_path ? (
<img
src={downloadUrl}
alt={fileName}
className="w-10 h-10 rounded object-cover shrink-0"
/>
) : (
<FileText className="w-5 h-5 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{fileName}</p>
{fileSize && (
<p className="text-xs text-muted-foreground">
{fileSize < 1024 * 1024
? `${(fileSize / 1024).toFixed(1)} KB`
: `${(fileSize / (1024 * 1024)).toFixed(1)} MB`}
</p>
)}
</div>
<a
href={downloadUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 hover:underline shrink-0"
>
</a>
</div>
);
})}
</div>
) : (
<div className="mt-1.5 px-4 py-8 border-2 border-dashed rounded-md bg-gray-50 text-center text-sm text-muted-foreground">
.
</div>
)}
</div>
</CardContent>
</Card>
@@ -437,7 +496,7 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
</Card>
</div>
);
}, [detail, adjustments]);
}, [detail, adjustments, inspectionAttachments]);
// ===== 등록/수정 폼 콘텐츠 =====
const renderFormContent = useCallback(() => {
@@ -450,11 +509,30 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* 자재번호 - 읽기전용 */}
{renderReadOnlyField('자재번호', formData.materialNo, true)}
{/* 입고번호 - 읽기전용 */}
{renderReadOnlyField('입고번호', formData.materialNo, true)}
{/* 로트번호 - 읽기전용 */}
{renderReadOnlyField('로트번호', formData.lotNo, true)}
{/* 자재번호 (거래처) - 수정 가능 */}
<div>
<Label className="text-sm text-muted-foreground"></Label>
<Input
className="mt-1.5"
value={formData.supplierMaterialNo || ''}
onChange={(e) => setFormData((prev) => ({ ...prev, supplierMaterialNo: e.target.value }))}
placeholder="거래처 자재번호"
/>
</div>
{/* 원자재로트 - 수정 가능 */}
<div>
<Label className="text-sm text-muted-foreground"></Label>
<Input
className="mt-1.5"
value={formData.lotNo || ''}
onChange={(e) => setFormData((prev) => ({ ...prev, lotNo: e.target.value }))}
placeholder="원자재로트를 입력하세요"
/>
</div>
{/* 품목코드 - 검색 모달 선택 */}
<div>
@@ -710,17 +788,18 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
// ===== 커스텀 헤더 액션 (view/edit 모드) =====
// 수정 버튼은 IntegratedDetailTemplate의 DetailActions에서 아이콘으로 제공하므로 중복 제거
const customHeaderActions = (isViewMode || isEditMode) && detail ? (
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleInspection}>
<ClipboardCheck className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
<Button variant="outline" size="sm" onClick={handleViewInspectionReport}>
<FileText className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline"> </span>
</Button>
</div>
// 수입검사하기 버튼은 수입검사 성적서 템플릿이 있는 품목만 표시
const customHeaderActions = (isViewMode || isEditMode) && detail && hasInspectionTemplate ? (
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleInspection}>
<ClipboardCheck className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
<Button variant="outline" size="sm" onClick={handleViewInspectionReport}>
<FileText className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline"> </span>
</Button>
</div>
) : undefined;
// 에러 상태 표시 (view/edit 모드에서만)
@@ -804,7 +883,7 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
id: 'import-inspection',
type: 'import',
title: '수입검사 성적서',
count: 0,
count: 0,
}}
documentItem={{
id: id,
@@ -813,20 +892,31 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
code: detail?.lotNo || '',
}}
// 수입검사 템플릿 로드용 props
itemId={detail?.itemId}
itemName={detail?.itemName}
specification={detail?.specification}
supplier={detail?.supplier}
inspector={getLoggedInUserName()}
inspectorDept={getLoggedInUser().department}
lotSize={detail?.receivingQty}
materialNo={detail?.materialNo}
readOnly={true}
/>
{/* 수입검사 입력 모달 */}
<ImportInspectionInputModal
open={isImportInspectionModalOpen}
onOpenChange={setIsImportInspectionModalOpen}
productName={detail?.itemName}
specification={detail?.specification}
onComplete={handleImportInspectionComplete}
/>
{/* 수입검사 입력 모달 */}
<ImportInspectionInputModal
open={isImportInspectionModalOpen}
onOpenChange={setIsImportInspectionModalOpen}
itemId={detail?.itemId}
itemName={detail?.itemName}
specification={detail?.specification}
supplier={detail?.supplier}
inspector={getLoggedInUserName()}
lotSize={detail?.receivingQty}
materialNo={detail?.materialNo}
receivingId={id}
onSave={handleImportInspectionSave}
/>
</>
);
}

View File

@@ -38,10 +38,41 @@ import {
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import { InventoryAdjustmentDialog } from './InventoryAdjustmentDialog';
import { getReceivings, getReceivingStats } from './actions';
import { RECEIVING_STATUS_LABELS, RECEIVING_STATUS_STYLES } from './types';
import {
RECEIVING_STATUS_LABELS,
RECEIVING_STATUS_STYLES,
INSPECTION_STATUS_LABELS,
INSPECTION_STATUS_STYLES,
type InspectionDisplayStatus,
} from './types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import type { ReceivingItem, ReceivingStats } from './types';
/**
* 수입검사 표시 상태 결정
* - 수입검사 템플릿이 연결되지 않으면 → none (수입검사 대상 아님)
* - 검사결과가 '합격'이면 → passed
* - 검사결과가 '불합격'이면 → failed
* - 그 외 (템플릿 연결되어 있고 검사결과 없음) → waiting (대기)
*/
function getInspectionDisplayStatus(item: ReceivingItem): InspectionDisplayStatus {
// 수입검사 템플릿이 연결되지 않은 경우 수입검사 대상 아님
if (!item.hasInspectionTemplate) {
return 'none';
}
// 검사결과에 따른 상태
if (item.inspectionResult === '합격') {
return 'passed';
}
if (item.inspectionResult === '불합격') {
return 'failed';
}
// 템플릿이 연결되어 있지만 검사결과가 없으면 대기 상태
return 'waiting';
}
// 페이지당 항목 수
const ITEMS_PER_PAGE = 20;
@@ -208,8 +239,8 @@ export function ReceivingList() {
// 테이블 컬럼 (기획서 2026-02-03 순서)
columns: [
{ key: 'no', label: 'No.', className: 'w-[50px] text-center' },
{ key: 'materialNo', label: '자재번호', className: 'w-[100px]' },
{ key: 'lotNo', label: '로트번호', className: 'w-[120px]' },
{ key: 'materialNo', label: '입고번호', className: 'w-[130px]' },
{ key: 'lotNo', label: '원자재로트', className: 'w-[120px]' },
{ key: 'inspectionStatus', label: '수입검사', className: 'w-[70px] text-center' },
{ key: 'inspectionDate', label: '검사일', className: 'w-[90px] text-center' },
{ key: 'supplier', label: '발주처', className: 'min-w-[100px]' },
@@ -306,7 +337,17 @@ export function ReceivingList() {
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell>{item.materialNo || '-'}</TableCell>
<TableCell className="font-medium">{item.lotNo || '-'}</TableCell>
<TableCell className="text-center">{item.inspectionStatus || '-'}</TableCell>
<TableCell className="text-center">
{(() => {
const status = getInspectionDisplayStatus(item);
if (status === 'none') return '-';
return (
<Badge className={`text-xs ${INSPECTION_STATUS_STYLES[status]}`}>
{INSPECTION_STATUS_LABELS[status]}
</Badge>
);
})()}
</TableCell>
<TableCell className="text-center">{item.inspectionDate || '-'}</TableCell>
<TableCell>{item.supplier}</TableCell>
<TableCell>{item.manufacturer || '-'}</TableCell>
@@ -363,12 +404,22 @@ export function ReceivingList() {
}
infoGrid={
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<InfoField label="자재번호" value={item.materialNo || '-'} />
<InfoField label="품목코드" value={item.itemCode} />
<InfoField label="품목코드" value={item.itemCode || '-'} />
<InfoField label="품목유형" value={item.itemType || '-'} />
<InfoField label="발주처" value={item.supplier} />
<InfoField label="제조사" value={item.manufacturer || '-'} />
<InfoField label="수입검사" value={item.inspectionStatus || '-'} />
<InfoField
label="수입검사"
value={(() => {
const status = getInspectionDisplayStatus(item);
if (status === 'none') return '-';
return (
<Badge className={`text-xs ${INSPECTION_STATUS_STYLES[status]}`}>
{INSPECTION_STATUS_LABELS[status]}
</Badge>
);
})()}
/>
<InfoField label="수량" value={item.receivingQty !== undefined ? `${item.receivingQty}` : '-'} />
<InfoField label="입고변경일" value={item.receivingDate || '-'} />
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -38,12 +38,35 @@ export const RECEIVING_STATUS_OPTIONS = [
{ value: 'inspection_completed', label: '검사완료' },
] as const;
// 수입검사 상태 (리스트 표시용)
export type InspectionDisplayStatus = 'waiting' | 'passed' | 'failed' | 'none';
// 수입검사 상태 라벨
export const INSPECTION_STATUS_LABELS: Record<InspectionDisplayStatus, string> = {
waiting: '대기',
passed: '합격',
failed: '불합격',
none: '-',
};
// 수입검사 상태 스타일
export const INSPECTION_STATUS_STYLES: Record<InspectionDisplayStatus, string> = {
waiting: 'bg-yellow-100 text-yellow-800',
passed: 'bg-green-100 text-green-800',
failed: 'bg-red-100 text-red-800',
none: 'bg-gray-100 text-gray-500',
};
// 입고 목록 아이템
export interface ReceivingItem {
id: string;
materialNo?: string; // 자재번호
lotNo?: string; // 로트번호
materialNo?: string; // 입고번호 (receiving_number)
supplierMaterialNo?: string; // 거래처 자재번호
lotNo?: string; // 원자재로트
itemId?: number; // 품목 ID
hasInspectionTemplate?: boolean; // 수입검사 템플릿 연결 여부
inspectionStatus?: string; // 수입검사 (적/부적/-)
inspectionResult?: string; // 검사결과 (합격/불합격)
inspectionDate?: string; // 검사일
supplier: string; // 발주처
manufacturer?: string; // 제조사
@@ -66,8 +89,10 @@ export interface ReceivingItem {
export interface ReceivingDetail {
id: string;
// 기본 정보
materialNo?: string; // 자재번호 (읽기전용)
lotNo?: string; // 로트번호 (읽기전용)
materialNo?: string; // 입고번호 (receiving_number, 읽기전용)
supplierMaterialNo?: string; // 거래처 자재번호 (수정가능)
lotNo?: string; // 원자재로트 (수정가능)
itemId?: number; // 품목 ID (수입검사 템플릿 조회용)
itemCode: string; // 품목코드 (수정가능)
itemName: string; // 품목명 (읽기전용 - 품목코드 선택 시 자동)
specification?: string; // 규격 (읽기전용)

View File

@@ -106,8 +106,7 @@ export function StockStatusList() {
const searchLower = searchTerm.toLowerCase();
const matchesSearch =
stock.itemCode.toLowerCase().includes(searchLower) ||
stock.itemName.toLowerCase().includes(searchLower) ||
stock.stockNumber.toLowerCase().includes(searchLower);
stock.itemName.toLowerCase().includes(searchLower);
if (!matchesSearch) return false;
}
@@ -127,7 +126,6 @@ export function StockStatusList() {
// ===== 엑셀 컬럼 정의 =====
const excelColumns: ExcelColumn<StockItem>[] = [
{ header: '자재번호', key: 'stockNumber' },
{ header: '품목코드', key: 'itemCode' },
{ header: '품목유형', key: 'itemType', transform: (value) => ITEM_TYPE_LABELS[value as ItemType] || '-' },
{ header: '품목명', key: 'itemName' },
@@ -148,14 +146,25 @@ export function StockStatusList() {
const hasStock = !!stock;
return {
id: String(item.id ?? ''),
stockNumber: hasStock ? (String(stock?.stock_number ?? stock?.id ?? item.id)) : String(item.id ?? ''),
itemCode: (item.code ?? '') as string,
itemName: (item.name ?? '') as string,
itemType: (item.item_type ?? 'RM') as ItemType,
specification: (item.specification ?? item.attributes ?? '') as string,
specification: (() => {
if (item.attributes && typeof item.attributes === 'object') {
const attrs = item.attributes as Record<string, unknown>;
if (attrs.spec && String(attrs.spec).trim()) return String(attrs.spec).trim();
const parts: string[] = [];
if (attrs.thickness) parts.push(`${attrs.thickness}T`);
if (attrs.width) parts.push(`${attrs.width}`);
if (attrs.length) parts.push(`${attrs.length}`);
if (parts.length > 0) return parts.join('×');
}
if (stock?.specification && String(stock.specification).trim()) return String(stock.specification).trim();
return '';
})(),
unit: (item.unit ?? 'EA') as string,
calculatedQty: hasStock ? (parseFloat(String(stock?.calculated_qty ?? stock?.stock_qty)) || 0) : 0,
actualQty: hasStock ? (parseFloat(String(stock?.actual_qty ?? stock?.stock_qty)) || 0) : 0,
calculatedQty: hasStock ? (parseFloat(String(stock?.stock_qty)) || 0) : 0,
actualQty: hasStock ? (parseFloat(String(stock?.stock_qty)) || 0) : 0,
stockQty: hasStock ? (parseFloat(String(stock?.stock_qty)) || 0) : 0,
safetyStock: hasStock ? (parseFloat(String(stock?.safety_stock)) || 0) : 0,
wipQty: hasStock ? (parseFloat(String(stock?.wip_qty)) || 0) : 0,
@@ -223,7 +232,6 @@ export function StockStatusList() {
// ===== 테이블 컬럼 =====
const tableColumns = [
{ key: 'no', label: 'No.', className: 'w-[60px] text-center' },
{ key: 'stockNumber', label: '자재번호', className: 'w-[100px]' },
{ key: 'itemCode', label: '품목코드', className: 'min-w-[100px]' },
{ key: 'itemType', label: '품목유형', className: 'w-[80px]' },
{ key: 'itemName', label: '품목명', className: 'min-w-[150px]' },
@@ -255,8 +263,7 @@ export function StockStatusList() {
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell className="font-medium">{item.stockNumber}</TableCell>
<TableCell>{item.itemCode}</TableCell>
<TableCell className="font-medium">{item.itemCode}</TableCell>
<TableCell>{ITEM_TYPE_LABELS[item.itemType] || '-'}</TableCell>
<TableCell className="max-w-[150px] truncate">{item.itemName}</TableCell>
<TableCell>{item.specification || '-'}</TableCell>
@@ -290,7 +297,6 @@ export function StockStatusList() {
headerBadges={
<>
<Badge variant="outline" className="text-xs">#{globalIndex}</Badge>
<Badge variant="outline" className="text-xs">{item.stockNumber}</Badge>
</>
}
title={item.itemName}
@@ -367,8 +373,7 @@ export function StockStatusList() {
const searchLower = searchValue.toLowerCase();
return (
stock.itemCode.toLowerCase().includes(searchLower) ||
stock.itemName.toLowerCase().includes(searchLower) ||
stock.stockNumber.toLowerCase().includes(searchLower)
stock.itemName.toLowerCase().includes(searchLower)
);
},
@@ -411,7 +416,7 @@ export function StockStatusList() {
// 테이블 푸터
tableFooter: (
<TableRow className="bg-gray-50 hover:bg-gray-50">
<TableCell colSpan={12} className="py-3">
<TableCell colSpan={11} className="py-3">
<span className="text-sm text-muted-foreground">
{filteredStocks.length}
</span>

View File

@@ -114,27 +114,34 @@ function transformApiToListItem(data: ItemApiData): StockItem {
const stock = data.stock;
const hasStock = !!stock;
// description 또는 attributes에서 규격 정보 추출
// 규격: attributes.spec → thickness/width/length 조합 → stock.specification
let specification = '';
if (data.description) {
specification = data.description;
} else if (data.attributes && typeof data.attributes === 'object') {
if (data.attributes && typeof data.attributes === 'object') {
const attrs = data.attributes as Record<string, unknown>;
if (attrs.specification) {
specification = String(attrs.specification);
if (attrs.spec && String(attrs.spec).trim()) {
specification = String(attrs.spec).trim();
} else {
const parts: string[] = [];
if (attrs.thickness) parts.push(`${attrs.thickness}T`);
if (attrs.width) parts.push(`${attrs.width}`);
if (attrs.length) parts.push(`${attrs.length}`);
if (parts.length > 0) specification = parts.join('×');
}
}
if (!specification && hasStock) {
const stockSpec = (stock as unknown as Record<string, unknown>).specification;
if (stockSpec && String(stockSpec).trim()) specification = String(stockSpec).trim();
}
return {
id: String(data.id),
stockNumber: hasStock ? String((stock as unknown as Record<string, unknown>).stock_number ?? stock.id ?? data.id) : String(data.id),
itemCode: data.code,
itemName: data.name,
itemType: data.item_type,
specification,
unit: data.unit || 'EA',
calculatedQty: hasStock ? (parseFloat(String((stock as unknown as Record<string, unknown>).calculated_qty ?? stock.stock_qty)) || 0) : 0,
actualQty: hasStock ? (parseFloat(String((stock as unknown as Record<string, unknown>).actual_qty ?? stock.stock_qty)) || 0) : 0,
calculatedQty: hasStock ? (parseFloat(String(stock.stock_qty)) || 0) : 0,
actualQty: hasStock ? (parseFloat(String(stock.stock_qty)) || 0) : 0,
stockQty: hasStock ? (parseFloat(String(stock.stock_qty)) || 0) : 0,
safetyStock: hasStock ? (parseFloat(String(stock.safety_stock)) || 0) : 0,
wipQty: hasStock ? (parseFloat(String((stock as unknown as Record<string, unknown>).wip_qty)) || 0) : 0,
@@ -169,17 +176,24 @@ function transformApiToDetail(data: ItemApiData): StockDetail {
const stock = data.stock;
const hasStock = !!stock;
// description 또는 attributes에서 규격 정보 추출
// 규격: attributes.spec → thickness/width/length 조합 → stock.specification
let specification = '-';
if (data.description) {
specification = data.description;
} else if (data.attributes && typeof data.attributes === 'object') {
// attributes에서 규격 관련 정보 추출 시도
if (data.attributes && typeof data.attributes === 'object') {
const attrs = data.attributes as Record<string, unknown>;
if (attrs.specification) {
specification = String(attrs.specification);
if (attrs.spec && String(attrs.spec).trim()) {
specification = String(attrs.spec).trim();
} else {
const parts: string[] = [];
if (attrs.thickness) parts.push(`${attrs.thickness}T`);
if (attrs.width) parts.push(`${attrs.width}`);
if (attrs.length) parts.push(`${attrs.length}`);
if (parts.length > 0) specification = parts.join('×');
}
}
if (specification === '-' && hasStock) {
const stockSpec = (stock as unknown as Record<string, unknown>).specification;
if (stockSpec && String(stockSpec).trim()) specification = String(stockSpec).trim();
}
return {
id: String(data.id),

View File

@@ -41,7 +41,6 @@ function generateLocation(type: string, seed: number): string {
const rawMaterialItems: StockItem[] = [
{
id: 'rm-1',
stockNumber: 'STK-RM-001',
itemCode: 'SCR-FABRIC-WHT-03T',
itemName: '스크린원단-백색-0.3T',
itemType: 'raw_material',
@@ -61,7 +60,6 @@ const rawMaterialItems: StockItem[] = [
},
{
id: 'rm-2',
stockNumber: 'STK-RM-002',
itemCode: 'SCR-FABRIC-GRY-03T',
itemName: '스크린원단-회색-0.3T',
itemType: 'raw_material',
@@ -81,7 +79,6 @@ const rawMaterialItems: StockItem[] = [
},
{
id: 'rm-3',
stockNumber: 'STK-RM-003',
itemCode: 'SCR-FABRIC-BLK-03T',
itemName: '스크린원단-흑색-0.3T',
itemType: 'raw_material',
@@ -101,7 +98,6 @@ const rawMaterialItems: StockItem[] = [
},
{
id: 'rm-4',
stockNumber: 'STK-RM-004',
itemCode: 'SCR-FABRIC-BEI-03T',
itemName: '스크린원단-베이지-0.3T',
itemType: 'raw_material',
@@ -133,7 +129,6 @@ const bentPartItems: StockItem[] = Array.from({ length: 41 }, (_, i) => {
return {
id: `bp-${i + 1}`,
stockNumber: `STK-BP-${String(i + 1).padStart(3, '0')}`,
itemCode: `BENT-${type.toUpperCase().slice(0, 3)}-${variant}-${String(i + 1).padStart(2, '0')}`,
itemName: `${type}-${variant}형-${i + 1}`,
itemType: 'bent_part' as const,
@@ -167,7 +162,6 @@ const purchasedPartItems: StockItem[] = [
return {
id: `pp-sqp-${i + 1}`,
stockNumber: `STK-PP-SQP-${String(i + 1).padStart(3, '0')}`,
itemCode: `SQP-${size.replace('×', '')}-${length.slice(0, 2)}`,
itemName: `각파이프 ${size} L:${length}`,
itemType: 'purchased_part' as const,
@@ -198,7 +192,6 @@ const purchasedPartItems: StockItem[] = [
return {
id: `pp-ang-${i + 1}`,
stockNumber: `STK-PP-ANG-${String(i + 1).padStart(3, '0')}`,
itemCode: `ANG-${size.replace('×', '')}-${length.slice(0, 2)}`,
itemName: `앵글 ${size} L:${length}`,
itemType: 'purchased_part' as const,
@@ -231,7 +224,6 @@ const purchasedPartItems: StockItem[] = [
return {
id: `pp-motor-${i + 1}`,
stockNumber: `STK-PP-MOT-${String(i + 1).padStart(3, '0')}`,
itemCode: `MOTOR-${voltage}${weight}${type === '무선' ? '-W' : ''}`,
itemName: `전동개폐기-${voltage}${weight}${type}`,
itemType: 'purchased_part' as const,
@@ -262,7 +254,6 @@ const purchasedPartItems: StockItem[] = [
return {
id: `pp-bolt-${i + 1}`,
stockNumber: `STK-PP-BLT-${String(i + 1).padStart(3, '0')}`,
itemCode: `BOLT-${size}-${length}`,
itemName: `볼트 ${size}×${length}mm`,
itemType: 'purchased_part' as const,
@@ -291,7 +282,6 @@ const purchasedPartItems: StockItem[] = [
return {
id: `pp-bearing-${i + 1}`,
stockNumber: `STK-PP-BRG-${String(i + 1).padStart(3, '0')}`,
itemCode: `BEARING-${type}`,
itemName: `베어링 ${type}`,
itemType: 'purchased_part' as const,
@@ -322,7 +312,6 @@ const purchasedPartItems: StockItem[] = [
return {
id: `pp-spring-${i + 1}`,
stockNumber: `STK-PP-SPR-${String(i + 1).padStart(3, '0')}`,
itemCode: `SPRING-${type.toUpperCase().slice(0, 2)}-${size}`,
itemName: `스프링-${type}-${size}`,
itemType: 'purchased_part' as const,
@@ -347,7 +336,6 @@ const purchasedPartItems: StockItem[] = [
const subMaterialItems: StockItem[] = [
{
id: 'sm-1',
stockNumber: 'STK-SM-001',
itemCode: 'SEW-WHT',
itemName: '미싱실-백색',
itemType: 'sub_material',
@@ -367,7 +355,6 @@ const subMaterialItems: StockItem[] = [
},
{
id: 'sm-2',
stockNumber: 'STK-SM-002',
itemCode: 'ALU-BAR',
itemName: '하단바-알루미늄',
itemType: 'sub_material',
@@ -387,7 +374,6 @@ const subMaterialItems: StockItem[] = [
},
{
id: 'sm-3',
stockNumber: 'STK-SM-003',
itemCode: 'END-CAP-STD',
itemName: '앤드락-표준',
itemType: 'sub_material',
@@ -407,7 +393,6 @@ const subMaterialItems: StockItem[] = [
},
{
id: 'sm-4',
stockNumber: 'STK-SM-004',
itemCode: 'SILICON-TRANS',
itemName: '실리콘-투명',
itemType: 'sub_material',
@@ -427,7 +412,6 @@ const subMaterialItems: StockItem[] = [
},
{
id: 'sm-5',
stockNumber: 'STK-SM-005',
itemCode: 'TAPE-DBL-25',
itemName: '양면테이프-25mm',
itemType: 'sub_material',
@@ -447,7 +431,6 @@ const subMaterialItems: StockItem[] = [
},
{
id: 'sm-6',
stockNumber: 'STK-SM-006',
itemCode: 'RIVET-STL-4',
itemName: '리벳-스틸-4mm',
itemType: 'sub_material',
@@ -467,7 +450,6 @@ const subMaterialItems: StockItem[] = [
},
{
id: 'sm-7',
stockNumber: 'STK-SM-007',
itemCode: 'WASHER-M8',
itemName: '와셔-M8',
itemType: 'sub_material',
@@ -491,7 +473,6 @@ const subMaterialItems: StockItem[] = [
const consumableItems: StockItem[] = [
{
id: 'cs-1',
stockNumber: 'STK-CS-001',
itemCode: 'PKG-BOX-L',
itemName: '포장박스-대형',
itemType: 'consumable',
@@ -511,7 +492,6 @@ const consumableItems: StockItem[] = [
},
{
id: 'cs-2',
stockNumber: 'STK-CS-002',
itemCode: 'PKG-BOX-M',
itemName: '포장박스-중형',
itemType: 'consumable',

View File

@@ -54,7 +54,6 @@ export const LOT_STATUS_LABELS: Record<LotStatusType, string> = {
// 재고 목록 아이템 (Item 기준 + Stock 정보)
export interface StockItem {
id: string;
stockNumber: string; // 재고번호 (Stock.stock_number)
itemCode: string; // Item.code
itemName: string; // Item.name
itemType: ItemType; // Item.item_type (RM, SM, CS)

View File

@@ -234,93 +234,58 @@ export function OrderRegistration({
}, [])
);
// 제품코드에서 그룹핑 키 추출: FG-KWE01-벽면형-SUS → KWE01-SUS
const extractGroupKey = useCallback((productName: string): string => {
const parts = productName.split('-');
if (parts.length >= 4) {
// FG-{model}-{installationType}-{finishType}
return `${parts[1]}-${parts[3]}`;
}
return productName;
}, []);
// 아이템을 제품 모델+타입별로 그룹핑 (제품 단위 집약)
// 아이템을 개소별(floor+code)로 그룹핑
const itemGroups = useMemo(() => {
const calcItems = form.selectedQuotation?.calculationInputs?.items;
if (!calcItems || calcItems.length === 0) {
return null;
}
// floor+code → productCode 매핑
const locationProductMap = new Map<string, string>();
// floor+code → calculationInput 매핑 (개소 메타정보)
const locationMetaMap = new Map<string, {
productCode: string;
productName: string;
quantity: number;
floor: string;
code: string;
}>();
calcItems.forEach(ci => {
if (ci.floor && ci.code && ci.productCode) {
locationProductMap.set(`${ci.floor}|${ci.code}`, ci.productCode);
if (ci.floor && ci.code) {
const locKey = `${ci.floor}|${ci.code}`;
locationMetaMap.set(locKey, {
productCode: ci.productCode || '',
productName: ci.productName || '',
quantity: ci.quantity ?? 1,
floor: ci.floor,
code: ci.code,
});
}
});
// 그룹별 데이터 집계
// 개소별 그룹
const groups = new Map<string, {
items: OrderItem[];
productCode: string;
locations: Set<string>; // 개소 목록
quantity: number; // 개소별 수량 합계 (calculation_inputs 기준)
meta: { productCode: string; productName: string; quantity: number; floor: string; code: string };
}>();
const ungrouped: OrderItem[] = [];
form.items.forEach(item => {
const locKey = `${item.type}|${item.symbol}`;
const productCode = locationProductMap.get(locKey);
if (productCode) {
const groupKey = extractGroupKey(productCode);
if (!groups.has(groupKey)) {
groups.set(groupKey, { items: [], productCode, locations: new Set(), quantity: 0 });
const meta = locationMetaMap.get(locKey);
if (meta) {
if (!groups.has(locKey)) {
groups.set(locKey, { items: [], meta });
}
const g = groups.get(groupKey)!;
g.items.push(item);
g.locations.add(locKey);
groups.get(locKey)!.items.push(item);
} else {
ungrouped.push(item);
}
});
// calculation_inputs에서 개소별 수량 합산
calcItems.forEach(ci => {
if (ci.productCode) {
const groupKey = extractGroupKey(ci.productCode);
const g = groups.get(groupKey);
if (g) {
g.quantity += ci.quantity ?? 1;
}
}
});
if (groups.size <= 1 && ungrouped.length === 0) {
if (groups.size === 0) {
return null;
}
// 그룹 내 동일 품목(item_code) 합산
const aggregateItems = (items: OrderItem[]) => {
const map = new Map<string, OrderItem & { _sourceIds: string[] }>();
items.forEach(item => {
const code = item.itemCode || item.itemName;
if (map.has(code)) {
const existing = map.get(code)!;
existing.quantity += item.quantity;
existing.amount = (existing.amount ?? 0) + (item.amount ?? 0);
existing._sourceIds.push(item.id);
} else {
map.set(code, {
...item,
quantity: item.quantity,
amount: item.amount ?? 0,
_sourceIds: [item.id],
});
}
});
return Array.from(map.values());
};
const result: Array<{
key: string;
label: string;
@@ -329,20 +294,19 @@ export function OrderRegistration({
quantity: number;
amount: number;
items: OrderItem[];
aggregatedItems: (OrderItem & { _sourceIds: string[] })[];
}> = [];
let orderNum = 1;
groups.forEach((value, key) => {
const amount = value.items.reduce((sum, item) => sum + (item.amount ?? 0), 0);
result.push({
key,
label: `수주 ${orderNum}: ${key}`,
productCode: key,
locationCount: value.locations.size,
quantity: value.quantity,
label: `${orderNum}. ${value.meta.floor} / ${value.meta.code}`,
productCode: value.meta.productName || value.meta.productCode,
locationCount: 1,
quantity: value.meta.quantity,
amount,
items: value.items,
aggregatedItems: aggregateItems(value.items),
});
orderNum++;
});
@@ -357,12 +321,11 @@ export function OrderRegistration({
quantity: ungrouped.length,
amount,
items: ungrouped,
aggregatedItems: aggregateItems(ungrouped),
});
}
return result;
}, [form.items, form.selectedQuotation?.calculationInputs, extractGroupKey]);
}, [form.items, form.selectedQuotation?.calculationInputs]);
// 견적 선택 핸들러
const handleQuotationSelect = (quotation: QuotationForSelect) => {
@@ -903,17 +866,18 @@ export function OrderRegistration({
{itemGroups ? (
// 그룹핑 표시
<div className="space-y-4">
{itemGroups.map((group) => {
return (
{itemGroups.map((group) => (
<div key={group.key} className={cn("border rounded-lg overflow-hidden", fieldErrors.items && "border-red-500")}>
<div className="bg-blue-50 px-4 py-2 border-b flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant="outline" className={getPresetStyle('info')}>
{group.label}
</Badge>
<span className="text-sm text-muted-foreground">
({group.locationCount} / {group.quantity})
</span>
{group.productCode && (
<span className="text-sm text-muted-foreground">
{group.productCode} ({group.quantity})
</span>
)}
</div>
<span className="text-sm font-medium">
: {formatAmount(group.amount)}
@@ -934,8 +898,8 @@ export function OrderRegistration({
</TableRow>
</TableHeader>
<TableBody>
{group.aggregatedItems.map((item, index) => (
<TableRow key={`agg-${item.itemCode || item.id}`}>
{group.items.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-center">{index + 1}</TableCell>
<TableCell>
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
@@ -960,8 +924,7 @@ export function OrderRegistration({
</TableBody>
</Table>
</div>
);
})}
))}
</div>
) : (
// 기본 플랫 리스트

View File

@@ -33,6 +33,7 @@ interface WorkOrderApiItem {
client_id?: number;
client_name?: string;
client?: { id: number; name: string };
root_nodes_count?: number;
};
assignee?: { id: number; name: string };
items?: { id: number; item_name: string; quantity: number }[];
@@ -53,7 +54,7 @@ function mapApiStatus(status: WorkOrderApiItem['status']): 'waiting' | 'inProgre
// ===== API → WorkOrder 변환 =====
function transformToProductionFormat(api: WorkOrderApiItem): WorkOrder {
const totalQuantity = (api.items || []).reduce((sum, item) => sum + item.quantity, 0);
const totalQuantity = (api.items || []).reduce((sum, item) => sum + Number(item.quantity), 0);
const productName = api.items?.[0]?.item_name || '-';
// 납기일 계산 (지연 여부)
@@ -81,6 +82,7 @@ function transformToProductionFormat(api: WorkOrderApiItem): WorkOrder {
projectName: api.project_name || '-',
assignees: api.assignee ? [api.assignee.name] : [],
quantity: totalQuantity,
shutterCount: api.sales_order?.root_nodes_count || 0,
dueDate,
priority: isUrgent ? 1 : 5,
status: mapApiStatus(api.status),

View File

@@ -21,6 +21,7 @@ export interface WorkOrder {
projectName: string; // 강남 타워 신축현장
assignees: string[]; // 담당자 배열
quantity: number; // EA 수량
shutterCount: number; // 개소수 (root_nodes_count)
dueDate: string; // 납기
priority: number; // 순위 (1~5)
status: WorkOrderStatus;
@@ -28,7 +29,27 @@ export interface WorkOrder {
isDelayed: boolean;
delayDays?: number; // 지연 일수
instruction?: string; // 지시사항
salesOrderNo?: string; // 수주번호
createdAt: string;
// 개소별 아이템 그룹 (작업자 화면용)
nodeGroups?: WorkOrderNodeGroup[];
}
// 개소별 아이템 그룹
export interface WorkOrderNodeGroup {
nodeId: number | null;
nodeName: string;
items: WorkOrderNodeItem[];
totalQuantity: number;
}
// 개소 내 개별 아이템
export interface WorkOrderNodeItem {
id: number;
itemName: string;
quantity: number;
specification?: string | null;
options?: Record<string, unknown> | null;
}
// 작업자 현황

View File

@@ -6,7 +6,7 @@
* IntegratedDetailTemplate 마이그레이션 완료 (2026-01-20)
*/
import { useState, useEffect, useCallback, useMemo } from 'react';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { FileText, Play, CheckCircle2, Loader2, Undo2, ClipboardCheck } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -38,6 +38,18 @@ import {
type ProcessStep,
} from './types';
// 수량 포맷팅 (EA, 개 등 개수 단위는 정수, m, kg 등은 소수점 유지)
function formatQuantity(quantity: number | string, unit: string): string {
const num = typeof quantity === 'string' ? Number(quantity) : quantity;
if (isNaN(num)) return String(quantity);
const countableUnits = ['EA', 'ea', '개', '대', '세트', 'SET', 'set', 'PCS', 'pcs'];
if (countableUnits.includes(unit) || unit === '-') {
return String(Math.floor(num));
}
// 소수점이 있으면 표시, 없으면 정수로
return Number.isInteger(num) ? String(num) : num.toFixed(2);
}
// 공정 진행 단계 (wrapper 없이 pills만 렌더링)
function ProcessStepPills({
processType,
@@ -105,7 +117,7 @@ function BendingDetailsSection({ order }: { order: WorkOrder }) {
<span className="font-medium">{detail.name}</span>
<span className="text-sm text-muted-foreground">{detail.material}</span>
</div>
<span className="text-sm">: {detail.quantity}</span>
<span className="text-sm">: {Math.floor(detail.quantity)}</span>
</div>
{/* 상세 정보 */}
@@ -384,7 +396,7 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
</div>
<div className="min-w-0">
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">-</p>
<p className="font-medium">{order.processName !== '-' ? order.processName : '-'}</p>
</div>
{/* 2행: 로트번호 | 수주처 | 현장명 | 수주 담당자 */}
@@ -402,21 +414,21 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
</div>
<div>
<p className="text-sm text-muted-foreground mb-1"> </p>
<p className="font-medium">-</p>
<p className="font-medium">{order.salesOrderWriter || '-'}</p>
</div>
{/* 3행: 담당자 연락처 | 출고예정일 | 틀수 | 우선순위 */}
<div>
<p className="text-sm text-muted-foreground mb-1"> </p>
<p className="font-medium">-</p>
<p className="font-medium">{order.clientContact || '-'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{order.shipmentDate || '-'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{order.shutterCount ?? '-'}</p>
<p className="text-sm text-muted-foreground mb-1"> ()</p>
<p className="font-medium">{order.shutterCount != null ? `${Math.floor(order.shutterCount)}개소` : '-'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
@@ -461,7 +473,7 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
<>
{order.items[0].productName}
{order.items[0].specification !== '-' ? ` ${order.items[0].specification}` : ''}
{` ${order.items[0].quantity}${order.items[0].unit !== '-' ? order.items[0].unit : '개'}`}
{` ${formatQuantity(order.items[0].quantity, order.items[0].unit)}${order.items[0].unit !== '-' ? order.items[0].unit : '개'}`}
</>
) : (
<>
@@ -478,28 +490,101 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
/>
</div>
{/* 작업 품목 테이블 (로트번호 | 품목명 | 수량 | 단위) */}
{/* 작업 품목 - 개소별 그룹 */}
{order.items.length > 0 ? (
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-24 text-right"></TableHead>
<TableHead className="w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{order.items.map((item) => (
<TableRow key={item.id}>
<TableCell>{order.lotNo}</TableCell>
<TableCell className="font-medium">{item.productName}</TableCell>
<TableCell className="text-right">{item.quantity}</TableCell>
<TableCell>{item.unit}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="space-y-6">
{/* 개소별 품목 그룹 */}
<div>
<h4 className="text-sm font-semibold text-muted-foreground mb-2"> </h4>
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="w-36"></TableHead>
<TableHead></TableHead>
<TableHead className="w-24 text-right"></TableHead>
<TableHead className="w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(() => {
// 개소별로 그룹화
const nodeGroups = new Map<string, { nodeName: string; items: typeof order.items }>();
for (const item of order.items) {
const key = item.orderNodeId != null ? String(item.orderNodeId) : 'none';
if (!nodeGroups.has(key)) {
nodeGroups.set(key, { nodeName: item.orderNodeName, items: [] });
}
nodeGroups.get(key)!.items.push(item);
}
const rows: React.ReactNode[] = [];
for (const [key, group] of nodeGroups) {
group.items.forEach((item, idx) => {
rows.push(
<TableRow key={`node-${key}-${item.id}`} className={idx === 0 ? 'border-t-2' : ''}>
{idx === 0 && (
<TableCell rowSpan={group.items.length} className="align-top font-medium bg-muted/30">
{group.nodeName}
</TableCell>
)}
<TableCell>{item.productName}</TableCell>
<TableCell className="text-right">{formatQuantity(item.quantity, item.unit)}</TableCell>
<TableCell>{item.unit}</TableCell>
</TableRow>
);
});
}
return rows;
})()}
</TableBody>
</Table>
</div>
{/* 품목별 합산 그룹 */}
<div>
<h4 className="text-sm font-semibold text-muted-foreground mb-2"> </h4>
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="w-12 text-center">No</TableHead>
<TableHead></TableHead>
<TableHead className="w-24 text-right"></TableHead>
<TableHead className="w-20"></TableHead>
<TableHead className="w-20 text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(() => {
// 품목명+단위 기준으로 중복 합산
const itemMap = new Map<string, { productName: string; totalQty: number; unit: string; nodeCount: number }>();
for (const item of order.items) {
const key = `${item.productName}||${item.unit}`;
if (!itemMap.has(key)) {
itemMap.set(key, { productName: item.productName, totalQty: 0, unit: item.unit, nodeCount: 0 });
}
const entry = itemMap.get(key)!;
entry.totalQty += Number(item.quantity);
entry.nodeCount += 1;
}
let no = 0;
return Array.from(itemMap.values()).map((entry) => {
no++;
return (
<TableRow key={`sum-${entry.productName}-${entry.unit}`}>
<TableCell className="text-center">{no}</TableCell>
<TableCell className="font-medium">{entry.productName}</TableCell>
<TableCell className="text-right">{formatQuantity(entry.totalQty, entry.unit)}</TableCell>
<TableCell>{entry.unit}</TableCell>
<TableCell className="text-right">{entry.nodeCount}</TableCell>
</TableRow>
);
});
})()}
</TableBody>
</Table>
</div>
</div>
) : (
<p className="text-muted-foreground text-center py-8">
.

View File

@@ -1,14 +1,13 @@
'use client';
/**
* 작업지시 목록 - 공정 기반 탭 구조
* 작업지시 목록 - 공정 기반 동적 탭 구조
*
* 기획서 기반 전면 개편:
* - 탭: 공정 기반 3개 (스크린/슬랫/절곡) — 통계 카드 위에 배치
* - 탭: 전체 + 공정관리에서 가져온 동적 공정 탭 + 기타(미지정)
* - 필터: 상태 + 우선순위
* - 통계 카드 6개: 전체 작업 / 작업 대기 / 작업중 / 작업 완료 / 긴급 / 지연
* - 컬럼: 작업번호/수주일/출고예정일/로트번호/수주처/현장명/틀수/상태/우선순위/부서/비고
* - API: getProcessOptions로 공정 ID 매핑 후 processId로 필터링
* - API: getProcessOptions로 공정 목록 동적 로드 → processId로 필터링
*/
import { useState, useEffect, useMemo, useCallback } from 'react';
@@ -57,11 +56,11 @@ export function WorkOrderList() {
const router = useRouter();
// ===== 활성 탭 및 재공품 모달 =====
const [activeTab, setActiveTab] = useState('screen');
const [activeTab, setActiveTab] = useState(TAB_ALL);
const [isWipModalOpen, setIsWipModalOpen] = useState(false);
// ===== 공정 ID 매핑 (getProcessOptions) =====
const [processMap, setProcessMap] = useState<Record<string, number>>({});
// ===== 공정 목록 (동적 탭 생성용) =====
const [processList, setProcessList] = useState<ProcessOption[]>([]);
const [processMapLoaded, setProcessMapLoaded] = useState(false);
useEffect(() => {
@@ -69,17 +68,7 @@ export function WorkOrderList() {
try {
const result = await getProcessOptions();
if (result.success && result.data) {
const map: Record<string, number> = {};
result.data.forEach((process: ProcessOption) => {
// process_name 또는 process_code로 탭 매핑
const tabKeyByName = PROCESS_NAME_TO_TAB[process.processName];
const tabKeyByCode = PROCESS_CODE_TO_TAB[process.processCode];
const tabKey = tabKeyByName || tabKeyByCode;
if (tabKey) {
map[tabKey] = process.id;
}
});
setProcessMap(map);
setProcessList(result.data);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
@@ -99,15 +88,29 @@ export function WorkOrderList() {
waiting: 0,
inProgress: 0,
completed: 0,
byProcess: {},
});
// 통계 데이터 로드 (초기 1회)
// 통계 데이터 로드 (초기 1회) + 탭 카운트 세팅
useEffect(() => {
const loadStats = async () => {
try {
const result = await getWorkOrderStats();
if (result.success && result.data) {
setStatsData(result.data);
// 공정별 카운트 → 탭 카운트에 반영
const bp = result.data.byProcess;
const counts: Record<string, number> = {
[TAB_ALL]: result.data.total,
[TAB_OTHER]: bp['none'] || 0,
};
for (const [processId, count] of Object.entries(bp)) {
if (processId !== 'none') {
counts[processId] = count;
}
}
setTabCounts(counts);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
@@ -130,20 +133,20 @@ export function WorkOrderList() {
router.push('/ko/production/work-orders?mode=new');
}, [router]);
// ===== 탭 옵션 (공정 기반 3개) — 카운트는 API 응답으로 동적 업데이트 =====
const [tabCounts, setTabCounts] = useState<Record<string, number>>({
screen: 0,
slat: 0,
bending: 0,
});
// ===== 탭 옵션 (전체 + 동적 공정 + 기타) =====
const [tabCounts, setTabCounts] = useState<Record<string, number>>({});
const tabs: TabOption[] = useMemo(
() => [
{ value: 'screen', label: '스크린 공정', count: tabCounts.screen },
{ value: 'slat', label: '슬랫 공정', count: tabCounts.slat },
{ value: 'bending', label: '절곡 공정', count: tabCounts.bending },
{ value: TAB_ALL, label: '전체', count: tabCounts[TAB_ALL] },
...processList.map((p) => ({
value: String(p.id),
label: `${p.processName} 공정`,
count: tabCounts[String(p.id)],
})),
{ value: TAB_OTHER, label: '기타', count: tabCounts[TAB_OTHER] },
],
[tabCounts]
[processList, tabCounts]
);
// ===== 통계 카드 6개 (기획서 기반) =====
@@ -205,20 +208,16 @@ export function WorkOrderList() {
actions: {
getList: async (params?: ListParams) => {
try {
// 탭 → processId 매핑
const tabValue = params?.tab || 'screen';
const tabValue = params?.tab || TAB_ALL;
setActiveTab(tabValue);
const processId = processMap[tabValue];
// 해당 공정이 DB에 없으면 빈 목록 반환
if (!processId) {
return {
success: true,
data: [],
totalCount: 0,
totalPages: 0,
};
}
// 탭별 processId 결정
// 'all' → 필터 없음 (전체), 'other' → 'none' (미지정), 그 외 → 공정 ID
const processId = tabValue === TAB_ALL
? ('all' as const)
: tabValue === TAB_OTHER
? ('none' as const)
: Number(tabValue);
// 필터 값 추출
const statusFilter = params?.filters?.status as string | undefined;
@@ -238,16 +237,21 @@ export function WorkOrderList() {
});
if (result.success) {
// 현재 탭의 카운트 업데이트
setTabCounts((prev) => ({
...prev,
[tabValue]: result.pagination.total,
}));
// 통계도 다시 로드
// 통계 + 공정별 카운트 다시 로드
const statsResult = await getWorkOrderStats();
if (statsResult.success && statsResult.data) {
setStatsData(statsResult.data);
const bp = statsResult.data.byProcess;
const counts: Record<string, number> = {
[TAB_ALL]: statsResult.data.total,
[TAB_OTHER]: bp['none'] || 0,
};
for (const [processId, count] of Object.entries(bp)) {
if (processId !== 'none') {
counts[processId] = count;
}
}
setTabCounts(counts);
}
return {
@@ -312,11 +316,8 @@ export function WorkOrderList() {
tabsPosition: 'above-stats',
// 테이블 헤더 액션 (절곡 공정 탭일 때만 재공품 생산 버튼)
// 절곡 공정 ID 찾기
tableHeaderActions: (() => {
const bendingProcess = processList.find(p =>
p.processName === '절곡' || p.processCode.toLowerCase() === 'bending'
);
const bendingProcess = processList.find(p => p.processName === '절곡');
return bendingProcess && activeTab === String(bendingProcess.id);
})() ? (
<Button
@@ -370,7 +371,7 @@ export function WorkOrderList() {
<TableCell>{item.client}</TableCell>
<TableCell className="max-w-[200px] truncate">{item.projectName}</TableCell>
<TableCell className="text-center">{item.shutterCount ?? '-'}</TableCell>
<TableCell>{'-'}</TableCell>
<TableCell>{item.processName !== '-' ? item.processName : '-'}</TableCell>
<TableCell>
<Badge className={`${WORK_ORDER_STATUS_COLORS[item.status]} border-0`}>
{WORK_ORDER_STATUS_LABELS[item.status]}

View File

@@ -47,7 +47,7 @@ export async function getWorkOrders(params?: {
page?: number;
perPage?: number;
status?: WorkOrderStatus | 'all';
processId?: number | 'all'; // 공정 ID (FK → processes.id)
processId?: number | 'all' | 'none'; // 공정 ID (FK → processes.id), 'none' = 미지정
processType?: 'screen' | 'slat' | 'bending'; // 공정 타입 필터
priority?: string; // 우선순위 필터 (urgent/priority/normal)
search?: string;
@@ -74,6 +74,7 @@ export async function getWorkOrders(params?: {
searchParams.set('status', params.status);
}
if (params?.processId && params.processId !== 'all') {
// 'none': 공정 미지정 필터 (process_id IS NULL)
searchParams.set('process_id', String(params.processId));
}
if (params?.processType) {

View File

@@ -112,6 +112,8 @@ export interface WorkOrderItem {
specification: string; // 규격
quantity: number;
unit: string; // 단위
orderNodeId: number | null; // 개소 ID
orderNodeName: string; // 개소명
}
// 전개도 상세 (절곡용)
@@ -186,6 +188,8 @@ export interface WorkOrder {
// 수주 관련
salesOrderDate: string; // 수주일
salesOrderWriter?: string; // 수주 담당자
clientContact?: string; // 담당자 연락처
shutterCount: number | null; // 틀수
department: string; // 부서명
@@ -232,6 +236,8 @@ export interface WorkOrderStats {
waiting: number;
inProgress: number;
completed: number;
/** 공정별 카운트 (key: process_id 또는 'none', value: count) */
byProcess: Record<string, number>;
}
// ===== API 타입 정의 =====
@@ -249,6 +255,15 @@ export interface WorkOrderItemApi {
status: 'waiting' | 'in_progress' | 'completed';
created_at: string;
updated_at: string;
source_order_item?: {
id: number;
order_node_id: number | null;
node?: {
id: number;
name: string;
code: string;
} | null;
} | null;
}
// API 응답 - 벤딩 상세
@@ -315,17 +330,24 @@ export interface WorkOrderApi {
deleted_at: string | null;
// Relations
priority: number | null;
shutter_count: number | null;
sales_order?: {
id: number;
order_no: string;
order_date?: string;
site_name?: string;
client_contact?: string;
received_at?: string;
created_at?: string;
quantity?: number;
root_nodes_count?: number;
client?: { id: number; name: string };
writer?: { id: number; name: string };
};
process?: {
id: number;
process_code: string;
process_name: string;
department?: string;
work_steps?: string[] | { key: string; label: string; order: number }[];
};
assignee?: { id: number; name: string };
@@ -354,6 +376,7 @@ export interface WorkOrderStatsApi {
in_progress: number;
completed: number;
shipped: number;
by_process?: Record<string, number>;
}
// ===== 변환 함수 =====
@@ -407,7 +430,7 @@ export function transformApiToFrontend(api: WorkOrderApi): WorkOrder {
processType: processNameToType(api.process?.process_name || ''), // 하위 호환
status: api.status,
client: api.sales_order?.client?.name || '-',
projectName: api.project_name || '-',
projectName: api.sales_order?.site_name || api.project_name || '-',
dueDate: api.scheduled_date || '-',
assignee: assigneeName,
assignees: assignees.length > 0 ? assignees : undefined,
@@ -418,9 +441,11 @@ export function transformApiToFrontend(api: WorkOrderApi): WorkOrder {
isStarted: ['in_progress', 'completed', 'shipped'].includes(api.status),
priority: priorityValue,
priorityLabel: getPriorityLabel(priorityValue),
salesOrderDate: api.sales_order?.order_date || api.created_at.split('T')[0],
shutterCount: api.shutter_count ?? null,
department: api.team?.name || '-',
salesOrderDate: api.sales_order?.received_at?.split('T')[0] || api.sales_order?.created_at?.split('T')[0] || api.created_at.split('T')[0],
salesOrderWriter: api.sales_order?.writer?.name || '-',
clientContact: api.sales_order?.client_contact || '-',
shutterCount: api.sales_order?.root_nodes_count || null,
department: api.process?.department || api.team?.name || '-',
currentStep: getStatusStep(api.status),
items: (api.items || []).map((item, idx) => ({
id: String(item.id),
@@ -431,6 +456,8 @@ export function transformApiToFrontend(api: WorkOrderApi): WorkOrder {
specification: item.specification || '-',
quantity: item.quantity,
unit: item.unit || '-',
orderNodeId: item.source_order_item?.order_node_id ?? null,
orderNodeName: item.source_order_item?.node?.name || '-',
})),
bendingDetails: api.bending_detail ? transformBendingDetail(api.bending_detail) : undefined,
issues: (api.issues || []).map(issue => ({
@@ -521,5 +548,6 @@ export function transformStatsApiToFrontend(api: WorkOrderStatsApi): WorkOrderSt
waiting: api.waiting,
inProgress: api.in_progress,
completed: api.completed + api.shipped,
byProcess: api.by_process || {},
};
}

View File

@@ -232,8 +232,8 @@ export function MaterialInputModal({
</TableRow>
</TableHeader>
<TableBody>
{materials.map((material) => (
<TableRow key={material.id}>
{materials.map((material, index) => (
<TableRow key={`mat-${material.id}-${index}`}>
<TableCell className="text-center text-sm">
{material.materialCode}
</TableCell>

View File

@@ -76,7 +76,7 @@ export function WorkItemCard({
{item.itemNo}
</span>
<span className="text-base font-bold text-gray-900">
{item.itemCode} ({item.itemName})
{item.itemCode} - {item.itemName}
</span>
</div>
{!item.isWip && (

View File

@@ -0,0 +1,131 @@
'use client';
/**
* 작업지시서 리스트 패널 (좌측)
*
* 마스터-디테일 레이아웃의 좌측 패널.
* 공정별 필터링된 작업지시서 목록을 표시하고 선택 기능 제공.
*/
import { cn } from '@/lib/utils';
import { AlertTriangle, Package } from 'lucide-react';
import type { WorkOrder } from '../ProductionDashboard/types';
interface WorkOrderListPanelProps {
workOrders: WorkOrder[];
selectedId: string | null;
onSelect: (id: string) => void;
isLoading: boolean;
}
export function WorkOrderListPanel({
workOrders,
selectedId,
onSelect,
isLoading,
}: WorkOrderListPanelProps) {
if (isLoading) {
return (
<div className="p-2 space-y-1.5">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-[84px] rounded-lg bg-gray-100 animate-pulse" />
))}
</div>
);
}
if (workOrders.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground p-6">
<Package className="h-10 w-10 mb-2 opacity-40" />
<p className="text-sm"> .</p>
</div>
);
}
return (
<div className="overflow-y-auto h-full p-2 space-y-1.5">
{workOrders.map((order, index) => (
<button
key={order.id}
onClick={() => onSelect(order.id)}
className={cn(
'w-full text-left p-3 rounded-lg border transition-all duration-150',
selectedId === order.id
? 'bg-primary/5 border-primary/30 shadow-sm'
: 'bg-white border-gray-100 hover:bg-gray-50 hover:border-gray-200'
)}
>
{/* 헤더: 번호 + 작업지시번호 + 상태 */}
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2 min-w-0">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-emerald-500 text-white text-xs font-bold shrink-0">
{index + 1}
</span>
<span className="text-sm font-semibold text-gray-900 truncate">
{order.orderNo}
</span>
</div>
<div className="flex items-center gap-1.5 shrink-0 ml-2">
{order.isUrgent && (
<AlertTriangle className="h-3.5 w-3.5 text-red-500" />
)}
<StatusBadge status={order.status} />
</div>
</div>
{/* 품목명 */}
<p className="text-xs text-gray-600 truncate ml-8">{order.productName}</p>
{/* 현장명 + 수량 */}
<div className="flex items-center justify-between mt-1.5 ml-8">
<span className="text-xs text-gray-400 truncate">
{order.projectName || order.client}
</span>
<span className="text-xs font-medium text-gray-500 shrink-0">
{order.quantity}EA
</span>
</div>
{/* 납기일 */}
{order.dueDate && (
<div className="mt-1 ml-8">
<span
className={cn(
'text-[11px]',
order.isDelayed
? 'text-red-500 font-medium'
: 'text-gray-400'
)}
>
: {new Date(order.dueDate).toLocaleDateString('ko-KR')}
{order.isDelayed &&
order.delayDays &&
` (${order.delayDays}일 지연)`}
</span>
</div>
)}
</button>
))}
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const config: Record<string, { label: string; className: string }> = {
waiting: { label: '대기', className: 'bg-gray-100 text-gray-600' },
inProgress: { label: '작업중', className: 'bg-blue-100 text-blue-700' },
completed: { label: '완료', className: 'bg-green-100 text-green-700' },
};
const { label, className } = config[status] || config.waiting;
return (
<span
className={cn(
'text-[10px] font-medium px-1.5 py-0.5 rounded',
className
)}
>
{label}
</span>
);
}

View File

@@ -1,6 +1,6 @@
/**
* 작업자 화면 서버 액션
* API 연동 완료 (2025-12-26)
* API 연동 (2025-12-26 초기, 2026-02-06 options + step-progress 확장)
*
* WorkOrders API를 호출하고 WorkerScreen에 맞는 형식으로 변환
*/
@@ -11,13 +11,22 @@
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { serverFetch } from '@/lib/api/fetch-wrapper';
import type { WorkOrder, WorkOrderStatus } from '../ProductionDashboard/types';
import type { WorkItemData, WorkStepData, ProcessTab } from './types';
// ===== API 타입 =====
interface WorkOrderApiItem {
id: number;
work_order_no: string;
project_name: string | null;
process_type: 'screen' | 'slat' | 'bending';
process_id: number | null;
process?: {
id: number;
process_name: string;
process_code: string;
department?: string | null;
};
/** @deprecated process_id + process relation 사용 */
process_type?: 'screen' | 'slat' | 'bending';
status: 'unassigned' | 'pending' | 'waiting' | 'in_progress' | 'completed' | 'shipped';
scheduled_date: string | null;
memo: string | null;
@@ -26,9 +35,21 @@ interface WorkOrderApiItem {
id: number;
order_no: string;
client?: { id: number; name: string };
root_nodes_count?: number;
};
assignee?: { id: number; name: string };
items?: { id: number; item_name: string; quantity: number }[];
items?: {
id: number;
item_name: string;
quantity: number;
specification?: string | null;
options?: Record<string, unknown> | null;
source_order_item?: {
id: number;
order_node_id: number | null;
node?: { id: number; name: string; code: string } | null;
} | null;
}[];
}
// ===== 상태 변환 =====
@@ -46,7 +67,7 @@ function mapApiStatus(status: WorkOrderApiItem['status']): WorkOrderStatus {
// ===== API → WorkOrder 변환 =====
function transformToWorkerScreenFormat(api: WorkOrderApiItem): WorkOrder {
const totalQuantity = (api.items || []).reduce((sum, item) => sum + item.quantity, 0);
const totalQuantity = (api.items || []).reduce((sum, item) => sum + Number(item.quantity), 0);
const productName = api.items?.[0]?.item_name || '-';
// 납기일 계산 (지연 여부)
@@ -59,13 +80,62 @@ function transformToWorkerScreenFormat(api: WorkOrderApiItem): WorkOrder {
? Math.ceil((today.getTime() - due.getTime()) / (1000 * 60 * 60 * 24))
: undefined;
// process_type → processCode/processName 매핑
const processTypeMap: Record<string, { code: string; name: string }> = {
screen: { code: 'screen', name: '스크린' },
slat: { code: 'slat', name: '슬랫' },
bending: { code: 'bending', name: '절곡' },
// process relation → processCode/processName 매핑
// 신규: process relation 사용, 폴백: deprecated process_type 필드
const rawProcessName = api.process?.process_name || '';
const rawProcessCode = api.process?.process_code || '';
// 한글 공정명 → 탭 키 매핑 (필터링에 사용)
const nameToTab: Record<string, { code: string; name: string }> = {
'스크린': { code: 'screen', name: '스크린' },
'슬랫': { code: 'slat', name: '슬랫' },
'절곡': { code: 'bending', name: '절곡' },
};
const processInfo = processTypeMap[api.process_type] || { code: api.process_type, name: api.process_type };
// 1차: process relation의 process_name으로 매핑
let processInfo = Object.entries(nameToTab).find(
([key]) => rawProcessName.includes(key)
)?.[1];
// 2차: deprecated process_type 폴백
if (!processInfo && api.process_type) {
const legacyMap: Record<string, { code: string; name: string }> = {
screen: { code: 'screen', name: '스크린' },
slat: { code: 'slat', name: '슬랫' },
bending: { code: 'bending', name: '절곡' },
};
processInfo = legacyMap[api.process_type];
}
// 3차: 그래도 없으면 원시값 사용
if (!processInfo) {
processInfo = { code: rawProcessCode || 'unknown', name: rawProcessName || '알수없음' };
}
// 아이템을 개소(node)별로 그룹핑
const nodeMap = new Map<string, { nodeId: number | null; nodeName: string; items: typeof api.items }>();
for (const item of (api.items || [])) {
const nodeId = item.source_order_item?.order_node_id ?? null;
const nodeName = item.source_order_item?.node?.name || '미지정';
const key = nodeId != null ? String(nodeId) : 'unassigned';
if (!nodeMap.has(key)) {
nodeMap.set(key, { nodeId, nodeName, items: [] });
}
nodeMap.get(key)!.items!.push(item);
}
const nodeGroups = Array.from(nodeMap.values()).map((g) => ({
nodeId: g.nodeId,
nodeName: g.nodeName,
items: (g.items || []).map((it) => ({
id: it.id,
itemName: it.item_name,
quantity: Number(it.quantity),
specification: it.specification,
options: it.options,
})),
totalQuantity: (g.items || []).reduce((sum, it) => sum + Number(it.quantity), 0),
}));
return {
id: String(api.id),
@@ -77,6 +147,7 @@ function transformToWorkerScreenFormat(api: WorkOrderApiItem): WorkOrder {
projectName: api.project_name || '-',
assignees: api.assignee ? [api.assignee.name] : [],
quantity: totalQuantity,
shutterCount: api.sales_order?.root_nodes_count || 0,
dueDate,
priority: 5, // 기본 우선순위
status: mapApiStatus(api.status),
@@ -84,7 +155,9 @@ function transformToWorkerScreenFormat(api: WorkOrderApiItem): WorkOrder {
isDelayed,
delayDays,
instruction: api.memo || undefined,
salesOrderNo: api.sales_order?.order_no || undefined,
createdAt: api.created_at,
nodeGroups,
};
}
@@ -95,8 +168,8 @@ export async function getMyWorkOrders(): Promise<{
error?: string;
}> {
try {
// 작업 대기 + 작업중 상태만 조회 (완료 제외)
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders?per_page=100&assigned_to_me=1`;
// 작업자 화면용 캐스케이드 필터: 개인 배정 → 부서 → 전체
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders?per_page=100&worker_screen=1`;
console.log('[WorkerScreenActions] GET my work orders:', url);
@@ -530,4 +603,216 @@ export async function requestInspection(
error: '서버 오류가 발생했습니다.',
};
}
}
// ===== 공정 단계 진행 현황 조회 =====
export interface StepProgressItem {
id: number;
process_step_id: number;
step_code: string;
step_name: string;
sort_order: number;
needs_inspection: boolean;
connection_type: string | null;
completion_type: string | null;
status: string;
is_completed: boolean;
completed_at: string | null;
completed_by: number | null;
}
export async function getStepProgress(
workOrderId: string
): Promise<{
success: boolean;
data: StepProgressItem[];
error?: string;
}> {
try {
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders/${workOrderId}/step-progress`;
const { response, error } = await serverFetch(url, { method: 'GET' });
if (error || !response) {
return { success: false, data: [], error: error?.message || '네트워크 오류' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, data: [], error: result.message || '단계 진행 조회 실패' };
}
return { success: true, data: result.data || [] };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[WorkerScreenActions] getStepProgress error:', error);
return { success: false, data: [], error: '서버 오류' };
}
}
// ===== 공정 단계 완료 토글 =====
export async function toggleStepProgress(
workOrderId: string,
progressId: number
): Promise<{ success: boolean; data?: StepProgressItem; error?: string }> {
try {
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders/${workOrderId}/step-progress/${progressId}/toggle`;
const { response, error } = await serverFetch(url, { method: 'PATCH' });
if (error || !response) {
return { success: false, error: error?.message || '네트워크 오류' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '단계 토글 실패' };
}
return { success: true, data: result.data };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[WorkerScreenActions] toggleStepProgress error:', error);
return { success: false, error: '서버 오류' };
}
}
// ===== 작업지시 상세 조회 (items + options 포함) =====
export async function getWorkOrderDetail(
workOrderId: string
): Promise<{
success: boolean;
data: WorkItemData[];
error?: string;
}> {
try {
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders/${workOrderId}`;
const { response, error } = await serverFetch(url, { method: 'GET' });
if (error || !response) {
return { success: false, data: [], error: error?.message || '네트워크 오류' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, data: [], error: result.message || '상세 조회 실패' };
}
const wo = result.data;
const processName = (wo.process?.process_name || '').toLowerCase();
const processType: ProcessTab =
processName.includes('스크린') ? 'screen' :
processName.includes('슬랫') ? 'slat' :
processName.includes('절곡') ? 'bending' : 'screen';
// items → WorkItemData 변환 (options 파싱)
const items: WorkItemData[] = (wo.items || []).map((item: {
id: number;
item_name: string;
specification: string | null;
quantity: string;
unit: string | null;
status: string;
sort_order: number;
options: Record<string, unknown> | null;
}, index: number) => {
const opts = item.options || {};
// steps: stepProgress에서 가져오거나 process.steps에서 생성
const stepProgressList = wo.step_progress || [];
const processSteps = wo.process?.steps || [];
let steps: WorkStepData[];
if (stepProgressList.length > 0) {
steps = stepProgressList
.filter((sp: { work_order_item_id: number | null }) => !sp.work_order_item_id || sp.work_order_item_id === item.id)
.map((sp: { id: number; process_step: { step_name: string; step_code: string } | null; status: string }) => ({
id: String(sp.id),
name: sp.process_step?.step_name || '',
isMaterialInput: (sp.process_step?.step_code || '').includes('MAT') || (sp.process_step?.step_name || '').includes('자재투입'),
isCompleted: sp.status === 'completed',
}));
} else {
steps = processSteps.map((ps: { id: number; step_name: string; step_code: string }, si: number) => ({
id: `${item.id}-step-${si}`,
name: ps.step_name,
isMaterialInput: ps.step_code.includes('MAT') || ps.step_name.includes('자재투입'),
isCompleted: false,
}));
}
const workItem: WorkItemData = {
id: String(item.id),
itemNo: index + 1,
itemCode: wo.work_order_no || '-',
itemName: item.item_name || '-',
floor: (opts.floor as string) || '-',
code: (opts.code as string) || '-',
width: (opts.width as number) || 0,
height: (opts.height as number) || 0,
quantity: Number(item.quantity) || 0,
processType,
steps,
materialInputs: [],
};
// 공정별 상세 정보 파싱 (options에서)
if (opts.cutting_info) {
const ci = opts.cutting_info as { width: number; sheets: number };
workItem.cuttingInfo = { width: ci.width, sheets: ci.sheets };
}
if (opts.slat_info) {
const si = opts.slat_info as { length: number; slat_count: number; joint_bar: number };
workItem.slatInfo = { length: si.length, slatCount: si.slat_count, jointBar: si.joint_bar };
}
if (opts.bending_info) {
const bi = opts.bending_info as {
common: { kind: string; type: string; length_quantities: { length: number; quantity: number }[] };
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 || [],
},
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;
if (wi) {
workItem.wipInfo = {
specification: wi.specification,
lengthQuantity: wi.length_quantity,
drawingUrl: wi.drawing_url,
};
}
}
if (opts.is_joint_bar) {
workItem.isJointBar = true;
const jb = opts.slat_joint_bar_info as { specification: string; length: number; quantity: number } | undefined;
if (jb) {
workItem.slatJointBarInfo = jb;
}
}
return workItem;
});
return { success: true, data: items };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[WorkerScreenActions] getWorkOrderDetail error:', error);
return { success: false, data: [], error: '서버 오류' };
}
}

View File

@@ -303,7 +303,7 @@ export default function WorkerScreen() {
const { sidebarCollapsed } = useMenuStore();
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [activeTab, setActiveTab] = useState<ProcessTab>('screen');
const [activeTab, setActiveTab] = useState<string>('');
const [bendingSubMode, setBendingSubMode] = useState<'normal' | 'wip'>('normal');
const [slatSubMode, setSlatSubMode] = useState<'normal' | 'jointbar'>('normal');
@@ -313,7 +313,7 @@ export default function WorkerScreen() {
const [productionDate, setProductionDate] = useState('');
// 좌측 사이드바
const [selectedSidebarOrderId, setSelectedSidebarOrderId] = useState<string>('order-1');
const [selectedSidebarOrderId, setSelectedSidebarOrderId] = useState<string>('');
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
// 공정별 step 완료 상태: { [itemId-stepName]: boolean }
@@ -406,24 +406,36 @@ export default function WorkerScreen() {
fetchProcessList();
}, []);
// 활성 공정 목록 (탭용) - 공정관리에서 등록된 활성 공정만
const processTabs = useMemo(() => {
return processListCache.filter((p) => p.status === '사용중');
}, [processListCache]);
// 공정 목록 로드 후 첫 번째 공정을 기본 선택
useEffect(() => {
if (processTabs.length > 0 && !activeTab) {
setActiveTab(processTabs[0].id);
}
}, [processTabs, activeTab]);
// 선택된 공정의 ProcessTab 키 (mock 데이터 및 기존 로직 호환용)
const activeProcessTabKey: ProcessTab = useMemo(() => {
const process = processListCache.find((p) => p.id === activeTab);
if (!process) return 'screen';
const name = process.processName.toLowerCase();
if (name.includes('스크린')) return 'screen';
if (name.includes('슬랫')) return 'slat';
if (name.includes('절곡')) return 'bending';
return 'screen';
}, [activeTab, processListCache]);
// activeTab 변경 시 해당 공정의 중간검사 설정 조회
useEffect(() => {
if (processListCache.length === 0) return;
if (processListCache.length === 0 || !activeTab) return;
// activeTab에 해당하는 공정 찾기
const tabToProcessName: Record<ProcessTab, string[]> = {
screen: ['스크린', 'screen'],
slat: ['슬랫', 'slat'],
bending: ['절곡', 'bending'],
};
const matchNames = tabToProcessName[activeTab] || [];
const matchedProcess = processListCache.find((p) =>
matchNames.some((name) => p.processName.toLowerCase().includes(name.toLowerCase()))
);
const matchedProcess = processListCache.find((p) => p.id === activeTab);
if (matchedProcess?.steps) {
// 검사 단계에서 inspectionSetting 찾기
const inspectionStep = matchedProcess.steps.find(
(step) => step.needsInspection && step.inspectionSetting
);
@@ -435,22 +447,49 @@ export default function WorkerScreen() {
// ===== 탭별 필터링된 작업 =====
const filteredWorkOrders = useMemo(() => {
// process_type 기반 필터링
const selectedProcess = processListCache.find((p) => p.id === activeTab);
if (!selectedProcess) return workOrders;
const selectedName = selectedProcess.processName.toLowerCase();
return workOrders.filter((order) => {
// WorkOrder의 processCode/processName으로 매칭
const processName = (order.processName || '').toLowerCase();
switch (activeTab) {
case 'screen':
return processName.includes('스크린') || processName === 'screen';
case 'slat':
return processName.includes('슬랫') || processName === 'slat';
case 'bending':
return processName.includes('절곡') || processName === 'bending';
default:
return true;
}
const orderProcessName = (order.processName || '').toLowerCase();
return orderProcessName.includes(selectedName) || selectedName.includes(orderProcessName);
});
}, [workOrders, activeTab]);
}, [workOrders, activeTab, processListCache]);
// ===== API WorkOrders → SidebarOrder 변환 =====
const apiSidebarOrders: SidebarOrder[] = useMemo(() => {
return filteredWorkOrders.map((wo) => ({
id: wo.id,
siteName: wo.projectName || wo.client || '-',
date: wo.dueDate || (wo.createdAt ? wo.createdAt.slice(0, 10) : '-'),
quantity: wo.quantity,
shutterCount: wo.shutterCount || 0,
priority: (wo.isUrgent ? 'urgent' : (wo.priority <= 3 ? 'priority' : 'normal')) as SidebarOrder['priority'],
}));
}, [filteredWorkOrders]);
// ===== 탭 변경/데이터 로드 시 최상위 우선순위 작업 자동 선택 =====
useEffect(() => {
if (isLoading) return;
const allOrders: SidebarOrder[] = [...apiSidebarOrders, ...MOCK_SIDEBAR_ORDERS[activeProcessTabKey]];
// 우선순위 순서: urgent → priority → normal
for (const group of PRIORITY_GROUPS) {
const first = allOrders.find((o) => o.priority === group.key);
if (first) {
setSelectedSidebarOrderId(first.id);
// subType에 따라 서브모드도 설정
if (activeProcessTabKey === 'slat') {
setSlatSubMode(first.subType === 'jointbar' ? 'jointbar' : 'normal');
}
if (activeProcessTabKey === 'bending') {
setBendingSubMode(first.subType === 'wip' ? 'wip' : 'normal');
}
return;
}
}
}, [isLoading, apiSidebarOrders, activeProcessTabKey]);
// ===== 통계 계산 (탭별) =====
const stats: WorkerStats = useMemo(() => {
@@ -462,46 +501,84 @@ export default function WorkerScreen() {
};
}, [filteredWorkOrders]);
// ===== WorkOrder → WorkItemData 변환 + 목업 =====
// ===== 선택된 작업지시의 개소별 WorkItemData 변환 + 목업 =====
const workItems: WorkItemData[] = useMemo(() => {
const apiItems: WorkItemData[] = filteredWorkOrders.map((order, index) => {
const stepsKey = (activeTab === 'bending' && bendingSubMode === 'wip') ? 'bending_wip' : activeTab;
const stepsTemplate = PROCESS_STEPS[stepsKey];
const selectedOrder = filteredWorkOrders.find((wo) => wo.id === selectedSidebarOrderId);
const stepsKey = (activeProcessTabKey === 'bending' && bendingSubMode === 'wip') ? 'bending_wip' : activeProcessTabKey;
const stepsTemplate = PROCESS_STEPS[stepsKey];
const apiItems: WorkItemData[] = [];
if (selectedOrder && selectedOrder.nodeGroups && selectedOrder.nodeGroups.length > 0) {
// 개소별로 WorkItemData 생성
selectedOrder.nodeGroups.forEach((group, index) => {
const nodeKey = group.nodeId != null ? String(group.nodeId) : `unassigned-${index}`;
const steps: WorkStepData[] = stepsTemplate.map((st, si) => {
const stepKey = `${selectedOrder.id}-${nodeKey}-${st.name}`;
return {
id: `${selectedOrder.id}-${nodeKey}-step-${si}`,
name: st.name,
isMaterialInput: st.isMaterialInput,
isInspection: st.isInspection,
isCompleted: stepCompletionMap[stepKey] || false,
};
});
// 개소 내 아이템 이름 요약
const itemNames = group.items.map((it) => it.itemName).filter(Boolean);
const itemSummary = itemNames.length > 0 ? itemNames.join(', ') : '-';
apiItems.push({
id: `${selectedOrder.id}-node-${nodeKey}`,
itemNo: index + 1,
itemCode: selectedOrder.orderNo || '-',
itemName: `${group.nodeName} : ${itemSummary}`,
floor: '-',
code: '-',
width: 0,
height: 0,
quantity: group.totalQuantity,
processType: activeProcessTabKey,
steps,
materialInputs: [],
});
});
} else if (selectedOrder) {
// nodeGroups가 없는 경우 폴백 (단일 항목)
const steps: WorkStepData[] = stepsTemplate.map((st, si) => {
const stepKey = `${order.id}-${st.name}`;
const stepKey = `${selectedOrder.id}-${st.name}`;
return {
id: `${order.id}-step-${si}`,
id: `${selectedOrder.id}-step-${si}`,
name: st.name,
isMaterialInput: st.isMaterialInput,
isInspection: st.isInspection,
isCompleted: stepCompletionMap[stepKey] || false,
};
});
return {
id: order.id,
itemNo: index + 1,
itemCode: order.orderNo || '-',
itemName: order.productName || '-',
apiItems.push({
id: selectedOrder.id,
itemNo: 1,
itemCode: selectedOrder.orderNo || '-',
itemName: selectedOrder.productName || '-',
floor: '-',
code: '-',
width: 0,
height: 0,
quantity: order.quantity || 0,
processType: activeTab,
quantity: selectedOrder.quantity || 0,
processType: activeProcessTabKey,
steps,
materialInputs: [],
};
});
});
}
// 목업 데이터 합치기 (API 데이터 뒤에 번호 이어서)
// 절곡 탭에서 재공품 서브모드면 WIP 전용 목업 사용
// 슬랫 탭에서 조인트바 서브모드면 조인트바 전용 목업 사용
const baseMockItems = (activeTab === 'bending' && bendingSubMode === 'wip')
const baseMockItems = (activeProcessTabKey === 'bending' && bendingSubMode === 'wip')
? MOCK_ITEMS_BENDING_WIP
: (activeTab === 'slat' && slatSubMode === 'jointbar')
: (activeProcessTabKey === 'slat' && slatSubMode === 'jointbar')
? MOCK_ITEMS_SLAT_JOINTBAR
: MOCK_ITEMS[activeTab];
: MOCK_ITEMS[activeProcessTabKey];
const mockItems = baseMockItems.map((item, i) => ({
...item,
itemNo: apiItems.length + i + 1,
@@ -515,22 +592,51 @@ export default function WorkerScreen() {
}));
return [...apiItems, ...mockItems];
}, [filteredWorkOrders, activeTab, stepCompletionMap, bendingSubMode, slatSubMode]);
}, [filteredWorkOrders, selectedSidebarOrderId, activeProcessTabKey, stepCompletionMap, bendingSubMode, slatSubMode]);
// ===== 수주 정보 (첫 번째 작업 기반 표시) =====
// ===== 수주 정보 (사이드바 선택 항목 기반) =====
const orderInfo = useMemo(() => {
// 1. 선택된 API 작업지시에서 찾기
const apiOrder = filteredWorkOrders.find((wo) => wo.id === selectedSidebarOrderId);
if (apiOrder) {
return {
orderDate: apiOrder.createdAt ? new Date(apiOrder.createdAt).toLocaleDateString('ko-KR') : '-',
salesOrderNo: apiOrder.salesOrderNo || '-',
siteName: apiOrder.projectName || '-',
client: apiOrder.client || '-',
salesManager: apiOrder.assignees?.[0] || '-',
managerPhone: '-',
shippingDate: apiOrder.dueDate ? new Date(apiOrder.dueDate).toLocaleDateString('ko-KR') : '-',
};
}
// 2. 목업 사이드바에서 찾기
const mockOrder = MOCK_SIDEBAR_ORDERS[activeProcessTabKey].find((o) => o.id === selectedSidebarOrderId);
if (mockOrder) {
return {
orderDate: mockOrder.date,
salesOrderNo: 'SO-2024-0001',
siteName: mockOrder.siteName,
client: '-',
salesManager: '-',
managerPhone: '-',
shippingDate: '-',
};
}
// 3. 폴백: 첫 번째 작업
const first = filteredWorkOrders[0];
if (!first) return null;
return {
orderDate: first.createdAt ? new Date(first.createdAt).toLocaleDateString('ko-KR') : '-',
lotNo: '-',
salesOrderNo: first.salesOrderNo || '-',
siteName: first.projectName || '-',
client: first.client || '-',
salesManager: first.assignees?.[0] || '-',
managerPhone: '-',
shippingDate: first.dueDate ? new Date(first.dueDate).toLocaleDateString('ko-KR') : '-',
};
}, [filteredWorkOrders]);
}, [filteredWorkOrders, selectedSidebarOrderId, activeProcessTabKey]);
// ===== 핸들러 =====
@@ -722,14 +828,14 @@ export default function WorkerScreen() {
// 현재 공정에 맞는 중간검사 타입 결정
const getInspectionProcessType = useCallback((): InspectionProcessType => {
if (activeTab === 'bending' && bendingSubMode === 'wip') {
if (activeProcessTabKey === 'bending' && bendingSubMode === 'wip') {
return 'bending_wip';
}
if (activeTab === 'slat' && slatSubMode === 'jointbar') {
if (activeProcessTabKey === 'slat' && slatSubMode === 'jointbar') {
return 'slat_jointbar';
}
return activeTab as InspectionProcessType;
}, [activeTab, bendingSubMode, slatSubMode]);
return activeProcessTabKey as InspectionProcessType;
}, [activeProcessTabKey, bendingSubMode, slatSubMode]);
// 하단 버튼 핸들러
const handleWorkLog = useCallback(() => {
@@ -766,13 +872,13 @@ export default function WorkerScreen() {
// ===== 재공품 감지 =====
const hasWipItems = useMemo(() => {
return activeTab === 'bending' && workItems.some(item => item.isWip);
}, [activeTab, workItems]);
return activeProcessTabKey === 'bending' && workItems.some(item => item.isWip);
}, [activeProcessTabKey, workItems]);
// ===== 조인트바 감지 =====
const hasJointBarItems = useMemo(() => {
return activeTab === 'slat' && slatSubMode === 'jointbar';
}, [activeTab, slatSubMode]);
return activeProcessTabKey === 'slat' && slatSubMode === 'jointbar';
}, [activeProcessTabKey, slatSubMode]);
// 재공품 통합 문서 (작업일지 + 중간검사) 핸들러
const handleWipInspection = useCallback(() => {
@@ -802,21 +908,32 @@ export default function WorkerScreen() {
</div>
</div>
{/* 공정별 탭 */}
{/* 공정별 탭 (공정관리 API 기반 동적 생성) */}
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as ProcessTab)}
onValueChange={(v) => setActiveTab(v)}
>
<TabsList className="w-full">
{(['screen', 'slat', 'bending'] as ProcessTab[]).map((tab) => (
<TabsTrigger key={tab} value={tab} className="flex-1">
{PROCESS_TAB_LABELS[tab]}
</TabsTrigger>
))}
{processTabs.length > 0 ? (
processTabs.map((proc) => (
<TabsTrigger key={proc.id} value={proc.id} className="flex-1">
{proc.processName}
</TabsTrigger>
))
) : (
(['screen', 'slat', 'bending'] as ProcessTab[]).map((tab) => (
<TabsTrigger key={tab} value={tab} className="flex-1">
{PROCESS_TAB_LABELS[tab]}
</TabsTrigger>
))
)}
</TabsList>
{(['screen', 'slat', 'bending'] as ProcessTab[]).map((tab) => (
<TabsContent key={tab} value={tab} className="mt-4">
{(processTabs.length > 0
? processTabs.map((p) => p.id)
: ['screen', 'slat', 'bending']
).map((tabValue) => (
<TabsContent key={tabValue} value={tabValue} className="mt-4">
{/* 모바일: 사이드바 토글 */}
<div className="md:hidden mb-4">
<button
@@ -826,15 +943,16 @@ export default function WorkerScreen() {
>
<div className="flex items-center gap-2">
<List className="h-4 w-4" />
<span className="text-sm font-medium"> </span>
<span className="text-sm font-medium"> </span>
</div>
{isSidebarOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</button>
{isSidebarOpen && (
<div className="mt-2 p-3 border rounded-lg bg-white max-h-[300px] overflow-y-auto">
<SidebarContent
tab={tab}
tab={activeProcessTabKey}
selectedOrderId={selectedSidebarOrderId}
apiOrders={apiSidebarOrders}
onSelectOrder={(id, subType) => {
setSelectedSidebarOrderId(id);
if (subType === 'slat' || subType === 'jointbar') setSlatSubMode(subType === 'jointbar' ? 'jointbar' : 'normal');
@@ -853,8 +971,9 @@ export default function WorkerScreen() {
<Card className="max-h-[calc(100vh-7.5rem)] overflow-y-auto">
<CardContent className="p-4">
<SidebarContent
tab={tab}
tab={activeProcessTabKey}
selectedOrderId={selectedSidebarOrderId}
apiOrders={apiSidebarOrders}
onSelectOrder={(id, subType) => {
setSelectedSidebarOrderId(id);
if (subType === 'slat' || subType === 'jointbar') setSlatSubMode(subType === 'jointbar' ? 'jointbar' : 'normal');
@@ -901,7 +1020,7 @@ export default function WorkerScreen() {
<h3 className="text-sm font-semibold text-gray-900 mb-3"> </h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-3">
<InfoField label="수주일" value={orderInfo?.orderDate} />
<InfoField label="로트번호" value={orderInfo?.lotNo} />
<InfoField label="수주로트" value={orderInfo?.salesOrderNo} />
<InfoField label="현장명" value={orderInfo?.siteName} />
<InfoField label="수주처" value={orderInfo?.client} />
<InfoField label="수주 담당자" value={orderInfo?.salesManager} />
@@ -977,16 +1096,37 @@ export default function WorkerScreen() {
</Card>
) : (
<div className="space-y-4">
{workItems.map((item) => (
<WorkItemCard
key={item.id}
item={item}
onStepClick={handleStepClick}
onEditMaterial={handleEditMaterial}
onDeleteMaterial={handleDeleteMaterial}
onInspectionClick={handleInspectionClick}
/>
))}
{(() => {
const apiCount = workItems.filter((i) => !i.id.startsWith('mock-')).length;
return apiCount > 0 ? (
<span className="text-[10px] font-semibold text-blue-600 bg-blue-50 px-2 py-0.5 rounded inline-block">
({apiCount})
</span>
) : null;
})()}
{workItems.map((item, index) => {
const isFirstMock = item.id.startsWith('mock-') &&
(index === 0 || !workItems[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" />}
<span className="text-[10px] font-semibold text-orange-600 bg-orange-50 px-2 py-0.5 rounded inline-block">
</span>
</div>
)}
<WorkItemCard
item={item}
onStepClick={handleStepClick}
onEditMaterial={handleEditMaterial}
onDeleteMaterial={handleDeleteMaterial}
onInspectionClick={handleInspectionClick}
/>
</div>
);
})}
</div>
)}
</div>
@@ -1049,14 +1189,14 @@ export default function WorkerScreen() {
open={isWorkLogModalOpen}
onOpenChange={setIsWorkLogModalOpen}
workOrderId={selectedOrder?.id || null}
processType={activeTab}
processType={activeProcessTabKey}
/>
<InspectionReportModal
open={isInspectionModalOpen}
onOpenChange={setIsInspectionModalOpen}
workOrderId={selectedOrder?.id || null}
processType={hasWipItems ? 'bending_wip' : activeTab}
processType={hasWipItems ? 'bending_wip' : activeProcessTabKey}
readOnly={true}
isJointBar={hasJointBarItems}
inspectionData={selectedOrder ? inspectionDataMap.get(selectedOrder.id) : undefined}
@@ -1094,6 +1234,7 @@ export default function WorkerScreen() {
interface SidebarContentProps {
tab: ProcessTab;
selectedOrderId: string;
apiOrders: SidebarOrder[];
onSelectOrder: (id: string, subType?: SidebarOrder['subType']) => void;
}
@@ -1101,14 +1242,12 @@ function SidebarContent({
tab,
selectedOrderId,
onSelectOrder,
apiOrders,
}: SidebarContentProps) {
const orders = MOCK_SIDEBAR_ORDERS[tab];
const mockOrders = MOCK_SIDEBAR_ORDERS[tab];
return (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-gray-900"> </h3>
{/* 우선순위별 작업지시 카드 + 태그 */}
const renderOrders = (orders: SidebarOrder[]) => (
<>
{PRIORITY_GROUPS.map((group) => {
const groupOrders = orders.filter((o) => o.priority === group.key);
if (groupOrders.length === 0) return null;
@@ -1140,7 +1279,7 @@ function SidebarContent({
</div>
<div className="flex items-center justify-between mt-1 text-gray-500">
<span>{order.date}</span>
<span>{order.quantity}/{order.shutterCount}</span>
<span>{order.shutterCount}</span>
</div>
</button>
);
@@ -1149,6 +1288,37 @@ function SidebarContent({
</div>
);
})}
</>
);
return (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-gray-900"> </h3>
{/* API 실제 데이터 */}
{apiOrders.length > 0 && (
<div className="space-y-2">
<span className="text-[10px] font-semibold text-blue-600 bg-blue-50 px-2 py-0.5 rounded">
({apiOrders.length})
</span>
{renderOrders(apiOrders)}
</div>
)}
{/* 구분선 */}
{apiOrders.length > 0 && mockOrders.length > 0 && (
<div className="border-t border-dashed border-gray-300 my-1" />
)}
{/* 목업 데이터 */}
{mockOrders.length > 0 && (
<div className="space-y-2">
<span className="text-[10px] font-semibold text-orange-600 bg-orange-50 px-2 py-0.5 rounded">
</span>
{renderOrders(mockOrders)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,194 @@
/**
* 작업자 화면 목업 데이터
*
* 실제 API 연동 전환 시 참조용으로 보존
* USE_MOCK = true 일 때 API 데이터 뒤에 병합됨
*/
import type { ProcessTab, WorkItemData } from './types';
// ===== 목업 ON/OFF 플래그 =====
// true: API 데이터 + 목업 데이터 병합 (개발 중)
// false: API 데이터만 사용 (운영)
export const USE_MOCK = false;
// ===== 공정별 목업 데이터 =====
export const MOCK_ITEMS: Record<ProcessTab, WorkItemData[]> = {
screen: [
{
id: 'mock-s1', itemNo: 1, itemCode: 'KWWS03', itemName: '와이어', floor: '1층', code: 'FSS-01',
width: 8260, height: 8350, quantity: 2, processType: 'screen',
cuttingInfo: { width: 1210, sheets: 8 },
steps: [
{ id: 's1-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
{ id: 's1-2', name: '절단', isMaterialInput: false, isCompleted: true },
{ id: 's1-3', name: '미싱', isMaterialInput: false, isCompleted: false },
{ id: 's1-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
],
materialInputs: [
{ id: 'm1', lotNo: 'LOT-2026-001', itemName: '스크린 원단 A', quantity: 500, unit: 'm' },
{ id: 'm2', lotNo: 'LOT-2026-002', itemName: '와이어 B', quantity: 120, unit: 'EA' },
],
},
{
id: 'mock-s2', itemNo: 2, itemCode: 'KWWS05', itemName: '메쉬', floor: '2층', code: 'FSS-03',
width: 6400, height: 5200, quantity: 4, processType: 'screen',
cuttingInfo: { width: 1600, sheets: 4 },
steps: [
{ id: 's2-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
{ id: 's2-2', name: '절단', isMaterialInput: false, isCompleted: false },
{ id: 's2-3', name: '미싱', isMaterialInput: false, isCompleted: false },
{ id: 's2-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
],
},
{
id: 'mock-s3', itemNo: 3, itemCode: 'KWWS08', itemName: '와이어(광폭)', floor: '3층', code: 'FSS-05',
width: 12000, height: 4500, quantity: 1, processType: 'screen',
cuttingInfo: { width: 2400, sheets: 5 },
steps: [
{ id: 's3-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
{ id: 's3-2', name: '절단', isMaterialInput: false, isCompleted: true },
{ id: 's3-3', name: '미싱', isMaterialInput: false, isCompleted: true },
{ id: 's3-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
],
materialInputs: [
{ id: 'm3', lotNo: 'LOT-2026-005', itemName: '광폭 원단', quantity: 300, unit: 'm' },
],
},
],
slat: [
{
id: 'mock-l1', itemNo: 1, itemCode: 'KQTS01', itemName: '슬랫코일', floor: '1층', code: 'FSS-01',
width: 8260, height: 8350, quantity: 2, processType: 'slat',
slatInfo: { length: 3910, slatCount: 40, jointBar: 4 },
steps: [
{ id: 'l1-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
{ id: 'l1-2', name: '포밍/절단', isMaterialInput: false, isCompleted: false },
{ id: 'l1-3', name: '포장완료', isMaterialInput: false, isCompleted: false },
],
materialInputs: [
{ id: 'm4', lotNo: 'LOT-2026-010', itemName: '슬랫 코일 A', quantity: 200, unit: 'kg' },
],
},
{
id: 'mock-l2', itemNo: 2, itemCode: 'KQTS03', itemName: '슬랫코일(광폭)', floor: '2층', code: 'FSS-02',
width: 10500, height: 6200, quantity: 3, processType: 'slat',
slatInfo: { length: 5200, slatCount: 55, jointBar: 6 },
steps: [
{ id: 'l2-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
{ id: 'l2-2', name: '포밍/절단', isMaterialInput: false, isCompleted: false },
{ id: 'l2-3', name: '포장완료', isMaterialInput: false, isCompleted: false },
],
},
],
bending: [
{
id: 'mock-b1', itemNo: 1, itemCode: 'KWWS03', itemName: '가이드레일', floor: '1층', code: 'FSS-01',
width: 0, height: 0, quantity: 6, processType: 'bending',
bendingInfo: {
common: {
kind: '혼합형 120X70', type: '혼합형',
lengthQuantities: [{ length: 4000, quantity: 6 }, { length: 3000, quantity: 6 }],
},
detailParts: [
{ partName: '엘바', material: 'EGI 1.6T', barcyInfo: '16 I 75' },
{ partName: '하장바', material: 'EGI 1.6T', barcyInfo: '16|75|16|75|16(A각)' },
],
},
steps: [
{ id: 'b1-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
{ id: 'b1-2', name: '절단', isMaterialInput: false, isCompleted: true },
{ id: 'b1-3', name: '절곡', isMaterialInput: false, isCompleted: false },
{ id: 'b1-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
],
},
],
};
// ===== 절곡 재공품 전용 목업 데이터 =====
export const MOCK_ITEMS_BENDING_WIP: WorkItemData[] = [
{
id: 'mock-bw1', itemNo: 1, itemCode: 'KWWS03', itemName: '케이스 - 전면부', floor: '-', code: '-',
width: 0, height: 0, quantity: 6, processType: 'bending',
isWip: true,
wipInfo: { specification: 'EGI 1.55T (W576)', lengthQuantity: '4,000mm X 6개' },
steps: [
{ id: 'bw1-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
{ id: 'bw1-2', name: '절단', isMaterialInput: false, isCompleted: false },
],
},
{
id: 'mock-bw2', itemNo: 2, itemCode: 'KWWS04', itemName: '케이스 - 후면부', floor: '-', code: '-',
width: 0, height: 0, quantity: 4, processType: 'bending',
isWip: true,
wipInfo: { specification: 'EGI 1.55T (W576)', lengthQuantity: '3,500mm X 4개' },
steps: [
{ id: 'bw2-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
{ id: 'bw2-2', name: '절단', isMaterialInput: false, isCompleted: false },
],
},
{
id: 'mock-bw3', itemNo: 3, itemCode: 'KWWS05', itemName: '하단마감재', floor: '-', code: '-',
width: 0, height: 0, quantity: 10, processType: 'bending',
isWip: true,
wipInfo: { specification: 'EGI 1.2T (W400)', lengthQuantity: '2,800mm X 10개' },
steps: [
{ id: 'bw3-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
{ id: 'bw3-2', name: '절단', isMaterialInput: false, isCompleted: false },
],
},
];
// ===== 슬랫 조인트바 전용 목업 데이터 =====
export const MOCK_ITEMS_SLAT_JOINTBAR: WorkItemData[] = [
{
id: 'mock-jb1', itemNo: 1, itemCode: 'KQJB01', itemName: '조인트바 A', floor: '1층', code: 'FSS-01',
width: 0, height: 0, quantity: 8, processType: 'slat',
isJointBar: true,
slatJointBarInfo: { specification: 'EGI 1.6T', length: 3910, quantity: 8 },
steps: [
{ id: 'jb1-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
{ id: 'jb1-2', name: '포밍/절단', isMaterialInput: false, isCompleted: false },
{ id: 'jb1-3', name: '포장완료', isMaterialInput: false, isCompleted: false },
],
materialInputs: [
{ id: 'mjb1', lotNo: 'LOT-2026-020', itemName: '조인트바 코일', quantity: 100, unit: 'kg' },
],
},
{
id: 'mock-jb2', itemNo: 2, itemCode: 'KQJB02', itemName: '조인트바 B', floor: '2층', code: 'FSS-02',
width: 0, height: 0, quantity: 12, processType: 'slat',
isJointBar: true,
slatJointBarInfo: { specification: 'EGI 1.6T', length: 5200, quantity: 12 },
steps: [
{ id: 'jb2-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
{ id: 'jb2-2', name: '포밍/절단', isMaterialInput: false, isCompleted: false },
{ id: 'jb2-3', name: '포장완료', isMaterialInput: false, isCompleted: false },
],
},
];
// ===== 하드코딩된 공정별 단계 폴백 =====
export const PROCESS_STEPS_FALLBACK: Record<string, { name: string; isMaterialInput: boolean }[]> = {
screen: [
{ name: '자재투입', isMaterialInput: true },
{ name: '절단', isMaterialInput: false },
{ name: '미싱', isMaterialInput: false },
{ name: '포장완료', isMaterialInput: false },
],
slat: [
{ name: '자재투입', isMaterialInput: true },
{ name: '포밍/절단', isMaterialInput: false },
{ name: '포장완료', isMaterialInput: false },
],
bending: [
{ name: '자재투입', isMaterialInput: true },
{ name: '절단', isMaterialInput: false },
{ name: '절곡', isMaterialInput: false },
{ name: '포장완료', isMaterialInput: false },
],
bending_wip: [
{ name: '자재투입', isMaterialInput: true },
{ name: '절단', isMaterialInput: false },
],
};

View File

@@ -70,13 +70,10 @@ export function ItemSearchModal({
}
}, [itemType]);
// 검색어 유효성 검사: 영문 1자 이상 또는 한글 1자 이상
// 검색어 유효성 검사: 영문, 한글, 숫자 1자 이상
const isValidSearchQuery = useCallback((query: string) => {
if (!query) return false;
// 영문 1자 이상 또는 한글 1자 이상
const hasEnglish = /[a-zA-Z]/.test(query);
const hasKorean = /[가-힣ㄱ-ㅎㅏ-ㅣ]/.test(query);
return hasEnglish || hasKorean;
if (!query || !query.trim()) return false;
return /[a-zA-Z가-힣ㄱ-ㅎㅏ-ㅣ0-9]/.test(query);
}, []);
// 모달 열릴 때 초기화 (자동 로드 안함)
@@ -167,7 +164,7 @@ export function ItemSearchModal({
{!searchQuery
? "품목코드 또는 품목명을 입력하세요"
: !isValidSearchQuery(searchQuery)
? "영문 또는 한글 1자 이상 입력하세요"
? "영문, 한글 또는 숫자 1자 이상 입력하세요"
: "검색 결과가 없습니다"}
</div>
) : (

View File

@@ -188,8 +188,8 @@ export function QuoteFooterBar({
</Button>
)}
{/* 견적완료 - edit 모드에서만 표시 (final 상태가 아닐 때만) */}
{!isViewMode && status !== "final" && (
{/* 견적확정 - final 상태가 아닐 때 표시 (view/edit 모두) */}
{status !== "final" && (
<Button
onClick={onFinalize}
disabled={isSaving || totalAmount === 0}
@@ -201,7 +201,7 @@ export function QuoteFooterBar({
) : (
<Check className="h-4 w-4 md:mr-2" />
)}
<span className="hidden md:inline"></span>
<span className="hidden md:inline">{isViewMode ? "견적확정" : "견적완료"}</span>
</Button>
)}

View File

@@ -56,7 +56,8 @@ import { quoteRegistrationCreateConfig, quoteRegistrationEditConfig } from "./qu
import { FormSection } from "@/components/organisms/FormSection";
import { FormFieldGrid } from "@/components/organisms/FormFieldGrid";
import { FormField } from "../molecules/FormField";
import { getFinishedGoods, calculateBomBulk, getSiteNames, type FinishedGoods, type BomCalculationResult } from "./actions";
import { getFinishedGoods, calculateBomBulk, getSiteNames, type FinishedGoods } from "./actions";
import type { BomCalculationResult } from "./types";
import { getClients } from "../accounting/VendorManagement/actions";
import { isNextRedirectError } from "@/lib/utils/redirect-error";
import type { Vendor } from "../accounting/VendorManagement";

View File

@@ -693,6 +693,19 @@ export function QuoteRegistrationV2({
const handleSave = useCallback(async (saveType: "temporary" | "final") => {
if (!onSave) return;
// 확정 시 필수 필드 밸리데이션
if (saveType === "final") {
const missing: string[] = [];
if (!formData.clientName?.trim()) missing.push("업체명");
if (!formData.siteName?.trim()) missing.push("현장명");
if (!formData.managerName?.trim()) missing.push("담당자");
if (!formData.managerContact?.trim()) missing.push("연락처");
if (missing.length > 0) {
toast.error(`견적확정을 위해 다음 항목을 입력해주세요: ${missing.join(", ")}`);
return;
}
}
setIsSaving(true);
try {
const dataToSave: QuoteFormDataV2 = {
@@ -700,7 +713,10 @@ export function QuoteRegistrationV2({
status: saveType === "temporary" ? "temporary" : "final",
};
await onSave(dataToSave, saveType);
toast.success(saveType === "temporary" ? "저장되었습니다." : "견적이 확정되었습니다.");
// 확정 성공 시 상태 즉시 반영 → 견적확정 버튼 → 수주등록 버튼으로 전환
if (saveType === "final") {
setFormData(prev => ({ ...prev, status: "final" }));
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
const message = error instanceof Error ? error.message : "저장 중 오류가 발생했습니다.";

View File

@@ -902,8 +902,8 @@ export interface BomCalculateItem {
inspectionFee?: number;
}
// BomCalculationResult는 types.ts에서 import하고 re-export
export type { BomCalculationResult } from './types';
// BomCalculationResult는 types.ts에서 직접 import하세요
// import type { BomCalculationResult } from './types';
// API 서버 응답 구조 (QuoteCalculationService::calculateBomBulk)
export interface BomBulkResponse {

View File

@@ -71,7 +71,7 @@ function formatFileSize(bytes: number): string {
// 파일 확장자에 따른 아이콘 색상
function getFileColor(fileName: string): string {
const ext = fileName.split('.').pop()?.toLowerCase();
const ext = (fileName || '').split('.').pop()?.toLowerCase();
switch (ext) {
case 'pdf':
return 'text-red-500';

View File

@@ -136,7 +136,7 @@ export function useItemList(): UseItemListResult {
itemType: (item.type_code ?? item.item_type ?? '') as ItemType,
partType: item.part_type as PartType | undefined,
unit: (item.unit ?? '') as string,
specification: (item.specification ?? '') as string,
specification: ((item.details as Record<string, unknown>)?.specification ?? item.specification ?? '') as string,
// is_active가 null/undefined면 deleted_at 기준으로 판단 (삭제 안됐으면 활성)
// deleted_at이 없거나 null이면 활성, 값이 있으면 비활성
isActive: item.is_active != null ? Boolean(item.is_active) : !item.deleted_at,