theorose49 65bcb69374
All checks were successful
build-and-push / build (push) Successful in 31s
feat: 메일함·프로필 사진·근무상태 드롭다운·접이식 사이드바 + #03143F 팔레트 + 인센티브 게이지
- 브랜드 포인트컬러 #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>
2026-06-28 09:38:51 +09:00

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>
);
}