feat: [quotes] 견적 등록 개선

- 수주처 선택 시 담당자/연락처 자동 입력
- 현장명 직접 입력 가능 (creatable 옵션)
- SearchableSelect에 creatable 기능 추가
This commit is contained in:
2026-03-14 08:29:30 +09:00
parent 22a398024c
commit 1280c8d61a
2 changed files with 43 additions and 5 deletions

View File

@@ -775,10 +775,13 @@ export function QuoteRegistration({
options={clients.map((c) => ({ value: c.id, label: c.vendorName }))}
value={formData.clientId}
onChange={(value, option) => {
const selectedClient = clients.find((c) => c.id === value);
setFormData((prev) => ({
...prev,
clientId: value,
clientName: option.label,
manager: selectedClient?.managerName || selectedClient?.representativeName || prev.manager,
contact: selectedClient?.managerPhone || selectedClient?.mobile || selectedClient?.phone || prev.contact,
}));
}}
placeholder={isLoadingClients ? "로딩 중..." : "수주처를 선택하세요"}
@@ -794,10 +797,12 @@ export function QuoteRegistration({
options={siteNames.map((name) => ({ value: name, label: name }))}
value={formData.siteName}
onChange={(value) => handleFieldChange("siteName", value)}
placeholder="현장명을 선택하세요"
searchPlaceholder="현장명 검색..."
placeholder="현장명을 선택 또는 입력하세요"
searchPlaceholder="현장명 검색 또는 입력..."
emptyText="현장명이 없습니다"
disabled={isViewMode}
creatable
creatableText="직접 입력"
/>
</div>
</div>

View File

@@ -11,7 +11,7 @@
*/
import * as React from 'react';
import { Check, ChevronsUpDown, Loader2 } from 'lucide-react';
import { Check, ChevronsUpDown, Loader2, Plus } from 'lucide-react';
import { cn } from './utils';
import { Button } from './button';
import { Popover, PopoverContent, PopoverTrigger } from './popover';
@@ -45,6 +45,10 @@ interface SearchableSelectProps {
isLoading?: boolean;
/** 최소 입력 안내 메시지 */
minInputText?: string;
/** 직접 입력 허용 (목록에 없는 값 생성 가능) */
creatable?: boolean;
/** 직접 입력 시 안내 텍스트 */
creatableText?: string;
}
/**
@@ -78,12 +82,15 @@ export function SearchableSelect({
onSearch,
isLoading = false,
minInputText = '한글 1자 또는 영문 2자 이상 입력하세요',
creatable = false,
creatableText = '직접 입력',
}: SearchableSelectProps) {
const [open, setOpen] = React.useState(false);
const [searchQuery, setSearchQuery] = React.useState('');
const debounceRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const selectedOption = options.find((opt) => opt.value === value);
const selectedOption = options.find((opt) => opt.value === value)
|| (creatable && value ? { value, label: value } : undefined);
const isServerSearch = !!onSearch;
const queryValid = isValidSearchQuery(searchQuery);
@@ -147,6 +154,20 @@ export function SearchableSelect({
// 서버 검색 모드에서 검색어 미입력/미충족 시 옵션 숨김
const displayOptions = isServerSearch && !queryValid ? [] : options;
// creatable: 검색어가 기존 옵션에 정확히 매칭되지 않으면 생성 옵션 표시
const showCreateOption =
creatable &&
searchQuery.trim() &&
!options.some((opt) => opt.label === searchQuery.trim());
const handleCreate = () => {
const trimmed = searchQuery.trim();
const newOption: SearchableSelectOption = { value: trimmed, label: trimmed };
onChange(trimmed, newOption);
setOpen(false);
setSearchQuery('');
};
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
@@ -187,7 +208,7 @@ export function SearchableSelect({
</div>
) : (
<>
<CommandEmpty>{getEmptyMessage()}</CommandEmpty>
{!showCreateOption && <CommandEmpty>{getEmptyMessage()}</CommandEmpty>}
<CommandGroup>
{displayOptions.map((option) => (
<CommandItem
@@ -213,6 +234,18 @@ export function SearchableSelect({
</CommandItem>
))}
</CommandGroup>
{showCreateOption && (
<CommandGroup>
<CommandItem
value={`__create__${searchQuery.trim()}`}
onSelect={handleCreate}
className="cursor-pointer"
>
<Plus className="mr-2 h-4 w-4" />
<span>{creatableText}: <strong>{searchQuery.trim()}</strong></span>
</CommandItem>
</CommandGroup>
)}
</>
)}
</CommandList>