(wip) schedle generation;

added confirmations on schedule  DELETE!!!
better reports page;
log every delete over API, more logging;
This commit is contained in:
Dobromir Popov
2024-05-24 12:53:17 +03:00
parent 73ac798a6d
commit 2202e8b1b4
9 changed files with 166 additions and 64 deletions

View File

@ -246,4 +246,11 @@ in schedule admin - if a publisher is always pair & family is not in the shift -
[x] make test notification for user
[] add Congregation field
[] use original assignment when scheduling
[]
[] invalidate one/all user sessions
[] log deletions
[] add user permissions [with logging when used]
[] improve reports page(s)

View File

@ -89,15 +89,15 @@ export default function ReportForm({ shiftId, existingItem, onDone }) {
const handleSubmit = async (e) => {
e.preventDefault();
item.publisher = { connect: { id: publisherId } };
delete item.publisherId;
if (allDay) {
delete item.shift;
} else {
item.shift = { connect: { id: parseInt(item.shiftId) } };
}
delete item.shiftId;
item.date = new Date(item.date);
item.type = ReportType.Report;
delete item.publisherId;
delete item.shiftId;
item.placementCount = parseInt(item.placementCount);
item.videoCount = parseInt(item.videoCount);
item.returnVisitInfoCount = parseInt(item.returnVisitInfoCount);

View File

@ -32,13 +32,12 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
//get target action
if (req.method === 'DELETE') {
switch (targetTable) {
case 'publishers':
case 'availabilities':
// case 'publishers':
// case 'availabilities':
default:
const targetId = req.query.nextcrud[1];
logger.info('[nextCrud] ' + targetTable + ': ' + targetId + ' DELETED by ' + session.user.email);
break;
default:
break;
}
}
return nextCrudHandler(req, res);

View File

@ -366,13 +366,13 @@ export default async function handler(req, res) {
+ " до: " + pubsToSend.map(p => p.firstName + " " + p.lastName + "<" + p.email + ">").join(", "),
}
});
logger.info("User: " + publisher.email + " sent a 'CoverMe' request for his assignment " + assignmentId + " - " + assignment.shift.cartEvent.location.name + " " + assignment.shift.startTime.toISOString() + " to " + pubsToSend.map(p => p.firstName + " " + p.lastName + "<" + p.email + ">").join(", ") + ". EventLogId: " + eventLog.id + "");
logger.info(". EventLogId: " + eventLog.id + "User: " + publisher.email + " sent a 'CoverMe' request for his assignment " + assignmentId + ", shift " + assignment.shift.id + " - " + assignment.shift.cartEvent.location.name + " " + assignment.shift.startTime.toISOString() + " to " + pubsToSend.map(p => p.firstName + " " + p.lastName + "<" + p.email + ">").join(", "));
//send email to all subscribed publishers
for (let i = 0; i < pubsToSend.length; i++) {
//send email to subscribed publisher
let acceptUrl = process.env.NEXTAUTH_URL + "/api/email?action=email_response&emailaction=coverMeAccept&userId=" + pubsToSend[i].id + "&shiftId=" + assignment.shift.id + "&assignmentPID=" + newPublicGuid;
let acceptUrl = process.env.NEXTAUTH_URL + "/api/email?action=email_response&emailaction=coverMeAccept&userId=" + pubsToSend[i].id + "&shiftId=" + assignment.shift.id + "&assignmentPID=" + newPublicGuid + "&eventLogID=" + eventLog.id;
publisher.prefix = publisher.isMale ? "Брат" : "Сестра";
let model = {

View File

@ -10,7 +10,7 @@ import { filterPublishers, /* other functions */ } from './index';
import CAL from "../../src/helpers/calendar";
//const common = require("@common");
import common from "../../src/helpers/common";
import common, { logger } from "../../src/helpers/common";
import data from "../../src/helpers/data";
import { Axios } from 'axios';
@ -59,6 +59,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
break;
case "delete":
result = await DeleteSchedule(axios, req.query.date, common.parseBool(req.query.forDay));
let msg = "Deleted schedule for " + (req.query.forDay ? req.query.date : "the entire month of ") + req.query.date + ". Action requested by " + token.email;
logger.warn(msg);
console.log(msg);
res.send("deleted"); // JSON.stringify(result, null, 2)
break;
case "createcalendarevent":
@ -694,7 +697,8 @@ async function GenerateSchedule(axios, date, copyFromPreviousMonth = false, auto
return (hasTransportInAvailability || hasTransportOutAvailability) && isNotAssigned && isNotAssignedToday;
});
// rank publishers based on different factors
let rankedPublishers = await RankPublishersForShift(availablePublishers)
let rankedPublishersOld = await RankPublishersForShift(availablePublishers)
let rankedPublishers = await RankPublishersForShiftWeighted(availablePublishers)
if (rankedPublishers.length > 0) {
const newAssignment = await prisma.assignment.create({
data: {
@ -801,36 +805,36 @@ async function GenerateSchedule(axios, date, copyFromPreviousMonth = false, auto
});
shift.assignments.push(newAssignment);
publishersToday.push(rankedPublishers[0].id);
}
//check if publisher.familyMembers are also available and add them to the shift. ToDo: test case
let familyMembers = availablePubsForTheShift.filter(p => p.familyHeadId && p.familyHeadId === rankedPublishers[0].familyHeadId);
if (familyMembers.length > 0) {
familyMembers.forEach(async familyMember => {
if (shift.assignments.length < event.numberOfPublishers) {
console.log("Assigning " + familyMember.firstName + " " + familyMember.lastName + " to " + shift.startDate.getDate() + " " + shift.name);
const newAssignment = await prisma.assignment.create({
data: {
shift: {
connect: {
id: shift.id,
//check if publisher.familyMembers are also available and add them to the shift. ToDo: test case
let familyMembers = availablePubsForTheShift.filter(p => p.familyHeadId && p.familyHeadId === rankedPublishers[0].familyHeadId);
if (familyMembers.length > 0) {
familyMembers.forEach(async familyMember => {
if (shift.assignments.length < event.numberOfPublishers) {
console.log("Assigning " + familyMember.firstName + " " + familyMember.lastName + " to " + shift.startDate.getDate() + " " + shift.name);
const newAssignment = await prisma.assignment.create({
data: {
shift: {
connect: {
id: shift.id,
},
},
},
publisher: {
connect: {
id: familyMember.id,
publisher: {
connect: {
id: familyMember.id,
},
},
isConfirmed: true,
isBySystem: false,
},
isConfirmed: true,
isBySystem: false,
},
});
shift.assignments.push(newAssignment);
publishersToday.push(familyMember.id);
}
});
});
shift.assignments.push(newAssignment);
publishersToday.push(familyMember.id);
}
});
}
}
}
};
}
@ -887,9 +891,6 @@ async function RankPublishersForShift(publishers) {
const availabilityDifference = a.currentMonthAvailabilityHoursCount - b.currentMonthAvailabilityHoursCount;
if (availabilityDifference !== 0) return availabilityDifference;
// biggest difference between desired and current assignments first (descending)
const desiredCurrentDifference = b.DesiredMinusCurrent - a.DesiredMinusCurrent;
if (desiredCurrentDifference !== 0) return desiredCurrentDifference;
// less assigned first (ascending)
return a.currentMonthAssignments - b.currentMonthAssignments;
@ -897,6 +898,52 @@ async function RankPublishersForShift(publishers) {
return ranked;
}
async function RankPublishersForShiftWeighted(publishers) {
// Define weights for each criterion
const weights = {
gender: 2,
desiredCompletion: 3,
availability: 2,
lastMonthCompletion: 3,
currentAssignments: 1
};
// Normalize weights to ensure they sum to 1
const totalWeight = Object.values(weights).reduce((acc, val) => acc + val, 0);
Object.keys(weights).forEach(key => {
weights[key] /= totalWeight;
});
publishers.forEach(p => {
p.lastMonthCompletion = p.previousMonthAssignments / p.currentMonthAssignments;
p.desiredCompletion = p.currentMonthAssignments / p.desiredShiftsPerMonth;
});
let ranked = publishers.sort((a, b) => {
// Calculate weighted score for each publisher
const scoreA = (a.isMale ? weights.gender : 0) -
(a.desiredCompletion * weights.desiredCompletion) +
((1 - a.currentMonthAvailabilityHoursCount / 24) * weights.availability) +
(a.currentMonthAssignments * weights.lastMonthCompletion) -
(a.currentMonthAssignments * weights.currentAssignments);
const scoreB = (b.isMale ? weights.gender : 0) -
(b.desiredCompletion * weights.desiredCompletion) +
((1 - b.currentMonthAvailabilityHoursCount / 24) * weights.availability) +
(b.currentMonthAssignments * weights.lastMonthCompletion) -
(b.currentMonthAssignments * weights.currentAssignments);
return scoreB - scoreA; // Sort descending by score
});
return ranked;
}
async function DeleteShiftsForMonth(monthInfo) {
try {
const prisma = common.getPrismaClient();

View File

@ -434,9 +434,8 @@ export default function CalendarPage({ initialEvents, initialShifts }) {
setActiveButton(null);
}
}
const deleteShifts = async (buttonId, forDay: Boolean) => {
const deleteShifts = async (forDay: Boolean) => {
try {
setActiveButton(buttonId);
await axiosInstance.get(`/api/shiftgenerate?action=delete&date=${common.getISODateOnly(value)}&forDay=${forDay}`);
toast.success('Готово!', { autoClose: 1000 });
setIsMenuOpen(false);
@ -533,7 +532,29 @@ export default function CalendarPage({ initialEvents, initialShifts }) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isConfirmModalOpen, setConfirmModalOpen] = useState(false);
const [confirmModalProps, setConfirmModalProps] = useState({
isOpen: false,
message: '',
onConfirm: () => { }
});
const openConfirmModal = (message, action, actionName) => {
if (actionName) {
setActiveButton(actionName);
}
setConfirmModalProps({
isOpen: true,
message: message,
onConfirm: () => {
toast.info('Потвърдено!', { autoClose: 2000 });
setConfirmModalProps((prevProps) => ({ ...prevProps, isOpen: false }));
action();
},
});
};
//const [isConfirmModalDeletOpen, setConfirmModalDeleteOpen] = useState(false);
async function copyOldAvailabilities(event: MouseEvent<HTMLButtonElement, MouseEvent>): Promise<void> {
await axiosInstance.get(`/api/?action=copyOldAvailabilities&date=${common.getISODateOnly(value)}`);
}
@ -592,10 +613,16 @@ export default function CalendarPage({ initialEvents, initialShifts }) {
<button className="button btn m-2 bg-blue-800" onClick={generateDOCX}>
{isLoading('generateDOCX') ? (<i className="fas fa-sync-alt fa-spin mr-2"></i>) : (<i className="fa fa-file-export"></i>)}Експорт в Word
</button>
<button className="button btn m-2 bg-yellow-500 hover:bg-yellow-600 text-white" onClick={() => { setActiveButton("sendEmails"); setConfirmModalOpen(true) }}>
<button className="button btn m-2 bg-yellow-500 hover:bg-yellow-600 text-white"
onClick={() => openConfirmModal(
'Това ще изпрати имейли до всички участници за смените им през избрания месец. Сигурни ли сте?',
() => sendMails(),
"sendEmails"
)}
>
{isLoading('sendEmails') ? (<i className="fas fa-sync-alt fa-spin mr-2"></i>) : (<i className="fas fa-envelope mr-2"></i>)} изпрати мейли!
</button>
<ConfirmationModal
{/* <ConfirmationModal
isOpen={isConfirmModalOpen}
onClose={() => setConfirmModalOpen(false)}
onConfirm={() => {
@ -604,7 +631,14 @@ export default function CalendarPage({ initialEvents, initialShifts }) {
sendMails()
}}
message="Това ще изпрати имейли до всички участници за смените им през избрания месец. Сигурни ли сте?"
/> */}
<ConfirmationModal
isOpen={confirmModalProps.isOpen}
onClose={() => setConfirmModalProps((prevProps) => ({ ...prevProps, isOpen: false }))}
onConfirm={confirmModalProps.onConfirm}
message={confirmModalProps.message}
/>
<button
className={`button btn m-2 ${isPublished ? 'hover:bg-gray-500 bg-yellow-500' : 'hover:bg-red-300 bg-blue-400'}`}
onClick={togglePublished}>
@ -627,7 +661,13 @@ export default function CalendarPage({ initialEvents, initialShifts }) {
<button className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" onClick={() => generateShifts("genDay", false, true, true)}>
{isLoading('genDay') ? (<i className="fas fa-sync-alt fa-spin mr-2"></i>) : (<i className="fas fa-cogs mr-2"></i>)}
Генерирай смени ({value.getDate()}-ти) </button>
<button className="block px-4 py-2 text-sm text-red-500 hover:bg-gray-100" onClick={() => { deleteShifts("deleteShiftsDay", true) }}>
<button className="block px-4 py-2 text-sm text-red-500 hover:bg-gray-100"
onClick={() => openConfirmModal(
'Сигурни ли сте че искате да изтриете смените и назначения на този ден?',
() => deleteShifts(true),
"deleteShiftsDay"
)}
>
{isLoading('deleteShiftsDay') ? (<i className="fas fa-sync-alt fa-spin mr-2"></i>) : (<i className="fas fa-trash-alt mr-2"></i>)}
изтрий смените ({value.getDate()}-ти)</button>
@ -641,11 +681,16 @@ export default function CalendarPage({ initialEvents, initialShifts }) {
<button className="block w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center" onClick={() => generateShifts("genDay", true, true)}>
{isLoading('genDay') ? (<i className="fas fa-sync-alt fa-spin mr-2"></i>) : (<i className="fas fa-cogs mr-2"></i>)}
Генерирай смени </button>
<button className="block px-4 py-2 text-sm text-red-500 hover:bg-gray-100" onClick={() => { deleteShifts("deleteShifts", false) }}>
<button className="block px-4 py-2 text-sm text-red-500 hover:bg-gray-100"
onClick={() => openConfirmModal(
'Сигурни ли сте че искате да изтриете ВСИЧКИ смени и назначения за месеца?',
() => deleteShifts(false),
"deleteShifts"
)}
>
{isLoading('deleteShifts') ? (<i className="fas fa-sync-alt fa-spin mr-2"></i>) : (<i className="fas fa-trash-alt mr-2"></i>)}
изтрий смените</button>
<hr className="my-1" />
<button className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" onClick={generateXLS}><i className="fas fa-file-excel mr-2"></i> Генерирай XLSX</button>

View File

@ -62,7 +62,7 @@ export default function Reports() {
const { data } = await axiosInstance.get("/api/data/locations");
setLocations(data);
console.log(data);
axiosInstance.get(`/api/data/reports?include=publisher,location`)
axiosInstance.get(`/api/data/reports?include=publisher,location,shift`)
.then((res) => {
// let reports = res.data;
// reports.forEach((report) => {
@ -123,8 +123,12 @@ export default function Reports() {
{filteredReports.map((report) => (
<tr key={report.id}>
<td className="border px-2 py-2">{report.publisher.firstName + " " + report.publisher.lastName}</td>
<td className="border px-2 py-2">{common.getDateFormated(new Date(report.date))}</td>
<td className="border px-2 py-2">{report.location?.name}</td>
<td className="border px-2 py-2">{common.getDateFormated(new Date(report.date))}
{report.type === ReportType.ServiceReport ? (report.shift ? " от " + common.getTimeFormatted(report.shift?.startTime) + " ч." : "") : common.getTimeFormatted(report.date)}
</td>
<td className="border px-2 py-2">{report.location?.name}
{report.type === ReportType.ServiceReport ? (report.shift ? "" : "за целия ден") : report.comments}
</td>
<td className="border px-2 py-2">
{(report.type === ReportType.ServiceReport)
? (
@ -145,12 +149,16 @@ export default function Reports() {
<span style={{ color: 'blue' }}> - Предложение</span> :
""}
</div>
<div dangerouslySetInnerHTML={{ __html: report.experienceInfo }} />
<div style={{ maxHeight: '960px', maxWidth: '960px', overflow: 'auto' }}>
<div dangerouslySetInnerHTML={{ __html: report.experienceInfo }} />
</div>
</>
) : (
<>
<div><strong>Случка</strong></div>
<div dangerouslySetInnerHTML={{ __html: report.experienceInfo }} />
<div style={{ maxHeight: '960px', maxWidth: '960px', overflow: 'auto' }}>
<div dangerouslySetInnerHTML={{ __html: report.experienceInfo }} />
</div>
</>
)

View File

@ -256,10 +256,6 @@ export const getServerSideProps = async (context) => {
// log first availability startTime to verify timezone and UTC conversion
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: {

View File

@ -236,14 +236,14 @@ model Location {
}
model Report {
id Int @id @default(autoincrement())
date DateTime
publisherId String
publisher Publisher @relation(fields: [publisherId], references: [id], onDelete: Cascade)
locationId Int?
location Location? @relation(fields: [locationId], references: [id])
shift Shift?
id Int @id @default(autoincrement())
date DateTime
publisherId String
publisher Publisher @relation(fields: [publisherId], references: [id], onDelete: Cascade)
locationId Int?
location Location? @relation(fields: [locationId], references: [id])
// shiftId Int? # reference is in Shift model
shift Shift?
placementCount Int?
videoCount Int?
returnVisitInfoCount Int?