diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-21 07:54:26 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-21 07:54:26 +0000 |
| commit | 14f61e24947fb92dd71ec0a7196a6e815f8e66da (patch) | |
| tree | 317c501d64662d05914330628f867467fba78132 /components | |
| parent | 194bd4bd7e6144d5c09c5e3f5476d254234dce72 (diff) | |
(최겸)기술영업 RFQ 담당자 초대, 요구사항 반영
Diffstat (limited to 'components')
| -rw-r--r-- | components/signup/tech-vendor-item-selector-dialog.tsx | 254 | ||||
| -rw-r--r-- | components/signup/tech-vendor-join-form.tsx | 69 | ||||
| -rw-r--r-- | components/tech-vendors/tech-vendor-container.tsx | 30 | ||||
| -rw-r--r-- | components/tech-vendors/tech-vendor-items-container.tsx | 121 |
4 files changed, 326 insertions, 148 deletions
diff --git a/components/signup/tech-vendor-item-selector-dialog.tsx b/components/signup/tech-vendor-item-selector-dialog.tsx new file mode 100644 index 00000000..a69dec5d --- /dev/null +++ b/components/signup/tech-vendor-item-selector-dialog.tsx @@ -0,0 +1,254 @@ +"use client"
+
+import * as React from "react"
+import { useState, useEffect } from "react"
+import { Search, X } from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Input } from "@/components/ui/input"
+import { Badge } from "@/components/ui/badge"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Checkbox } from "@/components/ui/checkbox"
+
+interface Item {
+ itemCode: string
+ itemList: string
+ subItemList?: string
+ workType?: string
+ shipTypes?: string
+}
+
+interface TechVendorItemSelectorDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ vendorType: string | string[]
+ onItemsSelected: (selectedItems: string[]) => void
+}
+
+export function TechVendorItemSelectorDialog({
+ open,
+ onOpenChange,
+ vendorType,
+ onItemsSelected,
+}: TechVendorItemSelectorDialogProps) {
+ const [items, setItems] = useState<Item[]>([])
+ const [filteredItems, setFilteredItems] = useState<Item[]>([])
+ const [searchTerm, setSearchTerm] = useState("")
+ const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set())
+ const [isLoading, setIsLoading] = useState(false)
+
+ // 벤더 타입에 따른 아이템 조회
+ useEffect(() => {
+ if (open && vendorType) {
+ loadItemsByVendorType()
+ }
+ }, [open, vendorType])
+
+ // 검색 필터링
+ useEffect(() => {
+ if (searchTerm.trim() === "") {
+ setFilteredItems(items)
+ } else {
+ const filtered = items.filter(
+ (item) =>
+ item.itemList.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ (item.subItemList && item.subItemList.toLowerCase().includes(searchTerm.toLowerCase())) ||
+ item.itemCode.toLowerCase().includes(searchTerm.toLowerCase())
+ )
+ setFilteredItems(filtered)
+ }
+ }, [searchTerm, items])
+
+ const loadItemsByVendorType = async () => {
+ setIsLoading(true)
+ try {
+ // 서버 액션으로 아이템 조회
+ const { getItemsByVendorType } = await import("@/lib/tech-vendors/service")
+
+ let allItems: any[] = []
+
+ // 여러 벤더 타입인 경우 각각 조회하여 합치기
+ if (Array.isArray(vendorType)) {
+ for (const type of vendorType) {
+ const result = await getItemsByVendorType(type, "")
+ if (result && result.data && result.data.length > 0) {
+ allItems = [...allItems, ...result.data]
+ }
+ }
+ } else {
+ // 단일 벤더 타입인 경우
+ const result = await getItemsByVendorType(vendorType, "")
+ if (result && result.data && result.data.length > 0) {
+ allItems = result.data
+ }
+ }
+
+ if (allItems.length > 0) {
+ // 중복 제거 (itemCode 기준)
+ const uniqueItems = allItems.filter((item, index, self) =>
+ index === self.findIndex(t => t.itemCode === item.itemCode)
+ )
+
+ const itemsData = uniqueItems.map((item: any) => ({
+ itemCode: item.itemCode || "",
+ itemList: item.itemList || "",
+ subItemList: item.subItemList || "",
+ workType: item.workType || "",
+ shipTypes: item.shipTypes || "",
+ }))
+ setItems(itemsData)
+ setFilteredItems(itemsData)
+ } else {
+ setItems([])
+ setFilteredItems([])
+ }
+ } catch (error) {
+ console.error("아이템 조회 실패:", error)
+ setItems([])
+ setFilteredItems([])
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const handleItemToggle = (itemCode: string) => {
+ const newSelected = new Set(selectedItems)
+ if (newSelected.has(itemCode)) {
+ newSelected.delete(itemCode)
+ } else {
+ newSelected.add(itemCode)
+ }
+ setSelectedItems(newSelected)
+ }
+
+ const handleConfirm = () => {
+ const selectedItemCodes = Array.from(selectedItems)
+ onItemsSelected(selectedItemCodes)
+ onOpenChange(false)
+ // 상태 초기화
+ setSelectedItems(new Set())
+ setSearchTerm("")
+ }
+
+ const handleCancel = () => {
+ onOpenChange(false)
+ // 상태 초기화
+ setSelectedItems(new Set())
+ setSearchTerm("")
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[80vh] flex flex-col">
+ <DialogHeader>
+ <DialogTitle>공급가능품목 선택</DialogTitle>
+ <DialogDescription>
+ {Array.isArray(vendorType) ? vendorType.join(", ") : vendorType} 관련 아이템 중에서 공급 가능한 품목을 선택해주세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="flex-1 space-y-4">
+ {/* 검색바 */}
+ <div className="relative">
+ <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
+ <Input
+ placeholder="아이템명, 서브아이템명, 아이템코드로 검색..."
+ value={searchTerm}
+ onChange={(e) => setSearchTerm(e.target.value)}
+ className="pl-10"
+ />
+ </div>
+
+ {/* 선택된 아이템 표시 */}
+ {selectedItems.size > 0 && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium">선택된 아이템 ({selectedItems.size}개)</div>
+ <div className="flex flex-wrap gap-2">
+ {Array.from(selectedItems).map((itemCode) => {
+ const item = items.find((i) => i.itemCode === itemCode)
+ return (
+ <Badge key={itemCode} variant="secondary" className="gap-1">
+ {item?.itemList || itemCode}
+ <button
+ onClick={() => handleItemToggle(itemCode)}
+ className="ml-1 hover:bg-muted rounded-full p-0.5"
+ >
+ <X className="h-3 w-3" />
+ </button>
+ </Badge>
+ )
+ })}
+ </div>
+ </div>
+ )}
+
+ {/* 아이템 목록 */}
+ <div className="border rounded-md">
+ <ScrollArea className="h-96">
+ {isLoading ? (
+ <div className="p-4 text-center text-muted-foreground">로딩 중...</div>
+ ) : filteredItems.length === 0 ? (
+ <div className="p-4 text-center text-muted-foreground">
+ {searchTerm ? "검색 결과가 없습니다." : "아이템이 없습니다."}
+ </div>
+ ) : (
+ <div className="p-4 space-y-2">
+ {filteredItems.map((item) => (
+ <div
+ key={item.itemCode}
+ className="flex items-start space-x-3 p-3 border rounded-lg hover:bg-muted/50"
+ >
+ <Checkbox
+ checked={selectedItems.has(item.itemCode)}
+ onCheckedChange={() => handleItemToggle(item.itemCode)}
+ className="mt-1"
+ />
+ <div className="flex-1 space-y-1">
+ <div className="flex items-center space-x-2">
+ <span className="font-medium">{item.itemList}</span>
+ <Badge variant="outline" className="text-xs">
+ {item.itemCode}
+ </Badge>
+ </div>
+ {item.subItemList && (
+ <div className="text-sm text-muted-foreground">
+ {item.subItemList}
+ </div>
+ )}
+ <div className="flex space-x-2 text-xs text-muted-foreground">
+ {item.workType && (
+ <span>공종: {item.workType}</span>
+ )}
+ {item.shipTypes && (
+ <span>선종: {item.shipTypes}</span>
+ )}
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+ </ScrollArea>
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button variant="outline" onClick={handleCancel}>
+ 취소
+ </Button>
+ <Button onClick={handleConfirm} disabled={selectedItems.size === 0}>
+ 선택 완료 ({selectedItems.size}개)
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
\ No newline at end of file diff --git a/components/signup/tech-vendor-join-form.tsx b/components/signup/tech-vendor-join-form.tsx index db81b88c..efdee322 100644 --- a/components/signup/tech-vendor-join-form.tsx +++ b/components/signup/tech-vendor-join-form.tsx @@ -38,6 +38,7 @@ import { Check, ChevronsUpDown, Loader2, Plus, X } from "lucide-react" import { cn } from "@/lib/utils"
import { createTechVendorFromSignup } from "@/lib/tech-vendors/service"
+import { TechVendorItemSelectorDialog } from "./tech-vendor-item-selector-dialog"
import { createTechVendorSchema, CreateTechVendorSchema } from "@/lib/tech-vendors/validations"
import { VENDOR_TYPES } from "@/db/schema/techVendors"
import { verifyTechVendorInvitationToken } from "@/lib/tech-vendor-invitation-token"
@@ -144,6 +145,8 @@ export function TechVendorJoinForm() { const [isSubmitting, setIsSubmitting] = React.useState(false)
const [isLoading, setIsLoading] = React.useState(false)
const [hasValidToken, setHasValidToken] = React.useState<boolean | null>(null)
+ const [isItemSelectorOpen, setIsItemSelectorOpen] = React.useState(false)
+ const [selectedItemCodes, setSelectedItemCodes] = React.useState<string[]>([])
// React Hook Form (항상 최상위에서 호출)
const form = useForm<CreateTechVendorSchema>({
@@ -158,7 +161,7 @@ export function TechVendorJoinForm() { phone: "",
country: "",
website: "",
- techVendorType: ["조선"],
+ techVendorType: [],
representativeName: "",
representativeBirth: "",
representativeEmail: "",
@@ -200,13 +203,15 @@ export function TechVendorJoinForm() { if (tokenPayload) {
setHasValidToken(true);
+ console.log("tokenPayload", tokenPayload);
// 토큰에서 가져온 정보로 폼 미리 채우기
form.setValue("vendorName", tokenPayload.vendorName);
form.setValue("email", tokenPayload.email);
+ form.setValue("techVendorType", tokenPayload.vendorType as "조선" | "해양TOP" | "해양HULL" | ("조선" | "해양TOP" | "해양HULL")[]);
- // 연락처 정보도 미리 채우기
- form.setValue("contacts.0.contactName", tokenPayload.vendorName);
- form.setValue("contacts.0.contactEmail", tokenPayload.email);
+ // // 연락처 정보도 미리 채우기
+ // form.setValue("contacts.0.contactName", tokenPayload.vendorName);
+ // form.setValue("contacts.0.contactEmail", tokenPayload.email);
toast({
title: "초대 정보 로드 완료",
@@ -292,6 +297,13 @@ export function TechVendorJoinForm() { form.setValue("files", updated, { shouldValidate: true })
}
+ const handleItemsSelected = (itemCodes: string[]) => {
+ setSelectedItemCodes(itemCodes)
+ // 선택된 아이템 코드들을 콤마로 구분하여 items 필드에 설정
+ const itemsString = itemCodes.join(", ")
+ form.setValue("items", itemsString)
+ }
+
// Submit
async function onSubmit(values: CreateTechVendorSchema) {
setIsSubmitting(true)
@@ -310,7 +322,7 @@ export function TechVendorJoinForm() { email: values.email,
phone: values.phone,
country: values.country,
- techVendorType: Array.isArray(values.techVendorType) ? values.techVendorType[0] : values.techVendorType,
+ techVendorType: values.techVendorType as "조선" | "해양TOP" | "해양HULL" | ("조선" | "해양TOP" | "해양HULL")[],
representativeName: values.representativeName || "",
representativeBirth: values.representativeBirth || "",
representativeEmail: values.representativeEmail || "",
@@ -323,6 +335,7 @@ export function TechVendorJoinForm() { vendorData: techVendorData,
files: mainFiles,
contacts: values.contacts,
+ selectedItemCodes: selectedItemCodes,
invitationToken: invitationToken || undefined,
})
@@ -413,7 +426,7 @@ export function TechVendorJoinForm() { id={`techVendorType-${type}`}
checked={field.value?.includes(type) || false}
onChange={(e) => {
- const currentValues = field.value || [];
+ const currentValues = Array.isArray(field.value) ? field.value : [];
if (e.target.checked) {
field.onChange([...currentValues, type]);
} else {
@@ -557,7 +570,7 @@ export function TechVendorJoinForm() { )}
/>
- {/* 이메일 */}
+ {/* 이메일 (수정 불가, 뷰 전용) */}
<FormField
control={form.control}
name="email"
@@ -567,7 +580,13 @@ export function TechVendorJoinForm() { 이메일
</FormLabel>
<FormControl>
- <Input placeholder="example@company.com" {...field} />
+ <Input
+ placeholder="example@company.com"
+ {...field}
+ readOnly
+ tabIndex={-1}
+ className="bg-muted cursor-not-allowed pointer-events-none"
+ />
</FormControl>
<FormMessage />
</FormItem>
@@ -616,11 +635,29 @@ export function TechVendorJoinForm() { <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500">
주요 품목
</FormLabel>
- <FormControl>
- <Input placeholder="주요 품목을 입력하세요" {...field} />
- </FormControl>
+ <div className="space-y-2">
+ <FormControl>
+ <Input placeholder="주요 품목을 입력하세요" {...field} />
+ </FormControl>
+ <div className="flex items-center space-x-2">
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={() => setIsItemSelectorOpen(true)}
+ disabled={!form.watch("techVendorType") || (Array.isArray(form.watch("techVendorType")) && form.watch("techVendorType").length === 0)}
+ >
+ 공급가능품목 선택
+ </Button>
+ {selectedItemCodes.length > 0 && (
+ <span className="text-sm text-muted-foreground">
+ {selectedItemCodes.length}개 아이템 선택됨
+ </span>
+ )}
+ </div>
+ </div>
<FormDescription>
- 회사에서 주로 다루는 품목들을 쉼표로 구분하여 입력하세요.
+ 공급가능품목 선택 버튼을 클릭하여 아이템을 선택하세요. 원하는 아이템이 없다면 텍스트로 입력하세요.
</FormDescription>
<FormMessage />
</FormItem>
@@ -902,6 +939,14 @@ export function TechVendorJoinForm() { </Form>
</div>
</section>
+
+ {/* 공급가능품목 선택 다이얼로그 */}
+ <TechVendorItemSelectorDialog
+ open={isItemSelectorOpen}
+ onOpenChange={setIsItemSelectorOpen}
+ vendorType={form.watch("techVendorType")}
+ onItemsSelected={handleItemsSelected}
+ />
</div>
)
}
\ No newline at end of file diff --git a/components/tech-vendors/tech-vendor-container.tsx b/components/tech-vendors/tech-vendor-container.tsx index af5169b8..94536702 100644 --- a/components/tech-vendors/tech-vendor-container.tsx +++ b/components/tech-vendors/tech-vendor-container.tsx @@ -40,20 +40,20 @@ export function TechVendorContainer({ // URL에서 현재 선택된 벤더 타입 가져오기
const vendorType = searchParams.get("vendorType") || "all"
- // 선택한 벤더 타입에 해당하는 이름 찾기
- const selectedVendor = vendorTypes.find((vendor) => vendor.id === vendorType)?.name || "전체"
+ // // 선택한 벤더 타입에 해당하는 이름 찾기
+ // const selectedVendor = vendorTypes.find((vendor) => vendor.id === vendorType)?.name || "전체"
- // 벤더 타입 변경 핸들러
- const handleVendorTypeChange = React.useCallback((value: string) => {
- const params = new URLSearchParams(searchParams.toString())
- if (value === "all") {
- params.delete("vendorType")
- } else {
- params.set("vendorType", value)
- }
+ // // 벤더 타입 변경 핸들러
+ // const handleVendorTypeChange = React.useCallback((value: string) => {
+ // const params = new URLSearchParams(searchParams.toString())
+ // if (value === "all") {
+ // params.delete("vendorType")
+ // } else {
+ // params.set("vendorType", value)
+ // }
- router.push(`${pathname}?${params.toString()}`)
- }, [router, pathname, searchParams])
+ // router.push(`${pathname}?${params.toString()}`)
+ // }, [router, pathname, searchParams])
return (
<>
@@ -62,7 +62,7 @@ export function TechVendorContainer({ {/* 왼쪽: 타이틀 & 설명 */}
<div>
<div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">기술영업 벤더 관리</h2>
+ <h2 className="text-2xl font-bold tracking-tight">기술영업 벤더 리스트</h2>
<InformationButton pagePath="evcp/tech-vendors" />
</div>
{/* <p className="text-muted-foreground">
@@ -70,7 +70,7 @@ export function TechVendorContainer({ </p> */}
</div>
- {/* 오른쪽: 벤더 타입 드롭다운 */}
+ {/* 오른쪽: 벤더 타입 드롭다운
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="min-w-[150px]">
@@ -89,7 +89,7 @@ export function TechVendorContainer({ </DropdownMenuItem>
))}
</DropdownMenuContent>
- </DropdownMenu>
+ </DropdownMenu> */}
</div>
{/* 컨텐츠 영역 */}
diff --git a/components/tech-vendors/tech-vendor-items-container.tsx b/components/tech-vendors/tech-vendor-items-container.tsx deleted file mode 100644 index 49a9d4ee..00000000 --- a/components/tech-vendors/tech-vendor-items-container.tsx +++ /dev/null @@ -1,121 +0,0 @@ -"use client"
-
-import * as React from "react"
-import { useRouter, usePathname, useSearchParams } from "next/navigation"
-import { ChevronDown } from "lucide-react"
-
-import { type TechVendor } from "@/db/schema/techVendors"
-
-import { Button } from "@/components/ui/button"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-import { TechVendorItemsTable } from "@/lib/tech-vendors/items-table/item-table"
-import { getVendorItemsByType } from "@/lib/tech-vendors/service"
-
-interface ItemType {
- id: string
- name: string
- vendorType: string
-}
-
-interface TechVendorItemsContainerProps {
- vendorId: number
- vendor: TechVendor
- itemTypes: ItemType[]
-}
-
-export function TechVendorItemsContainer({
- vendorId,
- vendor,
- itemTypes,
-}: TechVendorItemsContainerProps) {
- const router = useRouter()
- const pathname = usePathname()
- const searchParamsObj = useSearchParams()
-
- // useSearchParams를 메모이제이션하여 안정적인 참조 생성
- const currentSearchParams = React.useMemo(
- () => searchParamsObj || new URLSearchParams(),
- [searchParamsObj]
- )
-
- // URL에서 현재 선택된 아이템 타입 가져오기 (기본값은 첫 번째 타입)
- const itemType = currentSearchParams.get("type") || itemTypes[0]?.id || "ship"
-
- // 선택한 아이템 타입에 해당하는 정보 찾기
- const selectedItemType = itemTypes.find((item) => item.id === itemType) || itemTypes[0]
-
- // 아이템 타입 변경 핸들러
- const handleItemTypeChange = React.useCallback((value: string) => {
- const params = new URLSearchParams(currentSearchParams.toString())
- params.set("type", value)
-
- router.push(`${pathname}?${params.toString()}`)
- }, [router, pathname, currentSearchParams])
-
- // 현재 선택된 벤더 타입에 대한 아이템 데이터 가져오기
- const promises = React.useMemo(() => {
- if (selectedItemType) {
- return getVendorItemsByType(vendorId, selectedItemType.vendorType)
- }
- return Promise.resolve({ data: [] })
- }, [vendorId, selectedItemType])
-
- // 벤더 타입이 하나뿐인 경우 드롭다운 숨기기
- const showDropdown = itemTypes.length > 1
-
- return (
- <>
- {/* 상단 영역: 제목 왼쪽 / 아이템 타입 선택기 오른쪽 */}
- <div className="flex items-center justify-between">
- {/* 왼쪽: 타이틀 & 설명 */}
- <div>
- <h4 className="text-lg font-medium">자재 목록</h4>
- <p className="text-sm text-muted-foreground">
- {vendor.vendorName}의 공급 가능한 자재 목록입니다.
- </p>
- </div>
-
- {/* 오른쪽: 아이템 타입 드롭다운 (타입이 여러 개인 경우에만 표시) */}
- {showDropdown && (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button variant="outline" className="min-w-[150px]">
- {selectedItemType?.name || "타입 선택"}
- <ChevronDown className="ml-2 h-4 w-4" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="w-[200px]">
- {itemTypes.map((item) => (
- <DropdownMenuItem
- key={item.id}
- onClick={() => handleItemTypeChange(item.id)}
- className={item.id === itemType ? "bg-muted" : ""}
- >
- {item.name}
- </DropdownMenuItem>
- ))}
- </DropdownMenuContent>
- </DropdownMenu>
- )}
- </div>
-
- {/* 컨텐츠 영역 */}
- <section className="overflow-hidden">
- <div>
- {selectedItemType && (
- <TechVendorItemsTable
- promises={promises}
- vendorId={vendorId}
- vendorType={selectedItemType.vendorType}
- />
- )}
- </div>
- </section>
- </>
- )
-}
\ No newline at end of file |
