Autosave: 20260315-194325

This commit is contained in:
Flatlogic Bot 2026-03-15 19:43:25 +00:00
parent f6c56d480c
commit 2100afc063
16 changed files with 820 additions and 162 deletions

View File

@ -1,4 +1,3 @@
const express = require('express');
const cors = require('cors');
const app = express();
@ -37,10 +36,14 @@ const categoriesRoutes = require('./routes/categories');
const activitiesRoutes = require('./routes/activities');
const activityRegistrationRoutes = require('./routes/activityRegistration');
const participationsRoutes = require('./routes/participations');
const uploadsRoutes = require('./routes/uploads');
const materialsRoutes = require('./routes/materials');
const notificationsRoutes = require('./routes/notifications');
const class_ratingsRoutes = require('./routes/class_ratings');
@ -123,10 +126,14 @@ app.use('/api/categories', passport.authenticate('jwt', {session: false}), categ
app.use('/api/activities', passport.authenticate('jwt', {session: false}), activitiesRoutes);
app.use('/api/activities', passport.authenticate('jwt', {session: false}), activityRegistrationRoutes);
app.use('/api/participations', passport.authenticate('jwt', {session: false}), participationsRoutes);
app.use('/api/uploads', passport.authenticate('jwt', {session: false}), uploadsRoutes);
app.use('/api/materials', passport.authenticate('jwt', {session: false}), materialsRoutes);
app.use('/api/notifications', passport.authenticate('jwt', {session: false}), notificationsRoutes);
app.use('/api/class_ratings', passport.authenticate('jwt', {session: false}), class_ratingsRoutes);

View 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;

View 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;

View File

@ -1,4 +1,3 @@
const express = require('express');
const ParticipationsService = require('../services/participations');
@ -91,6 +90,50 @@ router.post('/', wrapAsync(async (req, res) => {
res.status(200).send(payload);
}));
/**
* @swagger
* /api/participations/{id}/review:
* post:
* security:
* - bearerAuth: []
* tags: [Participations]
* summary: Review an activity participation
* description: Review an activity participation
* parameters:
* - in: path
* name: id
* description: Participation ID
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* comment:
* type: string
* points:
* type: number
* responses:
* 200:
* description: The participation was successfully reviewed
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 404:
* description: Item not found
* 500:
* description: Some server error
*/
router.post('/:id/review', wrapAsync(async (req, res) => {
await ParticipationsService.review(req.params.id, req.body, req.currentUser);
res.status(200).send({ success: true });
}));
/**
* @swagger
* /api/budgets/bulk-import:
@ -444,4 +487,4 @@ router.get('/:id', wrapAsync(async (req, res) => {
router.use('/', require('../helpers').commonErrorHandler);
module.exports = router;
module.exports = router;

View File

@ -1,15 +1,7 @@
const db = require('../db/models');
const ParticipationsDBApi = require('../db/api/participations');
const processFile = require("../middlewares/upload");
const ValidationError = require('./notifications/errors/validation');
const csv = require('csv-parser');
const axios = require('axios');
const config = require('../config');
const stream = require('stream');
const moment = require('moment');
module.exports = class ParticipationsService {
static async create(data, currentUser) {
@ -97,6 +89,93 @@ module.exports = class ParticipationsService {
}
};
static async review(id, data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
let participation = await ParticipationsDBApi.findBy(
{id},
{transaction},
);
if (!participation) {
throw new ValidationError(
'participationsNotFound',
);
}
const reviewData = {
participation_status: data.status,
organizer_comment: data.comment,
awarded_points: data.points,
reviewed_at: moment().toDate(),
};
const updatedParticipation = await ParticipationsDBApi.update(
id,
reviewData,
{
currentUser,
transaction,
},
);
if (data.status === 'accepted' && data.points > 0) {
// Find existing achievements for student
const studentId = participation.studentId || participation.student?.id;
if (studentId) {
// Find all accepted participations for this student to calculate total points
const allParticipations = await db.participations.findAll({
where: {
studentId,
participation_status: 'accepted'
},
include: [{
model: db.activities,
as: 'activity'
}],
transaction
});
const totalPoints = allParticipations.reduce((acc, p) => acc + (parseFloat(p.awarded_points) || 0), 0) + (parseFloat(data.points) || 0);
// Check if student earned new achievements
const allAchievements = await db.achievements.findAll({
where: { is_active: true },
transaction
});
for (const achievement of allAchievements) {
if (totalPoints >= achievement.points_threshold) {
const alreadyAwarded = await db.user_achievements.findOne({
where: {
userId: studentId,
achievementId: achievement.id
},
transaction
});
if (!alreadyAwarded) {
await db.user_achievements.create({
userId: studentId,
achievementId: achievement.id,
awarded_at: new Date(),
note: `Total points: ${totalPoints}`
}, { transaction });
}
}
}
}
}
await transaction.commit();
return updatedParticipation;
} catch (error) {
await transaction.rollback();
throw error;
}
};
static async deleteByIds(ids, currentUser) {
const transaction = await db.sequelize.transaction();
@ -134,5 +213,3 @@ module.exports = class ParticipationsService {
};

View File

@ -12,6 +12,7 @@ type Props = {
showFieldName: string;
deleteThunk: AsyncThunk<any, any, any>;
updateThunk: AsyncThunk<any, any, any>;
onCardClick?: (id: string) => void;
};
const KanbanBoard = ({
@ -22,6 +23,7 @@ const KanbanBoard = ({
showFieldName,
deleteThunk,
updateThunk,
onCardClick,
}: Props) => {
return (
<div
@ -40,6 +42,7 @@ const KanbanBoard = ({
filtersQuery={filtersQuery}
deleteThunk={deleteThunk}
updateThunk={updateThunk}
onCardClick={onCardClick}
/>
</div>
))}
@ -48,4 +51,4 @@ const KanbanBoard = ({
);
};
export default KanbanBoard;
export default KanbanBoard;

View File

@ -1,5 +1,4 @@
import React from 'react';
import Link from 'next/link';
import moment from 'moment';
import ListActionsPopover from '../ListActionsPopover';
import { DragSourceMonitor, useDrag } from 'react-dnd';
@ -10,6 +9,7 @@ type Props = {
entityName: string;
showFieldName: string;
setItemIdToDelete: (id: string) => void;
onClick?: (id: string) => void;
};
const KanbanCard = ({
@ -18,6 +18,7 @@ const KanbanCard = ({
showFieldName,
setItemIdToDelete,
column,
onClick
}: Props) => {
const [{ isDragging }, drag] = useDrag(
() => ({
@ -33,17 +34,15 @@ const KanbanCard = ({
return (
<div
ref={drag}
onClick={() => onClick && onClick(item.id)}
className={
`bg-gray-50 dark:bg-dark-800 rounded-md space-y-2 p-4 relative ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}`
}
>
<div className={'flex items-center justify-between'}>
<Link
href={`/${entityName}/${entityName}-view/?id=${item.id}`}
className={'text-base font-semibold'}
>
<div className={'text-base font-semibold'}>
{item[showFieldName] ?? 'No data'}
</Link>
</div>
</div>
<div className={'flex items-center justify-between'}>
<p>{moment(item.createdAt).format('MMM DD hh:mm a')}</p>
@ -61,4 +60,4 @@ const KanbanCard = ({
);
};
export default KanbanCard;
export default KanbanCard;

View File

@ -15,6 +15,7 @@ type Props = {
filtersQuery: any;
deleteThunk: AsyncThunk<any, any, any>;
updateThunk: AsyncThunk<any, any, any>;
onCardClick?: (id: string) => void;
};
type DropResult = {
@ -32,6 +33,7 @@ const KanbanColumn = ({
filtersQuery,
deleteThunk,
updateThunk,
onCardClick
}: Props) => {
const [currentPage, setCurrentPage] = useState(0);
const [count, setCount] = useState(0);
@ -184,6 +186,7 @@ const KanbanColumn = ({
showFieldName={showFieldName}
entityName={entityName}
setItemIdToDelete={setItemIdToDelete}
onClick={onCardClick}
/>
</div>
))}
@ -206,4 +209,4 @@ const KanbanColumn = ({
);
};
export default KanbanColumn;
export default KanbanColumn;

View File

@ -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;

View File

@ -20,6 +20,7 @@ import {dataGridStyles} from "../../styles";
import KanbanBoard from '../KanbanBoard/KanbanBoard';
import axios from 'axios';
import ParticipationReviewModal from './ParticipationReviewModal';
const perPage = 10
@ -32,6 +33,8 @@ const TableSampleParticipations = ({ filterItems, setFilterItems, filters, showG
const pagesList = [];
const [id, setId] = useState(null);
const [reviewId, setReviewId] = useState(null);
const [isReviewModalActive, setIsReviewModalActive] = useState(false);
const [currentPage, setCurrentPage] = useState(0);
const [filterRequest, setFilterRequest] = React.useState('');
const [columns, setColumns] = useState<GridColDef[]>([]);
@ -89,6 +92,7 @@ const TableSampleParticipations = ({ filterItems, setFilterItems, filters, showG
const handleModalAction = () => {
setIsModalInfoActive(false)
setIsModalTrashActive(false)
setIsReviewModalActive(false)
}
@ -126,6 +130,16 @@ const TableSampleParticipations = ({ filterItems, setFilterItems, filters, showG
}
};
const handleReviewModalAction = (id: string) => {
setReviewId(id);
setIsReviewModalActive(true);
}
const handleReviewConfirm = () => {
setIsReviewModalActive(false);
loadData(0);
}
const generateFilterRequests = useMemo(() => {
let request = '&';
filterItems.forEach((item) => {
@ -470,6 +484,12 @@ const TableSampleParticipations = ({ filterItems, setFilterItems, filters, showG
<p>Are you sure you want to delete this item?</p>
</CardBoxModal>
<ParticipationReviewModal
participationId={reviewId}
isActive={isReviewModalActive}
onConfirm={handleReviewConfirm}
onCancel={handleModalAction}
/>
{!showGrid && kanbanColumns && (
@ -481,6 +501,7 @@ const TableSampleParticipations = ({ filterItems, setFilterItems, filters, showG
deleteThunk={deleteItem}
updateThunk={update}
columns={kanbanColumns}
onCardClick={handleReviewModalAction}
/>
)}
@ -504,4 +525,4 @@ const TableSampleParticipations = ({ filterItems, setFilterItems, filters, showG
)
}
export default TableSampleParticipations
export default TableSampleParticipations

View File

@ -1,139 +1,7 @@
import * as icon from '@mdi/js';
import { MenuAsideItem } from './interfaces'
import { mdiViewDashboard, mdiCalendar, mdiCloudUpload, mdiAccountCircle } from '@mdi/js'
const menuAside: MenuAsideItem[] = [
{
href: '/dashboard',
icon: icon.mdiViewDashboardOutline,
label: 'Dashboard',
},
{
href: '/users/users-list',
label: 'Users',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: icon.mdiAccountGroup ?? icon.mdiTable,
permissions: 'READ_USERS'
},
{
href: '/roles/roles-list',
label: 'Roles',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiTable,
permissions: 'READ_ROLES'
},
{
href: '/permissions/permissions-list',
label: 'Permissions',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: icon.mdiShieldAccountOutline ?? icon.mdiTable,
permissions: 'READ_PERMISSIONS'
},
{
href: '/organizations/organizations-list',
label: 'Organizations',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_ORGANIZATIONS'
},
{
href: '/schools/schools-list',
label: 'Schools',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiSchool' in icon ? icon['mdiSchool' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_SCHOOLS'
},
{
href: '/categories/categories-list',
label: 'Categories',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiTagMultiple' in icon ? icon['mdiTagMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_CATEGORIES'
},
{
href: '/activities/activities-list',
label: 'Activities',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiCalendarStar' in icon ? icon['mdiCalendarStar' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_ACTIVITIES'
},
{
href: '/participations/participations-list',
label: 'Participations',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiAccountCheck' in icon ? icon['mdiAccountCheck' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_PARTICIPATIONS'
},
{
href: '/uploads/uploads-list',
label: 'Uploads',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiCloudUpload' in icon ? icon['mdiCloudUpload' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_UPLOADS'
},
{
href: '/notifications/notifications-list',
label: 'Notifications',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiBell' in icon ? icon['mdiBell' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_NOTIFICATIONS'
},
{
href: '/class_ratings/class_ratings-list',
label: 'Class ratings',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiTrophy' in icon ? icon['mdiTrophy' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_CLASS_RATINGS'
},
{
href: '/achievements/achievements-list',
label: 'Achievements',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiMedal' in icon ? icon['mdiMedal' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_ACHIEVEMENTS'
},
{
href: '/user_achievements/user_achievements-list',
label: 'User achievements',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiMedalOutline' in icon ? icon['mdiMedalOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_USER_ACHIEVEMENTS'
},
{
href: '/reports/reports-list',
label: 'Reports',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiFileChart' in icon ? icon['mdiFileChart' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_REPORTS'
},
{
href: '/profile',
label: 'Profile',
icon: icon.mdiAccountCircle,
},
{
href: '/api-docs',
target: '_blank',
label: 'Swagger API',
icon: icon.mdiFileCode,
permissions: 'READ_API_DOCS'
},
]
export default menuAside
export const menuAside = [
{ href: '/user-dashboard', label: 'Dashboard', icon: mdiViewDashboard },
{ href: '/calendar', label: 'Calendar', icon: mdiCalendar },
{ href: '/my-uploads', label: 'Мои загрузки', icon: mdiCloudUpload },
]

View 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

View 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

View 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;

View 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

View 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;