feat: replace tenants with organization management

This commit is contained in:
2026-07-10 21:27:26 +08:00
parent 8ed1682b90
commit 8f0991a51f
49 changed files with 1292 additions and 698 deletions

View File

@@ -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>
}
/>

View File

@@ -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' },
],
},
{

View File

@@ -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 }]}>

View File

@@ -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>

View File

@@ -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,
}))}

View 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;

View File

@@ -25,7 +25,7 @@ const PermissionsPage: React.FC = () => {
bill: '账单管理',
deposit: '押金管理',
classroom: '教室管理',
tenant: '租赁方',
organization: '机构管理',
rental: '租赁订单',
log: '操作日志',
user: '用户管理',

View File

@@ -125,7 +125,7 @@ const RolesPage: React.FC = () => {
bill: '账单管理',
deposit: '押金管理',
classroom: '教室管理',
tenant: '租赁方',
organization: '机构管理',
rental: '租赁订单',
log: '操作日志',
user: '用户管理',

View File

@@ -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>
)}

View File

@@ -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="负责人/班主任">

View File

@@ -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;

View File

@@ -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',