Files
VolunteerManage-for-byw/client/src/pages/admin/FourHourExport.tsx
T
2026-10-07 01:04:55 +08:00

615 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}