- 공통 템플릿 타입 수정 (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>
446 lines
16 KiB
TypeScript
446 lines
16 KiB
TypeScript
'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}
|
||
/>
|
||
);
|
||
}
|