summaryrefslogtreecommitdiff
path: root/lib/b-rfq/initial
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-06-17 09:02:32 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-06-17 09:02:32 +0000
commit7a1524ba54f43d0f2a19e4bca2c6a2e0b01c5ef1 (patch)
treedaa214d404c7fc78b32419a028724e5671a6c7a4 /lib/b-rfq/initial
parentfa6a6093014c5d60188edfc9c4552e81c4b97bd1 (diff)
(대표님) 20250617 18시 작업사항
Diffstat (limited to 'lib/b-rfq/initial')
-rw-r--r--lib/b-rfq/initial/add-initial-rfq-dialog.tsx326
-rw-r--r--lib/b-rfq/initial/delete-initial-rfq-dialog.tsx149
-rw-r--r--lib/b-rfq/initial/initial-rfq-detail-columns.tsx358
-rw-r--r--lib/b-rfq/initial/initial-rfq-detail-table.tsx74
-rw-r--r--lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx301
-rw-r--r--lib/b-rfq/initial/update-initial-rfq-sheet.tsx496
6 files changed, 1287 insertions, 417 deletions
diff --git a/lib/b-rfq/initial/add-initial-rfq-dialog.tsx b/lib/b-rfq/initial/add-initial-rfq-dialog.tsx
index d0924be2..58a091ac 100644
--- a/lib/b-rfq/initial/add-initial-rfq-dialog.tsx
+++ b/lib/b-rfq/initial/add-initial-rfq-dialog.tsx
@@ -1,4 +1,3 @@
-// add-initial-rfq-dialog.tsx
"use client"
import * as React from "react"
@@ -45,6 +44,7 @@ import { Checkbox } from "@/components/ui/checkbox"
import { cn, formatDate } from "@/lib/utils"
import { addInitialRfqRecord, getIncotermsForSelection, getVendorsForSelection } from "../service"
import { Calendar } from "@/components/ui/calendar"
+import { InitialRfqDetailView } from "@/db/schema"
// Initial RFQ 추가 폼 스키마
const addInitialRfqSchema = z.object({
@@ -70,22 +70,30 @@ const addInitialRfqSchema = z.object({
returnRevision: z.number().default(0),
})
-type AddInitialRfqFormData = z.infer<typeof addInitialRfqSchema>
+export type AddInitialRfqFormData = z.infer<typeof addInitialRfqSchema>
interface Vendor {
id: number
vendorName: string
vendorCode: string
country: string
+ taxId: string
status: string
}
+interface Incoterm {
+ id: number
+ code: string
+ description: string
+}
+
interface AddInitialRfqDialogProps {
rfqId: number
onSuccess?: () => void
+ defaultValues?: InitialRfqDetailView // 선택된 항목의 기본값
}
-export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogProps) {
+export function AddInitialRfqDialog({ rfqId, onSuccess, defaultValues }: AddInitialRfqDialogProps) {
const [open, setOpen] = React.useState(false)
const [isSubmitting, setIsSubmitting] = React.useState(false)
const [vendors, setVendors] = React.useState<Vendor[]>([])
@@ -95,16 +103,38 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro
const [incotermsLoading, setIncotermsLoading] = React.useState(false)
const [incotermsSearchOpen, setIncotermsSearchOpen] = React.useState(false)
- const form = useForm<AddInitialRfqFormData>({
- resolver: zodResolver(addInitialRfqSchema),
- defaultValues: {
+ // 기본값 설정 (선택된 항목이 있으면 해당 값 사용, 없으면 일반 기본값)
+ const getDefaultFormValues = React.useCallback((): Partial<AddInitialRfqFormData> => {
+ if (defaultValues) {
+ return {
+ vendorId: defaultValues.vendorId,
+ initialRfqStatus: "DRAFT", // 새로 추가할 때는 항상 DRAFT로 시작
+ dueDate: defaultValues.dueDate || new Date(),
+ validDate: defaultValues.validDate,
+ incotermsCode: defaultValues.incotermsCode || "",
+ classification: defaultValues.classification || "",
+ sparepart: defaultValues.sparepart || "",
+ shortList: false, // 새로 추가할 때는 기본적으로 false
+ returnYn: false,
+ cpRequestYn: defaultValues.cpRequestYn || false,
+ prjectGtcYn: defaultValues.prjectGtcYn || false,
+ returnRevision: 0,
+ }
+ }
+
+ return {
initialRfqStatus: "DRAFT",
shortList: false,
returnYn: false,
cpRequestYn: false,
prjectGtcYn: false,
returnRevision: 0,
- },
+ }
+ }, [defaultValues])
+
+ const form = useForm<AddInitialRfqFormData>({
+ resolver: zodResolver(addInitialRfqSchema),
+ defaultValues: getDefaultFormValues(),
})
// 벤더 목록 로드
@@ -121,23 +151,27 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro
}
}, [])
- // Incoterms 목록 로드
- const loadIncoterms = React.useCallback(async () => {
- setIncotermsLoading(true)
- try {
- const incotermsList = await getIncotermsForSelection()
- setIncoterms(incotermsList)
- } catch (error) {
- console.error("Failed to load incoterms:", error)
- toast.error("Incoterms 목록을 불러오는데 실패했습니다.")
- } finally {
- setIncotermsLoading(false)
- }
- }, [])
+ // Incoterms 목록 로드
+ const loadIncoterms = React.useCallback(async () => {
+ setIncotermsLoading(true)
+ try {
+ const incotermsList = await getIncotermsForSelection()
+ setIncoterms(incotermsList)
+ } catch (error) {
+ console.error("Failed to load incoterms:", error)
+ toast.error("Incoterms 목록을 불러오는데 실패했습니다.")
+ } finally {
+ setIncotermsLoading(false)
+ }
+ }, [])
- // 다이얼로그 열릴 때 벤더 목록 로드
+ // 다이얼로그 열릴 때 실행
React.useEffect(() => {
if (open) {
+ // 폼을 기본값으로 리셋
+ form.reset(getDefaultFormValues())
+
+ // 데이터 로드
if (vendors.length === 0) {
loadVendors()
}
@@ -145,12 +179,12 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro
loadIncoterms()
}
}
- }, [open, vendors.length, incoterms.length, loadVendors, loadIncoterms])
+ }, [open, vendors.length, incoterms.length, loadVendors, loadIncoterms, form, getDefaultFormValues])
// 다이얼로그 닫기 핸들러
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen && !isSubmitting) {
- form.reset()
+ form.reset(getDefaultFormValues())
}
setOpen(newOpen)
}
@@ -167,7 +201,7 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro
if (result.success) {
toast.success(result.message || "초기 RFQ가 성공적으로 추가되었습니다.")
- form.reset()
+ form.reset(getDefaultFormValues())
handleOpenChange(false)
onSuccess?.()
} else {
@@ -186,20 +220,32 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro
const selectedVendor = vendors.find(vendor => vendor.id === form.watch("vendorId"))
const selectedIncoterm = incoterms.find(incoterm => incoterm.code === form.watch("incotermsCode"))
+ // 기본값이 있을 때 버튼 텍스트 변경
+ const buttonText = defaultValues ? "유사 항목 추가" : "초기 RFQ 추가"
+ const dialogTitle = defaultValues ? "유사 초기 RFQ 추가" : "초기 RFQ 추가"
+ const dialogDescription = defaultValues
+ ? "선택된 항목을 기본값으로 하여 새로운 초기 RFQ를 추가합니다."
+ : "새로운 벤더를 대상으로 하는 초기 RFQ를 추가합니다."
+
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Plus className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">초기 RFQ 추가</span>
+ <span className="hidden sm:inline">{buttonText}</span>
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[600px] max-h-[80vh] overflow-y-auto">
<DialogHeader>
- <DialogTitle>초기 RFQ 추가</DialogTitle>
+ <DialogTitle>{dialogTitle}</DialogTitle>
<DialogDescription>
- 새로운 벤더를 대상으로 하는 초기 RFQ를 추가합니다.
+ {dialogDescription}
+ {defaultValues && (
+ <div className="mt-2 p-2 bg-muted rounded-md text-sm">
+ <strong>기본값 출처:</strong> {defaultValues.vendorName} ({defaultValues.vendorCode})
+ </div>
+ )}
</DialogDescription>
</DialogHeader>
@@ -263,7 +309,7 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro
{vendor.vendorName}
</div>
<div className="text-sm text-muted-foreground">
- {vendor.vendorCode} • {vendor.country}
+ {vendor.vendorCode} • {vendor.country} • {vendor.taxId}
</div>
</div>
<Check
@@ -287,98 +333,98 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro
{/* 날짜 필드들 */}
<div className="grid grid-cols-2 gap-4">
- <FormField
- control={form.control}
- name="dueDate"
- render={({ field }) => (
- <FormItem className="flex flex-col">
- <FormLabel>견적 마감일</FormLabel>
- <Popover>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant="outline"
- className={cn(
- "w-full pl-3 text-left font-normal",
- !field.value && "text-muted-foreground"
- )}
- >
- {field.value ? (
- formatDate(field.value,"KR")
- ) : (
- <span>견적 유효일을 선택하세요</span>
- )}
- <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
- </Button>
- </FormControl>
- </PopoverTrigger>
- <PopoverContent className="w-auto p-0" align="start">
- <Calendar
- mode="single"
- selected={field.value}
- onSelect={field.onChange}
- disabled={(date) =>
- date < new Date() || date < new Date("1900-01-01")
- }
- initialFocus
- />
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- )}
- />
- <FormField
- control={form.control}
- name="validDate"
- render={({ field }) => (
- <FormItem className="flex flex-col">
- <FormLabel>견적 유효일</FormLabel>
- <Popover>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant="outline"
- className={cn(
- "w-full pl-3 text-left font-normal",
- !field.value && "text-muted-foreground"
- )}
- >
- {field.value ? (
- formatDate(field.value,"KR")
- ) : (
- <span>견적 유효일을 선택하세요</span>
- )}
- <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
- </Button>
- </FormControl>
- </PopoverTrigger>
- <PopoverContent className="w-auto p-0" align="start">
- <Calendar
- mode="single"
- selected={field.value}
- onSelect={field.onChange}
- disabled={(date) =>
- date < new Date() || date < new Date("1900-01-01")
- }
- initialFocus
- />
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- )}
- />
+ <FormField
+ control={form.control}
+ name="dueDate"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>견적 마감일 *</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={cn(
+ "w-full pl-3 text-left font-normal",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value ? (
+ formatDate(field.value, "KR")
+ ) : (
+ <span>견적 마감일을 선택하세요</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ disabled={(date) =>
+ date < new Date() || date < new Date("1900-01-01")
+ }
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="validDate"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>견적 유효일</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={cn(
+ "w-full pl-3 text-left font-normal",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value ? (
+ formatDate(field.value, "KR")
+ ) : (
+ <span>견적 유효일을 선택하세요</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ disabled={(date) =>
+ date < new Date() || date < new Date("1900-01-01")
+ }
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
</div>
- {/* Incoterms 및 GTC */}
- <div className="grid grid-cols-2 gap-4">
+ {/* Incoterms 선택 */}
<FormField
control={form.control}
- name="vendorId"
+ name="incotermsCode"
render={({ field }) => (
<FormItem className="flex flex-col">
- <FormLabel>Incoterms *</FormLabel>
+ <FormLabel>Incoterms</FormLabel>
<Popover open={incotermsSearchOpen} onOpenChange={setIncotermsSearchOpen}>
<PopoverTrigger asChild>
<FormControl>
@@ -391,9 +437,8 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro
>
{selectedIncoterm ? (
<div className="flex items-center gap-2">
- <Building className="h-4 w-4" />
<span className="truncate">
- {selectedIncoterm.code} ({selectedIncoterm.description})
+ {selectedIncoterm.code} - {selectedIncoterm.description}
</span>
</div>
) : (
@@ -419,18 +464,20 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro
key={incoterm.id}
value={`${incoterm.code} ${incoterm.description}`}
onSelect={() => {
- field.onChange(vendor.id)
- setVendorSearchOpen(false)
+ field.onChange(incoterm.code)
+ setIncotermsSearchOpen(false)
}}
>
<div className="flex items-center gap-2 w-full">
+ <div className="flex-1 min-w-0">
<div className="font-medium truncate">
- {incoterm.code} {incoterm.description}
+ {incoterm.code} - {incoterm.description}
</div>
+ </div>
<Check
className={cn(
"ml-auto h-4 w-4",
- incoterm.id === field.value ? "opacity-100" : "opacity-0"
+ incoterm.code === field.value ? "opacity-100" : "opacity-0"
)}
/>
</div>
@@ -445,34 +492,41 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro
</FormItem>
)}
/>
- </div>
- {/* GTC 정보 */}
+ {/* 옵션 체크박스 */}
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
- name="gtc"
+ name="cpRequestYn"
render={({ field }) => (
- <FormItem>
- <FormLabel>GTC</FormLabel>
+ <FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
- <Input placeholder="GTC 정보" {...field} />
+ <Checkbox
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ />
</FormControl>
- <FormMessage />
+ <div className="space-y-1 leading-none ml-2">
+ <FormLabel>CP 요청</FormLabel>
+ </div>
</FormItem>
)}
/>
<FormField
control={form.control}
- name="gtcValidDate"
+ name="prjectGtcYn"
render={({ field }) => (
- <FormItem>
- <FormLabel>GTC 유효일</FormLabel>
+ <FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
- <Input placeholder="GTC 유효일" {...field} />
+ <Checkbox
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ />
</FormControl>
- <FormMessage />
+ <div className="space-y-1 leading-none ml-2">
+ <FormLabel>Project용 GTC 사용</FormLabel>
+ </div>
</FormItem>
)}
/>
@@ -501,7 +555,7 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro
<FormItem>
<FormLabel>Spare part</FormLabel>
<FormControl>
- <Input placeholder="예비부품 정보" {...field} />
+ <Input placeholder="O1, O2" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -509,8 +563,6 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro
/>
</div>
-
-
<DialogFooter>
<Button
type="button"
@@ -529,6 +581,4 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro
</DialogContent>
</Dialog>
)
-}
-
-
+} \ No newline at end of file
diff --git a/lib/b-rfq/initial/delete-initial-rfq-dialog.tsx b/lib/b-rfq/initial/delete-initial-rfq-dialog.tsx
new file mode 100644
index 00000000..b5a231b7
--- /dev/null
+++ b/lib/b-rfq/initial/delete-initial-rfq-dialog.tsx
@@ -0,0 +1,149 @@
+"use client"
+
+import * as React from "react"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Trash } from "lucide-react"
+import { toast } from "sonner"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+
+import { InitialRfqDetailView } from "@/db/schema"
+import { removeInitialRfqs } from "../service"
+
+interface DeleteInitialRfqDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ initialRfqs: Row<InitialRfqDetailView>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeleteInitialRfqDialog({
+ initialRfqs,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeleteInitialRfqDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onDelete() {
+ startDeleteTransition(async () => {
+ const { error } = await removeInitialRfqs({
+ ids: initialRfqs.map((rfq) => rfq.id),
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("초기 RFQ가 삭제되었습니다")
+ onSuccess?.()
+ })
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제 ({initialRfqs.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle>
+ <DialogDescription>
+ 이 작업은 되돌릴 수 없습니다. 선택한{" "}
+ <span className="font-medium">{initialRfqs.length}개</span>의
+ 초기 RFQ{initialRfqs.length === 1 ? "를" : "들을"} 영구적으로 삭제합니다.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">취소</Button>
+ </DialogClose>
+ <Button
+ aria-label="Delete selected rows"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 삭제
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제 ({initialRfqs.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle>
+ <DrawerDescription>
+ 이 작업은 되돌릴 수 없습니다. 선택한{" "}
+ <span className="font-medium">{initialRfqs.length}개</span>의
+ 초기 RFQ{initialRfqs.length === 1 ? "를" : "들을"} 영구적으로 삭제합니다.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">취소</Button>
+ </DrawerClose>
+ <Button
+ aria-label="Delete selected rows"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ 삭제
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ No newline at end of file
diff --git a/lib/b-rfq/initial/initial-rfq-detail-columns.tsx b/lib/b-rfq/initial/initial-rfq-detail-columns.tsx
index f7ac0960..02dfd765 100644
--- a/lib/b-rfq/initial/initial-rfq-detail-columns.tsx
+++ b/lib/b-rfq/initial/initial-rfq-detail-columns.tsx
@@ -3,8 +3,9 @@
import * as React from "react"
import { type ColumnDef } from "@tanstack/react-table"
+import { type Row } from "@tanstack/react-table"
import {
- Ellipsis, Building, Calendar, Eye,
+ Ellipsis, Building, Eye, Edit, Trash,
MessageSquare, Settings, CheckCircle2, XCircle
} from "lucide-react"
@@ -14,17 +15,27 @@ import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
- DropdownMenuSeparator, DropdownMenuTrigger
+ DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuShortcut
} from "@/components/ui/dropdown-menu"
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { InitialRfqDetailView } from "@/db/schema"
+
+
+// RowAction 타입 정의
+export interface DataTableRowAction<TData> {
+ row: Row<TData>
+ type: "update" | "delete"
+}
interface GetInitialRfqDetailColumnsProps {
onSelectDetail?: (detail: any) => void
+ setRowAction?: React.Dispatch<React.SetStateAction<DataTableRowAction<InitialRfqDetailView> | null>>
}
export function getInitialRfqDetailColumns({
- onSelectDetail
-}: GetInitialRfqDetailColumnsProps = {}): ColumnDef<any>[] {
+ onSelectDetail,
+ setRowAction
+}: GetInitialRfqDetailColumnsProps = {}): ColumnDef<InitialRfqDetailView>[] {
return [
/** ───────────── 체크박스 ───────────── */
@@ -56,53 +67,6 @@ export function getInitialRfqDetailColumns({
/** ───────────── RFQ 정보 ───────────── */
{
- accessorKey: "rfqCode",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ 코드" />
- ),
- cell: ({ row }) => (
- <Button
- variant="link"
- className="p-0 h-auto font-medium text-blue-600 hover:text-blue-800"
- onClick={() => onSelectDetail?.(row.original)}
- >
- {row.getValue("rfqCode") as string}
- </Button>
- ),
- size: 120,
- },
- {
- accessorKey: "rfqStatus",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ 상태" />
- ),
- cell: ({ row }) => {
- const status = row.getValue("rfqStatus") as string
- const getStatusColor = (status: string) => {
- switch (status) {
- case "DRAFT": return "secondary"
- case "Doc. Received": return "outline"
- case "PIC Assigned": return "default"
- case "Doc. Confirmed": return "default"
- case "Init. RFQ Sent": return "default"
- case "Init. RFQ Answered": return "success"
- case "TBE started": return "warning"
- case "TBE finished": return "warning"
- case "Final RFQ Sent": return "default"
- case "Quotation Received": return "success"
- case "Vendor Selected": return "success"
- default: return "secondary"
- }
- }
- return (
- <Badge variant={getStatusColor(status) as any}>
- {status}
- </Badge>
- )
- },
- size: 140
- },
- {
accessorKey: "initialRfqStatus",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="초기 RFQ 상태" />
@@ -111,11 +75,10 @@ export function getInitialRfqDetailColumns({
const status = row.getValue("initialRfqStatus") as string
const getInitialStatusColor = (status: string) => {
switch (status) {
- case "PENDING": return "outline"
- case "SENT": return "default"
- case "RESPONDED": return "success"
- case "EXPIRED": return "destructive"
- case "CANCELLED": return "secondary"
+ case "DRAFT": return "outline"
+ case "Init. RFQ Sent": return "default"
+ case "Init. RFQ Answered": return "success"
+ case "S/L Decline": return "destructive"
default: return "secondary"
}
}
@@ -127,6 +90,30 @@ export function getInitialRfqDetailColumns({
},
size: 120
},
+ {
+ accessorKey: "rfqCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ 코드" />
+ ),
+ cell: ({ row }) => (
+ <div className="text-sm">
+ {row.getValue("rfqCode") as string}
+ </div>
+ ),
+ size: 120,
+ },
+ {
+ accessorKey: "rfqRevision",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ 리비전" />
+ ),
+ cell: ({ row }) => (
+ <div className="text-sm">
+ Rev. {row.getValue("rfqRevision") as number}
+ </div>
+ ),
+ size: 120,
+ },
/** ───────────── 벤더 정보 ───────────── */
{
@@ -137,7 +124,8 @@ export function getInitialRfqDetailColumns({
cell: ({ row }) => {
const vendorName = row.original.vendorName as string
const vendorCode = row.original.vendorCode as string
- const vendorCountry = row.original.vendorCountry as string
+ const vendorType = row.original.vendorCategory as string
+ const vendorCountry = row.original.vendorCountry === "KR" ? "D":"F"
const businessSize = row.original.vendorBusinessSize as string
return (
@@ -147,7 +135,7 @@ export function getInitialRfqDetailColumns({
<div className="font-medium">{vendorName}</div>
</div>
<div className="text-sm text-muted-foreground">
- {vendorCode} • {vendorCountry}
+ {vendorCode} • {vendorType} • {vendorCountry}
</div>
{businessSize && (
<Badge variant="outline" className="text-xs">
@@ -160,42 +148,67 @@ export function getInitialRfqDetailColumns({
size: 200,
},
- /** ───────────── 날짜 정보 ───────────── */
{
- accessorKey: "dueDate",
+ accessorKey: "cpRequestYn",
header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="마감일" />
+ <DataTableColumnHeaderSimple column={column} title="CP" />
),
cell: ({ row }) => {
- const dueDate = row.getValue("dueDate") as Date
- const isOverdue = dueDate && new Date(dueDate) < new Date()
-
- return dueDate ? (
- <div className={`flex items-center gap-2 ${isOverdue ? 'text-red-600' : ''}`}>
- <Calendar className="h-4 w-4" />
- <div>
- <div className="font-medium">{formatDate(dueDate)}</div>
- {isOverdue && (
- <div className="text-xs text-red-600">지연</div>
- )}
- </div>
- </div>
+ const cpRequest = row.getValue("cpRequestYn") as boolean
+ return cpRequest ? (
+ <Badge variant="outline" className="text-xs">
+ Yes
+ </Badge>
) : (
- <span className="text-muted-foreground">-</span>
+ <span className="text-muted-foreground text-xs">-</span>
)
},
- size: 120,
+ size: 60,
},
{
- accessorKey: "validDate",
+ accessorKey: "prjectGtcYn",
header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="유효일" />
+ <DataTableColumnHeaderSimple column={column} title="Project GTC" />
),
cell: ({ row }) => {
- const validDate = row.getValue("validDate") as Date
- return validDate ? (
+ const projectGtc = row.getValue("prjectGtcYn") as boolean
+ return projectGtc ? (
+ <Badge variant="outline" className="text-xs">
+ Yes
+ </Badge>
+ ) : (
+ <span className="text-muted-foreground text-xs">-</span>
+ )
+ },
+ size: 100,
+ },
+ {
+ accessorKey: "gtcYn",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="GTC" />
+ ),
+ cell: ({ row }) => {
+ const gtc = row.getValue("gtcYn") as boolean
+ return gtc ? (
+ <Badge variant="outline" className="text-xs">
+ Yes
+ </Badge>
+ ) : (
+ <span className="text-muted-foreground text-xs">-</span>
+ )
+ },
+ size: 60,
+ },
+ {
+ accessorKey: "gtcValidDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="GTC 유효일" />
+ ),
+ cell: ({ row }) => {
+ const gtcValidDate = row.getValue("gtcValidDate") as string
+ return gtcValidDate ? (
<div className="text-sm">
- {formatDate(validDate)}
+ {gtcValidDate}
</div>
) : (
<span className="text-muted-foreground">-</span>
@@ -204,7 +217,42 @@ export function getInitialRfqDetailColumns({
size: 100,
},
- /** ───────────── Incoterms ───────────── */
+ {
+ accessorKey: "classification",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="선급" />
+ ),
+ cell: ({ row }) => {
+ const classification = row.getValue("classification") as string
+ return classification ? (
+ <div className="text-sm font-medium max-w-[120px] truncate" title={classification}>
+ {classification}
+ </div>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ size: 120,
+ },
+
+ {
+ accessorKey: "sparepart",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Spare Part" />
+ ),
+ cell: ({ row }) => {
+ const sparepart = row.getValue("sparepart") as string
+ return sparepart ? (
+ <Badge variant="outline" className="text-xs">
+ {sparepart}
+ </Badge>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ size: 100,
+ },
+
{
id: "incoterms",
header: ({ column }) => (
@@ -230,84 +278,71 @@ export function getInitialRfqDetailColumns({
size: 120,
},
- /** ───────────── 플래그 정보 ───────────── */
+ /** ───────────── 날짜 정보 ───────────── */
{
- id: "flags",
+ accessorKey: "validDate",
header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="플래그" />
+ <DataTableColumnHeaderSimple column={column} title="유효일" />
),
cell: ({ row }) => {
- const shortList = row.original.shortList as boolean
- const returnYn = row.original.returnYn as boolean
- const cpRequestYn = row.original.cpRequestYn as boolean
- const prjectGtcYn = row.original.prjectGtcYn as boolean
-
- return (
- <div className="flex flex-wrap gap-1">
- {shortList && (
- <Badge variant="secondary" className="text-xs">
- <CheckCircle2 className="h-3 w-3 mr-1" />
- Short List
- </Badge>
- )}
- {returnYn && (
- <Badge variant="outline" className="text-xs">
- Return
- </Badge>
- )}
- {cpRequestYn && (
- <Badge variant="outline" className="text-xs">
- CP Request
- </Badge>
- )}
- {prjectGtcYn && (
- <Badge variant="outline" className="text-xs">
- GTC
- </Badge>
- )}
+ const validDate = row.getValue("validDate") as Date
+ return validDate ? (
+ <div className="text-sm">
+ {formatDate(validDate)}
</div>
+ ) : (
+ <span className="text-muted-foreground">-</span>
)
},
- size: 150,
+ size: 100,
},
-
- /** ───────────── 분류 정보 ───────────── */
{
- id: "classification",
+ accessorKey: "dueDate",
header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="분류" />
+ <DataTableColumnHeaderSimple column={column} title="마감일" />
),
cell: ({ row }) => {
- const classification = row.original.classification as string
- const sparepart = row.original.sparepart as string
+ const dueDate = row.getValue("dueDate") as Date
+ const isOverdue = dueDate && new Date(dueDate) < new Date()
- return (
- <div className="space-y-1">
- {classification && (
- <div className="text-sm font-medium max-w-[120px] truncate" title={classification}>
- {classification}
- </div>
- )}
- {sparepart && (
- <Badge variant="outline" className="text-xs">
- {sparepart}
- </Badge>
+ return dueDate ? (
+ <div className={`${isOverdue ? 'text-red-600' : ''}`}>
+ <div className="font-medium">{formatDate(dueDate)}</div>
+ {isOverdue && (
+ <div className="text-xs text-red-600">지연</div>
)}
</div>
+ ) : (
+ <span className="text-muted-foreground">-</span>
)
},
size: 120,
},
-
- /** ───────────── 리비전 정보 ───────────── */
+ {
+ accessorKey: "returnYn",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ 회신여부" />
+ ),
+ cell: ({ row }) => {
+ const returnFlag = row.getValue("returnYn") as boolean
+ return returnFlag ? (
+ <Badge variant="outline" className="text-xs">
+ Yes
+ </Badge>
+ ) : (
+ <span className="text-muted-foreground text-xs">-</span>
+ )
+ },
+ size: 70,
+ },
{
accessorKey: "returnRevision",
header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="리비전" />
+ <DataTableColumnHeaderSimple column={column} title="회신 리비전" />
),
cell: ({ row }) => {
const revision = row.getValue("returnRevision") as number
- return revision ? (
+ return revision > 0 ? (
<Badge variant="outline">
Rev. {revision}
</Badge>
@@ -318,6 +353,25 @@ export function getInitialRfqDetailColumns({
size: 80,
},
+ {
+ accessorKey: "shortList",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Short List" />
+ ),
+ cell: ({ row }) => {
+ const shortList = row.getValue("shortList") as boolean
+ return shortList ? (
+ <Badge variant="secondary" className="text-xs">
+ <CheckCircle2 className="h-3 w-3 mr-1" />
+ Yes
+ </Badge>
+ ) : (
+ <span className="text-muted-foreground text-xs">-</span>
+ )
+ },
+ size: 90,
+ },
+
/** ───────────── 등록/수정 정보 ───────────── */
{
accessorKey: "createdAt",
@@ -333,7 +387,7 @@ export function getInitialRfqDetailColumns({
<div className="text-sm">{formatDate(created)}</div>
{updated && new Date(updated) > new Date(created) && (
<div className="text-xs text-blue-600">
- 수정: {formatDate(updated)}
+ 수정: {formatDate(updated, "KR")}
</div>
)}
</div>
@@ -346,7 +400,7 @@ export function getInitialRfqDetailColumns({
{
id: "actions",
enableHiding: false,
- cell: ({ row }) => {
+ cell: function Cell({ row }) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -359,23 +413,29 @@ export function getInitialRfqDetailColumns({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
- <DropdownMenuItem onClick={() => onSelectDetail?.(row.original)}>
- <Eye className="mr-2 h-4 w-4" />
- 상세 보기
- </DropdownMenuItem>
<DropdownMenuItem>
<MessageSquare className="mr-2 h-4 w-4" />
벤더 응답 보기
</DropdownMenuItem>
<DropdownMenuSeparator />
- <DropdownMenuItem>
- <Settings className="mr-2 h-4 w-4" />
- 설정 수정
- </DropdownMenuItem>
- <DropdownMenuItem className="text-red-600">
- <XCircle className="mr-2 h-4 w-4" />
- 삭제
- </DropdownMenuItem>
+ {setRowAction && (
+ <>
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "update" })}
+ >
+ <Edit className="mr-2 h-4 w-4" />
+ 수정
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "delete" })}
+ >
+ <Trash className="mr-2 h-4 w-4" />
+ 삭제
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ </>
+ )}
+
</DropdownMenuContent>
</DropdownMenu>
)
diff --git a/lib/b-rfq/initial/initial-rfq-detail-table.tsx b/lib/b-rfq/initial/initial-rfq-detail-table.tsx
index fc8a5bc2..5ea6b0bf 100644
--- a/lib/b-rfq/initial/initial-rfq-detail-table.tsx
+++ b/lib/b-rfq/initial/initial-rfq-detail-table.tsx
@@ -6,8 +6,14 @@ import { useDataTable } from "@/hooks/use-data-table"
import { DataTable } from "@/components/data-table/data-table"
import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
import { getInitialRfqDetail } from "../service" // 앞서 만든 서버 액션
-import { getInitialRfqDetailColumns } from "./initial-rfq-detail-columns"
+import {
+ getInitialRfqDetailColumns,
+ type DataTableRowAction
+} from "./initial-rfq-detail-columns"
import { InitialRfqDetailTableToolbarActions } from "./initial-rfq-detail-toolbar-actions"
+import { DeleteInitialRfqDialog } from "./delete-initial-rfq-dialog"
+import { UpdateInitialRfqSheet } from "./update-initial-rfq-sheet"
+import { InitialRfqDetailView } from "@/db/schema"
interface InitialRfqDetailTableProps {
promises: Promise<Awaited<ReturnType<typeof getInitialRfqDetail>>>
@@ -19,10 +25,14 @@ export function InitialRfqDetailTable({ promises, rfqId }: InitialRfqDetailTable
// 선택된 상세 정보
const [selectedDetail, setSelectedDetail] = React.useState<any>(null)
+
+ // Row action 상태 (update/delete)
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<InitialRfqDetailView> | null>(null)
const columns = React.useMemo(
() => getInitialRfqDetailColumns({
- onSelectDetail: setSelectedDetail
+ onSelectDetail: setSelectedDetail,
+ setRowAction: setRowAction
}),
[]
)
@@ -62,11 +72,10 @@ export function InitialRfqDetailTable({ promises, rfqId }: InitialRfqDetailTable
id: "initialRfqStatus",
label: "초기 RFQ 상태",
options: [
- { label: "대기", value: "PENDING", count: 0 },
- { label: "발송", value: "SENT", count: 0 },
- { label: "응답", value: "RESPONDED", count: 0 },
- { label: "만료", value: "EXPIRED", count: 0 },
- { label: "취소", value: "CANCELLED", count: 0 },
+ { label: "초안", value: "DRAFT", count: 0 },
+ { label: "발송", value: "Init. RFQ Sent", count: 0 },
+ { label: "응답", value: "Init. RFQ Answered", count: 0 },
+ { label: "거절", value: "S/L Decline", count: 0 },
],
},
{
@@ -136,11 +145,10 @@ export function InitialRfqDetailTable({ promises, rfqId }: InitialRfqDetailTable
label: "초기 RFQ 상태",
type: "multi-select",
options: [
- { label: "대기", value: "PENDING" },
- { label: "발송", value: "SENT" },
- { label: "응답", value: "RESPONDED" },
- { label: "만료", value: "EXPIRED" },
- { label: "취소", value: "CANCELLED" },
+ { label: "초안", value: "DRAFT" },
+ { label: "발송", value: "Init. RFQ Sent" },
+ { label: "응답", value: "Init. RFQ Answered" },
+ { label: "거절", value: "S/L Decline" },
],
},
{
@@ -216,7 +224,7 @@ export function InitialRfqDetailTable({ promises, rfqId }: InitialRfqDetailTable
sorting: [{ id: "createdAt", desc: true }],
columnPinning: { right: ["actions"] },
},
- getRowId: (originalRow) => originalRow.initialRfqId.toString(),
+ getRowId: (originalRow) => originalRow.initialRfqId ? originalRow.initialRfqId.toString():"1",
shallow: false,
clearOnDefault: true,
})
@@ -236,28 +244,24 @@ export function InitialRfqDetailTable({ promises, rfqId }: InitialRfqDetailTable
</DataTable>
</div>
- {/* 선택된 상세 정보 패널 (필요시 추가) */}
- {selectedDetail && (
- <div className="border rounded-lg p-4">
- <h3 className="text-lg font-semibold mb-2">
- 상세 정보: {selectedDetail.rfqCode}
- </h3>
- <div className="grid grid-cols-2 gap-4 text-sm">
- <div>
- <strong>벤더:</strong> {selectedDetail.vendorName}
- </div>
- <div>
- <strong>국가:</strong> {selectedDetail.vendorCountry}
- </div>
- <div>
- <strong>마감일:</strong> {formatDate(selectedDetail.dueDate)}
- </div>
- <div>
- <strong>유효일:</strong> {formatDate(selectedDetail.validDate)}
- </div>
- </div>
- </div>
- )}
+ {/* Update Sheet */}
+ <UpdateInitialRfqSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ initialRfq={rowAction?.type === "update" ? rowAction.row.original : null}
+ />
+
+ {/* Delete Dialog */}
+ <DeleteInitialRfqDialog
+ open={rowAction?.type === "delete"}
+ onOpenChange={() => setRowAction(null)}
+ initialRfqs={rowAction?.type === "delete" ? [rowAction.row.original] : []}
+ showTrigger={false}
+ onSuccess={() => {
+ setRowAction(null)
+ // 테이블 리프레시는 revalidatePath로 자동 처리됨
+ }}
+ />
</div>
)
} \ No newline at end of file
diff --git a/lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx b/lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx
index 981659d5..639d338d 100644
--- a/lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx
+++ b/lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx
@@ -1,109 +1,220 @@
-// initial-rfq-detail-toolbar-actions.tsx
"use client"
import * as React from "react"
import { type Table } from "@tanstack/react-table"
+import { useRouter } from "next/navigation"
+import { toast } from "sonner"
import { Button } from "@/components/ui/button"
-import {
- Download,
- Mail,
- RefreshCw,
- Settings,
- Trash2,
- FileText
+import {
+ Download,
+ Mail,
+ RefreshCw,
+ Settings,
+ Trash2,
+ FileText,
+ CheckCircle2,
+ Loader
} from "lucide-react"
+import { AddInitialRfqDialog } from "./add-initial-rfq-dialog"
+import { DeleteInitialRfqDialog } from "./delete-initial-rfq-dialog"
+import { InitialRfqDetailView } from "@/db/schema"
+import { sendBulkInitialRfqEmails } from "../service"
interface InitialRfqDetailTableToolbarActionsProps {
- table: Table<any>
- rfqId?: number
+ table: Table<InitialRfqDetailView>
+ rfqId?: number
+ onRefresh?: () => void // 데이터 새로고침 콜백
}
export function InitialRfqDetailTableToolbarActions({
- table,
- rfqId
+ table,
+ rfqId,
+ onRefresh
}: InitialRfqDetailTableToolbarActionsProps) {
-
- // 선택된 행들 가져오기
- const selectedRows = table.getFilteredSelectedRowModel().rows
- const selectedDetails = selectedRows.map((row) => row.original)
- const selectedCount = selectedRows.length
-
- const handleBulkEmail = () => {
- console.log("Bulk email to selected vendors:", selectedDetails)
- // 벌크 이메일 로직 구현
- }
-
- const handleBulkDelete = () => {
- console.log("Bulk delete selected items:", selectedDetails)
- // 벌크 삭제 로직 구현
- table.toggleAllRowsSelected(false)
- }
-
- const handleExport = () => {
- console.log("Export data:", selectedCount > 0 ? selectedDetails : "all data")
- // 데이터 엑스포트 로직 구현
- }
-
- const handleRefresh = () => {
- window.location.reload()
- }
-
- return (
- <div className="flex items-center gap-2">
- {/** 선택된 항목이 있을 때만 표시되는 액션들 */}
- {selectedCount > 0 && (
+ const router = useRouter()
+
+ // 선택된 행들 가져오기
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ const selectedDetails = selectedRows.map((row) => row.original)
+ const selectedCount = selectedRows.length
+
+ // 상태 관리
+ const [showDeleteDialog, setShowDeleteDialog] = React.useState(false)
+ const [isEmailSending, setIsEmailSending] = React.useState(false)
+
+ const handleBulkEmail = async () => {
+ if (selectedCount === 0) return
+
+ setIsEmailSending(true)
+
+ try {
+ const initialRfqIds = selectedDetails.map(detail => detail.initialRfqId);
+
+ const result = await sendBulkInitialRfqEmails({
+ initialRfqIds,
+ language: "en" // 기본 영어, 필요시 사용자 설정으로 변경
+ })
+
+ if (result.success) {
+ toast.success(result.message)
+
+ // 에러가 있다면 별도 알림
+ if (result.errors && result.errors.length > 0) {
+ setTimeout(() => {
+ toast.warning(`일부 오류 발생: ${result.errors?.join(', ')}`)
+ }, 1000)
+ }
+
+ // 선택 해제
+ table.toggleAllRowsSelected(false)
+
+ // 데이터 새로고침
+ if (onRefresh) {
+ onRefresh()
+ }
+ } else {
+ toast.error(result.message || "RFQ 발송에 실패했습니다.")
+ }
+
+ } catch (error) {
+ console.error("Email sending error:", error)
+ toast.error("RFQ 발송 중 오류가 발생했습니다.")
+ } finally {
+ setIsEmailSending(false)
+ }
+ }
+
+ const handleBulkDelete = () => {
+ // DRAFT가 아닌 상태의 RFQ 확인
+ const nonDraftRfqs = selectedDetails.filter(
+ detail => detail.initialRfqStatus !== "DRAFT"
+ )
+
+ if (nonDraftRfqs.length > 0) {
+ const statusMessages = {
+ "Init. RFQ Sent": "이미 발송된",
+ "S/L Decline": "Short List 거절 처리된",
+ "Init. RFQ Answered": "답변 완료된"
+ }
+
+ const nonDraftStatuses = [...new Set(nonDraftRfqs.map(rfq => rfq.initialRfqStatus))]
+ const statusText = nonDraftStatuses
+ .map(status => statusMessages[status as keyof typeof statusMessages] || status)
+ .join(", ")
+
+ toast.error(
+ `${statusText} RFQ는 삭제할 수 없습니다. DRAFT 상태의 RFQ만 삭제 가능합니다.`
+ )
+ return
+ }
+
+ setShowDeleteDialog(true)
+ }
+
+ // S/L 확정 버튼 클릭
+ const handleSlConfirm = () => {
+ if (rfqId) {
+ router.push(`/evcp/b-rfq/${rfqId}`)
+ }
+ }
+
+ // 초기 RFQ 추가 성공 시 처리
+ const handleAddSuccess = () => {
+ // 선택 해제
+ table.toggleAllRowsSelected(false)
+
+ // 데이터 새로고침
+ if (onRefresh) {
+ onRefresh()
+ } else {
+ // fallback으로 페이지 새로고침
+ setTimeout(() => {
+ window.location.reload()
+ }, 1000)
+ }
+ }
+
+ // 삭제 성공 시 처리
+ const handleDeleteSuccess = () => {
+ // 선택 해제
+ table.toggleAllRowsSelected(false)
+ setShowDeleteDialog(false)
+
+ // 데이터 새로고침
+ if (onRefresh) {
+ onRefresh()
+ }
+ }
+
+ // 선택된 항목 중 첫 번째를 기본값으로 사용
+ const defaultValues = selectedCount > 0 ? selectedDetails[0] : undefined
+
+ const canDelete = selectedDetails.every(detail => detail.initialRfqStatus === "DRAFT")
+ const draftCount = selectedDetails.filter(detail => detail.initialRfqStatus === "DRAFT").length
+
+
+ return (
<>
- <Button
- variant="outline"
- size="sm"
- onClick={handleBulkEmail}
- className="h-8"
- >
- <Mail className="mr-2 h-4 w-4" />
- 이메일 발송 ({selectedCount})
- </Button>
-
- <Button
- variant="outline"
- size="sm"
- onClick={handleBulkDelete}
- className="h-8 text-red-600 hover:text-red-700"
- >
- <Trash2 className="mr-2 h-4 w-4" />
- 삭제 ({selectedCount})
- </Button>
+ <div className="flex items-center gap-2">
+ {/** 선택된 항목이 있을 때만 표시되는 액션들 */}
+ {selectedCount > 0 && (
+ <>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleBulkEmail}
+ className="h-8"
+ disabled={isEmailSending}
+ >
+ {isEmailSending ? <Loader className="mr-2 h-4 w-4 animate-spin" /> : <Mail className="mr-2 h-4 w-4" />}
+ RFQ 발송 ({selectedCount})
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleBulkDelete}
+ className="h-8 text-red-600 hover:text-red-700"
+ disabled={!canDelete || selectedCount === 0}
+ title={!canDelete ? "DRAFT 상태의 RFQ만 삭제할 수 있습니다" : ""}
+ >
+ <Trash2 className="mr-2 h-4 w-4" />
+ 삭제 ({draftCount}/{selectedCount})
+ </Button>
+ </>
+ )}
+
+ {/* S/L 확정 버튼 */}
+ {rfqId && (
+ <Button
+ variant="default"
+ size="sm"
+ onClick={handleSlConfirm}
+ className="h-8"
+ >
+ <CheckCircle2 className="mr-2 h-4 w-4" />
+ S/L 확정
+ </Button>
+ )}
+
+ {/* 초기 RFQ 추가 버튼 */}
+ {rfqId && (
+ <AddInitialRfqDialog
+ rfqId={rfqId}
+ onSuccess={handleAddSuccess}
+ defaultValues={defaultValues}
+ />
+ )}
+ </div>
+
+ {/* 삭제 다이얼로그 */}
+ <DeleteInitialRfqDialog
+ open={showDeleteDialog}
+ onOpenChange={setShowDeleteDialog}
+ initialRfqs={selectedDetails}
+ showTrigger={false}
+ onSuccess={handleDeleteSuccess}
+ />
</>
- )}
-
- {/** 항상 표시되는 액션들 */}
- <Button
- variant="outline"
- size="sm"
- onClick={handleExport}
- className="h-8"
- >
- <Download className="mr-2 h-4 w-4" />
- 엑스포트
- </Button>
-
- <Button
- variant="outline"
- size="sm"
- onClick={handleRefresh}
- className="h-8"
- >
- <RefreshCw className="mr-2 h-4 w-4" />
- 새로고침
- </Button>
-
- <Button
- variant="outline"
- size="sm"
- className="h-8"
- >
- <Settings className="mr-2 h-4 w-4" />
- 설정
- </Button>
- </div>
- )
-}
+ )
+} \ No newline at end of file
diff --git a/lib/b-rfq/initial/update-initial-rfq-sheet.tsx b/lib/b-rfq/initial/update-initial-rfq-sheet.tsx
new file mode 100644
index 00000000..a19b5172
--- /dev/null
+++ b/lib/b-rfq/initial/update-initial-rfq-sheet.tsx
@@ -0,0 +1,496 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { CalendarIcon, Loader, ChevronsUpDown, Check } from "lucide-react"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+import { format } from "date-fns"
+import { ko } from "date-fns/locale"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { Calendar } from "@/components/ui/calendar"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+ } from "@/components/ui/command"
+import { Input } from "@/components/ui/input"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { UpdateInitialRfqSchema, updateInitialRfqSchema } from "../validations"
+import { getIncotermsForSelection, modifyInitialRfq } from "../service"
+import { InitialRfqDetailView } from "@/db/schema"
+
+interface UpdateInitialRfqSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ initialRfq: InitialRfqDetailView | null
+}
+
+interface Incoterm {
+ id: number
+ code: string
+ description: string
+}
+
+export function UpdateInitialRfqSheet({ initialRfq, ...props }: UpdateInitialRfqSheetProps) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+ const [incoterms, setIncoterms] = React.useState<Incoterm[]>([])
+ const [incotermsLoading, setIncotermsLoading] = React.useState(false)
+ const [incotermsSearchOpen, setIncotermsSearchOpen] = React.useState(false)
+
+ const loadIncoterms = React.useCallback(async () => {
+ setIncotermsLoading(true)
+ try {
+ const incotermsList = await getIncotermsForSelection()
+ setIncoterms(incotermsList)
+ } catch (error) {
+ console.error("Failed to load incoterms:", error)
+ toast.error("Incoterms 목록을 불러오는데 실패했습니다.")
+ } finally {
+ setIncotermsLoading(false)
+ }
+ }, [])
+
+ React.useEffect(() => {
+ if (incoterms.length === 0) {
+ loadIncoterms()
+ }
+ }, [incoterms.length, loadIncoterms])
+
+ const form = useForm<UpdateInitialRfqSchema>({
+ resolver: zodResolver(updateInitialRfqSchema),
+ defaultValues: {
+ initialRfqStatus: initialRfq?.initialRfqStatus ?? "DRAFT",
+ dueDate: initialRfq?.dueDate ?? new Date(),
+ validDate: initialRfq?.validDate ?? undefined,
+ incotermsCode: initialRfq?.incotermsCode ?? "",
+ classification: initialRfq?.classification ?? "",
+ sparepart: initialRfq?.sparepart ?? "",
+ rfqRevision: initialRfq?.rfqRevision ?? 0,
+ shortList: initialRfq?.shortList ?? false,
+ returnYn: initialRfq?.returnYn ?? false,
+ cpRequestYn: initialRfq?.cpRequestYn ?? false,
+ prjectGtcYn: initialRfq?.prjectGtcYn ?? false,
+ },
+ })
+
+ // initialRfq가 변경될 때 폼 값을 업데이트
+ React.useEffect(() => {
+ if (initialRfq) {
+ form.reset({
+ initialRfqStatus: initialRfq.initialRfqStatus ?? "DRAFT",
+ dueDate: initialRfq.dueDate,
+ validDate: initialRfq.validDate,
+ incotermsCode: initialRfq.incotermsCode ?? "",
+ classification: initialRfq.classification ?? "",
+ sparepart: initialRfq.sparepart ?? "",
+ shortList: initialRfq.shortList ?? false,
+ returnYn: initialRfq.returnYn ?? false,
+ rfqRevision: initialRfq.rfqRevision ?? 0,
+ cpRequestYn: initialRfq.cpRequestYn ?? false,
+ prjectGtcYn: initialRfq.prjectGtcYn ?? false,
+ })
+ }
+ }, [initialRfq, form])
+
+ function onSubmit(input: UpdateInitialRfqSchema) {
+ startUpdateTransition(async () => {
+ if (!initialRfq || !initialRfq.initialRfqId) {
+ toast.error("유효하지 않은 RFQ입니다.")
+ return
+ }
+
+ const { error } = await modifyInitialRfq({
+ id: initialRfq.initialRfqId,
+ ...input,
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ form.reset()
+ props.onOpenChange?.(false)
+ toast.success("초기 RFQ가 수정되었습니다")
+ })
+ }
+
+ const selectedIncoterm = incoterms.find(incoterm => incoterm.code === form.watch("incotermsCode"))
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col h-full sm:max-w-md">
+ {/* 고정 헤더 */}
+ <SheetHeader className="flex-shrink-0 text-left pb-6">
+ <SheetTitle>초기 RFQ 수정</SheetTitle>
+ <SheetDescription>
+ 초기 RFQ 정보를 수정하고 변경사항을 저장하세요
+ </SheetDescription>
+ </SheetHeader>
+
+ {/* 스크롤 가능한 폼 영역 */}
+ <div className="flex-1 overflow-y-auto">
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="flex flex-col gap-4 pr-2"
+ >
+ {/* RFQ 리비전 */}
+ <FormField
+ control={form.control}
+ name="rfqRevision"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>RFQ 리비전</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ min="0"
+ placeholder="0"
+ {...field}
+ onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 마감일 */}
+ <FormField
+ control={form.control}
+ name="dueDate"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>마감일 *</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant={"outline"}
+ className={cn(
+ "w-full pl-3 text-left font-normal",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value ? (
+ format(field.value, "PPP", { locale: ko })
+ ) : (
+ <span>날짜를 선택하세요</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ disabled={(date) =>
+ date < new Date("1900-01-01")
+ }
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 유효일 */}
+ <FormField
+ control={form.control}
+ name="validDate"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>유효일</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant={"outline"}
+ className={cn(
+ "w-full pl-3 text-left font-normal",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value ? (
+ format(field.value, "PPP", { locale: ko })
+ ) : (
+ <span>날짜를 선택하세요</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ disabled={(date) =>
+ date < new Date("1900-01-01")
+ }
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Incoterms 코드 */}
+ <FormField
+ control={form.control}
+ name="incotermsCode"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>Incoterms</FormLabel>
+ <Popover open={incotermsSearchOpen} onOpenChange={setIncotermsSearchOpen}>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={incotermsSearchOpen}
+ className="justify-between"
+ disabled={incotermsLoading}
+ >
+ {selectedIncoterm ? (
+ <div className="flex items-center gap-2">
+ <span className="truncate">
+ {selectedIncoterm.code} - {selectedIncoterm.description}
+ </span>
+ </div>
+ ) : (
+ <span className="text-muted-foreground">
+ {incotermsLoading ? "로딩 중..." : "인코텀즈를 선택하세요"}
+ </span>
+ )}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-full p-0" align="start">
+ <Command>
+ <CommandInput
+ placeholder="코드 또는 내용으로 검색..."
+ className="h-9"
+ />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {incoterms.map((incoterm) => (
+ <CommandItem
+ key={incoterm.id}
+ value={`${incoterm.code} ${incoterm.description}`}
+ onSelect={() => {
+ field.onChange(incoterm.code)
+ setIncotermsSearchOpen(false)
+ }}
+ >
+ <div className="flex items-center gap-2 w-full">
+ <div className="flex-1 min-w-0">
+ <div className="font-medium truncate">
+ {incoterm.code} - {incoterm.description}
+ </div>
+ </div>
+ <Check
+ className={cn(
+ "ml-auto h-4 w-4",
+ incoterm.code === field.value ? "opacity-100" : "opacity-0"
+ )}
+ />
+ </div>
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ {/* 체크박스 옵션들 */}
+ <div className="space-y-3">
+ <FormField
+ control={form.control}
+ name="shortList"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-start space-x-3 space-y-0">
+ <FormControl>
+ <Checkbox
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ <div className="space-y-1 leading-none ml-2">
+ <FormLabel>Short List</FormLabel>
+ </div>
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="returnYn"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-start space-x-3 space-y-0">
+ <FormControl>
+ <Checkbox
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ <div className="space-y-1 leading-none ml-2">
+ <FormLabel>회신 여부</FormLabel>
+ </div>
+ </FormItem>
+ )}
+ />
+
+ {/* 선급 */}
+ <FormField
+ control={form.control}
+ name="classification"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>선급</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="선급"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 예비부품 */}
+ <FormField
+ control={form.control}
+ name="sparepart"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>예비부품</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="O1, O2"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+
+
+
+ <FormField
+ control={form.control}
+ name="cpRequestYn"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-start space-x-3 space-y-0">
+ <FormControl>
+ <Checkbox
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ <div className="space-y-1 leading-none ml-2">
+ <FormLabel>CP 요청</FormLabel>
+ </div>
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="prjectGtcYn"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-start space-x-3 space-y-0">
+ <FormControl>
+ <Checkbox
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ <div className="space-y-1 leading-none ml-2">
+ <FormLabel>프로젝트 GTC</FormLabel>
+ </div>
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 하단 여백 */}
+ <div className="h-4" />
+ </form>
+ </Form>
+ </div>
+
+ {/* 고정 푸터 */}
+ <SheetFooter className="flex-shrink-0 gap-2 pt-6 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ 취소
+ </Button>
+ </SheetClose>
+ <Button
+ onClick={form.handleSubmit(onSubmit)}
+ disabled={isUpdatePending}
+ >
+ {isUpdatePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 저장
+ </Button>
+ </SheetFooter>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file