Server: - Add DingTalk attendance import service with SSE progress streaming - Add IntegrationConfig entity & module for multi-tenant DingTalk setup - Add ExpenseType entity & ExpenseTypesModule - Add SeedModule for DB initialization - Add UserDingMapping entity for DingTalk user linkage - Attendance service: import flow with dedup & student auto-mapping - Rooms service: time-range overlap queries - Sync controller/service: DingTalk integration wiring - Permission guard: refactor to pure re-export - Campus scope middleware: tenant-aware filtering Admin UI: - Attendance page: import UI with progress & result summary - All pages: tableStyle/tablePagination standardization - Login page: responsive styling - Sensitive data: useViewSensitive hook for masked viewing - Vite config: path aliases, build optimization - Test infra: vitest config, test utilities Docs: PRD DingTalk batch 1 & 2 design docs
272 lines
7.9 KiB
TypeScript
272 lines
7.9 KiB
TypeScript
import React, { useEffect, useState, useMemo } from 'react';
|
|
import { Table, Modal, Form, Input, Select, Space, message, Tag, Popconfirm } from 'antd';
|
|
import { PlusOutlined, InboxOutlined } from '@ant-design/icons';
|
|
import api from '../../api';
|
|
import PermissionButton from '../../components/PermissionButton';
|
|
|
|
const PRESET_COLORS = [
|
|
'#ff7875',
|
|
'#ffa940',
|
|
'#ffc53d',
|
|
'#73d13d',
|
|
'#36cfc9',
|
|
'#40a9ff',
|
|
'#597ef7',
|
|
'#9254de',
|
|
'#f759ab',
|
|
'#8c8c8c',
|
|
];
|
|
|
|
const TenantsPage: React.FC = () => {
|
|
const [data, setData] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [editing, setEditing] = useState<any>(null);
|
|
const [form] = Form.useForm();
|
|
const [saving, setSaving] = useState(false);
|
|
const [searchText, setSearchText] = useState('');
|
|
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
|
|
|
|
const filteredData = useMemo(() => {
|
|
let result = data;
|
|
if (searchText) {
|
|
const s = searchText.toLowerCase();
|
|
result = result.filter((d: Record<string, unknown>) =>
|
|
(typeof d.name === 'string' && d.name.toLowerCase().includes(s)) ||
|
|
(typeof d.contact === 'string' && d.contact.toLowerCase().includes(s)));
|
|
}
|
|
if (filterStatus) result = result.filter((d: Record<string, unknown>) => d.status === filterStatus);
|
|
return result;
|
|
}, [data, searchText, filterStatus]);
|
|
|
|
const fetchData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res: any = await api.get('/tenants');
|
|
setData(res);
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
setLoading(false);
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, []);
|
|
|
|
const handleSave = async () => {
|
|
const values = await form.validateFields();
|
|
setSaving(true);
|
|
try {
|
|
if (editing) {
|
|
await api.put(`/tenants/${editing.id}`, values);
|
|
message.success('更新成功');
|
|
} else {
|
|
await api.post('/tenants', values);
|
|
message.success('创建成功');
|
|
}
|
|
setModalOpen(false);
|
|
form.resetFields();
|
|
setEditing(null);
|
|
fetchData();
|
|
} catch (e: any) {
|
|
message.error(e?.message || '操作失败');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleArchive = async (id: number) => {
|
|
try {
|
|
await api.delete(`/tenants/${id}`);
|
|
message.success('已归档');
|
|
fetchData();
|
|
} catch (e: any) {
|
|
message.error(e?.message || '归档失败');
|
|
}
|
|
};
|
|
|
|
const columns = useMemo(() => [
|
|
{
|
|
title: '租赁方名称', width: 120,
|
|
dataIndex: 'name',
|
|
render: (v: string, r: any) => (
|
|
<Space>
|
|
<Tag
|
|
color={r.color || 'default'}
|
|
style={{ borderColor: r.color, color: '#fff', background: r.color }}
|
|
>
|
|
{v}
|
|
</Tag>
|
|
</Space>
|
|
),
|
|
},
|
|
{ title: '联系人', dataIndex: 'contact', width: 100, render: (v: string) => v || '-' },
|
|
{ title: '电话', dataIndex: 'phone', width: 120, render: (v: string) => v || '-' },
|
|
{
|
|
title: '颜色', width: 80,
|
|
dataIndex: 'color',
|
|
render: (v: string) =>
|
|
v ? (
|
|
<span
|
|
style={{
|
|
display: 'inline-block',
|
|
width: 20,
|
|
height: 20,
|
|
background: v,
|
|
borderRadius: 4,
|
|
verticalAlign: 'middle',
|
|
}}
|
|
/>
|
|
) : (
|
|
'-'
|
|
),
|
|
},
|
|
{ title: '备注', dataIndex: 'notes', ellipsis: true, render: (v: string) => v || '-' },
|
|
{
|
|
title: '操作',
|
|
width: 150,
|
|
render: (_: any, record: any) => (
|
|
<Space>
|
|
<PermissionButton
|
|
permission="tenant:edit"
|
|
size="small"
|
|
onClick={() => {
|
|
setEditing(record);
|
|
form.setFieldsValue(record);
|
|
setModalOpen(true);
|
|
}}
|
|
>
|
|
编辑
|
|
</PermissionButton>
|
|
<Popconfirm
|
|
title="归档后仍可查看历史租赁"
|
|
onConfirm={() => handleArchive(record.id)}
|
|
okText="归档"
|
|
cancelText="取消"
|
|
>
|
|
<PermissionButton permission="tenant:delete" size="small" icon={<InboxOutlined />}>
|
|
归档
|
|
</PermissionButton>
|
|
</Popconfirm>
|
|
</Space>
|
|
),
|
|
},
|
|
], [handleArchive]);
|
|
|
|
return (
|
|
<div>
|
|
<div
|
|
style={{
|
|
marginBottom: 16,
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
flexWrap: 'wrap',
|
|
gap: 8,
|
|
}}
|
|
>
|
|
<Input.Search
|
|
placeholder="搜索名称或联系人"
|
|
allowClear
|
|
style={{ width: 200 }}
|
|
onSearch={(v) => setSearchText(v)}
|
|
onChange={(e) => {
|
|
if (!e.target.value) setSearchText('');
|
|
}}
|
|
/>
|
|
<Select placeholder="状态" allowClear style={{ width: 110 }} value={filterStatus} onChange={setFilterStatus}
|
|
options={[{value:'active',label:'活跃'},{value:'archived',label:'已归档'}]} />
|
|
<PermissionButton
|
|
permission="tenant:create"
|
|
type="primary"
|
|
icon={<PlusOutlined />}
|
|
onClick={() => {
|
|
setEditing(null);
|
|
form.resetFields();
|
|
setModalOpen(true);
|
|
}}
|
|
>
|
|
添加租赁方
|
|
</PermissionButton>
|
|
</div>
|
|
<Table
|
|
scroll={{ x: 1000 }}
|
|
columns={columns}
|
|
dataSource={filteredData}
|
|
rowKey="id"
|
|
loading={loading}
|
|
pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 条` }}
|
|
/>
|
|
|
|
<Modal
|
|
title={editing ? '编辑租赁方' : '添加租赁方'}
|
|
open={modalOpen}
|
|
onOk={handleSave}
|
|
onCancel={() => {
|
|
setModalOpen(false);
|
|
setEditing(null);
|
|
}}
|
|
okText="保存"
|
|
confirmLoading={saving}
|
|
>
|
|
<Form form={form} layout="vertical">
|
|
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
|
<Input placeholder="如:犀牛华安 / 艺考 / 博才" />
|
|
</Form.Item>
|
|
<Form.Item name="contact" label="联系人">
|
|
<Input />
|
|
</Form.Item>
|
|
<Form.Item name="phone" label="电话">
|
|
<Input />
|
|
</Form.Item>
|
|
<Form.Item name="color" label="标签颜色" tooltip="可视化排期时用的颜色,留空则自动分配">
|
|
<Space.Compact>
|
|
<Input placeholder="#40a9ff" style={{ flex: 1 }} />
|
|
<span
|
|
style={{
|
|
padding: '0 4px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
border: '1px solid #d9d9d9',
|
|
backgroundColor: '#fafafa',
|
|
gap: 4,
|
|
}}
|
|
>
|
|
{PRESET_COLORS.map((c) => (
|
|
<span
|
|
key={c}
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={`选择颜色 ${c}`}
|
|
onClick={() => form.setFieldValue('color', c)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
form.setFieldValue('color', c);
|
|
}
|
|
}}
|
|
style={{
|
|
display: 'inline-block',
|
|
width: 28,
|
|
height: 28,
|
|
background: c,
|
|
borderRadius: 3,
|
|
cursor: 'pointer',
|
|
border: '1px solid #d9d9d9',
|
|
}}
|
|
/>
|
|
))}
|
|
</span>
|
|
</Space.Compact>
|
|
</Form.Item>
|
|
<Form.Item name="notes" label="备注">
|
|
<Input.TextArea rows={2} />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default TenantsPage;
|