mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-06 11:44:40 +00:00
feat: assessments kanban overview
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
interface Subject { code: string; programme: number; metaclass: number; title: string; }
|
||||
interface PrefItem { name: string; value: string; }
|
||||
|
||||
let cache: { time: number; data: any } | null = null;
|
||||
const CACHE_MS = 10 * 60 * 1000;
|
||||
const student = 69;
|
||||
|
||||
async function fetchJSON(url: string, body: any) {
|
||||
const res = await fetch(`${location.origin}${url}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function loadSubjects() {
|
||||
const res = await fetchJSON('/seqta/student/load/subjects?', {});
|
||||
return res.payload.filter((s: any) => s.active === 1).flatMap((s: any) => s.subjects);
|
||||
}
|
||||
|
||||
async function loadPrefs(student: number) {
|
||||
const res = await fetchJSON('/seqta/student/load/prefs?', {
|
||||
request: 'userPrefs',
|
||||
asArray: true,
|
||||
user: student,
|
||||
});
|
||||
const colors: Record<string, string> = {};
|
||||
res.payload.forEach((p: PrefItem) => {
|
||||
if (p.name.startsWith('timetable.subject.colour.')) {
|
||||
const code = p.name.replace('timetable.subject.colour.', '');
|
||||
colors[code] = p.value;
|
||||
}
|
||||
});
|
||||
return colors;
|
||||
}
|
||||
|
||||
async function loadUpcoming(student: number) {
|
||||
const res = await fetchJSON('/seqta/student/assessment/list/upcoming?', { student });
|
||||
return res.payload;
|
||||
}
|
||||
|
||||
async function loadPast(student: number, subjects: Subject[]) {
|
||||
const map: Record<number, any> = {};
|
||||
await Promise.all(subjects.map(async (s) => {
|
||||
const res = await fetchJSON('/seqta/student/assessment/list/past?', {
|
||||
programme: s.programme,
|
||||
metaclass: s.metaclass,
|
||||
student,
|
||||
});
|
||||
if (res.payload.tasks) {
|
||||
res.payload.tasks.forEach((t: any) => { map[t.id] = t; });
|
||||
}
|
||||
}));
|
||||
return map;
|
||||
}
|
||||
|
||||
async function loadSubmissions(student: number, assessments: any[]) {
|
||||
const submissionMap: Record<number, boolean> = {};
|
||||
|
||||
// Fetch submission status for each assessment
|
||||
await Promise.all(assessments.map(async (assessment) => {
|
||||
try {
|
||||
const res = await fetchJSON('/seqta/student/assessment/submissions/get', {
|
||||
assessment: assessment.id,
|
||||
metaclass: assessment.metaclassID,
|
||||
student,
|
||||
});
|
||||
|
||||
// If there are any submissions, mark as submitted
|
||||
submissionMap[assessment.id] = res.payload && res.payload.length > 0;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to fetch submission for assessment ${assessment.id}:`, error);
|
||||
submissionMap[assessment.id] = false;
|
||||
}
|
||||
}));
|
||||
|
||||
return submissionMap;
|
||||
}
|
||||
|
||||
export async function getAssessmentsData() {
|
||||
if (cache && Date.now() - cache.time < CACHE_MS) return cache.data;
|
||||
const [subjects, colors, upcoming] = await Promise.all([
|
||||
loadSubjects(),
|
||||
loadPrefs(student),
|
||||
loadUpcoming(student),
|
||||
]);
|
||||
const pastMap = await loadPast(student, subjects);
|
||||
const map: Record<number, any> = {};
|
||||
upcoming.forEach((a: any) => { map[a.id] = { ...a }; });
|
||||
Object.values(pastMap).forEach((t: any) => {
|
||||
if (map[t.id]) Object.assign(map[t.id], t);
|
||||
else map[t.id] = t;
|
||||
});
|
||||
|
||||
// Load submission data for all assessments
|
||||
const allAssessments = Object.values(map);
|
||||
const submissions = await loadSubmissions(student, allAssessments);
|
||||
|
||||
// Add submission status to each assessment
|
||||
allAssessments.forEach((assessment: any) => {
|
||||
assessment.submitted = submissions[assessment.id] || false;
|
||||
});
|
||||
|
||||
const data = { assessments: allAssessments, subjects, colors };
|
||||
cache = { time: Date.now(), data };
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Plugin } from '../../core/types';
|
||||
import { waitForElm } from '@/seqta/utils/waitForElm';
|
||||
import { getAssessmentsData } from './api';
|
||||
import { renderSkeletonLoader, renderErrorState } from './ui';
|
||||
import styles from './styles.css?inline';
|
||||
import { delay } from '@/seqta/utils/delay';
|
||||
|
||||
const gridViewPlugin: Plugin<{}> = {
|
||||
id: 'assessments-grid-view',
|
||||
name: 'Assessments Overview',
|
||||
description: 'Adds an overview option to the assessments page that organizes assessments by status',
|
||||
version: '1.0.0',
|
||||
settings: {},
|
||||
disableToggle: false,
|
||||
styles,
|
||||
|
||||
run: async () => {
|
||||
const menu = (await waitForElm('[data-key="assessments"] > .sub > ul', true, 100, 60)) as HTMLElement;
|
||||
const gridItem = document.createElement('li');
|
||||
gridItem.className = 'item';
|
||||
const label = document.createElement('label');
|
||||
label.textContent = 'Overview';
|
||||
gridItem.appendChild(label);
|
||||
menu.insertBefore(gridItem, menu.children[1] || null);
|
||||
|
||||
if (window.location.hash.includes('/assessments/overview')) {
|
||||
loadGridView();
|
||||
}
|
||||
|
||||
const clickHandler = (e: Event) => {
|
||||
e.preventDefault();
|
||||
loadGridView();
|
||||
};
|
||||
gridItem.addEventListener('click', clickHandler);
|
||||
|
||||
async function loadGridView() {
|
||||
await delay(1);
|
||||
window.history.pushState({}, '', '/#?page=/assessments/overview');
|
||||
const main = document.getElementById('main');
|
||||
if (!main) return;
|
||||
|
||||
// Update navigation state
|
||||
document.querySelectorAll('[data-key="assessments"] .item').forEach(item => {
|
||||
item.classList.remove('active');
|
||||
});
|
||||
gridItem.classList.add('active');
|
||||
document.querySelector('[data-key="assessments"]')?.classList.add('active');
|
||||
|
||||
// Clear main content and add container
|
||||
main.innerHTML = '<div id="grid-view-container"></div>';
|
||||
const container = document.getElementById('grid-view-container') as HTMLElement;
|
||||
|
||||
renderSkeletonLoader(container);
|
||||
|
||||
try {
|
||||
const data = await getAssessmentsData();
|
||||
const { renderGrid } = await import('./ui');
|
||||
renderGrid(container, data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load assessments:', err);
|
||||
renderErrorState(container, err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
gridItem.removeEventListener('click', clickHandler);
|
||||
gridItem.remove();
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default gridViewPlugin;
|
||||
@@ -0,0 +1,681 @@
|
||||
#grid-view-container {
|
||||
background: transparent;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.grid-view-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.grid-view-title {
|
||||
font-size: 1.875rem !important;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
.dark .grid-view-title {
|
||||
color: #f8fafc;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.grid-view-filters {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
background: #ffffff;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
color: #1a1a1a;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.filter-select:focus {
|
||||
outline: none;
|
||||
border-color: #d41e3a;
|
||||
box-shadow: 0 0 0 3px rgba(212, 30, 58, 0.1);
|
||||
}
|
||||
|
||||
.filter-select:hover {
|
||||
border-color: #cbd5e1;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Dark mode dropdowns */
|
||||
.dark .filter-select {
|
||||
background: var(--background-primary);
|
||||
border-color: var(--background-secondary);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.dark .filter-select:focus {
|
||||
border-color: #d41e3a;
|
||||
box-shadow: 0 0 0 3px rgba(212, 30, 58, 0.2);
|
||||
}
|
||||
|
||||
.dark .filter-select:hover {
|
||||
border-color: var(--background-secondary);
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
|
||||
.dark .filter-select option {
|
||||
background: var(--background-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
#main-grid-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Kanban Board Layout */
|
||||
.kanban-board {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
overflow-x: auto;
|
||||
padding: 1rem;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.kanban-column-parent {
|
||||
flex: 0 0 320px;
|
||||
|
||||
}
|
||||
|
||||
.kanban-column {
|
||||
max-height: 100%;
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 0 0 2px #e2e8f0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Dark mode columns */
|
||||
.dark .kanban-column {
|
||||
background: var(--background-primary);
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.column-header {
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
background: #ffffff;
|
||||
border-radius: 12px 12px 0 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Dark mode column headers */
|
||||
.dark .column-header {
|
||||
background: var(--background-secondary);
|
||||
border-bottom-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.column-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.dark .column-title {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.column-count {
|
||||
background: #e2e8f0;
|
||||
color: #64748b;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dark .column-count {
|
||||
background: var(--background-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.column-cards {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Assessment Cards */
|
||||
.assessment-card {
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
border-left: 4px solid var(--subject-color, #d41e3a);
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.assessment-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
transform: translateY(-1px);
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
/* Dark mode cards */
|
||||
.dark .assessment-card {
|
||||
background: var(--background-secondary);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.dark .assessment-card:hover {
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.card-labels {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card-label {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.label-subject {
|
||||
background: var(--subject-color, #d41e3a);
|
||||
}
|
||||
|
||||
.assessment-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin: 0 0 0.75rem 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.dark .assessment-title {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.assessment-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.dark .assessment-meta {
|
||||
color: var(--text-primary);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.due-date {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.due-date.overdue {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.due-date.due-soon {
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.due-date.upcoming {
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.dark .card-footer {
|
||||
border-top-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.grade-display {
|
||||
font-weight: 700;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.grade-good {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: #059669;
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.grade-average {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: #d97706;
|
||||
border: 1px solid rgba(245, 158, 11, 0.2);
|
||||
}
|
||||
|
||||
.grade-bad {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #dc2626;
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.grade-empty {
|
||||
color: #64748b;
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
background: #f1f5f9;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.dark .grade-empty {
|
||||
color: var(--text-primary);
|
||||
opacity: 0.7;
|
||||
background: var(--background-secondary);
|
||||
border-color: var(--background-secondary);
|
||||
}
|
||||
|
||||
/* Column-specific styling */
|
||||
.column-upcoming .column-header {
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f0f9ff 100%);
|
||||
border-left: 4px solid #3b82f6;
|
||||
}
|
||||
|
||||
.column-due-soon .column-header {
|
||||
background: linear-gradient(135deg, #ffffff 0%, #fffbeb 100%);
|
||||
border-left: 4px solid #f59e0b;
|
||||
}
|
||||
|
||||
.column-overdue .column-header {
|
||||
background: linear-gradient(135deg, #ffffff 0%, #fef2f2 100%);
|
||||
border-left: 4px solid #ef4444;
|
||||
}
|
||||
|
||||
.column-marked .column-header {
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f0fdf4 100%);
|
||||
border-left: 4px solid #10b981;
|
||||
}
|
||||
|
||||
/* Dark mode column headers */
|
||||
.dark .column-upcoming .column-header {
|
||||
background: linear-gradient(135deg, var(--background-secondary) 0%, #1e3a8a 100%);
|
||||
}
|
||||
|
||||
.dark .column-due-soon .column-header {
|
||||
background: linear-gradient(135deg, var(--background-secondary) 0%, #92400e 100%);
|
||||
}
|
||||
|
||||
.dark .column-overdue .column-header {
|
||||
background: linear-gradient(135deg, var(--background-secondary) 0%, #991b1b 100%);
|
||||
}
|
||||
|
||||
.dark .column-marked .column-header {
|
||||
background: linear-gradient(135deg, var(--background-secondary) 0%, #065f46 100%);
|
||||
}
|
||||
|
||||
/* Subject filter view */
|
||||
.subject-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.subject-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem;
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-left: 4px solid var(--subject-color, #d41e3a);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.dark .subject-header {
|
||||
background: var(--background-secondary);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.subject-indicator {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.subject-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dark .subject-title {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.subject-code {
|
||||
font-size: 0.875rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.dark .subject-code {
|
||||
color: var(--text-primary);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Loading and error states */
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 4rem 0;
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
border: 2px solid #e2e8f0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.dark .loading-container {
|
||||
background: var(--background-primary);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border: 3px solid #e2e8f0;
|
||||
border-top: 3px solid #d41e3a;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.dark .loading-spinner {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
border-top-color: #d41e3a;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin-top: 1rem;
|
||||
color: #64748b;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dark .loading-text {
|
||||
color: var(--text-primary);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 4rem 0;
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
border: 2px solid #fecaca;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.dark .error-container {
|
||||
background: var(--background-primary);
|
||||
border-color: #991b1b;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #ef4444;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 2rem;
|
||||
color: #64748b;
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dark .empty-state {
|
||||
color: var(--text-primary);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.empty-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem 1rem;
|
||||
color: #64748b;
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.dark .empty-column {
|
||||
color: var(--text-primary);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -1000px 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 1000px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton-element {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#f1f5f9 0%,
|
||||
#e2e8f0 50%,
|
||||
#f1f5f9 100%
|
||||
);
|
||||
background-size: 1000px 100%;
|
||||
animation: shimmer 2s infinite linear;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.dark .skeleton-element {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--background-primary) 0%,
|
||||
var(--background-secondary) 50%,
|
||||
var(--background-primary) 100%
|
||||
);
|
||||
background-size: 1000px 100%;
|
||||
}
|
||||
|
||||
.skeleton-label {
|
||||
height: 20px;
|
||||
width: 60px;
|
||||
margin-bottom: 0.75rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.skeleton-title {
|
||||
height: 16px;
|
||||
width: 80%;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.skeleton-title-line2 {
|
||||
height: 16px;
|
||||
width: 60%;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.skeleton-meta {
|
||||
height: 12px;
|
||||
width: 40%;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.skeleton-footer {
|
||||
height: 8px;
|
||||
width: 100%;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.dark .skeleton-footer {
|
||||
border-top-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
#grid-view-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.grid-view-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.grid-view-filters {
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.kanban-board {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.kanban-column {
|
||||
flex: none;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
min-width: 140px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.grid-view-title {
|
||||
font-size: 1.5rem !important;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.assessment-card {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollbar styling for webkit browsers */
|
||||
.column-cards::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.column-cards::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.column-cards::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.column-cards::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
/* Dark mode scrollbars */
|
||||
.dark .column-cards::-webkit-scrollbar-track {
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
|
||||
.dark .column-cards::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.dark .column-cards::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
import { determineStatus, formatDate, getGradeValue } from './utils';
|
||||
|
||||
interface FilterOptions {
|
||||
subject: string;
|
||||
sortBy: 'due' | 'grade' | 'subject' | 'title';
|
||||
}
|
||||
|
||||
let currentFilters: FilterOptions = {
|
||||
subject: 'all',
|
||||
sortBy: 'due'
|
||||
};
|
||||
|
||||
export function renderGrid(container: HTMLElement, data: any) {
|
||||
container.innerHTML = '';
|
||||
container.className = '';
|
||||
container.id = 'grid-view-container';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'grid-view-header';
|
||||
header.innerHTML = `
|
||||
<h1 class="grid-view-title">Assessments</h1>
|
||||
<div class="grid-view-filters">
|
||||
<select class="filter-select" id="subject-filter">
|
||||
<option value="all">All Subjects</option>
|
||||
${data.subjects.map((s: any) => `<option value="${s.code}">${s.code} - ${s.title}</option>`).join('')}
|
||||
</select>
|
||||
<select class="filter-select" id="sort-filter">
|
||||
<option value="due">Sort by Due Date</option>
|
||||
<option value="grade">Sort by Grade</option>
|
||||
<option value="subject">Sort by Subject</option>
|
||||
<option value="title">Sort by Title</option>
|
||||
</select>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.appendChild(header);
|
||||
|
||||
const subjectFilter = header.querySelector('#subject-filter') as HTMLSelectElement;
|
||||
const sortFilter = header.querySelector('#sort-filter') as HTMLSelectElement;
|
||||
|
||||
subjectFilter.addEventListener('change', () => {
|
||||
currentFilters.subject = subjectFilter.value;
|
||||
renderAssessments();
|
||||
});
|
||||
|
||||
sortFilter.addEventListener('change', () => {
|
||||
currentFilters.sortBy = sortFilter.value as any;
|
||||
renderAssessments();
|
||||
});
|
||||
|
||||
const mainContent = document.createElement('div');
|
||||
mainContent.id = 'main-grid-content';
|
||||
container.appendChild(mainContent);
|
||||
|
||||
function renderAssessments() {
|
||||
const contentArea = container.querySelector('#main-grid-content') as HTMLElement;
|
||||
contentArea.innerHTML = '';
|
||||
|
||||
// Filter assessments by subject
|
||||
let filteredAssessments = data.assessments.filter((a: any) => {
|
||||
const subjectMatch = currentFilters.subject === 'all' || a.code === currentFilters.subject;
|
||||
return subjectMatch;
|
||||
});
|
||||
|
||||
// Sort assessments
|
||||
filteredAssessments.sort((a: any, b: any) => {
|
||||
switch (currentFilters.sortBy) {
|
||||
case 'due':
|
||||
return new Date(a.due).getTime() - new Date(b.due).getTime();
|
||||
case 'grade':
|
||||
const gradeA = getGradeValue(a);
|
||||
const gradeB = getGradeValue(b);
|
||||
if (gradeA === null && gradeB === null) return 0;
|
||||
if (gradeA === null) return 1;
|
||||
if (gradeB === null) return -1;
|
||||
return gradeB - gradeA;
|
||||
case 'subject':
|
||||
return a.code.localeCompare(b.code);
|
||||
case 'title':
|
||||
return a.title.localeCompare(b.title);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
if (filteredAssessments.length === 0) {
|
||||
contentArea.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">📋</div>
|
||||
<p>No assessments found matching your filters</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
renderKanbanBoard(contentArea, filteredAssessments, data);
|
||||
}
|
||||
|
||||
function renderKanbanBoard(container: HTMLElement, assessments: any[], data: any) {
|
||||
// Group assessments by status
|
||||
const statusGroups = {
|
||||
'UPCOMING': [] as any[],
|
||||
'DUE_SOON': [] as any[],
|
||||
'OVERDUE': [] as any[],
|
||||
'MARKS_RELEASED': [] as any[]
|
||||
};
|
||||
|
||||
assessments.forEach(assessment => {
|
||||
const status = determineStatus(assessment);
|
||||
if (statusGroups[status as keyof typeof statusGroups]) {
|
||||
statusGroups[status as keyof typeof statusGroups].push(assessment);
|
||||
}
|
||||
});
|
||||
|
||||
const board = document.createElement('div');
|
||||
board.className = 'kanban-board';
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'UPCOMING',
|
||||
title: 'Upcoming',
|
||||
className: 'column-upcoming',
|
||||
icon: '📅'
|
||||
},
|
||||
{
|
||||
key: 'DUE_SOON',
|
||||
title: 'Due Soon',
|
||||
className: 'column-due-soon',
|
||||
icon: '⏰'
|
||||
},
|
||||
{
|
||||
key: 'OVERDUE',
|
||||
title: 'Overdue',
|
||||
className: 'column-overdue',
|
||||
icon: '🚨'
|
||||
},
|
||||
{
|
||||
key: 'MARKS_RELEASED',
|
||||
title: 'Marked',
|
||||
className: 'column-marked',
|
||||
icon: '✅'
|
||||
}
|
||||
];
|
||||
|
||||
columns.forEach(column => {
|
||||
const columnParentEl = document.createElement('div');
|
||||
columnParentEl.className = 'kanban-column-parent';
|
||||
|
||||
const columnEl = document.createElement('div');
|
||||
columnEl.className = `kanban-column ${column.className}`;
|
||||
|
||||
const assessmentList = statusGroups[column.key as keyof typeof statusGroups];
|
||||
|
||||
columnEl.innerHTML = /* html */`
|
||||
<div class="column-header">
|
||||
<div class="column-title">
|
||||
${column.icon} ${column.title}
|
||||
<span class="column-count">${assessmentList.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column-cards" id="${column.key.toLowerCase()}-cards"></div>
|
||||
`;
|
||||
|
||||
const cardsContainer = columnEl.querySelector(`#${column.key.toLowerCase()}-cards`) as HTMLElement;
|
||||
|
||||
if (assessmentList.length === 0) {
|
||||
cardsContainer.innerHTML = /* html */`
|
||||
<div class="empty-column">
|
||||
<div class="empty-icon">${column.icon}</div>
|
||||
<p>No ${column.title.toLowerCase()} assessments</p>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
assessmentList.forEach(assessment => {
|
||||
cardsContainer.appendChild(createKanbanCard(assessment, data.colors[assessment.code] || '#6366f1'));
|
||||
});
|
||||
}
|
||||
|
||||
columnParentEl.appendChild(columnEl);
|
||||
board.appendChild(columnParentEl);
|
||||
});
|
||||
|
||||
container.appendChild(board);
|
||||
}
|
||||
|
||||
function createKanbanCard(assessment: any, color: string): HTMLElement {
|
||||
const status = determineStatus(assessment);
|
||||
const dueDateClass = getDueDateClass(assessment);
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'assessment-card';
|
||||
card.dataset.subject = assessment.code;
|
||||
card.dataset.status = status;
|
||||
card.style.setProperty('--subject-color', color);
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="card-labels">
|
||||
<span class="card-label label-subject">${assessment.code}</span>
|
||||
${assessment.submitted ? '<span class="card-label label-submitted" style="background: #10b981; color: white;">Submitted</span>' : ''}
|
||||
</div>
|
||||
<h3 class="assessment-title">${assessment.title}</h3>
|
||||
<div class="assessment-meta">
|
||||
<div class="due-date ${dueDateClass}">
|
||||
📅 ${formatDate(assessment.due)}
|
||||
</div>
|
||||
</div>
|
||||
${assessment.results
|
||||
? /* html */`
|
||||
<div class="card-footer">
|
||||
<div class="Thermoscore__Thermoscore___WFpL3" style="--fill-colour: ${color}">
|
||||
<div style="width: ${assessment.results.percentage}%" class="Thermoscore__fill___ojxDI">
|
||||
<div title="${assessment.results.percentage}%" class="Thermoscore__text___XSR_M">${assessment.results.percentage}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
` : ''
|
||||
}
|
||||
`;
|
||||
|
||||
card.addEventListener('click', () => {
|
||||
window.location.hash = `#?page=/assessments/${assessment.programmeID}:${assessment.metaclassID}&item=${assessment.id}`;
|
||||
});
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
|
||||
function getDueDateClass(assessment: any): string {
|
||||
const status = determineStatus(assessment);
|
||||
switch (status) {
|
||||
case 'OVERDUE':
|
||||
return 'overdue';
|
||||
case 'DUE_SOON':
|
||||
return 'due-soon';
|
||||
case 'UPCOMING':
|
||||
return 'upcoming';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Initial render
|
||||
renderAssessments();
|
||||
}
|
||||
|
||||
export function renderSkeletonLoader(container: HTMLElement) {
|
||||
container.innerHTML = '';
|
||||
container.className = '';
|
||||
container.id = 'grid-view-container';
|
||||
|
||||
// Create header with disabled filters
|
||||
const header = document.createElement('div');
|
||||
header.className = 'grid-view-header';
|
||||
header.innerHTML = `
|
||||
<h1 class="grid-view-title">Assessments</h1>
|
||||
<div class="grid-view-filters">
|
||||
<select class="filter-select" id="subject-filter" disabled>
|
||||
<option value="all">Loading subjects...</option>
|
||||
</select>
|
||||
<select class="filter-select" id="sort-filter" disabled>
|
||||
<option value="due">Sort by Due Date</option>
|
||||
</select>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.appendChild(header);
|
||||
|
||||
const mainContent = document.createElement('div');
|
||||
mainContent.id = 'main-grid-content';
|
||||
container.appendChild(mainContent);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'UPCOMING',
|
||||
title: 'Upcoming',
|
||||
className: 'column-upcoming',
|
||||
icon: '📅',
|
||||
skeletonCount: 3
|
||||
},
|
||||
{
|
||||
key: 'DUE_SOON',
|
||||
title: 'Due Soon',
|
||||
className: 'column-due-soon',
|
||||
icon: '⏰',
|
||||
skeletonCount: 2
|
||||
},
|
||||
{
|
||||
key: 'OVERDUE',
|
||||
title: 'Overdue',
|
||||
className: 'column-overdue',
|
||||
icon: '🚨',
|
||||
skeletonCount: 1
|
||||
},
|
||||
{
|
||||
key: 'MARKS_RELEASED',
|
||||
title: 'Marked',
|
||||
className: 'column-marked',
|
||||
icon: '✅',
|
||||
skeletonCount: 4
|
||||
}
|
||||
];
|
||||
|
||||
const board = document.createElement('div');
|
||||
board.className = 'kanban-board';
|
||||
|
||||
columns.forEach(column => {
|
||||
const columnParentEl = document.createElement('div');
|
||||
columnParentEl.className = 'kanban-column-parent';
|
||||
|
||||
const columnEl = document.createElement('div');
|
||||
columnEl.className = `kanban-column ${column.className}`;
|
||||
|
||||
columnEl.innerHTML = /* html */`
|
||||
<div class="column-header">
|
||||
<div class="column-title">
|
||||
${column.icon} ${column.title}
|
||||
<span class="column-count">...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column-cards" id="${column.key.toLowerCase()}-cards"></div>
|
||||
`;
|
||||
|
||||
const cardsContainer = columnEl.querySelector(`#${column.key.toLowerCase()}-cards`) as HTMLElement;
|
||||
|
||||
for (let i = 0; i < column.skeletonCount; i++) {
|
||||
cardsContainer.appendChild(createSkeletonCard(column.key === 'MARKS_RELEASED'));
|
||||
}
|
||||
|
||||
columnParentEl.appendChild(columnEl);
|
||||
board.appendChild(columnParentEl);
|
||||
});
|
||||
|
||||
mainContent.appendChild(board);
|
||||
}
|
||||
|
||||
function createSkeletonCard(footer: boolean = false): HTMLElement {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'assessment-card';
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="skeleton-element skeleton-label"></div>
|
||||
<div class="skeleton-element skeleton-title"></div>
|
||||
<div class="skeleton-element skeleton-title-line2"></div>
|
||||
<div class="skeleton-element skeleton-meta"></div>
|
||||
${footer ? /* html */`
|
||||
<div class="skeleton-footer">
|
||||
<div class="skeleton-element" style="height: 16px; width: 100%;"></div>
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
export function renderLoadingState(container: HTMLElement) {
|
||||
renderSkeletonLoader(container);
|
||||
}
|
||||
|
||||
export function renderErrorState(container: HTMLElement, error: string) {
|
||||
container.innerHTML = /* html */`
|
||||
<div class="error-container">
|
||||
<p class="error-text">Failed to load assessments</p>
|
||||
<p style="color: #94a3b8; font-size: 0.875rem;">${error}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
export function formatDate(dateStr: string): string {
|
||||
const d = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffTime = d.getTime() - now.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
// If it's overdue
|
||||
if (diffDays < 0) {
|
||||
const overdueDays = Math.abs(diffDays);
|
||||
if (overdueDays === 1) return '1 day overdue';
|
||||
return `${overdueDays} days overdue`;
|
||||
}
|
||||
|
||||
// If it's today
|
||||
if (diffDays === 0) return 'Today';
|
||||
|
||||
// If it's tomorrow
|
||||
if (diffDays === 1) return 'Tomorrow';
|
||||
|
||||
// If it's within a week
|
||||
if (diffDays <= 7) {
|
||||
return d.toLocaleDateString(undefined, { weekday: 'long' });
|
||||
}
|
||||
|
||||
// Otherwise show full date
|
||||
return d.toLocaleDateString(undefined, {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: d.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
|
||||
});
|
||||
}
|
||||
|
||||
export function determineStatus(item: any): string {
|
||||
// Check if marks are released or if there's a grade
|
||||
if (item.status === 'MARKS_RELEASED' || item.grade ||
|
||||
(item.percentage !== undefined && item.percentage !== null) ||
|
||||
(item.achieved !== undefined && item.achieved !== null)) {
|
||||
return 'MARKS_RELEASED';
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const due = new Date(item.due);
|
||||
|
||||
// Check if overdue, but only if not submitted
|
||||
if (due.getTime() < now.getTime()) {
|
||||
// If it's submitted, treat it as marks pending (upcoming) instead of overdue
|
||||
if (item.submitted) {
|
||||
return 'UPCOMING';
|
||||
}
|
||||
return 'OVERDUE';
|
||||
}
|
||||
|
||||
// Check if due soon (within 7 days)
|
||||
const diffTime = due.getTime() - now.getTime();
|
||||
const diffDays = diffTime / (1000 * 60 * 60 * 24);
|
||||
|
||||
if (diffDays <= 7) {
|
||||
return 'DUE_SOON';
|
||||
}
|
||||
|
||||
return 'UPCOMING';
|
||||
}
|
||||
|
||||
export function getGradeValue(assessment: any): number | null {
|
||||
// Check results.percentage first (most common for graded assessments)
|
||||
if (assessment.results?.percentage !== undefined && assessment.results.percentage !== null) {
|
||||
return assessment.results.percentage;
|
||||
}
|
||||
|
||||
// Check direct percentage property
|
||||
if (assessment.percentage !== undefined && assessment.percentage !== null) {
|
||||
return assessment.percentage;
|
||||
}
|
||||
|
||||
// Check achieved/outOf combination
|
||||
if (assessment.achieved !== undefined && assessment.outOf !== undefined &&
|
||||
assessment.achieved !== null && assessment.outOf !== null && assessment.outOf > 0) {
|
||||
return (assessment.achieved / assessment.outOf) * 100;
|
||||
}
|
||||
|
||||
// Check results achieved/outOf combination
|
||||
if (assessment.results?.achieved !== undefined && assessment.results?.outOf !== undefined &&
|
||||
assessment.results.achieved !== null && assessment.results.outOf !== null && assessment.results.outOf > 0) {
|
||||
return (assessment.results.achieved / assessment.results.outOf) * 100;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user