forked from wangziqi/gongxue-base
feat: 为批量归档补充批量恢复
This commit is contained in:
@@ -22,6 +22,7 @@ import {
|
||||
UploadOutlined,
|
||||
DownloadOutlined,
|
||||
ExportOutlined,
|
||||
UndoOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
@@ -30,6 +31,7 @@ import EditableCell from '../../components/EditableCell';
|
||||
import { downloadBlob } from '../../utils/download';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import { archiveViewPolicy, expenseStatusForView } from '../archive-view';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
@@ -61,6 +63,8 @@ const ExpensesPage: React.FC = () => {
|
||||
const [selectedPersonalKeys, setSelectedPersonalKeys] = useState<number[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const expenseViewPolicy = archiveViewPolicy(showArchived ? 'archived' : 'active');
|
||||
|
||||
// Dynamic expense type options from API
|
||||
const [typeOptions, setTypeOptions] = useState<{ value: string; label: string }[]>([]);
|
||||
@@ -124,12 +128,56 @@ const ExpensesPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchRestoreRoom = async () => {
|
||||
if (batchLoading) return;
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const res = await api.put<{ restored: number; skipped: number }>(
|
||||
'/expenses/room/batch-restore',
|
||||
{ ids: selectedRoomKeys },
|
||||
);
|
||||
message.success(
|
||||
`已恢复 ${res.restored} 条宿舍费用${res.skipped ? `,跳过 ${res.skipped} 条` : ''}`,
|
||||
);
|
||||
setSelectedRoomKeys([]);
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '批量恢复失败');
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchRestorePersonal = async () => {
|
||||
if (batchLoading) return;
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const res = await api.put<{ restored: number; skipped: number }>(
|
||||
'/expenses/personal/batch-restore',
|
||||
{ ids: selectedPersonalKeys },
|
||||
);
|
||||
message.success(
|
||||
`已恢复 ${res.restored} 条个人费用${res.skipped ? `,跳过 ${res.skipped} 条` : ''}`,
|
||||
);
|
||||
setSelectedPersonalKeys([]);
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '批量恢复失败');
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [re, pe, lookups]: any[] = await Promise.all([
|
||||
api.get('/expenses/room'),
|
||||
api.get('/expenses/personal'),
|
||||
api.get('/expenses/room', {
|
||||
params: { status: expenseStatusForView(showArchived ? 'archived' : 'active') },
|
||||
}),
|
||||
api.get('/expenses/personal', {
|
||||
params: { status: expenseStatusForView(showArchived ? 'archived' : 'active') },
|
||||
}),
|
||||
api.get('/expenses/lookups').catch(() => ({ rooms: [], students: [] })),
|
||||
]);
|
||||
setRoomExpenses(re);
|
||||
@@ -140,10 +188,12 @@ const ExpensesPage: React.FC = () => {
|
||||
message.error(e?.message || '加载失败,请稍后重试');
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
}, [showArchived]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
setSelectedRoomKeys([]);
|
||||
setSelectedPersonalKeys([]);
|
||||
}, [fetchData]);
|
||||
|
||||
const filteredRoomExpenses = useMemo(() => {
|
||||
@@ -287,6 +337,7 @@ const ExpensesPage: React.FC = () => {
|
||||
editor="select"
|
||||
options={rooms.map((item) => ({ value: item.id, label: item.roomNumber }))}
|
||||
permission="expense:edit"
|
||||
disabled={expenseViewPolicy.readonly}
|
||||
required
|
||||
onSave={(next) => saveRoomCell(r, 'roomId', next)}
|
||||
>
|
||||
@@ -304,6 +355,7 @@ const ExpensesPage: React.FC = () => {
|
||||
editor="select"
|
||||
options={typeOptions}
|
||||
permission="expense:edit"
|
||||
disabled={expenseViewPolicy.readonly}
|
||||
required
|
||||
onSave={(next) => saveRoomCell(r, 'expenseType', next)}
|
||||
>
|
||||
@@ -321,6 +373,7 @@ const ExpensesPage: React.FC = () => {
|
||||
editor="money"
|
||||
min={0.01}
|
||||
permission="expense:edit"
|
||||
disabled={expenseViewPolicy.readonly}
|
||||
required
|
||||
onSave={(next) => saveRoomCell(r, 'amount', next)}
|
||||
>{`¥${Number(v).toFixed(2)}`}</EditableCell>
|
||||
@@ -334,6 +387,7 @@ const ExpensesPage: React.FC = () => {
|
||||
value={[r.periodStart, r.periodEnd]}
|
||||
editor="date-range"
|
||||
permission="expense:edit"
|
||||
disabled={expenseViewPolicy.readonly}
|
||||
required
|
||||
onSave={async (next) => {
|
||||
const [periodStart, periodEnd] = next as unknown as [string, string];
|
||||
@@ -353,6 +407,7 @@ const ExpensesPage: React.FC = () => {
|
||||
value={v}
|
||||
editor="textarea"
|
||||
permission="expense:edit"
|
||||
disabled={expenseViewPolicy.readonly}
|
||||
onSave={(next) => saveRoomCell(r, 'description', next)}
|
||||
>
|
||||
{v || '-'}
|
||||
@@ -368,48 +423,60 @@ const ExpensesPage: React.FC = () => {
|
||||
{
|
||||
title: '操作',
|
||||
width: 120,
|
||||
render: (_: any, record: any) => (
|
||||
<Space>
|
||||
<PermissionButton
|
||||
permission="expense:edit"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => {
|
||||
setEditingRoom(record);
|
||||
roomForm.setFieldsValue({
|
||||
roomId: record.roomId,
|
||||
expenseType: record.expenseType,
|
||||
amount: Number(record.amount),
|
||||
period: [dayjs(record.periodStart), dayjs(record.periodEnd)],
|
||||
description: record.description,
|
||||
});
|
||||
setRoomModal(true);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</PermissionButton>
|
||||
<Popconfirm
|
||||
title="确定归档?"
|
||||
onConfirm={async () => {
|
||||
await api.delete(`/expenses/room/${record.id}`);
|
||||
message.success('归档成功');
|
||||
fetchData();
|
||||
}}
|
||||
>
|
||||
render: (_: any, record: any) =>
|
||||
showArchived ? (
|
||||
<Tag color="#999">已归档</Tag>
|
||||
) : (
|
||||
<Space>
|
||||
<PermissionButton
|
||||
permission="expense:delete"
|
||||
permission="expense:edit"
|
||||
size="small"
|
||||
danger
|
||||
icon={<InboxOutlined />}
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => {
|
||||
setEditingRoom(record);
|
||||
roomForm.setFieldsValue({
|
||||
roomId: record.roomId,
|
||||
expenseType: record.expenseType,
|
||||
amount: Number(record.amount),
|
||||
period: [dayjs(record.periodStart), dayjs(record.periodEnd)],
|
||||
description: record.description,
|
||||
});
|
||||
setRoomModal(true);
|
||||
}}
|
||||
>
|
||||
归档
|
||||
编辑
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
<Popconfirm
|
||||
title="确定归档?"
|
||||
onConfirm={async () => {
|
||||
await api.delete(`/expenses/room/${record.id}`);
|
||||
message.success('归档成功');
|
||||
fetchData();
|
||||
}}
|
||||
>
|
||||
<PermissionButton
|
||||
permission="expense:delete"
|
||||
size="small"
|
||||
danger
|
||||
icon={<InboxOutlined />}
|
||||
>
|
||||
归档
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
],
|
||||
[rooms, typeOptions, typeMap, saveRoomCell, roomForm, fetchData],
|
||||
[
|
||||
rooms,
|
||||
typeOptions,
|
||||
typeMap,
|
||||
saveRoomCell,
|
||||
roomForm,
|
||||
fetchData,
|
||||
showArchived,
|
||||
expenseViewPolicy.readonly,
|
||||
],
|
||||
);
|
||||
|
||||
const personalColumns = useMemo(
|
||||
@@ -423,6 +490,7 @@ const ExpensesPage: React.FC = () => {
|
||||
editor="select"
|
||||
options={students.map((item) => ({ value: item.id, label: item.name }))}
|
||||
permission="expense:edit"
|
||||
disabled={expenseViewPolicy.readonly}
|
||||
required
|
||||
onSave={(next) => savePersonalCell(r, 'studentId', next)}
|
||||
>
|
||||
@@ -440,6 +508,7 @@ const ExpensesPage: React.FC = () => {
|
||||
editor="select"
|
||||
options={personalTypeOptions}
|
||||
permission="expense:edit"
|
||||
disabled={expenseViewPolicy.readonly}
|
||||
required
|
||||
onSave={(next) => savePersonalCell(r, 'expenseType', next)}
|
||||
>
|
||||
@@ -456,6 +525,7 @@ const ExpensesPage: React.FC = () => {
|
||||
editor="money"
|
||||
min={0.01}
|
||||
permission="expense:edit"
|
||||
disabled={expenseViewPolicy.readonly}
|
||||
required
|
||||
onSave={(next) => savePersonalCell(r, 'amount', next)}
|
||||
>{`¥${Number(v).toFixed(2)}`}</EditableCell>
|
||||
@@ -470,6 +540,7 @@ const ExpensesPage: React.FC = () => {
|
||||
value={v}
|
||||
editor="date"
|
||||
permission="expense:edit"
|
||||
disabled={expenseViewPolicy.readonly}
|
||||
required
|
||||
onSave={(next) => savePersonalCell(r, 'expenseDate', next)}
|
||||
>
|
||||
@@ -486,6 +557,7 @@ const ExpensesPage: React.FC = () => {
|
||||
value={v}
|
||||
editor="textarea"
|
||||
permission="expense:edit"
|
||||
disabled={expenseViewPolicy.readonly}
|
||||
onSave={(next) => savePersonalCell(r, 'description', next)}
|
||||
>
|
||||
{v || '-'}
|
||||
@@ -495,53 +567,73 @@ const ExpensesPage: React.FC = () => {
|
||||
{
|
||||
title: '操作',
|
||||
width: 120,
|
||||
render: (_: any, record: any) => (
|
||||
<Space>
|
||||
<PermissionButton
|
||||
permission="expense:edit"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => {
|
||||
setEditingPersonal(record);
|
||||
personalForm.setFieldsValue({
|
||||
studentId: record.studentId,
|
||||
roomId: record.roomId,
|
||||
expenseType: record.expenseType,
|
||||
amount: Number(record.amount),
|
||||
expenseDate: dayjs(record.expenseDate),
|
||||
description: record.description,
|
||||
});
|
||||
setPersonalModal(true);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</PermissionButton>
|
||||
<Popconfirm
|
||||
title="确定归档?"
|
||||
onConfirm={async () => {
|
||||
await api.delete(`/expenses/personal/${record.id}`);
|
||||
message.success('归档成功');
|
||||
fetchData();
|
||||
}}
|
||||
>
|
||||
render: (_: any, record: any) =>
|
||||
showArchived ? (
|
||||
<Tag color="#999">已归档</Tag>
|
||||
) : (
|
||||
<Space>
|
||||
<PermissionButton
|
||||
permission="expense:delete"
|
||||
permission="expense:edit"
|
||||
size="small"
|
||||
danger
|
||||
icon={<InboxOutlined />}
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => {
|
||||
setEditingPersonal(record);
|
||||
personalForm.setFieldsValue({
|
||||
studentId: record.studentId,
|
||||
roomId: record.roomId,
|
||||
expenseType: record.expenseType,
|
||||
amount: Number(record.amount),
|
||||
expenseDate: dayjs(record.expenseDate),
|
||||
description: record.description,
|
||||
});
|
||||
setPersonalModal(true);
|
||||
}}
|
||||
>
|
||||
归档
|
||||
编辑
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
<Popconfirm
|
||||
title="确定归档?"
|
||||
onConfirm={async () => {
|
||||
await api.delete(`/expenses/personal/${record.id}`);
|
||||
message.success('归档成功');
|
||||
fetchData();
|
||||
}}
|
||||
>
|
||||
<PermissionButton
|
||||
permission="expense:delete"
|
||||
size="small"
|
||||
danger
|
||||
icon={<InboxOutlined />}
|
||||
>
|
||||
归档
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
],
|
||||
[students, personalTypeOptions, typeMap, savePersonalCell, personalForm, fetchData],
|
||||
[
|
||||
students,
|
||||
personalTypeOptions,
|
||||
typeMap,
|
||||
savePersonalCell,
|
||||
personalForm,
|
||||
fetchData,
|
||||
showArchived,
|
||||
expenseViewPolicy.readonly,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Button type={!showArchived ? 'primary' : 'default'} onClick={() => setShowArchived(false)}>
|
||||
正常费用
|
||||
</Button>
|
||||
<Button type={showArchived ? 'primary' : 'default'} onClick={() => setShowArchived(true)}>
|
||||
已归档费用
|
||||
</Button>
|
||||
</Space>
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
@@ -576,7 +668,7 @@ const ExpensesPage: React.FC = () => {
|
||||
onChange={(v) => setRoomTypeFilter(v)}
|
||||
options={typeOptions}
|
||||
/>
|
||||
{hasPermission('expense:create') ? (
|
||||
{!showArchived && hasPermission('expense:create') ? (
|
||||
<Upload
|
||||
accept=".xlsx,.xls"
|
||||
showUploadList={false}
|
||||
@@ -605,47 +697,71 @@ const ExpensesPage: React.FC = () => {
|
||||
<Button icon={<UploadOutlined />}>导入水电费Excel</Button>
|
||||
</Upload>
|
||||
) : null}
|
||||
<PermissionButton
|
||||
permission="expense:view"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => {
|
||||
downloadBlob('/expenses/utility/template', '水电费导入模板.xlsx').catch(
|
||||
() => message.error('下载失败'),
|
||||
);
|
||||
}}
|
||||
>
|
||||
下载水电费模板
|
||||
</PermissionButton>
|
||||
{!showArchived ? (
|
||||
<PermissionButton
|
||||
permission="expense:view"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => {
|
||||
downloadBlob('/expenses/utility/template', '水电费导入模板.xlsx').catch(
|
||||
() => message.error('下载失败'),
|
||||
);
|
||||
}}
|
||||
>
|
||||
下载水电费模板
|
||||
</PermissionButton>
|
||||
) : null}
|
||||
</Space>
|
||||
<Space>
|
||||
<Popconfirm
|
||||
title={`确定归档选中的 ${selectedRoomKeys.length} 条费用?`}
|
||||
onConfirm={handleBatchDeleteRoom}
|
||||
okText="归档"
|
||||
cancelText="取消"
|
||||
disabled={selectedRoomKeys.length === 0}
|
||||
>
|
||||
<PermissionButton
|
||||
permission="expense:delete"
|
||||
danger
|
||||
icon={<InboxOutlined />}
|
||||
{showArchived ? (
|
||||
<Popconfirm
|
||||
title={`确定恢复选中的 ${selectedRoomKeys.length} 条费用?`}
|
||||
onConfirm={handleBatchRestoreRoom}
|
||||
okText="恢复"
|
||||
cancelText="取消"
|
||||
disabled={selectedRoomKeys.length === 0}
|
||||
>
|
||||
批量归档
|
||||
<PermissionButton
|
||||
permission="expense:edit"
|
||||
type="primary"
|
||||
icon={<UndoOutlined />}
|
||||
loading={batchLoading}
|
||||
disabled={selectedRoomKeys.length === 0}
|
||||
>
|
||||
批量恢复
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
) : (
|
||||
<Popconfirm
|
||||
title={`确定归档选中的 ${selectedRoomKeys.length} 条费用?`}
|
||||
onConfirm={handleBatchDeleteRoom}
|
||||
okText="归档"
|
||||
cancelText="取消"
|
||||
disabled={selectedRoomKeys.length === 0}
|
||||
>
|
||||
<PermissionButton
|
||||
permission="expense:delete"
|
||||
danger
|
||||
icon={<InboxOutlined />}
|
||||
disabled={selectedRoomKeys.length === 0}
|
||||
>
|
||||
批量归档
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
)}
|
||||
{!showArchived ? (
|
||||
<PermissionButton
|
||||
permission="expense:create"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setEditingRoom(null);
|
||||
roomForm.resetFields();
|
||||
setRoomModal(true);
|
||||
}}
|
||||
>
|
||||
录入宿舍费用
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
<PermissionButton
|
||||
permission="expense:create"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setEditingRoom(null);
|
||||
roomForm.resetFields();
|
||||
setRoomModal(true);
|
||||
}}
|
||||
>
|
||||
录入宿舍费用
|
||||
</PermissionButton>
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
<Table
|
||||
@@ -701,7 +817,7 @@ const ExpensesPage: React.FC = () => {
|
||||
onChange={(v) => setPersonalTypeFilter(v)}
|
||||
options={personalTypeOptions}
|
||||
/>
|
||||
{hasPermission('expense:create') ? (
|
||||
{!showArchived && hasPermission('expense:create') ? (
|
||||
<Upload
|
||||
accept=".xlsx,.xls"
|
||||
showUploadList={false}
|
||||
@@ -724,69 +840,97 @@ const ExpensesPage: React.FC = () => {
|
||||
<Button icon={<UploadOutlined />}>导入个人附加费</Button>
|
||||
</Upload>
|
||||
) : null}
|
||||
<PermissionButton
|
||||
permission="expense:view"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => {
|
||||
downloadBlob(
|
||||
'/expenses/personal/template',
|
||||
'个人附加费导入模板.xlsx',
|
||||
).catch(() => message.error('下载失败'));
|
||||
}}
|
||||
>
|
||||
下载模板
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
permission="expense:view"
|
||||
icon={<ExportOutlined />}
|
||||
onClick={() => {
|
||||
downloadBlob('/expenses/personal/export', '个人附加费导出.xlsx').catch(() =>
|
||||
message.error('导出失败'),
|
||||
);
|
||||
}}
|
||||
>
|
||||
导出
|
||||
</PermissionButton>
|
||||
{!showArchived ? (
|
||||
<PermissionButton
|
||||
permission="expense:view"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => {
|
||||
downloadBlob(
|
||||
'/expenses/personal/template',
|
||||
'个人附加费导入模板.xlsx',
|
||||
).catch(() => message.error('下载失败'));
|
||||
}}
|
||||
>
|
||||
下载模板
|
||||
</PermissionButton>
|
||||
) : null}
|
||||
{!showArchived ? (
|
||||
<PermissionButton
|
||||
permission="expense:view"
|
||||
icon={<ExportOutlined />}
|
||||
onClick={() => {
|
||||
downloadBlob('/expenses/personal/export', '个人附加费导出.xlsx').catch(
|
||||
() => message.error('导出失败'),
|
||||
);
|
||||
}}
|
||||
>
|
||||
导出
|
||||
</PermissionButton>
|
||||
) : null}
|
||||
</Space>
|
||||
<Space>
|
||||
<Popconfirm
|
||||
title={`确定归档选中的 ${selectedPersonalKeys.length} 条个人费用?`}
|
||||
onConfirm={handleBatchDeletePersonal}
|
||||
okText="归档"
|
||||
cancelText="取消"
|
||||
disabled={selectedPersonalKeys.length === 0}
|
||||
>
|
||||
<PermissionButton
|
||||
permission="expense:delete"
|
||||
danger
|
||||
icon={<InboxOutlined />}
|
||||
{showArchived ? (
|
||||
<Popconfirm
|
||||
title={`确定恢复选中的 ${selectedPersonalKeys.length} 条个人费用?`}
|
||||
onConfirm={handleBatchRestorePersonal}
|
||||
okText="恢复"
|
||||
cancelText="取消"
|
||||
disabled={selectedPersonalKeys.length === 0}
|
||||
>
|
||||
批量归档
|
||||
<PermissionButton
|
||||
permission="expense:edit"
|
||||
type="primary"
|
||||
icon={<UndoOutlined />}
|
||||
loading={batchLoading}
|
||||
disabled={selectedPersonalKeys.length === 0}
|
||||
>
|
||||
批量恢复
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
) : (
|
||||
<Popconfirm
|
||||
title={`确定归档选中的 ${selectedPersonalKeys.length} 条个人费用?`}
|
||||
onConfirm={handleBatchDeletePersonal}
|
||||
okText="归档"
|
||||
cancelText="取消"
|
||||
disabled={selectedPersonalKeys.length === 0}
|
||||
>
|
||||
<PermissionButton
|
||||
permission="expense:delete"
|
||||
danger
|
||||
icon={<InboxOutlined />}
|
||||
disabled={selectedPersonalKeys.length === 0}
|
||||
>
|
||||
批量归档
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
)}
|
||||
{!showArchived ? (
|
||||
<PermissionButton
|
||||
permission="expense:create"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
utilityForm.resetFields();
|
||||
setUtilityModal(true);
|
||||
}}
|
||||
>
|
||||
添加学生水电费
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
<PermissionButton
|
||||
permission="expense:create"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
utilityForm.resetFields();
|
||||
setUtilityModal(true);
|
||||
}}
|
||||
>
|
||||
添加学生水电费
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
permission="expense:create"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setEditingPersonal(null);
|
||||
personalForm.resetFields();
|
||||
setPersonalModal(true);
|
||||
}}
|
||||
>
|
||||
录入个人费用
|
||||
</PermissionButton>
|
||||
) : null}
|
||||
{!showArchived ? (
|
||||
<PermissionButton
|
||||
permission="expense:create"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setEditingPersonal(null);
|
||||
personalForm.resetFields();
|
||||
setPersonalModal(true);
|
||||
}}
|
||||
>
|
||||
录入个人费用
|
||||
</PermissionButton>
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
<Table
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
UploadOutlined,
|
||||
DownloadOutlined,
|
||||
ExportOutlined,
|
||||
UndoOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import api from '../../api';
|
||||
@@ -34,6 +35,7 @@ import PermissionButton from '../../components/PermissionButton';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { buildCheckInPayload, buildTransferPayload } from './occupancy-form';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import { occupancyParamsForView, occupancyViewPolicy, type OccupancyView } from '../archive-view';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
@@ -50,7 +52,8 @@ const OccupanciesPage: React.FC = () => {
|
||||
const [checkInModal, setCheckInModal] = useState(false);
|
||||
const [checkOutModal, setCheckOutModal] = useState<any>(null);
|
||||
const [transferModal, setTransferModal] = useState<any>(null);
|
||||
const [showActive, setShowActive] = useState(true);
|
||||
const [viewMode, setViewMode] = useState<OccupancyView>('active');
|
||||
const viewPolicy = occupancyViewPolicy(viewMode);
|
||||
const [autoDeposit, setAutoDeposit] = useState(true);
|
||||
const [depositAmount, setDepositAmount] = useState(500);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
@@ -73,10 +76,30 @@ const OccupanciesPage: React.FC = () => {
|
||||
const selectedTransferRoomId = Form.useWatch('newRoomId', transferForm);
|
||||
|
||||
// Close modals when the user loses the required permission
|
||||
useEffect(() => { if (!canCheckIn) { setCheckInModal(false); checkInForm.resetFields(); } }, [canCheckIn, checkInForm]);
|
||||
useEffect(() => { if (!canCheckOut && checkOutModal) { setCheckOutModal(null); checkOutForm.resetFields(); } }, [canCheckOut, checkOutModal, checkOutForm]);
|
||||
useEffect(() => { if (!canCheckOut) { setBatchCheckOutModal(false); batchCheckOutForm.resetFields(); } }, [canCheckOut, batchCheckOutForm]);
|
||||
useEffect(() => { if (!canTransfer && transferModal) { setTransferModal(null); transferForm.resetFields(); } }, [canTransfer, transferModal, transferForm]);
|
||||
useEffect(() => {
|
||||
if (!canCheckIn) {
|
||||
setCheckInModal(false);
|
||||
checkInForm.resetFields();
|
||||
}
|
||||
}, [canCheckIn, checkInForm]);
|
||||
useEffect(() => {
|
||||
if (!canCheckOut && checkOutModal) {
|
||||
setCheckOutModal(null);
|
||||
checkOutForm.resetFields();
|
||||
}
|
||||
}, [canCheckOut, checkOutModal, checkOutForm]);
|
||||
useEffect(() => {
|
||||
if (!canCheckOut) {
|
||||
setBatchCheckOutModal(false);
|
||||
batchCheckOutForm.resetFields();
|
||||
}
|
||||
}, [canCheckOut, batchCheckOutForm]);
|
||||
useEffect(() => {
|
||||
if (!canTransfer && transferModal) {
|
||||
setTransferModal(null);
|
||||
transferForm.resetFields();
|
||||
}
|
||||
}, [canTransfer, transferModal, transferForm]);
|
||||
|
||||
const activeOccupancyByStudentId = useMemo(() => {
|
||||
const map = new Map<number, any>();
|
||||
@@ -139,7 +162,7 @@ const OccupanciesPage: React.FC = () => {
|
||||
const [occRes, stuRes, rmRes] = (await Promise.allSettled([
|
||||
api.get('/occupancies', {
|
||||
params: {
|
||||
active: showActive ? 'true' : undefined,
|
||||
...occupancyParamsForView(viewMode),
|
||||
dateFrom: dateRange?.[0]?.format('YYYY-MM-DD'),
|
||||
dateTo: dateRange?.[1]?.format('YYYY-MM-DD'),
|
||||
},
|
||||
@@ -161,7 +184,7 @@ const OccupanciesPage: React.FC = () => {
|
||||
message.error('数据加载异常');
|
||||
}
|
||||
setLoading(false);
|
||||
}, [showActive, dateRange]);
|
||||
}, [viewMode, dateRange]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
@@ -285,6 +308,7 @@ const OccupanciesPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleBatchCheckOut = async () => {
|
||||
if (batchLoading) return;
|
||||
const values = await batchCheckOutForm.validateFields();
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
@@ -307,6 +331,7 @@ const OccupanciesPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (batchLoading) return;
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const res: any = await api.post('/occupancies/batch-delete', { ids: selectedRowKeys });
|
||||
@@ -320,6 +345,26 @@ const OccupanciesPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchRestore = async () => {
|
||||
if (batchLoading) return;
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const res = await api.put<{ restored: number; skipped: number }>(
|
||||
'/occupancies/batch-restore',
|
||||
{ ids: selectedRowKeys },
|
||||
);
|
||||
message.success(
|
||||
`已恢复 ${res.restored} 条入住记录${res.skipped ? `,跳过 ${res.skipped} 条` : ''}`,
|
||||
);
|
||||
setSelectedRowKeys([]);
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '批量恢复失败');
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{ title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' },
|
||||
@@ -350,7 +395,9 @@ const OccupanciesPage: React.FC = () => {
|
||||
title: '操作',
|
||||
width: 220,
|
||||
render: (_: any, record: any) =>
|
||||
!record.checkOutDate ? (
|
||||
viewPolicy.readonly ? (
|
||||
<Tag color="#999">已归档</Tag>
|
||||
) : !record.checkOutDate ? (
|
||||
<Space>
|
||||
<PermissionButton
|
||||
permission="occupancy:checkout"
|
||||
@@ -394,11 +441,7 @@ const OccupanciesPage: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<InboxOutlined />}
|
||||
>
|
||||
<Button size="small" danger icon={<InboxOutlined />}>
|
||||
归档
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
@@ -407,17 +450,25 @@ const OccupanciesPage: React.FC = () => {
|
||||
),
|
||||
},
|
||||
],
|
||||
[fetchData, setCheckOutModal, checkOutForm, setTransferModal, transferForm],
|
||||
[
|
||||
fetchData,
|
||||
setCheckOutModal,
|
||||
checkOutForm,
|
||||
setTransferModal,
|
||||
transferForm,
|
||||
viewPolicy.readonly,
|
||||
],
|
||||
);
|
||||
|
||||
const rowSelection = useMemo(
|
||||
() => ({
|
||||
selectedRowKeys,
|
||||
onChange: (keys: any[]) => setSelectedRowKeys(keys),
|
||||
// 「在住记录」Tab:禁用已退宿(防止误选用于批量退宿);「全部记录」Tab:均可选用于批量归档
|
||||
getCheckboxProps: (record: any) => (showActive ? { disabled: !!record.checkOutDate } : {}),
|
||||
// 「在住记录」视图禁用已退宿;其余视图中的记录均可选择。
|
||||
getCheckboxProps: (record: any) =>
|
||||
viewMode === 'active' ? { disabled: !!record.checkOutDate } : {},
|
||||
}),
|
||||
[selectedRowKeys, showActive],
|
||||
[selectedRowKeys, viewMode],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -432,12 +483,24 @@ const OccupanciesPage: React.FC = () => {
|
||||
/>
|
||||
<div className="responsive-toolbar">
|
||||
<Space wrap className="responsive-toolbar__group">
|
||||
<Button type={showActive ? 'primary' : 'default'} onClick={() => setShowActive(true)}>
|
||||
<Button
|
||||
type={viewMode === 'active' ? 'primary' : 'default'}
|
||||
onClick={() => setViewMode('active')}
|
||||
>
|
||||
在住记录
|
||||
</Button>
|
||||
<Button type={!showActive ? 'primary' : 'default'} onClick={() => setShowActive(false)}>
|
||||
<Button
|
||||
type={viewMode === 'all' ? 'primary' : 'default'}
|
||||
onClick={() => setViewMode('all')}
|
||||
>
|
||||
全部记录
|
||||
</Button>
|
||||
<Button
|
||||
type={viewMode === 'archived' ? 'primary' : 'default'}
|
||||
onClick={() => setViewMode('archived')}
|
||||
>
|
||||
已归档
|
||||
</Button>
|
||||
<Input.Search
|
||||
placeholder="搜索学生姓名或房间号"
|
||||
onSearch={setSearchText}
|
||||
@@ -454,29 +517,31 @@ const OccupanciesPage: React.FC = () => {
|
||||
/>
|
||||
</Space>
|
||||
<Space wrap className="responsive-toolbar__group">
|
||||
<PermissionButton
|
||||
permission="occupancy:checkin"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
checkInForm.resetFields();
|
||||
setAvailableBeds([]);
|
||||
setAvailableLockers([]);
|
||||
setAvailableResourcesLoading(false);
|
||||
const today = dayjs();
|
||||
checkInForm.setFieldsValue({
|
||||
checkInDate: today,
|
||||
billingStartDate: today,
|
||||
stayType: 'short',
|
||||
collectDeposit: true,
|
||||
depositAmount: 500,
|
||||
});
|
||||
setCheckInModal(true);
|
||||
}}
|
||||
>
|
||||
入住登记
|
||||
</PermissionButton>
|
||||
{canCheckIn ? (
|
||||
{viewMode !== 'archived' ? (
|
||||
<PermissionButton
|
||||
permission="occupancy:checkin"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
checkInForm.resetFields();
|
||||
setAvailableBeds([]);
|
||||
setAvailableLockers([]);
|
||||
setAvailableResourcesLoading(false);
|
||||
const today = dayjs();
|
||||
checkInForm.setFieldsValue({
|
||||
checkInDate: today,
|
||||
billingStartDate: today,
|
||||
stayType: 'short',
|
||||
collectDeposit: true,
|
||||
depositAmount: 500,
|
||||
});
|
||||
setCheckInModal(true);
|
||||
}}
|
||||
>
|
||||
入住登记
|
||||
</PermissionButton>
|
||||
) : null}
|
||||
{viewMode !== 'archived' && canCheckIn ? (
|
||||
<>
|
||||
<Upload
|
||||
accept=".xlsx,.xls"
|
||||
@@ -547,30 +612,34 @@ const OccupanciesPage: React.FC = () => {
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
<PermissionButton
|
||||
permission="occupancy:view"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => {
|
||||
downloadBlob('/occupancies/template', '入住名单导入模板.xlsx').catch(() =>
|
||||
message.error('下载失败'),
|
||||
);
|
||||
}}
|
||||
>
|
||||
下载模板
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
permission="occupancy:view"
|
||||
icon={<ExportOutlined />}
|
||||
onClick={() => {
|
||||
const params = showActive ? '?active=true' : '';
|
||||
const filename = showActive ? '在住记录.xlsx' : '全部入住记录.xlsx';
|
||||
downloadBlob('/occupancies/export' + params, filename).catch(() =>
|
||||
message.error('导出失败'),
|
||||
);
|
||||
}}
|
||||
>
|
||||
导出记录
|
||||
</PermissionButton>
|
||||
{viewMode !== 'archived' ? (
|
||||
<PermissionButton
|
||||
permission="occupancy:view"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => {
|
||||
downloadBlob('/occupancies/template', '入住名单导入模板.xlsx').catch(() =>
|
||||
message.error('下载失败'),
|
||||
);
|
||||
}}
|
||||
>
|
||||
下载模板
|
||||
</PermissionButton>
|
||||
) : null}
|
||||
{viewMode !== 'archived' ? (
|
||||
<PermissionButton
|
||||
permission="occupancy:view"
|
||||
icon={<ExportOutlined />}
|
||||
onClick={() => {
|
||||
const params = viewMode === 'active' ? '?active=true' : '';
|
||||
const filename = viewMode === 'active' ? '在住记录.xlsx' : '全部入住记录.xlsx';
|
||||
downloadBlob('/occupancies/export' + params, filename).catch(() =>
|
||||
message.error('导出失败'),
|
||||
);
|
||||
}}
|
||||
>
|
||||
导出记录
|
||||
</PermissionButton>
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
{selectedRowKeys.length > 0 && (
|
||||
@@ -578,7 +647,7 @@ const OccupanciesPage: React.FC = () => {
|
||||
title={
|
||||
<span>
|
||||
已选 <strong>{selectedRowKeys.length}</strong> 条记录
|
||||
{showActive ? (
|
||||
{viewPolicy.batchAction === 'checkout' ? (
|
||||
<PermissionButton
|
||||
permission="occupancy:checkout"
|
||||
type="primary"
|
||||
@@ -594,7 +663,7 @@ const OccupanciesPage: React.FC = () => {
|
||||
>
|
||||
批量退宿
|
||||
</PermissionButton>
|
||||
) : (
|
||||
) : viewPolicy.batchAction === 'archive' ? (
|
||||
canDelete ? (
|
||||
<Popconfirm
|
||||
title={`确定归档选中的 ${selectedRowKeys.length} 条入住记录?在住记录会自动跳过`}
|
||||
@@ -613,7 +682,24 @@ const OccupanciesPage: React.FC = () => {
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null
|
||||
)}
|
||||
) : canDelete ? (
|
||||
<Popconfirm
|
||||
title={`确定恢复选中的 ${selectedRowKeys.length} 条入住记录?`}
|
||||
onConfirm={handleBatchRestore}
|
||||
okText="恢复"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<UndoOutlined />}
|
||||
style={{ marginLeft: 12 }}
|
||||
loading={batchLoading}
|
||||
>
|
||||
批量恢复
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
<Button size="small" onClick={() => setSelectedRowKeys([])} style={{ marginLeft: 8 }}>
|
||||
取消选择
|
||||
</Button>
|
||||
|
||||
@@ -32,6 +32,7 @@ import PermissionButton from '../../components/PermissionButton';
|
||||
import EditableCell from '../../components/EditableCell';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import { selectArchiveRecords } from '../archive-view';
|
||||
|
||||
const statusMap: Record<string, { text: string; color: string }> = {
|
||||
available: { text: '可入住', color: 'green' },
|
||||
@@ -95,7 +96,6 @@ const RoomsPage: React.FC = () => {
|
||||
const [editing, setEditing] = useState<any>(null);
|
||||
const canSaveRoom = editing ? canEditRooms : canCreateRooms;
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [archivedCount, setArchivedCount] = useState(0);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [filterBuilding, setFilterBuilding] = useState<string | undefined>(undefined);
|
||||
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
|
||||
@@ -118,9 +118,16 @@ const RoomsPage: React.FC = () => {
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
|
||||
// Close modals when the required permission is lost
|
||||
useEffect(() => { if (!canSaveRoom && modalOpen) { setModalOpen(false); setEditing(null); form.resetFields(); } }, [canSaveRoom, modalOpen, form]);
|
||||
useEffect(() => {
|
||||
if (!canSaveRoom && modalOpen) {
|
||||
setModalOpen(false);
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
}
|
||||
}, [canSaveRoom, modalOpen, form]);
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (batchLoading) return;
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const res: any = await api.post('/rooms/batch-delete', { ids: selectedRowKeys });
|
||||
@@ -134,14 +141,32 @@ const RoomsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchRestore = async () => {
|
||||
if (batchLoading) return;
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const res = await api.put<{ message?: string; restored: number; skipped: number }>(
|
||||
'/rooms/batch-restore',
|
||||
{ ids: selectedRowKeys },
|
||||
);
|
||||
message.success(
|
||||
`已批量恢复 ${res.restored} 间${res.skipped ? `,跳过 ${res.skipped} 间` : ''}`,
|
||||
);
|
||||
setSelectedRowKeys([]);
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '批量恢复失败');
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = { includeArchived: 'true' };
|
||||
const params: any = { includeArchived: showArchived ? 'true' : undefined };
|
||||
const res: any = await api.get('/rooms/overview', { params });
|
||||
const archived = res.filter((r: any) => r.status === 'archived');
|
||||
setArchivedCount(archived.length);
|
||||
const filtered = showArchived ? res : res.filter((r: any) => r.status !== 'archived');
|
||||
const filtered = selectArchiveRecords(res, showArchived ? 'archived' : 'active');
|
||||
setData(filtered);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
@@ -530,11 +555,7 @@ const RoomsPage: React.FC = () => {
|
||||
{r.status === 'archived' ? (
|
||||
canEditRooms ? (
|
||||
<Popconfirm title="确定恢复此宿舍?" onConfirm={() => handleRestore(r.id)}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<UndoOutlined />}
|
||||
type="link"
|
||||
>
|
||||
<Button size="small" icon={<UndoOutlined />} type="link">
|
||||
恢复
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
@@ -568,10 +589,7 @@ const RoomsPage: React.FC = () => {
|
||||
</PermissionButton>
|
||||
{canDeleteRooms ? (
|
||||
<Popconfirm title="确定归档?" onConfirm={() => handleArchive(r.id)}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<InboxOutlined />}
|
||||
>
|
||||
<Button size="small" icon={<InboxOutlined />}>
|
||||
归档
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
@@ -638,15 +656,34 @@ const RoomsPage: React.FC = () => {
|
||||
/>
|
||||
<Button
|
||||
type={showArchived ? 'primary' : 'default'}
|
||||
onClick={() => setShowArchived(!showArchived)}
|
||||
onClick={() => {
|
||||
setShowArchived(!showArchived);
|
||||
setFilterStatus(undefined);
|
||||
setSelectedRowKeys([]);
|
||||
}}
|
||||
>
|
||||
{showArchived
|
||||
? '隐藏已归档'
|
||||
: `显示已归档${archivedCount > 0 ? ` (${archivedCount})` : ''}`}
|
||||
{showArchived ? '返回正常数据' : '查看已归档'}
|
||||
</Button>
|
||||
</Space>
|
||||
<Space wrap className="responsive-toolbar__group">
|
||||
{canDeleteRooms ? (
|
||||
{showArchived && canEditRooms ? (
|
||||
<Popconfirm
|
||||
title={`确定批量恢复选中的 ${selectedRowKeys.length} 间宿舍?`}
|
||||
onConfirm={handleBatchRestore}
|
||||
okText="恢复"
|
||||
cancelText="取消"
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<UndoOutlined />}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
loading={batchLoading}
|
||||
>
|
||||
批量恢复
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : !showArchived && canDeleteRooms ? (
|
||||
<Popconfirm
|
||||
title={`确定批量归档选中的 ${selectedRowKeys.length} 间宿舍?(有在住人员的会跳过)`}
|
||||
onConfirm={handleBatchDelete}
|
||||
@@ -664,19 +701,21 @@ const RoomsPage: React.FC = () => {
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
<PermissionButton
|
||||
permission="room:create"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
添加宿舍
|
||||
</PermissionButton>
|
||||
{hasPermission('room:create') ? (
|
||||
{!showArchived ? (
|
||||
<PermissionButton
|
||||
permission="room:create"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
添加宿舍
|
||||
</PermissionButton>
|
||||
) : null}
|
||||
{!showArchived && hasPermission('room:create') ? (
|
||||
<Upload
|
||||
accept=".xlsx,.xls"
|
||||
showUploadList={false}
|
||||
@@ -734,7 +773,6 @@ const RoomsPage: React.FC = () => {
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => setSelectedRowKeys(keys as number[]),
|
||||
getCheckboxProps: (record: any) => ({ disabled: record.status === 'archived' }),
|
||||
}}
|
||||
/>
|
||||
<style>{`.archived-row { opacity: 0.6; background: #fafafa !important; }`}</style>
|
||||
|
||||
@@ -39,6 +39,7 @@ import JinshujuMatchModal from '../../components/JinshujuMatchModal';
|
||||
import { maskIdNumber, maskPhone } from '../../utils/sensitive';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import { selectArchiveRecords } from '../archive-view';
|
||||
|
||||
const statusMap: Record<string, { text: string; color: string }> = {
|
||||
active: { text: '在读', color: 'green' },
|
||||
@@ -111,7 +112,6 @@ const StudentsPage: React.FC = () => {
|
||||
const [classOptions, setClassOptions] = useState<StudentFilterLookups['classes']>([]);
|
||||
const [teacherOptions, setTeacherOptions] = useState<StudentFilterLookups['teachers']>([]);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [archivedCount, setArchivedCount] = useState(0);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
const [enrollmentData, setEnrollmentData] = useState<Record<number, EnrollmentInfo[]>>({});
|
||||
@@ -185,6 +185,7 @@ const StudentsPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (batchLoading) return;
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const res: any = await api.post('/students/batch-delete', { ids: selectedRowKeys });
|
||||
@@ -198,22 +199,41 @@ const StudentsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchRestore = async () => {
|
||||
if (batchLoading) return;
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const res = await api.put<{ message?: string; restored: number; skipped: number }>(
|
||||
'/students/batch-restore',
|
||||
{ ids: selectedRowKeys },
|
||||
);
|
||||
message.success(
|
||||
`已批量恢复 ${res.restored} 人${res.skipped ? `,跳过 ${res.skipped} 人` : ''}`,
|
||||
);
|
||||
setSelectedRowKeys([]);
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '批量恢复失败');
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, unknown> = {
|
||||
name: searchName || undefined,
|
||||
includeArchived: 'true',
|
||||
includeArchived: showArchived ? 'true' : undefined,
|
||||
};
|
||||
if (filterStatus) params.status = filterStatus;
|
||||
if (showArchived) params.status = 'archived';
|
||||
else if (filterStatus) params.status = filterStatus;
|
||||
if (filterOrganizationId) params.organizationId = filterOrganizationId;
|
||||
if (filterClassId) params.classId = filterClassId;
|
||||
if (filterTeacherId) params.teacherId = filterTeacherId;
|
||||
const res = (await api.get('/students', { params })) as Array<Record<string, unknown>>;
|
||||
const list = res as Array<Record<string, unknown>>;
|
||||
const archived = list.filter((r) => r.status === 'archived');
|
||||
setArchivedCount(archived.length);
|
||||
setData(showArchived ? list : list.filter((r) => r.status !== 'archived'));
|
||||
setData(selectArchiveRecords(list, showArchived ? 'archived' : 'active'));
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '加载失败,请稍后重试');
|
||||
@@ -702,14 +722,11 @@ const StudentsPage: React.FC = () => {
|
||||
okText="归档"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<InboxOutlined />}
|
||||
>
|
||||
归档
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
<Button size="small" icon={<InboxOutlined />}>
|
||||
归档
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
@@ -803,15 +820,34 @@ const StudentsPage: React.FC = () => {
|
||||
/>
|
||||
<Button
|
||||
type={showArchived ? 'primary' : 'default'}
|
||||
onClick={() => setShowArchived(!showArchived)}
|
||||
onClick={() => {
|
||||
setShowArchived(!showArchived);
|
||||
setFilterStatus(undefined);
|
||||
setSelectedRowKeys([]);
|
||||
}}
|
||||
>
|
||||
{showArchived
|
||||
? '隐藏已归档'
|
||||
: `显示已归档${archivedCount > 0 ? ` (${archivedCount})` : ''}`}
|
||||
{showArchived ? '返回正常数据' : '查看已归档'}
|
||||
</Button>
|
||||
</Space>
|
||||
<Space wrap className="responsive-toolbar__group">
|
||||
{canDeleteStudent ? (
|
||||
{showArchived && canEditStudent ? (
|
||||
<Popconfirm
|
||||
title={`确定批量恢复选中的 ${selectedRowKeys.length} 名学生?`}
|
||||
onConfirm={handleBatchRestore}
|
||||
okText="恢复"
|
||||
cancelText="取消"
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<UndoOutlined />}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
loading={batchLoading}
|
||||
>
|
||||
批量恢复
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : !showArchived && canDeleteStudent ? (
|
||||
<Popconfirm
|
||||
title={`确定批量归档选中的 ${selectedRowKeys.length} 名学生?(数据保留,可恢复)`}
|
||||
onConfirm={handleBatchDelete}
|
||||
@@ -829,21 +865,23 @@ const StudentsPage: React.FC = () => {
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
<PermissionButton
|
||||
permission="student:create"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
const host = organizations.find((organization) => organization.isHost);
|
||||
if (host) form.setFieldValue('organizationId', host.id);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
添加学生
|
||||
</PermissionButton>
|
||||
{hasPermission('student:import') ? (
|
||||
{!showArchived ? (
|
||||
<PermissionButton
|
||||
permission="student:create"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
const host = organizations.find((organization) => organization.isHost);
|
||||
if (host) form.setFieldValue('organizationId', host.id);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
添加学生
|
||||
</PermissionButton>
|
||||
) : null}
|
||||
{!showArchived && hasPermission('student:import') ? (
|
||||
<>
|
||||
<Upload
|
||||
accept=".xlsx,.xls"
|
||||
@@ -861,7 +899,7 @@ const StudentsPage: React.FC = () => {
|
||||
</Upload>
|
||||
</>
|
||||
) : null}
|
||||
{canSyncJinshuju ? (
|
||||
{!showArchived && canSyncJinshuju ? (
|
||||
<Button icon={<CloudUploadOutlined />} onClick={() => setJinshujuOpen(true)}>
|
||||
同步金数据
|
||||
</Button>
|
||||
@@ -910,7 +948,6 @@ const StudentsPage: React.FC = () => {
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => setSelectedRowKeys(keys as number[]),
|
||||
getCheckboxProps: (record: any) => ({ disabled: record.status === 'archived' }),
|
||||
}}
|
||||
expandable={{
|
||||
rowExpandable: () => true,
|
||||
|
||||
58
apps/admin/src/pages/archive-view.integration.test.ts
Normal file
58
apps/admin/src/pages/archive-view.integration.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
archiveViewPolicy,
|
||||
expenseStatusForView,
|
||||
occupancyParamsForView,
|
||||
occupancyViewPolicy,
|
||||
selectArchiveRecords,
|
||||
shouldClearSelectionOnViewChange,
|
||||
} from './archive-view';
|
||||
|
||||
describe('归档数据视图', () => {
|
||||
it('正常视图与归档视图不会混合记录', () => {
|
||||
const records = [
|
||||
{ id: 1, status: 'active' },
|
||||
{ id: 2, status: 'graduated' },
|
||||
{ id: 3, status: 'archived' },
|
||||
];
|
||||
|
||||
expect(selectArchiveRecords(records, 'active').map((item) => item.id)).toEqual([1, 2]);
|
||||
expect(selectArchiveRecords(records, 'archived').map((item) => item.id)).toEqual([3]);
|
||||
});
|
||||
|
||||
it('费用视图映射为后端 status 查询', () => {
|
||||
expect(expenseStatusForView('active')).toBe('active');
|
||||
expect(expenseStatusForView('archived')).toBe('archived');
|
||||
});
|
||||
|
||||
it('入住三态分别映射为在住、全部活动记录和归档记录', () => {
|
||||
expect(occupancyParamsForView('active')).toEqual({ active: 'true', status: 'active' });
|
||||
expect(occupancyParamsForView('all')).toEqual({ active: undefined, status: 'active' });
|
||||
expect(occupancyParamsForView('archived')).toEqual({
|
||||
active: undefined,
|
||||
status: 'archived',
|
||||
});
|
||||
});
|
||||
|
||||
it('正常与归档视图的批量动作互斥,且归档视图只读', () => {
|
||||
expect(archiveViewPolicy('active')).toEqual({ batchAction: 'archive', readonly: false });
|
||||
expect(archiveViewPolicy('archived')).toEqual({ batchAction: 'restore', readonly: true });
|
||||
});
|
||||
|
||||
it('入住三态分别只提供退宿、归档和恢复动作', () => {
|
||||
expect(occupancyViewPolicy('active')).toEqual({
|
||||
batchAction: 'checkout',
|
||||
readonly: false,
|
||||
});
|
||||
expect(occupancyViewPolicy('all')).toEqual({ batchAction: 'archive', readonly: false });
|
||||
expect(occupancyViewPolicy('archived')).toEqual({
|
||||
batchAction: 'restore',
|
||||
readonly: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('只有实际切换视图时才要求清空选择', () => {
|
||||
expect(shouldClearSelectionOnViewChange('active', 'archived')).toBe(true);
|
||||
expect(shouldClearSelectionOnViewChange('archived', 'archived')).toBe(false);
|
||||
});
|
||||
});
|
||||
36
apps/admin/src/pages/archive-view.ts
Normal file
36
apps/admin/src/pages/archive-view.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export type ArchiveView = 'active' | 'archived';
|
||||
export type OccupancyView = 'active' | 'all' | 'archived';
|
||||
export type BatchAction = 'archive' | 'restore' | 'checkout';
|
||||
|
||||
export interface ViewPolicy {
|
||||
batchAction: BatchAction;
|
||||
readonly: boolean;
|
||||
}
|
||||
|
||||
export const selectArchiveRecords = <T extends { status?: string }>(
|
||||
records: T[],
|
||||
view: ArchiveView,
|
||||
) =>
|
||||
records.filter((record) =>
|
||||
view === 'archived' ? record.status === 'archived' : record.status !== 'archived',
|
||||
);
|
||||
|
||||
export const expenseStatusForView = (view: ArchiveView) => view;
|
||||
|
||||
export const archiveViewPolicy = (view: ArchiveView): ViewPolicy => ({
|
||||
batchAction: view === 'archived' ? 'restore' : 'archive',
|
||||
readonly: view === 'archived',
|
||||
});
|
||||
|
||||
export const occupancyViewPolicy = (view: OccupancyView): ViewPolicy => ({
|
||||
batchAction: view === 'active' ? 'checkout' : view === 'all' ? 'archive' : 'restore',
|
||||
readonly: view === 'archived',
|
||||
});
|
||||
|
||||
export const shouldClearSelectionOnViewChange = <T extends string>(current: T, next: T) =>
|
||||
current !== next;
|
||||
|
||||
export const occupancyParamsForView = (view: OccupancyView) => ({
|
||||
active: view === 'active' ? 'true' : undefined,
|
||||
status: view === 'archived' ? 'archived' : 'active',
|
||||
});
|
||||
Reference in New Issue
Block a user