feat: refine attendance and finance workflows
This commit is contained in:
@@ -369,6 +369,36 @@
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
|
||||
.lesson-summary-strip {
|
||||
grid-template-columns: minmax(210px, 1fr) repeat(2, minmax(120px, 0.7fr));
|
||||
}
|
||||
|
||||
.lesson-summary-strip .attendance-rate,
|
||||
.lesson-summary-strip .attendance-summary-cell {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.lesson-summary-strip .attendance-rate {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.attendance-correction-segment.ant-segmented {
|
||||
padding: 2px;
|
||||
border: 1px solid var(--student-line, #e1e8e5);
|
||||
background: #f8faf9;
|
||||
}
|
||||
|
||||
.attendance-correction-segment .ant-segmented-item {
|
||||
min-width: 52px;
|
||||
}
|
||||
|
||||
.attendance-correction-segment .ant-segmented-item-label {
|
||||
min-height: 28px;
|
||||
line-height: 28px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.attendance-summary-icon {
|
||||
display: grid !important;
|
||||
flex: 0 0 auto;
|
||||
@@ -774,9 +804,10 @@
|
||||
|
||||
.student-filter-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(230px, 1.4fr) repeat(4, minmax(120px, 1fr)) auto;
|
||||
gap: 12px;
|
||||
grid-template-columns: 176px 220px 142px 142px 142px auto;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
justify-content: start;
|
||||
min-width: 0;
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
@@ -803,13 +834,54 @@
|
||||
|
||||
.student-filter-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.student-filter-actions .ant-btn {
|
||||
min-width: 88px;
|
||||
}
|
||||
|
||||
|
||||
.attendance-period-editor {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.attendance-period-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.05fr 1fr 132px 132px 92px auto;
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--student-line);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.attendance-period-row .ant-form-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.attendance-period-row__delete {
|
||||
align-self: end;
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
.attendance-period-add {
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.attendance-period-modal .ant-modal-body {
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.student-class-overview {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 0.9fr) minmax(0, 1.1fr);
|
||||
grid-template-columns: minmax(360px, 0.95fr) minmax(360px, 1.05fr);
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
margin-bottom: 16px;
|
||||
@@ -819,9 +891,10 @@
|
||||
.student-metric-strip,
|
||||
.student-workspace,
|
||||
.student-record-card {
|
||||
border: 1px solid var(--student-line);
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgb(21 122 101 / 12%);
|
||||
border-radius: 18px;
|
||||
background: var(--student-surface);
|
||||
box-shadow: 0 14px 36px rgb(24 44 38 / 7%);
|
||||
}
|
||||
|
||||
.student-class-overview > *,
|
||||
@@ -831,26 +904,66 @@
|
||||
}
|
||||
|
||||
.student-class-identity {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-height: 156px;
|
||||
padding: 18px;
|
||||
min-height: 176px;
|
||||
overflow: hidden;
|
||||
padding: 20px;
|
||||
background:
|
||||
radial-gradient(circle at 100% 0%, rgb(21 122 101 / 10%), transparent 34%),
|
||||
linear-gradient(135deg, #ffffff 0%, #f8fcfa 100%);
|
||||
}
|
||||
|
||||
.student-class-identity::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -36px;
|
||||
bottom: -44px;
|
||||
width: 118px;
|
||||
height: 118px;
|
||||
border: 18px solid rgb(21 122 101 / 7%);
|
||||
border-radius: 999px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.student-class-heading {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.student-overview-kicker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
margin-bottom: 8px;
|
||||
padding: 0 9px;
|
||||
border-radius: 999px;
|
||||
background: rgb(21 122 101 / 10%);
|
||||
color: var(--student-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.student-class-identity h2 {
|
||||
margin: 0 0 5px;
|
||||
font-size: 22px;
|
||||
margin: 0 0 6px;
|
||||
color: #111c18;
|
||||
font-size: 24px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.student-class-identity span {
|
||||
.student-class-identity p {
|
||||
margin: 0;
|
||||
color: var(--student-muted);
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.student-teacher-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
@@ -858,78 +971,118 @@
|
||||
.student-teacher-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
min-width: 150px;
|
||||
padding: 9px;
|
||||
border-radius: 8px;
|
||||
background: var(--student-soft);
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
border: 1px solid rgb(21 122 101 / 10%);
|
||||
border-radius: 12px;
|
||||
background: rgb(255 255 255 / 76%);
|
||||
}
|
||||
|
||||
.student-teacher-item.is-muted {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.student-teacher-item .ant-avatar {
|
||||
flex: 0 0 auto;
|
||||
color: var(--student-primary);
|
||||
background: var(--student-primary-soft);
|
||||
}
|
||||
|
||||
.student-teacher-item > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.student-teacher-item strong,
|
||||
.student-teacher-item span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.student-teacher-item span {
|
||||
color: var(--student-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.student-teacher-item strong {
|
||||
min-width: 0;
|
||||
margin-top: 2px;
|
||||
overflow-wrap: anywhere;
|
||||
color: #15231f;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.student-metric-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(70px, 1fr));
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.student-metric-card {
|
||||
appearance: none;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 6px;
|
||||
min-height: 156px;
|
||||
padding: 16px 12px;
|
||||
border: 0;
|
||||
border-right: 1px solid var(--student-line);
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
min-height: 76px;
|
||||
padding: 13px 14px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 14px;
|
||||
background: #f9fbfa;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.student-metric-card:last-child {
|
||||
border-right: 0;
|
||||
transition:
|
||||
background 160ms ease,
|
||||
border-color 160ms ease,
|
||||
box-shadow 160ms ease,
|
||||
transform 160ms ease;
|
||||
}
|
||||
|
||||
.student-metric-card:hover,
|
||||
.student-metric-card.active {
|
||||
background: var(--student-primary-soft);
|
||||
transform: translateY(-1px);
|
||||
border-color: rgb(21 122 101 / 24%);
|
||||
background: #ffffff;
|
||||
box-shadow: 0 10px 22px rgb(24 44 38 / 8%);
|
||||
}
|
||||
|
||||
.student-metric-card:focus-visible {
|
||||
outline: 3px solid rgb(21 122 101 / 22%);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.student-metric-icon {
|
||||
display: inline-grid;
|
||||
width: max-content;
|
||||
min-width: 34px;
|
||||
height: 26px;
|
||||
flex: 0 0 auto;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
place-items: center;
|
||||
padding: 0 7px;
|
||||
border-radius: 999px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.student-metric-copy {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.student-metric-card strong {
|
||||
font-size: 24px;
|
||||
overflow: hidden;
|
||||
color: #111c18;
|
||||
font-size: clamp(19px, 3vw, 24px);
|
||||
line-height: 1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.student-metric-card small {
|
||||
color: var(--student-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.student-workspace {
|
||||
@@ -959,13 +1112,20 @@
|
||||
.student-workspace-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.student-workspace-tools .ant-input-search {
|
||||
width: min(240px, 100%);
|
||||
flex: 0 0 240px;
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.student-workspace-tools .ant-btn {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.student-legend {
|
||||
@@ -1014,7 +1174,7 @@
|
||||
|
||||
.student-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(184px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(216px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@@ -1055,7 +1215,12 @@
|
||||
}
|
||||
|
||||
.student-attendance-head small {
|
||||
flex: 0 0 auto;
|
||||
max-width: 112px;
|
||||
overflow: hidden;
|
||||
color: var(--student-quiet);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.student-status-blocks {
|
||||
@@ -1189,10 +1354,11 @@
|
||||
}
|
||||
|
||||
.student-filter-field--date {
|
||||
grid-column: span 2;
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.student-filter-actions {
|
||||
grid-column: 1 / -1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
@@ -1203,11 +1369,20 @@
|
||||
}
|
||||
|
||||
.student-metric-strip {
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.lesson-summary-strip {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.lesson-summary-strip .attendance-rate,
|
||||
.lesson-summary-strip .attendance-summary-cell {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.student-attendance-center {
|
||||
margin: -12px;
|
||||
padding: 0 12px 20px;
|
||||
@@ -1233,6 +1408,10 @@
|
||||
}
|
||||
|
||||
.student-filter-panel,
|
||||
.attendance-period-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.student-class-overview {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -1242,12 +1421,21 @@
|
||||
}
|
||||
|
||||
.student-filter-actions > *,
|
||||
.student-center-actions .ant-btn,
|
||||
.student-workspace-tools,
|
||||
.student-workspace-tools .ant-input-search {
|
||||
.student-center-actions .ant-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.student-workspace-tools {
|
||||
align-self: stretch;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.student-workspace-tools .ant-input-search {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.student-filter-actions > * {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -1257,8 +1445,7 @@
|
||||
}
|
||||
|
||||
.student-metric-card {
|
||||
min-height: 112px;
|
||||
border-bottom: 1px solid var(--student-line);
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
.student-workspace {
|
||||
@@ -1269,6 +1456,10 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.student-teacher-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.student-detail-profile {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
@@ -1282,3 +1473,19 @@
|
||||
grid-template-columns: 56px minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 430px) {
|
||||
.student-metric-strip {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.student-attendance-head {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.student-attendance-head small {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -591,10 +591,10 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="dailyRate" label="日租金(可选)">
|
||||
<InputNumber min={0} precision={2} style={{ width: '100%' }} prefix="¥" />
|
||||
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} prefix="¥" />
|
||||
</Form.Item>
|
||||
<Form.Item name="totalAmount" label="合同总额(可选)">
|
||||
<InputNumber min={0} precision={2} style={{ width: '100%' }} prefix="¥" />
|
||||
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} prefix="¥" />
|
||||
</Form.Item>
|
||||
<Form.Item name="notes" label="备注">
|
||||
<Input.TextArea rows={2} />
|
||||
|
||||
@@ -604,7 +604,7 @@ const DepositsPage: React.FC = () => {
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="amount" label="本次收取金额(元)" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
|
||||
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="paidDate" label="收取日期" rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} placeholder="选择收取日期" format="YYYY-MM-DD" />
|
||||
@@ -752,7 +752,7 @@ const DepositsPage: React.FC = () => {
|
||||
>
|
||||
<Form form={installmentForm} layout="vertical">
|
||||
<Form.Item name="amount" label="分期金额(元)" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
|
||||
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="dueDate" label="到期日期" rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} placeholder="选择到期日期" format="YYYY-MM-DD" />
|
||||
|
||||
@@ -684,7 +684,7 @@ const ExpensesPage: React.FC = () => {
|
||||
<Select options={typeOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="amount" label="金额(元)" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
|
||||
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="period" label="账单周期" rules={[{ required: true }]}>
|
||||
<RangePicker
|
||||
@@ -769,7 +769,7 @@ const ExpensesPage: React.FC = () => {
|
||||
<Select options={personalTypeOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="amount" label="金额(元)" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
|
||||
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="expenseDate" label="费用日期" rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} placeholder="选择日期" format="YYYY-MM-DD" />
|
||||
|
||||
@@ -74,6 +74,11 @@ interface StudentUpdateImportResult {
|
||||
skipped?: number;
|
||||
}
|
||||
|
||||
interface StudentFilterLookups {
|
||||
classes: Array<{ id: number; name: string; code?: string }>;
|
||||
teachers: Array<{ id: number; name: string; username: string }>;
|
||||
}
|
||||
|
||||
const StudentsPage: React.FC = () => {
|
||||
const { modal } = App.useApp();
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
@@ -84,6 +89,10 @@ const StudentsPage: React.FC = () => {
|
||||
const [searchName, setSearchName] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
|
||||
const [filterOrganizationId, setFilterOrganizationId] = useState<number | undefined>(undefined);
|
||||
const [filterClassId, setFilterClassId] = useState<number | undefined>(undefined);
|
||||
const [filterTeacherId, setFilterTeacherId] = useState<number | undefined>(undefined);
|
||||
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[]>([]);
|
||||
@@ -149,6 +158,8 @@ const StudentsPage: React.FC = () => {
|
||||
};
|
||||
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');
|
||||
@@ -159,7 +170,7 @@ const StudentsPage: React.FC = () => {
|
||||
message.error(err?.message || '加载失败,请稍后重试');
|
||||
}
|
||||
setLoading(false);
|
||||
}, [searchName, showArchived, filterStatus, filterOrganizationId]);
|
||||
}, [searchName, showArchived, filterStatus, filterOrganizationId, filterClassId, filterTeacherId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
@@ -172,6 +183,13 @@ const StudentsPage: React.FC = () => {
|
||||
setOrganizations(res as Array<{ id: number; name: string }>);
|
||||
})
|
||||
.catch(() => {});
|
||||
api
|
||||
.get<StudentFilterLookups>('/students/filter-lookups')
|
||||
.then((res) => {
|
||||
setClassOptions(res.classes || []);
|
||||
setTeacherOptions(res.teachers || []);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
const handleSave = async () => {
|
||||
const values = await form.validateFields();
|
||||
@@ -329,8 +347,15 @@ const StudentsPage: React.FC = () => {
|
||||
? '/api'
|
||||
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
|
||||
const token = localStorage.getItem('token');
|
||||
const params = showArchived ? '?includeArchived=true' : '';
|
||||
fetch(`${baseURL}/students/export${params}`, { headers: { Authorization: `Bearer ${token}` } })
|
||||
const params = new URLSearchParams();
|
||||
if (searchName) params.set('name', searchName);
|
||||
if (filterStatus) params.set('status', filterStatus);
|
||||
if (filterOrganizationId) params.set('organizationId', String(filterOrganizationId));
|
||||
if (showArchived) params.set('includeArchived', 'true');
|
||||
if (filterClassId) params.set('classId', String(filterClassId));
|
||||
if (filterTeacherId) params.set('teacherId', String(filterTeacherId));
|
||||
const query = params.toString() ? `?${params.toString()}` : '';
|
||||
fetch(`${baseURL}/students/export${query}`, { headers: { Authorization: `Bearer ${token}` } })
|
||||
.then((res) => res.blob())
|
||||
.then((blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -567,6 +592,36 @@ const StudentsPage: React.FC = () => {
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
placeholder="所属班级"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{ width: 160 }}
|
||||
value={filterClassId}
|
||||
onChange={(v) => {
|
||||
setFilterClassId(v);
|
||||
}}
|
||||
options={classOptions.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.code ? `${item.name}(${item.code})` : item.name,
|
||||
}))}
|
||||
/>
|
||||
<Select
|
||||
placeholder="所属老师"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{ width: 160 }}
|
||||
value={filterTeacherId}
|
||||
onChange={(v) => {
|
||||
setFilterTeacherId(v);
|
||||
}}
|
||||
options={teacherOptions.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.name === item.username ? item.name : `${item.name}(${item.username})`,
|
||||
}))}
|
||||
/>
|
||||
<Button
|
||||
type={showArchived ? 'primary' : 'default'}
|
||||
onClick={() => setShowArchived(!showArchived)}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
InputNumber,
|
||||
Modal,
|
||||
Radio,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
@@ -25,6 +26,8 @@ interface WalletRow {
|
||||
studentNo?: string;
|
||||
balance: number;
|
||||
outstandingAmount: number;
|
||||
roomType?: string;
|
||||
roomNumber?: string;
|
||||
}
|
||||
|
||||
const transactionNames: Record<string, string> = {
|
||||
@@ -39,6 +42,8 @@ const WalletsPage: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [debtOnly, setDebtOnly] = useState(false);
|
||||
const [roomType, setRoomType] = useState<string | undefined>();
|
||||
const [roomTypes, setRoomTypes] = useState<string[]>([]);
|
||||
const [selected, setSelected] = useState<WalletRow | null>(null);
|
||||
const [transactions, setTransactions] = useState<any[]>([]);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
@@ -52,7 +57,7 @@ const WalletsPage: React.FC = () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.get('/wallets', {
|
||||
params: { keyword: keyword || undefined, debtOnly },
|
||||
params: { keyword: keyword || undefined, debtOnly, roomType },
|
||||
});
|
||||
setRows(data as WalletRow[]);
|
||||
} catch (error: any) {
|
||||
@@ -60,12 +65,27 @@ const WalletsPage: React.FC = () => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [keyword, debtOnly]);
|
||||
}, [keyword, debtOnly, roomType]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchRows();
|
||||
}, [fetchRows]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRoomTypes = async () => {
|
||||
try {
|
||||
setRoomTypes((await api.get('/wallets/room-types')) as string[]);
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '加载房型失败');
|
||||
}
|
||||
};
|
||||
void fetchRoomTypes();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedRowKeys([]);
|
||||
}, [keyword, debtOnly, roomType]);
|
||||
|
||||
const openChange = (row: WalletRow) => {
|
||||
setSelected(row);
|
||||
form.setFieldsValue({ type: 'recharge', amount: undefined, description: '' });
|
||||
@@ -154,6 +174,9 @@ const WalletsPage: React.FC = () => {
|
||||
<>
|
||||
<strong>{row.studentName}</strong>
|
||||
<div style={{ color: '#999' }}>{row.studentNo || `#${row.studentId}`}</div>
|
||||
<div style={{ color: '#999' }}>
|
||||
{row.roomType ? `${row.roomType}${row.roomNumber ? ` · ${row.roomNumber}` : ''}` : '未入住'}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
@@ -218,6 +241,14 @@ const WalletsPage: React.FC = () => {
|
||||
onSearch={setKeyword}
|
||||
onChange={(event) => !event.target.value && setKeyword('')}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="按房型筛选"
|
||||
style={{ width: 180 }}
|
||||
value={roomType}
|
||||
onChange={setRoomType}
|
||||
options={roomTypes.map((type) => ({ label: type, value: type }))}
|
||||
/>
|
||||
<span>仅看欠费</span>
|
||||
<Switch checked={debtOnly} onChange={setDebtOnly} />
|
||||
</Space>
|
||||
@@ -285,7 +316,7 @@ const WalletsPage: React.FC = () => {
|
||||
<Form form={batchForm} layout="vertical">
|
||||
<div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 8 }}>
|
||||
已选择 <strong>{selectedRowKeys.length}</strong>{' '}
|
||||
名学生,将按相同金额批量修改水电费余额。
|
||||
名学生,将按相同金额批量修改水电费余额。可先按房型筛选并勾选对应学生后批量缴费。
|
||||
</div>
|
||||
<Form.Item name="type" label="操作类型" rules={[{ required: true }]}>
|
||||
<Radio.Group
|
||||
|
||||
Reference in New Issue
Block a user