협상카드 - 목록/조회를 등록자 개인 → 회사(company.users 조인) 단위로 확장: 같은 회사 유저 카드 + 공용(user_id NULL) - 공용 카드는 기본 제공 자산 → 수정·삭제 불가(OWNER 포함), is_shared=True API 등록 거부 - 개인 카드 변경은 본인∪최고관리자만, 프론트 버튼 disable+툴팁 - 공개범위 셀렉터·엑셀 공개범위 컬럼·CardTable 전체 배지 제거(항상 개인 등록) - test_card.py 5케이스 재작성 견적 목표가 산정내역 - get_setting_rates: 단일 컬럼 select 결과를 이중 인덱싱해 항상 예외 → 마진율이 늘 0으로 적용되던 버그 수정 - get_target_breakdown: 채택 후보 판정을 '현재 최소값' → '저장 목표가와 값 일치'로 변경(산정 후 상품가 변동에도 초록 체크 유지) - 견적상세 협상카드 탭 세션ID 컬럼 제거 견적생성 모달 - 목표가 미리보기·마감기한 기본값(+1h)·미래 검증, combobox 팝오버 scrollIntoView, maskPrices 비율(%) 마스킹 Little-Helped: Stupid Claude
187 lines
6.5 KiB
TypeScript
187 lines
6.5 KiB
TypeScript
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
|
import { Search, Check, Loader2, ChevronsUpDown } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { Input } from './input';
|
|
import { Typography } from './typography';
|
|
|
|
// 서버검색 콤보박스 — 목록(options)은 부모가 쿼리에 맞춰 조회해 넘기고, 검색어 디바운스는 이 컴포넌트가 처리한다.
|
|
// variant: 'field'(트리거+팝오버, 단일선택 폼용) / 'inline'(검색창+리스트 상시노출, 다중 체크리스트용).
|
|
export type ComboOption = {
|
|
id: string;
|
|
label: string; // 검색결과에 없을 때 선택 표시용 텍스트
|
|
node?: ReactNode; // 커스텀 행(미지정 시 label 렌더)
|
|
disabled?: boolean;
|
|
};
|
|
|
|
type ComboboxProps = {
|
|
options: ComboOption[];
|
|
onQueryChange: (q: string) => void; // 내부 디바운스 후 호출
|
|
loading?: boolean;
|
|
placeholder?: string;
|
|
searchPlaceholder?: string;
|
|
emptyText?: string;
|
|
debounceMs?: number;
|
|
id?: string;
|
|
className?: string;
|
|
maxListHeight?: string; // tailwind, default max-h-56
|
|
variant?: 'field' | 'inline';
|
|
multiple?: boolean;
|
|
// single
|
|
value?: string;
|
|
selectedLabel?: ReactNode;
|
|
onSelect?: (opt: ComboOption) => void;
|
|
// multi
|
|
values?: string[];
|
|
onToggle?: (opt: ComboOption) => void;
|
|
};
|
|
|
|
export function Combobox({
|
|
options,
|
|
onQueryChange,
|
|
loading,
|
|
placeholder = '선택...',
|
|
searchPlaceholder = '검색...',
|
|
emptyText = '결과가 없습니다',
|
|
debounceMs = 300,
|
|
id,
|
|
className,
|
|
maxListHeight = 'max-h-56',
|
|
variant = 'field',
|
|
multiple = false,
|
|
value,
|
|
selectedLabel,
|
|
onSelect,
|
|
values = [],
|
|
onToggle,
|
|
}: ComboboxProps) {
|
|
const [text, setText] = useState('');
|
|
const [open, setOpen] = useState(false);
|
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
const popoverRef = useRef<HTMLDivElement>(null);
|
|
|
|
// 검색어 디바운스 → onQueryChange. 콜백은 ref로 잡아 text 변화에만 반응.
|
|
const qcRef = useRef(onQueryChange);
|
|
qcRef.current = onQueryChange;
|
|
useEffect(() => {
|
|
const t = setTimeout(() => qcRef.current(text.trim()), debounceMs);
|
|
return () => clearTimeout(t);
|
|
}, [text, debounceMs]);
|
|
|
|
// field 팝오버 바깥 클릭 시 닫기.
|
|
useEffect(() => {
|
|
if (variant === 'inline') return;
|
|
const onDoc = (e: MouseEvent) => {
|
|
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
|
|
};
|
|
document.addEventListener('mousedown', onDoc);
|
|
return () => document.removeEventListener('mousedown', onDoc);
|
|
}, [variant]);
|
|
|
|
// field 팝오버가 열리면 스크롤 컨테이너(모달 등) 안에서 잘리지 않게 뷰로 끌어온다.
|
|
// 로딩→목록 로 높이가 커질 때도 다시 당겨온다(이미 보이면 scrollIntoView 는 no-op).
|
|
useEffect(() => {
|
|
if (variant === 'inline' || !open) return;
|
|
popoverRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
}, [open, variant, loading, options.length]);
|
|
|
|
const isSelected = (oid: string) => (multiple ? values.includes(oid) : value === oid);
|
|
|
|
const handlePick = (opt: ComboOption) => {
|
|
if (opt.disabled) return;
|
|
if (multiple) onToggle?.(opt);
|
|
else {
|
|
onSelect?.(opt);
|
|
setOpen(false);
|
|
}
|
|
};
|
|
|
|
const searchInput = (
|
|
<div className="relative">
|
|
<Search size={13} className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
id={id}
|
|
type="text"
|
|
value={text}
|
|
onChange={(e) => setText(e.target.value)}
|
|
placeholder={searchPlaceholder}
|
|
className="pl-7 text-xs"
|
|
autoComplete="off"
|
|
/>
|
|
{loading && <Loader2 size={13} className="absolute right-2 top-1/2 -translate-y-1/2 animate-spin text-muted-foreground" />}
|
|
</div>
|
|
);
|
|
|
|
const list = (
|
|
<div className={cn('border border-border rounded bg-background overflow-y-auto', maxListHeight)}>
|
|
{loading && options.length === 0 ? (
|
|
<div className="flex items-center gap-2 p-3 text-muted-foreground text-[11px]">
|
|
<Loader2 size={13} className="animate-spin" />
|
|
<Typography as="span" variant="small" className="text-[11px]">불러오는 중…</Typography>
|
|
</div>
|
|
) : options.length === 0 ? (
|
|
<Typography as="p" variant="small" className="p-3 text-muted-foreground text-[11px]">{emptyText}</Typography>
|
|
) : (
|
|
options.map((opt) => {
|
|
const sel = isSelected(opt.id);
|
|
return (
|
|
<button
|
|
key={opt.id}
|
|
type="button"
|
|
disabled={opt.disabled}
|
|
onClick={() => handlePick(opt)}
|
|
className={cn(
|
|
'w-full flex items-center justify-between gap-2 p-2 text-left border-b border-border last:border-b-0 hover:bg-muted/40 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer transition-colors',
|
|
sel && 'bg-primary/5',
|
|
)}
|
|
>
|
|
<span className="min-w-0 flex-1">
|
|
{opt.node ?? <Typography as="span" variant="small">{opt.label}</Typography>}
|
|
</span>
|
|
{multiple ? (
|
|
<input type="checkbox" checked={sel} readOnly className="accent-primary h-3.5 w-3.5 shrink-0" />
|
|
) : (
|
|
sel && <Check size={14} className="text-primary shrink-0" />
|
|
)}
|
|
</button>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
if (variant === 'inline') {
|
|
return (
|
|
<div ref={rootRef} className={cn('space-y-1.5', className)}>
|
|
{searchInput}
|
|
{list}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// field: 트리거(선택 요약) + 팝오버(검색창 + 리스트)
|
|
const hasSelection = multiple ? values.length > 0 : !!value;
|
|
const summary = multiple
|
|
? (values.length ? `${values.length}개 선택됨` : placeholder)
|
|
: (value ? selectedLabel ?? '선택됨' : placeholder);
|
|
|
|
return (
|
|
<div ref={rootRef} className={cn('relative', className)}>
|
|
<button
|
|
type="button"
|
|
id={id}
|
|
onClick={() => setOpen((o) => !o)}
|
|
className="w-full flex items-center justify-between gap-2 p-2 bg-background border border-border rounded text-xs text-left hover:bg-muted/20 cursor-pointer"
|
|
>
|
|
<span className={cn('truncate', !hasSelection && 'text-muted-foreground')}>{summary}</span>
|
|
<ChevronsUpDown size={14} className="text-muted-foreground shrink-0" />
|
|
</button>
|
|
{open && (
|
|
<div ref={popoverRef} className="absolute z-50 mt-1 w-full rounded border border-border bg-card shadow-lg p-1.5 space-y-1.5">
|
|
{searchInput}
|
|
{list}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|