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
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
AttendanceRecord,
|
||||
AttendanceSession,
|
||||
AttendanceDevice,
|
||||
AttendancePeriodConfig,
|
||||
DingAttendanceRaw,
|
||||
SyncLog,
|
||||
SyncState,
|
||||
@@ -128,6 +129,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
|
||||
AttendanceRecord,
|
||||
AttendanceSession,
|
||||
AttendanceDevice,
|
||||
AttendancePeriodConfig,
|
||||
DingAttendanceRaw,
|
||||
Notification,
|
||||
StudentProfile,
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
AttendanceSummaryQueryDto,
|
||||
AttendanceCalendarQueryDto,
|
||||
QueryAttendanceRecordsDto,
|
||||
AttendanceScheduleOptionsQueryDto,
|
||||
QueryDingRawDto,
|
||||
MatchDingRecordDto,
|
||||
AttendanceReportQueryDto,
|
||||
@@ -33,6 +34,8 @@ import {
|
||||
GenerateFromSchedulesDto,
|
||||
LessonAttendanceQueryDto,
|
||||
StartLessonAttendanceDto,
|
||||
SaveAttendancePeriodConfigsDto,
|
||||
RefreshDingTalkAttendanceDto,
|
||||
} from './dto/attendance.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
@@ -92,6 +95,45 @@ export class AttendanceController {
|
||||
return this.service.assertClassAccess(req.user.id, classId, this.canManageAllAttendance(req));
|
||||
}
|
||||
|
||||
|
||||
@Get('attendance-period-configs')
|
||||
@RequirePermission('attendance:view')
|
||||
getAttendancePeriodConfigs() {
|
||||
return this.service.getAttendancePeriodConfigs();
|
||||
}
|
||||
|
||||
@Put('attendance-period-configs')
|
||||
@RequirePermission('attendance:edit')
|
||||
async saveAttendancePeriodConfigs(
|
||||
@Body() dto: SaveAttendancePeriodConfigsDto,
|
||||
@Request() req: { user: RequestUser },
|
||||
) {
|
||||
const result = await this.service.saveAttendancePeriodConfigs(dto);
|
||||
await this.logService.log({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
module: '考勤管理',
|
||||
action: '保存考勤时段配置',
|
||||
targetType: 'attendancePeriodConfig',
|
||||
detail: dto.periods.map((item) => `${item.label}:${item.startTime}-${item.endTime}`).join(';'),
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('attendance-period-configs/reset')
|
||||
@RequirePermission('attendance:edit')
|
||||
async resetAttendancePeriodConfigs(@Request() req: { user: RequestUser }) {
|
||||
const result = await this.service.resetAttendancePeriodConfigs();
|
||||
await this.logService.log({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
module: '考勤管理',
|
||||
action: '重置考勤时段配置',
|
||||
targetType: 'attendancePeriodConfig',
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get('attendance-lessons/schedules/:scheduleId')
|
||||
@RequirePermission('attendance:view')
|
||||
async getLessonAttendance(
|
||||
@@ -170,6 +212,84 @@ export class AttendanceController {
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@Get('attendance-records/dingtalk-sync-status')
|
||||
@RequirePermission('attendance:view')
|
||||
async getDingTalkSyncStatus() {
|
||||
const latest = await this.logService.findLatestDingTalkAttendancePull();
|
||||
return {
|
||||
lastPulledAt: latest?.createdAt ?? null,
|
||||
action: latest?.action ?? null,
|
||||
username: latest?.username ?? null,
|
||||
detail: latest?.detail ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@Post('attendance-records/refresh-dingtalk')
|
||||
@RequirePermission('attendance:create')
|
||||
async refreshDingTalkAttendance(
|
||||
@Body() dto: RefreshDingTalkAttendanceDto,
|
||||
@Request() req: { user: RequestUser },
|
||||
) {
|
||||
if (dto.date > this.getTodayDateOnly()) {
|
||||
throw new BadRequestException('不能查看或刷新未来日期的考勤');
|
||||
}
|
||||
if (dto.classId) await this.assertClassAccess(req, dto.classId);
|
||||
const schedules = await this.service.getRefreshableSchedules(
|
||||
dto.date,
|
||||
dto.classId,
|
||||
dto.session,
|
||||
await this.getAccessibleClassIds(req),
|
||||
);
|
||||
let refreshed = 0;
|
||||
let imported = 0;
|
||||
let matched = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const schedule of schedules) {
|
||||
try {
|
||||
const importClassIds = await this.service.getTeacherClassDingUserIds(
|
||||
req.user.id,
|
||||
schedule.classId!,
|
||||
this.canManageAllAttendance(req),
|
||||
dto.date,
|
||||
);
|
||||
const importRange = this.service.getLessonAttendanceImportDateRange(schedule, dto.date);
|
||||
const importResult = await this.importService.importFromDingTalk({
|
||||
...importRange,
|
||||
userIds: importClassIds,
|
||||
autoMatch: true,
|
||||
userId: req.user.id,
|
||||
});
|
||||
if (!importResult.success || importResult.errors.length > 0) {
|
||||
errors.push(...importResult.errors);
|
||||
continue;
|
||||
}
|
||||
await this.service.createLessonAttendanceFromDingTalk(schedule.id, dto.date, req.user.id);
|
||||
refreshed += 1;
|
||||
imported += importResult.imported;
|
||||
matched += importResult.matched;
|
||||
} catch (error: unknown) {
|
||||
errors.push((error as { message?: string })?.message || `排课 ${schedule.id} 刷新失败`);
|
||||
}
|
||||
}
|
||||
|
||||
await this.logService.log({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
module: '考勤管理',
|
||||
action: '刷新钉钉考勤',
|
||||
targetType: 'attendanceRecord',
|
||||
detail: `日期${dto.date},排课${schedules.length}节,刷新${refreshed}节,钉钉新增${imported}条,匹配${matched}条${errors.length ? `,错误${errors.length}条` : ''}`,
|
||||
status: errors.length > 0 && refreshed === 0 ? 'failure' : 'success',
|
||||
});
|
||||
|
||||
if (schedules.length === 0) {
|
||||
return { refreshed, imported, matched, errors: ['当前条件下没有可刷新的课程'] };
|
||||
}
|
||||
return { refreshed, imported, matched, errors };
|
||||
}
|
||||
|
||||
// ── Batch create attendance records ──
|
||||
@Post('attendance-records/batch')
|
||||
@RequirePermission('attendance:create')
|
||||
@@ -281,6 +401,16 @@ export class AttendanceController {
|
||||
res.end();
|
||||
}
|
||||
|
||||
@Get('attendance-records/schedules')
|
||||
@RequirePermission('attendance:view')
|
||||
async getAttendanceScheduleOptions(
|
||||
@Query() query: AttendanceScheduleOptionsQueryDto,
|
||||
@Request() req: { user: RequestUser },
|
||||
) {
|
||||
await this.assertClassAccess(req, query.classId);
|
||||
return this.service.getScheduleOptionsForAttendance(query.classId, query.date);
|
||||
}
|
||||
|
||||
// ── List attendance records with filters ──
|
||||
@Get('attendance-records')
|
||||
@RequirePermission('attendance:view')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AttendanceRecord, AttendanceSession, AttendanceDevice, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping } from '../entities';
|
||||
import { AttendanceRecord, AttendanceSession, AttendanceDevice, AttendancePeriodConfig, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping } from '../entities';
|
||||
import { AttendanceService } from './attendance.service';
|
||||
import { AttendanceImportService } from './attendance-import.service';
|
||||
import { AttendanceSettlementService } from './attendance-settlement.service';
|
||||
@@ -10,7 +10,7 @@ import { IntegrationModule } from '../integration/integration.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([AttendanceRecord, AttendanceSession, AttendanceDevice, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping]),
|
||||
TypeOrmModule.forFeature([AttendanceRecord, AttendanceSession, AttendanceDevice, AttendancePeriodConfig, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping]),
|
||||
OperationLogsModule,
|
||||
IntegrationModule,
|
||||
],
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
AttendanceRecord,
|
||||
AttendanceSession,
|
||||
AttendanceDevice,
|
||||
AttendancePeriodConfig,
|
||||
DingAttendanceRaw,
|
||||
Class,
|
||||
Student,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
UpdateAttendanceRecordDto,
|
||||
GenerateAttendanceFromSchedulesDto,
|
||||
GenerateFromSchedulesDto,
|
||||
SaveAttendancePeriodConfigsDto,
|
||||
} from './dto/attendance.dto';
|
||||
|
||||
/** Keyed mutex serializing operations on the same attendance session. */
|
||||
@@ -69,11 +71,20 @@ export class AttendanceService {
|
||||
private attendanceSessionRepo: Repository<AttendanceSession>,
|
||||
@InjectRepository(AttendanceDevice)
|
||||
private attendanceDeviceRepo: Repository<AttendanceDevice>,
|
||||
@InjectRepository(AttendancePeriodConfig)
|
||||
private attendancePeriodConfigRepo: Repository<AttendancePeriodConfig>,
|
||||
private dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
private sessionMutex = new SessionMutex();
|
||||
|
||||
private readonly defaultAttendancePeriods = [
|
||||
{ periodKey: 'morning_reading', label: '早自习', startTime: '07:30', endTime: '08:30', sortOrder: 1 },
|
||||
{ periodKey: 'morning', label: '早课', startTime: '09:00', endTime: '12:00', sortOrder: 2 },
|
||||
{ periodKey: 'afternoon', label: '晚课', startTime: '14:00', endTime: '17:00', sortOrder: 3 },
|
||||
{ periodKey: 'evening_study', label: '晚自习', startTime: '18:30', endTime: '21:00', sortOrder: 4 },
|
||||
] as const;
|
||||
|
||||
private formatDeviceDetail(device: AttendanceDevice): string {
|
||||
const classroomName = device.classroom?.name;
|
||||
return classroomName ? `${device.deviceName} · ${classroomName}` : device.deviceName;
|
||||
@@ -410,6 +421,7 @@ export class AttendanceService {
|
||||
);
|
||||
const existingStudentIds = new Set(existingRecords.map((record) => record.studentId));
|
||||
|
||||
const lessonSessionKey = this.mapLessonScheduleTimeToSession(schedule.startTime);
|
||||
const updatedRecords = existingRecords.map((record) => {
|
||||
record.student = studentsById.get(record.studentId)!;
|
||||
// Preserve manual corrections only while the lesson is still in progress.
|
||||
@@ -448,7 +460,7 @@ export class AttendanceService {
|
||||
scheduleId,
|
||||
attendanceSessionId: existing.id,
|
||||
attendanceDate: lessonDate,
|
||||
session: this.mapScheduleTimeToSession(schedule.startTime),
|
||||
session: lessonSessionKey,
|
||||
status: this.mapDingTalkStatus(raw, finalize),
|
||||
source: 'dingtalk',
|
||||
...this.getLessonPunchMetadata(
|
||||
@@ -522,6 +534,7 @@ export class AttendanceService {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const lessonSessionKey = this.mapLessonScheduleTimeToSession(schedule.startTime);
|
||||
const records = classStudents.map((classStudent) => {
|
||||
const raw = this.selectDingTalkRecordsForLesson(
|
||||
rawByStudent.get(classStudent.studentId) ?? [],
|
||||
@@ -535,7 +548,7 @@ export class AttendanceService {
|
||||
scheduleId,
|
||||
attendanceSessionId: session.id,
|
||||
attendanceDate: lessonDate,
|
||||
session: this.mapScheduleTimeToSession(schedule.startTime),
|
||||
session: lessonSessionKey,
|
||||
status: this.mapDingTalkStatus(raw, finalize),
|
||||
source: 'dingtalk',
|
||||
...this.getLessonPunchMetadata(
|
||||
@@ -704,7 +717,7 @@ export class AttendanceService {
|
||||
if (sched.weekDay !== weekDay) continue;
|
||||
if (dateStr < sched.startDate || dateStr > sched.endDate) continue;
|
||||
|
||||
const session = this.mapScheduleTimeToSession(sched.startTime);
|
||||
const session = await this.mapScheduleTimeToSession(sched.startTime);
|
||||
const classStudentsForDate = classStudents.filter((cs) =>
|
||||
this.isClassStudentActiveOnDate(cs, dateStr),
|
||||
);
|
||||
@@ -782,7 +795,97 @@ export class AttendanceService {
|
||||
return shifted.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
private mapScheduleTimeToSession(startTime: string): string {
|
||||
private async ensureAttendancePeriodConfigs() {
|
||||
const count = await this.attendancePeriodConfigRepo.count();
|
||||
if (count === 0) {
|
||||
await this.attendancePeriodConfigRepo.save(
|
||||
this.defaultAttendancePeriods.map((period) => this.attendancePeriodConfigRepo.create({
|
||||
...period,
|
||||
enabled: true,
|
||||
})),
|
||||
);
|
||||
}
|
||||
return this.attendancePeriodConfigRepo.find({ order: { sortOrder: 'ASC', id: 'ASC' } });
|
||||
}
|
||||
|
||||
async getAttendancePeriodConfigs() {
|
||||
return this.ensureAttendancePeriodConfigs();
|
||||
}
|
||||
|
||||
async getRefreshableSchedules(date: string, classId?: number, session?: string, accessibleClassIds?: number[]) {
|
||||
const parsedDate = new Date(`${date}T00:00:00`);
|
||||
if (Number.isNaN(parsedDate.getTime())) throw new BadRequestException('无效日期');
|
||||
const weekDay = parsedDate.getDay() === 0 ? 7 : parsedDate.getDay();
|
||||
const qb = this.scheduleRepo
|
||||
.createQueryBuilder('schedule')
|
||||
.where('schedule.scheduleType = :scheduleType', { scheduleType: ScheduleType.INTERNAL })
|
||||
.andWhere('schedule.status = :status', { status: 'active' })
|
||||
.andWhere('schedule.classId IS NOT NULL')
|
||||
.andWhere('schedule.weekDay = :weekDay', { weekDay })
|
||||
.andWhere('schedule.startDate <= :date', { date })
|
||||
.andWhere('schedule.endDate >= :date', { date });
|
||||
|
||||
if (classId) {
|
||||
qb.andWhere('schedule.classId = :classId', { classId });
|
||||
} else if (accessibleClassIds) {
|
||||
if (accessibleClassIds.length === 0) return [];
|
||||
qb.andWhere('schedule.classId IN (:...accessibleClassIds)', { accessibleClassIds });
|
||||
}
|
||||
|
||||
const schedules = await qb.orderBy('schedule.startTime', 'ASC').getMany();
|
||||
if (!session) return schedules;
|
||||
|
||||
const matchedSchedules: ClassSchedule[] = [];
|
||||
for (const schedule of schedules) {
|
||||
if ((await this.mapScheduleTimeToSession(schedule.startTime)) === session) {
|
||||
matchedSchedules.push(schedule);
|
||||
}
|
||||
}
|
||||
return matchedSchedules;
|
||||
}
|
||||
|
||||
async saveAttendancePeriodConfigs(dto: SaveAttendancePeriodConfigsDto) {
|
||||
const seen = new Set<string>();
|
||||
const normalized = dto.periods.map((period, index) => {
|
||||
const periodKey = period.periodKey.trim();
|
||||
const label = period.label.trim();
|
||||
if (!periodKey || !label) throw new BadRequestException('时段标识和名称不能为空');
|
||||
if (seen.has(periodKey)) throw new BadRequestException(`时段标识 ${periodKey} 重复`);
|
||||
seen.add(periodKey);
|
||||
if (this.toMinutes(period.endTime) <= this.toMinutes(period.startTime)) {
|
||||
throw new BadRequestException(`${label} 的结束时间必须晚于开始时间`);
|
||||
}
|
||||
return {
|
||||
periodKey,
|
||||
label,
|
||||
startTime: period.startTime,
|
||||
endTime: period.endTime,
|
||||
sortOrder: period.sortOrder ?? index + 1,
|
||||
enabled: period.enabled ?? true,
|
||||
};
|
||||
}).sort((left, right) => left.sortOrder - right.sortOrder);
|
||||
|
||||
for (let index = 1; index < normalized.length; index += 1) {
|
||||
const previous = normalized[index - 1];
|
||||
const current = normalized[index];
|
||||
if (previous.enabled && current.enabled && this.toMinutes(current.startTime) < this.toMinutes(previous.endTime)) {
|
||||
throw new BadRequestException(`${previous.label} 和 ${current.label} 时间段不能重叠`);
|
||||
}
|
||||
}
|
||||
|
||||
await this.attendancePeriodConfigRepo.clear();
|
||||
await this.attendancePeriodConfigRepo.save(
|
||||
normalized.map((period) => this.attendancePeriodConfigRepo.create(period)),
|
||||
);
|
||||
return this.getAttendancePeriodConfigs();
|
||||
}
|
||||
|
||||
async resetAttendancePeriodConfigs() {
|
||||
await this.attendancePeriodConfigRepo.clear();
|
||||
return this.ensureAttendancePeriodConfigs();
|
||||
}
|
||||
|
||||
private mapLessonScheduleTimeToSession(startTime: string): string {
|
||||
const hour = parseInt(startTime.slice(0, 2), 10);
|
||||
if (hour < 8) return 'morning_reading';
|
||||
if (hour < 12) return 'morning';
|
||||
@@ -791,13 +894,29 @@ export class AttendanceService {
|
||||
return 'night_check';
|
||||
}
|
||||
|
||||
private async mapScheduleTimeToSession(startTime: string): Promise<string> {
|
||||
const startMinutes = this.toMinutes(startTime);
|
||||
const periods = (await this.ensureAttendancePeriodConfigs()).filter((period) => period.enabled);
|
||||
const matched = periods.find((period) => {
|
||||
const periodStart = this.toMinutes(period.startTime);
|
||||
const periodEnd = this.toMinutes(period.endTime);
|
||||
return startMinutes >= periodStart && startMinutes < periodEnd;
|
||||
});
|
||||
if (matched) return matched.periodKey;
|
||||
throw new BadRequestException(`课程开始时间 ${startTime} 未匹配到考勤时段,请先配置考勤时段`);
|
||||
}
|
||||
|
||||
|
||||
// ── Attendance summary ──
|
||||
async getSummary(query: AttendanceSummaryQueryDto, accessibleClassIds?: number[]) {
|
||||
const qb = this.attendanceRepo.createQueryBuilder('ar');
|
||||
if (query.classId) {
|
||||
qb.andWhere('ar.classId = :classId', { classId: query.classId });
|
||||
} else if (accessibleClassIds) {
|
||||
}
|
||||
if (query.scheduleId) {
|
||||
qb.andWhere('ar.scheduleId = :scheduleId', { scheduleId: query.scheduleId });
|
||||
}
|
||||
if (!query.classId && accessibleClassIds) {
|
||||
if (accessibleClassIds.length === 0)
|
||||
return { total: 0, present: 0, late: 0, absent: 0, leave: 0, pending: 0, presentRate: 0 };
|
||||
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
|
||||
@@ -808,6 +927,9 @@ export class AttendanceService {
|
||||
if (query.dateTo) {
|
||||
qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo });
|
||||
}
|
||||
if (query.session) {
|
||||
qb.andWhere('ar.session = :session', { session: query.session });
|
||||
}
|
||||
|
||||
const rows = await qb.getMany();
|
||||
|
||||
@@ -841,6 +963,25 @@ export class AttendanceService {
|
||||
return this.buildCalendar(classId, weekStart);
|
||||
}
|
||||
|
||||
private getWeekDayForDate(date: string): number {
|
||||
const day = new Date(`${date}T00:00:00+08:00`).getUTCDay();
|
||||
return day === 0 ? 7 : day;
|
||||
}
|
||||
|
||||
async getScheduleOptionsForAttendance(classId: number, date: string) {
|
||||
const weekDay = this.getWeekDayForDate(date);
|
||||
return this.scheduleRepo
|
||||
.createQueryBuilder('cs')
|
||||
.where('cs.classId = :classId', { classId })
|
||||
.andWhere('cs.weekDay = :weekDay', { weekDay })
|
||||
.andWhere('cs.startDate <= :date', { date })
|
||||
.andWhere('cs.endDate >= :date', { date })
|
||||
.andWhere('cs.status = :status', { status: 'active' })
|
||||
.orderBy('cs.startTime', 'ASC')
|
||||
.addOrderBy('cs.subject', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
private async buildCalendar(classId: number, weekStart: string) {
|
||||
// Compute weekEnd (Sunday = weekStart + 6 days)
|
||||
const start = new Date(weekStart);
|
||||
@@ -1047,6 +1188,7 @@ export class AttendanceService {
|
||||
async findAllForExport(
|
||||
query: {
|
||||
classId?: number;
|
||||
scheduleId?: number;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
session?: string;
|
||||
@@ -1058,6 +1200,9 @@ export class AttendanceService {
|
||||
const qb = this.attendanceRepo.createQueryBuilder('ar');
|
||||
|
||||
qb.leftJoinAndSelect('ar.student', 'student').leftJoinAndSelect('ar.class', 'class');
|
||||
if (query.scheduleId) {
|
||||
qb.andWhere('ar.scheduleId = :scheduleId', { scheduleId: query.scheduleId });
|
||||
}
|
||||
if (query.classId) {
|
||||
qb.andWhere('ar.classId = :classId', { classId: query.classId });
|
||||
} else if (accessibleClassIds) {
|
||||
|
||||
@@ -3,16 +3,53 @@ import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsInt,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsIn,
|
||||
ValidateNested,
|
||||
IsNotEmpty,
|
||||
ArrayNotEmpty,
|
||||
Matches,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
|
||||
export class AttendancePeriodConfigItemDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
periodKey: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
label: string;
|
||||
|
||||
@IsString()
|
||||
@Matches(/^([01]\d|2[0-3]):[0-5]\d$/)
|
||||
startTime: string;
|
||||
|
||||
@IsString()
|
||||
@Matches(/^([01]\d|2[0-3]):[0-5]\d$/)
|
||||
endTime: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
sortOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export class SaveAttendancePeriodConfigsDto {
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AttendancePeriodConfigItemDto)
|
||||
periods: AttendancePeriodConfigItemDto[];
|
||||
}
|
||||
|
||||
export class AttendanceRecordItem {
|
||||
@IsInt()
|
||||
@IsNotEmpty()
|
||||
@@ -27,7 +64,6 @@ export class AttendanceRecordItem {
|
||||
attendanceDate: string;
|
||||
|
||||
@IsString()
|
||||
@IsIn(['morning_reading', 'morning', 'afternoon', 'evening_study', 'night_check'])
|
||||
@IsNotEmpty()
|
||||
session: string;
|
||||
|
||||
@@ -59,6 +95,11 @@ export class AttendanceSummaryQueryDto {
|
||||
@Type(() => Number)
|
||||
classId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
scheduleId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dateFrom?: string;
|
||||
@@ -66,6 +107,26 @@ export class AttendanceSummaryQueryDto {
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dateTo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
session?: string;
|
||||
}
|
||||
|
||||
|
||||
export class RefreshDingTalkAttendanceDto {
|
||||
@IsDateString()
|
||||
@IsNotEmpty()
|
||||
date: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
classId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
session?: string;
|
||||
}
|
||||
|
||||
export class AttendanceCalendarQueryDto {
|
||||
@@ -112,6 +173,17 @@ export class QueryDingRawDto {
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export class AttendanceScheduleOptionsQueryDto {
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
@IsNotEmpty()
|
||||
classId: number;
|
||||
|
||||
@IsDateString()
|
||||
@IsNotEmpty()
|
||||
date: string;
|
||||
}
|
||||
|
||||
export class QueryAttendanceRecordsDto {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
|
||||
33
apps/server/src/entities/attendance-period-config.entity.ts
Normal file
33
apps/server/src/entities/attendance-period-config.entity.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity('attendance_period_configs')
|
||||
@Index(['periodKey'], { unique: true })
|
||||
@Index(['sortOrder'])
|
||||
export class AttendancePeriodConfig {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ name: 'period_key', type: 'varchar', length: 40 })
|
||||
periodKey: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 40 })
|
||||
label: string;
|
||||
|
||||
@Column({ name: 'start_time', type: 'varchar', length: 5 })
|
||||
startTime: string;
|
||||
|
||||
@Column({ name: 'end_time', type: 'varchar', length: 5 })
|
||||
endTime: string;
|
||||
|
||||
@Column({ name: 'sort_order', type: 'integer', default: 0 })
|
||||
sortOrder: number;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
enabled: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export { ClassSchedule, ScheduleType } from './class-schedule.entity';
|
||||
export { AttendanceRecord } from './attendance-record.entity';
|
||||
export { AttendanceSession } from './attendance-session.entity';
|
||||
export { AttendanceDevice, AttendanceDeviceStatus } from './attendance-device.entity';
|
||||
export { AttendancePeriodConfig } from './attendance-period-config.entity';
|
||||
export { DingAttendanceRaw } from './ding-attendance-raw.entity';
|
||||
export { SyncLog } from './sync-log.entity';
|
||||
export { SyncState } from './sync-state.entity';
|
||||
|
||||
@@ -23,6 +23,17 @@ export class OperationLogsService {
|
||||
return this.repo.save(entry);
|
||||
}
|
||||
|
||||
async findLatestDingTalkAttendancePull() {
|
||||
return this.repo
|
||||
.createQueryBuilder('log')
|
||||
.where('log.module = :module', { module: '考勤管理' })
|
||||
.andWhere('log.action IN (:...actions)', {
|
||||
actions: ['拉取钉钉课程考勤', '查看已拉取课程考勤', '钉钉考勤导入', '刷新钉钉考勤'],
|
||||
})
|
||||
.orderBy('log.createdAt', 'DESC')
|
||||
.getOne();
|
||||
}
|
||||
|
||||
async findAll(query?: {
|
||||
module?: string;
|
||||
userId?: number;
|
||||
|
||||
@@ -110,4 +110,14 @@ export class QueryStudentDto {
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
organizationId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
classId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
teacherId?: number;
|
||||
}
|
||||
|
||||
@@ -175,6 +175,16 @@ export class StudentsController {
|
||||
return this.service.getBasicLookups();
|
||||
}
|
||||
|
||||
@Get('filter-lookups')
|
||||
@RequirePermission('student:view')
|
||||
async getFilterLookups(@Request() req: AuthenticatedRequest) {
|
||||
const classIds = await this.service.getAccessibleClassIds(
|
||||
req.user.id,
|
||||
this.canManageAllStudents(req),
|
||||
);
|
||||
return this.service.getFilterLookups(classIds);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('student:view')
|
||||
async findAll(
|
||||
@@ -194,7 +204,7 @@ export class StudentsController {
|
||||
@Get('export')
|
||||
@RequirePermission('student:export')
|
||||
async exportExcel(
|
||||
@Query('includeArchived') includeArchived?: string,
|
||||
@Query() query: QueryStudentDto,
|
||||
@Res() res?: Response,
|
||||
@Request() req?: any,
|
||||
) {
|
||||
@@ -202,10 +212,7 @@ export class StudentsController {
|
||||
req.user.id,
|
||||
this.canManageAllStudents(req),
|
||||
);
|
||||
const students = await this.service.findAll(
|
||||
{ includeArchived: includeArchived === 'true' },
|
||||
classIds,
|
||||
);
|
||||
const students = await this.service.findAll(query, classIds);
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const ws = workbook.addWorksheet('学生名单');
|
||||
ws.columns = STUDENT_EXPORT_COLUMNS;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, Like, Not, In, FindOptionsWhere } from 'typeorm';
|
||||
import { Repository, Like, Not, In, FindOptionsWhere, IsNull } from 'typeorm';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { Class } from '../entities/class.entity';
|
||||
import { ClassStudent } from '../entities/class-student.entity';
|
||||
@@ -41,6 +41,8 @@ export class StudentsService {
|
||||
status?: string;
|
||||
includeArchived?: boolean;
|
||||
organizationId?: number | string;
|
||||
classId?: number | string;
|
||||
teacherId?: number | string;
|
||||
},
|
||||
accessibleClassIds?: number[],
|
||||
) {
|
||||
@@ -52,10 +54,28 @@ export class StudentsService {
|
||||
} else if (!query?.includeArchived) {
|
||||
where.status = Not(In(['archived', 'staff']));
|
||||
}
|
||||
if (accessibleClassIds) {
|
||||
if (accessibleClassIds.length === 0) return [];
|
||||
|
||||
let scopedClassIds = accessibleClassIds ? [...accessibleClassIds] : undefined;
|
||||
if (query?.teacherId) {
|
||||
const teacherAssignments = await this.classTeacherRepo.find({
|
||||
where: { userId: Number(query.teacherId) },
|
||||
});
|
||||
const teacherClassIds = [...new Set(teacherAssignments.map((item) => item.classId))];
|
||||
scopedClassIds = scopedClassIds
|
||||
? scopedClassIds.filter((classId) => teacherClassIds.includes(classId))
|
||||
: teacherClassIds;
|
||||
}
|
||||
if (query?.classId) {
|
||||
const classId = Number(query.classId);
|
||||
scopedClassIds = scopedClassIds
|
||||
? scopedClassIds.filter((accessibleClassId) => accessibleClassId === classId)
|
||||
: [classId];
|
||||
}
|
||||
|
||||
if (scopedClassIds) {
|
||||
if (scopedClassIds.length === 0) return [];
|
||||
const classStudents = await this.classStudentRepo.find({
|
||||
where: { classId: In(accessibleClassIds), status: 'active' },
|
||||
where: { classId: In(scopedClassIds), status: 'active' },
|
||||
});
|
||||
const studentIds = [...new Set(classStudents.map((item) => item.studentId))];
|
||||
if (studentIds.length === 0) return [];
|
||||
@@ -64,6 +84,42 @@ export class StudentsService {
|
||||
return this.repo.find({ where, order: { createdAt: 'DESC' }, relations: ['organization'] });
|
||||
}
|
||||
|
||||
async getFilterLookups(accessibleClassIds?: number[]) {
|
||||
if (accessibleClassIds && accessibleClassIds.length === 0) {
|
||||
return { classes: [], teachers: [] };
|
||||
}
|
||||
|
||||
const classWhere = accessibleClassIds
|
||||
? { id: In(accessibleClassIds), isArchived: false }
|
||||
: { isArchived: false };
|
||||
const classes = await this.classRepo.find({
|
||||
select: ['id', 'name', 'code'],
|
||||
where: classWhere,
|
||||
order: { name: 'ASC' },
|
||||
});
|
||||
|
||||
const teacherWhere = accessibleClassIds
|
||||
? { classId: In(accessibleClassIds) }
|
||||
: { classId: In(classes.map((item) => item.id)), userId: Not(IsNull()) };
|
||||
const assignments = classes.length
|
||||
? await this.classTeacherRepo.find({ where: teacherWhere, relations: ['user'] })
|
||||
: [];
|
||||
const teacherMap = new Map<number, { id: number; name: string; username: string }>();
|
||||
for (const assignment of assignments) {
|
||||
if (!assignment.user || !assignment.user.isActive || assignment.user.isArchived) continue;
|
||||
teacherMap.set(assignment.userId, {
|
||||
id: assignment.userId,
|
||||
name: assignment.user.name || assignment.user.username,
|
||||
username: assignment.user.username,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
classes: classes.map((item) => ({ id: item.id, name: item.name, code: item.code })),
|
||||
teachers: [...teacherMap.values()].sort((a, b) => a.name.localeCompare(b.name, 'zh-CN')),
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: number) {
|
||||
const student = await this.repo.findOne({
|
||||
where: { id },
|
||||
|
||||
@@ -13,8 +13,18 @@ export class WalletsController {
|
||||
|
||||
@Get()
|
||||
@RequirePermission('wallet:view')
|
||||
findAll(@Query('keyword') keyword?: string, @Query('debtOnly') debtOnly?: string) {
|
||||
return this.service.findAll({ keyword, debtOnly: debtOnly === 'true' });
|
||||
findAll(
|
||||
@Query('keyword') keyword?: string,
|
||||
@Query('debtOnly') debtOnly?: string,
|
||||
@Query('roomType') roomType?: string,
|
||||
) {
|
||||
return this.service.findAll({ keyword, debtOnly: debtOnly === 'true', roomType });
|
||||
}
|
||||
|
||||
@Get('room-types')
|
||||
@RequirePermission('wallet:view')
|
||||
findRoomTypes() {
|
||||
return this.service.findRoomTypes();
|
||||
}
|
||||
|
||||
@Get('transactions')
|
||||
|
||||
@@ -4,12 +4,14 @@ import { Bill } from '../entities/bill.entity';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { StudentWallet } from '../entities/student-wallet.entity';
|
||||
import { WalletTransaction } from '../entities/wallet-transaction.entity';
|
||||
import { Room } from '../entities/room.entity';
|
||||
import { Occupancy } from '../entities/occupancy.entity';
|
||||
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
||||
import { WalletsController } from './wallets.controller';
|
||||
import { WalletsService } from './wallets.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([StudentWallet, WalletTransaction, Student, Bill]), OperationLogsModule],
|
||||
imports: [TypeOrmModule.forFeature([StudentWallet, WalletTransaction, Student, Bill, Room, Occupancy]), OperationLogsModule],
|
||||
controllers: [WalletsController],
|
||||
providers: [WalletsService],
|
||||
exports: [WalletsService],
|
||||
|
||||
@@ -8,6 +8,7 @@ import { WalletTransaction } from '../entities/wallet-transaction.entity';
|
||||
import { In } from 'typeorm';
|
||||
import { BatchChangeWalletBalanceDto, ChangeWalletBalanceDto } from './dto/wallet.dto';
|
||||
import { FinancialOperationsService } from '../financial-operations/financial-operations.service';
|
||||
import { Room } from '../entities/room.entity';
|
||||
|
||||
const money = (value: number | string | null | undefined) => Number(Number(value || 0).toFixed(2));
|
||||
|
||||
@@ -21,21 +22,41 @@ export class WalletsService {
|
||||
private financialOperations?: FinancialOperationsService,
|
||||
) {}
|
||||
|
||||
async findAll(query?: { keyword?: string; debtOnly?: boolean }) {
|
||||
const students = await this.studentRepo
|
||||
async findAll(query?: { keyword?: string; debtOnly?: boolean; roomType?: string }) {
|
||||
const qb = this.studentRepo
|
||||
.createQueryBuilder('student')
|
||||
.where('student.status = :status', { status: 'active' })
|
||||
.andWhere(
|
||||
query?.keyword
|
||||
? '(student.name LIKE :keyword OR student.studentNo LIKE :keyword)'
|
||||
: '1 = 1',
|
||||
query?.keyword ? { keyword: `%${query.keyword}%` } : {},
|
||||
)
|
||||
.orderBy('student.name', 'ASC')
|
||||
.getMany();
|
||||
if (!students.length) return [];
|
||||
.leftJoin('student.occupancies', 'occupancy', 'occupancy.checkOutDate IS NULL')
|
||||
.leftJoin('occupancy.room', 'room')
|
||||
.where('student.status = :status', { status: 'active' });
|
||||
|
||||
const ids = students.map((student) => student.id);
|
||||
if (query?.keyword) {
|
||||
qb.andWhere('(student.name LIKE :keyword OR student.studentNo LIKE :keyword)', {
|
||||
keyword: `%${query.keyword}%`,
|
||||
});
|
||||
}
|
||||
if (query?.roomType) {
|
||||
qb.andWhere('room.roomType = :roomType', { roomType: query.roomType });
|
||||
}
|
||||
|
||||
const rows = await qb
|
||||
.select([
|
||||
'student.id AS studentId',
|
||||
'student.name AS studentName',
|
||||
'student.studentNo AS studentNo',
|
||||
'room.roomType AS roomType',
|
||||
'room.roomNumber AS roomNumber',
|
||||
])
|
||||
.orderBy('student.name', 'ASC')
|
||||
.getRawMany<{
|
||||
studentId: number;
|
||||
studentName: string;
|
||||
studentNo: string | null;
|
||||
roomType: string | null;
|
||||
roomNumber: string | null;
|
||||
}>();
|
||||
if (!rows.length) return [];
|
||||
|
||||
const ids = rows.map((row) => Number(row.studentId));
|
||||
const wallets = await this.walletRepo.find({ where: { studentId: In(ids) } });
|
||||
const bills = await this.dataSource.getRepository(Bill)
|
||||
.createQueryBuilder('bill')
|
||||
@@ -47,17 +68,34 @@ export class WalletsService {
|
||||
.getRawMany<{ studentId: number; outstandingAmount: string }>();
|
||||
const walletMap = new Map(wallets.map((wallet) => [wallet.studentId, wallet]));
|
||||
const debtMap = new Map(bills.map((bill) => [Number(bill.studentId), money(bill.outstandingAmount)]));
|
||||
return students
|
||||
.map((student) => ({
|
||||
studentId: student.id,
|
||||
studentName: student.name,
|
||||
studentNo: student.studentNo,
|
||||
balance: money(walletMap.get(student.id)?.balance),
|
||||
outstandingAmount: debtMap.get(student.id) || 0,
|
||||
return rows
|
||||
.map((row) => ({
|
||||
studentId: Number(row.studentId),
|
||||
studentName: row.studentName,
|
||||
studentNo: row.studentNo || undefined,
|
||||
roomType: row.roomType || undefined,
|
||||
roomNumber: row.roomNumber || undefined,
|
||||
balance: money(walletMap.get(Number(row.studentId))?.balance),
|
||||
outstandingAmount: debtMap.get(Number(row.studentId)) || 0,
|
||||
}))
|
||||
.filter((row) => !query?.debtOnly || row.outstandingAmount > 0);
|
||||
}
|
||||
|
||||
async findRoomTypes() {
|
||||
const rows = await this.dataSource
|
||||
.getRepository(Room)
|
||||
.createQueryBuilder('room')
|
||||
.innerJoin('room.occupancies', 'occupancy', 'occupancy.checkOutDate IS NULL')
|
||||
.innerJoin('occupancy.student', 'student', 'student.status = :status', { status: 'active' })
|
||||
.select('room.roomType', 'roomType')
|
||||
.where('room.roomType IS NOT NULL')
|
||||
.andWhere("room.roomType <> ''")
|
||||
.distinct(true)
|
||||
.orderBy('room.roomType', 'ASC')
|
||||
.getRawMany<{ roomType: string }>();
|
||||
return rows.map((row) => row.roomType);
|
||||
}
|
||||
|
||||
async findTransactions(studentId: number) {
|
||||
return this.transactionRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user