feat: replace tenants with organization management
This commit is contained in:
@@ -22,7 +22,7 @@ const TeachersPage = lazy(() => import('./pages/Teachers'));
|
||||
const StudentProfilePage = lazy(() => import('./pages/StudentProfile'));
|
||||
const ClassesPage = lazy(() => import('./pages/Classes'));
|
||||
const ClassDetailPage = lazy(() => import('./pages/Classes/detail'));
|
||||
const TenantsPage = lazy(() => import('./pages/Tenants'));
|
||||
const OrganizationsPage = lazy(() => import('./pages/Organizations'))
|
||||
const ClassroomRentalsPage = lazy(() => import('./pages/ClassroomRentals'));
|
||||
const ClassroomSchedulePage = lazy(() => import('./pages/ClassroomSchedule'));
|
||||
const SchedulesPage = lazy(() => import('./pages/Schedules'));
|
||||
@@ -205,10 +205,10 @@ const App: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="tenants"
|
||||
path="organizations"
|
||||
element={
|
||||
<PermissionRoute permission="tenant:view">
|
||||
<TenantsPage />
|
||||
<PermissionRoute permission="organization:view">
|
||||
<OrganizationsPage />
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -90,7 +90,7 @@ const allMenuItems: MenuItemType[] = [
|
||||
{ key: '/classroom-schedule', icon: <CalendarOutlined />, label: '排期总览', permission: 'classroom:view' },
|
||||
{ key: '/classrooms', icon: <ReadOutlined />, label: '教室列表', permission: 'classroom:view' },
|
||||
{ key: '/classroom-rentals', icon: <FileProtectOutlined />, label: '租赁订单', permission: 'rental:view' },
|
||||
{ key: '/tenants', icon: <TagsOutlined />, label: '租赁方', permission: 'tenant:view' },
|
||||
{ key: '/organizations', icon: <TagsOutlined />, label: '机构管理', permission: 'organization:view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -31,7 +31,7 @@ export const unavailableDatesCacheKey = (classroomId: number, date: Dayjs) =>
|
||||
const ClassroomRentalsPage: React.FC = () => {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [classrooms, setClassrooms] = useState<any[]>([]);
|
||||
const [tenants, setTenants] = useState<any[]>([]);
|
||||
const [organizations, setOrganizations] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<any>(null);
|
||||
@@ -50,8 +50,8 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
const s = searchText.toLowerCase();
|
||||
return data.filter((r: any) => {
|
||||
const matchClassroom = r.classroom?.name?.toLowerCase().includes(s);
|
||||
const matchTenant = r.tenant?.name?.toLowerCase().includes(s);
|
||||
return matchClassroom || matchTenant;
|
||||
const matchOrganization = r.lesseeOrganization?.name?.toLowerCase().includes(s);
|
||||
return matchClassroom || matchOrganization;
|
||||
});
|
||||
}, [data, searchText]);
|
||||
|
||||
@@ -70,9 +70,12 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
|
||||
const fetchMeta = async () => {
|
||||
try {
|
||||
const [cr, tn]: any = await Promise.all([api.get('/classrooms'), api.get('/tenants')]);
|
||||
const [cr, tn]: any = await Promise.all([
|
||||
api.get('/classrooms'),
|
||||
api.get('/organizations', { params: { scope: 'all' } }),
|
||||
]);
|
||||
setClassrooms(cr);
|
||||
setTenants(tn);
|
||||
setOrganizations(tn);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载教室列表失败');
|
||||
}
|
||||
@@ -166,7 +169,8 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
setSaving(true);
|
||||
const payload = {
|
||||
classroomId: values.classroomId,
|
||||
tenantId: values.tenantId,
|
||||
lessorOrganizationId: values.lessorOrganizationId,
|
||||
lesseeOrganizationId: values.lesseeOrganizationId,
|
||||
startDate: values.dateRange[0].format('YYYY-MM-DD'),
|
||||
endDate: values.dateRange[1].format('YYYY-MM-DD'),
|
||||
dailyRate: values.dailyRate,
|
||||
@@ -188,7 +192,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
} catch (e: any) {
|
||||
if (e?.conflicts?.length) {
|
||||
const list = e.conflicts
|
||||
.map((c: any) => `${c.tenantName}(${c.startDate}~${c.endDate})`)
|
||||
.map((c: any) => `${c.organizationName}(${c.startDate}~${c.endDate})`)
|
||||
.join('、');
|
||||
message.error(`时间段冲突:${list}`);
|
||||
} else {
|
||||
@@ -232,7 +236,8 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
resetUnavailableDates();
|
||||
form.setFieldsValue({
|
||||
classroomId: record.classroomId,
|
||||
tenantId: record.tenantId,
|
||||
lessorOrganizationId: record.lessorOrganizationId,
|
||||
lesseeOrganizationId: record.lesseeOrganizationId,
|
||||
dateRange: [dayjs(record.startDate), dayjs(record.endDate)],
|
||||
dailyRate: record.dailyRate ? Number(record.dailyRate) : undefined,
|
||||
totalAmount: record.totalAmount ? Number(record.totalAmount) : undefined,
|
||||
@@ -264,9 +269,9 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '租赁方',
|
||||
title: '承租机构',
|
||||
width: 100,
|
||||
dataIndex: 'tenant',
|
||||
dataIndex: 'lesseeOrganization',
|
||||
render: (t: any) =>
|
||||
t ? (
|
||||
<Tag
|
||||
@@ -392,7 +397,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
>
|
||||
<Space wrap>
|
||||
<Input.Search
|
||||
placeholder="搜索教室/租赁方"
|
||||
placeholder="搜索教室/承租机构"
|
||||
allowClear
|
||||
style={{ width: 180 }}
|
||||
onSearch={(v) => setSearchText(v)}
|
||||
@@ -458,12 +463,35 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="tenantId" label="租赁方" rules={[{ required: true }]}>
|
||||
<Form.Item name="lessorOrganizationId" label="出租机构" tooltip="默认由本机构出租">
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="选择租赁方"
|
||||
options={tenants.map((t) => ({ value: t.id, label: t.name }))}
|
||||
placeholder="默认本机构"
|
||||
allowClear
|
||||
options={organizations
|
||||
.filter((organization) => organization.isHost)
|
||||
.map((organization) => ({
|
||||
value: organization.id,
|
||||
label: `${organization.name}(本机构)`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="lesseeOrganizationId"
|
||||
label="承租机构"
|
||||
rules={[{ required: true, message: '请选择承租机构' }]}
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="选择外部承租机构"
|
||||
options={organizations
|
||||
.filter((organization) => !organization.isHost && organization.status === 'active')
|
||||
.map((organization) => ({
|
||||
value: organization.id,
|
||||
label: organization.name,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="dateRange" label="租赁起止日期" rules={[{ required: true }]}>
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
Spin,
|
||||
Empty,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
} from 'antd';
|
||||
import { CalendarOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import api from '../../api';
|
||||
@@ -24,7 +24,7 @@ interface ScheduleData {
|
||||
month: number;
|
||||
days: number;
|
||||
classrooms: any[];
|
||||
tenants: any[];
|
||||
organizations: any[];
|
||||
matrix: Record<number, Record<number, any>>;
|
||||
summary: Record<
|
||||
number,
|
||||
@@ -95,10 +95,7 @@ const ClassroomSchedulePage: React.FC = () => {
|
||||
|
||||
const handleDownloadContract = async (id: number, filename?: string) => {
|
||||
try {
|
||||
await downloadBlob(
|
||||
`/classroom-rentals/${id}/contract`,
|
||||
filename || `contract-${id}.pdf`,
|
||||
);
|
||||
await downloadBlob(`/classroom-rentals/${id}/contract`, filename || `contract-${id}.pdf`);
|
||||
} catch {
|
||||
// downloadBlob already shows an error via throw
|
||||
}
|
||||
@@ -175,7 +172,7 @@ const ClassroomSchedulePage: React.FC = () => {
|
||||
<Card size="small" style={{ marginBottom: 16 }} title="图例">
|
||||
<Space wrap>
|
||||
<Tag color="#52c41a">内部排课</Tag>
|
||||
{data.tenants.map((t) => (
|
||||
{data.organizations.map((t) => (
|
||||
<Tag
|
||||
key={t.id}
|
||||
color={t.color}
|
||||
@@ -184,7 +181,9 @@ const ClassroomSchedulePage: React.FC = () => {
|
||||
{t.name} (租赁)
|
||||
</Tag>
|
||||
))}
|
||||
<Tag color="#d9d9d9" style={{ color: '#999' }}>空闲</Tag>
|
||||
<Tag color="#d9d9d9" style={{ color: '#999' }}>
|
||||
空闲
|
||||
</Tag>
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
@@ -310,7 +309,7 @@ const ClassroomSchedulePage: React.FC = () => {
|
||||
title={
|
||||
isInternal
|
||||
? `${cell.className} · ${cell.subject}\n${cell.teacherName} · ${cell.startTime}-${cell.endTime}`
|
||||
: `${cell.tenantName}${cell.hasContract ? ' · 有合同' : ''}`
|
||||
: `${cell.organizationName}${cell.hasContract ? ' · 有合同' : ''}`
|
||||
}
|
||||
>
|
||||
<span style={{ color: '#fff', fontSize: 10, fontWeight: 600 }}>
|
||||
@@ -347,21 +346,22 @@ const ClassroomSchedulePage: React.FC = () => {
|
||||
{detailModal.classroom?.roomType})
|
||||
</div>
|
||||
<div>
|
||||
<strong>租赁方:</strong>
|
||||
<strong>承租机构:</strong>
|
||||
<Tag
|
||||
color={detailModal.tenant?.color}
|
||||
color={detailModal.lesseeOrganization?.color}
|
||||
style={{
|
||||
background: detailModal.tenant?.color,
|
||||
background: detailModal.lesseeOrganization?.color,
|
||||
color: '#fff',
|
||||
borderColor: detailModal.tenant?.color,
|
||||
borderColor: detailModal.lesseeOrganization?.color,
|
||||
}}
|
||||
>
|
||||
{detailModal.tenant?.name}
|
||||
{detailModal.lesseeOrganization?.name}
|
||||
</Tag>
|
||||
</div>
|
||||
<div>
|
||||
<strong>联系人:</strong>
|
||||
{detailModal.tenant?.contact || '-'} {detailModal.tenant?.phone || ''}
|
||||
{detailModal.lesseeOrganization?.contactName || '-'}{' '}
|
||||
{detailModal.lesseeOrganization?.phone || ''}
|
||||
</div>
|
||||
<div>
|
||||
<strong>起止日期:</strong>
|
||||
|
||||
@@ -39,7 +39,7 @@ const OccupanciesPage: React.FC = () => {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [students, setStudents] = useState<any[]>([]);
|
||||
const [rooms, setRooms] = useState<any[]>([]);
|
||||
const [tenants, setTenants] = useState<any[]>([]);
|
||||
const [organizations, setOrganizations] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [checkInModal, setCheckInModal] = useState(false);
|
||||
const [checkOutModal, setCheckOutModal] = useState<any>(null);
|
||||
@@ -67,9 +67,9 @@ const OccupanciesPage: React.FC = () => {
|
||||
api.get('/occupancies', { params: { active: showActive ? 'true' : undefined, dateFrom: dateRange?.[0]?.format('YYYY-MM-DD'), dateTo: dateRange?.[1]?.format('YYYY-MM-DD') } }),
|
||||
api.get('/students'),
|
||||
api.get('/rooms/overview'),
|
||||
api.get('/tenants'),
|
||||
api.get('/organizations'),
|
||||
])) as PromiseSettledResult<any>[];
|
||||
const labels = ['入住数据', '学生列表', '房间列表', '租赁方'];
|
||||
const labels = ['入住数据', '学生列表', '房间列表', '机构列表'];
|
||||
[occRes, stuRes, rmRes, tnRes].forEach((res, i) => {
|
||||
if (res.status === 'rejected') {
|
||||
message.warning(`${labels[i]}加载失败`);
|
||||
@@ -78,7 +78,7 @@ const OccupanciesPage: React.FC = () => {
|
||||
setData(occRes.status === 'fulfilled' ? occRes.value : []);
|
||||
setStudents(stuRes.status === 'fulfilled' ? stuRes.value : []);
|
||||
setRooms(rmRes.status === 'fulfilled' ? rmRes.value : []);
|
||||
setTenants(tnRes.status === 'fulfilled' ? tnRes.value : []);
|
||||
setOrganizations(tnRes.status === 'fulfilled' ? tnRes.value : []);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('数据加载异常');
|
||||
@@ -129,8 +129,8 @@ const OccupanciesPage: React.FC = () => {
|
||||
roomId: values.roomId,
|
||||
checkInDate: values.checkInDate.format('YYYY-MM-DD'),
|
||||
billingStartDate: values.billingStartDate?.format('YYYY-MM-DD'),
|
||||
rentalType: values.rentalType,
|
||||
tenantId: values.tenantId,
|
||||
stayType: values.stayType,
|
||||
responsibleOrganizationId: values.responsibleOrganizationId,
|
||||
notes: values.notes,
|
||||
bedId: values.bedId,
|
||||
lockerId: values.lockerId || undefined,
|
||||
@@ -556,7 +556,7 @@ const OccupanciesPage: React.FC = () => {
|
||||
format="YYYY-MM-DD"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="rentalType" label="租赁类型">
|
||||
<Form.Item name="stayType" label="入住类型">
|
||||
<Select
|
||||
allowClear
|
||||
options={[
|
||||
@@ -566,13 +566,13 @@ const OccupanciesPage: React.FC = () => {
|
||||
placeholder="默认为短租"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="tenantId" label="关联单位">
|
||||
<Form.Item name="responsibleOrganizationId" label="负责机构">
|
||||
<Select
|
||||
showSearch
|
||||
allowClear
|
||||
optionFilterProp="label"
|
||||
placeholder="选择关联单位"
|
||||
options={tenants.map((t: { id: number; name: string }) => ({
|
||||
placeholder="默认取学生所属机构"
|
||||
options={organizations.map((t: { id: number; name: string }) => ({
|
||||
value: t.id,
|
||||
label: t.name,
|
||||
}))}
|
||||
|
||||
314
apps/admin/src/pages/Organizations/index.tsx
Normal file
314
apps/admin/src/pages/Organizations/index.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, Empty, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag } from 'antd';
|
||||
import { BankOutlined, InboxOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
const PRESET_COLORS = [
|
||||
'#ff7875',
|
||||
'#ffa940',
|
||||
'#ffc53d',
|
||||
'#73d13d',
|
||||
'#36cfc9',
|
||||
'#40a9ff',
|
||||
'#597ef7',
|
||||
'#9254de',
|
||||
'#f759ab',
|
||||
'#8c8c8c',
|
||||
];
|
||||
|
||||
interface OrganizationItem {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
isHost: boolean;
|
||||
contactName?: string;
|
||||
phone?: string;
|
||||
color?: string;
|
||||
notes?: string;
|
||||
status: 'active' | 'archived';
|
||||
}
|
||||
|
||||
const OrganizationsPage: React.FC = () => {
|
||||
const [data, setData] = useState<OrganizationItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<OrganizationItem | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState<string>();
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
const keyword = searchText.trim().toLowerCase();
|
||||
return data.filter((item) => {
|
||||
const matchesKeyword =
|
||||
!keyword ||
|
||||
item.name.toLowerCase().includes(keyword) ||
|
||||
item.code.toLowerCase().includes(keyword) ||
|
||||
item.contactName?.toLowerCase().includes(keyword);
|
||||
return matchesKeyword && (!filterStatus || item.status === filterStatus);
|
||||
});
|
||||
}, [data, searchText, filterStatus]);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setData(
|
||||
await api.get<OrganizationItem[]>('/organizations', { params: { includeArchived: true } }),
|
||||
);
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '机构数据加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void fetchData();
|
||||
}, []);
|
||||
|
||||
const openEditor = (record?: OrganizationItem) => {
|
||||
setEditing(record ?? null);
|
||||
form.resetFields();
|
||||
if (record) form.setFieldsValue(record);
|
||||
else form.setFieldsValue({ color: PRESET_COLORS[data.length % PRESET_COLORS.length] });
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) await api.put(`/organizations/${editing.id}`, values);
|
||||
else await api.post('/organizations', values);
|
||||
message.success(editing ? '机构已更新' : '机构已创建');
|
||||
setModalOpen(false);
|
||||
await fetchData();
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '机构',
|
||||
dataIndex: 'name',
|
||||
width: 220,
|
||||
render: (name: string, record: OrganizationItem) => (
|
||||
<Space>
|
||||
<span
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
background: record.color || '#8c8c8c',
|
||||
}}
|
||||
/>
|
||||
<strong>{name}</strong>
|
||||
{record.isHost ? (
|
||||
<Tag color="blue" icon={<BankOutlined />}>
|
||||
本机构
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag>外部机构</Tag>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '机构编码',
|
||||
dataIndex: 'code',
|
||||
width: 130,
|
||||
render: (value: string) => <code>{value}</code>,
|
||||
},
|
||||
{
|
||||
title: '联系人',
|
||||
dataIndex: 'contactName',
|
||||
width: 120,
|
||||
render: (value?: string) => value || '-',
|
||||
},
|
||||
{ title: '电话', dataIndex: 'phone', width: 140, render: (value?: string) => value || '-' },
|
||||
{ title: '备注', dataIndex: 'notes', ellipsis: true, render: (value?: string) => value || '-' },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (status: string) => (
|
||||
<Tag color={status === 'active' ? 'green' : 'default'}>
|
||||
{status === 'active' ? '正常' : '已归档'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 160,
|
||||
render: (_: unknown, record: OrganizationItem) => (
|
||||
<Space>
|
||||
<PermissionButton
|
||||
permission="organization:edit"
|
||||
size="small"
|
||||
onClick={() => openEditor(record)}
|
||||
>
|
||||
编辑
|
||||
</PermissionButton>
|
||||
{!record.isHost && record.status === 'active' ? (
|
||||
<Popconfirm
|
||||
title="归档后仍保留历史学生、入住和租赁记录"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await api.delete(`/organizations/${record.id}`);
|
||||
message.success('机构已归档');
|
||||
await fetchData();
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '归档失败');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PermissionButton
|
||||
permission="organization:delete"
|
||||
size="small"
|
||||
icon={<InboxOutlined />}
|
||||
>
|
||||
归档
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="统一机构管理"
|
||||
description="本机构与外部机构使用同一套资料。学生明确归属机构;教室租赁则单独记录出租机构和承租机构。"
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Space wrap>
|
||||
<Input.Search
|
||||
placeholder="搜索名称、编码或联系人"
|
||||
allowClear
|
||||
style={{ width: 260 }}
|
||||
onChange={(event) => setSearchText(event.target.value)}
|
||||
/>
|
||||
<Select
|
||||
placeholder="全部状态"
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
value={filterStatus}
|
||||
onChange={setFilterStatus}
|
||||
options={[
|
||||
{ value: 'active', label: '正常' },
|
||||
{ value: 'archived', label: '已归档' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
<PermissionButton
|
||||
permission="organization:create"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => openEditor()}
|
||||
>
|
||||
添加机构
|
||||
</PermissionButton>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={filteredData}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
locale={{ emptyText: <Empty description="暂无机构" /> }}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 个机构` }}
|
||||
/>
|
||||
<Modal
|
||||
title={editing ? `编辑机构 · ${editing.name}` : '添加外部机构'}
|
||||
open={modalOpen}
|
||||
onOk={handleSave}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
confirmLoading={saving}
|
||||
okText="保存"
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
{editing?.isHost ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="这是系统本机构,不可归档,也不能改为外部机构。"
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
) : null}
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="机构名称"
|
||||
rules={[{ required: true, message: '请输入机构名称' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="code"
|
||||
label="机构编码"
|
||||
tooltip="用于导入和系统识别,建议使用大写英文、数字、下划线或短横线"
|
||||
rules={[
|
||||
{ required: true },
|
||||
{ pattern: /^[A-Z0-9_-]+$/, message: '仅支持大写英文、数字、下划线和短横线' },
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
disabled={editing?.isHost}
|
||||
placeholder="如 PARTNER_A"
|
||||
onChange={(event) => form.setFieldValue('code', event.target.value.toUpperCase())}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="contactName" label="联系人">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="电话">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="color" label="识别颜色">
|
||||
<Space wrap>
|
||||
{PRESET_COLORS.map((color) => (
|
||||
<button
|
||||
type="button"
|
||||
key={color}
|
||||
aria-label={`选择 ${color}`}
|
||||
onClick={() => form.setFieldValue('color', color)}
|
||||
style={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 6,
|
||||
border: '1px solid #d9d9d9',
|
||||
background: color,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<Form.Item name="notes" label="备注">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrganizationsPage;
|
||||
@@ -25,7 +25,7 @@ const PermissionsPage: React.FC = () => {
|
||||
bill: '账单管理',
|
||||
deposit: '押金管理',
|
||||
classroom: '教室管理',
|
||||
tenant: '租赁方',
|
||||
organization: '机构管理',
|
||||
rental: '租赁订单',
|
||||
log: '操作日志',
|
||||
user: '用户管理',
|
||||
|
||||
@@ -125,7 +125,7 @@ const RolesPage: React.FC = () => {
|
||||
bill: '账单管理',
|
||||
deposit: '押金管理',
|
||||
classroom: '教室管理',
|
||||
tenant: '租赁方',
|
||||
organization: '机构管理',
|
||||
rental: '租赁订单',
|
||||
log: '操作日志',
|
||||
user: '用户管理',
|
||||
|
||||
@@ -11,8 +11,8 @@ function getCardStyle(room: any): React.CSSProperties {
|
||||
else if (room.currentCount === 0) base = { background: '#f6ffed', borderColor: '#b7eb8f' };
|
||||
else if (room.currentCount >= room.capacity) base = { background: '#fff2f0', borderColor: '#ffccc7' };
|
||||
else base = { background: '#e6f4ff', borderColor: '#91caff' };
|
||||
if (room.tenantColor) {
|
||||
return { ...base, background: `color-mix(in srgb, ${room.tenantColor} 15%, ${base.background || '#fff'} 85%)` };
|
||||
if (room.organizationColor) {
|
||||
return { ...base, background: `color-mix(in srgb, ${room.organizationColor} 15%, ${base.background || '#fff'} 85%)` };
|
||||
}
|
||||
return base;
|
||||
}
|
||||
@@ -24,18 +24,18 @@ function getStatusLabel(room: any) {
|
||||
return <Tag color="processing">部分入住</Tag>;
|
||||
}
|
||||
|
||||
function getTenantTags(occupants: any[]) {
|
||||
const tenantList = [
|
||||
function getOrganizationTags(occupants: any[]) {
|
||||
const organizationList = [
|
||||
...new Map(
|
||||
occupants
|
||||
.filter((o: any) => o.tenantName)
|
||||
.map((o: any) => [o.tenantId, { name: o.tenantName, color: o.tenantColor }]),
|
||||
.filter((o: any) => o.organizationName)
|
||||
.map((o: any) => [o.organizationId, { name: o.organizationName, color: o.organizationColor }]),
|
||||
).values(),
|
||||
] as { name: string; color: string | null }[];
|
||||
if (tenantList.length === 0) return null;
|
||||
if (organizationList.length === 0) return null;
|
||||
return (
|
||||
<div className="room-card-tag-wrapper" style={{ marginBottom: 6 }}>
|
||||
{tenantList.map((t) => (
|
||||
{organizationList.map((t) => (
|
||||
<Tag
|
||||
key={t.name}
|
||||
color={t.color || 'gold'}
|
||||
@@ -53,7 +53,7 @@ const RoomVisualPage: React.FC = () => {
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedBuilding, setSelectedBuilding] = useState<string>('all');
|
||||
const [selectedTenant, setSelectedTenant] = useState<number | 'all'>('all');
|
||||
const [selectedOrganization, setSelectedOrganization] = useState<number | 'all'>('all');
|
||||
const [detailRoom, setDetailRoom] = useState<any>(null);
|
||||
const [asOf, setAsOf] = useState<Dayjs | null>(null);
|
||||
|
||||
@@ -80,7 +80,7 @@ const RoomVisualPage: React.FC = () => {
|
||||
|
||||
const rooms = data.rooms.filter((r: any) => {
|
||||
if (selectedBuilding !== 'all' && r.building !== selectedBuilding) return false;
|
||||
if (selectedTenant !== 'all' && !(r.tenantIds || []).includes(selectedTenant)) return false;
|
||||
if (selectedOrganization !== 'all' && !(r.organizationIds || []).includes(selectedOrganization)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -93,7 +93,7 @@ const RoomVisualPage: React.FC = () => {
|
||||
const availableBedsCount = totalBeds - occupiedBeds;
|
||||
const fullRooms = rooms.filter((r: any) => r.currentCount >= r.capacity).length;
|
||||
|
||||
/* getCardStyle, getStatusLabel, getTenantTags are now standalone functions outside the component */
|
||||
/* getCardStyle, getStatusLabel, getOrganizationTags are now standalone functions outside the component */
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -128,12 +128,12 @@ const RoomVisualPage: React.FC = () => {
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={selectedTenant}
|
||||
onChange={setSelectedTenant}
|
||||
value={selectedOrganization}
|
||||
onChange={setSelectedOrganization}
|
||||
style={{ width: 180 }}
|
||||
options={[
|
||||
{ value: 'all', label: '全部租赁方' },
|
||||
...(data.tenants || []).map((t: any) => ({
|
||||
{ value: 'all', label: '全部机构' },
|
||||
...(data.organizations || []).map((t: any) => ({
|
||||
value: t.id,
|
||||
label: (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
@@ -215,10 +215,10 @@ const RoomVisualPage: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 16, fontWeight: 600, color: '#1d1d1f' }}>
|
||||
{room.tenantColor && (
|
||||
{room.organizationColor && (
|
||||
<span style={{
|
||||
width: 10, height: 10, borderRadius: '50%',
|
||||
backgroundColor: room.tenantColor, display: 'inline-block',
|
||||
backgroundColor: room.organizationColor, display: 'inline-block',
|
||||
flexShrink: 0,
|
||||
}} />
|
||||
)}
|
||||
@@ -257,7 +257,7 @@ const RoomVisualPage: React.FC = () => {
|
||||
</Tag>
|
||||
</div>
|
||||
)}
|
||||
{getTenantTags(room.occupants)}
|
||||
{getOrganizationTags(room.occupants)}
|
||||
{room.occupants.length > 0 && (
|
||||
<div className="room-card-tag-wrapper" style={{ borderTop: '1px solid rgba(0,0,0,0.06)', paddingTop: 6 }}>
|
||||
{room.occupants.slice(0, 4).map((o: any) => (
|
||||
@@ -303,10 +303,10 @@ const RoomVisualPage: React.FC = () => {
|
||||
位置:{detailRoom.building || '-'} {detailRoom.floor ? `${detailRoom.floor}F` : ''}
|
||||
</div>
|
||||
|
||||
{detailRoom.tenantColor && (
|
||||
{detailRoom.organizationColor && (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Tag color={detailRoom.tenantColor}>
|
||||
{detailRoom.occupants[0]?.tenantName || '租户'}
|
||||
<Tag color={detailRoom.organizationColor}>
|
||||
{detailRoom.occupants[0]?.organizationName || '机构'}
|
||||
</Tag>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -67,11 +67,11 @@ const StudentsPage: React.FC = () => {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [tenants, setTenants] = useState<any[]>([]);
|
||||
const [organizations, setOrganizations] = useState<any[]>([]);
|
||||
const [editing, setEditing] = useState<any>(null);
|
||||
const [searchName, setSearchName] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
|
||||
const [filterTenantId, setFilterTenantId] = useState<number | undefined>(undefined);
|
||||
const [filterOrganizationId, setFilterOrganizationId] = useState<number | undefined>(undefined);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [archivedCount, setArchivedCount] = useState(0);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
||||
@@ -136,7 +136,7 @@ const StudentsPage: React.FC = () => {
|
||||
includeArchived: 'true',
|
||||
};
|
||||
if (filterStatus) params.status = filterStatus;
|
||||
if (filterTenantId) params.tenantId = filterTenantId;
|
||||
if (filterOrganizationId) params.organizationId = filterOrganizationId;
|
||||
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');
|
||||
@@ -147,7 +147,7 @@ const StudentsPage: React.FC = () => {
|
||||
message.error(err?.message || '加载失败,请稍后重试');
|
||||
}
|
||||
setLoading(false);
|
||||
}, [searchName, showArchived, filterStatus, filterTenantId]);
|
||||
}, [searchName, showArchived, filterStatus, filterOrganizationId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
@@ -155,9 +155,9 @@ const StudentsPage: React.FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get('/tenants', { params: { includeArchived: 'false' } })
|
||||
.get('/organizations', { params: { includeArchived: 'false' } })
|
||||
.then((res: unknown) => {
|
||||
setTenants(res as Array<{ id: number; name: string }>);
|
||||
setOrganizations(res as Array<{ id: number; name: string }>);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
@@ -325,15 +325,15 @@ const StudentsPage: React.FC = () => {
|
||||
{ title: '紧急联系人电话', dataIndex: 'emergencyPhone', width: 130 },
|
||||
{
|
||||
title: '所属机构',
|
||||
dataIndex: 'tenant',
|
||||
dataIndex: 'organization',
|
||||
width: 100,
|
||||
render: (tenant: { name?: string } | null) =>
|
||||
tenant?.name ? (
|
||||
render: (organization: { name?: string } | null) =>
|
||||
organization?.name ? (
|
||||
<Tag
|
||||
color="purple"
|
||||
style={{ maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis' }}
|
||||
>
|
||||
{tenant.name}
|
||||
{organization.name}
|
||||
</Tag>
|
||||
) : (
|
||||
'-'
|
||||
@@ -415,7 +415,7 @@ const StudentsPage: React.FC = () => {
|
||||
),
|
||||
},
|
||||
],
|
||||
[handleViewSensitive, openDrawer, showArchived, tenants],
|
||||
[handleViewSensitive, openDrawer, showArchived, organizations],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -457,12 +457,12 @@ const StudentsPage: React.FC = () => {
|
||||
placeholder="所属机构"
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
value={filterTenantId}
|
||||
value={filterOrganizationId}
|
||||
onChange={(v) => {
|
||||
setFilterTenantId(v);
|
||||
setFilterOrganizationId(v);
|
||||
}}
|
||||
>
|
||||
{tenants.map((t: { id: number; name: string }) => (
|
||||
{organizations.map((t: { id: number; name: string }) => (
|
||||
<Select.Option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</Select.Option>
|
||||
@@ -502,6 +502,8 @@ const StudentsPage: React.FC = () => {
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
const host = organizations.find((organization) => organization.isHost);
|
||||
if (host) form.setFieldValue('organizationId', host.id);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
@@ -661,14 +663,21 @@ const StudentsPage: React.FC = () => {
|
||||
<Form.Item name="emergencyPhone" label="紧急联系人电话">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="tenantId" label="所属机构" tooltip="选择租赁方,留空表示本机构">
|
||||
<Form.Item
|
||||
name="organizationId"
|
||||
label="所属机构"
|
||||
rules={[{ required: true, message: '请选择所属机构' }]}
|
||||
>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="选择租赁方"
|
||||
options={tenants.map((t: { id: number; name: string }) => ({
|
||||
value: t.id,
|
||||
label: t.name,
|
||||
}))}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="选择所属机构"
|
||||
options={organizations.map(
|
||||
(organization: { id: number; name: string; isHost?: boolean }) => ({
|
||||
value: organization.id,
|
||||
label: organization.isHost ? `${organization.name}(本机构)` : organization.name,
|
||||
}),
|
||||
)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="supervisor" label="负责人/班主任">
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { Table, Modal, Form, Input, Select, Space, Tag, Popconfirm, Empty } from 'antd';
|
||||
import { PlusOutlined, InboxOutlined } from '@ant-design/icons';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
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: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '加载失败,请稍后重试');
|
||||
}
|
||||
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}
|
||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||
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;
|
||||
@@ -114,7 +114,7 @@ export const SAMPLE_OCCUPANCY = {
|
||||
checkInDate: '2026-03-01',
|
||||
billingStartDate: '2026-03-01',
|
||||
billingEndDate: '2026-06-30',
|
||||
rentalType: 'short',
|
||||
stayType: 'short',
|
||||
};
|
||||
|
||||
// ── Bill / Expense (PRD §9-10) ──────────────────────────────────────
|
||||
@@ -169,7 +169,7 @@ export const SAMPLE_CLASSROOM = {
|
||||
status: 'available',
|
||||
};
|
||||
|
||||
// ── Tenant (PRD §12) ────────────────────────────────────────────────
|
||||
// ── Organization (PRD §12) ────────────────────────────────────────────────
|
||||
|
||||
export const SAMPLE_TENANT = {
|
||||
name: '测试合作机构A',
|
||||
@@ -214,7 +214,7 @@ export const PERMISSION_NODES = [
|
||||
'attendance:view', 'attendance:add', 'attendance:update', 'attendance:delete',
|
||||
'attendance:batch',
|
||||
'classroom:view', 'classroom:add', 'classroom:update', 'classroom:delete',
|
||||
'tenant:view', 'tenant:add', 'tenant:update', 'tenant:delete',
|
||||
'organization:view', 'organization:create', 'organization:edit', 'organization:delete',
|
||||
'rental:view', 'rental:add', 'rental:update', 'rental:delete',
|
||||
'archive:view', 'archive:import', 'archive:export',
|
||||
'report:generate',
|
||||
|
||||
Reference in New Issue
Block a user