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",
"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"
}

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

View File

@ -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()) {

View File

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

View File

@ -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 }) {
</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">
<label className="label" htmlFor="town">Град</label>
<input type="text" id="town" name="town" value={publisher.town} onChange={handleChange} className="textbox" placeholder="Град" autoFocus />
</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 */}
<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}
{showList ? (
// 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) => (
<li key={publisher.id}
className="p-2 cursor-pointer hover:bg-gray-200"
@ -136,8 +136,9 @@ function PublisherSearchBox({ id, selectedId, onChange, isFocused, filterDate, s
</li>
))}
</ul>
) : null}
</div>
) : null
}
</div >
);
}

View File

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

View File

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

View File

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

View File

@ -74,6 +74,13 @@ export default function SignIn({ csrfToken }) {
src="https://authjs.dev/img/providers/apple.svg" className="mr-2" />
Влез чрез Apple
</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 className="w-full max-w-xs mt-8 mb-8">
<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 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) {
</a>
</div>
</ProtectedRoute>
<CongregationCRUD />
</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 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 (
<Layout>
<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 justify-center m-4">
<a href="/cart/publishers/new" className="btn">Добави вестител</a>
@ -195,23 +197,24 @@ function PublishersPage({ publishers = [] }: IProps) {
</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">
<label htmlFor="filter">Filter:</label>
<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"
<div className="z-60 sticky top-0" style={{ zIndex: 60, position: relative }}>
<div name="filters" className="flex items-center justify-center space-x-4 m-4 bg-gray-100 p-2" >
<label htmlFor="filter">Filter:</label>
<input type="text" id="filter" name="filter" value={filter} onChange={handleFilterChange}
className="border border-gray-300 rounded-md px-2 py-1"
/>
<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 className="grid gap-4 grid-cols-1 md:grid-cols-4 z-0">
{renderPublishers()}
</div>
@ -226,9 +229,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: {

View File

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

View File

@ -1,62 +1,262 @@
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';
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 [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('name');
const [sortOrder, setSortOrder] = useState('asc');
const months = common.getMonthNames();
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 (
<Layout>
<ProtectedRoute allowedRoles={[UserRole.ADMIN, UserRole.POWERUSER]}>
<div className="container mx-auto p-4">
<h1 className="text-xl font-semibold mb-4">Статистика </h1>
<h5 className="text-lg font-semibold mb-4">{publishers.length} участника с предпочитания за месеца (от {allPublishers.length} )</h5>
<input
type="text"
placeholder="Търси по име, имейл или телефон..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="border border-gray-300 rounded-md px-2 py-2 mb-4 w-full text-base md:text-sm"
/>
<h5 className="text-lg font-semibold mb-4">{pubWithAssignmentsCount} участника с предпочитания за месеца (от {filteredPublishers.length} )</h5>
<div className="mb-4 flex justify-between items-center">
<input name="filterText"
type="text"
placeholder="Търси по име, имейл или телефон..."
value={searchQuery}
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">
<table className="w-full text-left border-collapse">
<thead>
<tr>
<th className="border-b font-medium p-4 pl-8 pt-0 pb-3">Име</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">Участия</th>
<th className="border-b font-medium p-4 pt-0 pb-3">Последно влизане</th>
<th className="border-b font-medium p-4 pl-8 pt-0 pb-3 cursor-pointer" onClick={() => handleSort('name')}>
Име{renderSortArrow('name')}
</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>
</thead>
<tbody>
{filteredPublishers.map((allPub) => {
// Find the publisher in the publishers collection to access statistics
const pub = publishers.find(publisher => publisher.id === allPub.id);
{filteredPublishers.map((pub, i) => {
return (
<tr key={allPub.id}>
<td className="border-b p-4 pl-8" title={allPub.lastUpdate}>{allPub.firstName} {allPub.lastName}</td>
<tr key={pub.id}>
<td className="border-b p-4 pl-8" title={pub.lastUpdate}>{i + 1}. {pub.firstName} {pub.lastName}</td>
{/* Display statistics if publisher is found */}
{pub ? (
<>
<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.currentMonthAvailabilityDaysCount || 0} | {pub.currentMonthAvailabilityHoursCount || 0}
</span>
{pub.availabilities.length > 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`}>
{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 className="border-b p-4">
<div className="flex items-center justify-between">
@ -73,16 +273,15 @@ function ContactsPage({ publishers, allPublishers }) {
) : (
<>
<td className="border-b p-4">
</td>
<td className="border-b p-4">
<div className="flex items-center justify-between">
<div className="flex items-center">
<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 className="badge py-1 px-2 rounded-md text-xs bg-gray-300 text-gray-500" title="участия миналия месец">
{allPub.previousMonthAssignments || 0}
{pub.previousMonthAssignments}
</span>
</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 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>
);
})}
@ -106,132 +321,16 @@ 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;
const allPublishers = await data.getAllPublishersWithStatistics(new Date());
//merge first and last name
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);
publisher.name = `${publisher.firstName} ${publisher.lastName}`;
});
// 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 {
props: {
publishers,
allPublishers,
allPublishers
},
};
};

View File

@ -25,7 +25,17 @@ export default function EventLogList() {
useEffect(() => {
const fetchLocations = async () => {
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);
@ -40,6 +50,9 @@ export default function EventLogList() {
fetchLocations();
}
}, []);
return (
<Layout>
<ProtectedRoute allowedRoles={[UserRole.POWERUSER, UserRole.ADMIN]}>
@ -67,15 +80,18 @@ export default function EventLogList() {
<table className="w-full table-auto">
<thead>
<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> */}
</tr>
</thead>
<tbody>
{!showOpenRequests && (eventLogs.map((event) => (
<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">{new Date(event.shift?.startTime).toLocaleString('bg')}</td>
<td className="border px-2 py-2">
@ -86,17 +102,18 @@ export default function EventLogList() {
<td className="border px-2 py-2">
{event.content}
</td>
<td className="border px-4 py-2">
{/* <td className="border px-4 py-2">
<button
className="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded">
Изтрий
</button>
</td>
</td> */}
</tr>
))
)}
{showOpenRequests && (requestedAssignments.map((assignment) => (
<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">{new Date(assignment.shift.startTime).toLocaleString('bg')}</td>
<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>
))}
</td>
<td className="border px-4 py-2">
{/* <td className="border px-4 py-2">
<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>
</td>
</td> */}
</tr>
))
)}

View File

@ -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
<div className="text-center font-bold pb-3 xs:pb-1">
<PublisherInlineForm publisherId={userId} />
</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>
</ProtectedRoute>
@ -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
},
};

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[]
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,9 +273,11 @@ model Message {
isRead Boolean @default(false)
isPublic Boolean @default(false)
type MessageType @default(Email)
publicUntil DateTime?
}
enum EventLogType {
AssignmentReplacementManual
AssignmentReplacementRequested
AssignmentReplacementAccepted
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`whereClause: ${JSON.stringify(whereClause)}`
//include availabilities if flag is true
@ -420,7 +419,8 @@ async function filterPublishersNew(selectFields, filterDate, isExactTime = false
if (isWithStats) {
pub.currentMonthAvailability = pub.availabilities?.filter(avail => {
// 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.availabilities.filter(avail => {
@ -472,13 +472,13 @@ async function filterPublishersNew(selectFields, filterDate, isExactTime = false
}
//ToDo: refactor this function
async function getAllPublishersWithStatistics(filterDate) {
async function getAllPublishersWithStatistics(filterDate, noEndDateFilter = false) {
const prisma = common.getPrismaClient();
const monthInfo = common.getMonthDatesInfo(new Date(filterDate));
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 { 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,
lastLogin: true,
type: true,
pushSubscription: true,
assignments: {
select: {
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 => {
publisher.isPushActive = publisher.pushSubscription ? true : false;
delete publisher.pushSubscription
// Use helper functions to calculate and assign assignment counts
publisher.currentMonthAssignments = countAssignments(publisher.assignments, monthInfo.firstMonday, monthInfo.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
});
//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
allPublishers = allPublishers.map(pub => {

View File

@ -37,12 +37,30 @@ iframe {
filter: invert(1);
}
.modal-container {
display: flex;
flex-direction: column-reverse; /* Newest first if new elements are prepended */
}
.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 {
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 {
position: relative;