This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { useRef, useState } from 'react';
|
||||
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 { FourHourPreviewResult } from '../../types';
|
||||
import type { FourHourExportHistoryItem, FourHourPreviewResult } from '../../types';
|
||||
import {
|
||||
IconFourHour,
|
||||
IconFileExcel,
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
IconCheck,
|
||||
IconUsers,
|
||||
IconClock,
|
||||
IconUndo,
|
||||
IconHistory,
|
||||
} from '../../components/Icons';
|
||||
|
||||
function MiniTable({ heads, rows }: { heads: string[]; rows: any[][] }) {
|
||||
@@ -69,8 +71,29 @@ export default function FourHourExport() {
|
||||
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);
|
||||
@@ -101,16 +124,22 @@ export default function FourHourExport() {
|
||||
setConfirming(true);
|
||||
(async () => {
|
||||
try {
|
||||
const r = await exportApi.fourHourConfirm(preview.confirm_plan);
|
||||
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\n` +
|
||||
`跳过(并发冲突/已结算):${r.skipped}\n` +
|
||||
`本次实际核销合计:${Number(r.total_applied || 0).toFixed(1)} h\n\n` +
|
||||
`请点击下方「重新生成预览」以查看核销后的最新数据。`;
|
||||
setConfirmedMsg(msg2);
|
||||
alert(msg2);
|
||||
loadHistory();
|
||||
} else {
|
||||
alert('确认失败:服务器未返回成功状态');
|
||||
}
|
||||
@@ -122,6 +151,44 @@ export default function FourHourExport() {
|
||||
})();
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
@@ -290,6 +357,93 @@ export default function FourHourExport() {
|
||||
</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">
|
||||
|
||||
Reference in New Issue
Block a user