c3e5d7fc1b
SubstituteSection 의 loadCandidates 가 res.data.list 를 가정했지만
getUserList(/api/admin/users) 응답은 { data: [...] } 형태 (data 가 list 자체).
결과로 모든 select 가 '지정 가능한 사용자가 없습니다' 로 표시됐음.
Array.isArray(res.data) 와 res.data.list 둘 다 fallback 으로 처리.
342 lines
12 KiB
TypeScript
342 lines
12 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 {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
getSubstituteList,
|
|
createSubstitute,
|
|
deleteSubstitute,
|
|
checkSubstituteOverlap,
|
|
} from "@/lib/api/substitute";
|
|
import { getUserList } from "@/lib/api/user";
|
|
|
|
/**
|
|
* 사용자별 대무자(代務者) 관리 섹션.
|
|
*
|
|
* 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);
|
|
|
|
// 같은 회사 활성 사용자 목록 (대무자 후보) — 본인/SUPER_ADMIN/비활성 제외
|
|
const [candidates, setCandidates] = useState<Record<string, any>[]>([]);
|
|
const [candidatesLoading, setCandidatesLoading] = 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 loadCandidates = useCallback(async () => {
|
|
setCandidatesLoading(true);
|
|
const res: any = await getUserList({ status: "active", limit: 1000 });
|
|
// user.ts 의 getUserList 는 axios response 의 data 를 반환:
|
|
// { success, data: [...], total, ... } (data 가 list 자체)
|
|
// 또는 cross-tenant mode 등에서 { data: { list: [...] } } 일 수도 있어 둘 다 지원.
|
|
const rawList: any[] = Array.isArray(res?.data)
|
|
? res.data
|
|
: Array.isArray(res?.data?.list)
|
|
? res.data.list
|
|
: [];
|
|
if (rawList.length > 0) {
|
|
const filtered = (rawList as Record<string, any>[]).filter(
|
|
(u) =>
|
|
u.user_id !== originalUserId &&
|
|
u.user_type !== "SUPER_ADMIN" &&
|
|
u.status === "active",
|
|
);
|
|
setCandidates(filtered);
|
|
} else {
|
|
setCandidates([]);
|
|
}
|
|
setCandidatesLoading(false);
|
|
}, [originalUserId]);
|
|
|
|
const openDialog = () => {
|
|
setForm({ proxy_user_id: "", start_date: "", end_date: "", reason: "" });
|
|
setError(null);
|
|
setDialogOpen(true);
|
|
loadCandidates();
|
|
};
|
|
|
|
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">대무자</Label>
|
|
<Select
|
|
value={form.proxy_user_id}
|
|
onValueChange={(v) => setForm({ ...form, proxy_user_id: v })}
|
|
>
|
|
<SelectTrigger id="proxy_user_id" autoFocus>
|
|
<SelectValue
|
|
placeholder={
|
|
candidatesLoading
|
|
? "사용자 목록 불러오는 중..."
|
|
: candidates.length === 0
|
|
? "지정 가능한 사용자가 없습니다"
|
|
: "대무자를 선택하세요"
|
|
}
|
|
/>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{candidates.map((u) => (
|
|
<SelectItem key={u.user_id} value={u.user_id}>
|
|
{u.user_name || u.user_id}
|
|
<span className="ml-1 text-muted-foreground">
|
|
({u.user_id}
|
|
{u.dept_name ? ` · ${u.dept_name}` : ""})
|
|
</span>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<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>
|
|
);
|
|
}
|