spin-frontend/src/pages/AccountSettings.tsx
theorose49 a0911804ee
All checks were successful
build-and-push / build (push) Successful in 32s
feat(ui): 직급 라벨(X 컨설턴트)·쪽지함 명칭·검색아이콘 겹침 수정·프로젝트 다중필터·공간활용 강화
- 우측 상단/프로필/대시보드/인센티브 직급을 "주임 컨설턴트"식 라벨로 표시(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>
2026-06-29 07:33:29 +09:00

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