6a9fc06f0e
- 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)
283 lines
9.8 KiB
TypeScript
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>
|
|
);
|
|
}
|