diff --git a/.vscode/launch.json b/.vscode/launch.json index ac9398c..ed8ac12 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -55,7 +55,7 @@ "request": "launch", "type": "node-terminal", "cwd": "${workspaceFolder}", - "command": "conda activate node && npm run start-env", + "command": "conda activate node && npm install && npm run start-env", "env": { "APP_ENV": "development.devserver" } diff --git a/_doc/ToDo.md b/_doc/ToDo.md index d111925..abcbef7 100644 --- a/_doc/ToDo.md +++ b/_doc/ToDo.md @@ -238,3 +238,6 @@ in schedule admin - if a publisher is always pair & family is not in the shift - [] new page to show EventLog (substitutions) [] fix "login as" [] list with open shift replacements (coverMe requests) +[] fix statistics +[] add notification to statistics info +[] fix logins (apple/azure) diff --git a/components/ConfirmationModal.tsx b/components/ConfirmationModal.tsx index 787be29..ddf646b 100644 --- a/components/ConfirmationModal.tsx +++ b/components/ConfirmationModal.tsx @@ -1,29 +1,29 @@ import zIndex from "@mui/material/styles/zIndex"; +import ReactDOM from 'react-dom'; export default function ConfirmationModal({ isOpen, onClose, onConfirm, message }) { //export default function ConfirmationModal({ isOpen, onClose, onConfirm, message }) if (!isOpen) return null; - return ( -
-
+ const modalContent = ( +
+

{message}

- -
); + + return ReactDOM.createPortal( + modalContent, + document.getElementById('modal-root') + ); }; // const CustomCalendar = ({ month, year, shifts }) => { // export default function CustomCalendar({ date, shifts }: CustomCalendarProps) { \ No newline at end of file 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/components/layout.tsx b/components/layout.tsx index 7c5639b..77f1073 100644 --- a/components/layout.tsx +++ b/components/layout.tsx @@ -63,6 +63,7 @@ export default function Layout({ children }) {
{children}
+ {/* Modal container */}
diff --git a/components/publisher/PublisherForm.js b/components/publisher/PublisherForm.js index e851008..51ae652 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 { @@ -242,10 +221,32 @@ export default function PublisherForm({ item, me }) { + {/* language preference */} +
+ + +
+
+ + +
+ + {/* notifications */}
diff --git a/components/publisher/PublisherSearchBox.js b/components/publisher/PublisherSearchBox.js index 7ad9925..18085c7 100644 --- a/components/publisher/PublisherSearchBox.js +++ b/components/publisher/PublisherSearchBox.js @@ -127,7 +127,7 @@ function PublisherSearchBox({ id, selectedId, onChange, isFocused, filterDate, s ) : null} {showList ? ( // Display only clickable list of all publishers -
+ ) : null + } + ); } diff --git a/pages/api/auth/[...nextauth].ts b/pages/api/auth/[...nextauth].ts index 8cb4f4d..6020c99 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...') @@ -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/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..330c407 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"; /** * @@ -360,7 +361,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({ @@ -821,10 +823,68 @@ 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 + }, + + include: { + cartEvent: { + select: { + location: { + select: { + name: true + } + } + } + }, + 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/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/pages/cart/publishers/index.tsx b/pages/cart/publishers/index.tsx index 896ec40..493fe3e 100644 --- a/pages/cart/publishers/index.tsx +++ b/pages/cart/publishers/index.tsx @@ -8,11 +8,13 @@ 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"; import ProtectedRoute from '../../../components/protectedRoute'; import ConfirmationModal from '../../../components/ConfirmationModal'; +import { relative } from "path"; @@ -164,7 +166,7 @@ function PublishersPage({ publishers = [] }: IProps) { return ( -
+
Добави вестител @@ -195,23 +197,24 @@ function PublishersPage({ publishers = [] }: IProps) {
-
- - - -