All checks were successful
build-and-push / build (push) Successful in 32s
- 우측 상단/프로필/대시보드/인센티브 직급을 "주임 컨설턴트"식 라벨로 표시(rankLabel) - form-input을 @layer components로 이동 → 검색창 pl-9가 padding 단축속성에 묻히던 아이콘 겹침 전역 해결 - 프로젝트 목록(유저/관리자 공용) 다중 필터: 검색+상태+업체+컨설팅+국가+범위+PM, 초기화 (ProjectFilters) - 메일함 → 쪽지함 명칭 통일(Topbar/Inbox/AccountSettings) - 내 프로필: 전폭 반응형 레이아웃(신원 카드 + 상세 3열) 공간 활용 - 계정 설정: 2열 반응형 그리드로 넓은 화면 공간 활용 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
99 lines
5.1 KiB
TypeScript
99 lines
5.1 KiB
TypeScript
import { useState } from "react";
|
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { LogOut, ShieldCheck } from "lucide-react";
|
|
import { updateMember, logout } from "@/lib/api";
|
|
import { useAuth } from "@/context/Auth";
|
|
import { Card, CardHeader, Button, Field, Input, PageHeader, LoadingState } from "@/components/ui";
|
|
import { classNames } from "@/lib/format";
|
|
|
|
// 계정 설정: 표시 이름·알림·화면 환경설정 + 로그아웃. 비밀번호/2단계 인증 등 계정 보안은
|
|
// Keycloak이 담당하며, 계정 생성/삭제도 Keycloak에서 이뤄집니다.
|
|
export function AccountSettingsPage() {
|
|
const { me, loading } = useAuth();
|
|
const qc = useQueryClient();
|
|
const member = me?.member;
|
|
const [displayName, setDisplayName] = useState(member?.displayName ?? "");
|
|
|
|
const save = useMutation({
|
|
mutationFn: () => updateMember(member!.id, { displayName }),
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["me"] }),
|
|
});
|
|
|
|
// 알림/화면 환경설정 (클라이언트 보관)
|
|
const [prefs, setPrefs] = useState(() => ({
|
|
notifyProject: localStorage.getItem("spin.notify.project") !== "0",
|
|
notifyWork: localStorage.getItem("spin.notify.work") !== "0",
|
|
notifyIncentive: localStorage.getItem("spin.notify.incentive") !== "0",
|
|
sidebarCollapsed: localStorage.getItem("spin.sidebarCollapsed") === "1",
|
|
}));
|
|
const setPref = (k: keyof typeof prefs, key: string, v: boolean) => {
|
|
setPrefs((p) => ({ ...p, [k]: v }));
|
|
localStorage.setItem(key, v ? "1" : "0");
|
|
};
|
|
|
|
if (loading) return <LoadingState />;
|
|
if (!member) return <Card className="p-8 text-center text-ink-secondary">구성원 정보가 없습니다. 관리자에게 문의하세요.</Card>;
|
|
|
|
return (
|
|
<div>
|
|
<PageHeader title="계정 설정" description="표시 이름과 알림·화면 환경을 설정합니다. 비밀번호 등 보안은 Keycloak 계정에서 관리됩니다." />
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 items-start">
|
|
<Card className="lg:col-span-2">
|
|
<CardHeader title="기본" action={<Button size="sm" onClick={() => save.mutate()} disabled={save.isPending}>저장</Button>} />
|
|
<div className="p-5 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<Field label="표시 이름"><Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} /></Field>
|
|
<Field label="이메일 (변경 불가)"><Input value={member.email} disabled /></Field>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader title="알림" subtitle="쪽지함에서 받을 알림 종류" />
|
|
<div className="p-5 divide-y divide-divider">
|
|
<ToggleRow label="프로젝트 알림" desc="프로젝트 추가·변경 알림" on={prefs.notifyProject} onChange={(v) => setPref("notifyProject", "spin.notify.project", v)} />
|
|
<ToggleRow label="근무 알림" desc="휴가·초과근무 승인/반려 알림" on={prefs.notifyWork} onChange={(v) => setPref("notifyWork", "spin.notify.work", v)} />
|
|
<ToggleRow label="인센티브 알림" desc="포인트 반영·정산 확정 알림" on={prefs.notifyIncentive} onChange={(v) => setPref("notifyIncentive", "spin.notify.incentive", v)} />
|
|
</div>
|
|
</Card>
|
|
|
|
<div className="space-y-4">
|
|
<Card>
|
|
<CardHeader title="화면" />
|
|
<div className="p-5 divide-y divide-divider">
|
|
<ToggleRow label="사이드바 기본 접힘" desc="다음 접속부터 메뉴를 접은 상태로 시작" on={prefs.sidebarCollapsed} onChange={(v) => setPref("sidebarCollapsed", "spin.sidebarCollapsed", v)} />
|
|
</div>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader title="보안 · 세션" />
|
|
<div className="p-5 space-y-4">
|
|
<div className="flex items-start gap-3 text-sm text-ink-secondary bg-canvas rounded-control p-4">
|
|
<ShieldCheck size={18} className="text-navy mt-0.5 shrink-0" />
|
|
<p>비밀번호 변경·2단계 인증 등 계정 보안과 계정 생성/삭제는 <span className="font-semibold text-ink">Keycloak 계정</span>에서 관리됩니다.</p>
|
|
</div>
|
|
<Button variant="danger" icon={<LogOut size={16} />} onClick={() => logout()}>로그아웃</Button>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ToggleRow({ label, desc, on, onChange }: { label: string; desc?: string; on: boolean; onChange: (v: boolean) => void }) {
|
|
return (
|
|
<div className="flex items-center justify-between py-3 first:pt-0 last:pb-0">
|
|
<div>
|
|
<div className="text-sm font-medium text-ink">{label}</div>
|
|
{desc && <div className="text-xs text-ink-muted mt-0.5">{desc}</div>}
|
|
</div>
|
|
<button
|
|
role="switch" aria-checked={on} onClick={() => onChange(!on)}
|
|
className={classNames("relative w-10 h-6 rounded-pill transition-colors shrink-0", on ? "bg-navy" : "bg-border-strong")}
|
|
>
|
|
<span className={classNames("absolute top-0.5 w-5 h-5 bg-white rounded-full shadow transition-all", on ? "left-[18px]" : "left-0.5")} />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|