diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-14 05:28:01 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-14 05:28:01 +0000 |
| commit | 675b4e3d8ffcb57a041db285417d81e61284d900 (patch) | |
| tree | 254f3d6a6c0ce39ae8fba35618f3810e08945f19 /lib/rfq-last/vendor-response/editor/quotation-items-table.tsx | |
| parent | 39f12cb19f29cbc5568057e154e6adf4789ae736 (diff) | |
(대표님) RFQ-last, tbe-last, 기본계약 템플릿 내 견적,입찰,계약 추가, env.dev NAS_PATH 수정
Diffstat (limited to 'lib/rfq-last/vendor-response/editor/quotation-items-table.tsx')
| -rw-r--r-- | lib/rfq-last/vendor-response/editor/quotation-items-table.tsx | 449 |
1 files changed, 449 insertions, 0 deletions
diff --git a/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx b/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx new file mode 100644 index 00000000..08928b4d --- /dev/null +++ b/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx @@ -0,0 +1,449 @@ +"use client" + +import { useFormContext, useFieldArray } from "react-hook-form" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Checkbox } from "@/components/ui/checkbox" +import { Button } from "@/components/ui/button" +import { Calendar } from "@/components/ui/calendar" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { Badge } from "@/components/ui/badge" +import { Label } from "@/components/ui/label" +import { CalendarIcon, Eye, Calculator, AlertCircle } from "lucide-react" +import { format } from "date-fns" +import { cn, formatCurrency } from "@/lib/utils" +import { useState } from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" + +interface QuotationItemsTableProps { + prItems: any[] +} + +export default function QuotationItemsTable({ prItems }: QuotationItemsTableProps) { + const { control, register, setValue, watch } = useFormContext() + const { fields } = useFieldArray({ + control, + name: "quotationItems" + }) + + const [selectedItem, setSelectedItem] = useState<any>(null) + const [showDetail, setShowDetail] = useState(false) + + const currency = watch("vendorCurrency") || "USD" + const quotationItems = watch("quotationItems") + + // 단가 * 수량 계산 + const calculateTotal = (index: number) => { + const item = quotationItems[index] + const prItem = prItems[index] + if (item && prItem) { + const total = (item.unitPrice || 0) * (prItem.quantity || 0) + setValue(`quotationItems.${index}.totalPrice`, total) + } + } + + // 할인 적용 + const applyDiscount = (index: number) => { + const item = quotationItems[index] + const prItem = prItems[index] + if (item && prItem && item.discountRate) { + const originalTotal = (item.unitPrice || 0) * (prItem.quantity || 0) + const discountAmount = originalTotal * (item.discountRate / 100) + const finalTotal = originalTotal - discountAmount + setValue(`quotationItems.${index}.totalPrice`, finalTotal) + } + } + + const totalAmount = quotationItems?.reduce( + (sum: number, item: any) => sum + (item.totalPrice || 0), 0 + ) || 0 + + // 상세 정보 다이얼로그 + const ItemDetailDialog = ({ item, prItem, index }: any) => ( + <Dialog open={showDetail} onOpenChange={setShowDetail}> + <DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>견적 상세 정보</DialogTitle> + <DialogDescription> + {prItem.materialCode} - {prItem.materialDescription} + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {/* PR 아이템 정보 */} + <Card> + <CardHeader className="pb-3"> + <CardTitle className="text-base">PR 아이템 정보</CardTitle> + </CardHeader> + <CardContent className="grid grid-cols-2 gap-4"> + <div> + <Label className="text-xs text-muted-foreground">PR 번호</Label> + <p className="font-medium">{prItem.prNo}</p> + </div> + <div> + <Label className="text-xs text-muted-foreground">자재 코드</Label> + <p className="font-medium">{prItem.materialCode}</p> + </div> + <div> + <Label className="text-xs text-muted-foreground">수량</Label> + <p className="font-medium">{prItem.quantity} {prItem.uom}</p> + </div> + <div> + <Label className="text-xs text-muted-foreground">요청 납기일</Label> + <p className="font-medium"> + {prItem.deliveryDate ? format(new Date(prItem.deliveryDate), "yyyy-MM-dd") : '-'} + </p> + </div> + {prItem.specNo && ( + <div> + <Label className="text-xs text-muted-foreground">스펙 번호</Label> + <p className="font-medium">{prItem.specNo}</p> + </div> + )} + {prItem.trackingNo && ( + <div> + <Label className="text-xs text-muted-foreground">추적 번호</Label> + <p className="font-medium">{prItem.trackingNo}</p> + </div> + )} + </CardContent> + </Card> + + {/* 제조사 정보 */} + <Card> + <CardHeader className="pb-3"> + <CardTitle className="text-base">제조사 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-3"> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor={`manufacturer-${index}`}>제조사</Label> + <Input + id={`manufacturer-${index}`} + {...register(`quotationItems.${index}.manufacturer`)} + placeholder="제조사 입력" + /> + </div> + <div className="space-y-2"> + <Label htmlFor={`manufacturerCountry-${index}`}>제조국</Label> + <Input + id={`manufacturerCountry-${index}`} + {...register(`quotationItems.${index}.manufacturerCountry`)} + placeholder="제조국 입력" + /> + </div> + </div> + <div className="space-y-2"> + <Label htmlFor={`modelNo-${index}`}>모델 번호</Label> + <Input + id={`modelNo-${index}`} + {...register(`quotationItems.${index}.modelNo`)} + placeholder="모델 번호 입력" + /> + </div> + </CardContent> + </Card> + + {/* 기술 준수 및 대안 */} + <Card> + <CardHeader className="pb-3"> + <CardTitle className="text-base">기술 사양</CardTitle> + </CardHeader> + <CardContent className="space-y-3"> + <div className="flex items-center space-x-2"> + <Checkbox + id={`technicalCompliance-${index}`} + checked={watch(`quotationItems.${index}.technicalCompliance`)} + onCheckedChange={(checked) => + setValue(`quotationItems.${index}.technicalCompliance`, checked) + } + /> + <Label htmlFor={`technicalCompliance-${index}`}> + 기술 사양 준수 + </Label> + </div> + + {!watch(`quotationItems.${index}.technicalCompliance`) && ( + <div className="space-y-2"> + <Label htmlFor={`alternativeProposal-${index}`}> + 대안 제안 <span className="text-red-500">*</span> + </Label> + <Textarea + id={`alternativeProposal-${index}`} + {...register(`quotationItems.${index}.alternativeProposal`)} + placeholder="기술 사양을 준수하지 않는 경우 대안을 제시해주세요" + className="min-h-[100px]" + /> + </div> + )} + + <div className="space-y-2"> + <Label htmlFor={`deviationReason-${index}`}>편차 사유</Label> + <Textarea + id={`deviationReason-${index}`} + {...register(`quotationItems.${index}.deviationReason`)} + placeholder="요구사항과 다른 부분이 있는 경우 사유를 입력하세요" + className="min-h-[80px]" + /> + </div> + </CardContent> + </Card> + + {/* 할인 정보 */} + <Card> + <CardHeader className="pb-3"> + <CardTitle className="text-base">할인 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-3"> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor={`discountRate-${index}`}>할인율 (%)</Label> + <Input + id={`discountRate-${index}`} + type="number" + step="0.01" + {...register(`quotationItems.${index}.discountRate`, { valueAsNumber: true })} + onChange={(e) => { + setValue(`quotationItems.${index}.discountRate`, parseFloat(e.target.value)) + applyDiscount(index) + }} + placeholder="0.00" + /> + </div> + <div className="space-y-2"> + <Label>할인 금액</Label> + <div className="h-10 px-3 py-2 border rounded-md bg-muted"> + {formatCurrency( + (watch(`quotationItems.${index}.unitPrice`) || 0) * + (prItem.quantity || 0) * + ((watch(`quotationItems.${index}.discountRate`) || 0) / 100), + currency + )} + </div> + </div> + </div> + </CardContent> + </Card> + + {/* 비고 */} + <Card> + <CardHeader className="pb-3"> + <CardTitle className="text-base">비고</CardTitle> + </CardHeader> + <CardContent> + <Textarea + {...register(`quotationItems.${index}.itemRemark`)} + placeholder="아이템별 비고사항을 입력하세요" + className="min-h-[100px]" + /> + </CardContent> + </Card> + </div> + </DialogContent> + </Dialog> + ) + + return ( + <Card> + <CardHeader> + <div className="flex items-center justify-between"> + <div> + <CardTitle>견적 품목</CardTitle> + <CardDescription> + 각 PR 아이템에 대한 견적 단가와 정보를 입력하세요 + </CardDescription> + </div> + <div className="text-right"> + <p className="text-sm text-muted-foreground">총 견적금액</p> + <p className="text-2xl font-bold text-primary"> + {formatCurrency(totalAmount, currency)} + </p> + </div> + </div> + </CardHeader> + <CardContent> + <div className="overflow-x-auto"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[50px]">No</TableHead> + <TableHead className="w-[100px]">PR No</TableHead> + <TableHead className="min-w-[150px]">자재코드</TableHead> + <TableHead className="min-w-[200px]">자재설명</TableHead> + <TableHead className="text-right w-[100px]">수량</TableHead> + <TableHead className="w-[150px]">단가</TableHead> + <TableHead className="text-right w-[150px]">총액</TableHead> + <TableHead className="w-[150px]">납기일</TableHead> + <TableHead className="w-[100px]">리드타임</TableHead> + <TableHead className="w-[80px]">작업</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {fields.map((field, index) => { + const prItem = prItems[index] + const quotationItem = quotationItems[index] + const isMajor = prItem?.majorYn + + return ( + <TableRow key={field.id} className={isMajor ? "bg-yellow-50" : ""}> + <TableCell> + <div className="flex items-center gap-2"> + {prItem?.rfqItem || index + 1} + {isMajor && ( + <Badge variant="secondary" className="text-xs"> + 주요 + </Badge> + )} + </div> + </TableCell> + <TableCell className="font-mono text-xs"> + {prItem?.prNo} + </TableCell> + <TableCell className="font-mono text-xs"> + {prItem?.materialCode} + </TableCell> + <TableCell> + <div className="max-w-[200px]"> + <p className="truncate text-sm" title={prItem?.materialDescription}> + {prItem?.materialDescription} + </p> + {prItem?.size && ( + <p className="text-xs text-muted-foreground"> + 사이즈: {prItem.size} + </p> + )} + </div> + </TableCell> + <TableCell className="text-right"> + {prItem?.quantity} {prItem?.uom} + </TableCell> + <TableCell> + <div className="flex items-center gap-1"> + <Input + type="number" + step="0.01" + {...register(`quotationItems.${index}.unitPrice`, { valueAsNumber: true })} + onChange={(e) => { + setValue(`quotationItems.${index}.unitPrice`, parseFloat(e.target.value)) + calculateTotal(index) + }} + className="w-[120px]" + placeholder="0.00" + /> + <span className="text-xs text-muted-foreground"> + {currency} + </span> + </div> + </TableCell> + <TableCell className="text-right font-medium"> + {formatCurrency(quotationItem?.totalPrice || 0, currency)} + </TableCell> + <TableCell> + <Popover> + <PopoverTrigger asChild> + <Button + variant="outline" + size="sm" + className={cn( + "w-[130px] justify-start text-left font-normal", + !quotationItem?.vendorDeliveryDate && "text-muted-foreground" + )} + > + <CalendarIcon className="mr-2 h-3 w-3" /> + {quotationItem?.vendorDeliveryDate + ? format(quotationItem.vendorDeliveryDate, "yyyy-MM-dd") + : "선택"} + </Button> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <Calendar + mode="single" + selected={quotationItem?.vendorDeliveryDate} + onSelect={(date) => setValue(`quotationItems.${index}.vendorDeliveryDate`, date)} + initialFocus + /> + </PopoverContent> + </Popover> + {prItem?.deliveryDate && quotationItem?.vendorDeliveryDate && + new Date(quotationItem.vendorDeliveryDate) > new Date(prItem.deliveryDate) && ( + <div className="mt-1"> + <Badge variant="destructive" className="text-xs"> + 지연 + </Badge> + </div> + )} + </TableCell> + <TableCell> + <Input + type="number" + {...register(`quotationItems.${index}.leadTime`, { valueAsNumber: true })} + className="w-[80px]" + placeholder="일" + /> + </TableCell> + <TableCell> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => { + setSelectedItem({ item: quotationItem, prItem, index }) + setShowDetail(true) + }} + > + <Eye className="h-4 w-4" /> + </Button> + </TableCell> + </TableRow> + ) + })} + </TableBody> + </Table> + </div> + + {/* 총액 요약 */} + <div className="mt-4 flex justify-end"> + <Card className="w-[400px]"> + <CardContent className="pt-4"> + <div className="space-y-2"> + <div className="flex justify-between text-sm"> + <span className="text-muted-foreground">소계</span> + <span>{formatCurrency(totalAmount, currency)}</span> + </div> + <div className="flex justify-between text-sm"> + <span className="text-muted-foreground">통화</span> + <span>{currency}</span> + </div> + <div className="border-t pt-2"> + <div className="flex justify-between"> + <span className="font-semibold">총 견적금액</span> + <span className="text-xl font-bold text-primary"> + {formatCurrency(totalAmount, currency)} + </span> + </div> + </div> + </div> + </CardContent> + </Card> + </div> + </CardContent> + + {/* 상세 다이얼로그 */} + {selectedItem && ( + <ItemDetailDialog + item={selectedItem.item} + prItem={selectedItem.prItem} + index={selectedItem.index} + /> + )} + </Card> + ) +}
\ No newline at end of file |
