o2o-negosium-original/negodata/front/src/components/ui/combobox.tsx
Mina Choi 1b9ca062f3 [feat] negodata: 협상카드 회사 스코프·공용 읽기전용 + 견적 목표가 산정내역 수정
협상카드
- 목록/조회를 등록자 개인 → 회사(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
2026-07-09 17:04:40 +09:00

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>
);
}