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:
2026-05-12 08:07:15 +09:00
parent c0bd420c66
commit 6a9fc06f0e
6 changed files with 543 additions and 1 deletions
+18 -1
View File
@@ -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}>
+115
View File
@@ -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 };
}
}