feat: [shipment] 출고관리 개선
- 출고증/납품확인서 목업 데이터 → API 실데이터 전환 - 출고 등록(수동) 기능 제거 (자동생성만 유지) - 출고로트/수주로트 분리 표시, 로트번호 폴백 처리 - 출고 목록 카드뷰 불일치 수정
This commit is contained in:
@@ -1,10 +0,0 @@
|
||||
/**
|
||||
* 출하관리 - 등록 페이지
|
||||
* URL: /outbound/shipments/new
|
||||
*/
|
||||
|
||||
import { ShipmentCreate } from '@/components/outbound/ShipmentManagement';
|
||||
|
||||
export default function NewShipmentPage() {
|
||||
return <ShipmentCreate />;
|
||||
}
|
||||
@@ -1,21 +1,12 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 출하관리 - 목록/등록 페이지
|
||||
* 출하관리 - 목록 페이지
|
||||
* URL: /outbound/shipments
|
||||
* URL: /outbound/shipments?mode=new
|
||||
*/
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { ShipmentList, ShipmentCreate } from '@/components/outbound/ShipmentManagement';
|
||||
import { ShipmentList } from '@/components/outbound/ShipmentManagement';
|
||||
|
||||
export default function ShipmentsPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
if (mode === 'new') {
|
||||
return <ShipmentCreate />;
|
||||
}
|
||||
|
||||
return <ShipmentList />;
|
||||
}
|
||||
}
|
||||
@@ -1,760 +0,0 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 출고 등록 페이지
|
||||
* 4개 섹션 구조: 기본정보, 수주/배송정보, 배차정보, 제품내용
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Plus, Trash2, ChevronDown, Search } from 'lucide-react';
|
||||
import { getTodayString } from '@/lib/utils/date';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DateTimePicker } from '@/components/ui/date-time-picker';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { shipmentCreateConfig } from './shipmentConfig';
|
||||
import {
|
||||
createShipment,
|
||||
getLotOptions,
|
||||
getLogisticsOptions,
|
||||
getVehicleTonnageOptions,
|
||||
} from './actions';
|
||||
import type {
|
||||
ShipmentCreateFormData,
|
||||
DeliveryMethod,
|
||||
FreightCostType,
|
||||
VehicleDispatch,
|
||||
LotOption,
|
||||
LogisticsOption,
|
||||
VehicleTonnageOption,
|
||||
ProductGroup,
|
||||
ProductPart,
|
||||
} from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { toast } from 'sonner';
|
||||
import { useDaumPostcode } from '@/hooks/useDaumPostcode';
|
||||
import { useDevFill } from '@/components/dev';
|
||||
import { generateShipmentData } from '@/components/dev/generators/shipmentData';
|
||||
import { mockProductGroups, mockOtherParts } from './mockData';
|
||||
|
||||
// 배송방식 옵션
|
||||
const deliveryMethodOptions: { value: DeliveryMethod; label: string }[] = [
|
||||
{ value: 'direct_dispatch', label: '직접배차' },
|
||||
{ value: 'loading', label: '상차' },
|
||||
{ value: 'kyungdong_delivery', label: '경동택배' },
|
||||
{ value: 'daesin_delivery', label: '대신택배' },
|
||||
{ value: 'kyungdong_freight', label: '경동화물' },
|
||||
{ value: 'daesin_freight', label: '대신화물' },
|
||||
{ value: 'self_pickup', label: '직접수령' },
|
||||
];
|
||||
|
||||
// 운임비용 옵션 (선불, 착불, 없음)
|
||||
const freightCostOptions: { value: FreightCostType; label: string }[] = [
|
||||
{ value: 'prepaid', label: '선불' },
|
||||
{ value: 'collect', label: '착불' },
|
||||
{ value: 'none', label: '없음' },
|
||||
];
|
||||
|
||||
// 빈 배차 행 생성
|
||||
function createEmptyDispatch(): VehicleDispatch {
|
||||
return {
|
||||
id: `vd-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
logisticsCompany: '',
|
||||
arrivalDateTime: '',
|
||||
tonnage: '',
|
||||
vehicleNo: '',
|
||||
driverContact: '',
|
||||
remarks: '',
|
||||
};
|
||||
}
|
||||
|
||||
export function ShipmentCreate() {
|
||||
const router = useRouter();
|
||||
|
||||
// 폼 상태
|
||||
const [formData, setFormData] = useState<ShipmentCreateFormData>({
|
||||
lotNo: '',
|
||||
scheduledDate: getTodayString(),
|
||||
priority: 'normal',
|
||||
deliveryMethod: 'direct_dispatch',
|
||||
shipmentDate: '',
|
||||
freightCost: 'none',
|
||||
receiver: '',
|
||||
receiverContact: '',
|
||||
zipCode: '',
|
||||
address: '',
|
||||
addressDetail: '',
|
||||
vehicleDispatches: [createEmptyDispatch()],
|
||||
logisticsCompany: '',
|
||||
vehicleTonnage: '',
|
||||
loadingTime: '',
|
||||
loadingManager: '',
|
||||
remarks: '',
|
||||
});
|
||||
|
||||
// API 옵션 데이터 상태
|
||||
const [lotOptions, setLotOptions] = useState<LotOption[]>([]);
|
||||
const [logisticsOptions, setLogisticsOptions] = useState<LogisticsOption[]>([]);
|
||||
const [vehicleTonnageOptions, setVehicleTonnageOptions] = useState<VehicleTonnageOption[]>([]);
|
||||
|
||||
// 제품 데이터 (LOT 선택 시 표시)
|
||||
const [productGroups, setProductGroups] = useState<ProductGroup[]>([]);
|
||||
const [otherParts, setOtherParts] = useState<ProductPart[]>([]);
|
||||
|
||||
// 로딩/에러 상태
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// 아코디언 상태
|
||||
const [accordionValue, setAccordionValue] = useState<string[]>([]);
|
||||
|
||||
// 우편번호 찾기
|
||||
const { openPostcode } = useDaumPostcode({
|
||||
onComplete: (result) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
zipCode: result.zonecode,
|
||||
address: result.address,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
// 옵션 데이터 로드
|
||||
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(() => {
|
||||
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(prev => ({ ...prev, ...sampleData }));
|
||||
toast.success('[Dev] 출고 폼이 자동으로 채워졌습니다.');
|
||||
}, [lotOptions, logisticsOptions, vehicleTonnageOptions])
|
||||
);
|
||||
|
||||
// LOT 선택 시 현장명/수주처 자동 매핑 + 목데이터 제품 표시
|
||||
const handleLotChange = useCallback((lotNo: string) => {
|
||||
setFormData(prev => ({ ...prev, lotNo }));
|
||||
if (lotNo) {
|
||||
// 목데이터로 제품 그룹 표시
|
||||
setProductGroups(mockProductGroups);
|
||||
setOtherParts(mockOtherParts);
|
||||
} else {
|
||||
setProductGroups([]);
|
||||
setOtherParts([]);
|
||||
}
|
||||
if (validationErrors.lotNo) {
|
||||
setValidationErrors(prev => { const { lotNo: _, ...rest } = prev; return rest; });
|
||||
}
|
||||
}, [validationErrors]);
|
||||
|
||||
// 배송방식에 따라 운임비용 '없음' 고정 여부 판단
|
||||
const isFreightCostLocked = (method: DeliveryMethod) =>
|
||||
method === 'direct_dispatch' || method === 'self_pickup';
|
||||
|
||||
// 폼 입력 핸들러
|
||||
const handleInputChange = (field: keyof ShipmentCreateFormData, value: string) => {
|
||||
if (field === 'deliveryMethod') {
|
||||
const method = value as DeliveryMethod;
|
||||
if (isFreightCostLocked(method)) {
|
||||
setFormData(prev => ({ ...prev, deliveryMethod: method, freightCost: 'none' as FreightCostType }));
|
||||
} else {
|
||||
setFormData(prev => ({ ...prev, deliveryMethod: method }));
|
||||
}
|
||||
} else {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
}
|
||||
if (validationErrors[field]) {
|
||||
setValidationErrors(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[field];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 배차 정보 핸들러
|
||||
const handleDispatchChange = (index: number, field: keyof VehicleDispatch, value: string) => {
|
||||
setFormData(prev => {
|
||||
const newDispatches = [...prev.vehicleDispatches];
|
||||
newDispatches[index] = { ...newDispatches[index], [field]: value };
|
||||
return { ...prev, vehicleDispatches: newDispatches };
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddDispatch = () => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
vehicleDispatches: [...prev.vehicleDispatches, createEmptyDispatch()],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRemoveDispatch = (index: number) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
vehicleDispatches: prev.vehicleDispatches.filter((_, i) => i !== index),
|
||||
}));
|
||||
};
|
||||
|
||||
// 아코디언 제어
|
||||
const handleExpandAll = useCallback(() => {
|
||||
const allIds = [
|
||||
...productGroups.map(g => g.id),
|
||||
...(otherParts.length > 0 ? ['other-parts'] : []),
|
||||
];
|
||||
setAccordionValue(allIds);
|
||||
}, [productGroups, otherParts]);
|
||||
|
||||
const handleCollapseAll = useCallback(() => {
|
||||
setAccordionValue([]);
|
||||
}, []);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
router.push('/ko/outbound/shipments');
|
||||
}, [router]);
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const errors: Record<string, string> = {};
|
||||
if (!formData.lotNo) errors.lotNo = '로트번호는 필수 선택 항목입니다.';
|
||||
if (!formData.scheduledDate) errors.scheduledDate = '출고예정일은 필수 입력 항목입니다.';
|
||||
if (!formData.deliveryMethod) errors.deliveryMethod = '배송방식은 필수 선택 항목입니다.';
|
||||
setValidationErrors(errors);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
const firstError = Object.values(errors)[0];
|
||||
toast.error(firstError);
|
||||
}
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
||||
if (!validateForm()) return { success: false, error: '' };
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await createShipment(formData);
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || '출고 등록에 실패했습니다.' };
|
||||
}
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
if (isNextRedirectError(err)) throw err;
|
||||
console.error('[ShipmentCreate] handleSubmit error:', err);
|
||||
const errorMessage = err instanceof Error ? err.message : '저장 중 오류가 발생했습니다.';
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [formData]);
|
||||
|
||||
// 제품 부품 테이블 렌더링
|
||||
const renderPartsTable = (parts: ProductPart[]) => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16 text-center">순번</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
<TableHead className="w-32">규격</TableHead>
|
||||
<TableHead className="w-20 text-center">수량</TableHead>
|
||||
<TableHead className="w-20 text-center">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{parts.map((part) => (
|
||||
<TableRow key={part.id}>
|
||||
<TableCell className="text-center">{part.seq}</TableCell>
|
||||
<TableCell>{part.itemName}</TableCell>
|
||||
<TableCell>{part.specification}</TableCell>
|
||||
<TableCell className="text-center">{part.quantity}</TableCell>
|
||||
<TableCell className="text-center">{part.unit}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
|
||||
// LOT에서 선택한 정보 표시
|
||||
const selectedLot = lotOptions.find(o => o.value === formData.lotNo);
|
||||
|
||||
// 폼 컨텐츠 렌더링
|
||||
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">
|
||||
{/* 카드 1: 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{/* 출고번호 - 자동생성 */}
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">출고번호</div>
|
||||
<div className="font-medium text-muted-foreground">자동생성</div>
|
||||
</div>
|
||||
{/* 로트번호 - Select */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-muted-foreground">로트번호 *</div>
|
||||
<Select
|
||||
value={formData.lotNo}
|
||||
onValueChange={handleLotChange}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger className={validationErrors.lotNo ? 'border-red-500' : ''}>
|
||||
<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>
|
||||
{validationErrors.lotNo && <p className="text-sm text-red-500">{validationErrors.lotNo}</p>}
|
||||
</div>
|
||||
{/* 현장명 - LOT 선택 시 자동 매핑 */}
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">현장명</div>
|
||||
<div className="font-medium">{selectedLot?.siteName || '-'}</div>
|
||||
</div>
|
||||
{/* 수주처 - LOT 선택 시 자동 매핑 */}
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">수주처</div>
|
||||
<div className="font-medium">{selectedLot?.customerName || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 카드 2: 수주/배송 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">수주/배송 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>출고 예정일 *</Label>
|
||||
<DatePicker
|
||||
value={formData.scheduledDate}
|
||||
onChange={(date) => handleInputChange('scheduledDate', date)}
|
||||
disabled={isSubmitting}
|
||||
className={validationErrors.scheduledDate ? 'border-red-500' : ''}
|
||||
/>
|
||||
{validationErrors.scheduledDate && <p className="text-sm text-red-500">{validationErrors.scheduledDate}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>출고일</Label>
|
||||
<DatePicker
|
||||
value={formData.shipmentDate || ''}
|
||||
onChange={(date) => handleInputChange('shipmentDate', date)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>배송방식 *</Label>
|
||||
<Select
|
||||
value={formData.deliveryMethod}
|
||||
onValueChange={(value) => handleInputChange('deliveryMethod', value)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger className={validationErrors.deliveryMethod ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="배송방식 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{deliveryMethodOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{validationErrors.deliveryMethod && <p className="text-sm text-red-500">{validationErrors.deliveryMethod}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>운임비용</Label>
|
||||
<Select
|
||||
value={formData.freightCost || ''}
|
||||
onValueChange={(value) => handleInputChange('freightCost', value)}
|
||||
disabled={isSubmitting || isFreightCostLocked(formData.deliveryMethod)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{freightCostOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>수신자</Label>
|
||||
<Input
|
||||
value={formData.receiver || ''}
|
||||
onChange={(e) => handleInputChange('receiver', e.target.value)}
|
||||
placeholder="수신자명"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>수신처</Label>
|
||||
<Input
|
||||
value={formData.receiverContact || ''}
|
||||
onChange={(e) => handleInputChange('receiverContact', e.target.value)}
|
||||
placeholder="수신처"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* 주소 */}
|
||||
<div className="space-y-2">
|
||||
<Label>주소</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={formData.zipCode || ''}
|
||||
placeholder="우편번호"
|
||||
className="w-32"
|
||||
readOnly
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={openPostcode}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Search className="w-4 h-4 mr-1" />
|
||||
우편번호 찾기
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
value={formData.address || ''}
|
||||
placeholder="주소"
|
||||
readOnly
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<Input
|
||||
value={formData.addressDetail || ''}
|
||||
onChange={(e) => handleInputChange('addressDetail', e.target.value)}
|
||||
placeholder="상세주소"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 카드 3: 배차 정보 */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">배차 정보</CardTitle>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleAddDispatch}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>물류업체</TableHead>
|
||||
<TableHead>입차일시</TableHead>
|
||||
<TableHead>구분</TableHead>
|
||||
<TableHead>차량번호</TableHead>
|
||||
<TableHead>기사연락처</TableHead>
|
||||
<TableHead>비고</TableHead>
|
||||
<TableHead className="w-12"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{formData.vehicleDispatches.map((dispatch, index) => (
|
||||
<TableRow key={dispatch.id}>
|
||||
<TableCell className="p-1">
|
||||
<Select
|
||||
value={dispatch.logisticsCompany}
|
||||
onValueChange={(value) => handleDispatchChange(index, 'logisticsCompany', value)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{logisticsOptions.filter(o => o.value).map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<DateTimePicker
|
||||
value={dispatch.arrivalDateTime}
|
||||
onChange={(val) => handleDispatchChange(index, 'arrivalDateTime', val)}
|
||||
size="sm"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Select
|
||||
value={dispatch.tonnage}
|
||||
onValueChange={(value) => handleDispatchChange(index, 'tonnage', value)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{vehicleTonnageOptions.filter(o => o.value).map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input
|
||||
value={dispatch.vehicleNo}
|
||||
onChange={(e) => handleDispatchChange(index, 'vehicleNo', e.target.value)}
|
||||
placeholder="차량번호"
|
||||
className="h-8"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input
|
||||
value={dispatch.driverContact}
|
||||
onChange={(e) => handleDispatchChange(index, 'driverContact', e.target.value)}
|
||||
placeholder="연락처"
|
||||
className="h-8"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input
|
||||
value={dispatch.remarks}
|
||||
onChange={(e) => handleDispatchChange(index, 'remarks', e.target.value)}
|
||||
placeholder="비고"
|
||||
className="h-8"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-center">
|
||||
{formData.vehicleDispatches.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleRemoveDispatch(index)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 카드 4: 제품내용 (읽기전용 - LOT 선택 시 표시) */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">제품내용</CardTitle>
|
||||
{productGroups.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
펼치기/접기
|
||||
<ChevronDown className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleExpandAll}>
|
||||
모두 펼치기
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleCollapseAll}>
|
||||
모두 접기
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{productGroups.length > 0 || otherParts.length > 0 ? (
|
||||
<Accordion
|
||||
type="multiple"
|
||||
value={accordionValue}
|
||||
onValueChange={setAccordionValue}
|
||||
>
|
||||
{productGroups.map((group: ProductGroup) => (
|
||||
<AccordionItem key={group.id} value={group.id}>
|
||||
<AccordionTrigger className="hover:no-underline">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-medium">{group.productName}</span>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
({group.specification})
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{group.partCount}개 부품
|
||||
</Badge>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{renderPartsTable(group.parts)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
{otherParts.length > 0 && (
|
||||
<AccordionItem value="other-parts">
|
||||
<AccordionTrigger className="hover:no-underline">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-medium">기타부품</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{otherParts.length}개 부품
|
||||
</Badge>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{renderPartsTable(otherParts)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
</Accordion>
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground text-sm py-4">
|
||||
{formData.lotNo ? '제품 정보를 불러오는 중...' : '로트를 선택하면 제품 목록이 표시됩니다.'}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
), [
|
||||
formData, validationErrors, isSubmitting, lotOptions, logisticsOptions,
|
||||
vehicleTonnageOptions, selectedLot, productGroups, otherParts, accordionValue,
|
||||
handleLotChange, handleExpandAll, handleCollapseAll, openPostcode,
|
||||
]);
|
||||
|
||||
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> }) => (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-900">{error}</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<IntegratedDetailTemplate
|
||||
config={shipmentCreateConfig}
|
||||
mode="create"
|
||||
isLoading={isLoading}
|
||||
onCancel={handleCancel}
|
||||
onSubmit={async () => {
|
||||
return await handleSubmit();
|
||||
}}
|
||||
renderForm={renderFormContent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -72,6 +72,8 @@ import type {
|
||||
} from './types';
|
||||
import { ShippingSlip } from './documents/ShippingSlip';
|
||||
import { DeliveryConfirmation } from './documents/DeliveryConfirmation';
|
||||
import type { OrderDocumentDetail } from './documents/ShipmentOrderDocument';
|
||||
import { getOrderDocumentDetail } from '@/components/orders/actions';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -90,6 +92,7 @@ const STATUS_TRANSITIONS: Record<ShipmentStatus, ShipmentStatus | null> = {
|
||||
export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
const router = useRouter();
|
||||
const [previewDocument, setPreviewDocument] = useState<'shipping' | 'delivery' | null>(null);
|
||||
const [orderDetail, setOrderDetail] = useState<OrderDocumentDetail | null>(null);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
@@ -139,6 +142,21 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// 문서 모달 열 때 수주 BOM 데이터 로드
|
||||
useEffect(() => {
|
||||
if (!previewDocument || !detail?.orderId) {
|
||||
if (!previewDocument) setOrderDetail(null);
|
||||
return;
|
||||
}
|
||||
getOrderDocumentDetail(String(detail.orderId)).then((result) => {
|
||||
if (result.success && result.data) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const raw = result.data as Record<string, any>;
|
||||
setOrderDetail((raw?.data ?? raw) as OrderDocumentDetail);
|
||||
}
|
||||
});
|
||||
}, [previewDocument, detail?.orderId]);
|
||||
|
||||
const _handleGoBack = useCallback(() => {
|
||||
router.push('/ko/outbound/shipments');
|
||||
}, [router]);
|
||||
@@ -340,7 +358,8 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
{renderInfoField('로트번호', detail.lotNo)}
|
||||
{renderInfoField('출고로트', detail.lotNo || detail.shipmentNo)}
|
||||
{renderInfoField('수주로트', detail.orderLotNo)}
|
||||
{renderInfoField('현장명', detail.siteName)}
|
||||
{renderInfoField('수주처', detail.customerName)}
|
||||
{renderInfoField('작성자', detail.registrant)}
|
||||
@@ -543,8 +562,8 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
>
|
||||
{detail && (
|
||||
<>
|
||||
{previewDocument === 'shipping' && <ShippingSlip data={detail} />}
|
||||
{previewDocument === 'delivery' && <DeliveryConfirmation data={detail} />}
|
||||
{previewDocument === 'shipping' && <ShippingSlip data={detail} orderDetail={orderDetail} />}
|
||||
{previewDocument === 'delivery' && <DeliveryConfirmation data={detail} orderDetail={orderDetail} />}
|
||||
</>
|
||||
)}
|
||||
</DocumentViewer>
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
Plus,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -92,11 +91,6 @@ export function ShipmentList() {
|
||||
[router]
|
||||
);
|
||||
|
||||
// ===== 등록 핸들러 =====
|
||||
const handleCreate = useCallback(() => {
|
||||
router.push('/ko/outbound/shipments?mode=new');
|
||||
}, [router]);
|
||||
|
||||
// ===== 통계 카드 (3개: 당일 출고대기, 출고대기, 출고완료) =====
|
||||
const stats: StatCard[] = useMemo(
|
||||
() => [
|
||||
@@ -218,17 +212,11 @@ export function ShipmentList() {
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
|
||||
// 등록 버튼
|
||||
createButton: {
|
||||
label: '출고 등록',
|
||||
onClick: handleCreate,
|
||||
icon: Plus,
|
||||
},
|
||||
|
||||
// 테이블 컬럼 (11개)
|
||||
// 테이블 컬럼 (12개)
|
||||
columns: [
|
||||
{ key: 'no', label: '번호', className: 'w-[50px] text-center' },
|
||||
{ key: 'lotNo', label: '로트번호', className: 'min-w-[120px]', copyable: true },
|
||||
{ key: 'lotNo', label: '출고로트', className: 'min-w-[120px]', copyable: true },
|
||||
{ key: 'orderLotNo', label: '수주로트', className: 'min-w-[120px]', copyable: true },
|
||||
{ key: 'siteName', label: '현장명', className: 'min-w-[100px]', copyable: true },
|
||||
{ key: 'orderCustomer', label: '수주처', className: 'min-w-[100px]', copyable: true },
|
||||
{ key: 'receiver', label: '수신자', className: 'w-[80px] text-center', copyable: true },
|
||||
@@ -300,7 +288,8 @@ export function ShipmentList() {
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell className="font-medium">{item.lotNo || item.shipmentNo || '-'}</TableCell>
|
||||
<TableCell className="font-medium">{item.lotNo?.trim() || item.shipmentNo || '-'}</TableCell>
|
||||
<TableCell>{item.orderLotNo || '-'}</TableCell>
|
||||
<TableCell className="max-w-[100px] truncate">{item.siteName}</TableCell>
|
||||
<TableCell>{item.orderCustomer || item.customerName || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.receiver || '-'}</TableCell>
|
||||
@@ -350,7 +339,8 @@ export function ShipmentList() {
|
||||
}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="로트번호" value={item.lotNo || item.shipmentNo} />
|
||||
<InfoField label="출고로트" value={item.lotNo || item.shipmentNo} />
|
||||
<InfoField label="수주로트" value={item.orderLotNo || '-'} />
|
||||
<InfoField label="현장명" value={item.siteName} />
|
||||
<InfoField label="수주처" value={item.orderCustomer || item.customerName || '-'} />
|
||||
<InfoField label="수신자" value={item.receiver || '-'} />
|
||||
@@ -403,7 +393,7 @@ export function ShipmentList() {
|
||||
/>
|
||||
),
|
||||
}),
|
||||
[stats, startDate, endDate, scheduleEvents, calendarDate, scheduleView, handleRowClick, handleCreate, handleCalendarDateClick, handleCalendarEventClick]
|
||||
[stats, startDate, endDate, scheduleEvents, calendarDate, scheduleView, handleRowClick, handleCalendarDateClick, handleCalendarEventClick]
|
||||
);
|
||||
|
||||
return <UniversalListPage config={config} />;
|
||||
|
||||
@@ -31,9 +31,7 @@ import type {
|
||||
ShipmentPriority,
|
||||
DeliveryMethod,
|
||||
FreightCostType,
|
||||
ShipmentCreateFormData,
|
||||
ShipmentEditFormData,
|
||||
LotOption,
|
||||
LogisticsOption,
|
||||
VehicleTonnageOption,
|
||||
} from './types';
|
||||
@@ -151,6 +149,7 @@ function transformApiToListItem(data: ShipmentApiData): ShipmentItem {
|
||||
id: String(data.id),
|
||||
shipmentNo: data.shipment_no,
|
||||
lotNo: data.lot_no || '',
|
||||
orderLotNo: data.order_info?.order_no || '',
|
||||
scheduledDate: data.scheduled_date,
|
||||
status: data.status,
|
||||
priority: data.priority,
|
||||
@@ -189,10 +188,40 @@ function transformApiToProduct(data: ShipmentItemApiData): ShipmentProduct {
|
||||
|
||||
// ===== API → Frontend 변환 (상세용) =====
|
||||
function transformApiToDetail(data: ShipmentApiData): ShipmentDetail {
|
||||
// items를 floor_unit 기준으로 productGroups 자동 그룹핑
|
||||
const rawItems = data.items || [];
|
||||
const items = rawItems.map(transformApiToProduct);
|
||||
const groupMap = new Map<string, { productName: string; specification: string; parts: { product: ShipmentProduct; unit: string }[] }>();
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
const raw = rawItems[i];
|
||||
const key = item.floorUnit || `item-${item.id}`;
|
||||
if (!groupMap.has(key)) {
|
||||
groupMap.set(key, { productName: key, specification: '', parts: [] });
|
||||
}
|
||||
groupMap.get(key)!.parts.push({ product: item, unit: raw.unit || '' });
|
||||
}
|
||||
const productGroups = Array.from(groupMap.entries()).map(([key, g]) => ({
|
||||
id: key,
|
||||
productName: g.productName,
|
||||
specification: g.parts[0]?.product.specification || '',
|
||||
partCount: g.parts.length,
|
||||
parts: g.parts.map((p, i) => ({
|
||||
id: p.product.id,
|
||||
seq: i + 1,
|
||||
itemName: p.product.itemName,
|
||||
specification: p.product.specification,
|
||||
quantity: p.product.quantity,
|
||||
unit: p.unit,
|
||||
})),
|
||||
}));
|
||||
|
||||
return {
|
||||
id: String(data.id),
|
||||
orderId: data.order_id ?? data.order_info?.order_id,
|
||||
shipmentNo: data.shipment_no,
|
||||
lotNo: data.lot_no || '',
|
||||
orderLotNo: data.order_info?.order_no || '',
|
||||
scheduledDate: data.scheduled_date,
|
||||
shipmentDate: (data as unknown as Record<string, unknown>).shipment_date as string | undefined,
|
||||
status: data.status,
|
||||
@@ -238,10 +267,10 @@ function transformApiToDetail(data: ShipmentApiData): ShipmentDetail {
|
||||
remarks: '',
|
||||
}]
|
||||
: []),
|
||||
// 제품내용 (그룹핑) - 프론트엔드에서 그룹핑 처리
|
||||
productGroups: [],
|
||||
// 제품내용 (그룹핑) - floor_unit 기준 자동 그룹핑
|
||||
productGroups,
|
||||
otherParts: [],
|
||||
products: (data.items || []).map(transformApiToProduct),
|
||||
products: items,
|
||||
logisticsCompany: data.logistics_company,
|
||||
vehicleTonnage: data.vehicle_tonnage,
|
||||
shippingCost: data.shipping_cost ? parseFloat(String(data.shipping_cost)) : undefined,
|
||||
@@ -253,11 +282,12 @@ function transformApiToDetail(data: ShipmentApiData): ShipmentDetail {
|
||||
}
|
||||
|
||||
// ===== API → Frontend 변환 (통계용) =====
|
||||
function transformApiToStats(data: ShipmentApiStatsResponse & { total_count?: number }): ShipmentStats {
|
||||
function transformApiToStats(data: ShipmentApiStatsResponse & { total_count?: number; completed_count?: number; ready_count?: number }): ShipmentStats {
|
||||
return {
|
||||
todayShipmentCount: data.today_shipment_count,
|
||||
scheduledCount: data.scheduled_count,
|
||||
shippingCount: data.shipping_count,
|
||||
completedCount: data.completed_count || 0,
|
||||
urgentCount: data.urgent_count,
|
||||
totalCount: data.total_count || 0,
|
||||
};
|
||||
@@ -285,37 +315,6 @@ function transformApiToStatsByStatus(data: ShipmentApiStatsByStatusResponse): Sh
|
||||
return result;
|
||||
}
|
||||
|
||||
// ===== Frontend → API 변환 (등록용) =====
|
||||
function transformCreateFormToApi(
|
||||
data: ShipmentCreateFormData
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {
|
||||
lot_no: data.lotNo,
|
||||
scheduled_date: data.scheduledDate,
|
||||
priority: data.priority,
|
||||
delivery_method: data.deliveryMethod,
|
||||
logistics_company: data.logisticsCompany,
|
||||
vehicle_tonnage: data.vehicleTonnage,
|
||||
loading_time: data.loadingTime,
|
||||
loading_manager: data.loadingManager,
|
||||
remarks: data.remarks,
|
||||
};
|
||||
|
||||
if (data.vehicleDispatches && data.vehicleDispatches.length > 0) {
|
||||
result.vehicle_dispatches = data.vehicleDispatches.map((vd, idx) => ({
|
||||
seq: idx + 1,
|
||||
logistics_company: vd.logisticsCompany || null,
|
||||
arrival_datetime: vd.arrivalDateTime || null,
|
||||
tonnage: vd.tonnage || null,
|
||||
vehicle_no: vd.vehicleNo || null,
|
||||
driver_contact: vd.driverContact || null,
|
||||
remarks: vd.remarks || null,
|
||||
}));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ===== Frontend → API 변환 (수정용) =====
|
||||
function transformEditFormToApi(
|
||||
data: Partial<ShipmentEditFormData>
|
||||
@@ -423,22 +422,6 @@ export async function getShipmentById(id: string): Promise<{ success: boolean; d
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
// ===== 출고 등록 =====
|
||||
export async function createShipment(
|
||||
data: ShipmentCreateFormData
|
||||
): Promise<{ success: boolean; data?: ShipmentDetail; error?: string; __authError?: boolean }> {
|
||||
const apiData = transformCreateFormToApi(data);
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl('/api/v1/shipments'),
|
||||
method: 'POST',
|
||||
body: apiData,
|
||||
transform: (d: ShipmentApiData) => transformApiToDetail(d),
|
||||
errorMessage: '출고 등록에 실패했습니다.',
|
||||
});
|
||||
if (result.__authError) return { success: false, __authError: true };
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
// ===== 출고 수정 =====
|
||||
export async function updateShipment(
|
||||
id: string, data: Partial<ShipmentEditFormData>
|
||||
@@ -493,16 +476,6 @@ export async function deleteShipment(id: string): Promise<{ success: boolean; er
|
||||
return { success: result.success, error: result.error };
|
||||
}
|
||||
|
||||
// ===== LOT 옵션 조회 =====
|
||||
export async function getLotOptions(): Promise<{ success: boolean; data: LotOption[]; error?: string; __authError?: boolean }> {
|
||||
const result = await executeServerAction<LotOption[]>({
|
||||
url: buildApiUrl('/api/v1/shipments/options/lots'),
|
||||
errorMessage: 'LOT 옵션 조회에 실패했습니다.',
|
||||
});
|
||||
if (result.__authError) return { success: false, data: [], __authError: true };
|
||||
return { success: result.success, data: result.data || [], error: result.error };
|
||||
}
|
||||
|
||||
// ===== 물류사 옵션 조회 =====
|
||||
export async function getLogisticsOptions(): Promise<{ success: boolean; data: LogisticsOption[]; error?: string; __authError?: boolean }> {
|
||||
const result = await executeServerAction<LogisticsOption[]>({
|
||||
|
||||
@@ -6,12 +6,13 @@
|
||||
*/
|
||||
|
||||
import type { ShipmentDetail } from '../types';
|
||||
import { ShipmentOrderDocument } from './ShipmentOrderDocument';
|
||||
import { ShipmentOrderDocument, type OrderDocumentDetail } from './ShipmentOrderDocument';
|
||||
|
||||
interface DeliveryConfirmationProps {
|
||||
data: ShipmentDetail;
|
||||
orderDetail?: OrderDocumentDetail | null;
|
||||
}
|
||||
|
||||
export function DeliveryConfirmation({ data }: DeliveryConfirmationProps) {
|
||||
return <ShipmentOrderDocument title="납 품 확 인 서" data={data} />;
|
||||
}
|
||||
export function DeliveryConfirmation({ data, orderDetail }: DeliveryConfirmationProps) {
|
||||
return <ShipmentOrderDocument title="납 품 확 인 서" data={data} orderDetail={orderDetail} />;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,12 +6,13 @@
|
||||
*/
|
||||
|
||||
import type { ShipmentDetail } from '../types';
|
||||
import { ShipmentOrderDocument } from './ShipmentOrderDocument';
|
||||
import { ShipmentOrderDocument, type OrderDocumentDetail } from './ShipmentOrderDocument';
|
||||
|
||||
interface ShippingSlipProps {
|
||||
data: ShipmentDetail;
|
||||
orderDetail?: OrderDocumentDetail | null;
|
||||
}
|
||||
|
||||
export function ShippingSlip({ data }: ShippingSlipProps) {
|
||||
return <ShipmentOrderDocument title="출 고 증" data={data} showDispatchInfo showLotColumn />;
|
||||
}
|
||||
export function ShippingSlip({ data, orderDetail }: ShippingSlipProps) {
|
||||
return <ShipmentOrderDocument title="출 고 증" data={data} orderDetail={orderDetail} showDispatchInfo showLotColumn />;
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
*/
|
||||
|
||||
export { ShipmentList } from './ShipmentList';
|
||||
export { ShipmentCreate } from './ShipmentCreate';
|
||||
export { ShipmentDetail } from './ShipmentDetail';
|
||||
export { ShipmentEdit } from './ShipmentEdit';
|
||||
|
||||
|
||||
@@ -37,12 +37,11 @@ export const shipmentConfig: DetailConfig = {
|
||||
};
|
||||
|
||||
/**
|
||||
* 출고 등록 페이지 Config
|
||||
* IntegratedDetailTemplate 마이그레이션 (2025-01-20)
|
||||
* 출고 수정 페이지 Config
|
||||
*/
|
||||
export const shipmentCreateConfig: DetailConfig = {
|
||||
export const shipmentEditConfig: DetailConfig = {
|
||||
title: '출고',
|
||||
description: '새로운 출고를 등록합니다',
|
||||
description: '출고 정보를 수정합니다',
|
||||
icon: Truck,
|
||||
basePath: '/outbound/shipments',
|
||||
fields: [],
|
||||
@@ -53,13 +52,4 @@ export const shipmentCreateConfig: DetailConfig = {
|
||||
showSave: true,
|
||||
submitLabel: '저장',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 출고 수정 페이지 Config
|
||||
*/
|
||||
export const shipmentEditConfig: DetailConfig = {
|
||||
...shipmentCreateConfig,
|
||||
title: '출고',
|
||||
description: '출고 정보를 수정합니다',
|
||||
};
|
||||
};
|
||||
@@ -111,7 +111,8 @@ export const DELIVERY_METHOD_LABELS: Record<DeliveryMethod, string> = {
|
||||
export interface ShipmentItem {
|
||||
id: string;
|
||||
shipmentNo: string; // 출고번호
|
||||
lotNo: string; // 로트번호
|
||||
lotNo: string; // 출고로트
|
||||
orderLotNo?: string; // 수주로트 (수주번호)
|
||||
scheduledDate: string; // 출고예정일
|
||||
status: ShipmentStatus; // 상태
|
||||
priority: ShipmentPriority; // 우선순위
|
||||
@@ -158,8 +159,10 @@ export interface ShipmentProduct {
|
||||
export interface ShipmentDetail {
|
||||
// 기본 정보 (읽기전용)
|
||||
id: string;
|
||||
orderId?: number; // 수주 ID (문서 BOM 데이터 조회용)
|
||||
shipmentNo: string; // 출고번호
|
||||
lotNo: string; // 로트번호
|
||||
lotNo: string; // 출고로트
|
||||
orderLotNo?: string; // 수주로트 (수주번호)
|
||||
siteName: string; // 현장명
|
||||
customerName: string; // 수주처
|
||||
customerGrade: string; // 거래등급
|
||||
|
||||
Reference in New Issue
Block a user