feat(ui): 자유 텍스트 필드 자동완성(TextSuggest) — 컨설팅종류·국가·역할
All checks were successful
build-and-push / build (push) Successful in 31s

- TextSuggest(input+datalist): 직접 입력 + 추천 드롭다운
- 컨설팅 종류/제출 국가: 큐레이션 + 기존 프로젝트 입력값 병합 추천(useFieldSuggestions)
- 작업자 역할: 작업자/PM/검토자/디자이너/자문/번역 추천
- 프로젝트 생성·수정 모달, 작업자 추가 폼에 적용

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
theorose49 2026-06-30 08:50:57 +09:00
parent 2013152fa7
commit 3a260c207b
4 changed files with 63 additions and 8 deletions

View File

@ -1,7 +1,7 @@
import type { import type {
ButtonHTMLAttributes, InputHTMLAttributes, ReactNode, SelectHTMLAttributes, ButtonHTMLAttributes, InputHTMLAttributes, ReactNode, SelectHTMLAttributes,
} from "react"; } from "react";
import { useEffect } from "react"; import { useEffect, useId } from "react";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { classNames } from "@/lib/format"; import { classNames } from "@/lib/format";
@ -101,6 +101,28 @@ export function Select(props: SelectHTMLAttributes<HTMLSelectElement>) {
return <select {...props} className={classNames("form-select", props.className)} />; return <select {...props} className={classNames("form-select", props.className)} />;
} }
// 자유 입력 + 추천 자동완성(datalist). 직접 타이핑도 되고 드롭다운에서 고를 수도 있다.
export function TextSuggest({
value, onChange, options, className, ...rest
}: { value: string; onChange: (v: string) => void; options: string[] }
& Omit<InputHTMLAttributes<HTMLInputElement>, "value" | "onChange">) {
const id = useId();
return (
<>
<input
{...rest}
list={id}
value={value}
onChange={(e) => onChange(e.target.value)}
className={classNames("form-input", className)}
/>
<datalist id={id}>
{options.map((o) => <option key={o} value={o} />)}
</datalist>
</>
);
}
export function Textarea(props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) { export function Textarea(props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
return ( return (
<textarea <textarea

29
src/lib/suggest.ts Normal file
View File

@ -0,0 +1,29 @@
import { useQuery } from "@tanstack/react-query";
import { getProjects } from "@/lib/api";
// 자유 텍스트 입력의 추천값(자동완성). 사용자가 매번 전부 타이핑하지 않도록
// 자주 쓰는 값을 큐레이션해 두고, 기존 프로젝트에 입력된 값과 합쳐 제안한다.
export const COUNTRY_SUGGESTIONS = [
"미국(FDA)", "유럽(CE-MDR)", "유럽(CE-IVDR)", "한국(MFDS)", "일본(PMDA)",
"중국(NMPA)", "캐나다(Health Canada)", "호주(TGA)", "영국(MHRA)", "브라질(ANVISA)",
];
export const CONSULTING_SUGGESTIONS = [
"510(k)", "De Novo", "PMA", "CE-MDR", "CE-IVDR", "MFDS 인증",
"기술문서(STED)", "임상평가(CER)", "품질시스템(QMS)", "해외인증 대행",
];
export const ROLE_SUGGESTIONS = ["작업자", "PM", "검토자", "디자이너", "자문", "번역"];
const merge = (existing: (string | undefined)[], curated: string[]) =>
Array.from(new Set([...existing.filter((x): x is string => !!x), ...curated]));
// 기존 프로젝트에서 쓰인 값을 우선 노출하고, 큐레이션 값으로 보강.
export function useFieldSuggestions() {
const q = useQuery({ queryKey: ["projects", "all"], queryFn: () => getProjects() });
const data = q.data ?? [];
return {
consultingTypes: merge(data.map((p) => p.consultingType), CONSULTING_SUGGESTIONS),
countries: merge(data.map((p) => p.country), COUNTRY_SUGGESTIONS),
};
}

View File

@ -13,12 +13,13 @@ import {
} from "@/lib/api"; } from "@/lib/api";
import { useAuth } from "@/context/Auth"; import { useAuth } from "@/context/Auth";
import { import {
Card, CardHeader, Button, Badge, Tabs, Modal, Field, Input, Select, Textarea, Card, CardHeader, Button, Badge, Tabs, Modal, Field, Input, Select, Textarea, TextSuggest,
PageHeader, EmptyState, LoadingState, PageHeader, EmptyState, LoadingState,
} from "@/components/ui"; } from "@/components/ui";
import { Gantt } from "@/components/Gantt"; import { Gantt } from "@/components/Gantt";
import { Kanban } from "@/components/Kanban"; import { Kanban } from "@/components/Kanban";
import { MemberSelect } from "@/components/MemberSelect"; import { MemberSelect } from "@/components/MemberSelect";
import { useFieldSuggestions, ROLE_SUGGESTIONS } from "@/lib/suggest";
import { import {
formatDate, formatWon, formatSize, PROJECT_STATUS_META, LANE_LABELS, classNames, formatDate, formatWon, formatSize, PROJECT_STATUS_META, LANE_LABELS, classNames,
} from "@/lib/format"; } from "@/lib/format";
@ -138,7 +139,7 @@ function Members({ projectId, isAdmin }: { projectId: string; isAdmin: boolean }
{isAdmin && ( {isAdmin && (
<div className="flex flex-wrap items-end gap-2 mt-4 p-3 bg-canvas rounded-control"> <div className="flex flex-wrap items-end gap-2 mt-4 p-3 bg-canvas rounded-control">
<Field label="작업자"><div className="w-60"><MemberSelect value={email} onChange={setEmail} disabled={!!editId} placeholder="구성원 검색·선택" /></div></Field> <Field label="작업자"><div className="w-60"><MemberSelect value={email} onChange={setEmail} disabled={!!editId} placeholder="구성원 검색·선택" /></div></Field>
<Field label="역할"><Input value={role} onChange={(e) => setRole(e.target.value)} className="w-32" /></Field> <Field label="역할"><TextSuggest value={role} onChange={setRole} options={ROLE_SUGGESTIONS} className="w-32" /></Field>
<Field label="기여도 %"><Input type="number" value={portion} onChange={(e) => setPortion(e.target.value)} className="w-24" /></Field> <Field label="기여도 %"><Input type="number" value={portion} onChange={(e) => setPortion(e.target.value)} className="w-24" /></Field>
<Button icon={editId ? undefined : <Plus size={15} />} disabled={!email || save.isPending} onClick={() => save.mutate()}>{editId ? "수정" : "추가"}</Button> <Button icon={editId ? undefined : <Plus size={15} />} disabled={!email || save.isPending} onClick={() => save.mutate()}>{editId ? "수정" : "추가"}</Button>
{editId && <Button variant="ghost" onClick={reset}></Button>} {editId && <Button variant="ghost" onClick={reset}></Button>}
@ -450,6 +451,7 @@ function Payments({ projectId, payments, onChange }: { projectId: string; paymen
/* ---- edit project basic info (admin) ---- */ /* ---- edit project basic info (admin) ---- */
function EditProjectModal({ project, onClose }: { project: Project; onClose: () => void }) { function EditProjectModal({ project, onClose }: { project: Project; onClose: () => void }) {
const qc = useQueryClient(); const qc = useQueryClient();
const { consultingTypes, countries } = useFieldSuggestions();
const [f, setF] = useState({ const [f, setF] = useState({
name: project.name, consultingType: project.consultingType, country: project.country, name: project.name, consultingType: project.consultingType, country: project.country,
scopeText: project.scopeText, scopeGraphic: project.scopeGraphic, pmEmail: project.pmEmail, scopeText: project.scopeText, scopeGraphic: project.scopeGraphic, pmEmail: project.pmEmail,
@ -465,8 +467,8 @@ function EditProjectModal({ project, onClose }: { project: Project; onClose: ()
<div className="space-y-4"> <div className="space-y-4">
<Field label="프로젝트명"><Input value={f.name} onChange={(e) => setF({ ...f, name: e.target.value })} /></Field> <Field label="프로젝트명"><Input value={f.name} onChange={(e) => setF({ ...f, name: e.target.value })} /></Field>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<Field label="컨설팅 종류"><Input value={f.consultingType} onChange={(e) => setF({ ...f, consultingType: e.target.value })} /></Field> <Field label="컨설팅 종류"><TextSuggest value={f.consultingType} onChange={(v) => setF({ ...f, consultingType: v })} options={consultingTypes} /></Field>
<Field label="제출 국가"><Input value={f.country} onChange={(e) => setF({ ...f, country: e.target.value })} /></Field> <Field label="제출 국가"><TextSuggest value={f.country} onChange={(v) => setF({ ...f, country: v })} options={countries} /></Field>
<Field label="상태"><Select value={f.status} onChange={(e) => setF({ ...f, status: e.target.value as Project["status"] })}> <Field label="상태"><Select value={f.status} onChange={(e) => setF({ ...f, status: e.target.value as Project["status"] })}>
{Object.entries(PROJECT_STATUS_META).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)} {Object.entries(PROJECT_STATUS_META).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</Select></Field> </Select></Field>

View File

@ -7,11 +7,12 @@ import {
createCompany, createProduct, createVersion, createCompany, createProduct, createVersion,
} from "@/lib/api"; } from "@/lib/api";
import { import {
Card, Button, Badge, PageHeader, Modal, Field, Input, Select, Textarea, Card, Button, Badge, PageHeader, Modal, Field, Input, Select, Textarea, TextSuggest,
EmptyState, LoadingState, EmptyState, LoadingState,
} from "@/components/ui"; } from "@/components/ui";
import { useProjectFilters } from "@/components/ProjectFilters"; import { useProjectFilters } from "@/components/ProjectFilters";
import { MemberSelect } from "@/components/MemberSelect"; import { MemberSelect } from "@/components/MemberSelect";
import { useFieldSuggestions } from "@/lib/suggest";
import { formatDate, PROJECT_STATUS_META } from "@/lib/format"; import { formatDate, PROJECT_STATUS_META } from "@/lib/format";
// 나의 업무 > 프로젝트: 본인이 참여한 프로젝트만 보는 read-only 뷰 (관리자·유저 동일). // 나의 업무 > 프로젝트: 본인이 참여한 프로젝트만 보는 read-only 뷰 (관리자·유저 동일).
@ -78,6 +79,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
const [newProduct, setNewProduct] = useState(""); const [newProduct, setNewProduct] = useState("");
const [newVersion, setNewVersion] = useState(""); const [newVersion, setNewVersion] = useState("");
const { consultingTypes, countries } = useFieldSuggestions();
const comp = compQ.data?.find((c) => c.id === companyId); const comp = compQ.data?.find((c) => c.id === companyId);
const prod = prodQ.data?.find((p) => p.id === productId); const prod = prodQ.data?.find((p) => p.id === productId);
const ver = verQ.data?.find((v) => v.id === versionId); const ver = verQ.data?.find((v) => v.id === versionId);
@ -133,8 +135,8 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Field label="컨설팅 종류"><Input value={form.consultingType} onChange={(e) => setForm({ ...form, consultingType: e.target.value })} placeholder="예: 510(k)" /></Field> <Field label="컨설팅 종류"><TextSuggest value={form.consultingType} onChange={(v) => setForm({ ...form, consultingType: v })} options={consultingTypes} placeholder="예: 510(k)" /></Field>
<Field label="제출 국가"><Input value={form.country} onChange={(e) => setForm({ ...form, country: e.target.value })} placeholder="예: 미국(FDA)" /></Field> <Field label="제출 국가"><TextSuggest value={form.country} onChange={(v) => setForm({ ...form, country: v })} options={countries} placeholder="예: 미국(FDA)" /></Field>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Field label="계약 범위 — 글" hint="글 작업 범위를 자유롭게 기술"><Textarea value={form.scopeText} onChange={(e) => setForm({ ...form, scopeText: e.target.value })} placeholder="예: 기술문서·라벨링 작성/검토" /></Field> <Field label="계약 범위 — 글" hint="글 작업 범위를 자유롭게 기술"><Textarea value={form.scopeText} onChange={(e) => setForm({ ...form, scopeText: e.target.value })} placeholder="예: 기술문서·라벨링 작성/검토" /></Field>