feat: DingTalk attendance import + integration config + expense types + UI polish
Server: - Add DingTalk attendance import service with SSE progress streaming - Add IntegrationConfig entity & module for multi-tenant DingTalk setup - Add ExpenseType entity & ExpenseTypesModule - Add SeedModule for DB initialization - Add UserDingMapping entity for DingTalk user linkage - Attendance service: import flow with dedup & student auto-mapping - Rooms service: time-range overlap queries - Sync controller/service: DingTalk integration wiring - Permission guard: refactor to pure re-export - Campus scope middleware: tenant-aware filtering Admin UI: - Attendance page: import UI with progress & result summary - All pages: tableStyle/tablePagination standardization - Login page: responsive styling - Sensitive data: useViewSensitive hook for masked viewing - Vite config: path aliases, build optimization - Test infra: vitest config, test utilities Docs: PRD DingTalk batch 1 & 2 design docs
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
||||
import { PlusOutlined, DeleteOutlined, DollarOutlined, CheckOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import { maskPhone, maskIdNumber } from '../../utils/sensitive';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
|
||||
const statusMap: Record<string, { text: string; color: string }> = {
|
||||
@@ -66,6 +67,7 @@ const DepositsPage: React.FC = () => {
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
|
||||
const [activeTab, setActiveTab] = useState<string>('all');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
@@ -105,7 +107,19 @@ const DepositsPage: React.FC = () => {
|
||||
});
|
||||
}, [data, searchText, filterStatus]);
|
||||
|
||||
const studentOptions = useMemo(
|
||||
() =>
|
||||
students
|
||||
.filter((s: any) => s.status === 'active')
|
||||
.map((s: any) => ({
|
||||
value: s.id,
|
||||
label: `${s.name} (${s.idNumber ? maskIdNumber(s.idNumber) : (s.phone ? maskPhone(s.phone) : '')})`,
|
||||
})),
|
||||
[students],
|
||||
);
|
||||
|
||||
const handleCreate = async () => {
|
||||
setSaving(true);
|
||||
const values = await createForm.validateFields();
|
||||
try {
|
||||
await api.post('/deposits', {
|
||||
@@ -120,10 +134,13 @@ const DepositsPage: React.FC = () => {
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '操作失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefund = async () => {
|
||||
setSaving(true);
|
||||
const values = await refundForm.validateFields();
|
||||
try {
|
||||
await api.put(`/deposits/${refundModal.id}/refund`, {
|
||||
@@ -138,6 +155,8 @@ const DepositsPage: React.FC = () => {
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '操作失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -165,6 +184,7 @@ const DepositsPage: React.FC = () => {
|
||||
|
||||
const handleRejectRefund = async () => {
|
||||
if (!rejectModal) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.put(`/deposits/${rejectModal.id}/reject-refund`, { reason: rejectReason || '未说明原因' });
|
||||
message.success('已驳回退款申请');
|
||||
@@ -174,6 +194,8 @@ const DepositsPage: React.FC = () => {
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '驳回失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
const handleAddInstallment = async () => {
|
||||
@@ -216,7 +238,7 @@ const DepositsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
const columns = useMemo(() => [
|
||||
{ title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' },
|
||||
{ title: '押金金额', dataIndex: 'amount', width: 110, render: (v: number) => `¥${Number(v).toFixed(2)}` },
|
||||
{ title: '缴纳日期', dataIndex: 'paidDate', width: 110 },
|
||||
@@ -304,7 +326,7 @@ const DepositsPage: React.FC = () => {
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
], [handleRequestRefund, fetchData]);
|
||||
|
||||
const pendingColumns = [
|
||||
{ title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' },
|
||||
@@ -444,6 +466,7 @@ const DepositsPage: React.FC = () => {
|
||||
onOk={handleCreate}
|
||||
onCancel={() => setCreateModal(false)}
|
||||
okText="确认"
|
||||
confirmLoading={saving}
|
||||
>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item
|
||||
@@ -455,12 +478,7 @@ const DepositsPage: React.FC = () => {
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="搜索并选择学生"
|
||||
options={students
|
||||
.filter((s: any) => s.status === 'active')
|
||||
.map((s: any) => ({
|
||||
value: s.id,
|
||||
label: `${s.name} (${s.idNumber || s.phone || ''})`,
|
||||
}))}
|
||||
options={studentOptions}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="amount" label="押金金额(元)" rules={[{ required: true }]}>
|
||||
@@ -482,6 +500,7 @@ const DepositsPage: React.FC = () => {
|
||||
onOk={handleRefund}
|
||||
onCancel={() => setRefundModal(null)}
|
||||
okText="确认退还"
|
||||
confirmLoading={saving}
|
||||
>
|
||||
<Form form={refundForm} layout="vertical">
|
||||
<div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 8 }}>
|
||||
@@ -624,9 +643,10 @@ const DepositsPage: React.FC = () => {
|
||||
onCancel={() => { setRejectModal(null); setRejectReason(''); }}
|
||||
okText="确认驳回"
|
||||
okButtonProps={{ danger: true }}
|
||||
confirmLoading={saving}
|
||||
>
|
||||
<Input.TextArea
|
||||
placeholder="请输入驳回原因"
|
||||
aria-label="驳回原因"
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
rows={3}
|
||||
|
||||
Reference in New Issue
Block a user