feat(perm): 업체담당자 폼/작업 삭제를 일반 구성원에게 개방 + 개요 주의사항 인라인 편집
All checks were successful
build-and-push / build (push) Successful in 31s
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:
parent
50fb1a1253
commit
539ec2eb69
@ -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);
|
||||||
|
|||||||
@ -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,20 +86,35 @@ 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 className="grid grid-cols-1 lg:grid-cols-2 gap-x-10">
|
<div>
|
||||||
<div>
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-x-10">
|
||||||
<Row label="컨설팅 종류" value={p.consultingType} />
|
<div>
|
||||||
<Row label="제출 국가" value={p.country} />
|
<Row label="컨설팅 종류" value={p.consultingType} />
|
||||||
<Row label="계약 범위(글)" value={p.scopeText} />
|
<Row label="제출 국가" value={p.country} />
|
||||||
<Row label="계약 범위(그림)" value={p.scopeGraphic} />
|
<Row label="계약 범위(글)" value={p.scopeText} />
|
||||||
<Row label="PM" value={p.pmEmail ? nameOf(p.pmEmail) : ""} />
|
<Row label="계약 범위(그림)" value={p.scopeGraphic} />
|
||||||
|
<Row label="PM" value={p.pmEmail ? nameOf(p.pmEmail) : ""} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Row label="업체 / 제품" value={`${p.companyName} · ${p.productName}`} />
|
||||||
|
<Row label="버전" value={p.versionName} />
|
||||||
|
<Row label="기간" value={`${formatDate(p.startDate)} ~ ${formatDate(p.dueDate)}`} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{/* 주의사항: 프로젝트 구성원 누구나 편집 (비민감 정보) */}
|
||||||
<Row label="업체 / 제품" value={`${p.companyName} · ${p.productName}`} />
|
<div className="mt-5">
|
||||||
<Row label="버전" value={p.versionName} />
|
<span className="form-label">주의사항 <span className="text-ink-muted font-normal">· 구성원 누구나 편집</span></span>
|
||||||
<Row label="기간" value={`${formatDate(p.startDate)} ~ ${formatDate(p.dueDate)}`} />
|
<Textarea value={cautions} onChange={(e) => setCautions(e.target.value)}
|
||||||
<Row label="주의사항" value={p.cautions} />
|
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,28 +471,26 @@ 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>
|
<Field label="연락처"><Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} className="w-36" /></Field>
|
||||||
<Field label="연락처"><Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} className="w-36" /></Field>
|
<Field label="이메일"><Input value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} className="w-44" /></Field>
|
||||||
<Field label="이메일"><Input value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} className="w-44" /></Field>
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user