Files
sam-react-prod/src/components/outbound/ShipmentManagement/ShipmentCreate.tsx
유병철 a1f4c82cec fix: 프로젝트 전체 TypeScript 타입에러 408개 수정 (tsc --noEmit 0 errors)
- 공통 템플릿 타입 수정 (IntegratedDetailTemplate, UniversalListPage)
- 페이지(app/[locale]) 타입 호환성 수정 (80개)
- 재고/자재 모듈 타입 수정 (StockStatus, ReceivingManagement)
- 생산 모듈 타입 수정 (WorkOrders, WorkerScreen, WorkResults)
- 주문/출고 모듈 타입 수정 (ShipmentManagement, Orders)
- 견적/단가 모듈 타입 수정 (Quotes, Pricing)
- 건설 모듈 타입 수정 (49개, 17개 하위 모듈)
- HR 모듈 타입 수정 (CardManagement, VacationManagement 등)
- 설정 모듈 타입 수정 (PermissionManagement, AccountManagement 등)
- 게시판 모듈 타입 수정 (BoardManagement, BoardList 등)
- 회계 모듈 타입 수정 (VendorManagement, BadDebtCollection 등)
- 기타 모듈 타입 수정 (CEODashboard, clients, vehicle 등)
- 유틸/훅/API 타입 수정 (hooks, contexts, lib)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 10:07:58 +09:00

