From db17572ea6cce2b675af844e0c8fbfdaf289e0fb Mon Sep 17 00:00:00 2001 From: Dobromir Popov Date: Thu, 2 May 2024 01:50:09 +0300 Subject: [PATCH 01/21] add statsFilters --- pages/cart/publishers/stats.tsx | 297 ++++++++++++++++---------------- 1 file changed, 144 insertions(+), 153 deletions(-) diff --git a/pages/cart/publishers/stats.tsx b/pages/cart/publishers/stats.tsx index 2e8b558..70d503d 100644 --- a/pages/cart/publishers/stats.tsx +++ b/pages/cart/publishers/stats.tsx @@ -1,62 +1,173 @@ -import { useState } from 'react'; +import { useState, useEffect, useRef } from 'react'; import Layout from "../../../components/layout"; import ProtectedRoute from '../../../components/protectedRoute'; -import { Prisma, UserRole } from '@prisma/client'; +import { Prisma, UserRole, PublisherType } from '@prisma/client'; import axiosServer from '../../../src/axiosServer'; import common from '../../../src/helpers/common'; // import { filterPublishers, /* other functions */ } from '../../api/index'; import data from '../../../src/helpers/data'; +import axiosInstance from '../../../src/axiosSecure'; +import { setFlagsFromString } from 'v8'; +import { set } from 'date-fns'; -// const data = require('../../src/helpers/data'); +function ContactsPage({ allPublishers }) { + + const currentMonth = new Date().getMonth(); + const [selectedMonth, setSelectedMonth] = useState(""); + const isMounted = useRef(false); -function ContactsPage({ publishers, allPublishers }) { const [searchQuery, setSearchQuery] = useState(''); + const [publisherType, setPublisherType] = useState(''); + const [publishers, setPublishers] = useState(allPublishers); + const [pubWithAssignmentsCount, setPubWithAssignmentsCount] = useState(0); + const [filteredPublishers, setFilteredPublishers] = useState(allPublishers); - const filteredPublishers = allPublishers.filter((publisher) => - publisher.firstName.toLowerCase().includes(searchQuery.toLowerCase()) || - publisher.lastName.toLowerCase().includes(searchQuery.toLowerCase()) || - publisher.email.toLowerCase().includes(searchQuery.toLowerCase()) || - publisher.phone?.toLowerCase().includes(searchQuery.toLowerCase()) - ); + const [sortField, setSortField] = useState('firstName'); + const [sortOrder, setSortOrder] = useState('asc'); + + const months = common.getMonthNames(); + const subsetMonths = Array.from({ length: 7 }, (_, i) => { + const monthIndex = (currentMonth - 3 + i + 12) % 12; // Adjust for year wrap-around + return { + name: months[monthIndex], + index: monthIndex + 1 + }; + }); + const datesOn15th = Array.from({ length: 7 }, (_, i) => new Date(new Date().getFullYear(), new Date().getMonth() - 3 + i, 15)) + .map(date => date.toISOString().split('T')[0]); + + + function handleSort(field) { + const order = sortField === field && sortOrder === 'asc' ? 'desc' : 'asc'; + setSortField(field); + setSortOrder(order); + } + + useEffect(() => { + let filtered = allPublishers.filter(publisher => + (publisher.firstName.toLowerCase().includes(searchQuery.toLowerCase()) || + publisher.lastName.toLowerCase().includes(searchQuery.toLowerCase()) || + publisher.email.toLowerCase().includes(searchQuery.toLowerCase()) || + (publisher.phone?.toLowerCase().includes(searchQuery.toLowerCase()))) && + (publisherType ? publisher.type === publisherType : true) + ); + + if (sortField) { + filtered.sort((a, b) => { + // Check for undefined or null values and treat them as "larger" when sorting ascending + const aValue = a[sortField] || 0; // Treat undefined, null as 0 + const bValue = b[sortField] || 0; // Treat undefined, null as 0 + + if (aValue === 0 && bValue !== 0) return 1; // aValue is falsy, push it to end if asc + if (bValue === 0 && aValue !== 0) return -1; // bValue is falsy, push it to end if asc + + if (aValue < bValue) return sortOrder === 'asc' ? -1 : 1; + if (aValue > bValue) return sortOrder === 'asc' ? 1 : -1; + return 0; + }); + } + setFilteredPublishers(filtered); + }, [searchQuery, publisherType, sortField, sortOrder, allPublishers]); + + useEffect(() => { + if (isMounted.current) { + const fetchData = async () => { + const month = parseInt(selectedMonth); + const filterDate = new Date(new Date().getFullYear(), month - 1, 15); + try { + const response = await axiosInstance.get(`/api/?action=getAllPublishersWithStatistics&date=${filterDate.toISOString()}`); + setPublishers(response.data); + setFilteredPublishers(response.data); + + setPubWithAssignmentsCount(response.data.filter(publisher => publisher.currentMonthAvailabilityHoursCount && publisher.currentMonthAvailabilityHoursCount > 0).length); + } catch (error) { + console.error('Failed to fetch publishers data:', error); + // Optionally, handle errors more gracefully here + } + }; + fetchData(); + } else { + // Set the ref to true after the initial render + isMounted.current = true; + } + }, [selectedMonth]); // Dependency array includes only selectedMonth + + function renderSortArrow(field) { + return sortField === field ? sortOrder === 'asc' ? ' ↑' : ' ↓' : ''; + } return (

Статистика

