Files
sam-react-prod/src/components/production/WorkerScreen/MaterialInputModal.tsx
byeongcheolryu e56b7d53a4 fix(WEB): 토큰 만료 시 무한 로딩 대신 로그인 리다이렉트 처리
- 52개 이상의 컴포넌트에 isNextRedirectError 처리 추가
- Server Action의 redirect() 에러가 catch 블록에서 삼켜지는 문제 해결
- access_token + refresh_token 모두 만료 시 정상적으로 로그인 페이지로 리다이렉트

수정된 영역:
- accounting: 10개 컴포넌트
- production: 12개 컴포넌트
- hr: 5개 컴포넌트
- settings: 8개 컴포넌트
- approval: 5개 컴포넌트
- items: 20개+ 컴포넌트
- board: 5개 컴포넌트
- quality: 4개 컴포넌트
- material, outbound, quotes 등 기타 컴포넌트

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-11 17:19:11 +09:00

292 lines
10 KiB
TypeScript

'use client';
/**
* 자재투입 모달
* API 연동 완료 (2025-12-26)
*
* 기획 화면에 맞춘 레이아웃:
* - FIFO 순위 설명 (1 최우선, 2 차선, 3+ 대기)
* - ① 자재 선택 (BOM 기준) 테이블
* - 취소 / 투입 등록 버튼 (전체 너비)
*/
import { useState, useEffect, useCallback } from 'react';
import { Loader2 } from 'lucide-react';
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { toast } from 'sonner';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { getMaterialsForWorkOrder, registerMaterialInput, type MaterialForInput } from './actions';
import type { WorkOrder } from '../ProductionDashboard/types';
import type { MaterialInput } from './types';
interface MaterialInputModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
order: WorkOrder | null;
onComplete?: () => void;
isCompletionFlow?: boolean;
onSaveMaterials?: (orderId: string, materials: MaterialInput[]) => void;
savedMaterials?: MaterialInput[];
}
export function MaterialInputModal({
open,
onOpenChange,
order,
onComplete,
isCompletionFlow = false,
onSaveMaterials,
}: MaterialInputModalProps) {
const [selectedMaterials, setSelectedMaterials] = useState<Set<string>>(new Set());
const [materials, setMaterials] = useState<MaterialForInput[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
// API로 자재 목록 로드
const loadMaterials = useCallback(async () => {
if (!order) return;
setIsLoading(true);
try {
const result = await getMaterialsForWorkOrder(order.id);
if (result.success) {
setMaterials(result.data);
} else {
toast.error(result.error || '자재 목록 조회에 실패했습니다.');
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[MaterialInputModal] loadMaterials error:', error);
toast.error('자재 목록 로드 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
}
}, [order]);
// 모달이 열릴 때 데이터 로드
useEffect(() => {
if (open && order) {
loadMaterials();
}
}, [open, order, loadMaterials]);
const handleToggleMaterial = (materialId: string) => {
setSelectedMaterials((prev) => {
const next = new Set(prev);
if (next.has(materialId)) {
next.delete(materialId);
} else {
next.add(materialId);
}
return next;
});
};
// 투입 등록
const handleSubmit = async () => {
if (!order) return;
setIsSubmitting(true);
try {
// 선택된 자재 ID 배열
const materialIds = materials
.filter((m) => selectedMaterials.has(String(m.id)))
.map((m) => m.id);
const result = await registerMaterialInput(order.id, materialIds);
if (result.success) {
toast.success('자재 투입이 등록되었습니다.');
// onSaveMaterials 콜백 호출 (기존 호환성)
if (onSaveMaterials) {
const selectedMaterialList: MaterialInput[] = materials
.filter((m) => selectedMaterials.has(String(m.id)))
.map((m) => ({
id: String(m.id),
materialCode: m.materialCode,
materialName: m.materialName,
unit: m.unit,
currentStock: m.currentStock,
fifoRank: m.fifoRank,
}));
onSaveMaterials(order.id, selectedMaterialList);
}
resetAndClose();
if (isCompletionFlow && onComplete) {
onComplete();
}
} else {
toast.error(result.error || '자재 투입 등록에 실패했습니다.');
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[MaterialInputModal] handleSubmit error:', error);
toast.error('자재 투입 등록 중 오류가 발생했습니다.');
} finally {
setIsSubmitting(false);
}
};
// 취소
const handleCancel = () => {
resetAndClose();
};
const resetAndClose = () => {
setSelectedMaterials(new Set());
onOpenChange(false);
};
if (!order) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl p-0 gap-0">
{/* 헤더 */}
<DialogHeader className="p-6 pb-4">
<DialogTitle className="text-xl font-semibold"> </DialogTitle>
</DialogHeader>
<div className="px-6 pb-6 space-y-6">
{/* FIFO 순위 설명 */}
<div className="flex items-center gap-4 p-4 bg-gray-50 rounded-lg">
<span className="text-sm font-medium text-gray-700">FIFO :</span>
<div className="flex items-center gap-4">
<span className="flex items-center gap-1.5">
<Badge className="bg-gray-900 hover:bg-gray-900 text-white rounded-full w-6 h-6 flex items-center justify-center p-0 text-xs">
1
</Badge>
<span className="text-sm text-gray-600"></span>
</span>
<span className="flex items-center gap-1.5">
<Badge className="bg-gray-900 hover:bg-gray-900 text-white rounded-full w-6 h-6 flex items-center justify-center p-0 text-xs">
2
</Badge>
<span className="text-sm text-gray-600"></span>
</span>
<span className="flex items-center gap-1.5">
<Badge className="bg-gray-900 hover:bg-gray-900 text-white rounded-full w-6 h-6 flex items-center justify-center p-0 text-xs">
3+
</Badge>
<span className="text-sm text-gray-600"></span>
</span>
</div>
</div>
{/* 자재 선택 섹션 */}
<div>
<h3 className="text-sm font-medium text-gray-900 mb-3">
(BOM )
</h3>
{isLoading ? (
<ContentLoadingSpinner text="자재 목록을 불러오는 중..." />
) : materials.length === 0 ? (
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell colSpan={5} className="text-center py-12 text-gray-500">
.
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
) : (
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="text-center font-medium"></TableHead>
<TableHead className="text-center font-medium"></TableHead>
<TableHead className="text-center font-medium"></TableHead>
<TableHead className="text-center font-medium"></TableHead>
<TableHead className="text-center font-medium"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{materials.map((material) => (
<TableRow key={material.id}>
<TableCell className="text-center font-medium">
{material.materialCode}
</TableCell>
<TableCell className="text-center">{material.materialName}</TableCell>
<TableCell className="text-center">{material.unit}</TableCell>
<TableCell className="text-center">
{material.currentStock.toLocaleString()}
</TableCell>
<TableCell className="text-center">
<Checkbox
checked={selectedMaterials.has(String(material.id))}
onCheckedChange={() => handleToggleMaterial(String(material.id))}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
{/* 버튼 영역 */}
<div className="flex gap-3">
<Button
variant="outline"
onClick={handleCancel}
disabled={isSubmitting}
className="flex-1 py-6 text-base font-medium"
>
</Button>
<Button
onClick={handleSubmit}
disabled={selectedMaterials.size === 0 || isSubmitting}
className="flex-1 py-6 text-base font-medium bg-gray-400 hover:bg-gray-500 disabled:bg-gray-300"
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
'투입 등록'
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}