446 lines
16 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
/**
* 출하 등록 페이지
* API 연동 완료 (2025-12-26)
* IntegratedDetailTemplate 마이그레이션 (2025-01-20)
*/
import { useState, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { getTodayString } from '@/utils/date';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { shipmentCreateConfig } from './shipmentConfig';
import {
createShipment,
getLotOptions,
getLogisticsOptions,
getVehicleTonnageOptions,
} from './actions';
import type {
ShipmentCreateFormData,
ShipmentPriority,
DeliveryMethod,
LotOption,
LogisticsOption,
VehicleTonnageOption,
} from './types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { toast } from 'sonner';
import { useDevFill } from '@/components/dev';
import { generateShipmentData } from '@/components/dev/generators/shipmentData';
// 고정 옵션 (클라이언트에서 관리)
const priorityOptions: { value: ShipmentPriority; label: string }[] = [
{ value: 'urgent', label: '긴급' },
{ value: 'normal', label: '일반' },
{ value: 'low', label: '낮음' },
];
const deliveryMethodOptions: { value: DeliveryMethod; label: string }[] = [
{ value: 'pickup', label: '상차 (물류업체)' },
{ value: 'direct', label: '직접배송 (자체)' },
{ value: 'logistics', label: '물류사' },
];
export function ShipmentCreate() {
const router = useRouter();
// 폼 상태
const [formData, setFormData] = useState<ShipmentCreateFormData>({
lotNo: '',
scheduledDate: getTodayString(),
priority: 'normal',
deliveryMethod: 'pickup',
logisticsCompany: '',
vehicleTonnage: '',
loadingTime: '',
loadingManager: '',
remarks: '',
});
// API 옵션 데이터 상태
const [lotOptions, setLotOptions] = useState<LotOption[]>([]);
const [logisticsOptions, setLogisticsOptions] = useState<LogisticsOption[]>([]);
const [vehicleTonnageOptions, setVehicleTonnageOptions] = useState<VehicleTonnageOption[]>([]);
// 로딩/에러 상태
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [validationErrors, setValidationErrors] = useState<string[]>([]);
// 옵션 데이터 로드
const loadOptions = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const [lotsResult, logisticsResult, tonnageResult] = await Promise.all([
getLotOptions(),
getLogisticsOptions(),
getVehicleTonnageOptions(),
]);
if (lotsResult.success && lotsResult.data) {
setLotOptions(lotsResult.data);
}
if (logisticsResult.success && logisticsResult.data) {
setLogisticsOptions(logisticsResult.data);
}
if (tonnageResult.success && tonnageResult.data) {
setVehicleTonnageOptions(tonnageResult.data);
}
} catch (err) {
if (isNextRedirectError(err)) throw err;
console.error('[ShipmentCreate] loadOptions error:', err);
setError('옵션 데이터를 불러오는 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
}
}, []);
// 옵션 로드
useEffect(() => {
loadOptions();
}, [loadOptions]);
// DevToolbar 자동 채우기
useDevFill(
'shipment',
useCallback(() => {
// lotOptions를 generateShipmentData에 전달하기 위해 변환
const lotOptionsForGenerator = lotOptions.map(o => ({
lotNo: o.value,
customerName: o.customerName,
siteName: o.siteName,
}));
const logisticsOptionsForGenerator = logisticsOptions.map(o => ({
id: o.value,
name: o.label,
}));
const tonnageOptionsForGenerator = vehicleTonnageOptions.map(o => ({
value: o.value,
label: o.label,
}));
const sampleData = generateShipmentData({
lotOptions: lotOptionsForGenerator as unknown as LotOption[],
logisticsOptions: logisticsOptionsForGenerator as unknown as LogisticsOption[],
tonnageOptions: tonnageOptionsForGenerator,
});
setFormData(sampleData);
toast.success('[Dev] 출하 폼이 자동으로 채워졌습니다.');
}, [lotOptions, logisticsOptions, vehicleTonnageOptions])
);
// 폼 입력 핸들러
const handleInputChange = (field: keyof ShipmentCreateFormData, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
// 입력 시 에러 클리어
if (validationErrors.length > 0) {
setValidationErrors([]);
}
};
// 취소
const handleCancel = useCallback(() => {
router.push('/ko/outbound/shipments');
}, [router]);
// validation 체크
const validateForm = (): boolean => {
const errors: string[] = [];
// 필수 필드 체크
if (!formData.lotNo) {
errors.push('로트번호는 필수 선택 항목입니다.');
}
if (!formData.scheduledDate) {
errors.push('출고예정일은 필수 입력 항목입니다.');
}
if (!formData.priority) {
errors.push('출고 우선순위는 필수 선택 항목입니다.');
}
if (!formData.deliveryMethod) {
errors.push('배송방식은 필수 선택 항목입니다.');
}
setValidationErrors(errors);
return errors.length === 0;
};
// 저장
const handleSubmit = useCallback(async () => {
// validation 체크
if (!validateForm()) {
return;
}
setIsSubmitting(true);
try {
const result = await createShipment(formData);
if (result.success) {
router.push('/ko/outbound/shipments');
} else {
setValidationErrors([result.error || '출하 등록에 실패했습니다.']);
}
} catch (err) {
if (isNextRedirectError(err)) throw err;
console.error('[ShipmentCreate] handleSubmit error:', err);
setValidationErrors(['저장 중 오류가 발생했습니다.']);
} finally {
setIsSubmitting(false);
}
}, [formData, router]);
// 폼 컨텐츠 렌더링
const renderFormContent = useCallback((_props: { formData: Record<string, unknown>; onChange: (key: string, value: unknown) => void; mode: string; errors: Record<string, string> }) => (
<div className="space-y-6">
{/* Validation 에러 표시 */}
{validationErrors.length > 0 && (
<Alert className="bg-red-50 border-red-200">
<AlertDescription className="text-red-900">
<div className="flex items-start gap-2">
<span className="text-lg"></span>
<div className="flex-1">
<strong className="block mb-2">
({validationErrors.length} )
</strong>
<ul className="space-y-1 text-sm">
{validationErrors.map((error, index) => (
<li key={index} className="flex items-start gap-1">
<span></span>
<span>{error}</span>
</li>
))}
</ul>
</div>
</div>
</AlertDescription>
</Alert>
)}
{/* 수주 선택 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label> *</Label>
<Select
value={formData.lotNo}
onValueChange={(value) => handleInputChange('lotNo', value)}
disabled={isSubmitting}
>
<SelectTrigger>
<SelectValue placeholder="로트 선택" />
</SelectTrigger>
<SelectContent>
{lotOptions.filter(o => o.value).map((option, index) => (
<SelectItem key={`${option.value}-${index}`} value={option.value}>
{option.label} ({option.customerName} - {option.siteName})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* 출고 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label> *</Label>
<Input
type="date"
value={formData.scheduledDate}
onChange={(e) => handleInputChange('scheduledDate', e.target.value)}
disabled={isSubmitting}
/>
</div>
<div className="space-y-2">
<Label> *</Label>
<Select
value={formData.priority}
onValueChange={(value) => handleInputChange('priority', value as ShipmentPriority)}
disabled={isSubmitting}
>
<SelectTrigger>
<SelectValue placeholder="우선순위 선택" />
</SelectTrigger>
<SelectContent>
{priorityOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label> *</Label>
<Select
value={formData.deliveryMethod}
onValueChange={(value) => handleInputChange('deliveryMethod', value as DeliveryMethod)}
disabled={isSubmitting}
>
<SelectTrigger>
<SelectValue placeholder="배송방식 선택" />
</SelectTrigger>
<SelectContent>
{deliveryMethodOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 상차 (물류업체) - 배송방식이 상차 또는 물류사일 때 표시 */}
{(formData.deliveryMethod === 'pickup' || formData.deliveryMethod === 'logistics') && (
<Card className="bg-muted/30">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium"> ()</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Select
value={formData.logisticsCompany || ''}
onValueChange={(value) => handleInputChange('logisticsCompany', value)}
disabled={isSubmitting}
>
<SelectTrigger>
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
{logisticsOptions.filter(o => o.value).map((option, index) => (
<SelectItem key={`${option.value}-${index}`} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label> ()</Label>
<Select
value={formData.vehicleTonnage || ''}
onValueChange={(value) => handleInputChange('vehicleTonnage', value)}
disabled={isSubmitting}
>
<SelectTrigger>
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
{vehicleTonnageOptions.filter(o => o.value).map((option, index) => (
<SelectItem key={`${option.value}-${index}`} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label> ()</Label>
<Input
type="datetime-local"
value={formData.loadingTime || ''}
onChange={(e) => handleInputChange('loadingTime', e.target.value)}
placeholder="연도. 월. 일. -- --:--"
disabled={isSubmitting}
/>
<p className="text-sm text-muted-foreground">
.
</p>
</div>
</CardContent>
</Card>
)}
<div className="space-y-2">
<Label></Label>
<Input
value={formData.loadingManager || ''}
onChange={(e) => handleInputChange('loadingManager', e.target.value)}
placeholder="상차 작업 담당자명"
disabled={isSubmitting}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Textarea
value={formData.remarks || ''}
onChange={(e) => handleInputChange('remarks', e.target.value)}
placeholder="특이사항 입력"
rows={4}
disabled={isSubmitting}
/>
</div>
</CardContent>
</Card>
</div>
), [formData, validationErrors, isSubmitting, lotOptions, logisticsOptions, vehicleTonnageOptions]);
// 로딩 또는 에러 상태 처리
if (error) {
return (
<IntegratedDetailTemplate
config={shipmentCreateConfig}
mode="create"
isLoading={false}
onCancel={handleCancel}
renderForm={(_props: { formData: Record<string, unknown>; onChange: (key: string, value: unknown) => void; mode: string; errors: Record<string, string> }) => (
<Alert className="bg-red-50 border-red-200">
<AlertDescription className="text-red-900">{error}</AlertDescription>
</Alert>
)}
/>
);
}
return (
<IntegratedDetailTemplate
config={shipmentCreateConfig}
mode="create"
isLoading={isLoading}
onCancel={handleCancel}
onSubmit={async (_data: Record<string, unknown>) => {
await handleSubmit();
return { success: true };
}}
renderForm={renderFormContent}
/>
);
}