Compare commits
No commits in common. "ai-dev" and "master" have entirely different histories.
@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const app = express();
|
const app = express();
|
||||||
@ -36,14 +37,10 @@ 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');
|
||||||
@ -126,14 +123,10 @@ 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);
|
||||||
|
|||||||
@ -1,45 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
|
|
||||||
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,3 +1,4 @@
|
|||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
|
||||||
const ParticipationsService = require('../services/participations');
|
const ParticipationsService = require('../services/participations');
|
||||||
@ -90,50 +91,6 @@ 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:
|
||||||
|
|||||||
@ -1,7 +1,15 @@
|
|||||||
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 moment = require('moment');
|
const csv = require('csv-parser');
|
||||||
|
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) {
|
||||||
@ -89,93 +97,6 @@ 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();
|
||||||
|
|
||||||
@ -213,3 +134,5 @@ module.exports = class ParticipationsService {
|
|||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,6 @@ 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 = ({
|
||||||
@ -23,7 +22,6 @@ const KanbanBoard = ({
|
|||||||
showFieldName,
|
showFieldName,
|
||||||
deleteThunk,
|
deleteThunk,
|
||||||
updateThunk,
|
updateThunk,
|
||||||
onCardClick,
|
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -42,7 +40,6 @@ const KanbanBoard = ({
|
|||||||
filtersQuery={filtersQuery}
|
filtersQuery={filtersQuery}
|
||||||
deleteThunk={deleteThunk}
|
deleteThunk={deleteThunk}
|
||||||
updateThunk={updateThunk}
|
updateThunk={updateThunk}
|
||||||
onCardClick={onCardClick}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
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';
|
||||||
@ -9,7 +10,6 @@ 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,7 +18,6 @@ const KanbanCard = ({
|
|||||||
showFieldName,
|
showFieldName,
|
||||||
setItemIdToDelete,
|
setItemIdToDelete,
|
||||||
column,
|
column,
|
||||||
onClick
|
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [{ isDragging }, drag] = useDrag(
|
const [{ isDragging }, drag] = useDrag(
|
||||||
() => ({
|
() => ({
|
||||||
@ -34,15 +33,17 @@ 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'}>
|
||||||
<div className={'text-base font-semibold'}>
|
<Link
|
||||||
|
href={`/${entityName}/${entityName}-view/?id=${item.id}`}
|
||||||
|
className={'text-base font-semibold'}
|
||||||
|
>
|
||||||
{item[showFieldName] ?? 'No data'}
|
{item[showFieldName] ?? 'No data'}
|
||||||
</div>
|
</Link>
|
||||||
</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>
|
||||||
|
|||||||
@ -15,7 +15,6 @@ 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 = {
|
||||||
@ -33,7 +32,6 @@ 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);
|
||||||
@ -186,7 +184,6 @@ const KanbanColumn = ({
|
|||||||
showFieldName={showFieldName}
|
showFieldName={showFieldName}
|
||||||
entityName={entityName}
|
entityName={entityName}
|
||||||
setItemIdToDelete={setItemIdToDelete}
|
setItemIdToDelete={setItemIdToDelete}
|
||||||
onClick={onCardClick}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -1,82 +0,0 @@
|
|||||||
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,7 +20,6 @@ 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
|
||||||
@ -33,8 +32,6 @@ 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[]>([]);
|
||||||
@ -92,7 +89,6 @@ const TableSampleParticipations = ({ filterItems, setFilterItems, filters, showG
|
|||||||
const handleModalAction = () => {
|
const handleModalAction = () => {
|
||||||
setIsModalInfoActive(false)
|
setIsModalInfoActive(false)
|
||||||
setIsModalTrashActive(false)
|
setIsModalTrashActive(false)
|
||||||
setIsReviewModalActive(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -130,16 +126,6 @@ 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) => {
|
||||||
@ -484,12 +470,6 @@ 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 && (
|
||||||
@ -501,7 +481,6 @@ const TableSampleParticipations = ({ filterItems, setFilterItems, filters, showG
|
|||||||
deleteThunk={deleteItem}
|
deleteThunk={deleteItem}
|
||||||
updateThunk={update}
|
updateThunk={update}
|
||||||
columns={kanbanColumns}
|
columns={kanbanColumns}
|
||||||
onCardClick={handleReviewModalAction}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,139 @@
|
|||||||
import { mdiViewDashboard, mdiCalendar, mdiCloudUpload, mdiAccountCircle } from '@mdi/js'
|
import * as icon from '@mdi/js';
|
||||||
|
import { MenuAsideItem } from './interfaces'
|
||||||
|
|
||||||
export const menuAside = [
|
const menuAside: MenuAsideItem[] = [
|
||||||
{ href: '/user-dashboard', label: 'Dashboard', icon: mdiViewDashboard },
|
{
|
||||||
{ href: '/calendar', label: 'Calendar', icon: mdiCalendar },
|
href: '/dashboard',
|
||||||
{ href: '/my-uploads', label: 'Мои загрузки', icon: mdiCloudUpload },
|
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 default menuAside
|
||||||
|
|||||||
@ -1,133 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,82 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,96 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,90 +0,0 @@
|
|||||||
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