Merge commit 'dcf42df20bebc491183f2c069c65536853a84999' into production

This commit is contained in:
Dobromir Popov
2024-05-13 02:06:23 +03:00
23 changed files with 721 additions and 269 deletions

2
.vscode/launch.json vendored
View File

@ -55,7 +55,7 @@
"request": "launch", "request": "launch",
"type": "node-terminal", "type": "node-terminal",
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}",
"command": "conda activate node && npm run start-env", "command": "conda activate node && npm install && npm run start-env",
"env": { "env": {
"APP_ENV": "development.devserver" "APP_ENV": "development.devserver"
} }

View File

@ -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) [] new page to show EventLog (substitutions)
[] fix "login as" [] fix "login as"
[] list with open shift replacements (coverMe requests) [] list with open shift replacements (coverMe requests)
[] fix statistics
[] add notification to statistics info
[] fix logins (apple/azure)

View File

@ -1,29 +1,29 @@
import zIndex from "@mui/material/styles/zIndex"; 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 }) {
//export default function ConfirmationModal({ isOpen, onClose, onConfirm, message }) //export default function ConfirmationModal({ isOpen, onClose, onConfirm, message })
if (!isOpen) return null; if (!isOpen) return null;
return ( const modalContent = (
<div className="opacity-100 fixed inset-0 flex items-center justify-center z-1002" > <div className="modal-container opacity-100 inset-0 flex items-center justify-center z-1152">
<div className="bg-white p-4 rounded-md shadow-lg modal-content" style={{ zIndex: 1002 }}> <div className="bg-white p-4 rounded-md shadow-lg modal-content">
<p className="mb-4">{message}</p> <p className="mb-4">{message}</p>
<button <button className="bg-red-500 text-white px-4 py-2 rounded mr-2" onClick={onConfirm}>
className="bg-red-500 text-white px-4 py-2 rounded mr-2"
onClick={onConfirm}
>
Потвтрждавам Потвтрждавам
</button> </button>
<button <button className="bg-gray-300 px-4 py-2 rounded" onClick={onClose}>
className="bg-gray-300 px-4 py-2 rounded"
onClick={onClose}
>
Отказвам Отказвам
</button> </button>
</div> </div>
<div className="fixed inset-0 bg-black opacity-50 modal-overlay" onClick={onClose}></div> <div className="fixed inset-0 bg-black opacity-50 modal-overlay" onClick={onClose}></div>
</div> </div>
); );
return ReactDOM.createPortal(
modalContent,
document.getElementById('modal-root')
);
}; };
// const CustomCalendar = ({ month, year, shifts }) => { // const CustomCalendar = ({ month, year, shifts }) => {
// export default function CustomCalendar({ date, shifts }: CustomCalendarProps) { // export default function CustomCalendar({ date, shifts }: CustomCalendarProps) {

View File

