initial commit - code moved to separate repo

This commit is contained in:
Dobromir Popov
2024-02-22 04:19:38 +02:00
commit 560d503219
240 changed files with 105125 additions and 0 deletions

View File

@ -0,0 +1,629 @@
import { toast } from 'react-toastify';
import Layout from "../../../components/layout";
import { Publisher, Availability, AvailabilityType, DayOfWeek, UserRole } from "@prisma/client";
import ProtectedRoute from '../../../components/protectedRoute';
import axiosInstance from '../../../src/axiosSecure';
import { useState, useRef } from "react";
import * as XLSX from "xlsx";
// import { Table } from "react-bootstrap";
import { staticGenerationAsyncStorage } from "next/dist/client/components/static-generation-async-storage.external";
import moment from 'moment';
// import { DatePicker } from '@mui/x-date-pickers'; !! CAUSERS ERROR ???
// import { DatePicker } from '@mui/x-date-pickers/DatePicker';
import { set } from 'date-fns';
// import * as common from "src/helpers/common";
const common = require('../../../src/helpers/common');
export default function ImportPage() {
const [data, setData] = useState([])
const [rawData, setRawData] = useState([]);
const [status, setStatus] = useState({ status: 'idle', info: '' });
const MODE_PUBLISHERS1 = "PUBLISHERS1";
const MODE_PUBLISHERS2 = "PUBLISHERS2";
type ModeState = {
mainMode: typeof MODE_PUBLISHERS1 | typeof MODE_PUBLISHERS2;
schedule: boolean;
publishers2Import: boolean;
headerRow: number;
}
const [mode, setMode] = useState<ModeState>({
mainMode: MODE_PUBLISHERS1,
schedule: false,
publishers2Import: false,
headerRow: 0
});
const headerRef = useRef({
header: null,
dateIndex: -1,
emailIndex: -1,
nameIndex: -1,
phoneIndex: -1,
isTrainedIndex: -1,
desiredShiftsIndex: -1,
dataStartIndex: -1,
isActiveIndex: -1,
pubTypeIndex: -1
});
const handleFile = (e) => {
const [file] = e.target.files;
const reader = new FileReader();
reader.onload = (evt) => {
const bstr = evt.target.result;
const wb = XLSX.read(bstr, { type: "binary" });
const wsname = wb.SheetNames[0];
const ws = wb.Sheets[wsname];
const sheetData = XLSX.utils.sheet_to_json(ws, {
header: 1,
range: 0,
blankrows: false,
defval: '',
});
for (let i = 0; i < 5; i++) {
if (sheetData[i].includes('Имейл')) {
setMode({
mainMode: MODE_PUBLISHERS1,
schedule: false,
publishers2Import: false,
headerRow: i
});
headerRef.current.header = sheetData[i];
headerRef.current.dataStartIndex = i;
common.logger.debug("header at row " + i);
break;
}
// it seems we are processing availability sheet. import only publishers by default, and read only the first 3 columns as publisher data
if (sheetData[i].includes('Email Address')) {
setMode({
mainMode: MODE_PUBLISHERS2,
schedule: true,
publishers2Import: false,
headerRow: i
});
headerRef.current.header = sheetData[i];
headerRef.current.dataStartIndex = i;
break;
}
}
if (!headerRef.current.header) {
console.error("header not found in the first 5 rows!");
return;
}
const header = headerRef.current.header
headerRef.current.dateIndex = header.indexOf('Timestamp');
headerRef.current.emailIndex = header.indexOf('Имейл') !== -1 ? header.indexOf('Имейл') : header.indexOf('Email Address');
headerRef.current.nameIndex = header.indexOf('Име, Фамилия');
headerRef.current.phoneIndex = header.indexOf('Телефон');
headerRef.current.isTrainedIndex = header.indexOf('Обучен');
headerRef.current.desiredShiftsIndex = header.indexOf('Желан брой участия');
headerRef.current.isActiveIndex = header.indexOf("Неактивен");
headerRef.current.pubTypeIndex = header.indexOf("Назначение");
const filteredData = sheetData.slice(headerRef.current.dataStartIndex).map((row) => {
let date;
date = common.excelSerialDateToDate(row[headerRef.current.dateIndex]);
//substract 1 day, because excel serial date is 1 day ahead
date.setDate(date.getDate() - 1);
date = common.getDateFormated(date);
common.logger.debug(date);
return [date, row[headerRef.current.emailIndex], row[headerRef.current.nameIndex]];
});
setRawData(sheetData);
setData(filteredData);
setStatus({ status: 'зареден', info: `Заредени ${filteredData.length} от ${rawData.length} записа` });
};
reader.readAsBinaryString(file);
// Reset the file input value
e.target.value = null;
};
const handleSave = async () => {
try {
common.logger.debug("handleSave to: " + common.getBaseUrl());
const header = rawData[mode.headerRow];
for (let i = mode.headerRow + 1; i < rawData.length; i++) { //fullData.length; each publisher
//update status.info with current publisher
setStatus({ status: 'running', info: `Processing row ${i} of ${rawData.length}` });
//sleep for 300ms to allow the database to process the previous request
await new Promise(r => setTimeout(r, 100));
const row = rawData[i];
var email, phone, names, dateOfInput, oldAvDeleted = false, isTrained = false, desiredShiftsPerMonth = 4, isActive = false;
//const date = new Date(row[0]).toISOS{tring().slice(0, 10);
if (mode.mainMode == MODE_PUBLISHERS1) {
email = row[headerRef.current.emailIndex];
phone = row[headerRef.current.phoneIndex].toString().trim(); // Trim whitespace
// Remove any non-digit characters, except for the leading +
//phone = phone.replace(/(?!^\+)\D/g, '');
phone = phone.replace(/[^+\d]/g, '');
if (phone.startsWith('8') || phone.startsWith('9')) {
phone = '+359' + phone.substring(1); // Assumes all numbers starting with 8 are Bulgarian and should have +359
} else if (!phone.startsWith('+')) {
phone = '+' + phone; // Add + if it's missing, assuming the number is complete
}
names = row[headerRef.current.nameIndex].normalize('NFC').split(/[ ]+/);
dateOfInput = importDate.value || new Date().toISOString();
// not empty == true
isTrained = row[headerRef.current.isTrainedIndex] !== '';
isActive = row[headerRef.current.isActiveIndex] == '';
desiredShiftsPerMonth = row[headerRef.current.desiredShiftsIndex] !== '' ? row[headerRef.current.desiredShiftsIndex] : 4;
}
else {
dateOfInput = common.excelSerialDateToDate(row[0]);
email = row[1];
names = row[2].normalize('NFC').split(/[, ]+/);
}
//remove special characters from name
// let names = common.removeAccentsAndSpecialCharacters(row[2]).split(/[, ]+/);
let personId = '';
try {
try {
const select = "&select=id,firstName,lastName,phone,isTrained,desiredShiftsPerMonth,isactive,availabilities";
const responseByName = await axiosInstance.get(`/api/?action=findPublisher&filter=${names.join(' ')}${select}`);
let existingPublisher = responseByName.data[0];
if (!existingPublisher) {
// If no match by name, check by email
const responseByEmail = await axiosInstance.get(`/api/?action=findPublisher&email=${email}${select}`);
if (responseByEmail.data.length > 0) {
// Iterate over all matches by email to find one with a matching or similar name
const fullName = names.join(' ').toLowerCase(); // Simplify comparison
existingPublisher = responseByEmail.data.find(publisher => {
const publisherFullName = (publisher.firstName + ' ' + publisher.lastName).toLowerCase();
return fullName === publisherFullName; // Consider expanding this comparison for typos
});
}
}
if (existingPublisher?.id) { // UPDATE
// Create a flag to check if update is needed
const updatedData = {};
personId = existingPublisher?.id;
let updateNeeded = false;
// Check for name update
const fullName = names.join(' ');
const existingFullName = existingPublisher.firstName + ' ' + existingPublisher.lastName;
if (fullName !== existingFullName) {
common.logger.debug(`Existing publisher '${existingFullName}' found for ${email} (ID:${personId})`);
updatedData.firstName = names[0];
updatedData.lastName = names.slice(1).join(' ');
data[i - mode.headerRow][4] = "name updated!";
updateNeeded = true;
} else {
data[i - mode.headerRow][4] = "existing";
}
// Log existing publisher
common.logger.debug(`Existing publisher '${[existingPublisher.firstName, existingPublisher.lastName].join(' ')}' found for ${email} (ID:${personId})`);
// Check for other updates
const fieldsToUpdate = [
{ key: 'phone', value: phone },
{ key: 'desiredShiftsPerMonth', value: desiredShiftsPerMonth, parse: parseInt },
{ key: 'isTrained', value: isTrained },
{ key: 'isactive', value: isActive }
];
fieldsToUpdate.forEach(({ key, value, parse }) => {
if (!existingPublisher[key] && value !== '' && value !== undefined) {
updatedData[key] = parse ? parse(value) : value;
updateNeeded = true;
}
});
// Update the record if needed and if MODE_PUBLISHERS1 (Import from List of Participants)
if (updateNeeded && (mode.publishers2Import || mode.mainMode == MODE_PUBLISHERS1)) {
try {
await axiosInstance.put(`/api/data/publishers/${personId}`, updatedData);
common.logger.debug(`Updated publisher ${personId}`);
data[i - mode.headerRow][4] = "updated";
} catch (error) {
console.error(`Failed to update publisher ${personId}`, error);
}
}
} else { // CREATE
// If no publisher with the email exists, create one
// const names = email.split('@')[0].split('.');\
//Save new publisher
if (mode.publishers2Import) {
const personResponse = await axiosInstance.post('/api/data/publishers', {
email,
phone,
firstName: names[0],
lastName: names[1],
isactive: isActive,
isTrained,
desiredShiftsPerMonth,
});
personId = personResponse.data.id;
data[i][4] = "new";
}
else
if (mode.mainMode == MODE_PUBLISHERS1) {
const firstname = names.length > 2 ? names.slice(0, -1).join(' ') : names[0];
const personResponse = await axiosInstance.post('/api/data/publishers', {
email,
phone,
firstName: firstname,
lastName: names[names.length - 1],
isactive: isActive,
isTrained,
desiredShiftsPerMonth
});
data[i - mode.headerRow][4] = "new";
} else {
data[i - mode.headerRow][4] = "import disabled";
}
common.logger.debug(`NEW Publisher ${personId} created for email ${email} (${names})`);
}
} catch (error) {
console.error(error);
data[i - mode.headerRow][4] = "error";
}
if (mode.schedule) {
// Parse the availability data from the Excel cell
//get days of the month and add up to the next full week
// Save availability records
const availabilities: Availability[] = [];
for (let j = 3; j < header.length; j++) {
const dayHeader = header[j];
const shifts = row[j];
if (!shifts || shifts === 'Не мога') {
continue;
}
// specific date: Седмица (17-23 април) [Четвъртък ]
// const regex = /^Седмица \((\d{1,2})-(\d{1,2}) (\S+)\) \[(\S+)\]$/;
// specific week: Седмица 1 <any character> [Четвъртък]
// const regex = /^Седмица (\d{1,2}) \((\d{1,2})-(\d{1,2}) (\S+)\) \[(\S+)\]$/;
//allow optional space before and after the brackets
// match both 'Седмица 3 (20-25 ноември) [пон]' and 'Седмица 4 (27 ноември - 2 декември) [четв]'
//const regex = /^\s*Седмица\s+(\d{1,2})\s+\((\d{1,2})\s+(\S+)(?:\s*-\s*(\d{1,2})\s+(\S+))?\)\s*\[\s*(\S+)\s*\]\s*$/;
//the original, but missing the two month names
let regex = /^Седмица (\d{1,2}) \((\d{1,2})-(\d{1,2}) (\S+)\)\s*\[(\S+)\s*\]\s*$/;
regex = /^Седмица (\d{1,2}) \((\d{1,2})(?:\s*-\s*(\d{1,2}))? (\S+)(?:\s*-\s*(\d{1,2}) (\S+))?\)\s*\[(\S+)\s*\]\s*$/;
//const regex = /^Седмица (\d{1,2}) \((\d{1,2}(\s*\S*?))-(\d{1,2}) (\S+)\)\s*\[(\S+)\s*\]\s*$/;
//both Седмица 1 (6-11 ноември) [пон]
// Седмица 4 (27 ноември-2 декември) [пет]
//const regex = /^Седмица (\d{1,2}) \((\d{1,2} \S+)?-? ?(\d{1,2} \S+)\)\s*\[(\S+)\s*\]\s*$/;
//const regex = /^Седмица (\d{1,2}) \((\d{1,2} \S+)?-? ?(\d{1,2} \S+)\)\s*\[(\S+)\s*\]\s*$/;
// const regex = /^Седмица (\d{1,2}) \* \[(\S+)\]$/;
// const regex = /^Седмица (\d{1,2}) \[(\S+)\]$/;
// replace multiple spaces with single space
const normalizedHeader = dayHeader.replace(/\s+/g, ' ');
var match = normalizedHeader.match(regex);
if (!match) {
common.logger.debug("was not able to parse availability " + shifts + "trying again with different regex");
let regex = /^Седмица (\d{1,2}) \((\d{1,2})-(\d{1,2}) (\S+)\)\s*\[(\S+)\s*\]\s*$/;
match = normalizedHeader.match(regex);
}
if (match) {
//ToDo: can't we use date.getDayEuropean() instead of this logic down?
const weekNr = parseInt(match[1]);
const weekStart = match[2];
// const endDate = match[2];
const month = match[4];
const dayOfWeekStr = match[7];
const dayOfWeek = common.getDayOfWeekIndex(dayOfWeekStr);
common.logger.debug("processing availability for week " + weekNr + ": " + weekStart + "." + month + "." + dayOfWeekStr)
// Create a new Date object for the start date of the range
const day = new Date();
day.setDate(1); // Set to the first day of the month to avoid overflow
//day.setMonth(day.getMonth() + 1); // Add one month to the date, because we assume we are p
day.setMonth(common.getMonthIndex(month));
day.setDate(parseInt(weekStart) + dayOfWeek);
day.setHours(0, 0, 0, 0);
common.logger.debug("processing availabilities for " + day.toLocaleDateString()); // Output: Sun Apr 17 2022 14:07:11 GMT+0300 (Eastern European Summer Time)
common.logger.debug("parsing availability input: " + shifts); // Output: 0 (Sunday)
const dayOfWeekName = common.getDayOfWeekNameEnEnum(day);
let dayOfMonth = day.getDate();
const name = `${names[0]} ${names[1]}`;
const intervals = shifts.split(",");
if (!oldAvDeleted && personId) {
if (mode.schedule && email) {
common.logger.debug(`Deleting existing availabilities for publisher ${personId} for date ${day}`);
try {
await axiosInstance.post(`/api/?action=deleteAvailabilityForPublisher&publisherId=${personId}&date=${day}&deleteFromPreviousAssignments=true`);
common.logger.debug(`Deleted all availabilities for publisher ${personId}`);
oldAvDeleted = true;
}
catch (error) {
console.error(`Failed to delete availabilities for publisher ${personId}`, error);
}
}
}
let parsedIntervals: { end: number; }[] = [];
intervals.forEach(interval => {
// Summer format: (12:00-14:00)
//if (!/\(\d{1,2}:\d{2}-\d{1,2}:\d{2}\)/.test(interval)) {
//winter regex:
//\d{1,2}-\d{1,2}:\d{2}/
// Regular expression to match both Summer format (12:00-14:00) and winter format '09-10:30'
const regex = /\d{1,2}(?::\d{2})?-\d{1,2}(?::\d{2})?/;
if (!regex.test(interval)) {
common.logger.debug(`Skipping invalid interval: ${interval}`);
return;
}
// If the interval matches the format, remove any non-essential characters
const cleanedInterval = interval.replace(/[()]/g, "");
// Extract start and end times from interval string
const [start, end] = cleanedInterval.split("-");
// Convert times like "12" to "12:00" for consistent parsing
const formattedStart = start.includes(":") ? start : start + ":00";
const formattedEnd = end.includes(":") ? end : end + ":00";
// Try to parse the times, and skip the interval if it can't be parsed
try {
const parsedStart = Number(formattedStart.split(":").join(""));
const parsedEnd = Number(formattedEnd.split(":").join(""));
// Store parsed interval
parsedIntervals.push({
start: parsedStart,
end: parsedEnd
});
} catch (error) {
common.logger.debug(`Error parsing interval: ${interval}`);
return;
}
});
// Sort intervals by start time
parsedIntervals.sort((a, b) => a.start - b.start);
// Initialize start and end times with the first interval
let minStartTime = parsedIntervals[0].start;
let maxEndTime = parsedIntervals[0].end;
let isOld = false;
// Calculate the total month difference by considering the year difference
let totalMonthDifference = (day.getFullYear() - dateOfInput.getFullYear()) * 12 + (day.getMonth() - dateOfInput.getMonth());
// If the total month difference is 2 or more, set isOld to true
if (totalMonthDifference >= 2) {
isOld = true;
}
// Iterate over intervals
for (let i = 1; i < parsedIntervals.length; i++) {
if (parsedIntervals[i].start > maxEndTime) {
availabilities.push(createAvailabilityObject(minStartTime, maxEndTime, day, dayOfWeekName, dayOfMonth, weekNr, personId, name, isOld));
minStartTime = parsedIntervals[i].start;
maxEndTime = parsedIntervals[i].end;
} else {
maxEndTime = Math.max(maxEndTime, parsedIntervals[i].end);
}
}
// Add the last availability
availabilities.push(createAvailabilityObject(minStartTime, maxEndTime, day, dayOfWeekName, dayOfMonth, weekNr, personId, name, isOld));
}
else {
common.logger.debug("availability not matched. header:" + dayHeader + " shifts:" + shifts);
}
}
common.logger.debug("availabilities to save for " + personId + ": " + availabilities.length);
// Send a single request to create all availabilities
axiosInstance.post('/api/?action=createAvailabilities', availabilities)
.then(response => common.logger.debug('Availabilities created:', response.data))
.catch(error => console.error('Error creating availabilities:', error));
// Experimental: add availabilities to all publishers with the same email
//check if more than one publisher has the same email, and add the availabilities to both
//check existing publishers with the same email
var sameNamePubs = axiosInstance.get(`/api/?action=findPublisher&all=true&email=${email}&select=id,firstName,lastName`);
sameNamePubs.then(function (response) {
common.logger.debug("same name pubs: " + response.data.length);
if (response.data.length > 1) {
response.data.forEach(pub => {
//check the publisher is not the same as the one we already added the availabilities to
if (pub.id != personId) {
//change the publisher id to the new one
availabilities.forEach(availability => {
availability.publisherId = pub.id;
}
);
//delete existing availabilities for the publisher
axiosInstance.post(`/api/?action=deleteAvailabilityForPublisher&publisherId=${pub.id}&date=${dateOfInput}`);
// Send a single request to create all availabilities
axiosInstance.post('/api/?action=createAvailabilities', availabilities)
.then(response => common.logger.debug('Availabilities created:', response.data))
.catch(error => console.error('Error creating availabilities:', error));
}
});
}
});
}
// await axios.post("/api/data/availabilities", availabilities);
} catch (error) {
console.error(error);
}
}
toast.info('Records saved successfully', { autoClose: 30000 });
} catch (error) {
console.error(error);
toast.error('An error occurred while saving records!');
}
};
// Function to create an availability object
function createAvailabilityObject(start: any, end: number, day: Date, dayOfWeekName: any, dayOfMonth: number, weekNr: number, personId: string, name: string, isFromPreviousMonth: boolean): Availability {
const formatTime = (time) => {
const paddedTime = String(time).padStart(4, '0');
return new Date(day.getFullYear(), day.getMonth(), day.getDate(), parseInt(paddedTime.substr(0, 2)), parseInt(paddedTime.substr(2, 4)));
};
const startTime = formatTime(start);
const endTime = formatTime(end);
return {
id: 0, // Add the missing 'id' property
publisherId: personId,
name,
dayofweek: dayOfWeekName,
dayOfMonth,
weekOfMonth: weekNr, // Add the missing 'weekOfMonth' property
startTime,
endTime,
isactive: true,
type: AvailabilityType.OneTime,
isWithTransportIn: false, // Add the missing 'isWithTransport' property
isWithTransportOut: false, // Add the missing 'isWithTransport' property
isFromPreviousAssignment: false, // Add the missing 'isFromPreviousAssignment' property
isFromPreviousMonth: isFromPreviousMonth // Add the missing 'isFromPreviousMonth' property
};
}
return (
<Layout>
<ProtectedRoute allowedRoles={[UserRole.POWERUSER, UserRole.ADMIN]}>
<div className="p-6">
<h1 className="text-3xl mb-4 font-semibold">Import Page</h1>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700" htmlFor="fileInput">Choose a file to import:</label>
<input type="file" id="fileInput" onChange={handleFile} className="mt-1 p-2 border rounded" placeholder="Choose a file" />
</div>
{/* <DatePicker label="Дата" value={date} onChange={setDate} /> */}
<div className="mb-4 space-y-2"> {/* Adjust this value as needed */}
<label className="flex items-center">
<input
type="radio"
value="PUBLISHERS1"
checked={mode.mainMode === "PUBLISHERS1"}
onChange={() => setMode({ mainMode: "PUBLISHERS1", schedule: false, publishers2Import: false })}
className="text-indigo-600"
/>
<span className="ml-2 space-x-4">Импортирай от Списък с участниците</span>
</label>
<label className="flex items-center">
<input
type="radio"
value="PUBLISHERS2"
checked={mode.mainMode === "PUBLISHERS2"}
onChange={() => setMode(prev => ({ ...prev, mainMode: "PUBLISHERS2" }))}
className="text-indigo-600"
/>
<span className="ml-2">Импортирай от Предпочитания за колички</span>
</label>
<label className="flex items-center">
{/* <DatePicker
label="Дата на импорт"
value={new Date()}
onChange={(date) => {
common.logger.debug("date changed to " + date);
}}
/> */}
{/* simple HTML datepicker for import date */}
<input type="date" id="importDate" name="importDate" />
</label>
{mode.mainMode === "PUBLISHERS2" && (
<div className="mt-2 space-y-1 pl-6">
<label className="flex items-center">
<input
type="checkbox"
checked={mode.schedule}
onChange={(e) => setMode(prev => ({ ...prev, schedule: e.target.checked }))}
className="text-indigo-600"
/>
<span className="ml-2">Предпочитания</span>
</label>
<label className="flex items-center ">
<input
type="checkbox"
checked={mode.publishers2Import}
onChange={(e) => setMode(prev => ({ ...prev, publishers2Import: e.target.checked }))}
className="text-indigo-600"
/>
<span className="ml-2">Вестители</span>
</label>
</div>
)}
</div>
<div className="mb-4">
<button onClick={handleSave} className="bg-indigo-600 text-white py-2 px-4 rounded hover:bg-indigo-700">Запази</button>
</div>
<div className="mb-4 flex items-center space-x-4">
<span className="text-gray-600">{data.length} вестители прочетени</span>
<span className="text-gray-600">{status.info}</span>
</div>
<table className="min-w-full border-collapse border border-gray-500">
<thead>
<tr>
{data.length > 0 &&
Object.keys(data[0]).map((key) => <th className="px-4 py-2 border-b font-medium" key={key}>{Object.values(data[0])[key]}</th>)}
</tr>
</thead>
<tbody>
{data.slice(1).map((row, index) => (
<tr key={index} className="even:bg-gray-100">
{Object.values(row).map((value, index) => (
<td key={index} className="border px-4 py-2">{value}</td>
))}
<td id={row[1]}>
<i className={`fa fa-circle ${status[row[3]] || 'text-gray-400'}`}></i>
</td>
</tr>
))}
</tbody>
</table>
</div>
</ProtectedRoute>
</Layout>
);
};