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)
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
||||
type ApprovalRequest,
|
||||
type ApprovalLine,
|
||||
} from "@/lib/api/approval";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
// 상태 배지 색상
|
||||
const statusConfig: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
|
||||
@@ -204,12 +205,17 @@ function ApprovalDetailModal({ request, open, onClose, onRefresh, pendingLineId
|
||||
|
||||
// 결재 대기 행 (ApprovalLine 기반)
|
||||
function ApprovalLineRow({ line, onClick }: { line: ApprovalLine; onClick: () => void }) {
|
||||
const { user } = useAuth();
|
||||
const statusInfo = lineStatusConfig[line.status] || { label: line.status, icon: null };
|
||||
const createdAt = line.request_created_at || line.created_at;
|
||||
const formattedDate = createdAt
|
||||
? new Date(createdAt).toLocaleDateString("ko-KR", { year: "2-digit", month: "2-digit", day: "2-digit" })
|
||||
: "-";
|
||||
|
||||
// 대무 받은 결재: 현재 사용자가 본인이 아닌 다른 사람(원본 결재자)에게 배정된 라인을 보고 있음
|
||||
const isProxy = user?.user_id != null && line.approver_id != null
|
||||
&& user.user_id !== line.approver_id;
|
||||
|
||||
return (
|
||||
<button
|
||||
className="w-full rounded-md border p-3 text-left transition-colors hover:bg-muted/50 sm:p-4"
|
||||
@@ -217,7 +223,18 @@ function ApprovalLineRow({ line, onClick }: { line: ApprovalLine; onClick: () =>
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-medium sm:text-sm">{line.title || "제목 없음"}</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="truncate text-xs font-medium sm:text-sm">{line.title || "제목 없음"}</p>
|
||||
{isProxy && (
|
||||
<span
|
||||
className="shrink-0 rounded border border-primary/60 px-1.5 py-0.5 text-[0.6rem] font-medium text-primary"
|
||||
style={{ boxShadow: "var(--v5-glow-sm)" }}
|
||||
title={`${line.approver_name || line.approver_id} 의 대무 처리`}
|
||||
>
|
||||
대무 ← {line.approver_name || line.approver_id}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{line.requester_name && (
|
||||
<p className="text-muted-foreground mt-0.5 text-[10px] sm:text-xs">
|
||||
요청자: {line.requester_name}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { userAPI } from "@/lib/api/user";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { SubstituteSection } from "@/components/admin/SubstituteSection";
|
||||
|
||||
// 알림 모달 컴포넌트
|
||||
interface AlertModalProps {
|
||||
@@ -683,6 +684,14 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 대무자(代務者) 관리 — 수정 모드에서만 노출 */}
|
||||
{isEditMode && editingUser?.user_id && (
|
||||
<SubstituteSection
|
||||
originalUserId={editingUser.user_id}
|
||||
originalUserName={editingUser.user_name}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="flex justify-end gap-3 border-t pt-4">
|
||||
<Button type="button" variant="outline" onClick={handleClose} disabled={isLoading}>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { Camera, X } from "lucide-react";
|
||||
import { ProfileFormData } from "@/types/profile";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { MySubstituteView } from "@/components/layout/MySubstituteView";
|
||||
|
||||
// 언어 정보 타입
|
||||
interface LanguageInfo {
|
||||
@@ -289,6 +290,9 @@ export function ProfileModal({
|
||||
|
||||
</div>
|
||||
|
||||
{/* 나의 대무 관계 — read-only */}
|
||||
<MySubstituteView isVisible={isOpen} />
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose} disabled={isSaving}>
|
||||
취소
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* 대무자(代務者) 관리 API 클라이언트
|
||||
* 엔드포인트: /api/substitutes/*
|
||||
*
|
||||
* 데이터 타입은 CLAUDE.md 컨벤션에 따라 Record<string, any>.
|
||||
* 백엔드가 Map<String, Object> 로 응답.
|
||||
*/
|
||||
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import type { ApiResponse } from "./approval";
|
||||
|
||||
/**
|
||||
* 회사 전체 대무 관계 조회 (관리자).
|
||||
* @param params - status, original_user_id, proxy_user_id, limit, offset 등 옵션
|
||||
*/
|
||||
export async function getSubstituteList(
|
||||
params: Record<string, any> = {}
|
||||
): Promise<ApiResponse<{ list: Record<string, any>[]; total: number }>> {
|
||||
try {
|
||||
const response = await apiClient.get("/substitutes", { params });
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 본인 대무 관계 조회 (ProfileModal read-only).
|
||||
* 반환: { proxying_for_me: [...], my_proxies: [...] }
|
||||
*/
|
||||
export async function getMySubstitutes(): Promise<
|
||||
ApiResponse<{
|
||||
proxying_for_me: Record<string, any>[];
|
||||
my_proxies: Record<string, any>[];
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
const response = await apiClient.get("/substitutes/mine");
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSubstituteInfo(
|
||||
substituteId: number
|
||||
): Promise<ApiResponse<Record<string, any>>> {
|
||||
try {
|
||||
const response = await apiClient.get(`/substitutes/${substituteId}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 대무자 신규 지정 (관리자).
|
||||
* end_date 필수, start_date 옵션(비우면 즉시).
|
||||
*/
|
||||
export async function createSubstitute(data: {
|
||||
original_user_id: string;
|
||||
proxy_user_id: string;
|
||||
end_date: string;
|
||||
start_date?: string;
|
||||
reason?: string;
|
||||
}): Promise<ApiResponse<Record<string, any>>> {
|
||||
try {
|
||||
const response = await apiClient.post("/substitutes", data);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSubstitute(
|
||||
substituteId: number,
|
||||
data: Record<string, any>
|
||||
): Promise<ApiResponse<Record<string, any>>> {
|
||||
try {
|
||||
const response = await apiClient.put(`/substitutes/${substituteId}`, data);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSubstitute(
|
||||
substituteId: number
|
||||
): Promise<ApiResponse<void>> {
|
||||
try {
|
||||
const response = await apiClient.delete(`/substitutes/${substituteId}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 같은 (original_user_id, proxy_user_id) 쌍의 기간 겹침 사전 검증.
|
||||
* UI 등록 직전 호출 (백엔드 EXCLUDE 제약이 최종 방어).
|
||||
*/
|
||||
export async function checkSubstituteOverlap(data: {
|
||||
original_user_id: string;
|
||||
proxy_user_id: string;
|
||||
start_date?: string | null;
|
||||
end_date: string;
|
||||
exclude_substitute_id?: number;
|
||||
}): Promise<ApiResponse<{ overlap: boolean; count: number }>> {
|
||||
try {
|
||||
const response = await apiClient.post("/substitutes/check-overlap", data);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user