主要变更: - RDP 远程桌面:新增 backend/pkg/rdp(RDP 引擎 + credssp/display/rle/trace)、 backend/controller/rdp*(连接/凭证/文件/会话/实时)、frontend RDP 组件与 API - Windows SMB 文件传输:backend/pkg/winsmb - 云账单同步:backend/pkg/billingsync + billing_sync/aliyun_billing,前端账单详情 - Agent CLI Kubernetes 工具:kubernetes_action/get、pod_execute/logs - 服务器导入助手:server_import_helpers、ImportCredentialSettings - 国际化与组件重构(cloud/deploy/monitor/server/system 等多模块) - 清理编译产物二进制:移除 backend/server、backend/agent/bin/aiops-agent-* - .gitignore:新增 backend/agent/ 与 .cache/(避免 334MB go-build 缓存误入库) Co-Authored-By: Claude <noreply@anthropic.com>
496 lines
25 KiB
TypeScript
496 lines
25 KiB
TypeScript
// 审批分类通用页面 — 资源申请/应用申请/权限审批三个子页面共用
|
||
import { useState, useEffect, useCallback } from 'react';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import { Label } from '@/components/ui/label';
|
||
import {
|
||
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,
|
||
} from '@/components/ui/dialog';
|
||
import {
|
||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||
} from '@/components/ui/table';
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||
import { Plus, Search, RefreshCw, Clock, CheckCircle, XCircle, FileText, Eye, Check, XIcon, User, ChevronRight, Play } from 'lucide-react';
|
||
import { approvalApi } from '@/api';
|
||
import { toast } from 'sonner';
|
||
import { PageShell } from '@/components/layout/PageShell';
|
||
import { ContentPanel } from '@/components/layout/ContentPanel';
|
||
import { PageEmpty } from '@/components/layout/PageEmpty';
|
||
import type { JsonData } from '@/types/common';
|
||
|
||
export interface FormField {
|
||
key: string; label: string; type: 'text' | 'number' | 'select' | 'textarea';
|
||
options?: string[]; placeholder?: string; defaultValue?: string | number; required?: boolean;
|
||
}
|
||
|
||
interface Props {
|
||
category: 'resource' | 'app' | 'permission';
|
||
title: string; description: string; fields: FormField[]; templateCode: string;
|
||
}
|
||
|
||
interface StepItem {
|
||
id: number; step: number; step_type?: string; approver: number; status: string; comment: string;
|
||
approved_at: string | null; approver_user?: { username?: string; real_name?: string };
|
||
}
|
||
|
||
interface ProcessItem {
|
||
id: number; title: string; status: string; form_data: string; created_at: string;
|
||
applicant: number; applicant_user?: { username?: string; real_name?: string };
|
||
template?: { name?: string }; current_step?: number; total_steps?: number;
|
||
steps?: StepItem[];
|
||
}
|
||
|
||
const statusBadge = (status: string, t: (k: string) => string) => {
|
||
switch (status) {
|
||
case 'approved': return <Badge className="bg-emerald-500/20 dg-text-green border-emerald-500/30"><CheckCircle className="h-3 w-3 mr-1" />{t('approval.status.approved')}</Badge>;
|
||
case 'rejected': return <Badge className="bg-red-500/20 dg-text-red border-red-500/30"><XCircle className="h-3 w-3 mr-1" />{t('approval.status.rejected')}</Badge>;
|
||
default: return <Badge className="bg-amber-500/20 dg-text-yellow border-amber-500/30"><Clock className="h-3 w-3 mr-1" />{t('approval.status.pending')}</Badge>;
|
||
}
|
||
};
|
||
|
||
function parseFormData(data: string): Record<string, string> {
|
||
try { return JSON.parse(data) || {}; } catch { return {}; }
|
||
}
|
||
|
||
function getApplicant(p: JsonData): string {
|
||
const u = p?.applicant_user;
|
||
return u ? (u.real_name || u.username || `#${p.applicant}`) : (p?.applicant ? `#${p.applicant}` : '--');
|
||
}
|
||
|
||
function getCurrentApprover(p: ProcessItem, t: (k: string) => string): string {
|
||
if (p.status !== 'pending') return '--';
|
||
const steps = p.steps || [];
|
||
if (steps.length === 0) return t('approval.detail.adminPending');
|
||
const cs = steps.find(s => s.step === p.current_step && s.status === 'pending');
|
||
if (!cs) return t('approval.detail.adminPending');
|
||
if (cs.approver_user) return cs.approver_user.real_name || cs.approver_user.username || `#${cs.approver}`;
|
||
return t('approval.detail.admin');
|
||
}
|
||
|
||
export function ApprovalCategoryPage({ category, title, description, fields, templateCode }: Props) {
|
||
const { t } = useTranslation();
|
||
const [processes, setProcesses] = useState<ProcessItem[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [keyword, setKeyword] = useState('');
|
||
const [statusFilter, setStatusFilter] = useState('');
|
||
const [dialogOpen, setDialogOpen] = useState(false);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [templateId, setTemplateId] = useState<number | null>(null);
|
||
const [formData, setFormData] = useState<Record<string, string>>({});
|
||
const [detailItem, setDetailItem] = useState<ProcessItem | null>(null);
|
||
const [acting, setActing] = useState(false);
|
||
|
||
const fetchProcesses = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const params: JsonData = { page_size: '100' };
|
||
if (category) params.category = category;
|
||
if (statusFilter) params.status = statusFilter;
|
||
const resp = (await approvalApi.getProcesses(params)) as JsonData;
|
||
setProcesses(resp?.list ?? resp?.data?.list ?? []);
|
||
} catch { setProcesses([]); }
|
||
finally { setLoading(false); }
|
||
}, [category, statusFilter]);
|
||
|
||
const fetchTemplateId = useCallback(async () => {
|
||
try {
|
||
const resp = (await approvalApi.getTemplates({ type: templateCode })) as JsonData;
|
||
const list = resp?.list ?? resp?.data?.list ?? [];
|
||
if (list.length > 0) setTemplateId(list[0].id);
|
||
} catch {
|
||
// 模板缺失时保留空状态,由创建流程提示用户。// Logan.Li
|
||
}
|
||
}, [templateCode]);
|
||
|
||
useEffect(() => { fetchProcesses(); }, [fetchProcesses]);
|
||
useEffect(() => { fetchTemplateId(); }, [fetchTemplateId]);
|
||
|
||
const handleCreate = async () => {
|
||
if (!templateId) { toast.error(t('approval.msg.templateNotLoaded')); return; }
|
||
for (const f of fields) { if (f.required && !formData[f.key]) { toast.error(t('approval.msg.pleaseFill', { label: f.label })); return; } }
|
||
setSubmitting(true);
|
||
try {
|
||
const firstVal = formData[fields[0]?.key] || '';
|
||
const secondVal = fields[1] ? formData[fields[1].key] || '' : '';
|
||
await approvalApi.createProcess({
|
||
template_id: templateId,
|
||
title: `${title} - ${firstVal}${secondVal ? ' / ' + secondVal : ''}`,
|
||
form_data: JSON.stringify(formData),
|
||
});
|
||
toast.success(t('approval.msg.submitSuccess'));
|
||
setDialogOpen(false); setFormData({}); fetchProcesses();
|
||
} catch (err) { toast.error(err?.message || t('approval.msg.submitFailed')); }
|
||
finally { setSubmitting(false); }
|
||
};
|
||
|
||
const openCreateDialog = () => {
|
||
const init: Record<string, string> = {};
|
||
fields.forEach((f) => { init[f.key] = f.defaultValue?.toString() || ''; });
|
||
setFormData(init); setDialogOpen(true);
|
||
};
|
||
|
||
const handleApprove = async (id: number) => {
|
||
setActing(true);
|
||
try { await approvalApi.approveProcess(id); toast.success(t('approval.msg.approveSuccess')); fetchProcesses(); }
|
||
catch (err) { toast.error(err?.response?.data?.message || t('approval.msg.approveFailed')); }
|
||
finally { setActing(false); }
|
||
};
|
||
|
||
const handleReject = async (id: number) => {
|
||
setActing(true);
|
||
try { await approvalApi.rejectProcess(id, t('approval.flow.rejectTitle')); toast.success(t('approval.msg.rejectSuccess')); fetchProcesses(); }
|
||
catch (err) { toast.error(err?.response?.data?.message || t('common.operationFailed')); }
|
||
finally { setActing(false); }
|
||
};
|
||
|
||
const openDetail = async (p: ProcessItem) => {
|
||
// Load full detail including steps
|
||
try {
|
||
const resp: JsonData = await approvalApi.getProcesses({ page_size: 200 } as JsonData);
|
||
const list = resp?.list ?? resp?.data?.list ?? [];
|
||
const full = list.find((x: JsonData) => x.id === p.id);
|
||
setDetailItem(full || p);
|
||
} catch { setDetailItem(p); }
|
||
};
|
||
|
||
const filtered = processes.filter((p) => !keyword || (p.title || '').toLowerCase().includes(keyword.toLowerCase()));
|
||
|
||
// 工具栏:搜索 + 状态筛选 // Logan.Li
|
||
const toolbar = (
|
||
<div className="flex items-center gap-3 flex-wrap">
|
||
<div className="relative w-64">
|
||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 dg-text-muted" />
|
||
<Input placeholder={t('approval.flow.searchRequest')} value={keyword} onChange={(e) => setKeyword(e.target.value)}
|
||
className="pl-9 h-9 dg-input" />
|
||
</div>
|
||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||
<SelectTrigger className="w-32 h-9 dg-input">
|
||
<SelectValue placeholder={t('common.all')} />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">{t('common.all')}</SelectItem>
|
||
<SelectItem value="pending">{t('approval.status.pending')}</SelectItem>
|
||
<SelectItem value="approved">{t('approval.status.approved')}</SelectItem>
|
||
<SelectItem value="rejected">{t('approval.status.rejected')}</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={fetchProcesses}>
|
||
<RefreshCw className="h-4 w-4 mr-2" />{t('common.refresh')}
|
||
</Button>
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<>
|
||
<PageShell
|
||
variant="list"
|
||
title={title}
|
||
subtitle={description}
|
||
icon={<FileText className="h-5 w-5" />}
|
||
toolbar={toolbar}
|
||
loading={loading}
|
||
actions={(
|
||
<Button onClick={openCreateDialog} className="dg-btn-primary">
|
||
<Plus className="h-4 w-4 mr-2" />{t('approval.newRequest')}
|
||
</Button>
|
||
)}
|
||
>
|
||
<ContentPanel noPadding>
|
||
{filtered.length === 0 ? (
|
||
<PageEmpty
|
||
icon={<FileText className="h-10 w-10" />}
|
||
title={t('approval.noRecords')}
|
||
action={(
|
||
<Button variant="link" onClick={openCreateDialog}>{t('approval.applyNow')}</Button>
|
||
)}
|
||
/>
|
||
) : (
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
||
<TableHead className="text-[var(--dg-text-secondary)]">{t('approval.col.submitTime')}</TableHead>
|
||
<TableHead className="text-[var(--dg-text-secondary)]">{t('approval.col.submitAccount')}</TableHead>
|
||
<TableHead className="text-[var(--dg-text-secondary)]">{t('approval.col.currentApprover')}</TableHead>
|
||
<TableHead className="text-[var(--dg-text-secondary)]">{t('approval.col.status')}</TableHead>
|
||
<TableHead className="text-[var(--dg-text-secondary)] w-28">{t('approval.col.actions')}</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{filtered.map((p) => (
|
||
<TableRow key={p.id} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
|
||
<TableCell className="text-[var(--dg-text-muted)] text-sm whitespace-nowrap">
|
||
{p.created_at ? new Date(p.created_at).toLocaleString('zh-CN') : '--'}
|
||
</TableCell>
|
||
<TableCell className="text-[var(--dg-text-primary)]">{getApplicant(p)}</TableCell>
|
||
<TableCell className="text-[var(--dg-text-muted)] text-sm">{getCurrentApprover(p, t)}</TableCell>
|
||
<TableCell>{statusBadge(p.status, t)}</TableCell>
|
||
<TableCell>
|
||
<div className="flex items-center gap-1">
|
||
{p.status === 'pending' ? (
|
||
<>
|
||
<Button variant="ghost" size="sm" className="h-8 dg-text-red hover:bg-red-500/10" onClick={() => handleReject(p.id)} disabled={acting}>
|
||
<XIcon className="h-3.5 w-3.5 mr-1" />{t('common.reject')}
|
||
</Button>
|
||
<Button variant="ghost" size="sm" className="h-8 dg-text-green hover:bg-emerald-500/10" onClick={() => handleApprove(p.id)} disabled={acting}>
|
||
<Check className="h-3.5 w-3.5 mr-1" />{t('common.approve')}
|
||
</Button>
|
||
</>
|
||
) : (
|
||
<Button variant="ghost" size="sm" className="h-8 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]" onClick={() => openDetail(p)}>
|
||
<Eye className="h-3.5 w-3.5 mr-1" />{t('common.detail')}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
)}
|
||
</ContentPanel>
|
||
</PageShell>
|
||
|
||
{/* Create Dialog */}
|
||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||
<DialogContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-lg max-h-[85vh] overflow-y-auto">
|
||
<DialogHeader>
|
||
<DialogTitle className="text-[var(--dg-text-primary)]">{t('approval.newRequest')} {title}</DialogTitle>
|
||
<DialogDescription className="text-[var(--dg-text-secondary)]">{description}</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="space-y-4 py-2">
|
||
{fields.map((field) => (
|
||
<div key={field.key} className="space-y-1.5">
|
||
<Label className="text-[var(--dg-text-primary)] text-sm">{field.label}{field.required && <span className="dg-text-red ml-0.5">*</span>}</Label>
|
||
{field.type === 'select' ? (
|
||
<Select value={formData[field.key] || ''} onValueChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))}>
|
||
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"><SelectValue placeholder={field.placeholder || `${t('approval.editor.pleaseSelect')}${field.label}`} /></SelectTrigger>
|
||
<SelectContent>{(field.options || []).map((opt) => (<SelectItem key={opt} value={opt}>{opt}</SelectItem>))}</SelectContent>
|
||
</Select>
|
||
) : field.type === 'textarea' ? (
|
||
<textarea value={formData[field.key] || ''} onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
|
||
placeholder={field.placeholder} rows={3}
|
||
className="w-full rounded-md bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-[var(--dg-text-primary)] px-3 py-2 text-sm resize-none focus:outline-none focus:border-[var(--dg-blue)]" />
|
||
) : (
|
||
<Input type={field.type} value={formData[field.key] || ''} onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
|
||
placeholder={field.placeholder} className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="flex justify-end gap-3 pt-2">
|
||
<Button variant="outline" className="border-[var(--dg-border)]" onClick={() => setDialogOpen(false)}>{t('common.cancel')}</Button>
|
||
<Button onClick={handleCreate} disabled={submitting} className="dg-btn-primary">
|
||
{submitting ? t('approval.submitting') : t('approval.submitRequest')}
|
||
</Button>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* Detail Dialog — operation timeline */}
|
||
<DetailTimelineDialog item={detailItem} onClose={() => setDetailItem(null)} fields={fields} onStatusChange={fetchProcesses} />
|
||
</>
|
||
);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* DetailTimelineDialog — 操作时间线 */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
function DetailTimelineDialog({ item, onClose, fields, onStatusChange }: {
|
||
item: ProcessItem | null; onClose: () => void; fields: FormField[]; onStatusChange: () => void;
|
||
}) {
|
||
const { t } = useTranslation();
|
||
const [acting, setActing] = useState(false);
|
||
if (!item) return null;
|
||
|
||
const timeStr = (tm: string | null) => {
|
||
if (!tm) return '';
|
||
return new Date(tm).toLocaleString('zh-CN');
|
||
};
|
||
|
||
const steps = item.steps || [];
|
||
|
||
const handleApprove = async () => {
|
||
setActing(true);
|
||
try { await approvalApi.approveProcess(item.id); toast.success(t('approval.msg.approveSuccess')); onStatusChange(); onClose(); }
|
||
catch (err) { toast.error(err?.response?.data?.message || t('approval.msg.approveFailed')); }
|
||
finally { setActing(false); }
|
||
};
|
||
|
||
const handleReject = async () => {
|
||
setActing(true);
|
||
try { await approvalApi.rejectProcess(item.id, t('approval.flow.rejectTitle')); toast.success(t('approval.msg.rejectSuccess')); onStatusChange(); onClose(); }
|
||
catch (err) { toast.error(err?.response?.data?.message || t('common.operationFailed')); }
|
||
finally { setActing(false); }
|
||
};
|
||
|
||
const stepStatusLabel = (s: StepItem) => {
|
||
if (s.status === 'approved') return t('approval.status.passed');
|
||
if (s.status === 'rejected') return t('common.reject');
|
||
if (s.status === 'pending' && s.step === item.current_step) return t('approval.status.waiting');
|
||
if (s.status === 'pending') return t('approval.status.notStarted');
|
||
return s.status;
|
||
};
|
||
|
||
const stepStatusColor = (s: StepItem) => {
|
||
if (s.status === 'approved') return 'dg-text-green';
|
||
if (s.status === 'rejected') return 'dg-text-red';
|
||
if (s.step === item.current_step) return 'dg-text-blue';
|
||
return 'text-[var(--dg-text-muted)]';
|
||
};
|
||
|
||
const approverName = (s: StepItem) => {
|
||
if (s.approver_user) return s.approver_user.real_name || s.approver_user.username || `#${s.approver}`;
|
||
return t('approval.detail.admin');
|
||
};
|
||
|
||
return (
|
||
<Dialog open={!!item} onOpenChange={(v) => { if (!v) onClose(); }}>
|
||
<DialogContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-lg max-h-[85vh] overflow-y-auto">
|
||
<DialogHeader>
|
||
<DialogTitle className="text-[var(--dg-text-primary)]">{t('approval.detail.title')}</DialogTitle>
|
||
<DialogDescription className="text-[var(--dg-text-secondary)]">{item.title}</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="space-y-4 py-2">
|
||
{/* Status + Meta */}
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-sm text-[var(--dg-text-muted)]">{t('approval.detail.statusLabel')}</span>
|
||
{statusBadge(item.status, t)}
|
||
{item.status === 'pending' && (
|
||
<span className="text-xs text-[var(--dg-text-muted)]">
|
||
{t('approval.detail.stepProgress', { current: item.current_step, total: item.total_steps })}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Operation Timeline */}
|
||
<div className="border-t border-[var(--dg-border)] pt-4">
|
||
<h4 className="text-sm font-medium text-[var(--dg-text-primary)] mb-4">{t('approval.detail.operationTimeline')}</h4>
|
||
<div className="space-y-0">
|
||
{/* 1. Submit node */}
|
||
<div className="flex gap-3">
|
||
<div className="flex flex-col items-center">
|
||
<div className="w-7 h-7 rounded-full bg-blue-500/20 flex items-center justify-center"><User className="w-3.5 h-3.5 dg-text-blue" /></div>
|
||
<div className="w-0.5 flex-1 bg-[var(--dg-border)] my-1" />
|
||
</div>
|
||
<div className="flex-1 pb-4">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-sm font-medium text-[var(--dg-text-primary)]">{t('approval.detail.submitNode')}</span>
|
||
<span className="text-xs text-[var(--dg-text-muted)]">·</span>
|
||
<span className="text-xs text-[var(--dg-text-secondary)]">{getApplicant(item)}</span>
|
||
<span className="text-xs text-[var(--dg-text-muted)]">·</span>
|
||
<span className="text-xs text-[var(--dg-text-muted)]">{timeStr(item.created_at)}</span>
|
||
</div>
|
||
{/* Form data summary */}
|
||
<div className="mt-1 flex flex-wrap gap-2">
|
||
{(() => {
|
||
const data = parseFormData(item.form_data || '{}');
|
||
return fields.slice(0, 4).map((f) => {
|
||
const val = data[f.key];
|
||
if (!val) return null;
|
||
return (
|
||
<span key={f.key} className="text-[11px] px-1.5 py-0.5 rounded bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]">
|
||
{f.label}: <span className="text-[var(--dg-text-secondary)]">{val}</span>
|
||
</span>
|
||
);
|
||
});
|
||
})()}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 2. Approval / Execution steps */}
|
||
{steps.map((s, i) => {
|
||
const isLast = i === steps.length - 1;
|
||
const isExecute = s.step_type === 'execute';
|
||
const stepLabel = isExecute ? t('approval.step.executeShort') : t('approval.step.auditShort');
|
||
const iconColor = s.status === 'approved' ? 'bg-emerald-500/20'
|
||
: s.status === 'rejected' ? 'bg-red-500/20'
|
||
: 'bg-[var(--dg-bg-tertiary)]';
|
||
return (
|
||
<div key={s.id} className="flex gap-3">
|
||
<div className="flex flex-col items-center">
|
||
<div className={`w-7 h-7 rounded-full flex items-center justify-center ${iconColor}`}>
|
||
{s.status === 'approved' ? <Check className="w-3.5 h-3.5 dg-text-green" />
|
||
: s.status === 'rejected' ? <XIcon className="w-3.5 h-3.5 dg-text-red" />
|
||
: isExecute
|
||
? <Play className={`w-3.5 h-3.5 ${s.step === item.current_step ? 'dg-text-green' : 'text-[var(--dg-text-muted)]'}`} />
|
||
: <ChevronRight className={`w-3.5 h-3.5 ${s.step === item.current_step ? 'dg-text-blue' : 'text-[var(--dg-text-muted)]'}`} />}
|
||
</div>
|
||
{!isLast && <div className="w-0.5 flex-1 bg-[var(--dg-border)] my-1" />}
|
||
</div>
|
||
<div className={`flex-1 ${!isLast ? 'pb-4' : ''}`}>
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="text-sm font-medium text-[var(--dg-text-primary)]">{stepLabel}</span>
|
||
{s.approver_user && (
|
||
<>
|
||
<span className="text-xs text-[var(--dg-text-muted)]">·</span>
|
||
<span className="text-xs text-[var(--dg-text-secondary)]">{t('approval.detail.assignee')}: {approverName(s)}</span>
|
||
</>
|
||
)}
|
||
{s.approved_at && (
|
||
<>
|
||
<span className="text-xs text-[var(--dg-text-muted)]">·</span>
|
||
<span className={`text-xs ${stepStatusColor(s)}`}>
|
||
{approverName(s)} · {stepStatusLabel(s)} · {timeStr(s.approved_at)}
|
||
</span>
|
||
</>
|
||
)}
|
||
{!s.approved_at && (
|
||
<>
|
||
<span className="text-xs text-[var(--dg-text-muted)]">·</span>
|
||
<span className={`text-xs ${stepStatusColor(s)}`}>{stepStatusLabel(s)}</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
{s.comment && s.comment !== s.status && s.comment !== 'pending' && (
|
||
<p className="text-xs text-[var(--dg-text-muted)] mt-1 ml-0">{s.comment}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Full form data */}
|
||
<div className="border-t border-[var(--dg-border)] pt-4">
|
||
<h4 className="text-sm font-medium text-[var(--dg-text-primary)] mb-2">{t('approval.detail.requestContent')}</h4>
|
||
<div className="space-y-2">
|
||
{(() => {
|
||
const data = parseFormData(item.form_data || '{}');
|
||
return fields.map((f) => {
|
||
const val = data[f.key];
|
||
return (
|
||
<div key={f.key} className="grid grid-cols-3 gap-2 text-sm">
|
||
<span className="text-[var(--dg-text-muted)]">{f.label}:</span>
|
||
<span className="text-[var(--dg-text-primary)] col-span-2">{val ?? '--'}</span>
|
||
</div>
|
||
);
|
||
});
|
||
})()}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex justify-end gap-3 pt-2">
|
||
{item.status === 'pending' && (
|
||
<>
|
||
<Button variant="outline" className="border-red-500/30 dg-text-red hover:bg-red-500/10" onClick={handleReject} disabled={acting}>
|
||
<XIcon className="h-4 w-4 mr-1.5" />{t('common.reject')}
|
||
</Button>
|
||
<Button className="bg-emerald-600 hover:bg-emerald-700" onClick={handleApprove} disabled={acting}>
|
||
<Check className="h-4 w-4 mr-1.5" />{t('common.approve')}
|
||
</Button>
|
||
</>
|
||
)}
|
||
<Button variant="outline" className="border-[var(--dg-border)]" onClick={onClose}>{t('common.close')}</Button>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|