@ -48,9 +48,19 @@ const messages = {
// Any other labels you want to translate... // Any other labels you want to translate...
}; };
const AvCalendar = ({ publisherId, events, selectedDate, cartEvents }) => { const AvCalendar = ({ publisherId, events, selectedDate, cartEvents, lastPublishedDate }) => {
const [editLockedBefore, setEditLockedBefore] = useState(new Date(lastPublishedDate));
const isAdmin = ProtectedRoute.IsInRole(UserRole.ADMIN); 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()); const [date, setDate] = useState(new Date());
//ToDo: see if we can optimize this //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) //readonly for past dates (ToDo: if not admin)
if (!isAdmin) { if (!isAdmin) {
if (startdate < new Date() || end < new Date() || startdate > end) return; 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 // Check if start and end are on the same day
if (startdate.toDateString() !== enddate.toDateString()) { if (startdate.toDateString() !== enddate.toDateString()) {

View File

@ -63,6 +63,7 @@ export default function Layout({ children }) {
<div className=""> <div className="">
{children} {children}
</div> </div>
<div id="modal-root"></div> {/* Modal container */}
</main> </main>
</div> </div>
</div> </div>

View File

@ -19,34 +19,6 @@ import { useSession } from "next-auth/react"
// import { Tabs, List } from 'tw-elements' // 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) { Array.prototype.groupBy = function (prop) {
return this.reduce(function (groups, item) { return this.reduce(function (groups, item) {
const val = item[prop] const val = item[prop]
@ -59,9 +31,11 @@ Array.prototype.groupBy = function (prop) {
export default function PublisherForm({ item, me }) { export default function PublisherForm({ item, me }) {
const router = useRouter(); const router = useRouter();
const { data: session } = useSession() const { data: session } = useSession()
const [congregations, setCongregations] = useState([]);
const urls = { const urls = {
apiUrl: "/api/data/publishers/", apiUrl: "/api/data/publishers/",
congregationsUrl: "/api/data/congregations",
indexUrl: session?.user?.role == UserRole.ADMIN ? "/cart/publishers" : "/dash" indexUrl: session?.user?.role == UserRole.ADMIN ? "/cart/publishers" : "/dash"
} }
console.log("urls.indexUrl: " + urls.indexUrl); 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; const h = (await import("../../src/helpers/const.js")).default;
//console.log("fetchModules: " + JSON.stringify(h)); //console.log("fetchModules: " + JSON.stringify(h));
setHelper(h); setHelper(h);
const response = await axiosInstance.get(urls.congregationsUrl);
setCongregations(response.data);
} }
useEffect(() => { useEffect(() => {
fetchModules(); fetchModules();
@ -113,15 +90,17 @@ export default function PublisherForm({ item, me }) {
publisher.availabilities = undefined; publisher.availabilities = undefined;
publisher.assignments = undefined; publisher.assignments = undefined;
let { familyHeadId, userId, ...rest } = publisher; let { familyHeadId, userId, congregationId, ...rest } = publisher;
// Set the familyHead relation based on the selected head // Set the familyHead relation based on the selected head
const familyHeadRelation = familyHeadId ? { connect: { id: familyHeadId } } : { disconnect: true }; const familyHeadRelation = familyHeadId ? { connect: { id: familyHeadId } } : { disconnect: true };
const userRel = userId ? { connect: { id: userId } } : { 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 // Return the new state without familyHeadId and with the correct familyHead relation
rest = { rest = {
...rest, ...rest,
familyHead: familyHeadRelation, familyHead: familyHeadRelation,
user: userRel user: userRel,
congregation: congregationRel
}; };
try { try {
@ -242,10 +221,32 @@ export default function PublisherForm({ item, me }) {
</div> </div>
</div> </div>
</div> </div>
{/* language preference */}
<div className="mb-4">
<label className="label" htmlFor="locale">Език (в разработка)</label>
<select id="locale" name="locale" value={publisher.locale} onChange={handleChange} className="select textbox" placeholder="Език" autoFocus >
<option value="bg" >Български</option>
<option value="en">English</option>
<option value="ru">Руски</option>
</select>
</div>
<div className="mb-4"> <div className="mb-4">
<label className="label" htmlFor="town">Град</label> <label className="label" htmlFor="town">Град</label>
<input type="text" id="town" name="town" value={publisher.town} onChange={handleChange} className="textbox" placeholder="Град" autoFocus /> <input type="text" id="town" name="town" value={publisher.town} onChange={handleChange} className="textbox" placeholder="Град" autoFocus />
</div> </div>
<div className="mb-4">
<label className="label" htmlFor="congregationId">Сбор</label>
<select id="congregationId" name="congregationId" value={publisher.congregationId} onChange={handleChange} className="select textbox" placeholder="Община" autoFocus >
<option value="">Избери сбор</option>
{congregations.map((congregation) => (
<option key={congregation.id} value={congregation.id}>
{congregation.name}
</option>
))}
</select>
</div>
{/* notifications */} {/* notifications */}
<div className="mb-6 p-4 border border-gray-300 rounded-lg"> <div className="mb-6 p-4 border border-gray-300 rounded-lg">

View File

@ -127,7 +127,7 @@ function PublisherSearchBox({ id, selectedId, onChange, isFocused, filterDate, s
) : null} ) : null}
{showList ? ( {showList ? (
// Display only clickable list of all publishers // Display only clickable list of all publishers
<ul className="absolute bg-white border border-gray-300 w-full z-10"> <ul className="absolute bg-white border border-gray-300 w-full z-10 overflow-y-auto">
{publishers.map((publisher) => ( {publishers.map((publisher) => (
<li key={publisher.id} <li key={publisher.id}
className="p-2 cursor-pointer hover:bg-gray-200" className="p-2 cursor-pointer hover:bg-gray-200"
@ -136,8 +136,9 @@ function PublisherSearchBox({ id, selectedId, onChange, isFocused, filterDate, s
</li> </li>
))} ))}
</ul> </ul>
) : null} ) : null
</div> }
</div >
); );
} }

View File

@ -56,11 +56,11 @@ export const authOptions: NextAuthOptions = {
keyId: process.env.APPLE_KEY_ID, keyId: process.env.APPLE_KEY_ID,
} }
}), }),
// AzureADProvider({ AzureADProvider({
// clientId: process.env.AZURE_AD_CLIENT_ID, clientId: process.env.AZURE_AD_CLIENT_ID,
// clientSecret: process.env.AZURE_AD_CLIENT_SECRET, clientSecret: process.env.AZURE_AD_CLIENT_SECRET,
// tenantId: process.env.AZURE_AD_TENANT_ID, tenantId: process.env.AZURE_AD_TENANT_ID,
// }), }),
CredentialsProvider({ CredentialsProvider({
id: 'credentials', id: 'credentials',
// The name to display on the sign in form (e.g. 'Sign in with...') // 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" } password: { label: "Парола", type: "password" }
}, },
async authorize(credentials, req) { 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 = [ const users = [
{ id: "1", name: "admin", email: "admin@example.com", password: "admin123", role: "ADMIN", static: true }, { 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 }, { 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) verifyRequest: "/auth/verify-request", // (used for check email message)
newUser: null // If set, new users will be directed here on first sign in newUser: null // If set, new users will be directed here on first sign in
}, },
debug: process.env.NODE_ENV === 'development',
} }
export default NextAuth(authOptions) export default NextAuth(authOptions)

View File

@ -106,6 +106,7 @@ export default async function handler(req, res) {
}, },
data: { data: {
publisherId: userId, publisherId: userId,
originalPublisherId: originalPublisher.id,
publicGuid: null, // if this exists, we consider the request open publicGuid: null, // if this exists, we consider the request open
isConfirmed: true isConfirmed: true
} }

View File

@ -2,7 +2,7 @@ import { getToken } from "next-auth/jwt";
import { authOptions } from './auth/[...nextauth]' import { authOptions } from './auth/[...nextauth]'
import { getServerSession } from "next-auth/next" import { getServerSession } from "next-auth/next"
import { NextApiRequest, NextApiResponse } from '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 common = require('../../src/helpers/common');
const dataHelper = require('../../src/helpers/data'); const dataHelper = require('../../src/helpers/data');
const subq = require('../../prisma/bl/subqueries'); const subq = require('../../prisma/bl/subqueries');
@ -11,6 +11,7 @@ import { addMinutes } from 'date-fns';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { all } from "axios"; 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); res.status(200).json(data);
break; break;
case "getAllPublishersWithStatistics": 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: default:
res.status(200).json({ res.status(200).json({
@ -821,10 +823,68 @@ async function replaceInAssignment(oldPublisherId, newPublisherId, shiftId) {
}, },
data: { data: {
publisherId: newPublisherId, publisherId: newPublisherId,
originalPublisherId: oldPublisherId,
isConfirmed: false, isConfirmed: false,
isBySystem: true, isBySystem: true,
isMailSent: false 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; return result;
} }

View File

@ -74,6 +74,13 @@ export default function SignIn({ csrfToken }) {
src="https://authjs.dev/img/providers/apple.svg" className="mr-2" /> src="https://authjs.dev/img/providers/apple.svg" className="mr-2" />
Влез чрез Apple Влез чрез Apple
</button> */} </button> */}
{/* microsoft */}
{/* <button onClick={() => signIn('azure-ad', { callbackUrl: '/' })}
className="mt-4 flex items-center justify-center w-full py-3 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50">
<img loading="lazy" height="24" width="24" alt="Microsoft logo"
src="https://authjs.dev/img/providers/azure-ad.svg" className="mr-2" />
Влез чрез Microsoft
</button> */}
</div> </div>
<div className="w-full max-w-xs mt-8 mb-8"> <div className="w-full max-w-xs mt-8 mb-8">
<hr className="border-t border-gray-300" /> <hr className="border-t border-gray-300" />

View File

@ -4,6 +4,7 @@ import Layout from "../../../components/layout";
import LocationCard from "../../../components/location/LocationCard"; import LocationCard from "../../../components/location/LocationCard";
import axiosServer from '../../../src/axiosServer'; import axiosServer from '../../../src/axiosServer';
import ProtectedRoute from '../../../components/protectedRoute'; import ProtectedRoute from '../../../components/protectedRoute';
import CongregationCRUD from "../publishers/congregationCRUD";
interface IProps { interface IProps {
item: Location; item: Location;
} }
@ -32,6 +33,7 @@ function LocationsPage({ items = [] }: IProps) {
</a> </a>
</div> </div>
</ProtectedRoute> </ProtectedRoute>
<CongregationCRUD />
</Layout> </Layout>
); );
} }

View File

@ -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 (
<ProtectedRoute allowedRoles={[UserRole.ADMIN]}>
<div className="h-5/6 grid place-items-start px-4 pt-8">
<div className="flex flex-col w-full px-4">
<h1 className="text-2xl font-bold text-center">Сборове</h1>
<div className="flex gap-2 mb-4">
<input
type="text"
value={newCongregation}
onChange={(e) => setNewCongregation(e.target.value)}
placeholder="Име на сбор"
className="px-4 py-2 rounded-md border border-gray-300"
/>
<button
onClick={addCongregation}
className="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded"
>
Добави
</button>
</div>
<table className="w-full">
<thead>
<tr>
<th>Име</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{congregations.map((congregation) => (
<tr key={congregation.id}>
<td>{congregation.name}</td>
<td className='right'>
{/* <button
onClick={() => router.push(`/cart/publishers/congregation/${congregation.id}`)}
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
>
Преглед
</button> */}
<button
onClick={() => deleteCongregation(congregation.id)}
className="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded"
>
Изтрий
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</ProtectedRoute>
);
}

View File

@ -8,11 +8,13 @@ import Layout from "../../../components/layout";
import PublisherCard from "../../../components/publisher/PublisherCard"; import PublisherCard from "../../../components/publisher/PublisherCard";
import axiosInstance from "../../../src/axiosSecure"; import axiosInstance from "../../../src/axiosSecure";
import axiosServer from '../../../src/axiosServer'; import axiosServer from '../../../src/axiosServer';
const common = require("../../../src/helpers/common");
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { levenshteinEditDistance } from "levenshtein-edit-distance"; import { levenshteinEditDistance } from "levenshtein-edit-distance";
import ProtectedRoute from '../../../components/protectedRoute'; import ProtectedRoute from '../../../components/protectedRoute';
import ConfirmationModal from '../../../components/ConfirmationModal'; import ConfirmationModal from '../../../components/ConfirmationModal';
import { relative } from "path";
@ -164,7 +166,7 @@ function PublishersPage({ publishers = [] }: IProps) {
return ( return (
<Layout> <Layout>
<ProtectedRoute allowedRoles={[UserRole.ADMIN, UserRole.POWERUSER]}> <ProtectedRoute allowedRoles={[UserRole.ADMIN, UserRole.POWERUSER]}>
<div className=""> <div className="mx-auto ">
<div className="flex items-center justify-center space-x-4 m-4"> <div className="flex items-center justify-center space-x-4 m-4">
<div className="flex justify-center m-4"> <div className="flex justify-center m-4">
<a href="/cart/publishers/new" className="btn">Добави вестител</a> <a href="/cart/publishers/new" className="btn">Добави вестител</a>
@ -195,23 +197,24 @@ function PublishersPage({ publishers = [] }: IProps) {
</div> </div>
</div> </div>
<div name="filters" className="flex items-center justify-center space-x-4 m-4 sticky top-4 z-10 bg-gray-100 p-2"> <div className="z-60 sticky top-0" style={{ zIndex: 60, position: relative }}>
<label htmlFor="filter">Filter:</label> <div name="filters" className="flex items-center justify-center space-x-4 m-4 bg-gray-100 p-2" >
<input type="text" id="filter" name="filter" value={filter} onChange={handleFilterChange} <label htmlFor="filter">Filter:</label>
className="border border-gray-300 rounded-md px-2 py-1" <input type="text" id="filter" name="filter" value={filter} onChange={handleFilterChange}
/> className="border border-gray-300 rounded-md px-2 py-1"
<label htmlFor="zeroShiftsOnly" className="ml-4 inline-flex items-center">
<input type="checkbox" id="zeroShiftsOnly" checked={showZeroShiftsOnly}
onChange={e => setShowZeroShiftsOnly(e.target.checked)}
className="form-checkbox text-indigo-600"
/> />
<span className="ml-2">само без смени</span>
</label>
<span id="filter-info" className="ml-4">{publishers.length} от {publishers.length} вестителя</span> <label htmlFor="zeroShiftsOnly" className="ml-4 inline-flex items-center">
<input type="checkbox" id="zeroShiftsOnly" checked={showZeroShiftsOnly}
onChange={e => setShowZeroShiftsOnly(e.target.checked)}
className="form-checkbox text-indigo-600"
/>
<span className="ml-2">само без смени</span>
</label>
<span id="filter-info" className="ml-4">{publishers.length} от {publishers.length} вестителя</span>
</div>
</div> </div>
<div className="grid gap-4 grid-cols-1 md:grid-cols-4 z-0"> <div className="grid gap-4 grid-cols-1 md:grid-cols-4 z-0">
{renderPublishers()} {renderPublishers()}
</div> </div>
@ -226,9 +229,38 @@ export default PublishersPage;
//import { set } from "date-fns"; //import { set } from "date-fns";
export const getServerSideProps = async (context) => { export const getServerSideProps = async (context) => {
const axios = await axiosServer(context); // const axios = await axiosServer(context);
//ToDo: refactor all axios calls to use axiosInstance and this URL // //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 { 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 { return {
props: { props: {

View File

@ -14,6 +14,8 @@ import { useSession, getSession } from 'next-auth/react';
import axiosInstance from 'src/axiosSecure'; import axiosInstance from 'src/axiosSecure';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import LocalShippingIcon from '@mui/icons-material/LocalShipping'; import LocalShippingIcon from '@mui/icons-material/LocalShipping';
import { getServerSession } from 'next-auth';
import { authOptions } from 'pages/api/auth/[...nextauth]';
export default function MySchedulePage({ assignments }) { export default function MySchedulePage({ assignments }) {
@ -160,7 +162,7 @@ export default function MySchedulePage({ assignments }) {
//get future assignments for the current user (session.user.id) //get future assignments for the current user (session.user.id)
export const getServerSideProps = async (context) => { 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"); context.res.setHeader("Cache-Control", "s-maxage=1, stale-while-revalidate");

View File

@ -1,62 +1,262 @@
import { useState } from 'react'; import { useState, useEffect, useRef } from 'react';
import Layout from "../../../components/layout"; import Layout from "../../../components/layout";
import ProtectedRoute from '../../../components/protectedRoute'; import ProtectedRoute from '../../../components/protectedRoute';
import { Prisma, UserRole } from '@prisma/client'; import { Prisma, UserRole, PublisherType } from '@prisma/client';
import axiosServer from '../../../src/axiosServer'; import axiosServer from '../../../src/axiosServer';
import common from '../../../src/helpers/common'; import common from '../../../src/helpers/common';
// import { filterPublishers, /* other functions */ } from '../../api/index'; // import { filterPublishers, /* other functions */ } from '../../api/index';
import data from '../../../src/helpers/data'; import data from '../../../src/helpers/data';
import axiosInstance from '../../../src/axiosSecure';
import { setFlagsFromString } from 'v8';
import { set } from 'date-fns';
function ContactsPage({ allPublishers }) {
const currentMonth = new Date().getMonth();
const [selectedMonth, setSelectedMonth] = useState(currentMonth + 1);
const isMounted = useRef(false);
// const data = require('../../src/helpers/data');
function ContactsPage({ publishers, allPublishers }) {
const [searchQuery, setSearchQuery] = useState(''); 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) => const [sortField, setSortField] = useState('name');
publisher.firstName.toLowerCase().includes(searchQuery.toLowerCase()) || const [sortOrder, setSortOrder] = useState('asc');
publisher.lastName.toLowerCase().includes(searchQuery.toLowerCase()) ||
publisher.email.toLowerCase().includes(searchQuery.toLowerCase()) || const months = common.getMonthNames();
publisher.phone?.toLowerCase().includes(searchQuery.toLowerCase()) const subsetMonths = Array.from({ length: 9 }, (_, i) => {
); const monthIndex = (currentMonth - 3 + i + 12) % 12; // Adjust for year wrap-around
return {
name: months[monthIndex],
index: monthIndex + 1
};
});
const [hideEmptyFields, setHideEmptyFields] = useState({
availability: 'off', // 'on', 'off', 'onlyEmpty'
assignments: 'off', // 'on', 'off', 'onlyEmpty'
lastLogin: false,
notifiications: false
});
const availabilityRef = useRef(null);
const assignmentsRef = useRef(null);
useEffect(() => {
if (availabilityRef.current) {
availabilityRef.current.indeterminate = hideEmptyFields.availability === 'off';
}
}, [hideEmptyFields.availability]);
useEffect(() => {
if (assignmentsRef.current) {
assignmentsRef.current.indeterminate = hideEmptyFields.assignments === 'off';
}
}, [hideEmptyFields.assignments]);
const getCheckboxState = (field) => {
switch (hideEmptyFields[field]) {
case 'on':
return true;
case 'onlyEmpty':
return false;
default:
return undefined; // this will be used to set indeterminate
}
};
const getCheckboxTooltip = (field, label) => {
switch (hideEmptyFields[field]) {
case 'on':
return 'Само със ' + label;
case 'onlyEmpty':
return 'Само без ' + label;
default:
return 'Всички ' + label;
}
}
function handleSort(field) {
const order = sortField === field && sortOrder === 'asc' ? 'desc' : 'asc';
setSortField(field);
setSortOrder(order);
}
useEffect(() => {
let filtered = publishers.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)
// && (!hideEmptyFields.availability || publisher.currentMonthAvailabilityDaysCount > 0)
// && (!hideEmptyFields.assignments || publisher.currentMonthAssignments > 0)
&& (hideEmptyFields.availability === 'on' ? publisher.currentMonthAvailabilityDaysCount > 0 :
hideEmptyFields.availability === 'onlyEmpty' ? !publisher.currentMonthAvailabilityDaysCount || publisher.currentMonthAvailabilityDaysCount === 0 : true)
&& (hideEmptyFields.assignments === 'on' ? publisher.currentMonthAssignments > 0 :
hideEmptyFields.assignments === 'onlyEmpty' ? publisher.currentMonthAssignments === 0 : true)
&& (!hideEmptyFields.lastLogin || publisher.lastLogin)
&& (!hideEmptyFields.notifiications || publisher.isPushActive)
);
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);
setPubWithAssignmentsCount(filtered.filter(publisher => publisher.currentMonthAvailabilityHoursCount && publisher.currentMonthAvailabilityHoursCount > 0).length);
}, [searchQuery, publisherType, sortField, sortOrder, allPublishers, hideEmptyFields, selectedMonth]);
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()}&noEndDate=false`);
setPublishers(response.data);
setFilteredPublishers(response.data);
setPubWithAssignmentsCount(response.data.filter(publisher => publisher.currentMonthAvailabilityHoursCount && publisher.currentMonthAvailabilityHoursCount > 0).length);
setHideEmptyFields({ availability: 'off', assignments: 'off', lastLogin: false, notifiications: false })
} 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 ( return (
<Layout> <Layout>
<ProtectedRoute allowedRoles={[UserRole.ADMIN, UserRole.POWERUSER]}> <ProtectedRoute allowedRoles={[UserRole.ADMIN, UserRole.POWERUSER]}>
<div className="container mx-auto p-4"> <div className="container mx-auto p-4">
<h1 className="text-xl font-semibold mb-4">Статистика </h1> <h1 className="text-xl font-semibold mb-4">Статистика </h1>
<h5 className="text-lg font-semibold mb-4">{publishers.length} участника с предпочитания за месеца (от {allPublishers.length} )</h5> <h5 className="text-lg font-semibold mb-4">{pubWithAssignmentsCount} участника с предпочитания за месеца (от {filteredPublishers.length} )</h5>
<input <div className="mb-4 flex justify-between items-center">
type="text" <input name="filterText"
placeholder="Търси по име, имейл или телефон..." type="text"
value={searchQuery} placeholder="Търси по име, имейл или телефон..."
onChange={(e) => setSearchQuery(e.target.value)} value={searchQuery}
className="border border-gray-300 rounded-md px-2 py-2 mb-4 w-full text-base md:text-sm" onChange={(e) => 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 */}
<select name="filterMonth"
className="border border-gray-300 rounded-md px-2 py-2 text-base md:text-sm"
value={selectedMonth}
onChange={(e) => setSelectedMonth(e.target.value)}
>
<option value="">избери месец</option>
{subsetMonths.map((month) => (
<option key={month.index} value={month.index}>{month.name}</option>
))}
</select>
{/* Publisher type dropdown */}
<select name="filterType"
className="border border-gray-300 rounded-md px-2 py-2 text-base md:text-sm"
value={publisherType}
onChange={(e) => setPublisherType(e.target.value)}
>
<option value="">Всички типове</option>
{Object.keys(PublisherType).map((type) => (
<option key={type} value={PublisherType[type]}>{PublisherType[type]}</option>
))}
</select>
</div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-left border-collapse"> <table className="w-full text-left border-collapse">
<thead> <thead>
<tr> <tr>
<th className="border-b font-medium p-4 pl-8 pt-0 pb-3">Име</th> <th className="border-b font-medium p-4 pl-8 pt-0 pb-3 cursor-pointer" onClick={() => handleSort('name')}>
<th className="border-b font-medium p-4 pt-0 pb-3">Възможности</th> Име{renderSortArrow('name')}
<th className="border-b font-medium p-4 pt-0 pb-3">Участия</th> </th>
<th className="border-b font-medium p-4 pt-0 pb-3">Последно влизане</th> <th className="border-b font-medium p-4 pt-0 pb-3 cursor-pointer" >
<p className="inline-block" onClick={() => handleSort('currentMonthAvailabilityDaysCount')}>
Възможности{renderSortArrow('currentMonthAvailabilityDaysCount')}
</p>
<input
type="checkbox" className='ml-2'
ref={availabilityRef}
checked={getCheckboxState('availability') ?? false}
onChange={() => {
const newState = hideEmptyFields.availability === 'on' ? 'onlyEmpty' : (hideEmptyFields.availability === 'onlyEmpty' ? 'off' : 'on');
setHideEmptyFields({ ...hideEmptyFields, availability: newState });
}}
title={getCheckboxTooltip('availability', "възможности")}
/>
</th>
<th className="border-b font-medium p-4 pt-0 pb-3 cursor-pointer" >
<p className="inline-block" onClick={() => handleSort('currentMonthAssignments')}>
Участия{renderSortArrow('currentMonthAssignments')}
</p>
<input
type="checkbox" className='ml-2'
ref={assignmentsRef}
checked={getCheckboxState('assignments') ?? false}
onChange={() => {
const newState = hideEmptyFields.assignments === 'on' ? 'onlyEmpty' : (hideEmptyFields.assignments === 'onlyEmpty' ? 'off' : 'on');
setHideEmptyFields({ ...hideEmptyFields, assignments: newState });
}}
title={getCheckboxTooltip('assignments', "участия")}
/>
</th>
<th className="border-b font-medium p-4 pt-0 pb-3 cursor-pointer" >
<p className="inline-block" onClick={() => handleSort('lastLogin')}>
Последно влизане{renderSortArrow('lastLogin')}
</p>
<input
type="checkbox" className='ml-2'
checked={hideEmptyFields.lastLogin}
onChange={() => setHideEmptyFields({ ...hideEmptyFields, lastLogin: !hideEmptyFields.lastLogin })}
title="Скрий редове без нотификации"
/>
</th>
<th className="border-b font-medium p-4 pt-0 pb-3 cursor-pointer">
<p className="inline-block" >
Нотификации
</p>
<input
type="checkbox" className='ml-2'
checked={hideEmptyFields.notifiications}
onChange={() => setHideEmptyFields({ ...hideEmptyFields, notifiications: !hideEmptyFields.notifiications })}
title="Скрий редове без последно влизане"
/>
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filteredPublishers.map((allPub) => { {filteredPublishers.map((pub, i) => {
// Find the publisher in the publishers collection to access statistics
const pub = publishers.find(publisher => publisher.id === allPub.id);
return ( return (
<tr key={allPub.id}> <tr key={pub.id}>
<td className="border-b p-4 pl-8" title={allPub.lastUpdate}>{allPub.firstName} {allPub.lastName}</td> <td className="border-b p-4 pl-8" title={pub.lastUpdate}>{i + 1}. {pub.firstName} {pub.lastName}</td>
{/* Display statistics if publisher is found */} {/* Display statistics if publisher is found */}
{pub ? ( {pub ? (
<> <>
<td className="border-b p-4"> <td className="border-b p-4">
<span title="Възможност: часове | дни" className={`badge py-1 px-2 rounded-md text-xs ${pub.currentMonthAvailabilityHoursCount || pub.currentMonthAvailabilityDaysCount ? 'bg-teal-500 text-white' : 'bg-teal-200 text-gray-300'} hover:underline`}> {pub.availabilities.length > 0 ? (
{pub.currentMonthAvailabilityDaysCount || 0} | {pub.currentMonthAvailabilityHoursCount || 0} <span title="Възможност: часове | дни" className={`badge py-1 px-2 rounded-md text-xs ${pub.currentMonthAvailabilityHoursCount || pub.currentMonthAvailabilityDaysCount ? 'bg-teal-500 text-white' : 'bg-teal-200 text-gray-300'} hover:underline`}>
</span> {pub.currentMonthAvailabilityDaysCount} | {pub.currentMonthAvailabilityHoursCount}
</span>
) : <span title="Няма възможности" className="badge py-1 px-2 rounded-md text-xs bg-gray-300 text-gray-500">0</span>}
</td> </td>
<td className="border-b p-4"> <td className="border-b p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -73,16 +273,15 @@ function ContactsPage({ publishers, allPublishers }) {
) : ( ) : (
<> <>
<td className="border-b p-4"> <td className="border-b p-4">
</td> </td>
<td className="border-b p-4"> <td className="border-b p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center"> <div className="flex items-center">
<span className="badge py-1 px-2 rounded-md text-xs bg-gray-300 text-gray-500" title="участия този месец"> <span className="badge py-1 px-2 rounded-md text-xs bg-gray-300 text-gray-500" title="участия този месец">
{allPub.currentMonthAssignments || 0} {pub.currentMonthAssignments}
</span> </span>
<span className="badge py-1 px-2 rounded-md text-xs bg-gray-300 text-gray-500" title="участия миналия месец"> <span className="badge py-1 px-2 rounded-md text-xs bg-gray-300 text-gray-500" title="участия миналия месец">
{allPub.previousMonthAssignments || 0} {pub.previousMonthAssignments}
</span> </span>
</div> </div>
</div> </div>
@ -90,6 +289,22 @@ function ContactsPage({ publishers, allPublishers }) {
<td className="border-b p-4"></td> {/* Empty cell for alignment */} <td className="border-b p-4"></td> {/* Empty cell for alignment */}
</> </>
)} )}
<td className="border-b p-4 text-center">
{pub.isPushActive ? (
<span className="text-green-500 flex items-center justify-center" aria-label="Notifications Enabled">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 28 28" stroke="currentColor" className="w-7 h-7" title="Notifications are active">
<circle cx="13" cy="13" r="12" fill="white" stroke="currentColor" stroke-width="2" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 16l2.5 2.5 7-10" /> </svg>
</span>
) : (
<span className="text-gray-400">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="3" stroke="currentColor" className="w-4 h-4 inline-block">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</span>
)}
</td>
</tr> </tr>
); );
})} })}
@ -106,132 +321,16 @@ function ContactsPage({ publishers, allPublishers }) {
export default ContactsPage; 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) => { export const getServerSideProps = async (context) => {
const allPublishers = await data.getAllPublishersWithStatistics(new Date());
const prisma = common.getPrismaClient(); //merge first and last name
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 => { allPublishers.forEach(publisher => {
// Use helper functions to calculate and assign assignment counts publisher.name = `${publisher.firstName} ${publisher.lastName}`;
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));
return { return {
props: { props: {
publishers, allPublishers
allPublishers,
}, },
}; };
}; };

View File

@ -25,7 +25,17 @@ export default function EventLogList() {
useEffect(() => { useEffect(() => {
const fetchLocations = async () => { const fetchLocations = async () => {
try { try {
const { data: eventLogsData } = await axiosInstance.get(`/api/data/prisma/eventLog?where={"type":"${EventLogType.AssignmentReplacementAccepted}"}&include={"publisher":{"select":{"firstName":true,"lastName":true}},"shift":{"include":{"assignments":{"include":{"publisher":{"select":{"firstName":true,"lastName":true}}}}}}}`); const { data: eventLogsDataold } = await axiosInstance.get(`/api/data/prisma/eventLog?where={"type":"${EventLogType.AssignmentReplacementAccepted}"}&include={"publisher":{"select":{"firstName":true,"lastName":true}},"shift":{"include":{"assignments":{"include":{"publisher":{"select":{"firstName":true,"lastName":true}}}}}}}`);
// const where = encodeURIComponent(`{OR: [{type: "${EventLogType.AssignmentReplacementAccepted}"}, {type: "${EventLogType.AssignmentReplacementManual}"}]}`);
const where = encodeURIComponent(JSON.stringify({
OR: [
{ type: EventLogType.AssignmentReplacementAccepted },
{ type: EventLogType.AssignmentReplacementManual }
]
}));
const { data: eventLogsData } = await axiosInstance.get(`/api/data/prisma/eventLog?where=${where}&include={"publisher":{"select":{"firstName":true,"lastName":true}},"shift":{"include":{"assignments":{"include":{"publisher":{"select":{"firstName":true,"lastName":true}}}}}}}`);
setEventLog(eventLogsData); setEventLog(eventLogsData);
@ -40,6 +50,9 @@ export default function EventLogList() {
fetchLocations(); fetchLocations();
} }
}, []); }, []);
return ( return (
<Layout> <Layout>
<ProtectedRoute allowedRoles={[UserRole.POWERUSER, UserRole.ADMIN]}> <ProtectedRoute allowedRoles={[UserRole.POWERUSER, UserRole.ADMIN]}>
@ -67,15 +80,18 @@ export default function EventLogList() {
<table className="w-full table-auto"> <table className="w-full table-auto">
<thead> <thead>
<tr> <tr>
<th className="px-4 py-2 text-left">На</th>
<th className="px-4 py-2 text-left">От</th> <th className="px-4 py-2 text-left">От</th>
<th className="px-4 py-2 text-left">Дата</th> <th className="px-4 py-2 text-left">Дата</th>
<th className="px-4 py-2 text-left">Смяна</th> <th className="px-4 py-2 text-left">Смяна</th>
<th className="px-4 py-2 text-left">Действия</th> <th className="px-4 py-2 text-left">Събитие</th>
{/* <th className="px-4 py-2 text-left">Действия</th> */}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{!showOpenRequests && (eventLogs.map((event) => ( {!showOpenRequests && (eventLogs.map((event) => (
<tr key={event.id}> <tr key={event.id}>
<td className="border px-2 py-2">{new Date(event.date).toLocaleString('bg')}</td>
<td className="border px-2 py-2">{event.publisher.firstName + " " + event.publisher.lastName}</td> <td className="border px-2 py-2">{event.publisher.firstName + " " + event.publisher.lastName}</td>
<td className="border px-2 py-2">{new Date(event.shift?.startTime).toLocaleString('bg')}</td> <td className="border px-2 py-2">{new Date(event.shift?.startTime).toLocaleString('bg')}</td>
<td className="border px-2 py-2"> <td className="border px-2 py-2">
@ -86,17 +102,18 @@ export default function EventLogList() {
<td className="border px-2 py-2"> <td className="border px-2 py-2">
{event.content} {event.content}
</td> </td>
<td className="border px-4 py-2"> {/* <td className="border px-4 py-2">
<button <button
className="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded"> className="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded">
Изтрий Изтрий
</button> </button>
</td> </td> */}
</tr> </tr>
)) ))
)} )}
{showOpenRequests && (requestedAssignments.map((assignment) => ( {showOpenRequests && (requestedAssignments.map((assignment) => (
<tr key={assignment.id}> <tr key={assignment.id}>
<td className="border px-2 py-2">{new Date(assignment.date).toLocaleString('bg')}</td>
<td className="border px-2 py-2">{assignment.publisher.firstName + " " + assignment.publisher.lastName}</td> <td className="border px-2 py-2">{assignment.publisher.firstName + " " + assignment.publisher.lastName}</td>
<td className="border px-2 py-2">{new Date(assignment.shift.startTime).toLocaleString('bg')}</td> <td className="border px-2 py-2">{new Date(assignment.shift.startTime).toLocaleString('bg')}</td>
<td className="border px-2 py-2"> <td className="border px-2 py-2">
@ -104,12 +121,13 @@ export default function EventLogList() {
<div key={ass.id}>{ass.publisher.firstName + " " + ass.publisher.lastName}</div> <div key={ass.id}>{ass.publisher.firstName + " " + ass.publisher.lastName}</div>
))} ))}
</td> </td>
<td className="border px-4 py-2"> {/* <td className="border px-4 py-2">
<button <button
className="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded"> className="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded"
>
Изтрий Изтрий
</button> </button>
</td> </td> */}
</tr> </tr>
)) ))
)} )}

View File

@ -21,8 +21,10 @@ import CartEventForm from "components/cartevent/CartEventForm";
interface IProps { interface IProps {
initialItems: Availability[]; initialItems: Availability[];
initialUserId: string; 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 { data: session } = useSession();
const [userName, setUserName] = useState(session?.user?.name); const [userName, setUserName] = useState(session?.user?.name);
const [userId, setUserId] = useState(initialUserId); const [userId, setUserId] = useState(initialUserId);
@ -79,7 +81,7 @@ export default function IndexPage({ initialItems, initialUserId, cartEvents }: I
<div className="text-center font-bold pb-3 xs:pb-1"> <div className="text-center font-bold pb-3 xs:pb-1">
<PublisherInlineForm publisherId={userId} /> <PublisherInlineForm publisherId={userId} />
</div> </div>
<AvCalendar publisherId={userId} events={events} selectedDate={new Date()} cartEvents={cartEvents} /> <AvCalendar publisherId={userId} events={events} selectedDate={new Date()} cartEvents={cartEvents} lastPublishedDate={lastPublishedDate} />
</div> </div>
</div> </div>
</ProtectedRoute> </ProtectedRoute>
@ -229,6 +231,7 @@ export const getServerSideProps = async (context) => {
startTime: updatedItem.shift.startTime.toISOString(), startTime: updatedItem.shift.startTime.toISOString(),
endTime: updatedItem.shift.endTime.toISOString() endTime: updatedItem.shift.endTime.toISOString()
}; };
updatedItem.isPublished = updatedItem.shift.isPublished;
} }
return updatedItem; 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);
console.log("First availability startTime: " + items[0]?.startTime.toLocaleString()); console.log("First availability startTime: " + items[0]?.startTime.toLocaleString());
const prisma = common.getPrismaClient(); const prisma = common.getPrismaClient();
let cartEvents = await prisma.cartEvent.findMany({ let cartEvents = await prisma.cartEvent.findMany({
where: { where: {
@ -253,11 +257,23 @@ export const getServerSideProps = async (context) => {
} }
}); });
cartEvents = common.convertDatesToISOStrings(cartEvents); cartEvents = common.convertDatesToISOStrings(cartEvents);
const lastPublishedDate = (await prisma.shift.findFirst({
where: {
isPublished: true,
},
select: {
endTime: true,
},
orderBy: {
endTime: 'desc'
}
})).endTime;
return { return {
props: { props: {
initialItems: items, initialItems: items,
userId: sessionServer?.user.id, userId: sessionServer?.user.id,
cartEvents: cartEvents, cartEvents: cartEvents,
lastPublishedDate: lastPublishedDate.toISOString(),
// messages: (await import(`../content/i18n/${context.locale}.json`)).default // messages: (await import(`../content/i18n/${context.locale}.json`)).default
}, },
}; };

View File

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

View File

@ -0,0 +1,19 @@
-- AlterTable
ALTER TABLE `EventLog`
MODIFY `type` ENUM(
'AssignmentReplacementManual', 'AssignmentReplacementRequested', 'AssignmentReplacementAccepted', 'SentEmail', 'PasswordResetRequested', 'PasswordResetEmailConfirmed', 'PasswordResetCompleted'
) NOT NULL;
INSERT INTO
`Congregation`
VALUES (1, 'Перник', '', 1),
(2, 'София Люлин', '', 1),
(3, 'София Юг', '', 1),
(4, 'София Надежда', '', 1),
(5, 'София Руски', '', 1),
(6, 'София Факултета', '', 1),
(7, 'София Изток', '', 1),
(8, 'София Младост', '', 1),
(9, 'София Английски', '', 1),
(10, 'Ботевград', '', 1),
(11, 'София Дружба', '', 1);

View File

@ -124,6 +124,18 @@ model Publisher {
EventLog EventLog[] EventLog EventLog[]
lastLogin DateTime? lastLogin DateTime?
pushSubscription Json? 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 { model Availability {
@ -181,23 +193,25 @@ model Shift {
//date DateTime //date DateTime
reportId Int? @unique reportId Int? @unique
Report Report? @relation(fields: [reportId], references: [id]) Report Report? @relation(fields: [reportId], references: [id])
isPublished Boolean @default(false) //NEW v1.0.1 isPublished Boolean @default(false)
EventLog EventLog[] EventLog EventLog[]
@@map("Shift") @@map("Shift")
} }
model Assignment { model Assignment {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
shift Shift @relation(fields: [shiftId], references: [id], onDelete: Cascade) shift Shift @relation(fields: [shiftId], references: [id], onDelete: Cascade)
shiftId Int shiftId Int
publisher Publisher @relation(fields: [publisherId], references: [id], onDelete: Cascade) publisher Publisher @relation(fields: [publisherId], references: [id], onDelete: Cascade)
publisherId String publisherId String
isBySystem Boolean @default(false) // if no availability for it, when importing previous schedules isBySystem Boolean @default(false) // if no availability for it, when importing previous schedules
isConfirmed Boolean @default(false) isConfirmed Boolean @default(false)
isWithTransport Boolean @default(false) isWithTransport Boolean @default(false)
isMailSent Boolean @default(false) isMailSent Boolean @default(false)
publicGuid String? @unique 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") @@map("Assignment")
} }
@ -237,6 +251,7 @@ model Report {
experienceInfo String? @db.LongText experienceInfo String? @db.LongText
type ReportType @default(ServiceReport) type ReportType @default(ServiceReport)
comments String?
@@map("Report") @@map("Report")
} }
@ -258,9 +273,11 @@ model Message {
isRead Boolean @default(false) isRead Boolean @default(false)
isPublic Boolean @default(false) isPublic Boolean @default(false)
type MessageType @default(Email) type MessageType @default(Email)
publicUntil DateTime?
} }
enum EventLogType { enum EventLogType {
AssignmentReplacementManual
AssignmentReplacementRequested AssignmentReplacementRequested
AssignmentReplacementAccepted AssignmentReplacementAccepted
SentEmail SentEmail

View File

@ -346,7 +346,6 @@ async function filterPublishersNew(selectFields, filterDate, isExactTime = false
} }
} }
console.log(`getting publishers for date: ${filterDate}, isExactTime: ${isExactTime}, isForTheMonth: ${isForTheMonth}`); console.log(`getting publishers for date: ${filterDate}, isExactTime: ${isExactTime}, isForTheMonth: ${isForTheMonth}`);
console.log`whereClause: ${JSON.stringify(whereClause)}` console.log`whereClause: ${JSON.stringify(whereClause)}`
//include availabilities if flag is true //include availabilities if flag is true
@ -420,7 +419,8 @@ async function filterPublishersNew(selectFields, filterDate, isExactTime = false
if (isWithStats) { if (isWithStats) {
pub.currentMonthAvailability = pub.availabilities?.filter(avail => { pub.currentMonthAvailability = pub.availabilities?.filter(avail => {
// return avail.dayOfMonth != null && avail.startTime >= currentMonthStart && avail.startTime <= monthInfo.lastSunday; // return avail.dayOfMonth != null && avail.startTime >= currentMonthStart && avail.startTime <= monthInfo.lastSunday;
return avail.startTime >= monthInfo.firstMonday && (noEndDateFilter || avail.startTime <= monthInfo.lastSunday); return (avail.startTime >= monthInfo.firstMonday && (noEndDateFilter || avail.startTime <= monthInfo.lastSunday))
|| (avail.dayOfMonth == null); // include repeating availabilities
}) })
pub.currentMonthAvailabilityDaysCount = pub.currentMonthAvailability.length; pub.currentMonthAvailabilityDaysCount = pub.currentMonthAvailability.length;
// pub.currentMonthAvailabilityDaysCount += pub.availabilities.filter(avail => { // pub.currentMonthAvailabilityDaysCount += pub.availabilities.filter(avail => {
@ -472,13 +472,13 @@ async function filterPublishersNew(selectFields, filterDate, isExactTime = false
} }
//ToDo: refactor this function //ToDo: refactor this function
async function getAllPublishersWithStatistics(filterDate) { async function getAllPublishersWithStatistics(filterDate, noEndDateFilter = false) {
const prisma = common.getPrismaClient(); const prisma = common.getPrismaClient();
const monthInfo = common.getMonthDatesInfo(new Date(filterDate)); const monthInfo = common.getMonthDatesInfo(new Date(filterDate));
const dateStr = new Date(monthInfo.firstMonday).toISOString().split('T')[0]; const dateStr = new Date(monthInfo.firstMonday).toISOString().split('T')[0];
let publishers = await filterPublishersNew('id,firstName,lastName,email,isActive,desiredShiftsPerMonth,lastLogin,type', dateStr, false, true, true, true, true); let publishers = await filterPublishersNew('id,firstName,lastName,email,isActive,desiredShiftsPerMonth,lastLogin,type', dateStr, false, true, noEndDateFilter, true, true);
// const axios = await axiosServer(context); // 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`); // const { data: publishers } = await axios.get(`api/?action=filterPublishers&assignments=true&availabilities=true&date=${dateStr}&select=id,firstName,lastName,isActive,desiredShiftsPerMonth`);
@ -531,6 +531,7 @@ async function getAllPublishersWithStatistics(filterDate) {
desiredShiftsPerMonth: true, desiredShiftsPerMonth: true,
lastLogin: true, lastLogin: true,
type: true, type: true,
pushSubscription: true,
assignments: { assignments: {
select: { select: {
id: true, id: true,
@ -557,6 +558,14 @@ async function getAllPublishersWithStatistics(filterDate) {
}, },
}, },
orderBy: [
{
firstName: 'asc', // or 'desc' if you want descending order
},
{
lastName: 'asc', // or 'desc' if you want descending order
}
],
}); });
@ -566,6 +575,8 @@ async function getAllPublishersWithStatistics(filterDate) {
allPublishers.forEach(publisher => { allPublishers.forEach(publisher => {
publisher.isPushActive = publisher.pushSubscription ? true : false;
delete publisher.pushSubscription
// Use helper functions to calculate and assign assignment counts // Use helper functions to calculate and assign assignment counts
publisher.currentMonthAssignments = countAssignments(publisher.assignments, monthInfo.firstMonday, monthInfo.lastSunday); publisher.currentMonthAssignments = countAssignments(publisher.assignments, monthInfo.firstMonday, monthInfo.lastSunday);
publisher.previousMonthAssignments = countAssignments(publisher.assignments, prevMonthInfo.firstMonday, prevMonthInfo.lastSunday); publisher.previousMonthAssignments = countAssignments(publisher.assignments, prevMonthInfo.firstMonday, prevMonthInfo.lastSunday);
@ -577,6 +588,11 @@ async function getAllPublishersWithStatistics(filterDate) {
// common.convertDatesToISOStrings(publisher.availabilities); //ToDo fix the function to work with this sctucture and use it // common.convertDatesToISOStrings(publisher.availabilities); //ToDo fix the function to work with this sctucture and use it
}); });
//debug
const pubsWithAvailabilities = allPublishers.filter(publisher => publisher.availabilities.length > 0);
const pubsWithAvailabilities2 = publishers.filter(publisher => publisher.currentMonthAvailabilityHoursCount > 0);
console.log(`publishers: ${allPublishers.length}, publishers with availabilities: ${pubsWithAvailabilities.length}, publishers with availabilities2: ${pubsWithAvailabilities2.length}`);
//merge allPublishers with publishers //merge allPublishers with publishers
allPublishers = allPublishers.map(pub => { allPublishers = allPublishers.map(pub => {

View File

@ -37,12 +37,30 @@ iframe {
filter: invert(1); filter: invert(1);
} }
.modal-container {
display: flex;
flex-direction: column-reverse; /* Newest first if new elements are prepended */
}
.modal-content { .modal-content {
z-index: 1002; /* or a higher value if necessary */ /* z-index: 1002; or a higher value if necessary */
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%); /* Center the modal */
z-index: 1051; /* Higher z-index than overlay */
background-color: #fff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
z-index: 1051; /* High z-index */
} }
.modal-overlay { .modal-overlay {
z-index: 1001; position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.5); /* Semi-transparent background */
z-index: 1050; /* High z-index */
} }
.publisher { .publisher {
position: relative; position: relative;