feat(perm): 업체담당자 폼/작업 삭제를 일반 구성원에게 개방 + 개요 주의사항 인라인 편집
All checks were successful
build-and-push / build (push) Successful in 31s

- 업체 담당자 추가/수정/삭제 폼을 모든 구성원에게 표시
- 작업 상세 '삭제'를 모든 구성원에게 노출
- 개요 탭 주의사항을 인라인 편집(patchProjectNotes, 구성원 누구나)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
theorose49 2026-06-30 13:50:13 +09:00
parent 50fb1a1253
commit 539ec2eb69
2 changed files with 48 additions and 30 deletions

View File

@ -133,6 +133,11 @@ export const getContacts = (id: string) =>
export const upsertContact = (id: string, b: Partial<ClientContact>) => export const upsertContact = (id: string, b: Partial<ClientContact>) =>
api.post<ClientContact>(`/projects/${id}/contacts`, b).then((r) => r.data); api.post<ClientContact>(`/projects/${id}/contacts`, b).then((r) => r.data);
export const deleteContact = (cId: string) => api.delete(`/contacts/${cId}`).then((r) => r.data); export const deleteContact = (cId: string) => api.delete(`/contacts/${cId}`).then((r) => r.data);
// 비민감 정보(주의사항·계약범위)는 프로젝트 구성원 누구나 편집
export const patchProjectNotes = (
id: string,
b: { cautions?: string; scopeText?: string; scopeGraphic?: string }
) => api.patch<Project>(`/projects/${id}/notes`, b).then((r) => r.data);
export const getTasks = (id: string) => export const getTasks = (id: string) =>
api.get<ProjectTask[]>(`/projects/${id}/tasks`).then((r) => r.data); api.get<ProjectTask[]>(`/projects/${id}/tasks`).then((r) => r.data);

View File

