615 lines
28 KiB
TypeScript
615 lines
28 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
||
import Button from '../../components/Button';
|
||
import { exportApi, errMsg } from '../../api';
|
||
import { exportToExcel, exportTableToImage, utc8DateStamp, utc8DateTimeStamp } from '../../utils/export';
|
||
import type { FourHourExportHistoryItem, FourHourPreviewResult } from '../../types';
|
||
import {
|
||
IconFourHour,
|
||
IconFileExcel,
|
||
IconFileImage,
|
||
IconEye,
|
||
IconCheck,
|
||
IconUsers,
|
||
IconClock,
|
||
IconUndo,
|
||
IconHistory,
|
||
} from '../../components/Icons';
|
||
|
||
function MiniTable({ heads, rows }: { heads: string[]; rows: any[][] }) {
|
||
return (
|
||
<div className="w-full overflow-x-auto border border-gray-200 bg-white rounded-lg">
|
||
<table className="w-full text-sm border-collapse">
|
||
<thead>
|
||
<tr className="bg-gray-100 border-b border-gray-200">
|
||
{heads.map((h, i) => (
|
||
<th key={i} className="px-3 py-2.5 text-left font-semibold whitespace-nowrap text-gray-800">
|
||
{h}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map((r, ri) => (
|
||
<tr key={ri} className={`border-b border-gray-200 ${ri % 2 ? 'bg-gray-50' : 'bg-white'} hover:bg-primary-50 transition-colors`}>
|
||
{r.map((c, ci) => (
|
||
<td key={ci} className="px-3 py-2 whitespace-nowrap text-gray-700">
|
||
{c}
|
||
</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CellHours({ v, truncated, remark }: { v: number; truncated: boolean; remark?: string }) {
|
||
const numStr = v > 0 ? Number(v).toFixed(1) : '—';
|
||
const color = v > 0 ? 'text-primary-700 font-semibold bg-primary-50/40' : 'text-gray-400';
|
||
return (
|
||
<div className="leading-snug text-xs" style={{ minWidth: 180, verticalAlign: 'top' }}>
|
||
<div className={`text-sm font-semibold ${v > 0 ? 'text-primary-700' : 'text-gray-400'}`}>
|
||
{numStr}
|
||
{truncated ? (
|
||
<span className="ml-1.5 text-[11px] font-medium text-amber-700 bg-amber-50 border border-amber-200 rounded px-1.5 py-0.5 align-middle tracking-wide">
|
||
截断
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
{remark ? (
|
||
<div className="mt-1.5 mx-auto max-w-[88%] text-[11px] font-normal text-gray-500 text-left bg-white/95 rounded-sm border border-gray-200/80 px-2 py-1 leading-relaxed break-words shadow-[0_1px_2px_rgba(15,23,42,0.04)]" title={remark}>
|
||
{remark}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function FourHourExport() {
|
||
const [loading, setLoading] = useState(false);
|
||
const [confirming, setConfirming] = useState(false);
|
||
const [preview, setPreview] = useState<FourHourPreviewResult | null>(null);
|
||
const [confirmedMsg, setConfirmedMsg] = useState<string | null>(null);
|
||
const [history, setHistory] = useState<FourHourExportHistoryItem[]>([]);
|
||
const [historyLoading, setHistoryLoading] = useState(false);
|
||
const [voidingId, setVoidingId] = useState<number | null>(null);
|
||
const previewRef = useRef<HTMLDivElement>(null);
|
||
|
||
const loadHistory = () => {
|
||
setHistoryLoading(true);
|
||
(async () => {
|
||
try {
|
||
const r = await exportApi.fourHourHistory();
|
||
setHistory(Array.isArray(r?.list) ? r.list : []);
|
||
} catch (e) {
|
||
console.error(e);
|
||
} finally {
|
||
setHistoryLoading(false);
|
||
}
|
||
})();
|
||
};
|
||
|
||
useEffect(() => {
|
||
loadHistory();
|
||
}, []);
|
||
|
||
const loadPreview = () => {
|
||
setLoading(true);
|
||
setConfirmedMsg(null);
|
||
(async () => {
|
||
try {
|
||
const d = await exportApi.fourHourPreview();
|
||
setPreview(d);
|
||
} catch (e) {
|
||
alert(errMsg(e));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
})();
|
||
};
|
||
|
||
const doConfirm = () => {
|
||
if (!preview || !preview.confirm_plan?.length) {
|
||
alert('无可确认的导出记录,请先生成预览');
|
||
return;
|
||
}
|
||
const msg =
|
||
`确认后将把本次预览显示的 ${preview.summary.eligible_count} 位部员合计 ` +
|
||
`${Number(preview.summary.eligible_hours_total).toFixed(1)}h 标记为「已通过四小时导出核销」,\n` +
|
||
`下次四小时导出时不会再重复计算这些时长。\n\n` +
|
||
`注:原始义工时登记不会改变,仅影响「四小时导出」功能本身。\n\n` +
|
||
`是否继续?`;
|
||
if (!window.confirm(msg)) return;
|
||
setConfirming(true);
|
||
(async () => {
|
||
try {
|
||
const r = await exportApi.fourHourConfirm(preview.confirm_plan, {
|
||
eligible_count: preview.summary.eligible_count,
|
||
eligible_hours_total: preview.summary.eligible_hours_total,
|
||
});
|
||
if (r?.ok) {
|
||
const msg2 =
|
||
`确认成功!\n\n` +
|
||
`批次编号:#${r.export_id || '—'}\n` +
|
||
`计划处理明细条数:${r.total_processed}\n` +
|
||
`实际更新成功:${r.updated}\n` +
|
||
`跳过(并发冲突/已结算):${r.skipped}\n` +
|
||
`本次实际核销合计:${Number(r.total_applied || 0).toFixed(1)} h\n\n` +
|
||
`请点击下方「重新生成预览」以查看核销后的最新数据。`;
|
||
setConfirmedMsg(msg2);
|
||
alert(msg2);
|
||
loadHistory();
|
||
} else {
|
||
alert('确认失败:服务器未返回成功状态');
|
||
}
|
||
} catch (e) {
|
||
alert(errMsg(e));
|
||
} finally {
|
||
setConfirming(false);
|
||
}
|
||
})();
|
||
};
|
||
|
||
const doVoid = (it: FourHourExportHistoryItem) => {
|
||
const prefix =
|
||
`即将撤回批次 #${it.id}(${String(it.created_at || '').slice(0, 19)})\n` +
|
||
`该批次:${it.eligible_count} 人 / ${Number(it.eligible_hours_total || 0).toFixed(1)} h / ${it.items_updated} 条明细\n` +
|
||
`操作人:${it.admin_username || '—'}\n\n` +
|
||
`撤回后将:\n` +
|
||
` 1. 把该批次核销掉的小时数逐条加回「未核销余额」;\n` +
|
||
` 2. 标记该批次状态为 voided(不可再次撤回)。\n\n` +
|
||
`请在输入框内输入 "撤回批次 #${it.id}"(不含引号)以继续。`;
|
||
const input = window.prompt(prefix, '');
|
||
if (input === null) return;
|
||
if (input.trim() !== `撤回批次 #${it.id}`) {
|
||
alert('确认文本不匹配,已取消');
|
||
return;
|
||
}
|
||
setVoidingId(it.id);
|
||
(async () => {
|
||
try {
|
||
const r = await exportApi.fourHourVoid(it.id);
|
||
if (r?.ok) {
|
||
alert(
|
||
`撤回成功!\n\n` +
|
||
`批次:#${r.export_id}\n` +
|
||
`成功回退明细:${r.rolled}\n` +
|
||
`跳过:${r.skipped}\n` +
|
||
`累计回退小时数:${Number(r.rolled_hours || 0).toFixed(1)} h`
|
||
);
|
||
loadHistory();
|
||
if (preview) loadPreview();
|
||
}
|
||
} catch (e) {
|
||
alert(errMsg(e));
|
||
} finally {
|
||
setVoidingId(null);
|
||
}
|
||
})();
|
||
};
|
||
|
||
const eligibleCount = preview?.summary?.eligible_count || 0;
|
||
const eligibleHours = Number(preview?.summary?.eligible_hours_total || 0).toFixed(1);
|
||
|
||
const doExcel = () => {
|
||
if (!preview) { alert('请先生成预览'); return; }
|
||
const sheets: Parameters<typeof exportToExcel>[0] = [];
|
||
|
||
const flatRows = (preview.hours || []).map((h: any, i: number) => {
|
||
const hoursTxt = Number(h.hours || 0).toFixed(1) + (h.truncated ? '(截断)' : '');
|
||
return {
|
||
'序号': i + 1,
|
||
'班级': h.class,
|
||
'姓名': h.name,
|
||
'义工号': h.volunteer_no || '-',
|
||
'工作组': h.group_name || '-',
|
||
'活动名称': h.activity_title,
|
||
'活动时间': String(h.activity_start_time || '').replace('T', ' ').slice(0, 16),
|
||
'本次计时长(小时)': hoursTxt,
|
||
'备注': h.remark || '-',
|
||
};
|
||
});
|
||
sheets.push({ name: '总合明细(4h核销)', data: flatRows });
|
||
|
||
if (preview.user_totals?.length) {
|
||
sheets.push({
|
||
name: '部员核销汇总',
|
||
data: preview.user_totals.map((u: any) => ({
|
||
'排名': u.rank,
|
||
'班级': u.class,
|
||
'姓名': u.name,
|
||
'义工号': u.volunteer_no || '-',
|
||
'工作组': u.group_name || '-',
|
||
'本次核销(h)': Number(u.exported_total || 0).toFixed(1),
|
||
'核销后剩余未导出(h)': Number(u.unexported_remaining || 0).toFixed(1),
|
||
})),
|
||
});
|
||
}
|
||
|
||
if (preview.activity_matrix?.activities?.length && preview.activity_matrix?.users?.length) {
|
||
const { activities, users } = preview.activity_matrix;
|
||
const data = users.map((u: any) => {
|
||
const row: any = { '部员': u.name, '义工号': u.volunteer_no || '-' };
|
||
for (const a of activities) {
|
||
const h = Number(u[String(a.id)] || 0);
|
||
const rmk = (u as any)?._remarks?.[String(a.id)] as string | undefined;
|
||
const tr = (u as any)?._truncated?.[String(a.id)] as boolean | undefined;
|
||
let cell = h > 0 ? h.toFixed(1) : '—';
|
||
if (tr) cell = cell + '(截断)';
|
||
if (rmk) cell = cell + '\n备注:' + rmk;
|
||
row[a.title] = cell;
|
||
}
|
||
row['本次核销合计'] = Number(u._total || 0).toFixed(1);
|
||
return row;
|
||
});
|
||
const firstAct = preview.activity_matrix.activities[0];
|
||
sheets.push({
|
||
name: '活动矩阵(4h核销)',
|
||
data,
|
||
hint: firstAct ? {
|
||
matrixColStart: String(firstAct.title),
|
||
matrixColEnd: '本次核销合计',
|
||
} : undefined,
|
||
});
|
||
}
|
||
|
||
exportToExcel(sheets, `义工时长-四小时导出-${utc8DateStamp()}.xlsx`);
|
||
};
|
||
|
||
const doImage = async () => {
|
||
if (!previewRef.current) return;
|
||
await exportTableToImage(previewRef.current, `义工时长-四小时导出-${utc8DateStamp()}.png`);
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<header className="mb-2">
|
||
<div className="flex items-start gap-3">
|
||
<div className="shrink-0 p-3 bg-primary-50 text-primary-600 rounded-lg shadow-sm">
|
||
<IconFourHour size={28} />
|
||
</div>
|
||
<div className="flex-1">
|
||
<h1 className="section-title">四小时导出核销</h1>
|
||
<p className="muted mt-1 leading-relaxed">
|
||
按部员维度:每位部员「尚未核销的累计义工时」≥ 4h 即列入当次导出。
|
||
单次活动若剩余时长超过 4h,显示时自动截断为 4.0(并标注「截断」),但原始登记时长不变。
|
||
点击「确认核销」后,本次合计小时数将写入核销记录,下次自动扣除、不会重复统计。
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<section className="card p-4 md:p-5 bg-gradient-to-r from-primary-50/60 to-white border border-primary-100">
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 md:gap-5">
|
||
<div className="flex items-center gap-3 md:gap-4">
|
||
<div className="shrink-0 w-11 h-11 rounded-lg bg-white border border-primary-200 text-primary-600 grid place-items-center shadow-sm">
|
||
<IconUsers size={22} />
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-gray-500 tracking-wide">当次达标部员</div>
|
||
<div className="text-2xl font-bold text-primary-700 leading-tight">
|
||
{preview ? eligibleCount : '—'}
|
||
<span className="ml-1 text-sm font-medium text-gray-500">人</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-3 md:gap-4">
|
||
<div className="shrink-0 w-11 h-11 rounded-lg bg-white border border-primary-200 text-primary-600 grid place-items-center shadow-sm">
|
||
<IconClock size={22} />
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-gray-500 tracking-wide">当次核销总小时数</div>
|
||
<div className="text-2xl font-bold text-primary-700 leading-tight">
|
||
{preview ? eligibleHours : '—'}
|
||
<span className="ml-1 text-sm font-medium text-gray-500">h</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-3 md:gap-4">
|
||
<div className="shrink-0 w-11 h-11 rounded-lg bg-white border border-primary-200 text-primary-600 grid place-items-center shadow-sm">
|
||
<IconCheck size={22} />
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-gray-500 tracking-wide">核销状态</div>
|
||
<div className="text-base md:text-lg font-semibold leading-tight mt-0.5">
|
||
{!preview ? (
|
||
<span className="text-gray-500">尚未生成预览</span>
|
||
) : confirmedMsg ? (
|
||
<span className="text-emerald-700">已确认核销</span>
|
||
) : (
|
||
<span className="text-amber-700">已预览 · 待确认</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 mt-5 pt-4 border-t border-primary-100/70">
|
||
<div className="muted text-sm">
|
||
建议流程:<span className="font-semibold text-gray-700">生成预览 → 核对数据 → 导出 Excel/图片留档 → 确认核销</span>。
|
||
</div>
|
||
<div className="flex gap-2 flex-wrap justify-end">
|
||
<Button variant="secondary" onClick={loadPreview} disabled={loading}>
|
||
<span className="inline-flex items-center gap-2">
|
||
<IconEye size={18} />
|
||
{loading ? '生成中...' : (preview ? '重新生成预览' : '生成预览')}
|
||
</span>
|
||
</Button>
|
||
<Button variant="secondary" onClick={doExcel} disabled={!preview}>
|
||
<span className="inline-flex items-center gap-2"><IconFileExcel size={18} />导出 Excel</span>
|
||
</Button>
|
||
<Button variant="secondary" onClick={doImage} disabled={!preview}>
|
||
<span className="inline-flex items-center gap-2"><IconFileImage size={18} />导出为图片</span>
|
||
</Button>
|
||
<Button onClick={doConfirm} disabled={!preview || confirming || !!confirmedMsg}>
|
||
<span className="inline-flex items-center gap-2">
|
||
<IconCheck size={18} />
|
||
{confirming ? '确认中...' : '确认核销(写入记录)'}
|
||
</span>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{confirmedMsg ? (
|
||
<div className="card p-4 bg-emerald-50 border border-emerald-200 text-emerald-800 text-sm leading-relaxed whitespace-pre-line">
|
||
{confirmedMsg}
|
||
</div>
|
||
) : null}
|
||
|
||
<section className="card p-4 md:p-5">
|
||
<div className="flex items-start justify-between gap-3 flex-wrap mb-4">
|
||
<div className="inline-flex items-center gap-2">
|
||
<div className="shrink-0 p-2.5 bg-gray-50 text-primary-600 rounded-lg border border-gray-200 shadow-sm">
|
||
<IconHistory size={20} />
|
||
</div>
|
||
<div>
|
||
<h3 className="font-semibold text-lg text-gray-800 leading-tight">核销批次历史</h3>
|
||
<p className="muted text-sm mt-0.5">
|
||
最近 {Math.min(200, history.length || 0)} 条 · 状态为「active」的批次可一键撤回(把小时数加回未核销余额)
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<Button variant="secondary" size="sm" onClick={loadHistory} disabled={historyLoading}>
|
||
<span className="inline-flex items-center gap-2">
|
||
<IconHistory size={16} />
|
||
{historyLoading ? '刷新中...' : '刷新历史'}
|
||
</span>
|
||
</Button>
|
||
</div>
|
||
{history.length === 0 ? (
|
||
<div className="py-10 text-center text-sm text-gray-500 border border-dashed border-gray-200 rounded-lg bg-gray-50/50">
|
||
暂无核销批次记录
|
||
</div>
|
||
) : (
|
||
<div className="overflow-x-auto border border-gray-200 rounded-lg bg-white">
|
||
<table className="w-full text-sm border-collapse">
|
||
<thead>
|
||
<tr className="bg-gray-100 border-b border-gray-200">
|
||
{['批次', '时间', '状态', '操作人', '达标部员', '核销合计', '明细条数', '撤回时间', '操作'].map((h) => (
|
||
<th key={h} className="px-3 py-2.5 text-left font-semibold whitespace-nowrap text-gray-800">{h}</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{history.map((it, i) => {
|
||
const isVoided = it.status === 'voided';
|
||
return (
|
||
<tr
|
||
key={it.id}
|
||
className={`border-b border-gray-200 ${i % 2 ? 'bg-gray-50' : 'bg-white'} hover:bg-primary-50 transition-colors`}
|
||
>
|
||
<td className="px-3 py-2 whitespace-nowrap font-mono text-xs text-gray-600">#{it.id}</td>
|
||
<td className="px-3 py-2 whitespace-nowrap">{String(it.created_at || '').slice(0, 19)}</td>
|
||
<td className="px-3 py-2 whitespace-nowrap">
|
||
{isVoided ? (
|
||
<span className="text-xs font-medium text-gray-600 bg-gray-100 border border-gray-200 rounded px-2 py-0.5">
|
||
已撤回
|
||
</span>
|
||
) : (
|
||
<span className="text-xs font-medium text-emerald-700 bg-emerald-50 border border-emerald-200 rounded px-2 py-0.5">
|
||
active
|
||
</span>
|
||
)}
|
||
</td>
|
||
<td className="px-3 py-2 whitespace-nowrap">{it.admin_username || '—'}</td>
|
||
<td className="px-3 py-2 whitespace-nowrap text-center font-medium">{it.eligible_count}</td>
|
||
<td className="px-3 py-2 whitespace-nowrap text-center font-semibold text-primary-700">
|
||
{Number(it.eligible_hours_total || 0).toFixed(1)} h
|
||
</td>
|
||
<td className="px-3 py-2 whitespace-nowrap text-center">{it.items_updated}</td>
|
||
<td className="px-3 py-2 whitespace-nowrap text-xs text-gray-500">
|
||
{isVoided ? String(it.voided_at || '').slice(0, 19) : '—'}
|
||
</td>
|
||
<td className="px-3 py-2 whitespace-nowrap">
|
||
<Button
|
||
size="sm"
|
||
variant={isVoided ? 'ghost' : 'danger'}
|
||
disabled={!it.can_void || voidingId === it.id}
|
||
onClick={() => doVoid(it)}
|
||
className="!px-3"
|
||
>
|
||
<span className="inline-flex items-center gap-1.5">
|
||
<IconUndo size={15} />
|
||
{voidingId === it.id ? '撤回中...' : '撤回'}
|
||
</span>
|
||
</Button>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
{preview ? (
|
||
<div ref={previewRef} className="card bg-white p-3 md:p-6 space-y-8">
|
||
<div className="border-b border-gray-200 pb-4 text-center">
|
||
<div className="text-xl md:text-2xl font-semibold tracking-tight text-gray-800">
|
||
宣传部义工时长记录系统 · 四小时导出核销报告
|
||
</div>
|
||
<div className="muted mt-1.5">
|
||
达标部员 {preview.summary.eligible_count} 人 · 本次核销合计 {Number(preview.summary.eligible_hours_total).toFixed(1)}h
|
||
{' | '}生成时间:{utc8DateTimeStamp()}
|
||
</div>
|
||
</div>
|
||
|
||
{preview.hours?.length ? (
|
||
<section className="animate-fade-in-up">
|
||
<h3 className="font-semibold text-lg mb-3 border-l-4 border-primary-500 pl-3 inline-flex items-center gap-2 text-gray-800">
|
||
<span className="chip-primary">A</span>
|
||
四小时核销 · 总合明细平铺({preview.hours.length} 条)
|
||
</h3>
|
||
<div className="text-xs text-gray-500 mb-2 leading-relaxed">
|
||
每行对应「部员 × 活动」的本次核销小时数,超过单条上限 4h 的单元格会标注「截断」,剩余未核销部分留待下次四小时导出继续统计。
|
||
</div>
|
||
<MiniTable
|
||
heads={['序号', '班级', '姓名', '义工号', '工作组', '活动名称', '活动时间', '本次核销(h)', '备注']}
|
||
rows={preview.hours.map((h: any, i) => [
|
||
i + 1,
|
||
h.class,
|
||
h.name,
|
||
<span key="vn" className="font-mono text-[12px] tracking-wider text-gray-700">{h.volunteer_no || '-'}</span>,
|
||
h.group_name || '-',
|
||
h.activity_title,
|
||
String(h.activity_start_time || '').replace('T', ' ').slice(0, 16),
|
||
<span key="h" className="inline-flex items-center gap-1.5">
|
||
<span className={h.truncated ? 'text-amber-700 font-semibold' : 'text-primary-700 font-semibold'}>
|
||
{Number(h.hours || 0).toFixed(1)}
|
||
</span>
|
||
{h.truncated ? (
|
||
<span className="text-[11px] font-medium text-amber-700 bg-amber-50 border border-amber-200 rounded px-1.5 py-0.5">
|
||
截断
|
||
</span>
|
||
) : null}
|
||
</span>,
|
||
h.remark || '-',
|
||
])}
|
||
/>
|
||
</section>
|
||
) : null}
|
||
|
||
{preview.user_totals?.length ? (
|
||
<section className="animate-fade-in-up">
|
||
<h3 className="font-semibold text-lg mb-3 border-l-4 border-primary-500 pl-3 inline-flex items-center gap-2 text-gray-800">
|
||
<span className="chip-primary">B</span>
|
||
部员核销汇总({preview.user_totals.length} 人)
|
||
</h3>
|
||
<MiniTable
|
||
heads={['排名', '班级', '姓名', '义工号', '工作组', '本次核销(h)', '核销后剩余未导出(h)']}
|
||
rows={preview.user_totals.map((u) => [
|
||
u.rank,
|
||
u.class,
|
||
u.name,
|
||
<span key="vn" className="font-mono text-[12px] tracking-wider text-gray-700">{u.volunteer_no || '-'}</span>,
|
||
u.group_name || '-',
|
||
<span key="t" className="font-semibold text-primary-700">{Number(u.exported_total || 0).toFixed(1)}</span>,
|
||
<span key="r" className="font-medium text-gray-600">{Number(u.unexported_remaining || 0).toFixed(1)}</span>,
|
||
])}
|
||
/>
|
||
</section>
|
||
) : null}
|
||
|
||
{preview.activity_matrix?.activities?.length ? (
|
||
<section className="card p-5 animate-fade-in-up">
|
||
<h3 className="font-semibold text-lg mb-3 border-l-4 border-primary-500 pl-3 inline-flex items-center gap-2 text-gray-800">
|
||
<span className="chip-primary">C</span>
|
||
活动矩阵 · 四小时核销视图
|
||
<span className="chip ml-2">
|
||
{preview.activity_matrix.users.length} 人 × {preview.activity_matrix.activities.length} 活动
|
||
</span>
|
||
<span className="ml-3 inline-flex items-center gap-1.5 text-xs font-medium text-amber-700 bg-amber-50 border border-amber-200 rounded px-2 py-0.5">
|
||
「截断」= 剩余未核销超过当次单活动上限,下次四小时导出可继续使用
|
||
</span>
|
||
</h3>
|
||
<div className="overflow-x-auto">
|
||
<table className="min-w-full text-sm border-collapse border border-gray-200 rounded-lg bg-white">
|
||
<thead>
|
||
<tr className="bg-gray-100 border-b border-gray-200">
|
||
<th className="px-3 py-2.5 text-left font-semibold whitespace-nowrap border-r border-gray-200 text-gray-800 sticky left-0 bg-gray-100 z-10">
|
||
部员
|
||
</th>
|
||
<th className="px-3 py-2.5 text-left font-semibold whitespace-nowrap border-r border-gray-200 text-gray-800 sticky left-[96px] bg-gray-100 z-10">
|
||
义工号
|
||
</th>
|
||
{preview.activity_matrix.activities.map((a, i) => {
|
||
const last = i < preview.activity_matrix!.activities!.length - 1;
|
||
return (
|
||
<th
|
||
key={a.id}
|
||
className={`px-3 py-2.5 text-center font-semibold whitespace-nowrap text-gray-800 ${last ? 'border-r border-gray-200' : ''}`}
|
||
style={{ minWidth: 180 }}
|
||
title={`${a.title}\n${String(a.start_time || '').slice(0, 16)}`}
|
||
>
|
||
<div className="font-semibold">{a.title}</div>
|
||
<div className="mt-0.5 text-[11px] text-gray-500 font-normal">本次核销 / 备注</div>
|
||
<div className="mt-0.5 text-[11px] text-gray-500 font-normal">{String(a.start_time || '').slice(0, 10)}</div>
|
||
</th>
|
||
);
|
||
})}
|
||
<th className="px-3 py-2.5 text-center font-semibold whitespace-nowrap border-l border-gray-200 text-gray-800 bg-primary-50/60">
|
||
本次合计
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{preview.activity_matrix.users.map((u: any, ri) => {
|
||
const bg = ri % 2 ? '#f9fafb' : '#fff';
|
||
return (
|
||
<tr
|
||
key={u.id}
|
||
className={`border-b border-gray-200 ${ri % 2 ? 'bg-gray-50' : 'bg-white'} hover:bg-primary-50 transition-colors`}
|
||
>
|
||
<td
|
||
className="px-3 py-2 whitespace-nowrap border-r border-gray-200 font-medium text-gray-800 sticky left-0 z-10"
|
||
style={{ background: bg }}
|
||
>
|
||
{u.name}
|
||
</td>
|
||
<td
|
||
className="px-3 py-2 whitespace-nowrap border-r border-gray-200 font-mono text-xs text-gray-600 sticky left-[96px] z-10"
|
||
style={{ background: bg }}
|
||
>
|
||
{u.volunteer_no || '-'}
|
||
</td>
|
||
{preview.activity_matrix!.activities!.map((a, i) => {
|
||
const last = i < preview.activity_matrix!.activities!.length - 1;
|
||
const v = Number(u[String(a.id)] || 0);
|
||
const r = (u as any)?._remarks?.[String(a.id)];
|
||
const tr = !!(u as any)?._truncated?.[String(a.id)];
|
||
return (
|
||
<td
|
||
key={a.id}
|
||
className={`px-2 py-2 whitespace-normal text-center align-top ${last ? 'border-r border-gray-200/70' : ''}`}
|
||
style={{ verticalAlign: 'top' }}
|
||
>
|
||
<CellHours v={v} truncated={tr} remark={r} />
|
||
</td>
|
||
);
|
||
})}
|
||
<td className="px-3 py-2 whitespace-nowrap text-center border-l border-gray-200 font-semibold bg-primary-50/70 text-primary-700">
|
||
{Number(u._total || 0).toFixed(1)}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
{!preview.hours?.length && !preview.activity_matrix?.activities?.length ? (
|
||
<div className="py-14 text-center text-gray-500">
|
||
<div className="text-base font-medium text-gray-700 mb-1">目前没有部员的未核销累计时长达到或超过 4.0 小时</div>
|
||
<div className="text-sm">请先在「时长登记」中登记义工时,或等待部员累计到满足门槛后再次生成预览。</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|