Files
gongxue-base/apps/admin/src/pages/ClassroomRentals/RentalTable.tsx
wangziqi 1b4ba893fd feat: 空状态引导全面应用与考勤批量标记
admin:
- 17 个列表页空态统一为 QueryEmpty 引导:学生/账单/入住/费用/押金/
  教室/房间/班级/考试/排课/机构/考勤机/钱包/租赁/通知/角色等,
  有创建权限的页面附带主操作按钮,无权限时纯展示
- 教师端课堂点名新增「全部已打卡/全部未打卡」批量按钮:
  仅作用于状态不一致的记录,确认后调用批量接口,展示成功/失败数量

server:
- 新增 PUT /attendance-records/batch-status 批量改状态接口
  (ids ≤200,逐条权限校验与会话锁,部分失败返回 failedIds,
  审计日志记录批量结果;路由声明在 :id 之前避免被捕获)

aislop scan: 5 引擎 0 issues
2026-08-07 18:00:29 +08:00

359 lines
10 KiB
TypeScript

import React, { useState } from 'react';
import {
Button,
Popconfirm,
Space,
Table,
Tag,
Tooltip,
Upload,
} from 'antd';
import {
CheckOutlined,
FileTextOutlined,
StopOutlined,
UploadOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import PermissionButton from '../../components/PermissionButton';
import EditableCell from '../../components/EditableCell';
import { message } from '../../ui/app-message';
import { QueryEmpty } from '../../components/QueryState';
const RENTAL_FIELDS = {
classroomId: 'classroomId',
lesseeOrganizationId: 'lesseeOrganizationId',
startDate: 'startDate',
endDate: 'endDate',
dailyRate: 'dailyRate',
totalAmount: 'totalAmount',
} as const;
export interface RentalTableProps {
data: any[];
loading: boolean;
classrooms: any[];
organizations: any[];
canPurgeRental: boolean;
hasPermission: (permission: string) => boolean;
onSaveCell: (record: any, field: string, value: unknown) => Promise<void> | void;
onEdit: (record: any) => void;
onAction: (id: number, action: 'cancel' | 'end') => void;
onArchive: (id: number) => void;
onPurge: (id: number, name: string) => void;
onDownloadContract: (id: number, filename?: string) => void;
onDeleteContract: (id: number) => void;
onUploadContract: (
id: number,
formData: FormData,
onProgress?: (percent: number) => void,
) => Promise<unknown>;
}
export const RentalTable: React.FC<RentalTableProps> = ({
data,
loading,
classrooms,
organizations,
canPurgeRental,
hasPermission,
onSaveCell,
onEdit,
onAction,
onArchive,
onPurge,
onDownloadContract,
onDeleteContract,
onUploadContract,
}) => {
const [uploadingContractId, setUploadingContractId] = useState<number | null>(null);
const [contractPercent, setContractPercent] = useState(0);
const EditableRentalCell = <R extends { id: number; effectiveStatus?: string }>({
value,
field,
record,
editor,
min,
required,
options,
children,
}: {
value: unknown;
field: string;
record: R;
editor?: React.ComponentProps<typeof EditableCell>['editor'];
min?: number;
required?: boolean;
options?: Array<{ value: string | number; label: string }>;
children?: React.ReactNode;
}) => (
<EditableCell
value={value}
editor={editor}
min={min}
required={required}
options={options}
permission="rental:edit"
disabled={record.effectiveStatus !== 'active'}
onSave={async (next) => {
await onSaveCell(record, field, next);
}}
>
{children ?? String(value ?? '-')}
</EditableCell>
);
const columns = [
{
title: '教室',
width: 120,
dataIndex: 'classroom',
render: (c: any, r: any) => (
<EditableRentalCell
value={r.classroomId}
field={RENTAL_FIELDS.classroomId}
record={r}
editor="select"
options={classrooms
.filter((item) => item.status !== 'archived')
.map((item) => ({
value: item.id,
label: item.building ? `${item.building} · ${item.name}` : item.name,
}))}
required
>
{c ? (
<span>
{c.building ? `${c.building} · ` : ''}
{c.name}
</span>
) : (
'-'
)}
</EditableRentalCell>
),
},
{
title: '承租机构',
width: 100,
dataIndex: 'lesseeOrganization',
render: (t: any, r: any) => (
<EditableRentalCell
value={r.lesseeOrganizationId}
field={RENTAL_FIELDS.lesseeOrganizationId}
record={r}
editor="select"
options={organizations
.filter((item) => item.status !== 'archived')
.map((item) => ({ value: item.id, label: item.name }))}
required
>
{t ? (
<Tag
color={t.color}
style={{ background: t.color, color: '#fff', borderColor: t.color }}
>
{t.name}
</Tag>
) : (
'-'
)}
</EditableRentalCell>
),
},
{
title: '开始日期',
dataIndex: 'startDate',
width: 110,
render: (v: string, r: any) => (
<EditableRentalCell value={v} field={RENTAL_FIELDS.startDate} record={r} editor="date" required>
{v}
</EditableRentalCell>
),
},
{
title: '结束日期',
dataIndex: 'endDate',
width: 110,
render: (v: string, r: any) => (
<EditableRentalCell value={v} field={RENTAL_FIELDS.endDate} record={r} editor="date" required>
{v}
</EditableRentalCell>
),
},
{
title: '时长',
width: 80,
render: (_: any, r: any) => {
const d = dayjs(r.endDate).diff(dayjs(r.startDate), 'day') + 1;
return `${d}`;
},
},
{
title: '日租金',
dataIndex: 'dailyRate',
width: 100,
render: (v: any, r: any) => (
<EditableRentalCell value={v} field={RENTAL_FIELDS.dailyRate} record={r} editor="money" min={0.01}>
{v ? `¥${v}` : '-'}
</EditableRentalCell>
),
},
{
title: '总额',
dataIndex: 'totalAmount',
width: 100,
render: (v: any, r: any) => (
<EditableRentalCell value={v} field={RENTAL_FIELDS.totalAmount} record={r} editor="money" min={0.01}>
{v ? `¥${v}` : '-'}
</EditableRentalCell>
),
},
{
title: '状态',
dataIndex: 'effectiveStatus',
width: 90,
render: (status: string) => {
const config: Record<string, { text: string; color: string }> = {
active: { text: '进行中', color: 'green' },
ended: { text: '已结束', color: 'default' },
cancelled: { text: '已取消', color: 'red' },
};
return <Tag color={config[status]?.color}>{config[status]?.text || status}</Tag>;
},
},
{
title: '合同',
width: 120,
dataIndex: 'contractPath',
render: (v: string, r: any) =>
v ? (
<Space>
<Tooltip title={r.contractOriginalName}>
<Button
size="small"
icon={<FileTextOutlined />}
onClick={() => onDownloadContract(r.id, r.contractOriginalName)}
>
</Button>
</Tooltip>
{hasPermission('rental:edit') ? (
<Popconfirm title="移除合同文件?" onConfirm={() => onDeleteContract(r.id)}>
<Button size="small" danger icon={<StopOutlined />} aria-label="移除合同文件" />
</Popconfirm>
) : null}
</Space>
) : hasPermission('rental:edit') ? (
<Upload
accept="application/pdf"
showUploadList={false}
customRequest={async ({ file, onSuccess, onError }: any) => {
if (file.size > 10 * 1024 * 1024) {
message.error('文件不能超过 10MB');
onError?.(new Error('size'));
return;
}
const formData = new FormData();
formData.append('file', file);
setUploadingContractId(r.id);
setContractPercent(0);
try {
await onUploadContract(r.id, formData, (percent) => setContractPercent(percent));
message.success('合同已上传');
onSuccess?.({});
} catch (e) {
onError?.(e as Error);
} finally {
setUploadingContractId(null);
setContractPercent(0);
}
}}
>
<Button
size="small"
icon={<UploadOutlined />}
loading={uploadingContractId === r.id}
>
{uploadingContractId === r.id && contractPercent > 0 && contractPercent < 100
? `上传中 ${contractPercent}%`
: '上传PDF'}
</Button>
</Upload>
) : (
'-'
),
},
{
title: '操作',
width: 150,
render: (_: any, record: any) => (
<Space>
{record.effectiveStatus === 'active' && (
<>
<PermissionButton permission="rental:edit" size="small" onClick={() => onEdit(record)}>
</PermissionButton>
<Popconfirm title="确定取消该租赁?" onConfirm={() => onAction(record.id, 'cancel')}>
<PermissionButton
permission="rental:edit"
size="small"
danger
icon={<StopOutlined />}
>
</PermissionButton>
</Popconfirm>
{!dayjs(record.startDate).isAfter(dayjs(), 'day') && (
<Popconfirm title="确定今天结束该租赁?" onConfirm={() => onAction(record.id, 'end')}>
<PermissionButton permission="rental:edit" size="small" icon={<CheckOutlined />}>
</PermissionButton>
</Popconfirm>
)}
</>
)}
{record.effectiveStatus !== 'active' && (
<Popconfirm
title="确定归档该租赁订单?合同文件会保留。"
onConfirm={() => onArchive(record.id)}
>
<PermissionButton permission="rental:delete" size="small" danger>
</PermissionButton>
</Popconfirm>
)}
{record.status === 'cancelled' && canPurgeRental ? (
<Button
size="small"
danger
type="link"
onClick={() => onPurge(record.id, record.lesseeOrganization?.name || `订单${record.id}`)}
>
</Button>
) : null}
</Space>
),
},
];
return (
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
locale={{ emptyText: <QueryEmpty description="暂无租赁订单,点击右上角「新增租赁」创建第一笔订单" /> }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50, 100],
showTotal: (total) => `${total}`,
}}
scroll={{ x: 1200 }}
/>
);
};