feat(ui): 자유 텍스트 필드 자동완성(TextSuggest) — 컨설팅종류·국가·역할
All checks were successful
build-and-push / build (push) Successful in 31s
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:
parent
2013152fa7
commit
3a260c207b
@ -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
29
src/lib/suggest.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user