All checks were successful
build-and-push / build (push) Successful in 31s
- 브랜드 포인트컬러 #03143F로 팔레트 전면 재설정, 회사 로고 흰 wrap 제거+크롭, 로고 블렌딩 - 사이드바 접기/펼치기(localStorage), 로고 아래 근무상태 드롭다운(출근/퇴근/휴식/미팅/이동) - 대시보드 역할 무관 동일(회계/전사 위젯 제거) - 유저 근무화면 단순화(남은연차 소수점·기록·휴가/공가만), 관리자 근무관리(/admin/attendance) - 프로젝트: 관리자 전용 관리창(/admin/projects), 나의 업무는 본인 참여분 read-only - 메일함(/inbox)+탑바 벨(미확인), 프로필(부서·연락처·사진 업로드) - 인센티브 유저: BE/non-BE·환율 숨김, 할당량 세그먼트 게이지(지급완료→반영완료→반영중→예정, 할당량 화살표) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
126 lines
8.7 KiB
TypeScript
126 lines
8.7 KiB
TypeScript
import { useState } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { Plus, Trash2, Users } from "lucide-react";
|
|
import {
|
|
getMembers, createMember, updateMember, deleteMember, getDepartments, createDepartment,
|
|
} from "@/lib/api";
|
|
import {
|
|
Card, Button, Badge, PageHeader, Modal, Drawer, Field, Input, Select,
|
|
Tabs, EmptyState, LoadingState,
|
|
} from "@/components/ui";
|
|
import { formatDate } from "@/lib/format";
|
|
import type { Member } from "@/types";
|
|
|
|
const RANKS = ["주임", "선임", "책임", "파트너"];
|
|
|
|
export function MembersPage() {
|
|
const qc = useQueryClient();
|
|
const [tab, setTab] = useState("members");
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
const [edit, setEdit] = useState<Member | null>(null);
|
|
const mQ = useQuery({ queryKey: ["members"], queryFn: getMembers });
|
|
const dQ = useQuery({ queryKey: ["departments"], queryFn: getDepartments });
|
|
|
|
return (
|
|
<div>
|
|
<PageHeader
|
|
title="구성원 관리"
|
|
description="계정 생성/해제는 Keycloak에서, 여기서는 회사 구성원 정보(직급·부서·권한)를 관리합니다."
|
|
action={tab === "members" && <Button icon={<Plus size={16} />} onClick={() => setCreateOpen(true)}>구성원 추가</Button>}
|
|
/>
|
|
<Card>
|
|
<div className="px-3 pt-2"><Tabs active={tab} onChange={setTab} tabs={[{ key: "members", label: "구성원" }, { key: "departments", label: "부서" }]} /></div>
|
|
<div className="p-2">
|
|
{tab === "members" && (
|
|
mQ.isLoading ? <LoadingState /> : (mQ.data?.length ?? 0) === 0 ? <EmptyState title="구성원이 없습니다" icon={<Users size={28} />} /> : (
|
|
<table className="dense-table">
|
|
<thead><tr><th>이름</th><th>이메일</th><th>직급</th><th>권한</th><th>파트너</th><th>입사일</th><th></th></tr></thead>
|
|
<tbody>
|
|
{mQ.data!.map((m) => (
|
|
<tr key={m.id} className="cursor-pointer" onClick={() => setEdit(m)}>
|
|
<td className="font-medium">{m.displayName}</td>
|
|
<td>{m.email}</td>
|
|
<td><Badge label={m.rank || "—"} fg="#03143F" bg="#E9ECF3" size="sm" /></td>
|
|
<td>{m.role === "admin" ? <Badge label="관리자" fg="#5925DC" bg="#EBE9FE" size="sm" /> : "구성원"}</td>
|
|
<td>{m.isPartner ? "✓" : ""}</td>
|
|
<td className="tabular">{formatDate(m.joinDate)}</td>
|
|
<td className="text-right"><button className="text-ink-muted hover:text-money-out" onClick={(e) => { e.stopPropagation(); if (confirm("삭제하시겠습니까?")) deleteMember(m.id).then(() => qc.invalidateQueries({ queryKey: ["members"] })); }}><Trash2 size={15} /></button></td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)
|
|
)}
|
|
{tab === "departments" && <Departments depts={dQ.data ?? []} onChange={() => qc.invalidateQueries({ queryKey: ["departments"] })} />}
|
|
</div>
|
|
</Card>
|
|
|
|
{createOpen && <MemberCreateModal onClose={() => setCreateOpen(false)} depts={dQ.data ?? []} onDone={() => qc.invalidateQueries({ queryKey: ["members"] })} />}
|
|
{edit && <MemberEditDrawer member={edit} depts={dQ.data ?? []} onClose={() => setEdit(null)} onDone={() => { qc.invalidateQueries({ queryKey: ["members"] }); setEdit(null); }} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MemberCreateModal({ onClose, onDone, depts }: { onClose: () => void; onDone: () => void; depts: { id: string; name: string }[] }) {
|
|
const [f, setF] = useState({ email: "", displayName: "", rank: "주임", role: "user", departmentId: "", isPartner: false, joinDate: "" });
|
|
const m = useMutation({ mutationFn: () => createMember({ ...f, role: f.role as any, rank: f.rank as any, departmentId: f.departmentId || null, joinDate: f.joinDate || null }), onSuccess: () => { onDone(); onClose(); } });
|
|
return (
|
|
<Modal open onClose={onClose} title="구성원 추가"
|
|
footer={<><Button variant="secondary" onClick={onClose}>취소</Button><Button disabled={!f.email || m.isPending} onClick={() => m.mutate()}>추가</Button></>}>
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Field label="이름"><Input value={f.displayName} onChange={(e) => setF({ ...f, displayName: e.target.value })} /></Field>
|
|
<Field label="이메일"><Input value={f.email} onChange={(e) => setF({ ...f, email: e.target.value })} /></Field>
|
|
<Field label="직급"><Select value={f.rank} onChange={(e) => setF({ ...f, rank: e.target.value })}>{RANKS.map((r) => <option key={r}>{r}</option>)}</Select></Field>
|
|
<Field label="권한"><Select value={f.role} onChange={(e) => setF({ ...f, role: e.target.value })}><option value="user">구성원</option><option value="admin">관리자</option></Select></Field>
|
|
<Field label="부서"><Select value={f.departmentId} onChange={(e) => setF({ ...f, departmentId: e.target.value })}><option value="">미지정</option>{depts.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}</Select></Field>
|
|
<Field label="입사일"><Input type="date" value={f.joinDate} onChange={(e) => setF({ ...f, joinDate: e.target.value })} /></Field>
|
|
</div>
|
|
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={f.isPartner} onChange={(e) => setF({ ...f, isPartner: e.target.checked })} /> 파트너 (non-BE 수익 분배 대상)</label>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
function MemberEditDrawer({ member, depts, onClose, onDone }: { member: Member; depts: { id: string; name: string }[]; onClose: () => void; onDone: () => void }) {
|
|
const [f, setF] = useState({ ...member });
|
|
const m = useMutation({
|
|
mutationFn: () => updateMember(member.id, { displayName: f.displayName, rank: f.rank, role: f.role, departmentId: f.departmentId || null, isPartner: f.isPartner, phone: f.phone, position: f.position, annualLeave: Number(f.annualLeave) }),
|
|
onSuccess: onDone,
|
|
});
|
|
return (
|
|
<Drawer open onClose={onClose} title={`${member.displayName} 정보`}
|
|
footer={<><Button variant="secondary" onClick={onClose}>닫기</Button><Button disabled={m.isPending} onClick={() => m.mutate()}>저장</Button></>}>
|
|
<div className="space-y-4">
|
|
<Field label="이름"><Input value={f.displayName} onChange={(e) => setF({ ...f, displayName: e.target.value })} /></Field>
|
|
<Field label="이메일 (Keycloak 매칭)"><Input value={f.email} disabled /></Field>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Field label="직급"><Select value={f.rank} onChange={(e) => setF({ ...f, rank: e.target.value as any })}>{RANKS.map((r) => <option key={r}>{r}</option>)}</Select></Field>
|
|
<Field label="권한"><Select value={f.role} onChange={(e) => setF({ ...f, role: e.target.value as any })}><option value="user">구성원</option><option value="admin">관리자</option></Select></Field>
|
|
<Field label="부서"><Select value={f.departmentId ?? ""} onChange={(e) => setF({ ...f, departmentId: e.target.value })}><option value="">미지정</option>{depts.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}</Select></Field>
|
|
<Field label="연차 부여일"><Input type="number" value={f.annualLeave} onChange={(e) => setF({ ...f, annualLeave: Number(e.target.value) })} /></Field>
|
|
<Field label="전화번호"><Input value={f.phone} onChange={(e) => setF({ ...f, phone: e.target.value })} /></Field>
|
|
<Field label="직책"><Input value={f.position} onChange={(e) => setF({ ...f, position: e.target.value })} /></Field>
|
|
</div>
|
|
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={f.isPartner} onChange={(e) => setF({ ...f, isPartner: e.target.checked })} /> 파트너</label>
|
|
</div>
|
|
</Drawer>
|
|
);
|
|
}
|
|
|
|
function Departments({ depts, onChange }: { depts: { id: string; name: string }[]; onChange: () => void }) {
|
|
const [name, setName] = useState("");
|
|
const add = useMutation({ mutationFn: () => createDepartment({ name }), onSuccess: () => { onChange(); setName(""); } });
|
|
return (
|
|
<div className="p-3">
|
|
<table className="dense-table mb-3"><thead><tr><th>부서명</th></tr></thead>
|
|
<tbody>{depts.map((d) => <tr key={d.id}><td>{d.name}</td></tr>)}{depts.length === 0 && <tr><td className="text-center text-ink-muted py-6">부서가 없습니다</td></tr>}</tbody>
|
|
</table>
|
|
<div className="flex items-end gap-2">
|
|
<Field label="새 부서"><Input value={name} onChange={(e) => setName(e.target.value)} className="w-56" /></Field>
|
|
<Button icon={<Plus size={15} />} disabled={!name || add.isPending} onClick={() => add.mutate()}>추가</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|