feat: refine admin forms, attendance and finance workflows

Squash merge PR #23.

Included changes:
- complete occupancy check-in required fields/default payload
- improve responsive admin management pages
- fix attendance edge cases and attendance period config
- refine wallet/finance-related workflow handling

Checks:
- npm run typecheck -w apps/admin
- npm run typecheck -w apps/server
This commit is contained in:
2026-07-18 12:54:10 +00:00
parent 92d303ed01
commit 375c7ec60b
64 changed files with 5169 additions and 2404 deletions

View File

@@ -68,7 +68,6 @@ describe('lesson check-in summary', () => {
});
});
describe('lesson attendance filters', () => {
const records = [
{ id: 1, student: { name: '张三' }, status: 'present' },
@@ -78,15 +77,21 @@ describe('lesson attendance filters', () => {
];
it('searches students by name and ignores surrounding whitespace', () => {
expect(filterLessonAttendanceRecords(records, ' 张 ', 'all').map((item) => item.id)).toEqual([1]);
expect(filterLessonAttendanceRecords(records, ' 张 ', 'all').map((item) => item.id)).toEqual([
1,
]);
});
it('groups present and late as checked in', () => {
expect(filterLessonAttendanceRecords(records, '', 'checked_in').map((item) => item.id)).toEqual([1, 2]);
expect(filterLessonAttendanceRecords(records, '', 'checked_in').map((item) => item.id)).toEqual(
[1, 2],
);
});
it('groups pending and absent as not checked in and combines with search', () => {
expect(filterLessonAttendanceRecords(records, '王', 'not_checked_in').map((item) => item.id)).toEqual([3]);
expect(
filterLessonAttendanceRecords(records, '王', 'not_checked_in').map((item) => item.id),
).toEqual([3]);
});
});

View File

@@ -8,11 +8,7 @@ export function getAttendanceExperience(
roles: readonly string[],
): AttendanceExperience {
const domains = getRoleDomains(roles, permissions);
if (
permissions.includes('attendance:edit') ||
domains.has('academic') ||
domains.has('super')
) {
if (permissions.includes('attendance:edit') || domains.has('academic') || domains.has('super')) {
return 'admin';
}
return 'teacher';
@@ -83,7 +79,6 @@ export function summarizeLessonCheckins(
};
}
export type LessonAttendanceFilter = 'all' | 'checked_in' | 'not_checked_in';
export interface LessonAttendanceFilterRecord {

View File

@@ -225,7 +225,10 @@
border-left: 4px solid #c9d2df;
border-radius: 14px;
background: white;
transition: transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease;
transition:
transform 180ms ease,
box-shadow 180ms ease,
border-color 180ms ease;
}
.lesson-card:hover {
@@ -366,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;
@@ -376,11 +409,26 @@
font-weight: 700;
}
.is-present { color: #198754 !important; background: #eaf8f1; }
.is-late { color: #b56b00 !important; background: #fff4db; }
.is-absent { color: #cf3030 !important; background: #fff0f0; }
.is-leave { color: #2874c6 !important; background: #edf5ff; }
.is-pending { color: #667085 !important; background: #f1f3f6; }
.is-present {
color: #198754 !important;
background: #eaf8f1;
}
.is-late {
color: #b56b00 !important;
background: #fff4db;
}
.is-absent {
color: #cf3030 !important;
background: #fff0f0;
}
.is-leave {
color: #2874c6 !important;
background: #edf5ff;
}
.is-pending {
color: #667085 !important;
background: #f1f3f6;
}
.lesson-record-filters {
display: flex;
@@ -659,7 +707,6 @@
min-width: 54px;
}
.punch-device-cell {
display: flex;
flex-direction: column;
@@ -692,9 +739,11 @@
--student-line: #e1e8e5;
--student-primary: #157a65;
--student-primary-soft: #e8f4f0;
min-width: 0;
min-height: calc(100vh - 64px);
margin: -24px;
padding: 0 24px 28px;
overflow: hidden;
background: var(--student-bg);
color: var(--student-ink);
}
@@ -755,9 +804,11 @@
.student-filter-panel {
display: grid;
grid-template-columns: minmax(260px, 1.4fr) repeat(4, minmax(150px, 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;
border: 1px solid var(--student-line);
@@ -783,13 +834,56 @@
.student-filter-actions {
display: flex;
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(320px, .9fr) minmax(0, 1.1fr);
grid-template-columns: minmax(360px, 0.95fr) minmax(360px, 1.05fr);
gap: 16px;
min-width: 0;
margin-bottom: 16px;
}
@@ -797,32 +891,79 @@
.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 > *,
.student-workspace,
.student-record-card {
min-width: 0;
}
.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;
}
@@ -830,77 +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(82px, 1fr));
overflow: hidden;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
min-width: 0;
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 {
@@ -930,18 +1112,27 @@
.student-workspace-tools {
display: flex;
align-items: center;
flex: 0 0 auto;
flex-wrap: nowrap;
gap: 8px;
min-width: 0;
white-space: nowrap;
}
.student-workspace-tools .ant-input-search {
flex: 0 0 240px;
width: 240px;
}
.student-workspace-tools .ant-btn {
flex: 0 0 auto;
}
.student-legend {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
gap: 8px 12px;
padding: 12px 0;
color: var(--student-muted);
font-size: 12px;
@@ -960,11 +1151,21 @@
border-radius: 2px;
}
.student-legend i.is-present { background: #2f9c78; }
.student-legend i.is-late { background: #d78a18; }
.student-legend i.is-leave { background: #397bbf; }
.student-legend i.is-absent { background: #d44d4d; }
.student-legend i.is-pending { background: #8a9692; }
.student-legend i.is-present {
background: #2f9c78;
}
.student-legend i.is-late {
background: #d78a18;
}
.student-legend i.is-leave {
background: #397bbf;
}
.student-legend i.is-absent {
background: #d44d4d;
}
.student-legend i.is-pending {
background: #8a9692;
}
.student-legend em {
color: var(--student-quiet);
@@ -973,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;
}
@@ -989,7 +1190,10 @@
color: inherit;
text-align: left;
cursor: pointer;
transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
transition:
border-color 160ms ease,
box-shadow 160ms ease,
transform 160ms ease;
}
.student-attendance-card:hover,
@@ -1011,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 {
@@ -1139,43 +1348,144 @@
background: linear-gradient(180deg, #56bea3, #157a65);
}
@media (max-width: 1180px) {
.student-filter-panel,
.student-class-overview {
grid-template-columns: 1fr 1fr;
@media (max-width: 1280px) {
.student-filter-panel {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.student-filter-field--date {
grid-column: auto;
}
.student-filter-actions {
grid-column: 1 / -1;
justify-content: flex-end;
}
}
@media (max-width: 1180px) {
.student-class-overview {
grid-template-columns: 1fr;
}
.student-metric-strip {
grid-template-columns: repeat(3, 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: -16px;
padding: 0 16px 22px;
margin: -12px;
padding: 0 12px 20px;
}
.student-center-topbar {
margin-inline: -12px;
padding: 12px;
}
.student-center-topbar,
.student-workspace-header,
.student-center-actions,
.student-legend {
align-items: flex-start;
align-items: stretch;
flex-direction: column;
}
.student-center-title {
align-items: flex-start;
flex-direction: column;
gap: 2px;
}
.student-filter-panel,
.attendance-period-row {
grid-template-columns: 1fr;
}
.student-class-overview {
grid-template-columns: 1fr;
}
.student-workspace-tools,
.student-workspace-tools .ant-input-search {
.student-filter-field--date {
grid-column: auto;
}
.student-filter-actions > *,
.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;
}
.student-metric-strip {
grid-template-columns: repeat(2, 1fr);
}
.student-metric-card {
min-height: 72px;
}
.student-workspace {
padding-inline: 12px;
}
.student-card-grid {
grid-template-columns: 1fr;
}
.student-teacher-list {
grid-template-columns: 1fr;
}
.student-detail-profile {
grid-template-columns: auto minmax(0, 1fr);
}
.student-detail-rate {
grid-column: 1 / -1;
text-align: left;
}
.student-detail-timeline > div {
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