Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2100afc063 |
@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const app = express();
|
const app = express();
|
||||||
@ -37,10 +36,14 @@ const categoriesRoutes = require('./routes/categories');
|
|||||||
|
|
||||||
const activitiesRoutes = require('./routes/activities');
|
const activitiesRoutes = require('./routes/activities');
|
||||||
|
|
||||||
|
const activityRegistrationRoutes = require('./routes/activityRegistration');
|
||||||
|
|
||||||
const participationsRoutes = require('./routes/participations');
|
const participationsRoutes = require('./routes/participations');
|
||||||
|
|
||||||
const uploadsRoutes = require('./routes/uploads');
|
const uploadsRoutes = require('./routes/uploads');
|
||||||
|
|
||||||
|
const materialsRoutes = require('./routes/materials');
|
||||||
|
|
||||||
const notificationsRoutes = require('./routes/notifications');
|
const notificationsRoutes = require('./routes/notifications');
|
||||||
|
|
||||||
const class_ratingsRoutes = require('./routes/class_ratings');
|
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}), activitiesRoutes);
|
||||||
|
|
||||||
|
app.use('/api/activities', passport.authenticate('jwt', {session: false}), activityRegistrationRoutes);
|
||||||
|
|
||||||
app.use('/api/participations', passport.authenticate('jwt', {session: false}), participationsRoutes);
|
app.use('/api/participations', passport.authenticate('jwt', {session: false}), participationsRoutes);
|
||||||
|
|
||||||
app.use('/api/uploads', passport.authenticate('jwt', {session: false}), uploadsRoutes);
|
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/notifications', passport.authenticate('jwt', {session: false}), notificationsRoutes);
|
||||||
|
|
||||||
app.use('/api/class_ratings', passport.authenticate('jwt', {session: false}), class_ratingsRoutes);
|
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 express = require('express');
|
||||||
|
|
||||||
const ParticipationsService = require('../services/participations');
|
const ParticipationsService = require('../services/participations');
|
||||||
@ -91,6 +90,50 @@ router.post('/', wrapAsync(async (req, res) => {
|
|||||||
res.status(200).send(payload);
|
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
|
* @swagger
|
||||||
* /api/budgets/bulk-import:
|
* /api/budgets/bulk-import:
|
||||||
@ -444,4 +487,4 @@ router.get('/:id', wrapAsync(async (req, res) => {
|
|||||||
|
|
||||||
router.use('/', require('../helpers').commonErrorHandler);
|
router.use('/', require('../helpers').commonErrorHandler);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@ -1,15 +1,7 @@
|
|||||||
const db = require('../db/models');
|
const db = require('../db/models');
|
||||||
const ParticipationsDBApi = require('../db/api/participations');
|
const ParticipationsDBApi = require('../db/api/participations');
|
||||||
const processFile = require("../middlewares/upload");
|
|
||||||
const ValidationError = require('./notifications/errors/validation');
|
const ValidationError = require('./notifications/errors/validation');
|
||||||
const csv = require('csv-parser');
|
const moment = require('moment');
|
||||||
const axios = require('axios');
|
|
||||||
const config = require('../config');
|
|
||||||
const stream = require('stream');
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
module.exports = class ParticipationsService {
|
module.exports = class ParticipationsService {
|
||||||
static async create(data, currentUser) {
|
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) {
|
static async deleteByIds(ids, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
@ -134,5 +213,3 @@ module.exports = class ParticipationsService {
|
|||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@ type Props = {
|
|||||||
showFieldName: string;
|
showFieldName: string;
|
||||||
deleteThunk: AsyncThunk<any, any, any>;
|
deleteThunk: AsyncThunk<any, any, any>;
|
||||||
updateThunk: AsyncThunk<any, any, any>;
|
updateThunk: AsyncThunk<any, any, any>;
|
||||||
|
onCardClick?: (id: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const KanbanBoard = ({
|
const KanbanBoard = ({
|
||||||
@ -22,6 +23,7 @@ const KanbanBoard = ({
|
|||||||
showFieldName,
|
showFieldName,
|
||||||
deleteThunk,
|
deleteThunk,
|
||||||
updateThunk,
|
updateThunk,
|
||||||
|
onCardClick,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -40,6 +42,7 @@ const KanbanBoard = ({
|
|||||||
filtersQuery={filtersQuery}
|
filtersQuery={filtersQuery}
|
||||||
deleteThunk={deleteThunk}
|
deleteThunk={deleteThunk}
|
||||||
updateThunk={updateThunk}
|
updateThunk={updateThunk}
|
||||||
|
onCardClick={onCardClick}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -48,4 +51,4 @@ const KanbanBoard = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default KanbanBoard;
|
export default KanbanBoard;
|
||||||
@ -1,5 +1,4 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import ListActionsPopover from '../ListActionsPopover';
|
import ListActionsPopover from '../ListActionsPopover';
|
||||||
import { DragSourceMonitor, useDrag } from 'react-dnd';
|
import { DragSourceMonitor, useDrag } from 'react-dnd';
|
||||||
@ -10,6 +9,7 @@ type Props = {
|
|||||||
entityName: string;
|
entityName: string;
|
||||||
showFieldName: string;
|
showFieldName: string;
|
||||||
setItemIdToDelete: (id: string) => void;
|
setItemIdToDelete: (id: string) => void;
|
||||||
|
onClick?: (id: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const KanbanCard = ({
|
const KanbanCard = ({
|
||||||
@ -18,6 +18,7 @@ const KanbanCard = ({
|
|||||||
showFieldName,
|
showFieldName,
|
||||||
setItemIdToDelete,
|
setItemIdToDelete,
|
||||||
column,
|
column,
|
||||||
|
onClick
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [{ isDragging }, drag] = useDrag(
|
const [{ isDragging }, drag] = useDrag(
|
||||||
() => ({
|
() => ({
|
||||||
@ -33,17 +34,15 @@ const KanbanCard = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={drag}
|
ref={drag}
|
||||||
|
onClick={() => onClick && onClick(item.id)}
|
||||||
className={
|
className={
|
||||||
`bg-gray-50 dark:bg-dark-800 rounded-md space-y-2 p-4 relative ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}`
|
`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'}>
|
<div className={'flex items-center justify-between'}>
|
||||||
<Link
|
<div className={'text-base font-semibold'}>
|
||||||
href={`/${entityName}/${entityName}-view/?id=${item.id}`}
|
|
||||||
className={'text-base font-semibold'}
|
|
||||||
>
|
|
||||||
{item[showFieldName] ?? 'No data'}
|
{item[showFieldName] ?? 'No data'}
|
||||||
</Link>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={'flex items-center justify-between'}>
|
<div className={'flex items-center justify-between'}>
|
||||||
<p>{moment(item.createdAt).format('MMM DD hh:mm a')}</p>
|
<p>{moment(item.createdAt).format('MMM DD hh:mm a')}</p>
|
||||||
@ -61,4 +60,4 @@ const KanbanCard = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default KanbanCard;
|
export default KanbanCard;
|
||||||
@ -15,6 +15,7 @@ type Props = {
|
|||||||
filtersQuery: any;
|
filtersQuery: any;
|
||||||
deleteThunk: AsyncThunk<any, any, any>;
|
deleteThunk: AsyncThunk<any, any, any>;
|
||||||
updateThunk: AsyncThunk<any, any, any>;
|
updateThunk: AsyncThunk<any, any, any>;
|
||||||
|
onCardClick?: (id: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DropResult = {
|
type DropResult = {
|
||||||
@ -32,6 +33,7 @@ const KanbanColumn = ({
|
|||||||
filtersQuery,
|
filtersQuery,
|
||||||
deleteThunk,
|
deleteThunk,
|
||||||
updateThunk,
|
updateThunk,
|
||||||
|
onCardClick
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [currentPage, setCurrentPage] = useState(0);
|
const [currentPage, setCurrentPage] = useState(0);
|
||||||
const [count, setCount] = useState(0);
|
const [count, setCount] = useState(0);
|
||||||
@ -184,6 +186,7 @@ const KanbanColumn = ({
|
|||||||
showFieldName={showFieldName}
|
showFieldName={showFieldName}
|
||||||
entityName={entityName}
|
entityName={entityName}
|
||||||
setItemIdToDelete={setItemIdToDelete}
|
setItemIdToDelete={setItemIdToDelete}
|
||||||
|
onClick={onCardClick}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -206,4 +209,4 @@ const KanbanColumn = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default KanbanColumn;
|
export default KanbanColumn;
|
||||||
@ -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 KanbanBoard from '../KanbanBoard/KanbanBoard';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import ParticipationReviewModal from './ParticipationReviewModal';
|
||||||
|
|
||||||
|
|
||||||
const perPage = 10
|
const perPage = 10
|
||||||
@ -32,6 +33,8 @@ const TableSampleParticipations = ({ filterItems, setFilterItems, filters, showG
|
|||||||
|
|
||||||
const pagesList = [];
|
const pagesList = [];
|
||||||
const [id, setId] = useState(null);
|
const [id, setId] = useState(null);
|
||||||
|
const [reviewId, setReviewId] = useState(null);
|
||||||
|
const [isReviewModalActive, setIsReviewModalActive] = useState(false);
|
||||||
const [currentPage, setCurrentPage] = useState(0);
|
const [currentPage, setCurrentPage] = useState(0);
|
||||||
const [filterRequest, setFilterRequest] = React.useState('');
|
const [filterRequest, setFilterRequest] = React.useState('');
|
||||||
const [columns, setColumns] = useState<GridColDef[]>([]);
|
const [columns, setColumns] = useState<GridColDef[]>([]);
|
||||||
@ -89,6 +92,7 @@ const TableSampleParticipations = ({ filterItems, setFilterItems, filters, showG
|
|||||||
const handleModalAction = () => {
|
const handleModalAction = () => {
|
||||||
setIsModalInfoActive(false)
|
setIsModalInfoActive(false)
|
||||||
setIsModalTrashActive(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(() => {
|
const generateFilterRequests = useMemo(() => {
|
||||||
let request = '&';
|
let request = '&';
|
||||||
filterItems.forEach((item) => {
|
filterItems.forEach((item) => {
|
||||||
@ -470,6 +484,12 @@ const TableSampleParticipations = ({ filterItems, setFilterItems, filters, showG
|
|||||||
<p>Are you sure you want to delete this item?</p>
|
<p>Are you sure you want to delete this item?</p>
|
||||||
</CardBoxModal>
|
</CardBoxModal>
|
||||||
|
|
||||||
|
<ParticipationReviewModal
|
||||||
|
participationId={reviewId}
|
||||||
|
isActive={isReviewModalActive}
|
||||||
|
onConfirm={handleReviewConfirm}
|
||||||
|
onCancel={handleModalAction}
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
{!showGrid && kanbanColumns && (
|
{!showGrid && kanbanColumns && (
|
||||||
@ -481,6 +501,7 @@ const TableSampleParticipations = ({ filterItems, setFilterItems, filters, showG
|
|||||||
deleteThunk={deleteItem}
|
deleteThunk={deleteItem}
|
||||||
updateThunk={update}
|
updateThunk={update}
|
||||||
columns={kanbanColumns}
|
columns={kanbanColumns}
|
||||||
|
onCardClick={handleReviewModalAction}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -504,4 +525,4 @@ const TableSampleParticipations = ({ filterItems, setFilterItems, filters, showG
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TableSampleParticipations
|
export default TableSampleParticipations
|
||||||
@ -1,139 +1,7 @@
|
|||||||
import * as icon from '@mdi/js';
|
import { mdiViewDashboard, mdiCalendar, mdiCloudUpload, mdiAccountCircle } from '@mdi/js'
|
||||||
import { MenuAsideItem } from './interfaces'
|
|
||||||
|
|
||||||
const menuAside: MenuAsideItem[] = [
|
export const menuAside = [
|
||||||
{
|
{ href: '/user-dashboard', label: 'Dashboard', icon: mdiViewDashboard },
|
||||||
href: '/dashboard',
|
{ href: '/calendar', label: 'Calendar', icon: mdiCalendar },
|
||||||
icon: icon.mdiViewDashboardOutline,
|
{ href: '/my-uploads', label: 'Мои загрузки', icon: mdiCloudUpload },
|
||||||
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 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