Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2100afc063 |
@ -1,4 +1,3 @@
|
||||
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const app = express();
|
||||
@ -37,10 +36,14 @@ const categoriesRoutes = require('./routes/categories');
|
||||
|
||||
const activitiesRoutes = require('./routes/activities');
|
||||
|
||||
const activityRegistrationRoutes = require('./routes/activityRegistration');
|
||||
|
||||
const participationsRoutes = require('./routes/participations');
|
||||
|
||||
const uploadsRoutes = require('./routes/uploads');
|
||||
|
||||
const materialsRoutes = require('./routes/materials');
|
||||
|
||||
const notificationsRoutes = require('./routes/notifications');
|
||||
|
||||
const class_ratingsRoutes = require('./routes/class_ratings');
|
||||
@ -123,10 +126,14 @@ app.use('/api/categories', passport.authenticate('jwt', {session: false}), categ
|
||||
|
||||
app.use('/api/activities', passport.authenticate('jwt', {session: false}), activitiesRoutes);
|
||||
|
||||
app.use('/api/activities', passport.authenticate('jwt', {session: false}), activityRegistrationRoutes);
|
||||
|
||||
app.use('/api/participations', passport.authenticate('jwt', {session: false}), participationsRoutes);
|
||||
|
||||
app.use('/api/uploads', passport.authenticate('jwt', {session: false}), uploadsRoutes);
|
||||
|
||||
app.use('/api/materials', passport.authenticate('jwt', {session: false}), materialsRoutes);
|
||||
|
||||
app.use('/api/notifications', passport.authenticate('jwt', {session: false}), notificationsRoutes);
|
||||
|
||||
app.use('/api/class_ratings', passport.authenticate('jwt', {session: false}), class_ratingsRoutes);
|
||||
|
||||
45
backend/src/routes/activityRegistration.js
Normal file
45
backend/src/routes/activityRegistration.js
Normal file
@ -0,0 +1,45 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const db = require('../db/models');
|
||||
const wrapAsync = require('../helpers').wrapAsync;
|
||||
const { checkCrudPermissions } = require('../middlewares/check-permissions');
|
||||
|
||||
router.post('/:id/register', checkCrudPermissions('participations'), wrapAsync(async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { id: studentId } = req.currentUser;
|
||||
|
||||
const activity = await db.activities.findByPk(id);
|
||||
if (!activity) return res.status(404).send({ message: 'Activity not found' });
|
||||
|
||||
const existing = await db.participations.findOne({
|
||||
where: { activityId: id, studentId }
|
||||
});
|
||||
if (existing) return res.status(400).send({ message: 'Already registered' });
|
||||
|
||||
if (activity.max_participants > 0 && activity.current_participants >= activity.max_participants) {
|
||||
return res.status(400).send({ message: 'No seats available' });
|
||||
}
|
||||
|
||||
const transaction = await db.sequelize.transaction();
|
||||
try {
|
||||
await db.participations.create({
|
||||
activityId: id,
|
||||
studentId,
|
||||
organizationsId: activity.organizationsId,
|
||||
participation_status: 'registered',
|
||||
registered_at: new Date()
|
||||
}, { transaction });
|
||||
|
||||
await activity.increment('current_participants', { transaction });
|
||||
|
||||
// Add notification logic here if needed
|
||||
|
||||
await transaction.commit();
|
||||
res.status(200).send({ message: 'Registered successfully' });
|
||||
} catch (err) {
|
||||
await transaction.rollback();
|
||||
throw err;
|
||||
}
|
||||
}));
|
||||
|
||||
module.exports = router;
|
||||
42
backend/src/routes/materials.js
Normal file
42
backend/src/routes/materials.js
Normal file
@ -0,0 +1,42 @@
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const wrapAsync = require('../helpers').wrapAsync;
|
||||
const db = require('../db/models');
|
||||
|
||||
// POST /api/materials/upload
|
||||
// Expects: { participationId, file_url, file_type, file_size_bytes }
|
||||
router.post('/upload', wrapAsync(async (req, res) => {
|
||||
const { participationId, file_url, file_type, file_size_bytes } = req.body;
|
||||
const currentUser = req.currentUser;
|
||||
const organizationsId = currentUser.organizationsId;
|
||||
|
||||
const transaction = await db.sequelize.transaction();
|
||||
try {
|
||||
// 1. Create the upload record
|
||||
const upload = await db.uploads.create({
|
||||
file_type,
|
||||
file_url,
|
||||
file_size_bytes,
|
||||
uploaded_at: new Date(),
|
||||
participationId,
|
||||
organizationsId,
|
||||
createdById: currentUser.id,
|
||||
updatedById: currentUser.id
|
||||
}, { transaction });
|
||||
|
||||
// 2. Update participation status
|
||||
await db.participations.update(
|
||||
{ participation_status: 'materials_uploaded' },
|
||||
{ where: { id: participationId }, transaction }
|
||||
);
|
||||
|
||||
await transaction.commit();
|
||||
res.status(200).send({ success: true, upload });
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
}));
|
||||
|
||||
module.exports = router;
|
||||
@ -1,4 +1,3 @@
|
||||
|
||||
const express = require('express');
|
||||
|
||||
const ParticipationsService = require('../services/participations');
|
||||
@ -91,6 +90,50 @@ router.post('/', wrapAsync(async (req, res) => {
|
||||
res.status(200).send(payload);
|
||||
}));
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/participations/{id}/review:
|
||||
* post:
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* tags: [Participations]
|
||||
* summary: Review an activity participation
|
||||
* description: Review an activity participation
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* description: Participation ID
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* comment:
|
||||
* type: string
|
||||
* points:
|
||||
* type: number
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The participation was successfully reviewed
|
||||
* 401:
|
||||
* $ref: "#/components/responses/UnauthorizedError"
|
||||
* 404:
|
||||
* description: Item not found
|
||||
* 500:
|
||||
* description: Some server error
|
||||
*/
|
||||
router.post('/:id/review', wrapAsync(async (req, res) => {
|
||||
await ParticipationsService.review(req.params.id, req.body, req.currentUser);
|
||||
res.status(200).send({ success: true });
|
||||
}));
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/budgets/bulk-import:
|
||||
|
||||
@ -1,15 +1,7 @@
|
||||
const db = require('../db/models');
|
||||
const ParticipationsDBApi = require('../db/api/participations');
|
||||
const processFile = require("../middlewares/upload");
|
||||
const ValidationError = require('./notifications/errors/validation');
|
||||
const csv = require('csv-parser');
|
||||
const axios = require('axios');
|
||||
const config = require('../config');
|
||||
const stream = require('stream');
|
||||
|
||||
|
||||
|
||||
|
||||
const moment = require('moment');
|
||||
|
||||
module.exports = class ParticipationsService {
|
||||
static async create(data, currentUser) {
|
||||
@ -97,6 +89,93 @@ module.exports = class ParticipationsService {
|
||||
}
|
||||
};
|
||||
|
||||
static async review(id, data, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
try {
|
||||
let participation = await ParticipationsDBApi.findBy(
|
||||
{id},
|
||||
{transaction},
|
||||
);
|
||||
|
||||
if (!participation) {
|
||||
throw new ValidationError(
|
||||
'participationsNotFound',
|
||||
);
|
||||
}
|
||||
|
||||
const reviewData = {
|
||||
participation_status: data.status,
|
||||
organizer_comment: data.comment,
|
||||
awarded_points: data.points,
|
||||
reviewed_at: moment().toDate(),
|
||||
};
|
||||
|
||||
const updatedParticipation = await ParticipationsDBApi.update(
|
||||
id,
|
||||
reviewData,
|
||||
{
|
||||
currentUser,
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
|
||||
if (data.status === 'accepted' && data.points > 0) {
|
||||
// Find existing achievements for student
|
||||
const studentId = participation.studentId || participation.student?.id;
|
||||
if (studentId) {
|
||||
// Find all accepted participations for this student to calculate total points
|
||||
const allParticipations = await db.participations.findAll({
|
||||
where: {
|
||||
studentId,
|
||||
participation_status: 'accepted'
|
||||
},
|
||||
include: [{
|
||||
model: db.activities,
|
||||
as: 'activity'
|
||||
}],
|
||||
transaction
|
||||
});
|
||||
|
||||
const totalPoints = allParticipations.reduce((acc, p) => acc + (parseFloat(p.awarded_points) || 0), 0) + (parseFloat(data.points) || 0);
|
||||
|
||||
// Check if student earned new achievements
|
||||
const allAchievements = await db.achievements.findAll({
|
||||
where: { is_active: true },
|
||||
transaction
|
||||
});
|
||||
|
||||
for (const achievement of allAchievements) {
|
||||
if (totalPoints >= achievement.points_threshold) {
|
||||
const alreadyAwarded = await db.user_achievements.findOne({
|
||||
where: {
|
||||
userId: studentId,
|
||||
achievementId: achievement.id
|
||||
},
|
||||
transaction
|
||||
});
|
||||
|
||||
if (!alreadyAwarded) {
|
||||
await db.user_achievements.create({
|
||||
userId: studentId,
|
||||
achievementId: achievement.id,
|
||||
awarded_at: new Date(),
|
||||
note: `Total points: ${totalPoints}`
|
||||
}, { transaction });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
return updatedParticipation;
|
||||
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
static async deleteByIds(ids, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
|
||||
@ -134,5 +213,3 @@ module.exports = class ParticipationsService {
|
||||
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ type Props = {
|
||||
showFieldName: string;
|
||||
deleteThunk: AsyncThunk<any, any, any>;
|
||||
updateThunk: AsyncThunk<any, any, any>;
|
||||
onCardClick?: (id: string) => void;
|
||||
};
|
||||
|
||||
const KanbanBoard = ({
|
||||
@ -22,6 +23,7 @@ const KanbanBoard = ({
|
||||
showFieldName,
|
||||
deleteThunk,
|
||||
updateThunk,
|
||||
onCardClick,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div
|
||||
@ -40,6 +42,7 @@ const KanbanBoard = ({
|
||||
filtersQuery={filtersQuery}
|
||||
deleteThunk={deleteThunk}
|
||||
updateThunk={updateThunk}
|
||||
onCardClick={onCardClick}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import moment from 'moment';
|
||||
import ListActionsPopover from '../ListActionsPopover';
|
||||
import { DragSourceMonitor, useDrag } from 'react-dnd';
|
||||
@ -10,6 +9,7 @@ type Props = {
|
||||
entityName: string;
|
||||
showFieldName: string;
|
||||
setItemIdToDelete: (id: string) => void;
|
||||
onClick?: (id: string) => void;
|
||||
};
|
||||
|
||||
const KanbanCard = ({
|
||||
@ -18,6 +18,7 @@ const KanbanCard = ({
|
||||
showFieldName,
|
||||
setItemIdToDelete,
|
||||
column,
|
||||
onClick
|
||||
}: Props) => {
|
||||
const [{ isDragging }, drag] = useDrag(
|
||||
() => ({
|
||||
@ -33,17 +34,15 @@ const KanbanCard = ({
|
||||
return (
|
||||
<div
|
||||
ref={drag}
|
||||
onClick={() => onClick && onClick(item.id)}
|
||||
className={
|
||||
`bg-gray-50 dark:bg-dark-800 rounded-md space-y-2 p-4 relative ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}`
|
||||
}
|
||||
>
|
||||
<div className={'flex items-center justify-between'}>
|
||||
<Link
|
||||
href={`/${entityName}/${entityName}-view/?id=${item.id}`}
|
||||
className={'text-base font-semibold'}
|
||||
>
|
||||
<div className={'text-base font-semibold'}>
|
||||
{item[showFieldName] ?? 'No data'}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className={'flex items-center justify-between'}>
|
||||
<p>{moment(item.createdAt).format('MMM DD hh:mm a')}</p>
|
||||
|
||||
@ -15,6 +15,7 @@ type Props = {
|
||||
filtersQuery: any;
|
||||
deleteThunk: AsyncThunk<any, any, any>;
|
||||
updateThunk: AsyncThunk<any, any, any>;
|
||||
onCardClick?: (id: string) => void;
|
||||
};
|
||||
|
||||
type DropResult = {
|
||||
@ -32,6 +33,7 @@ const KanbanColumn = ({
|
||||
filtersQuery,
|
||||
deleteThunk,
|
||||
updateThunk,
|
||||
onCardClick
|
||||
}: Props) => {
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const [count, setCount] = useState(0);
|
||||
@ -184,6 +186,7 @@ const KanbanColumn = ({
|
||||
showFieldName={showFieldName}
|
||||
entityName={entityName}
|
||||
setItemIdToDelete={setItemIdToDelete}
|
||||
onClick={onCardClick}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@ -0,0 +1,82 @@
|
||||
import { mdiCheck, mdiClose, mdiCommentAlert } from '@mdi/js';
|
||||
import React, { useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import BaseButton from '../BaseButton';
|
||||
import CardBoxModal from '../CardBoxModal';
|
||||
import FormField from '../FormField';
|
||||
|
||||
interface ReviewModalProps {
|
||||
participationId: string;
|
||||
isActive: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const ParticipationReviewModal: React.FC<ReviewModalProps> = ({
|
||||
participationId,
|
||||
isActive,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) => {
|
||||
const [status, setStatus] = useState('accepted');
|
||||
const [comment, setComment] = useState('');
|
||||
const [points, setPoints] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await axios.post(`/participations/${participationId}/review`, {
|
||||
status,
|
||||
comment,
|
||||
points: parseFloat(points),
|
||||
});
|
||||
onConfirm();
|
||||
} catch (error) {
|
||||
console.error('Review failed', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CardBoxModal
|
||||
title="Review Participation"
|
||||
buttonColor="success"
|
||||
buttonLabel={loading ? 'Saving...' : 'Submit Review'}
|
||||
isActive={isActive}
|
||||
onConfirm={handleSubmit}
|
||||
onCancel={onCancel}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<FormField label="Status">
|
||||
<select
|
||||
className="w-full p-2 border rounded"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
>
|
||||
<option value="accepted">Accepted</option>
|
||||
<option value="reviewed">Reviewed (Needs fixes)</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Points Awarded">
|
||||
<input
|
||||
type="number"
|
||||
className="w-full p-2 border rounded"
|
||||
value={points}
|
||||
onChange={(e) => setPoints(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Organizer Comment">
|
||||
<textarea
|
||||
className="w-full p-2 border rounded"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</CardBoxModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ParticipationReviewModal;
|
||||
@ -20,6 +20,7 @@ import {dataGridStyles} from "../../styles";
|
||||
|
||||
import KanbanBoard from '../KanbanBoard/KanbanBoard';
|
||||
import axios from 'axios';
|
||||
import ParticipationReviewModal from './ParticipationReviewModal';
|
||||
|
||||
|
||||
const perPage = 10
|
||||
@ -32,6 +33,8 @@ const TableSampleParticipations = ({ filterItems, setFilterItems, filters, showG
|
||||
|
||||
const pagesList = [];
|
||||
const [id, setId] = useState(null);
|
||||
const [reviewId, setReviewId] = useState(null);
|
||||
const [isReviewModalActive, setIsReviewModalActive] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const [filterRequest, setFilterRequest] = React.useState('');
|
||||
const [columns, setColumns] = useState<GridColDef[]>([]);
|
||||
@ -89,6 +92,7 @@ const TableSampleParticipations = ({ filterItems, setFilterItems, filters, showG
|
||||
const handleModalAction = () => {
|
||||
setIsModalInfoActive(false)
|
||||
setIsModalTrashActive(false)
|
||||
setIsReviewModalActive(false)
|
||||
}
|
||||
|
||||
|
||||
@ -126,6 +130,16 @@ const TableSampleParticipations = ({ filterItems, setFilterItems, filters, showG
|
||||
}
|
||||
};
|
||||
|
||||
const handleReviewModalAction = (id: string) => {
|
||||
setReviewId(id);
|
||||
setIsReviewModalActive(true);
|
||||
}
|
||||
|
||||
const handleReviewConfirm = () => {
|
||||
setIsReviewModalActive(false);
|
||||
loadData(0);
|
||||
}
|
||||
|
||||
const generateFilterRequests = useMemo(() => {
|
||||
let request = '&';
|
||||
filterItems.forEach((item) => {
|
||||
@ -470,6 +484,12 @@ const TableSampleParticipations = ({ filterItems, setFilterItems, filters, showG
|
||||
<p>Are you sure you want to delete this item?</p>
|
||||
</CardBoxModal>
|
||||
|
||||
<ParticipationReviewModal
|
||||
participationId={reviewId}
|
||||
isActive={isReviewModalActive}
|
||||
onConfirm={handleReviewConfirm}
|
||||
onCancel={handleModalAction}
|
||||
/>
|
||||
|
||||
|
||||
{!showGrid && kanbanColumns && (
|
||||
@ -481,6 +501,7 @@ const TableSampleParticipations = ({ filterItems, setFilterItems, filters, showG
|
||||
deleteThunk={deleteItem}
|
||||
updateThunk={update}
|
||||
columns={kanbanColumns}
|
||||
onCardClick={handleReviewModalAction}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -1,139 +1,7 @@
|
||||
import * as icon from '@mdi/js';
|
||||
import { MenuAsideItem } from './interfaces'
|
||||
import { mdiViewDashboard, mdiCalendar, mdiCloudUpload, mdiAccountCircle } from '@mdi/js'
|
||||
|
||||
const menuAside: MenuAsideItem[] = [
|
||||
{
|
||||
href: '/dashboard',
|
||||
icon: icon.mdiViewDashboardOutline,
|
||||
label: 'Dashboard',
|
||||
},
|
||||
|
||||
{
|
||||
href: '/users/users-list',
|
||||
label: 'Users',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: icon.mdiAccountGroup ?? icon.mdiTable,
|
||||
permissions: 'READ_USERS'
|
||||
},
|
||||
{
|
||||
href: '/roles/roles-list',
|
||||
label: 'Roles',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiTable,
|
||||
permissions: 'READ_ROLES'
|
||||
},
|
||||
{
|
||||
href: '/permissions/permissions-list',
|
||||
label: 'Permissions',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: icon.mdiShieldAccountOutline ?? icon.mdiTable,
|
||||
permissions: 'READ_PERMISSIONS'
|
||||
},
|
||||
{
|
||||
href: '/organizations/organizations-list',
|
||||
label: 'Organizations',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_ORGANIZATIONS'
|
||||
},
|
||||
{
|
||||
href: '/schools/schools-list',
|
||||
label: 'Schools',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiSchool' in icon ? icon['mdiSchool' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_SCHOOLS'
|
||||
},
|
||||
{
|
||||
href: '/categories/categories-list',
|
||||
label: 'Categories',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiTagMultiple' in icon ? icon['mdiTagMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_CATEGORIES'
|
||||
},
|
||||
{
|
||||
href: '/activities/activities-list',
|
||||
label: 'Activities',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiCalendarStar' in icon ? icon['mdiCalendarStar' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_ACTIVITIES'
|
||||
},
|
||||
{
|
||||
href: '/participations/participations-list',
|
||||
label: 'Participations',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiAccountCheck' in icon ? icon['mdiAccountCheck' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_PARTICIPATIONS'
|
||||
},
|
||||
{
|
||||
href: '/uploads/uploads-list',
|
||||
label: 'Uploads',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiCloudUpload' in icon ? icon['mdiCloudUpload' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_UPLOADS'
|
||||
},
|
||||
{
|
||||
href: '/notifications/notifications-list',
|
||||
label: 'Notifications',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiBell' in icon ? icon['mdiBell' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_NOTIFICATIONS'
|
||||
},
|
||||
{
|
||||
href: '/class_ratings/class_ratings-list',
|
||||
label: 'Class ratings',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiTrophy' in icon ? icon['mdiTrophy' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_CLASS_RATINGS'
|
||||
},
|
||||
{
|
||||
href: '/achievements/achievements-list',
|
||||
label: 'Achievements',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiMedal' in icon ? icon['mdiMedal' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_ACHIEVEMENTS'
|
||||
},
|
||||
{
|
||||
href: '/user_achievements/user_achievements-list',
|
||||
label: 'User achievements',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiMedalOutline' in icon ? icon['mdiMedalOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_USER_ACHIEVEMENTS'
|
||||
},
|
||||
{
|
||||
href: '/reports/reports-list',
|
||||
label: 'Reports',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiFileChart' in icon ? icon['mdiFileChart' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_REPORTS'
|
||||
},
|
||||
{
|
||||
href: '/profile',
|
||||
label: 'Profile',
|
||||
icon: icon.mdiAccountCircle,
|
||||
},
|
||||
|
||||
|
||||
{
|
||||
href: '/api-docs',
|
||||
target: '_blank',
|
||||
label: 'Swagger API',
|
||||
icon: icon.mdiFileCode,
|
||||
permissions: 'READ_API_DOCS'
|
||||
},
|
||||
export const menuAside = [
|
||||
{ href: '/user-dashboard', label: 'Dashboard', icon: mdiViewDashboard },
|
||||
{ href: '/calendar', label: 'Calendar', icon: mdiCalendar },
|
||||
{ href: '/my-uploads', label: 'Мои загрузки', icon: mdiCloudUpload },
|
||||
]
|
||||
|
||||
export default menuAside
|
||||
|
||||
133
frontend/src/pages/activities/[id].tsx
Normal file
133
frontend/src/pages/activities/[id].tsx
Normal file
@ -0,0 +1,133 @@
|
||||
import { mdiAccountGroup, mdiCalendarClock, mdiFormatListChecks, mdiMapMarker, mdiTrophy, mdiUpload } from '@mdi/js'
|
||||
import Head from 'next/head'
|
||||
import React, { ReactElement, useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import axios from 'axios'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import CardBox from '../../../components/CardBox'
|
||||
import LayoutAuthenticated from '../../../layouts/Authenticated'
|
||||
import SectionMain from '../../../components/SectionMain'
|
||||
import SectionTitleLineWithButton from '../../../components/SectionTitleLineWithButton'
|
||||
import { getPageTitle } from '../../../config'
|
||||
import { useAppSelector } from '../../../stores/hooks'
|
||||
import BaseButton from '../../../components/BaseButton'
|
||||
import LoadingSpinner from '../../../components/LoadingSpinner'
|
||||
|
||||
const ActivityDetails = () => {
|
||||
const router = useRouter()
|
||||
const { id } = router.query
|
||||
const [activity, setActivity] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [participation, setParticipation] = useState<any>(null)
|
||||
|
||||
const { currentUser } = useAppSelector((state) => state.auth)
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
fetchActivity()
|
||||
}
|
||||
}, [id])
|
||||
|
||||
const fetchActivity = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await axios.get(`/activities/${id}`)
|
||||
setActivity(res.data)
|
||||
|
||||
// Check participation status
|
||||
const partRes = await axios.get(`/participations?activityId=${id}&studentId=${currentUser?.id}`)
|
||||
if (partRes.data.rows.length > 0) {
|
||||
setParticipation(partRes.data.rows[0])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleParticipate = async () => {
|
||||
try {
|
||||
await axios.post(`/activities/${id}/register`)
|
||||
fetchActivity()
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
alert("Не удалось зарегистрироваться")
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <LoadingSpinner />
|
||||
if (!activity) return <SectionMain><CardBox>Activity not found.</CardBox></SectionMain>
|
||||
|
||||
const isFull = activity.max_participants > 0 && activity.current_participants >= activity.max_participants
|
||||
const isExpired = dayjs().isAfter(dayjs(activity.registration_deadline_at))
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle(activity.title)}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiTrophy} title={activity.title} main />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
<CardBox className="mb-6">
|
||||
<h2 className="text-xl font-bold mb-4">Description</h2>
|
||||
<div dangerouslySetInnerHTML={{ __html: activity.description }} />
|
||||
</CardBox>
|
||||
|
||||
<CardBox>
|
||||
<h2 className="text-xl font-bold mb-4">Requirements</h2>
|
||||
<div dangerouslySetInnerHTML={{ __html: activity.result_requirements }} />
|
||||
</CardBox>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<CardBox className="mb-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-gray-500">Category:</span>
|
||||
<span className="px-2 py-1 rounded" style={{ backgroundColor: activity.category?.color || '#eee' }}>
|
||||
{activity.category?.name}
|
||||
</span>
|
||||
</div>
|
||||
<div><mdiCalendarClock className="inline w-5 h-5 mr-1" /> {dayjs(activity.starts_at).format('LLL')}</div>
|
||||
<div className="text-sm text-red-500">Registration deadline: {dayjs(activity.registration_deadline_at).format('LLL')}</div>
|
||||
<div className="text-sm">Points: {activity.base_points}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<mdiAccountGroup className="w-5 h-5" />
|
||||
{activity.current_participants} / {activity.max_participants > 0 ? activity.max_participants : '∞'}
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox>
|
||||
{!participation ? (
|
||||
isFull ? (
|
||||
<BaseButton label="Мест нет" color="danger" disabled />
|
||||
) : isExpired ? (
|
||||
<BaseButton label="Дедлайн истек" color="danger" disabled />
|
||||
) : (
|
||||
<BaseButton label="Участвовать" color="info" onClick={handleParticipate} />
|
||||
)
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="p-2 bg-green-100 text-green-800 rounded">Вы уже участвуете ({participation.participation_status})</div>
|
||||
<BaseButton icon={mdiUpload} label="Загрузить материалы" color="success" onClick={() => router.push(`/activities/${id}/upload`)} />
|
||||
</div>
|
||||
)}
|
||||
</CardBox>
|
||||
</div>
|
||||
</div>
|
||||
</SectionMain>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
ActivityDetails.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
|
||||
}
|
||||
|
||||
export default ActivityDetails
|
||||
82
frontend/src/pages/activities/[id]/upload.tsx
Normal file
82
frontend/src/pages/activities/[id]/upload.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import { mdiUpload, mdiArrowLeft } from '@mdi/js'
|
||||
import Head from 'next/head'
|
||||
import React, { ReactElement, useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import axios from 'axios'
|
||||
|
||||
import CardBox from '../../../components/CardBox'
|
||||
import LayoutAuthenticated from '../../../layouts/Authenticated'
|
||||
import SectionMain from '../../../components/SectionMain'
|
||||
import SectionTitleLineWithButton from '../../../components/SectionTitleLineWithButton'
|
||||
import { getPageTitle } from '../../../config'
|
||||
import BaseButton from '../../../components/BaseButton'
|
||||
import FormField from '../../../components/FormField'
|
||||
import DragDropFilePicker from '../../../components/DragDropFilePicker'
|
||||
|
||||
const UploadMaterials = () => {
|
||||
const router = useRouter()
|
||||
const { id } = router.query
|
||||
const [fileUrl, setFileUrl] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleUpload = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const partRes = await axios.get(`/participations?activityId=${id}`)
|
||||
const participationId = partRes.data.rows[0].id
|
||||
|
||||
await axios.post('/materials/upload', {
|
||||
participationId,
|
||||
file_url: fileUrl,
|
||||
file_type: 'photo',
|
||||
file_size_bytes: 0
|
||||
})
|
||||
|
||||
alert('Материалы успешно загружены!')
|
||||
router.push(`/activities/${id}`)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
alert('Ошибка при загрузке')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Загрузка материалов')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiUpload} title="Загрузка материалов" main>
|
||||
<BaseButton icon={mdiArrowLeft} label="Назад" color="info" onClick={() => router.back()} />
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
<CardBox>
|
||||
<FormField label="URL файла (временно)">
|
||||
<input
|
||||
type="text"
|
||||
value={fileUrl}
|
||||
onChange={(e) => setFileUrl(e.target.value)}
|
||||
className="px-3 py-2 border rounded w-full"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="mt-4">
|
||||
<DragDropFilePicker label="Или перетащите файлы сюда (заглушка)" />
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<BaseButton label={loading ? 'Загрузка...' : 'Загрузить'} color="success" onClick={handleUpload} disabled={loading} />
|
||||
</div>
|
||||
</CardBox>
|
||||
</SectionMain>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
UploadMaterials.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
|
||||
}
|
||||
|
||||
export default UploadMaterials
|
||||
96
frontend/src/pages/calendar.tsx
Normal file
96
frontend/src/pages/calendar.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
import { mdiCalendarMonth, mdiFilterVariant } from '@mdi/js';
|
||||
import Head from 'next/head';
|
||||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useRouter } from 'next/router';
|
||||
import CardBox from '../components/CardBox';
|
||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||
import SectionMain from '../components/SectionMain';
|
||||
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
||||
import { getPageTitle } from '../config';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
|
||||
const CalendarPage = () => {
|
||||
const router = useRouter();
|
||||
const [activities, setActivities] = useState([]);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [activitiesRes, categoriesRes] = await Promise.all([
|
||||
axios.get('/activities'),
|
||||
axios.get('/categories')
|
||||
]);
|
||||
setActivities(activitiesRes.data.rows || []);
|
||||
setCategories(categoriesRes.data.rows || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching calendar data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const filteredActivities = selectedCategory === 'all'
|
||||
? activities
|
||||
: activities.filter(act => act.categoriesId === selectedCategory);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Calendar')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiCalendarMonth} title="Activity Calendar" main>
|
||||
<div className="flex items-center gap-2">
|
||||
<BaseButton
|
||||
icon={mdiFilterVariant}
|
||||
label="Filter"
|
||||
color="white"
|
||||
/>
|
||||
<select
|
||||
className="px-3 py-2 border border-gray-300 rounded dark:bg-gray-800"
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
>
|
||||
<option value="all">All Categories</option>
|
||||
{categories.map(cat => (
|
||||
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
<CardBox>
|
||||
{loading ? (
|
||||
<p>Loading...</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredActivities.map(activity => (
|
||||
<div
|
||||
key={activity.id}
|
||||
className="p-4 border rounded hover:shadow-lg transition cursor-pointer"
|
||||
onClick={() => router.push(`/activities/${activity.id}`)}
|
||||
>
|
||||
<h3 className="font-bold">{activity.title}</h3>
|
||||
<p className="text-sm text-gray-500">{new Date(activity.starts_at).toLocaleDateString()}</p>
|
||||
<p className="mt-2 text-sm text-gray-600 line-clamp-2">{activity.description?.replace(/<[^>]+>/g, '')}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardBox>
|
||||
</SectionMain>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
CalendarPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||
};
|
||||
|
||||
export default CalendarPage;
|
||||
67
frontend/src/pages/my-uploads.tsx
Normal file
67
frontend/src/pages/my-uploads.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import { mdiUpload, mdiArrowLeft } from '@mdi/js'
|
||||
import Head from 'next/head'
|
||||
import React, { ReactElement, useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import axios from 'axios'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import CardBox from '../components/CardBox'
|
||||
import LayoutAuthenticated from '../layouts/Authenticated'
|
||||
import SectionMain from '../components/SectionMain'
|
||||
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'
|
||||
import { getPageTitle } from '../config'
|
||||
import LoadingSpinner from '../components/LoadingSpinner'
|
||||
|
||||
const MyUploads = () => {
|
||||
const [uploads, setUploads] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetchUploads()
|
||||
}, [])
|
||||
|
||||
const fetchUploads = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await axios.get('/uploads')
|
||||
setUploads(res.data.rows)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <LoadingSpinner />
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Мои загрузки')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiUpload} title="Мои загрузки" main />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{uploads.map((upload: any) => (
|
||||
<CardBox key={upload.id}>
|
||||
<div className="font-bold text-lg mb-2">Активность: {upload.participation?.activity?.title || 'Без названия'}</div>
|
||||
<div className="text-sm text-gray-500 mb-2">Дата: {dayjs(upload.uploaded_at).format('LLL')}</div>
|
||||
<div className="text-sm">Статус: {upload.participation?.participation_status}</div>
|
||||
<a href={upload.file_url} target="_blank" rel="noreferrer" className="text-blue-500 hover:underline mt-4 block">
|
||||
Посмотреть файл
|
||||
</a>
|
||||
</CardBox>
|
||||
))}
|
||||
{uploads.length === 0 && <CardBox>Нет загруженных материалов.</CardBox>}
|
||||
</div>
|
||||
</SectionMain>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
MyUploads.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
|
||||
}
|
||||
|
||||
export default MyUploads
|
||||
90
frontend/src/pages/user-dashboard.tsx
Normal file
90
frontend/src/pages/user-dashboard.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import { mdiChartTimelineVariant, mdiTrophy, mdiCalendarStar, mdiCheckCircle } from '@mdi/js';
|
||||
import Head from 'next/head';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useAppSelector } from '../../stores/hooks';
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated';
|
||||
import SectionMain from '../../components/SectionMain';
|
||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
|
||||
import CardBox from '../../components/CardBox';
|
||||
import BaseIcon from '../../components/BaseIcon';
|
||||
import { getPageTitle } from '../../config';
|
||||
|
||||
const UserDashboard = () => {
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
const [stats, setStats] = useState({ totalPoints: 0, completedActivities: 0, activeParticipations: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser) return;
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
// Fetch all accepted participations for this student
|
||||
const resp = await axios.get(`/participations?student=${currentUser.id}`);
|
||||
const participations = resp.data.rows;
|
||||
|
||||
const accepted = participations.filter(p => p.participation_status === 'accepted');
|
||||
const totalPoints = accepted.reduce((acc, p) => acc + (parseFloat(p.awarded_points) || 0), 0);
|
||||
const active = participations.filter(p => ['registered', 'materials_uploaded', 'reviewed'].includes(p.participation_status));
|
||||
|
||||
setStats({
|
||||
totalPoints,
|
||||
completedActivities: accepted.length,
|
||||
activeParticipations: active.length
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to load stats', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [currentUser]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('My Dashboard')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={`Welcome, ${currentUser?.firstName || 'Student'}`} main />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||
<CardBox>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-gray-500">Total Points</div>
|
||||
<div className="text-3xl font-bold">{loading ? '...' : stats.totalPoints}</div>
|
||||
</div>
|
||||
<BaseIcon path={mdiTrophy} size={48} className="text-yellow-500" />
|
||||
</div>
|
||||
</CardBox>
|
||||
<CardBox>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-gray-500">Completed Activities</div>
|
||||
<div className="text-3xl font-bold">{loading ? '...' : stats.completedActivities}</div>
|
||||
</div>
|
||||
<BaseIcon path={mdiCheckCircle} size={48} className="text-green-500" />
|
||||
</div>
|
||||
</CardBox>
|
||||
<CardBox>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-gray-500">Active Participations</div>
|
||||
<div className="text-3xl font-bold">{loading ? '...' : stats.activeParticipations}</div>
|
||||
</div>
|
||||
<BaseIcon path={mdiCalendarStar} size={48} className="text-blue-500" />
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
</SectionMain>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
UserDashboard.getLayout = (page: React.ReactElement) => <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||
|
||||
export default UserDashboard;
|
||||
Loading…
x
Reference in New Issue
Block a user