Files
johngreen 6a9fc06f0e feat(대무자): 프론트엔드 UI — UserFormModal 대무자 섹션 + ProfileModal 조회 + 결재 뱃지
- frontend/lib/api/substitute.ts: 7개 API 함수 (Record<string, any> 컨벤션)
- components/admin/SubstituteSection.tsx (신규): 관리자용 대무자 지정 섹션
  · 활성/예정 대무 관계 테이블, 사전 겹침 검증
  · v5 토큰 (--v5-surface-solid, --v5-glow-sm) 사용, blur 금지
- components/admin/UserFormModal.tsx: 수정 모드일 때 SubstituteSection 노출
- components/layout/MySubstituteView.tsx (신규): ProfileModal 용 read-only 조회
  · 내 대무자 + 내가 대무 중인 사람 양방향, D-day 카운트다운
- components/layout/ProfileModal.tsx: MySubstituteView 삽입
- app/(main)/approval/page.tsx: 대기함 행에 "대무 ← {원본 결재자}" 뱃지
  · currentUser.user_id !== line.approver_id 비교 (별도 타입 필드 X)
2026-05-12 08:07:15 +09:00

116 lines
4.0 KiB
TypeScript

"use client";
import React, { useEffect, useState } from "react";
import { getMySubstitutes } from "@/lib/api/substitute";
/**
* ProfileModal 내부 read-only 섹션: 내가 위임한 / 나를 대무 중인 관계.
*
* Spec: .omc/specs/deep-dive-user-substitute-management.md
* Plan: .omc/plans/autopilot-impl.md (T12)
*
* 지정/수정/해지 버튼 없음 — 관리자가 admin/userMng 에서만 변경 가능 (B4).
*/
export function MySubstituteView({ isVisible }: { isVisible: boolean }) {
const [proxyingForMe, setProxyingForMe] = useState<Record<string, any>[]>([]);
const [myProxies, setMyProxies] = useState<Record<string, any>[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!isVisible) return;
let cancelled = false;
(async () => {
setLoading(true);
const res = await getMySubstitutes();
if (cancelled) return;
if (res.success && res.data) {
setProxyingForMe(res.data.proxying_for_me ?? []);
setMyProxies(res.data.my_proxies ?? []);
}
setLoading(false);
})();
return () => {
cancelled = true;
};
}, [isVisible]);
const statusBadge = (row: Record<string, any>) => {
const status = row.status as string | undefined;
const baseClass =
"ml-1 inline-block rounded px-1.5 py-0.5 text-[0.65rem] font-medium border";
if (status === "active") return <span className={`${baseClass} border-emerald-500 text-emerald-600`}></span>;
if (status === "upcoming") return <span className={`${baseClass} border-blue-500 text-blue-600`}></span>;
return null;
};
const renderRow = (r: Record<string, any>, target: "proxy" | "original") => {
const personName = target === "proxy"
? r.proxy_user_name || r.proxy_user_id
: r.original_user_name || r.original_user_id;
const deptName = target === "proxy" ? r.proxy_dept_name : r.original_dept_name;
const days = r.days_remaining;
return (
<div
key={r.substitute_id}
className="flex items-center justify-between border-b border-border/40 py-1.5 text-xs"
>
<div>
<span className="font-medium">{personName}</span>
{deptName && <span className="ml-1 text-muted-foreground">({deptName})</span>}
{statusBadge(r)}
</div>
<div className="text-muted-foreground">
{r.start_date ?? "즉시"} ~ {r.end_date}
{typeof days === "number" && days >= 0 && (
<span className="ml-2 text-foreground">D-{days}</span>
)}
</div>
</div>
);
};
return (
<div
className="rounded-lg border border-border p-3"
style={{
background: "var(--v5-surface-solid, hsl(var(--background)))",
boxShadow: "var(--v5-glow-sm)",
}}
>
<div className="mb-2 text-sm font-semibold"> </div>
{loading ? (
<div className="text-xs text-muted-foreground"> ...</div>
) : (
<>
<div className="mb-3">
<div className="mb-1 text-[0.7rem] font-medium text-muted-foreground">
( )
</div>
{proxyingForMe.length === 0 ? (
<div className="text-xs text-muted-foreground"></div>
) : (
proxyingForMe.map((r) => renderRow(r, "proxy"))
)}
</div>
<div>
<div className="mb-1 text-[0.7rem] font-medium text-muted-foreground">
( )
</div>
{myProxies.length === 0 ? (
<div className="text-xs text-muted-foreground"></div>
) : (
myProxies.map((r) => renderRow(r, "original"))
)}
</div>
<p className="mt-2 text-[0.65rem] text-muted-foreground">
/ .
</p>
</>
)}
</div>
);
}