diff options
| author | joonhoekim <26rote@gmail.com> | 2025-09-24 17:36:08 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-09-24 17:36:08 +0900 |
| commit | bf2db28586569499e44b58999f2e0f33ed4cdeb5 (patch) | |
| tree | 9ef9305829fdec30ec7a442f2ba0547a62dba7a9 /components/common | |
| parent | 1bda7f20f113737f4af32495e7ff24f6022dc283 (diff) | |
(김준회) 구매 요청사항 반영 - vendor-pool 및 avl detail (이진용 프로)
Diffstat (limited to 'components/common')
4 files changed, 263 insertions, 2 deletions
diff --git a/components/common/project/project-service.ts b/components/common/project/project-service.ts index 6c103c6f..510d7527 100644 --- a/components/common/project/project-service.ts +++ b/components/common/project/project-service.ts @@ -28,6 +28,10 @@ export interface ProjectInfo { shipType?: string | null projectMsrm?: string | null projectHtDivision?: string | null + type?: string | null // projects 테이블의 type 필드 + sector?: string | null // biddingProjects 테이블의 sector 필드 + typeMdg?: string | null // projects 테이블의 TYPE_MDG 필드 + pjtType?: string | null // biddingProjects 테이블의 pjtType 필드 source: 'projects' | 'biddingProjects' } @@ -145,13 +149,15 @@ export async function getProjectInfoByCode(projectCode: string, searchFrom: 'bot if (searchFrom === 'both' || searchFrom === 'projects') { try { const projectInfo = await db.select().from(projects).where(eq(projects.code, projectCode)).limit(1) - + if (projectInfo && projectInfo.length > 0) { return { projectCode: projectInfo[0].code, projectName: projectInfo[0].name, shipType: projectInfo[0].SKND || undefined, projectHtDivision: projectInfo[0].type || undefined, + type: projectInfo[0].type || undefined, + typeMdg: projectInfo[0].TYPE_MDG || undefined, source: 'projects' } } @@ -180,6 +186,8 @@ export async function getProjectInfoByCode(projectCode: string, searchFrom: 'bot projectName: projectInfo[0].projNm, projectMsrm: projectInfo[0].ptypeNm, projectHtDivision, + sector: projectInfo[0].sector || undefined, + pjtType: projectInfo[0].pjtType || undefined, source: 'biddingProjects' } } diff --git a/components/common/selectors/place-of-shipping/place-of-shipping-selector.tsx b/components/common/selectors/place-of-shipping/place-of-shipping-selector.tsx index 63532365..0a9916cd 100644 --- a/components/common/selectors/place-of-shipping/place-of-shipping-selector.tsx +++ b/components/common/selectors/place-of-shipping/place-of-shipping-selector.tsx @@ -13,8 +13,27 @@ import { Select, SelectItem, SelectContent } from "@/components/ui/select" import { SelectTrigger } from "@/components/ui/select" import { SelectValue } from "@/components/ui/select" import { Input } from "@/components/ui/input" -import { useState, useEffect, useMemo } from "react" +import { useState, useEffect, useMemo, useCallback } from "react" import { getPlaceOfShippingForSelection } from "./place-of-shipping-service" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Search, Check } from "lucide-react" interface PlaceOfShippingData { code: string @@ -96,4 +115,233 @@ export function PlaceOfShippingSelector({ </Select> </div> ) +} + +/** + * 선적지/하역지 단일 선택 Dialog 컴포넌트 + * + * @description + * - PlaceOfShippingSelector를 Dialog로 래핑한 단일 선택 컴포넌트 + * - 버튼 클릭 시 Dialog가 열리고, 장소를 선택하면 Dialog가 닫히며 결과를 반환 + * + * @PlaceOfShippingData_Structure + * 선택된 장소 객체의 형태: + * ```typescript + * interface PlaceOfShippingData { + * code: string; // 장소코드 + * description: string; // 장소명 + * } + * ``` + * + * @state + * - open: Dialog 열림/닷힘 상태 + * - selectedPlace: 현재 선택된 장소 (단일) + * - tempSelectedPlace: Dialog 내에서 임시로 선택된 장소 (확인 버튼 클릭 전까지) + * + * @callback + * - onPlaceSelect: 장소 선택 완료 시 호출되는 콜백 + * - 매개변수: PlaceOfShippingData | null + * - 선택된 장소 정보 또는 null (선택 해제 시) + * + * @usage + * ```tsx + * <PlaceOfShippingSelectorDialogSingle + * triggerLabel="장소 선택" + * selectedPlace={selectedPlace} + * onPlaceSelect={(place) => { + * setSelectedPlace(place); + * console.log('선택된 장소:', place); + * }} + * placeholder="장소를 검색하세요..." + * /> + * ``` + */ + +interface PlaceOfShippingSelectorDialogSingleProps { + /** Dialog를 여는 트리거 버튼 텍스트 */ + triggerLabel?: string + /** 현재 선택된 장소 */ + selectedPlace?: PlaceOfShippingData | null + /** 장소 선택 완료 시 호출되는 콜백 */ + onPlaceSelect?: (place: PlaceOfShippingData | null) => void + /** 검색 입력창 placeholder */ + placeholder?: string + /** Dialog 제목 */ + title?: string + /** Dialog 설명 */ + description?: string + /** 트리거 버튼 비활성화 여부 */ + disabled?: boolean + /** 트리거 버튼 variant */ + triggerVariant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" +} + +export function PlaceOfShippingSelectorDialogSingle({ + triggerLabel = "장소 선택", + selectedPlace = null, + onPlaceSelect, + placeholder = "장소를 검색하세요...", + title = "장소 선택", + description = "원하는 장소를 검색하고 선택해주세요.", + disabled = false, + triggerVariant = "outline", +}: PlaceOfShippingSelectorDialogSingleProps) { + // Dialog 열림/닫힘 상태 + const [open, setOpen] = useState(false) + + // Dialog 내에서 임시로 선택된 장소 (확인 버튼 클릭 전까지) + const [tempSelectedPlace, setTempSelectedPlace] = useState<PlaceOfShippingData | null>(null) + + // 장소 데이터 + const [placeOfShippingData, setPlaceOfShippingData] = useState<PlaceOfShippingData[]>([]) + const [isLoading, setIsLoading] = useState(true) + const [searchTerm, setSearchTerm] = useState("") + + const filteredData = useMemo(() => { + if (!searchTerm) return placeOfShippingData + return placeOfShippingData.filter(item => + item.code.toLowerCase().includes(searchTerm.toLowerCase()) || + item.description.toLowerCase().includes(searchTerm.toLowerCase()) + ) + }, [placeOfShippingData, searchTerm]) + + // Dialog 열림 시 현재 선택된 장소로 임시 선택 초기화 + const handleOpenChange = useCallback((newOpen: boolean) => { + setOpen(newOpen) + if (newOpen) { + setTempSelectedPlace(selectedPlace || null) + } + }, [selectedPlace]) + + // 장소 선택 처리 (Dialog 내에서) + const handlePlaceChange = useCallback((place: PlaceOfShippingData) => { + setTempSelectedPlace(place) + }, []) + + // 확인 버튼 클릭 시 선택 완료 + const handleConfirm = useCallback(() => { + onPlaceSelect?.(tempSelectedPlace) + setOpen(false) + }, [tempSelectedPlace, onPlaceSelect]) + + // 취소 버튼 클릭 시 + const handleCancel = useCallback(() => { + setTempSelectedPlace(selectedPlace || null) + setOpen(false) + }, [selectedPlace]) + + // 선택 해제 + const handleClear = useCallback(() => { + setTempSelectedPlace(null) + }, []) + + useEffect(() => { + const loadData = async () => { + try { + const data = await getPlaceOfShippingForSelection() + setPlaceOfShippingData(data) + } catch (error) { + console.error('선적지/하역지 데이터 로드 실패:', error) + setPlaceOfShippingData([]) + } finally { + setIsLoading(false) + } + } + + loadData() + }, []) + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogTrigger asChild> + <Button variant={triggerVariant} disabled={disabled}> + {selectedPlace ? ( + <span className="truncate"> + {selectedPlace.code} - {selectedPlace.description} + </span> + ) : ( + triggerLabel + )} + </Button> + </DialogTrigger> + + <DialogContent className="max-w-4xl max-h-[80vh]"> + <DialogHeader> + <DialogTitle>{title}</DialogTitle> + <DialogDescription>{description}</DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + <div className="flex items-center space-x-2"> + <Search className="h-4 w-4" /> + <Input + placeholder={placeholder} + value={searchTerm} + onChange={(e) => setSearchTerm(e.target.value)} + className="flex-1" + /> + </div> + + {isLoading ? ( + <div className="flex justify-center py-8"> + <div className="text-sm text-muted-foreground">장소 데이터를 불러오는 중...</div> + </div> + ) : ( + <div className="border rounded-md max-h-96 overflow-auto"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-12"></TableHead> + <TableHead>장소코드</TableHead> + <TableHead>장소명</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {filteredData.length === 0 ? ( + <TableRow> + <TableCell colSpan={3} className="text-center py-8 text-muted-foreground"> + {searchTerm ? "검색 결과가 없습니다" : "데이터가 없습니다"} + </TableCell> + </TableRow> + ) : ( + filteredData.map((item) => ( + <TableRow + key={item.code} + className={`cursor-pointer hover:bg-muted/50 ${ + tempSelectedPlace?.code === item.code ? "bg-muted" : "" + }`} + onClick={() => handlePlaceChange(item)} + > + <TableCell> + {tempSelectedPlace?.code === item.code && ( + <Check className="h-4 w-4 text-primary" /> + )} + </TableCell> + <TableCell className="font-mono">{item.code}</TableCell> + <TableCell>{item.description}</TableCell> + </TableRow> + )) + )} + </TableBody> + </Table> + </div> + )} + </div> + + <DialogFooter className="gap-2"> + <Button variant="outline" onClick={handleCancel}> + 취소 + </Button> + {tempSelectedPlace && ( + <Button variant="ghost" onClick={handleClear}> + 선택 해제 + </Button> + )} + <Button onClick={handleConfirm}> + 확인 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) }
\ No newline at end of file diff --git a/components/common/vendor/vendor-selector-dialog-single.tsx b/components/common/vendor/vendor-selector-dialog-single.tsx index da9a9a74..7bb4b14c 100644 --- a/components/common/vendor/vendor-selector-dialog-single.tsx +++ b/components/common/vendor/vendor-selector-dialog-single.tsx @@ -28,6 +28,7 @@ import { VendorSearchItem } from "./vendor-service" * id: number; // 벤더 ID * vendorName: string; // 벤더명 * vendorCode: string | null; // 벤더코드 (없을 수 있음) + * taxId: string | null; // 사업자번호 * status: string; // 벤더 상태 (ACTIVE, PENDING_REVIEW 등) * displayText: string; // 표시용 텍스트 (vendorName + vendorCode) * } diff --git a/components/common/vendor/vendor-service.ts b/components/common/vendor/vendor-service.ts index 83a63cae..1c59843c 100644 --- a/components/common/vendor/vendor-service.ts +++ b/components/common/vendor/vendor-service.ts @@ -9,6 +9,7 @@ export interface VendorSearchItem { id: number vendorName: string vendorCode: string | null + taxId: string | null // 사업자번호 status: string displayText: string // vendorName + vendorCode로 구성된 표시용 텍스트 } @@ -100,6 +101,7 @@ export async function searchVendorsForSelector( id: vendors.id, vendorName: vendors.vendorName, vendorCode: vendors.vendorCode, + taxId: vendors.taxId, status: vendors.status, }) .from(vendors) @@ -169,6 +171,7 @@ export async function getAllVendors(): Promise<{ id: vendors.id, vendorName: vendors.vendorName, vendorCode: vendors.vendorCode, + taxId: vendors.taxId, status: vendors.status, }) .from(vendors) @@ -209,6 +212,7 @@ export async function getVendorById(vendorId: number): Promise<VendorSearchItem id: vendors.id, vendorName: vendors.vendorName, vendorCode: vendors.vendorCode, + taxId: vendors.taxId, status: vendors.status, }) .from(vendors) |