@ -10,7 +10,7 @@ import {
getPayments, upsertProjectMember, deleteProjectMember, createTask, updateTask, deleteTask, getPayments, upsertProjectMember, deleteProjectMember, createTask, updateTask, deleteTask,
getTaskComments, createTaskComment, deleteTaskComment, getTaskComments, createTaskComment, deleteTaskComment,
getProjectMails, putMailNote, syncProjectMails, hideMail, getProjectMails, putMailNote, syncProjectMails, hideMail,
upsertContact, deleteContact, putContract, uploadContractFile, getFileDownloadUrl, upsertContact, deleteContact, patchProjectNotes, putContract, uploadContractFile, getFileDownloadUrl,
deleteContractFile, createPayment, updatePayment, deletePayment, recomputeProject, deleteContractFile, createPayment, updatePayment, deletePayment, recomputeProject,
updateProject, updateProject,
} from "@/lib/api"; } from "@/lib/api";
@ -67,7 +67,7 @@ export function ProjectDetailPage() {
{tab === "members" && <Members projectId={id} isAdmin={isAdmin} />} {tab === "members" && <Members projectId={id} isAdmin={isAdmin} />}
{tab === "timeline" && <Timeline projectId={id} isAdmin={isAdmin} />} {tab === "timeline" && <Timeline projectId={id} isAdmin={isAdmin} />}
{tab === "mail" && <MailTab projectId={id} />} {tab === "mail" && <MailTab projectId={id} />}
{tab === "contacts" && <Contacts projectId={id} isAdmin={isAdmin} />} {tab === "contacts" && <Contacts projectId={id} />}
{tab === "contract" && isAdmin && <ContractTab projectId={id} />} {tab === "contract" && isAdmin && <ContractTab projectId={id} />}
</div> </div>
</Card> </Card>
@ -86,7 +86,15 @@ function Row({ label, value }: { label: string; value: React.ReactNode }) {
function Overview({ project: p }: { project: Project }) { function Overview({ project: p }: { project: Project }) {
const { nameOf } = useDirectory(); const { nameOf } = useDirectory();
const qc = useQueryClient();
const [cautions, setCautions] = useState(p.cautions);
useEffect(() => { setCautions(p.cautions); }, [p.id]);
const saveCautions = useMutation({
mutationFn: () => patchProjectNotes(p.id, { cautions }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["project", p.id] }),
});
return ( return (
<div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-x-10"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-x-10">
<div> <div>
<Row label="컨설팅 종류" value={p.consultingType} /> <Row label="컨설팅 종류" value={p.consultingType} />
@ -99,7 +107,14 @@ function Overview({ project: p }: { project: Project }) {
<Row label="업체 / 제품" value={`${p.companyName} · ${p.productName}`} /> <Row label="업체 / 제품" value={`${p.companyName} · ${p.productName}`} />
<Row label="버전" value={p.versionName} /> <Row label="버전" value={p.versionName} />
<Row label="기간" value={`${formatDate(p.startDate)} ~ ${formatDate(p.dueDate)}`} /> <Row label="기간" value={`${formatDate(p.startDate)} ~ ${formatDate(p.dueDate)}`} />
<Row label="주의사항" value={p.cautions} /> </div>
</div>
{/* 주의사항: 프로젝트 구성원 누구나 편집 (비민감 정보) */}
<div className="mt-5">
<span className="form-label"> <span className="text-ink-muted font-normal">· </span></span>
<Textarea value={cautions} onChange={(e) => setCautions(e.target.value)}
onBlur={() => { if (cautions !== p.cautions) saveCautions.mutate(); }}
placeholder="프로젝트 진행 시 주의할 점·공유사항을 적어주세요." style={{ minHeight: 88 }} />
</div> </div>
</div> </div>
); );
@ -338,7 +353,7 @@ function TaskDetailModal({ projectId, task, isAdmin, onClose }: { projectId: str
<span className="text-ink-muted font-normal"> </span> <span className="text-ink-muted font-normal"> </span>
</span>} </span>}
footer={<> footer={<>
{isAdmin && <Button variant="danger" onClick={() => { if (confirm("이 작업을 삭제하시겠습니까?")) del.mutate(); }} className="mr-auto"></Button>} <Button variant="danger" onClick={() => { if (confirm("이 작업을 삭제하시겠습니까?")) del.mutate(); }} className="mr-auto"></Button>
<Button variant="secondary" onClick={onClose}></Button> <Button variant="secondary" onClick={onClose}></Button>
</>}> </>}>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
@ -441,8 +456,8 @@ function Prop({ label, children }: { label: string; children: React.ReactNode })
); );
} }
/* ---- contacts ---- */ /* ---- contacts (업체 담당자 — 프로젝트 구성원 누구나 CRUD) ---- */
function Contacts({ projectId, isAdmin }: { projectId: string; isAdmin: boolean }) { function Contacts({ projectId }: { projectId: string }) {
const qc = useQueryClient(); const qc = useQueryClient();
const q = useQuery({ queryKey: ["contacts", projectId], queryFn: () => getContacts(projectId) }); const q = useQuery({ queryKey: ["contacts", projectId], queryFn: () => getContacts(projectId) });
const empty = { id: "", name: "", title: "", phone: "", email: "" }; const empty = { id: "", name: "", title: "", phone: "", email: "" };
@ -456,19 +471,18 @@ function Contacts({ projectId, isAdmin }: { projectId: string; isAdmin: boolean
return ( return (
<div> <div>
<table className="dense-table"> <table className="dense-table">
<thead><tr><th></th><th></th><th></th><th></th>{isAdmin && <th></th>}</tr></thead> <thead><tr><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody> <tbody>
{(q.data ?? []).map((c) => ( {(q.data ?? []).map((c) => (
<tr key={c.id}><td>{c.name}</td><td>{c.title}</td><td className="tabular">{c.phone}</td><td>{c.email}</td> <tr key={c.id}><td>{c.name}</td><td>{c.title}</td><td className="tabular">{c.phone}</td><td>{c.email}</td>
{isAdmin && <td className="text-right whitespace-nowrap"> <td className="text-right whitespace-nowrap">
<button className="text-ink-muted hover:text-ink mr-2" onClick={() => setForm({ id: c.id, name: c.name, title: c.title, phone: c.phone, email: c.email })}><Pencil size={15} /></button> <button className="text-ink-muted hover:text-ink mr-2" onClick={() => setForm({ id: c.id, name: c.name, title: c.title, phone: c.phone, email: c.email })}><Pencil size={15} /></button>
<button className="text-ink-muted hover:text-money-out" onClick={() => del.mutate(c.id)}><Trash2 size={15} /></button> <button className="text-ink-muted hover:text-money-out" onClick={() => del.mutate(c.id)}><Trash2 size={15} /></button>
</td>}</tr> </td></tr>
))} ))}
{(q.data?.length ?? 0) === 0 && <tr><td colSpan={5} className="text-center text-ink-muted py-6"> </td></tr>} {(q.data?.length ?? 0) === 0 && <tr><td colSpan={5} className="text-center text-ink-muted py-6"> </td></tr>}
</tbody> </tbody>
</table> </table>
{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="이름"><Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className="w-32" /></Field> <Field label="이름"><Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className="w-32" /></Field>
<Field label="직무"><Input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} className="w-32" /></Field> <Field label="직무"><Input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} className="w-32" /></Field>
@ -477,7 +491,6 @@ function Contacts({ projectId, isAdmin }: { projectId: string; isAdmin: boolean
<Button icon={form.id ? undefined : <Plus size={15} />} disabled={!form.name || save.isPending} onClick={() => save.mutate()}>{form.id ? "수정" : "추가"}</Button> <Button icon={form.id ? undefined : <Plus size={15} />} disabled={!form.name || save.isPending} onClick={() => save.mutate()}>{form.id ? "수정" : "추가"}</Button>
{form.id && <Button variant="ghost" onClick={() => setForm(empty)}></Button>} {form.id && <Button variant="ghost" onClick={() => setForm(empty)}></Button>}
</div> </div>
)}
</div> </div>
); );
} }