-
{publishers.length} участника с предпочитания за месеца (от {allPublishers.length} )
- setSearchQuery(e.target.value)} - className="border border-gray-300 rounded-md px-2 py-2 mb-4 w-full text-base md:text-sm" - /> +
{pubWithAssignmentsCount} участника с предпочитания за месеца (от {allPublishers.length} )
+
+ setSearchQuery(e.target.value)} + className="border border-gray-300 rounded-md px-2 py-2 text-base md:text-sm flex-grow mr-2" + /> + {/* Month dropdown */} + + {/* Publisher type dropdown */} + +
- - - - + + + + - {filteredPublishers.map((allPub) => { + {filteredPublishers.map((pub) => { // Find the publisher in the publishers collection to access statistics - const pub = publishers.find(publisher => publisher.id === allPub.id); + //const pub = publishers.find(publisher => publisher.id === allPub.id); return ( - - + + {/* Display statistics if publisher is found */} {pub ? ( <> - {filteredPublishers.map((pub) => { - // Find the publisher in the publishers collection to access statistics - //const pub = publishers.find(publisher => publisher.id === allPub.id); - + {filteredPublishers.map((pub, i) => { return ( - + {/* Display statistics if publisher is found */} {pub ? ( <> @@ -184,7 +181,6 @@ function ContactsPage({ allPublishers }) { ) : ( <>
ИмеВъзможностиУчастияПоследно влизане handleSort('firstName')}> + Име{renderSortArrow('firstName')} + handleSort('currentMonthAvailabilityDaysCount')}> + Възможности{renderSortArrow('currentMonthAvailabilityDaysCount')} + handleSort('currentMonthAssignments')}> + Участия{renderSortArrow('currentMonthAssignments')} + handleSort('lastLogin')}> + Последно влизане{renderSortArrow('lastLogin')} +
{allPub.firstName} {allPub.lastName}
{pub.firstName} {pub.lastName} - - {pub.currentMonthAvailabilityDaysCount || 0} | {pub.currentMonthAvailabilityHoursCount || 0} - + {pub.availabilities.length > 0 ? ( + + {pub.currentMonthAvailabilityDaysCount} | {pub.currentMonthAvailabilityHoursCount} + + ) : 0}
@@ -79,10 +190,10 @@ function ContactsPage({ publishers, allPublishers }) {
- {allPub.currentMonthAssignments || 0} + {pub.currentMonthAssignments} - {allPub.previousMonthAssignments || 0} + {pub.previousMonthAssignments}
@@ -106,132 +217,12 @@ function ContactsPage({ publishers, allPublishers }) { export default ContactsPage; -// Helper functions ToDo: move them to common and replace all implementations with the common ones -function countAssignments(assignments, startTime, endTime) { - return assignments.filter(assignment => - assignment.shift.startTime >= startTime && assignment.shift.startTime <= endTime - ).length; -} - -function convertShiftDates(assignments) { - assignments.forEach(assignment => { - if (assignment.shift && assignment.shift.startTime) { - assignment.shift.startTime = new Date(assignment.shift.startTime).toISOString(); - assignment.shift.endTime = new Date(assignment.shift.endTime).toISOString(); - } - }); -} export const getServerSideProps = async (context) => { - - const prisma = common.getPrismaClient(); - const dateStr = new Date().toISOString().split('T')[0]; - - let publishers = await data.filterPublishersNew('id,firstName,lastName,email,isActive,desiredShiftsPerMonth,lastLogin', dateStr, false, true, true, true, true); - - // const axios = await axiosServer(context); - // const { data: publishers } = await axios.get(`api/?action=filterPublishers&assignments=true&availabilities=true&date=${dateStr}&select=id,firstName,lastName,isActive,desiredShiftsPerMonth`); - - // api/index?action=filterPublishers&assignments=true&availabilities=true&date=2024-03-14&select=id%2CfirstName%2ClastName%2CisActive%2CdesiredShiftsPerMonth - publishers.forEach(publisher => { - publisher.desiredShiftsPerMonth = publisher.desiredShiftsPerMonth || 0; - publisher.assignments = publisher.assignments || []; - publisher.availabilities = publisher.availabilities || []; - publisher.lastUpdate = publisher.availabilities.reduce((acc, curr) => curr.dateOfEntry > acc ? curr.dateOfEntry : acc, null); - if (publisher.lastUpdate) { - publisher.lastUpdate = common.getDateFormated(publisher.lastUpdate); - } - else { - publisher.lastUpdate = "Няма данни"; - } - //serialize dates in publisher.assignments and publisher.availabilities - publisher.assignments.forEach(assignment => { - if (assignment.shift && assignment.shift.startTime) { - assignment.shift.startTime = assignment.shift.startTime.toISOString(); - assignment.shift.endTime = assignment.shift.endTime.toISOString(); - } - }); - publisher.availabilities.forEach(availability => { - if (availability.startTime) { - availability.startTime = availability.startTime.toISOString(); - availability.endTime = availability.endTime.toISOString(); - if (availability.dateOfEntry) { - availability.dateOfEntry = availability.dateOfEntry.toISOString(); - } - } - }); - publisher.lastLogin = publisher.lastLogin ? publisher.lastLogin.toISOString() : null; - //remove availabilities that isFromPreviousAssignment - publisher.availabilities = publisher.availabilities.filter(availability => !availability.isFromPreviousAssignment); - - - }); - //remove publishers without availabilities - publishers = publishers.filter(publisher => publisher.availabilities.length > 0); - - let allPublishers = await prisma.publisher.findMany({ - select: { - id: true, - firstName: true, - lastName: true, - email: true, - phone: true, - isActive: true, - desiredShiftsPerMonth: true, - lastLogin: true, - assignments: { - select: { - id: true, - shift: { - select: { - startTime: true, - endTime: true, - }, - }, - }, - }, - }, - }); - - - - - let monthInfo, - currentMonthStart, currentMonthEnd, - previousMonthStart, previousMonthEnd; - - monthInfo = common.getMonthDatesInfo(new Date()); - currentMonthStart = monthInfo.firstMonday; - currentMonthEnd = monthInfo.lastSunday; - let prevMnt = new Date(); - prevMnt.setMonth(prevMnt.getMonth() - 1); - monthInfo = common.getMonthDatesInfo(prevMnt); - previousMonthStart = monthInfo.firstMonday; - previousMonthEnd = monthInfo.lastSunday; - - - allPublishers.forEach(publisher => { - // Use helper functions to calculate and assign assignment counts - publisher.currentMonthAssignments = countAssignments(publisher.assignments, currentMonthStart, currentMonthEnd); - publisher.previousMonthAssignments = countAssignments(publisher.assignments, previousMonthStart, previousMonthEnd); - - publisher.lastLogin = publisher.lastLogin ? publisher.lastLogin.toISOString() : null; - // Convert date formats within the same iteration - convertShiftDates(publisher.assignments); - }); - - // Optionally, if you need a transformed list or additional properties, map the publishers - allPublishers = allPublishers.map(publisher => ({ - ...publisher, - // Potentially add more computed properties or transformations here if needed - })); - allPublishers.sort((a, b) => a.firstName.localeCompare(b.firstName) || a.lastName.localeCompare(b.lastName)); - - + const allPublishers = await data.getAllPublishersWithStatistics(new Date()); return { props: { - publishers, - allPublishers, + allPublishers }, }; }; From 17b8adbab893c3906c98b1f6f8a22b4d8b29ae96 Mon Sep 17 00:00:00 2001 From: Dobromir Popov Date: Fri, 3 May 2024 01:41:18 +0300 Subject: [PATCH 02/21] add numbers to stats list --- pages/cart/publishers/stats.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/pages/cart/publishers/stats.tsx b/pages/cart/publishers/stats.tsx index 70d503d..b5fa9a0 100644 --- a/pages/cart/publishers/stats.tsx +++ b/pages/cart/publishers/stats.tsx @@ -152,13 +152,10 @@ function ContactsPage({ allPublishers }) {
{pub.firstName} {pub.lastName}{i + 1}. {pub.firstName} {pub.lastName} -
From c7980f46bb35438d4a5b833e1e8f4437b5581bb6 Mon Sep 17 00:00:00 2001 From: Dobromir Popov Date: Sat, 4 May 2024 14:30:20 +0300 Subject: [PATCH 03/21] wip stats --- pages/api/index.ts | 3 ++- pages/cart/publishers/stats.tsx | 5 +++-- src/helpers/data.js | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/pages/api/index.ts b/pages/api/index.ts index 704e0bc..6dbe2d5 100644 --- a/pages/api/index.ts +++ b/pages/api/index.ts @@ -355,7 +355,8 @@ export default async function handler(req, res) { res.status(200).json(data); break; case "getAllPublishersWithStatistics": - res.status(200).json(await dataHelper.getAllPublishersWithStatistics(day)); + let noEndDate = common.parseBool(req.query.noEndDate); + res.status(200).json(await dataHelper.getAllPublishersWithStatistics(day, noEndDate)); default: res.status(200).json({ diff --git a/pages/cart/publishers/stats.tsx b/pages/cart/publishers/stats.tsx index b5fa9a0..38e58d4 100644 --- a/pages/cart/publishers/stats.tsx +++ b/pages/cart/publishers/stats.tsx @@ -67,6 +67,7 @@ function ContactsPage({ allPublishers }) { }); } setFilteredPublishers(filtered); + setPubWithAssignmentsCount(filtered.filter(publisher => publisher.currentMonthAvailabilityHoursCount && publisher.currentMonthAvailabilityHoursCount > 0).length); }, [searchQuery, publisherType, sortField, sortOrder, allPublishers]); useEffect(() => { @@ -75,7 +76,7 @@ function ContactsPage({ allPublishers }) { const month = parseInt(selectedMonth); const filterDate = new Date(new Date().getFullYear(), month - 1, 15); try { - const response = await axiosInstance.get(`/api/?action=getAllPublishersWithStatistics&date=${filterDate.toISOString()}`); + const response = await axiosInstance.get(`/api/?action=getAllPublishersWithStatistics&date=${filterDate.toISOString()}&noEndDate=false`); setPublishers(response.data); setFilteredPublishers(response.data); @@ -101,7 +102,7 @@ function ContactsPage({ allPublishers }) {

Статистика

-
{pubWithAssignmentsCount} участника с предпочитания за месеца (от {allPublishers.length} )
+
{pubWithAssignmentsCount} участника с предпочитания за месеца (от {filteredPublishers.length} )
Date: Sat, 11 May 2024 12:45:52 +0300 Subject: [PATCH 04/21] Added congregation table and field --- components/publisher/PublisherForm.js | 52 ++++----- pages/api/auth/[...nextauth].ts | 10 +- pages/auth/signin.tsx | 7 ++ pages/cart/locations/index.tsx | 2 + pages/cart/publishers/congregationCRUD.tsx | 103 ++++++++++++++++++ .../migration.sql | 33 ++++++ prisma/schema.prisma | 38 +++++-- 7 files changed, 199 insertions(+), 46 deletions(-) create mode 100644 pages/cart/publishers/congregationCRUD.tsx create mode 100644 prisma/migrations/20240510131656_publisher_congregation/migration.sql diff --git a/components/publisher/PublisherForm.js b/components/publisher/PublisherForm.js index e851008..1e39d46 100644 --- a/components/publisher/PublisherForm.js +++ b/components/publisher/PublisherForm.js @@ -19,34 +19,6 @@ import { useSession } from "next-auth/react" // import { Tabs, List } from 'tw-elements' -// model Publisher { -// id String @id @default(cuid()) -// firstName String -// lastName String -// email String @unique -// phone String? -// isActive Boolean @default(true) -// isImported Boolean @default(false) -// age Int? -// availabilities Availability[] -// assignments Assignment[] - -// emailVerified DateTime? -// accounts Account[] -// sessions Session[] -// role UserRole @default(USER) -// desiredShiftsPerMonth Int @default(4) -// isMale Boolean @default(true) -// isNameForeign Boolean @default(false) - -// familyHeadId String? // Optional familyHeadId for each family member -// familyHead Publisher? @relation("FamilyMember", fields: [familyHeadId], references: [id]) -// familyMembers Publisher[] @relation("FamilyMember") -// type PublisherType @default(Publisher) -// Town String? -// Comments String? -// } - Array.prototype.groupBy = function (prop) { return this.reduce(function (groups, item) { const val = item[prop] @@ -59,9 +31,11 @@ Array.prototype.groupBy = function (prop) { export default function PublisherForm({ item, me }) { const router = useRouter(); const { data: session } = useSession() + const [congregations, setCongregations] = useState([]); const urls = { apiUrl: "/api/data/publishers/", + congregationsUrl: "/api/data/congregations", indexUrl: session?.user?.role == UserRole.ADMIN ? "/cart/publishers" : "/dash" } console.log("urls.indexUrl: " + urls.indexUrl); @@ -72,6 +46,9 @@ export default function PublisherForm({ item, me }) { const h = (await import("../../src/helpers/const.js")).default; //console.log("fetchModules: " + JSON.stringify(h)); setHelper(h); + + const response = await axiosInstance.get(urls.congregationsUrl); + setCongregations(response.data); } useEffect(() => { fetchModules(); @@ -113,15 +90,17 @@ export default function PublisherForm({ item, me }) { publisher.availabilities = undefined; publisher.assignments = undefined; - let { familyHeadId, userId, ...rest } = publisher; + let { familyHeadId, userId, congregationId, ...rest } = publisher; // Set the familyHead relation based on the selected head const familyHeadRelation = familyHeadId ? { connect: { id: familyHeadId } } : { disconnect: true }; const userRel = userId ? { connect: { id: userId } } : { disconnect: true }; + const congregationRel = congregationId ? { connect: { id: parseInt(congregationId) } } : { disconnect: true }; // Return the new state without familyHeadId and with the correct familyHead relation rest = { ...rest, familyHead: familyHeadRelation, - user: userRel + user: userRel, + congregation: congregationRel }; try { @@ -246,6 +225,19 @@ export default function PublisherForm({ item, me }) {
+
+ + +
+ + {/* notifications */}
diff --git a/pages/api/auth/[...nextauth].ts b/pages/api/auth/[...nextauth].ts index 8cb4f4d..098b95b 100644 --- a/pages/api/auth/[...nextauth].ts +++ b/pages/api/auth/[...nextauth].ts @@ -56,11 +56,11 @@ export const authOptions: NextAuthOptions = { keyId: process.env.APPLE_KEY_ID, } }), - // AzureADProvider({ - // clientId: process.env.AZURE_AD_CLIENT_ID, - // clientSecret: process.env.AZURE_AD_CLIENT_SECRET, - // tenantId: process.env.AZURE_AD_TENANT_ID, - // }), + AzureADProvider({ + clientId: process.env.AZURE_AD_CLIENT_ID, + clientSecret: process.env.AZURE_AD_CLIENT_SECRET, + tenantId: process.env.AZURE_AD_TENANT_ID, + }), CredentialsProvider({ id: 'credentials', // The name to display on the sign in form (e.g. 'Sign in with...') diff --git a/pages/auth/signin.tsx b/pages/auth/signin.tsx index 9904a52..ceccfdd 100644 --- a/pages/auth/signin.tsx +++ b/pages/auth/signin.tsx @@ -74,6 +74,13 @@ export default function SignIn({ csrfToken }) { src="https://authjs.dev/img/providers/apple.svg" className="mr-2" /> Влез чрез Apple */} + {/* microsoft */} + {/* */}

diff --git a/pages/cart/locations/index.tsx b/pages/cart/locations/index.tsx index 716993a..e6b5b44 100644 --- a/pages/cart/locations/index.tsx +++ b/pages/cart/locations/index.tsx @@ -4,6 +4,7 @@ import Layout from "../../../components/layout"; import LocationCard from "../../../components/location/LocationCard"; import axiosServer from '../../../src/axiosServer'; import ProtectedRoute from '../../../components/protectedRoute'; +import CongregationCRUD from "../publishers/congregationCRUD"; interface IProps { item: Location; } @@ -32,6 +33,7 @@ function LocationsPage({ items = [] }: IProps) {
+ ); } diff --git a/pages/cart/publishers/congregationCRUD.tsx b/pages/cart/publishers/congregationCRUD.tsx new file mode 100644 index 0000000..95d0e43 --- /dev/null +++ b/pages/cart/publishers/congregationCRUD.tsx @@ -0,0 +1,103 @@ +// a simple CRUD componenet for congregations for admins + +import { useEffect, useState } from 'react'; +import axiosInstance from '../../../src/axiosSecure'; +import toast from 'react-hot-toast'; +import Layout from '../../../components/layout'; +import ProtectedRoute from '../../../components/protectedRoute'; +import { UserRole } from '@prisma/client'; +import { useRouter } from 'next/router'; + +export default function CongregationCRUD() { + const [congregations, setCongregations] = useState([]); + const [newCongregation, setNewCongregation] = useState(''); + const router = useRouter(); + + const fetchCongregations = async () => { + try { + const { data: congregationsData } = await axiosInstance.get(`/api/data/congregations`); + setCongregations(congregationsData); + } catch (error) { + console.error(error); + } + }; + + const addCongregation = async () => { + try { + await axiosInstance.post(`/api/data/congregations`, { name: newCongregation, address: "" }); + toast.success('Успешно добавен сбор'); + setNewCongregation(''); + fetchCongregations(); + } catch (error) { + console.error(error); + } + }; + + const deleteCongregation = async (id) => { + try { + await axiosInstance.delete(`/api/data/congregations/${id}`); + toast.success('Успешно изтрит сбор'); + fetchCongregations(); + } catch (error) { + console.error(error); + } + }; + useEffect(() => { + fetchCongregations(); + }, []); + + return ( + +
+
+

Сборове

+
+ setNewCongregation(e.target.value)} + placeholder="Име на сбор" + className="px-4 py-2 rounded-md border border-gray-300" + /> + +
+ + + + + + + + + {congregations.map((congregation) => ( + + + + + ))} + +
ИмеДействия
{congregation.name} + {/* */} + +
+
+
+
+ ); +} + diff --git a/prisma/migrations/20240510131656_publisher_congregation/migration.sql b/prisma/migrations/20240510131656_publisher_congregation/migration.sql new file mode 100644 index 0000000..133bedf --- /dev/null +++ b/prisma/migrations/20240510131656_publisher_congregation/migration.sql @@ -0,0 +1,33 @@ +-- AlterTable +ALTER TABLE `Assignment` +ADD COLUMN `originalPublisherId` VARCHAR(191) NULL; + +-- AlterTable +ALTER TABLE `Message` ADD COLUMN `publicUntil` DATETIME(3) NULL; + +-- AlterTable +ALTER TABLE `Publisher` +ADD COLUMN `congregationId` INTEGER NULL, +ADD COLUMN `locale` VARCHAR(191) NULL DEFAULT 'bg'; + +-- AlterTable +ALTER TABLE `Report` ADD COLUMN `comments` VARCHAR(191) NULL; + +-- CreateTable +CREATE TABLE `Congregation` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `name` VARCHAR(191) NOT NULL, + `address` VARCHAR(191) NOT NULL, + `isActive` BOOLEAN NOT NULL DEFAULT true, + + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `Publisher` +ADD CONSTRAINT `Publisher_congregationId_fkey` FOREIGN KEY (`congregationId`) REFERENCES `Congregation` (`id`) ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `Assignment` +ADD CONSTRAINT `Assignment_originalPublisherId_fkey` FOREIGN KEY (`originalPublisherId`) REFERENCES `Publisher` (`id`) ON DELETE SET NULL ON UPDATE CASCADE; \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 391079f..8341d0d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -124,6 +124,18 @@ model Publisher { EventLog EventLog[] lastLogin DateTime? pushSubscription Json? + originalAssignments Assignment[] @relation("OriginalPublisher") + congregation Congregation? @relation(fields: [congregationId], references: [id]) + congregationId Int? + locale String? @default("bg") +} + +model Congregation { + id Int @id @default(autoincrement()) + name String + address String + isActive Boolean @default(true) + publishers Publisher[] } model Availability { @@ -181,23 +193,25 @@ model Shift { //date DateTime reportId Int? @unique Report Report? @relation(fields: [reportId], references: [id]) - isPublished Boolean @default(false) //NEW v1.0.1 + isPublished Boolean @default(false) EventLog EventLog[] @@map("Shift") } model Assignment { - id Int @id @default(autoincrement()) - shift Shift @relation(fields: [shiftId], references: [id], onDelete: Cascade) - shiftId Int - publisher Publisher @relation(fields: [publisherId], references: [id], onDelete: Cascade) - publisherId String - isBySystem Boolean @default(false) // if no availability for it, when importing previous schedules - isConfirmed Boolean @default(false) - isWithTransport Boolean @default(false) - isMailSent Boolean @default(false) - publicGuid String? @unique + id Int @id @default(autoincrement()) + shift Shift @relation(fields: [shiftId], references: [id], onDelete: Cascade) + shiftId Int + publisher Publisher @relation(fields: [publisherId], references: [id], onDelete: Cascade) + publisherId String + isBySystem Boolean @default(false) // if no availability for it, when importing previous schedules + isConfirmed Boolean @default(false) + isWithTransport Boolean @default(false) + isMailSent Boolean @default(false) + publicGuid String? @unique + originalPublisherId String? // New field to store the original publisher id when the assignment is replaced + originalPublisher Publisher? @relation("OriginalPublisher", fields: [originalPublisherId], references: [id]) @@map("Assignment") } @@ -237,6 +251,7 @@ model Report { experienceInfo String? @db.LongText type ReportType @default(ServiceReport) + comments String? @@map("Report") } @@ -258,6 +273,7 @@ model Message { isRead Boolean @default(false) isPublic Boolean @default(false) type MessageType @default(Email) + publicUntil DateTime? } enum EventLogType { From 26af8382adff66b765161de6b1e36473d75ba0e9 Mon Sep 17 00:00:00 2001 From: Dobromir Popov Date: Sat, 11 May 2024 14:10:36 +0300 Subject: [PATCH 05/21] disable edits for published dates if not admin --- components/calendar/avcalendar.tsx | 22 +++++++++++++++++++--- pages/dash.tsx | 20 ++++++++++++++++++-- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/components/calendar/avcalendar.tsx b/components/calendar/avcalendar.tsx index d6497b5..cfac429 100644 --- a/components/calendar/avcalendar.tsx +++ b/components/calendar/avcalendar.tsx @@ -48,9 +48,19 @@ const messages = { // Any other labels you want to translate... }; -const AvCalendar = ({ publisherId, events, selectedDate, cartEvents }) => { - - const isAdmin = ProtectedRoute.IsInRole(UserRole.ADMIN); +const AvCalendar = ({ publisherId, events, selectedDate, cartEvents, lastPublishedDate }) => { + const [editLockedBefore, setEditLockedBefore] = useState(new Date(lastPublishedDate)); + const [isAdmin, setIsAdmin] = useState(false); + useEffect(() => { + (async () => { + try { + setIsAdmin(await ProtectedRoute.IsInRole(UserRole.ADMIN)); + } catch (error) { + console.error("Failed to check admin role:", error); + } + })(); + }, []); + //const isAdmin = ProtectedRoute.IsInRole(UserRole.ADMIN); const [date, setDate] = useState(new Date()); //ToDo: see if we can optimize this @@ -227,6 +237,12 @@ const AvCalendar = ({ publisherId, events, selectedDate, cartEvents }) => { //readonly for past dates (ToDo: if not admin) if (!isAdmin) { if (startdate < new Date() || end < new Date() || startdate > end) return; + //or if schedule is published (lastPublishedDate) + if (editLockedBefore && startdate < editLockedBefore) { + toast.error(`Не можете да променяте предпочитанията си за дати преди ${common.getDateFormatedShort(editLockedBefore)}.`, { autoClose: 5000 }); + return; + + } } // Check if start and end are on the same day if (startdate.toDateString() !== enddate.toDateString()) { diff --git a/pages/dash.tsx b/pages/dash.tsx index 951d9ca..53b3615 100644 --- a/pages/dash.tsx +++ b/pages/dash.tsx @@ -21,8 +21,10 @@ import CartEventForm from "components/cartevent/CartEventForm"; interface IProps { initialItems: Availability[]; initialUserId: string; + cartEvents: any; + lastPublishedDate: Date; } -export default function IndexPage({ initialItems, initialUserId, cartEvents }: IProps) { +export default function IndexPage({ initialItems, initialUserId, cartEvents, lastPublishedDate }: IProps) { const { data: session } = useSession(); const [userName, setUserName] = useState(session?.user?.name); const [userId, setUserId] = useState(initialUserId); @@ -79,7 +81,7 @@ export default function IndexPage({ initialItems, initialUserId, cartEvents }: I
- +
@@ -229,6 +231,7 @@ export const getServerSideProps = async (context) => { startTime: updatedItem.shift.startTime.toISOString(), endTime: updatedItem.shift.endTime.toISOString() }; + updatedItem.isPublished = updatedItem.shift.isPublished; } return updatedItem; @@ -239,6 +242,7 @@ export const getServerSideProps = async (context) => { console.log("First availability startTime: " + items[0]?.startTime); console.log("First availability startTime: " + items[0]?.startTime.toLocaleString()); + const prisma = common.getPrismaClient(); let cartEvents = await prisma.cartEvent.findMany({ where: { @@ -253,11 +257,23 @@ export const getServerSideProps = async (context) => { } }); cartEvents = common.convertDatesToISOStrings(cartEvents); + const lastPublishedDate = (await prisma.shift.findFirst({ + where: { + isPublished: true, + }, + select: { + endTime: true, + }, + orderBy: { + endTime: 'desc' + } + })).endTime; return { props: { initialItems: items, userId: sessionServer?.user.id, cartEvents: cartEvents, + lastPublishedDate: lastPublishedDate.toISOString(), // messages: (await import(`../content/i18n/${context.locale}.json`)).default }, }; From bf80e985de18b1b59aaf783884835e4dc4ee978e Mon Sep 17 00:00:00 2001 From: Dobromir Popov Date: Sat, 11 May 2024 14:25:49 +0300 Subject: [PATCH 06/21] using original Publisher when there is a replacement. log manual replacements. --- pages/api/email.ts | 1 + pages/api/index.ts | 63 +++++++++++++++++++++++++++++++++++++++++++- prisma/schema.prisma | 1 + 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/pages/api/email.ts b/pages/api/email.ts index b8c2161..14ee1de 100644 --- a/pages/api/email.ts +++ b/pages/api/email.ts @@ -106,6 +106,7 @@ export default async function handler(req, res) { }, data: { publisherId: userId, + originalPublisherId: originalPublisher.id, publicGuid: null, // if this exists, we consider the request open isConfirmed: true } diff --git a/pages/api/index.ts b/pages/api/index.ts index 736fdda..f928d8c 100644 --- a/pages/api/index.ts +++ b/pages/api/index.ts @@ -2,7 +2,7 @@ import { getToken } from "next-auth/jwt"; import { authOptions } from './auth/[...nextauth]' import { getServerSession } from "next-auth/next" import { NextApiRequest, NextApiResponse } from 'next' -import { DayOfWeek, AvailabilityType, UserRole } from '@prisma/client'; +import { DayOfWeek, AvailabilityType, UserRole, EventLogType } from '@prisma/client'; const common = require('../../src/helpers/common'); const dataHelper = require('../../src/helpers/data'); const subq = require('../../prisma/bl/subqueries'); @@ -11,6 +11,7 @@ import { addMinutes } from 'date-fns'; import fs from 'fs'; import path from 'path'; import { all } from "axios"; +import { logger } from "src/helpers/common"; /** * @@ -821,10 +822,70 @@ async function replaceInAssignment(oldPublisherId, newPublisherId, shiftId) { }, data: { publisherId: newPublisherId, + originalPublisherId: oldPublisherId, isConfirmed: false, isBySystem: true, isMailSent: false } }); + + // log the event + let shift = await prisma.shift.findUnique({ + where: { + id: shiftId + }, + select: { + startTime: true, + cartEvent: { + select: { + location: { + select: { + name: true + } + } + } + } + }, + include: { + assignments: { + include: { + publisher: { + select: { + firstName: true, + lastName: true, + email: true + } + } + } + } + } + }); + let publishers = await prisma.publisher.findMany({ + where: { + id: { in: [oldPublisherId, newPublisherId] } + }, + select: { + id: true, + firstName: true, + lastName: true, + email: true + } + }); + let originalPublisher = publishers.find(p => p.id == oldPublisherId); + let newPublisher = publishers.find(p => p.id == newPublisherId); + let eventLog = await prisma.eventLog.create({ + data: { + date: new Date(), + publisher: { connect: { id: oldPublisherId } }, + shift: { connect: { id: shiftId } }, + type: EventLogType.AssignmentReplacementManual, + content: "Въведено заместване от " + originalPublisher.firstName + " " + originalPublisher.lastName + ". Ще го замества " + newPublisher.firstName + " " + newPublisher.lastName + "." + + } + }); + + logger.info("User: " + originalPublisher.email + " replaced his assignment for " + shift.cartEvent.location.name + " " + shift.startTime.toISOString() + " with " + newPublisher.firstName + " " + newPublisher.lastName + "<" + newPublisher.email + ">. EventLogId: " + eventLog.id + ""); + + return result; } \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8341d0d..25f0496 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -277,6 +277,7 @@ model Message { } enum EventLogType { + AssignmentReplacementManual AssignmentReplacementRequested AssignmentReplacementAccepted SentEmail From d56ca612d4c862c2f8815bfdf8e850955eacdad7 Mon Sep 17 00:00:00 2001 From: Dobromir Popov Date: Sat, 11 May 2024 16:30:25 +0300 Subject: [PATCH 07/21] added new eventLog type --- components/publisher/PublisherForm.js | 2 +- .../migration.sql | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20240511112711_aded_enum_options/migration.sql diff --git a/components/publisher/PublisherForm.js b/components/publisher/PublisherForm.js index 1e39d46..fa81b47 100644 --- a/components/publisher/PublisherForm.js +++ b/components/publisher/PublisherForm.js @@ -227,7 +227,7 @@ export default function PublisherForm({ item, me }) {
- {congregations.map((congregation) => (
+ {/* language preference */} +
+ + +
From ffe68b492b3b7a9f777074b6587cd82ef7b5ebaa Mon Sep 17 00:00:00 2001 From: Dobromir Popov Date: Sat, 11 May 2024 17:48:42 +0300 Subject: [PATCH 10/21] fix migration (again) --- .../migrations/20240511112711_aded_enum_options/migration.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prisma/migrations/20240511112711_aded_enum_options/migration.sql b/prisma/migrations/20240511112711_aded_enum_options/migration.sql index c8d2dd1..2769eb0 100644 --- a/prisma/migrations/20240511112711_aded_enum_options/migration.sql +++ b/prisma/migrations/20240511112711_aded_enum_options/migration.sql @@ -1,5 +1,5 @@ -- AlterTable -ALTER TABLE `eventlog` +ALTER TABLE `EventLog` MODIFY `type` ENUM( 'AssignmentReplacementManual', 'AssignmentReplacementRequested', 'AssignmentReplacementAccepted', 'SentEmail', 'PasswordResetRequested', 'PasswordResetEmailConfirmed', 'PasswordResetCompleted' ) NOT NULL; From e29807540f0077e8a0565dbea736afb46c4137c7 Mon Sep 17 00:00:00 2001 From: Dobromir Popov Date: Sat, 11 May 2024 17:57:33 +0300 Subject: [PATCH 11/21] fix server session problem; optimize --- pages/api/auth/[...nextauth].ts | 17 ++----------- pages/cart/publishers/index.tsx | 36 +++++++++++++++++++++++++--- pages/cart/publishers/myschedule.tsx | 4 +++- 3 files changed, 38 insertions(+), 19 deletions(-) diff --git a/pages/api/auth/[...nextauth].ts b/pages/api/auth/[...nextauth].ts index 098b95b..6020c99 100644 --- a/pages/api/auth/[...nextauth].ts +++ b/pages/api/auth/[...nextauth].ts @@ -70,21 +70,6 @@ export const authOptions: NextAuthOptions = { password: { label: "Парола", type: "password" } }, async authorize(credentials, req) { - //const user = { id: "1", name: "Администратора", email: "jsmith@example.com" } - //return user - // const res = await fetch("/your/endpoint", { - // method: 'POST', - // body: JSON.stringify(credentials), - // headers: { "Content-Type": "application/json" } - // }) - // const user = await res.json() - - // // If no error and we have user data, return it - // if (res.ok && user) { - // return user - // } - // // Return null if user data could not be retrieved - // return null const users = [ { id: "1", name: "admin", email: "admin@example.com", password: "admin123", role: "ADMIN", static: true }, { id: "2", name: "krasi", email: "krasi@example.com", password: "krasi123", role: "ADMIN", static: true }, @@ -272,6 +257,8 @@ export const authOptions: NextAuthOptions = { verifyRequest: "/auth/verify-request", // (used for check email message) newUser: null // If set, new users will be directed here on first sign in }, + + debug: process.env.NODE_ENV === 'development', } export default NextAuth(authOptions) \ No newline at end of file diff --git a/pages/cart/publishers/index.tsx b/pages/cart/publishers/index.tsx index 896ec40..57d9d35 100644 --- a/pages/cart/publishers/index.tsx +++ b/pages/cart/publishers/index.tsx @@ -8,6 +8,7 @@ import Layout from "../../../components/layout"; import PublisherCard from "../../../components/publisher/PublisherCard"; import axiosInstance from "../../../src/axiosSecure"; import axiosServer from '../../../src/axiosServer'; +const common = require("../../../src/helpers/common"); import toast from "react-hot-toast"; import { levenshteinEditDistance } from "levenshtein-edit-distance"; @@ -226,9 +227,38 @@ export default PublishersPage; //import { set } from "date-fns"; export const getServerSideProps = async (context) => { - const axios = await axiosServer(context); - //ToDo: refactor all axios calls to use axiosInstance and this URL - const { data: publishers } = await axios.get('/api/data/publishers?select=id,firstName,lastName,email,isActive,isTrained,isImported,assignments.shift.startTime,availabilities.startTime&dev=fromuseefect'); + // const axios = await axiosServer(context); + // //ToDo: refactor all axios calls to use axiosInstance and this URL + // const { data: publishers } = await axios.get('/api/data/publishers?select=id,firstName,lastName,email,isActive,isTrained,isImported,assignments.shift.startTime,availabilities.startTime&dev=fromuseefect'); + //use prisma instead of axios + const prisma = common.getPrismaClient(); + let publishers = await prisma.publisher.findMany({ + select: { + id: true, + firstName: true, + lastName: true, + email: true, + isActive: true, + isTrained: true, + isImported: true, + assignments: { + select: { + shift: { + select: { + startTime: true, + }, + }, + }, + }, + availabilities: { + select: { + startTime: true, + }, + }, + } + }); + publishers = JSON.parse(JSON.stringify(publishers)); + return { props: { diff --git a/pages/cart/publishers/myschedule.tsx b/pages/cart/publishers/myschedule.tsx index 952d9aa..bda1015 100644 --- a/pages/cart/publishers/myschedule.tsx +++ b/pages/cart/publishers/myschedule.tsx @@ -14,6 +14,8 @@ import { useSession, getSession } from 'next-auth/react'; import axiosInstance from 'src/axiosSecure'; import { toast } from 'react-toastify'; import LocalShippingIcon from '@mui/icons-material/LocalShipping'; +import { getServerSession } from 'next-auth'; +import { authOptions } from 'pages/api/auth/[...nextauth]'; export default function MySchedulePage({ assignments }) { @@ -160,7 +162,7 @@ export default function MySchedulePage({ assignments }) { //get future assignments for the current user (session.user.id) export const getServerSideProps = async (context) => { - const session = await getSession(context); + const session = await getServerSession(context.req, context.res, authOptions) context.res.setHeader("Cache-Control", "s-maxage=1, stale-while-revalidate"); From e987b0028c496417b4ca7633a661e5b684c2bc87 Mon Sep 17 00:00:00 2001 From: Dobromir Popov Date: Sat, 11 May 2024 22:26:29 +0300 Subject: [PATCH 12/21] fix confirmation modal on top of other confirmation modal --- components/ConfirmationModal.tsx | 4 ++-- pages/api/index.ts | 10 ++++------ pages/cart/reports/coverMe.tsx | 32 +++++++++++++++++++++++++------- styles/styles.css | 22 ++++++++++++++++++++-- 4 files changed, 51 insertions(+), 17 deletions(-) diff --git a/components/ConfirmationModal.tsx b/components/ConfirmationModal.tsx index 787be29..1900155 100644 --- a/components/ConfirmationModal.tsx +++ b/components/ConfirmationModal.tsx @@ -5,8 +5,8 @@ export default function ConfirmationModal({ isOpen, onClose, onConfirm, message if (!isOpen) return null; return ( -
-
+
+

{message}