Files
invyone/frontend/components/admin/SubstituteSection.tsx
T
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

283 lines
9.8 KiB
TypeScript

"use client";
import React, { useCallback, useEffect, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
getSubstituteList,
createSubstitute,
deleteSubstitute,
checkSubstituteOverlap,
} from "@/lib/api/substitute";
/**
* 사용자별 대무자(代務者) 관리 섹션.
*
* Spec: .omc/specs/deep-dive-user-substitute-management.md
* Plan: .omc/plans/autopilot-impl.md (T11)
*
* 사용 위치: UserFormModal — 관리자만 보이며, editingUser (수정 모드) 일 때만 렌더링.
* 신규 사용자 등록 모드에서는 user_id 가 없어 의미가 없으므로 표시하지 않음.
*
* v5 디자인: --v5-surface-solid, var(--v5-glow-sm). blur 금지.
*/
interface SubstituteSectionProps {
/** 대상 원본 사용자 ID (이 사람의 대무자를 관리) */
originalUserId: string;
/** 대상 사용자 이름 (헤더 표시용) */
originalUserName?: string;
}
export function SubstituteSection({ originalUserId, originalUserName }: SubstituteSectionProps) {
const [rows, setRows] = useState<Record<string, any>[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState({
proxy_user_id: "",
start_date: "",
end_date: "",
reason: "",
});
const load = useCallback(async () => {
if (!originalUserId) return;
setLoading(true);
setError(null);
const res = await getSubstituteList({ original_user_id: originalUserId });
if (res.success && res.data) {
setRows(res.data.list ?? []);
} else {
setError(res.error || "대무자 목록 조회 실패");
}
setLoading(false);
}, [originalUserId]);
useEffect(() => {
load();
}, [load]);
const openDialog = () => {
setForm({ proxy_user_id: "", start_date: "", end_date: "", reason: "" });
setError(null);
setDialogOpen(true);
};
const submit = async () => {
setError(null);
if (!form.proxy_user_id.trim()) {
setError("대무자 ID 를 입력하세요.");
return;
}
if (!form.end_date) {
setError("종료일은 필수입니다.");
return;
}
if (form.proxy_user_id.trim() === originalUserId) {
setError("본인을 자기 대무자로 지정할 수 없습니다.");
return;
}
setSubmitting(true);
// 사전 겹침 검증 (UX) — 실패하면 백엔드 EXCLUDE 제약이 최종 방어
const overlap = await checkSubstituteOverlap({
original_user_id: originalUserId,
proxy_user_id: form.proxy_user_id.trim(),
start_date: form.start_date || null,
end_date: form.end_date,
});
if (overlap.success && overlap.data?.overlap) {
setError("같은 대상-대무자 쌍의 활성 기간이 겹칩니다.");
setSubmitting(false);
return;
}
const res = await createSubstitute({
original_user_id: originalUserId,
proxy_user_id: form.proxy_user_id.trim(),
end_date: form.end_date,
start_date: form.start_date || undefined,
reason: form.reason || undefined,
});
setSubmitting(false);
if (res.success) {
setDialogOpen(false);
load();
} else {
setError(res.error || res.message || "대무자 등록 실패");
}
};
const release = async (substituteId: number) => {
if (!confirm("이 대무 설정을 해지하시겠습니까?")) return;
const res = await deleteSubstitute(substituteId);
if (res.success) {
load();
} else {
alert(res.error || res.message || "해지 실패");
}
};
const statusBadge = (row: Record<string, any>) => {
const status = row.status as string | undefined;
const baseClass =
"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>;
if (status === "expired") return <span className={`${baseClass} border-muted-foreground text-muted-foreground`}></span>;
if (status === "inactive") return <span className={`${baseClass} border-destructive text-destructive`}></span>;
return null;
};
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 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold"> </span>
{originalUserName && (
<span className="text-xs text-muted-foreground">{originalUserName} </span>
)}
</div>
<Button type="button" size="sm" onClick={openDialog}>
</Button>
</div>
{error && !dialogOpen && (
<div className="mb-2 text-xs text-destructive">{error}</div>
)}
{loading ? (
<div className="text-xs text-muted-foreground"> ...</div>
) : rows.length === 0 ? (
<div className="text-xs text-muted-foreground"> .</div>
) : (
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border text-left text-muted-foreground">
<th className="py-1.5 font-normal"></th>
<th className="py-1.5 font-normal"></th>
<th className="py-1.5 font-normal"></th>
<th className="py-1.5 font-normal"></th>
<th className="py-1.5 font-normal text-right"></th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.substitute_id} className="border-b border-border/40">
<td className="py-1.5">
<span className="font-medium">{r.proxy_user_name || r.proxy_user_id}</span>
{r.proxy_dept_name && (
<span className="ml-1 text-muted-foreground">({r.proxy_dept_name})</span>
)}
</td>
<td className="py-1.5 text-muted-foreground">
{r.start_date ?? "즉시"} ~ {r.end_date}
</td>
<td className="py-1.5 text-muted-foreground">{r.reason ?? "-"}</td>
<td className="py-1.5">{statusBadge(r)}</td>
<td className="py-1.5 text-right">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => release(r.substitute_id)}
>
</Button>
</td>
</tr>
))}
</tbody>
</table>
)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2 text-sm">
<div>
<Label htmlFor="proxy_user_id"> ID</Label>
<Input
id="proxy_user_id"
value={form.proxy_user_id}
onChange={(e) => setForm({ ...form, proxy_user_id: e.target.value })}
placeholder="예: hjkim"
autoFocus
/>
<p className="mt-1 text-[0.7rem] text-muted-foreground">
. SUPER_ADMIN .
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="start_date"> ()</Label>
<Input
id="start_date"
type="date"
value={form.start_date}
onChange={(e) => setForm({ ...form, start_date: e.target.value })}
/>
<p className="mt-1 text-[0.7rem] text-muted-foreground"> </p>
</div>
<div>
<Label htmlFor="end_date">
<span className="text-destructive">*</span>
</Label>
<Input
id="end_date"
type="date"
value={form.end_date}
onChange={(e) => setForm({ ...form, end_date: e.target.value })}
/>
<p className="mt-1 text-[0.7rem] text-muted-foreground"> </p>
</div>
</div>
<div>
<Label htmlFor="reason"></Label>
<Textarea
id="reason"
rows={2}
value={form.reason}
onChange={(e) => setForm({ ...form, reason: e.target.value })}
placeholder="예: 연차 휴가"
/>
</div>
{error && <div className="text-xs text-destructive">{error}</div>}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
</Button>
<Button type="button" onClick={submit} disabled={submitting}>
{submitting ? "등록 중..." : "지정"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}