Merge branch 'production' of https://git.d-popov.com/popov/mwhitnessing into production

This commit is contained in:
Dobromir Popov
2024-04-25 20:34:00 +03:00
62 changed files with 1859 additions and 1012 deletions

29
.env
View File

@ -8,6 +8,7 @@ NEXTAUTH_SECRET=ed8a9681efc414df89dfd03cd188ed58
NODE_ENV=development
# mysql
DATABASE=mysql://cart:cartpw@localhost:3306/cart
# // owner: dobromir.popov@gmail.com | Специално Свидетелстване София
# // https://console.cloud.google.com/apis/credentials/oauthclient/926212607479-d3m8hm8f8esp3rf1639prskn445sa01v.apps.googleusercontent.com?project=grand-forge-108716
@ -18,16 +19,18 @@ AZURE_AD_CLIENT_ID=9e13bedd-1f9d-4c23-910e-a806aba308b6 # Application (client) I
AZURE_AD_CLIENT_SECRET=5ic8Q~GQmW-IUhuxzVGx3BE-i30GXDSpjfMHcb~z #client secret value
AZURE_AD_TENANT_ID=f69d1a93-bfba-498a-9b60-e87c1bc26276
# First APPLE_SECRET=eyJhbGciOiJFUzI1NiIsImtpZCI6IlRCM1YzNTVHNVkifQ.eyJhdWQiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiaXNzIjoiWEM1N1A5U1hESyIsImlhdCI6MTcxMjE3ODM0MiwiZXhwIjoxNzI3NzMwMzQzLCJzdWIiOiJjb20ubXdoaXRuZXNzaW5nLnNvZmlhIn0.XceA0qUQi0tXg0GM_LkJkpNU5AqXLiSB2JlEVbHCB_nINbQTWkjtoWxfqmvdOkIzwKtvdQ8FFb-crK9no9Bbbw
APPLE_ID=com.mwhitnessing.sofia
APPLE_SECRET=eyJhbGciOiJFUzI1NiIsImtpZCI6IlRCM1YzNTVHNVkifQ.eyJhdWQiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiaXNzIjoiWEM1N1A5U1hESyIsImlhdCI6MTcxMjY5NzM5NywiZXhwIjoxNzI4MjQ5Mzk4LCJzdWIiOiJYQzU3UDlTWERLLmNvbS5td2hpdG5lc3Npbmcuc29maWEifQ.QDX9eoRWAKMd10iRMW9Od88-0H_oZ_B6sPG61fw-zjHbNOvlHG3ddfxY1AqfdSMvLrXg1URKM1lnxOB-OCxg4A
# with team in the ID?
#APPLE_ID=XC57P9SXDK.com.mwhitnessing.sofia
#APPLE_SECRET=eyJhbGciOiJFUzI1NiIsImtpZCI6IlRCM1YzNTVHNVkifQ.eyJhdWQiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiaXNzIjoiWEM1N1A5U1hESyIsImlhdCI6MTcxMjE3ODM0MiwiZXhwIjoxNzI3NzMwMzQzLCJzdWIiOiJjb20ubXdoaXRuZXNzaW5nLnNvZmlhIn0.XceA0qUQi0tXg0GM_LkJkpNU5AqXLiSB2JlEVbHCB_nINbQTWkjtoWxfqmvdOkIzwKtvdQ8FFb-crK9no9Bbbw
# to generate
APPLE_TEAM_ID=XC57P9SXDK
APPLE_KEY_ID=TB3V355G5Y
APPLE_PRIVATE_KEY=
APPLE_APP_ID=com.mwhitnessing.sofia
APPLE_SECRET=eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IlRCM1YzNTVHNVkifQ.eyJpYXQiOjE3MTMzMDQ1OTMsImV4cCI6MTcyODg1NjU5MywiYXVkIjoiaHR0cHM6Ly9hcHBsZWlkLmFwcGxlLmNvbSIsImlzcyI6IlhDNTdQOVNYREsiLCJzdWIiOiJjb20ubXdoaXRuZXNzaW5nLnNvZmlhIn0.iO2prjQ_4P7F17R7LTJfG9zHluj59uUtm8DA1LbK49jVBMeGHQP_Az7s_yU5D-GeMHSwU7VnVHcaVKiGWT_Yjg
# with team in the ID?
#APPLE_APP_ID=XC57P9SXDK.com.mwhitnessing.sofia
#APPLE_SECRET=eyJhbGciOiJFUzI1NiIsImtpZCI6IlRCM1YzNTVHNVkifQ.eyJhdWQiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiaXNzIjoiWEM1N1A5U1hESyIsImlhdCI6MTcxMjE3ODM0MiwiZXhwIjoxNzI3NzMwMzQzLCJzdWIiOiJjb20ubXdoaXRuZXNzaW5nLnNvZmlhIn0.XceA0qUQi0tXg0GM_LkJkpNU5AqXLiSB2JlEVbHCB_nINbQTWkjtoWxfqmvdOkIzwKtvdQ8FFb-crK9no9Bbbw
# to generate
AUTH0_ID=Aa9f3HJowauUrmBVY4iQzQJ7fYsaZDbK
@ -45,18 +48,18 @@ GITHUB_SECRET=
TWITTER_ID=
TWITTER_SECRET=
# EMAIL_BYPASS_TO=mwitnessing@gmail.com
EMAIL_SENDER='"ССОМ" <mwitnessing@gmail.com>'
# EMAIL_SERVER=smtp://8ec69527ff2104:c7bc05f171c96c@smtp.mailtrap.io:2525
EMAIL_FROM=noreply@mwitnessing.com
# ?EMAIL_FROM=noreply@mwitnessing.com
MAILTRAP_HOST_BULK=bulk.smtp.mailtrap.io
EMAIL_SERVICE=mailtrap
# MAILTRAP_HOST_BULK=bulk.smtp.mailtrap.io
MAILTRAP_HOST=sandbox.smtp.mailtrap.io
MAILTRAP_PORT=2525
MAILTRAP_USER=8ec69527ff2104
MAILTRAP_PASS=c7bc05f171c96c
GMAIL_EMAIL_USERNAME=
GMAIL_EMAIL_APP_PASS=
TELEGRAM_BOT=false
TELEGRAM_BOT_TOKEN=7050075088:AAH6VRpNCyQd9x9sW6CLm6q0q4ibUgYBfnM

View File

@ -7,5 +7,12 @@ NEXT_PUBLIC_PUBLIC_URL=https://localhost:3003
# DATABASE=mysql://cart:cartpw@192.168.0.10:3306/cart_dev
DATABASE=mysql://cart:cartpw@localhost:3306/cart
EMAIL_SENDER='"ССОМ [ТЕСТ] " <mwitnessing@gmail.com>'
# MAILTRAP_HOST_BULK=bulk.smtp.mailtrap.io
# MAILTRAP_HOST=sandbox.smtp.mailtrap.io
# MAILTRAP_USER=8ec69527ff2104
# MAILTRAP_PASS=c7bc05f171c96c
SSL_KEY=./certificates/localhost-key.pem
SSL_CERT=./certificates/localhost.pem

View File

@ -7,9 +7,15 @@ NEXT_PUBLIC_PUBLIC_URL= https://sofia.mwitnessing.com
NEXTAUTH_SECRET=1dd8a5457970d1dda50600be28e935ecc4513ff27c49c431849e6746f158d638
# ? do we need to duplicate this? already defined in the deoployment yml file
DATABASE=mysql://jwpwsofia:dwxhns9p9vp248V39xJyRthUsZ2gR9@mariadb:3306/jwpwsofia
# DATABASE=mysql://cart:cartpw@localhost:3306/cart
MAILTRAP_HOST_BULK=bulk.smtp.mailtrap.io
MAILTRAP_HOST=live.smtp.mailtrap.io
MAILTRAP_USER=api
MAILTRAP_PASS=1cfe82e747b8dc3390ed08bb16e0f48d
EMAIL_BYPASS_TO=
EMAIL_SENDER='"Специално Свидетелстване София" <mwitnessing@gmail.com>'
EMAIL_SERVICE=gmail
EMAIL_GMAIL_USERNAME=mwitnessing
EMAIL_GMAIL_APP_PASS="acys uzsp eere qzyh"
# MAILTRAP_HOST_BULK=bulk.smtp.mailtrap.io
# MAILTRAP_HOST=live.smtp.mailtrap.io
# MAILTRAP_USER=api
# MAILTRAP_PASS=1cfe82e747b8dc3390ed08bb16e0f48d

View File

@ -10,26 +10,14 @@ NEXTAUTH_SECRET=1dd8a5457970d1dda50600be28e935ecc4513ff27c49c431849e6746f158d638
# ? do we need to duplicate this? already defined in the deoployment yml file
DATABASE=mysql://jwpwsofia_demo:dwxhns9p9vp248@mariadb:3306/jwpwsofia_demo
APPLE_ID=
APPLE_TEAM_ID=
APPLE_PRIVATE_KEY=
APPLE_KEY_ID=
AUTH0_ID=Aa9f3HJowauUrmBVY4iQzQJ7fYsaZDbK
AUTH0_SECRET=_c0O9GkyRXkoWMQW7jNExnl6UoXN6O4oD3mg7NZ_uHVeAinCUtcTAkeQmcKXpZ4x
AUTH0_ISSUER=https://dev-wkzi658ckibr1amv.us.auth0.com
FACEBOOK_ID=
FACEBOOK_SECRET=
GITHUB_ID=
GITHUB_SECRET=
# GOOGLE_ID=926212607479-d3m8hm8f8esp3rf1639prskn445sa01v.apps.googleusercontent.com
# GOOGLE_SECRET=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
TWITTER_ID=
TWITTER_SECRET=
EMAIL_SERVICE=mailtrap
MAILTRAP_HOST_BULK=bulk.smtp.mailtrap.io
MAILTRAP_HOST=live.smtp.mailtrap.io
MAILTRAP_USER=api

View File

@ -19,7 +19,7 @@ services:
- GIT_USERNAME=deploy
- GIT_PASSWORD=L3Kr2R438u4F7
command: sh -c " cd /app && npm install && npm run prod; tail -f /dev/null"
#command: sh -c " cd /app && n
#command: sh -c " cd /app && tail -f /dev/null"
tty: true
stdin_open: true
restart: always

View File

@ -27,13 +27,15 @@ if [ "$UPDATE_CODE_FROM_GIT" = "true" ]; then
rsync -av /tmp/clone/package.json /app/package.json || echo "Rsync failed: Issue copying package.json"
rsync -av /tmp/clone/package-lock.json /app/package-lock.json || echo "Rsync failed: Issue copying package-lock.json"
rm -rf /app/node_modules
cd /app
npm install --no-audit --no-fund --no-optional --omit=optional
yes | npx prisma generate
else
echo "Package files have not changed. Skipping package installation."
fi
cd /app
npm install --no-audit --no-fund --no-optional --omit=optional
npx next build
# Clean up
rm -rf /tmp/clone
echo "Update process completed."

View File

@ -4,14 +4,14 @@ import { SignJWT } from "jose"
import { createPrivateKey } from "crypto"
if (process.argv.includes("--help") || process.argv.includes("-h")) {
console.log(`
console.log(`
Creates a JWT from the components found at Apple.
By default, the JWT has a 6 months expiry date.
Read more: https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens#3262048
Usage:
node apple.mjs [--kid] [--iss] [--private_key] [--sub] [--expires_in] [--exp]
APPLE_ID=com.mwhitnessing.sofia
APPLE_APP_ID=com.mwhitnessing.sofia
APPLE_TEAM_ID=XC57P9SXDK
APPLE_KEY_ID=TB3V355G5Y
APPLE_KEY
@ -37,45 +37,45 @@ eyJhbGciOiJFUzI1NiIsImtpZCI6IlRCM1YzNTVHNVkifQ.eyJhdWQiOiJodHRwczovL2FwcGxlaWQuY
--exp Future date in seconds when the JWT expires
`)
} else {
const args = process.argv.slice(2).reduce((acc, arg, i) => {
if (arg.match(/^--\w/)) {
const key = arg.replace(/^--/, "").toLowerCase()
acc[key] = process.argv[i + 3]
}
return acc
}, {})
const args = process.argv.slice(2).reduce((acc, arg, i) => {
if (arg.match(/^--\w/)) {
const key = arg.replace(/^--/, "").toLowerCase()
acc[key] = process.argv[i + 3]
}
return acc
}, {})
const {
team_id,
iss = team_id,
const {
team_id,
iss = team_id,
private_key,
private_key,
client_id,
sub = client_id,
client_id,
sub = client_id,
key_id,
kid = key_id,
key_id,
kid = key_id,
expires_in = 86400 * 180,
exp = Math.ceil(Date.now() / 1000) + expires_in,
} = args
expires_in = 86400 * 180,
exp = Math.ceil(Date.now() / 1000) + expires_in,
} = args
/**
* How long is the secret valid in seconds.
* @default 15780000
*/
const expiresAt = Math.ceil(Date.now() / 1000) + expires_in
const expirationTime = exp ?? expiresAt
console.log(`
/**
* How long is the secret valid in seconds.
* @default 15780000
*/
const expiresAt = Math.ceil(Date.now() / 1000) + expires_in
const expirationTime = exp ?? expiresAt
console.log(`
Apple client secret generated. Valid until: ${new Date(expirationTime * 1000)}
${await new SignJWT({})
.setAudience("https://appleid.apple.com")
.setIssuer(iss)
.setIssuedAt()
.setExpirationTime(expirationTime)
.setSubject(sub)
.setProtectedHeader({ alg: "ES256", kid })
.sign(createPrivateKey(private_key.replace(/\\n/g, "\n")))}`)
.setAudience("https://appleid.apple.com")
.setIssuer(iss)
.setIssuedAt()
.setExpirationTime(expirationTime)
.setSubject(sub)
.setProtectedHeader({ alg: "ES256", kid })
.sign(createPrivateKey(private_key.replace(/\\n/g, "\n")))}`)
}

View File

@ -207,6 +207,18 @@ push notifications
store replacement
test email
problem with my repeating availability3
relax add/remove transport for publishers
fix published schedule to cover end of the week
имейлите - ОК
графика - синк - ОК
вестителите от Фабио -
потребителите с двойни имейли -
админс can send *urgent* email to everybody to ask for shift
in schedule admin - if a publisher is always pair & family is not in the shift - add + button to add them
постоянен лог
лог ако е изрит потребител.

View File

@ -1,67 +0,0 @@
#!/bin/node
# https://gist.githubusercontent.com/balazsorban44/09613175e7b37ec03f676dcefb7be5eb/raw/b0d31aa0c7f58e0088fdf59ec30cad1415a3475b/apple-gen-secret.mjs
import { SignJWT } from "jose"
import { createPrivateKey } from "crypto"
if (process.argv.includes("--help") || process.argv.includes("-h")) {
console.log(`
Creates a JWT from the components found at Apple.
By default, the JWT has a 6 months expiry date.
Read more: https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens#3262048
Usage:
node apple.mjs [--kid] [--iss] [--private_key] [--sub] [--expires_in] [--exp]
Options:
--help Print this help message
--kid, --key_id The key id of the private key
--iss, --team_id The Apple team ID
--private_key The private key to use to sign the JWT. (Starts with -----BEGIN PRIVATE KEY-----)
--sub, --client_id The client id to use in the JWT.
--expires_in Number of seconds from now when the JWT should expire. Defaults to 6 months.
--exp Future date in seconds when the JWT expires
`)
} else {
const args = process.argv.slice(2).reduce((acc, arg, i) => {
if (arg.match(/^--\w/)) {
const key = arg.replace(/^--/, "").toLowerCase()
acc[key] = process.argv[i + 3]
}
return acc
}, {})
const {
team_id,
iss = team_id,
private_key,
client_id,
sub = client_id,
key_id,
kid = key_id,
expires_in = 86400 * 180,
exp = Math.ceil(Date.now() / 1000) + expires_in,
} = args
/**
* How long is the secret valid in seconds.
* @default 15780000
*/
const expiresAt = Math.ceil(Date.now() / 1000) + expires_in
const expirationTime = exp ?? expiresAt
console.log(`
Apple client secret generated. Valid until: ${new Date(expirationTime * 1000)}
${await new SignJWT({})
.setAudience("https://appleid.apple.com")
.setIssuer(iss)
.setIssuedAt()
.setExpirationTime(expirationTime)
.setSubject(sub)
.setProtectedHeader({ alg: "ES256", kid })
.sign(createPrivateKey(private_key.replace(/\\n/g, "\n")))}`)
}

View File

@ -151,7 +151,7 @@ function PwaManager() {
return;
}
await fetch('/api/notification', {
await fetch('/api/notify', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
@ -219,6 +219,11 @@ function PwaManager() {
<span className="align-middle">Телеграм</span>
</a>
</div>
<div>
<a href="/api/auth/apple-signin" className="inline-flex items-center ml-4" target="_blank">
<span className="align-middle">Apple sign-in</span>
</a>
</div>
</>
);

View File

@ -10,6 +10,8 @@ import { bgBG } from '../x-date-pickers/locales/bgBG';
import { ToastContainer } from 'react-toastify';
const common = require('src/helpers/common');
//todo import Availability type from prisma schema
import { isBefore, addMinutes, isAfter, isEqual, set, getHours, getMinutes, getSeconds } from 'date-fns';
const fetchConfig = async () => {
@ -25,6 +27,7 @@ export default function AvailabilityForm({ publisherId, existingItems, inline, o
indexUrl: "/cart/availabilities"
};
const id = parseInt(router.query.id);
//coalsce existingItems to empty array
existingItems = existingItems || [];
@ -74,7 +77,6 @@ export default function AvailabilityForm({ publisherId, existingItems, inline, o
const fetchItemFromDB = async () => {
const id = parseInt(router.query.id);
if (existingItems.length == 0 && id) {
try {
const response = await axiosInstance.get(`/api/data/availabilities/${id}`);
@ -183,59 +185,6 @@ export default function AvailabilityForm({ publisherId, existingItems, inline, o
return groupedIntervals;
}
// // const firstSlotWithTransport = timeSlots[0].checked && timeSlots[0]?.isWithTransport;
// // const lastSlotWithTransport = timeSlots[timeSlots.length - 1].checked && timeSlots[timeSlots.length - 1]?.isWithTransport;
// function createAvailabilityFromGroup(group) {
// let startTime = new Date(day);
// startTime.setHours(group[0].startTime.getHours(), group[0].startTime.getMinutes(), group[0].startTime.getSeconds(), 0);
// let endTime = new Date(day);
// endTime.setHours(group[group.length - 1].endTime.getHours(), group[group.length - 1].endTime.getMinutes(), group[group.length - 1].endTime.getSeconds(), 0);
// return {
// name: common.getTimeFomatted(startTime) + "-" + common.getTimeFomatted(endTime),
// publisherId: publisher.id,
// startTime: startTime,
// endTime: endTime,
// isWithTransportIn: group[0].isFirst && timeSlots[0].isWithTransport,
// isWithTransportOut: group[group.length - 1].isLast && timeSlots[timeSlots.length - 1].isWithTransport,
// dayofweek: common.getDayOfWeekNameEnEnumForDate(day.getDay()),
// repeatWeekly: doRepeat,
// dayOfMonth: doRepeat ? null : startTime.getDate(),
// endDate: doRepeat ? repeatUntil : null,
// dateOfEntry: new Date(),
// };
// }
// function updateAvailabilityFromGroup(availability, group) {
// availability.startTime.setTime(group[0].startTime);
// availability.endTime.setTime(group[group.length - 1].endTime);
// availability.name = common.getTimeFomatted(availability.startTime) + "-" + common.getTimeFomatted(availability.endTime);
// availability.isWithTransportIn = group[0].isFirst && timeSlots[0].isWithTransport;
// availability.isWithTransportOut = group[group.length - 1].isLast && timeSlots[timeSlots.length - 1].isWithTransport;
// delete availability.weekOfMonth;
// if (doRepeat) {
// availability.repeatWeekly = true;
// availability.dayOfMonth = null;
// availability.weekOfMonth = 0;
// availability.endDate = repeatUntil;
// } else {
// availability.repeatWeekly = false;
// availability.dayOfMonth = availability.startTime.getDate();
// availability.endDate = null;
// }
// availability.dateOfEntry = new Date();
// if (availability.parentAvailabilityId) {
// availability.parentAvailability = { connect: { id: parentAvailabilityId } };
// }
// delete availability.parentAvailabilityId;
// return availability;
// }
// Common function to set shared properties
function setSharedAvailabilityProperties(availability, group, timeSlots) {
let startTime = new Date(availability.startTime || day);
@ -263,7 +212,7 @@ export default function AvailabilityForm({ publisherId, existingItems, inline, o
availability.dayOfMonth = startTime.getDate();
availability.endDate = null;
}
availability.isFromPreviousMonth = false;
availability.dateOfEntry = new Date();
}
@ -332,22 +281,25 @@ export default function AvailabilityForm({ publisherId, existingItems, inline, o
}
// console.log("AvailabilityForm: publisherId: " + publisher.id + ", id: " + availabilit .id, ", inline: " + isInline);
//ToDo: this is examplary function to be used in the future. replace all date/time related functions with this one
const generateTimeSlots = (start, end, increment, items) => {
const slots = [];
let currentTime = start.getTime();
let currentTime = start;
const endTime = end.getTime();
const baseDate = new Date(2000, 0, 1); // Use a constant date for all time comparisons
while (currentTime < endTime) {
let slotStart = new Date(currentTime);
let slotEnd = new Date(currentTime + increment * 60000); // increment is in minutes
while (isBefore(currentTime, end)) {
let slotStart = normalizeTime(currentTime, baseDate);
let slotEnd = normalizeTime(addMinutes(currentTime, increment), baseDate);
const isChecked = items.some(item =>
item.startTime && item.endTime &&
(slotStart.getTime() < item.endTime.getTime()) &&
(slotEnd.getTime() > item.startTime.getTime())
);
const isChecked = items.some(item => {
let itemStart = item.startTime ? normalizeTime(new Date(item.startTime), baseDate) : null;
let itemEnd = item.endTime ? normalizeTime(new Date(item.endTime), baseDate) : null;
return itemStart && itemEnd &&
(slotStart.getTime() < itemEnd.getTime()) &&
(slotEnd.getTime() > itemStart.getTime());
});
slots.push({
startTime: slotStart,
@ -355,10 +307,9 @@ export default function AvailabilityForm({ publisherId, existingItems, inline, o
isChecked: isChecked,
});
currentTime += increment * 60000; // Increment in milliseconds (minutes to milliseconds)
currentTime = addMinutes(currentTime, increment);
}
// Optional: Add isFirst, isLast, and isWithTransport properties
if (slots.length > 0 && items?.length > 0) {
slots[0].isFirst = true;
slots[slots.length - 1].isLast = true;
@ -369,6 +320,16 @@ export default function AvailabilityForm({ publisherId, existingItems, inline, o
return slots;
};
// Normalize the time part of a date by using a base date
function normalizeTime(date, baseDate) {
return set(baseDate, {
hours: getHours(date),
minutes: getMinutes(date),
seconds: getSeconds(date),
milliseconds: 0
});
}
const TimeSlotCheckboxes = ({ slots, setSlots, items: [] }) => {
const [allDay, setAllDay] = useState(slots.every(slot => slot.isChecked));
const handleAllDayChange = (e) => {
@ -467,13 +428,14 @@ export default function AvailabilityForm({ publisherId, existingItems, inline, o
<ToastContainer></ToastContainer>
<form id="formAv" className="form p-5 bg-white shadow-md rounded-lg" onSubmit={handleSubmit}>
<h3 className="text-xl font-semibold mb-5 text-gray-800 border-b pb-2">
{editMode ? "Редактирай" : "Нова"} възможност
{editMode ? "Редактирай" : "Нова"} възможност: {common.getDateFormatedShort(day)}
</h3>
<LocalizationProvider dateAdapter={AdapterDateFns} localeText={bgBG} adapterLocale={bg}>
<div className="mb-2">
{/* <div className="mb-2">
<DatePicker label="Изберете дата" value={day} onChange={(value) => setDay({ value })} />
</div>
</div> */}
<div className="mb-2">
<label className="checkbox-container">

View File

@ -10,7 +10,7 @@ const common = require('src/helpers/common');
function ShiftComponent({ shift, onShiftSelect, isSelected, onPublisherSelect, allPublishersInfo }) {
const [isDeleted, setIsDeleted] = useState(false);
const [assignments, setAssignments] = useState(shift.assignments);
const [isModalOpen, setIsModalOpen] = useState(false);
const [useFilterDate, setUseFilterDate] = useState(true);
@ -24,24 +24,14 @@ function ShiftComponent({ shift, onShiftSelect, isSelected, onPublisherSelect, a
}, [shift.assignments]);
const handleShiftClick = (shiftId) => {
// console.log("onShiftSelect prop:", onShiftSelect);
// console.log("Shift clicked:", shift);
//shift.selectedPublisher = selectedPublisher;
if (onShiftSelect) {
onShiftSelect(shift);
}
};
const handlePublisherClick = (publisher) => {
//toggle selected
// if (selectedPublisher != null) {
// setSelectedPublisher(null);
// }
// else {
setSelectedPublisher(publisher);
console.log("Publisher clicked:", publisher, "selected publisher:", selectedPublisher);
shift.selectedPublisher = publisher;
if (onShiftSelect) {
@ -54,6 +44,17 @@ function ShiftComponent({ shift, onShiftSelect, isSelected, onPublisherSelect, a
common.copyToClipboard(null, publisher.firstName + ' ' + publisher.lastName);
}
const deleteShift = async (id) => {
try {
console.log("Removing shift with id:", id);
await axiosInstance.delete("/api/data/shifts/" + id);
setIsDeleted(true);
} catch (error) {
console.error("Error removing shift:", error);
}
}
const removeAssignment = async (id) => {
try {
console.log("Removing assignment with id:", id);
@ -100,137 +101,162 @@ function ShiftComponent({ shift, onShiftSelect, isSelected, onPublisherSelect, a
} catch (error) { }
}
async function toggleRequiresTransport(shiftId): Promise<void> {
try {
shift.requiresTransport = !shift.requiresTransport;
const { data } = await axiosInstance.put("/api/data/shifts/" + shiftId,
{ requiresTransport: shift.requiresTransport })
.then(() => {
console.log("shift '" + shiftId + "' transport required:" + shift.requiresTransport);
// setTransportProvided(assignments.some(ass => ass.isWithTransport))
});
} catch (error) { }
}
return (
<div className={`flow w-full p-4 py-2 border-2 border-gray-300 rounded-md my-1 ${isSelected ? 'bg-gray-200' : ''}`}
onClick={handleShiftClick} onDoubleClick={copyAllPublisherNames}>
{/* Time Window Header */}
<div className="flex justify-between items-center mb-2 border-b pb-1">
<span className="text-lg font-semibold">
{`${common.getTimeRange(new Date(shift.startTime), new Date(shift.endTime))}`}
{/* {shift.requiresTransport && (<LocalShippingIcon />)} */}
</span>
<>{!isDeleted && (
<div className={`flow w-full p-4 py-2 border-2 border-gray-300 rounded-md my-1 ${isSelected ? 'bg-gray-200' : ''}`}
onClick={handleShiftClick} onDoubleClick={copyAllPublisherNames}>
{/* Time Window Header */}
<div className="flex justify-between items-center mb-2 border-b pb-1">
<span className="flex text-lg font-semibold">
{`${common.getTimeRange(new Date(shift.startTime), new Date(shift.endTime))}`}
{/* {shift.requiresTransport && (<LocalShippingIcon />)} */}
{/* Toggle for Transport Requirement */}
<label className="ml-4 flex items-center">
<input type="checkbox" checked={shift.requiresTransport}
onChange={() => toggleRequiresTransport(shift.id)}
className="form-checkbox h-5 w-5 text-green-600" />
<span className="ml-2 text-sm text-gray-700">транспорт</span>
</label>
</span>
{/* Copy All Names Button */}
<button onClick={copyAllPublisherNames} className="bg-green-500 text-white py-1 px-2 text-sm rounded-md">
копирай имената {/* Placeholder for Copy icon */}
</button>
{/* Hint Message */}
{showCopyHint && (
<div className="absolute top-0 right-0 p-2 bg-green-200 text-green-800 rounded">
Имената са копирани
</div>
)}
</div>
{/* Copy All Names Button */}
<button onClick={copyAllPublisherNames} className="bg-green-500 text-white py-1 px-2 text-sm rounded-md">
копирай имената {/* Placeholder for Copy icon */}
</button>
{/* Hint Message */}
{showCopyHint && (
<div className="absolute top-0 right-0 p-2 bg-green-200 text-green-800 rounded">
Имената са копирани
</div>
)}
</div>
{/* Assignments */}
{assignments.map((ass, index) => {
const publisherInfo = allPublishersInfo.find(info => info?.id === ass.publisher.id) || ass.publisher;
{/* Assignments */}
{assignments.map((ass, index) => {
const publisherInfo = allPublishersInfo.find(info => info?.id === ass.publisher.id) || ass.publisher;
// Determine border styles
let borderStyles = '';
let canTransport = false;
if (selectedPublisher && selectedPublisher.id === ass.publisher.id) {
borderStyles += 'border-2 border-blue-300'; // Bottom border for selected publishers
}
else {
if (publisherInfo.availabilityCount == 0) //user has never the form
{
borderStyles = 'border-2 border-orange-300 ';
// Determine border styles
let borderStyles = '';
let canTransport = false;
if (selectedPublisher && selectedPublisher.id === ass.publisher.id) {
borderStyles += 'border-2 border-blue-300'; // Bottom border for selected publishers
}
else
//if there is no publisherInfo - draw red border - publisher is no longer available for the day!
if (!publisherInfo.availabilities || publisherInfo.availabilities.length == 0) {
borderStyles = 'border-2 border-red-500 ';
else {
if (publisherInfo.availabilityCount == 0) //user has never the form
{
borderStyles = 'border-2 border-orange-300 ';
}
else {
else
//if there is no publisherInfo - draw red border - publisher is no longer available for the day!
if (!publisherInfo.availabilities || publisherInfo.availabilities.length == 0) {
borderStyles = 'border-2 border-red-500 ';
}
else {
// checkig if the publisher is available for this assignment
const av = publisherInfo.availabilities?.find(av =>
av.startTime <= shift.startTime && av.endTime >= shift.endTime
);
if (av) {
borderStyles += 'border-l-2 border-blue-500 '; // Left border for specific availability conditions
ass.canTransport = av.isWithTransportIn || av.isWithTransportOut;
}
if (publisherInfo.hasUpToDateAvailabilities) {
//add green right border
borderStyles += 'border-r-2 border-green-300';
}
//the pub is the same time as last month
// if (publisherInfo.availabilities?.some(av =>
// (!av.dayOfMonth || av.isFromPreviousMonth) &&
// av.startTime <= ass.startTime &&
// av.endTime >= ass.endTime)) {
// borderStyles += 'border-t-2 border-yellow-500 '; // Left border for specific availability conditions
// }
// checkig if the publisher is available for this assignment
const av = publisherInfo.availabilities?.find(av =>
av.startTime <= shift.startTime && av.endTime >= shift.endTime
);
if (av) {
borderStyles += 'border-l-2 border-blue-500 '; // Left border for specific availability conditions
ass.canTransport = av.isWithTransportIn || av.isWithTransportOut;
}
if (publisherInfo.hasUpToDateAvailabilities) {
//add green right border
borderStyles += 'border-r-2 border-green-300';
}
}
//the pub is the same time as last month
// if (publisherInfo.availabilities?.some(av =>
// (!av.dayOfMonth || av.isFromPreviousMonth) &&
// av.startTime <= ass.startTime &&
// av.endTime >= ass.endTime)) {
// borderStyles += 'border-t-2 border-yellow-500 '; // Left border for specific availability conditions
// }
return (
<div key={index}
className={`flow space-x-2 rounded-md px-2 py-1 my-1 ${ass.isConfirmed ? 'bg-green-100' : 'bg-gray-100'} ${borderStyles}`}
>
<div className="flex justify-between items-center" onClick={() => handlePublisherClick(ass.publisher)}>
<span className="text-gray-700">{publisherInfo.firstName} {publisherInfo.lastName}</span>
<div className="flex items-left" >
{/* //if shift.isWithTransport, add trnsport button toggle, which sets ass.isWithTransportIn */}
{shift.requiresTransport && (
<span
onClick={ass.canTransport || true ? () => toggleTransport(ass) : undefined}
className={`material-icons ${ass.isWithTransport ? 'text-green-500 font-bold' : (transportProvided ? 'text-gray-400 ' : 'text-orange-400 font-bold')} ${ass.canTransport || ass.isWithTransport || true ? ' cursor-pointer' : 'cursor-not-allowed'} px-3 py-1 ml-2 rounded-md`}
>
{ass.isWithTransport ? "транспорт" : ass.canTransport ? "може транспорт" : "без транспорт"} <LocalShippingIcon />
</span>
)}
<button onClick={() => removeAssignment(ass.id)} className="text-white bg-red-500 hover:bg-red-600 px-3 py-1 ml-2 rounded-md" >
махни
</button>
}
}
return (
<div key={index}
className={`flow space-x-2 rounded-md px-2 py-1 my-1 ${ass.isConfirmed ? 'bg-green-100' : 'bg-gray-100'} ${borderStyles}`}
>
<div className="flex justify-between items-center" onClick={() => handlePublisherClick(ass.publisher)}>
<span className="text-gray-700">{publisherInfo.firstName} {publisherInfo.lastName}</span>
<div className="flex items-left" >
{/* //if shift.isWithTransport, add trnsport button toggle, which sets ass.isWithTransportIn */}
{shift.requiresTransport && (
<span
onClick={ass.canTransport ? () => toggleTransport(ass) : undefined}
className={`material-icons ${ass.isWithTransport ? 'text-green-500 font-bold' : (transportProvided ? 'text-gray-400 ' : 'text-orange-400 font-bold')} ${ass.canTransport ? ' cursor-pointer' : 'cursor-not-allowed'} px-3 py-1 ml-2 rounded-md`}
>
{ass.isWithTransport ? "транспорт" : ass.canTransport ? "може транспорт" : "без транспорт"} <LocalShippingIcon />
</span>
)}
<button onClick={() => removeAssignment(ass.id)} className="text-white bg-red-500 hover:bg-red-600 px-3 py-1 ml-2 rounded-md" >
махни
</button>
</div>
</div>
</div>
</div>
);
})}
);
})}
{/* This is a placeholder for the dropdown to add a publisher. You'll need to implement or integrate a dropdown component */}
{/* This is a placeholder for the dropdown to add a publisher. You'll need to implement or integrate a dropdown component */}
<div className="flex space-x-2 items-center">
{/* Add Button */}
<button onClick={() => setIsModalOpen(true)} className="bg-blue-500 text-white p-2 py-1 rounded-md">
добави {/* Placeholder for Add icon */}
</button>
</div>
<div className="flex space-x-2 items-center">
{/* Add Button */}
<button onClick={() => setIsModalOpen(true)} className="bg-blue-500 text-white p-2 py-1 rounded-md">
добави участник{/* Placeholder for Add icon */}
</button>
{assignments.length == 0 && (
<button onClick={() => deleteShift(shift.id)} className="bg-red-500 text-white p-2 py-1 rounded-md"
>изтрий смяната</button>
)}
</div>
{/* Modal for Publisher Search
{/* Modal for Publisher Search
forDate={new Date(shift.startTime)}
*/}
<Modal isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
forDate={new Date(shift.startTime)}
useFilterDate={useFilterDate}
onUseFilterDateChange={(value) => setUseFilterDate(value)}>
<Modal isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
forDate={new Date(shift.startTime)}
useFilterDate={useFilterDate}
onUseFilterDateChange={(value) => setUseFilterDate(value)}>
<PublisherSearchBox
selectedId={null}
isFocused={isModalOpen}
filterDate={useFilterDate ? new Date(shift.startTime) : null}
onChange={(publisher) => {
// Add publisher as assignment logic
setIsModalOpen(false);
addAssignment(publisher, shift.id);
}}
showAllAuto={true}
showSearch={true}
showList={false}
/>
</Modal>
</div >
<PublisherSearchBox
selectedId={null}
isFocused={isModalOpen}
filterDate={useFilterDate ? new Date(shift.startTime) : null}
onChange={(publisher) => {
// Add publisher as assignment logic
setIsModalOpen(false);
addAssignment(publisher, shift.id);
}}
showAllAuto={true}
showSearch={true}
showList={false}
/>
</Modal>
</div >
)}</>
);
}

View File

@ -16,7 +16,13 @@ import { MdToday } from 'react-icons/md';
import { useSwipeable } from 'react-swipeable';
import axiosInstance from '../../src/axiosSecure';
import { set } from 'date-fns';
// import { set, format, addDays } from 'date-fns';
// import { isEqual, isSameDay, getHours, getMinutes } from 'date-fns';
import { filter } from 'jszip';
import e from 'express';
// Set moment to use the Bulgarian locale
moment.locale('bg');
@ -40,11 +46,13 @@ const messages = {
const AvCalendar = ({ publisherId, events, selectedDate }) => {
const [date, setDate] = useState(new Date());
const [currentView, setCurrentView] = useState('month');
//ToDo: see if we can optimize this
const [evts, setEvents] = useState(events); // Existing events
const [displayedEvents, setDisplayedEvents] = useState(evts); // Events to display in the calendar
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedEvents, setSelectedEvents] = useState([]);
const [displayedEvents, setDisplayedEvents] = useState(evts); // Events to display in the calendar
const [currentView, setCurrentView] = useState('month');
const [isModalOpen, setIsModalOpen] = useState(false);
const [visibleRange, setVisibleRange] = useState(() => {
const start = new Date();
start.setDate(1); // Set to the first day of the current month
@ -162,6 +170,31 @@ const AvCalendar = ({ publisherId, events, selectedDate }) => {
occurrences.push(occurrence);
}
};
const filterEvents = (evts, publisherId, startdate) => {
setDate(startdate); // Assuming setDate is a function that sets some state or context
const filterDayOfWeek = startdate.getDay(); // Sunday - 0, Monday - 1, ..., Saturday - 6
// Filter events based on the publisher ID and the start date/time
const existingEvents = evts?.filter(event => {
// Ensure the event belongs to the specified publisher
const isPublisherMatch = (event.publisher?.id || event.publisherId) === publisherId;
const eventDayOfWeek = event.startTime.getDay();
let isDateMatch;
const eventDate = new Date(event.startTime);
if (event.repeatWeekly && filterDayOfWeek === eventDayOfWeek) {
isDateMatch = true;
} else if (event.date) {
// Compare the full date. issameday is not working. do it manually
isDateMatch = eventDate.setHours(0, 0, 0, 0) === new Date(startdate).setHours(0, 0, 0, 0);
}
return isPublisherMatch && isDateMatch;
});
return existingEvents;
};
// Define min and max times
const minHour = 8; // 8:00 AM
@ -172,12 +205,13 @@ const AvCalendar = ({ publisherId, events, selectedDate }) => {
maxTime.setHours(maxHour, 0, 0);
const totalHours = maxHour - minHour;
const handleSelect = ({ start, end }) => {
const handleSelect = ({ mode, start, end }) => {
const startdate = typeof start === 'string' ? new Date(start) : start;
const enddate = typeof end === 'string' ? new Date(end) : end;
if (!start || !end) return;
if (startdate < new Date() || end < new Date() || startdate > end) return;
//readonly for past dates (ToDo: if not admin)
//if (startdate < new Date() || end < new Date() || startdate > end) return;
// Check if start and end are on the same day
if (startdate.toDateString() !== enddate.toDateString()) {
@ -198,52 +232,25 @@ const AvCalendar = ({ publisherId, events, selectedDate }) => {
setDate(start);
// get exising events for the selected date
const existingEvents = evts?.filter(event => (event.publisher?.id || event.publisherId) === publisherId && new Date(event.date).toDateString() === startdate.toDateString());
// const existingEvents = evts?.filter(event => {
// return event.publisherId === publisherId &&
// new Date(event.startTime).getFullYear() === start.getFullYear() &&
// new Date(event.startTime).getMonth() === start.getMonth() &&
// new Date(event.startTime).getDate() === start.getDate();
// });
//ToDo: properly fix this. filterEvents does not return the expcted results
let existingEvents = filterEvents(evts, publisherId, startdate);
// if existingEvents is empty - create new with the selected range
// if (existingEvents.length === 0) {
// existingEvents = [{ startTime: start, endTime: end }];
// }
console.log("handleSelect: " + existingEvents);
setSelectedEvents(existingEvents);
// setSelectedEvent({
// date: start,
// startTime: start,
// endTime: end,
// dayOfMonth: start.getDate(),
// isActive: true,
// publisherId: publisherId,
// // Add any other initial values needed
// //set dayOfMonth to null, so that we repeat the availability every week
// dayOfMonth: null,
// });
setIsModalOpen(true);
};
const handleEventClick = (event) => {
if (event.type === "assignment") return;
handleSelect({ start: event.startTime, end: event.endTime });
// Handle event click
// const eventForEditing = {
// ...event,
// startTime: new Date(event.startTime),
// endTime: new Date(event.endTime),
// publisherId: event.publisherId || event.publisher?.connect?.id,
// repeatWeekly: event.repeatWeekly || false,
// };
// //strip title, start, end and allDay properties
// delete eventForEditing.title;
// delete eventForEditing.start;
// delete eventForEditing.end;
// delete eventForEditing.type;
// delete eventForEditing.publisher
// console.log("handleEventClick: " + eventForEditing);
// setSelectedEvents([eventForEditing]);
// setIsModalOpen(true);
//select the whole day
let start = new Date(event.startTime);
start.setHours(0, 0, 0, 0);
let end = new Date(event.startTime);
end.setHours(23, 59, 59, 999);
handleSelect({ mode: 'select', start: start, end: end });
};
const handleDialogClose = async (dialogEvent) => {
@ -256,10 +263,8 @@ const AvCalendar = ({ publisherId, events, selectedDate }) => {
newEvents.forEach(event => {
event.startTime = new Date(event.startTime);
event.endTime = new Date(event.endTime);
});
setEvents(newEvents);
}
console.log("handleSave: ", dialogEvent);
@ -269,6 +274,56 @@ const AvCalendar = ({ publisherId, events, selectedDate }) => {
setIsModalOpen(false);
};
const eventStyleGetter = (event, start, end, isSelected) => {
//console.log("eventStyleGetter: " + event);
let backgroundColor = '#3174ad'; // default color for calendar events - #3174ad
if (currentView === 'agenda') {
return { style: {} }
}
if (event.type === "assignment") {
//event.title = event.publisher.name; //ToDo: add other publishers names
}
if (event.type === "availability") {
}
if (event.isFromPreviousAssignment) { //ToDo: does it work?
// orange-500 from Tailwind CSS
backgroundColor = '#f56565';
}
if (event.isFromPreviousMonth) {
//gray
backgroundColor = '#a0aec0';
}
if (event.isActive) {
switch (event.type) {
case 'assignment':
backgroundColor = event.isConfirmed ? '#48bb78' : '#f6e05e'; // green-500 and yellow-300 from Tailwind CSS
break;
case 'recurring':
backgroundColor = '#63b3ed'; // blue-300 from Tailwind CSS
break;
default: // availability
//backgroundColor = '#a0aec0'; // gray-400 from Tailwind CSS
break;
}
} else {
backgroundColor = '#a0aec0'; // Default color for inactive events
}
return {
style: {
backgroundColor,
opacity: 0.8,
color: 'white',
border: '0px',
display: 'block',
}
};
}
// Custom Components
const EventWrapper = ({ event, style }) => {
const [isHovered, setIsHovered] = useState(false);
let eventStyle = {
@ -386,51 +441,6 @@ const AvCalendar = ({ publisherId, events, selectedDate }) => {
);
};
const eventStyleGetter = (event, start, end, isSelected) => {
//console.log("eventStyleGetter: " + event);
let backgroundColor = '#3174ad'; // default color for calendar events - #3174ad
if (currentView === 'agenda') {
return { style: {} }
}
if (event.type === "assignment") {
//event.title = event.publisher.name; //ToDo: add other publishers names
}
if (event.type === "availability") {
}
if (event.isFromPreviousAssignment) { //ToDo: does it work?
// orange-500 from Tailwind CSS
backgroundColor = '#f56565';
}
if (event.isActive) {
switch (event.type) {
case 'assignment':
backgroundColor = event.isConfirmed ? '#48bb78' : '#f6e05e'; // green-500 and yellow-300 from Tailwind CSS
break;
case 'recurring':
backgroundColor = '#63b3ed'; // blue-300 from Tailwind CSS
break;
default: // availability
//backgroundColor = '#a0aec0'; // gray-400 from Tailwind CSS
break;
}
} else {
backgroundColor = '#a0aec0'; // Default color for inactive events
}
return {
style: {
backgroundColor,
opacity: 0.8,
color: 'white',
border: '0px',
display: 'block',
}
};
}
// Custom Toolbar Component
const CustomToolbar = ({ onNavigate, label, onView, view }) => {
return (
@ -463,6 +473,17 @@ const AvCalendar = ({ publisherId, events, selectedDate }) => {
);
};
const CustomEventAgenda = ({
event
}) => (
<span>
<em style={{ color: 'black' }}>{event.title}</em>
<p>{event.desc}</p>
</span>
);
return (
<> <div {...handlers} className="flex flex-col"
>
@ -488,10 +509,15 @@ const AvCalendar = ({ publisherId, events, selectedDate }) => {
components={{
event: EventWrapper,
toolbar: CustomToolbar,
view: CustomEventAgenda,
agenda: {
event: CustomEventAgenda
},
// ... other custom components
}}
eventPropGetter={(eventStyleGetter)}
date={date}
showAllEvents={true}
onNavigate={setDate}
className="rounded-lg shadow-lg"
/>

View File

@ -9,56 +9,63 @@ import Body from 'next/document'
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import { set } from "date-fns"
export default function Layout({ children }: { children: ReactNode }) {
const router = useRouter()
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
export default function Layout({ children }) {
const router = useRouter();
// auto resize for tablets: disabled.
// useEffect(() => {
// // Function to check and set the state based on window width
// const handleResize = () => {
// if (window.innerWidth < 768) { // Assuming 768px as the breakpoint for mobile devices
// setIsSidebarOpen(false);
// } else {
// setIsSidebarOpen(true);
// }
// };
// // Set initial state
// handleResize();
// // Add event listener
// window.addEventListener('resize', handleResize);
// // Cleanup
// return () => window.removeEventListener('resize', handleResize);
// }, []);
// Assuming that during SSR, we don't want the sidebar to be open.
const [isSmallScreen, setIsSmallScreen] = useState(true);
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
useEffect(() => {
// This function determines if we're on a small screen
const checkIfSmallScreen = () => window.innerWidth < 768;
// Set the initial screen size and sidebar state
const initialSmallScreenCheck = checkIfSmallScreen();
setIsSmallScreen(initialSmallScreenCheck);
setIsSidebarOpen(!initialSmallScreenCheck);
const handleResize = () => {
const smallScreenCheck = checkIfSmallScreen();
setIsSmallScreen(smallScreenCheck);
// On small screens, we want to ensure the sidebar is not open by default when resizing
if (smallScreenCheck) {
setIsSidebarOpen(false);
}
};
// Add event listener
window.addEventListener('resize', handleResize);
// Cleanup event listener on component unmount
return () => window.removeEventListener('resize', handleResize);
}, []);
// Toggle the sidebar only when the button is clicked
const toggleSidebar = () => {
setIsSidebarOpen(!isSidebarOpen);
};
// We use `isSmallScreen` to determine the margin-left on small screens
// and use `isSidebarOpen` to determine if we need to push the content on large screens.
const marginLeftClass = isSmallScreen ? 'ml-0' : isSidebarOpen ? 'ml-60' : 'ml-6';
return (
// <div className="h-screen w-screen" >
// <div className="flex flex-col">
// <div className="flex flex-row h-screen">
// <ToastContainer />
// <Sidebar isSidebarOpen={isSidebarOpen} toggleSidebar={toggleSidebar} />
// <main className={`pr-10 transition-all duration-300 ${isSidebarOpen ? 'ml-64 w-[calc(100%-16rem)] ' : 'ml-0 w-[calc(100%)] '}`}>
<div className="">
<div className="flex flex-col">
<div className="flex flex-row h-[90vh] w-screen ">
<div className="flex flex-row min-h-screen w-screen">
<ToastContainer position="top-center" style={{ zIndex: 9999 }} />
<Sidebar isSidebarOpen={isSidebarOpen} toggleSidebar={toggleSidebar} />
<main className={`w-full pr-10 transition-all h-[90vh] duration-300 ${isSidebarOpen ? 'ml-60 ' : 'ml-6'}`}>
{children}
<main className={`flex-1 transition-all duration-300 ${marginLeftClass}`}>
<div className="p-4 mx-auto pr-8 pl-0">
{children}
</div>
</main>
</div>
{/* <div className="justify-end items-center text-center ">
<Footer />
</div> */}
</div>
</div>
);
}
}

View File

@ -7,7 +7,7 @@ import DayOfWeek from "../DayOfWeek";
import TextEditor from "../TextEditor";
import FileUploadWithPreview from 'components/FileUploadWithPreview ';
import ProtectedRoute, { serverSideAuth } from "../..//components/protectedRoute";
import ProtectedRoute, { serverSideAuth } from "../../components/protectedRoute";
import { UserRole } from "@prisma/client";
const common = require('src/helpers/common');

View File

@ -5,6 +5,7 @@ import { useEffect, useState } from 'react'
import toast from "react-hot-toast";
import axiosInstance from '../../src/axiosSecure';
import ProtectedRoute, { serverSideAuth } from "../../components/protectedRoute";
//add months to date. works with negative numbers and numbers > 12
export function addMonths(numOfMonths, date) {
@ -53,6 +54,23 @@ export default function PublisherCard({ publisher }) {
console.log(JSON.stringify(error));
}
};
const handleLoginAs = async (userId) => {
const response = await fetch('/api/auth/login-as', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ userId }),
});
if (response.ok) {
const data = await response.json();
// Assuming you have some context or state management to update the session
updateSession(data.session);
} else {
alert("Failed to impersonate user.");
}
};
return isCardVisible ? (
// className="block p-6 max-w-sm bg-white rounded-lg border border-gray-200 shadow-md hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 dark:hover:bg-gray-700 mb-3"
@ -89,6 +107,10 @@ export default function PublisherCard({ publisher }) {
<path fillRule="evenodd" d="M4.293 4.293A1 1 0 015.707 3.707L10 8l4.293-4.293a1 1 0 111.414 1.414L11.414 9l4.293 4.293a1 1 0 01-1.414 1.414L10 10.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 9 4.293 4.707a1 1 0 010-1.414z" clipRule="evenodd" /> */}
</svg>
</button>
<ProtectedRoute>
<button onClick={() => handleLoginAs(publisher.id)}>Login as</button>
</ProtectedRoute>
</div>
<style jsx>{`
.cardFadeOut {

View File

@ -0,0 +1,147 @@
import React, { useState, useEffect } from 'react';
import axiosInstance from '../../src/axiosSecure';
import { toast } from 'react-toastify';
import { set } from 'date-fns';
function SearchReplacement({ shiftId, assignmentId }) {
const [users, setUsers] = useState([]);
const [showModal, setShowModal] = useState(false);
const fetchUsers = async () => {
// Dummy endpoint and shiftId, replace with actual
const response = await axiosInstance.get('/api/?action=getPossibleShiftPublisherEmails&shiftId=' + shiftId);
setUsers(response.data);
setShowModal(true);
};
const sendCoverMeRequestByEmail = (selectedGroups) => {
// You can map 'selectedGroups' to determine which API calls to make
console.log("Selected Groups:", selectedGroups);
axiosInstance.post('/api/email?action=sendCoverMeRequestByEmail', {
assignmentId: assignmentId,
toSubscribed: selectedGroups.includes('subscribedPublishers'),
toAvailable: selectedGroups.includes('availablePublishers'),
}).then(response => {
console.log("response", response);
setShowModal(false);
//toast success and confirm the change
toast.success("Заявката за заместник е изпратена!", {
onClose: () => {
window.location.reload();
}
});
}).catch(error => {
console.log("error", error);
});
}
return (
<div>
<button
className="mr-2 mb-2 inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
onClick={() => fetchUsers(shiftId)}
>
Търси заместник
</button>
{
showModal && (
<ConfirmationModal
isOpen={showModal}
onClose={() => setShowModal(false)}
onConfirm={sendCoverMeRequestByEmail}
subscribedPublishers={users.subscribedPublishers}
availablePublishers={users.availablePublishers}
/>
// <ConfirmationModal
// isOpen={showModal}
// users={users}
// onClose={() => setShowModal(false)}
// onConfirm={(selectedUsers) => {
// console.log(selectedUsers); // Here you would call the email API
// setShowModal(false);
// }}
// />
)
}
</div >
);
}
function ConfirmationModal({ isOpen, onClose, onConfirm, subscribedPublishers, availablePublishers }) {
const [selectedGroups, setSelectedGroups] = useState([]);
const handleToggleGroup = (groupName) => {
setSelectedGroups(prev => {
if (prev.includes(groupName)) {
return prev.filter(name => name !== groupName);
} else {
return [...prev, groupName];
}
});
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 flex items-center justify-center z-50">
<div className="absolute inset-0 bg-black opacity-50" onClick={onClose}></div>
<div className="bg-white p-6 rounded-lg shadow-lg z-10">
<h2 className="text-lg font-semibold mb-4">Можете да изпратите заявка за заместник до следните групи:</h2>
<div className="mb-4">
<label className="block mb-2">
<div className="flex items-center mb-2">
<input
type="checkbox"
className="mr-2 leading-tight"
checked={selectedGroups.includes('subscribedPublishers')}
onChange={() => handleToggleGroup('subscribedPublishers')}
/>
<span className="text-sm font-medium">Абонирани:</span>
</div>
<div className="flex flex-wrap">
{subscribedPublishers.map(pub => (
<span key={pub.id} className="bg-gray-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2">{pub.name}</span>
))}
</div>
</label>
</div>
<div className="mb-4">
<label className="block mb-2">
<div className="flex items-center mb-2">
<input
type="checkbox"
className="mr-2 leading-tight"
checked={selectedGroups.includes('availablePublishers')}
onChange={() => handleToggleGroup('availablePublishers')}
/>
<span className="text-sm font-medium">На разположение:</span>
</div>
<div className="flex flex-wrap">
{availablePublishers.map(pub => (
<span key={pub.id} className="bg-gray-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2">{pub.name}</span>
))}
</div>
</label>
</div>
<div className="text-right">
<button
className="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600 mr-2"
onClick={() => onConfirm(selectedGroups)}
>
Потвърждавам
</button>
<button
className="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400"
onClick={onClose}
>
Отказ
</button>
</div>
</div>
</div>
);
}
export default SearchReplacement;

View File

@ -4,7 +4,7 @@ import { toast } from 'react-toastify';
import { useRouter } from "next/router";
import Link from "next/link";
import DayOfWeek from "../DayOfWeek";
import { Location, UserRole } from "@prisma/client";
import { ReportType } from "@prisma/client";
const common = require('src/helpers/common');
import { useSession } from "next-auth/react"
@ -97,6 +97,7 @@ export default function ExperienceForm({ publisherId, assgnmentId, existingItem,
e.preventDefault();
item.publisher = { connect: { id: pubId } };
item.location = { connect: { id: parseInt(item.locationId) } };
item.type = ReportType.Experience;
delete item.locationId;
try {

View File

@ -4,7 +4,7 @@ import { toast } from 'react-toastify';
import { useRouter } from "next/router";
import Link from "next/link";
import DayOfWeek from "../DayOfWeek";
import { Location, UserRole } from "@prisma/client";
import { ReportType } from "@prisma/client";
const common = require('src/helpers/common');
import { useSession } from "next-auth/react"
@ -55,7 +55,7 @@ export default function FeedbackForm({ publisherId, onDone }) {
assignmentId: 0,
publisherId: publisherId,
date: new Date(),
placementCount: 0,
placementCount: 1,
videoCount: 0,
returnVisitInfoCount: 0,
conversationCount: 0
@ -73,6 +73,8 @@ export default function FeedbackForm({ publisherId, onDone }) {
const handleSubmit = async (e) => {
e.preventDefault();
item.publisher = { connect: { id: pubId } };
//ToDo: create dedicated feedback type instead of using placementCount for subtype
item.type = item.placementCount === 1 ? ReportType.Feedback_Problem : (item.placementCount === 2 ? ReportType.Feedback_Suggestion : ReportType.Feedback);
delete item.assignmentId;
try {

View File

@ -4,6 +4,7 @@ import { toast } from "react-hot-toast";
import { useRouter } from "next/router";
import Link from "next/link";
import { useSession } from "next-auth/react"
import { ReportType } from "@prisma/client";
const common = require('src/helpers/common');
@ -89,6 +90,7 @@ export default function ReportForm({ shiftId, existingItem, onDone }) {
item.publisher = { connect: { id: publisherId } };
item.shift = { connect: { id: parseInt(item.shiftId) } };
item.date = new Date(item.date);
item.type = ReportType.Report;
delete item.publisherId;
delete item.shiftId;
item.placementCount = parseInt(item.placementCount);

View File

@ -129,7 +129,7 @@ export default function Sidebar({ isSidebarOpen, toggleSidebar }) {
className="fixed top-1 left-4 z-40 m- text-xl bg-white border border-gray-200 p-2 rounded-full shadow-lg focus:outline-none"
style={{ transform: isSidebarOpen ? `translateX(${sidebarWidth - 64}px)` : 'translateX(-20px)' }}></button>
<aside id="sidenav" ref={sidebarRef}
className="px-2 fixed top-0 left-0 z-30 h-screen overflow-y-auto bg-white border-r dark:bg-gray-900 dark:border-gray-700 transition-all duration-300 w-64"
className="px-2 fixed top-0 left-0 z-30 h-screen overflow-y-auto bg-white border-r dark:bg-gray-900 dark:border-gray-700 transition-all duration-300 sm:translate-x-0 w-64"
style={{ width: `${sidebarWidth}px`, transform: isSidebarOpen ? 'translateX(0)' : `translateX(-${sidebarWidth - 16}px)` }}>
<h2 className="text-2xl font-semibold text-gray-800 dark:text-white pt-2 pl-4 pb-4"
title={`v.${packageVersion} ${process.env.GIT_COMMIT_ID}`} >Специално Свидетелстване София</h2>
@ -163,7 +163,7 @@ function UserSection({ session }) {
function SignInButton() {
return (
<div className="items-center py-2" onClick={() => signIn()}>
<div className="items-center py-2 font-bold" onClick={() => signIn()}>
<button>Впишете се</button>
</div>
);

BIN
mwitnessing_totp_setup.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -5,7 +5,7 @@ const withPWA = require('next-pwa')({
register: true, // ?
publicExcludes: ["!_error*.js"], //?
disable: process.env.NODE_ENV === 'development',
//disable: process.env.NODE_ENV === 'development',
})
module.exports = withPWA({

20
package-lock.json generated
View File

@ -4802,6 +4802,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
"integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==",
"optional": true,
"dependencies": {
"clean-stack": "^2.0.0",
"indent-string": "^4.0.0"
@ -5771,6 +5772,7 @@
"url": "https://github.com/sponsors/sibiraj-s"
}
],
"optional": true,
"engines": {
"node": ">=8"
}
@ -5784,6 +5786,7 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
"integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==",
"optional": true,
"engines": {
"node": ">=6"
}
@ -8546,7 +8549,8 @@
"node_modules/hosted-git-info": {
"version": "2.8.9",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz",
"integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw=="
"integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==",
"optional": true
},
"node_modules/hsl-to-hex": {
"version": "1.0.0",
@ -8888,6 +8892,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
"integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
"optional": true,
"engines": {
"node": ">=8"
}
@ -8915,6 +8920,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz",
"integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==",
"optional": true,
"engines": {
"node": ">=10"
}
@ -10943,6 +10949,7 @@
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
"integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==",
"optional": true,
"dependencies": {
"hosted-git-info": "^2.1.4",
"resolve": "^1.10.0",
@ -10954,6 +10961,7 @@
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
"optional": true,
"bin": {
"semver": "bin/semver"
}
@ -13870,6 +13878,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz",
"integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==",
"optional": true,
"dependencies": {
"aggregate-error": "^3.0.0"
},
@ -15871,6 +15880,7 @@
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz",
"integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==",
"optional": true,
"dependencies": {
"spdx-expression-parse": "^3.0.0",
"spdx-license-ids": "^3.0.0"
@ -15879,12 +15889,14 @@
"node_modules/spdx-exceptions": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz",
"integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w=="
"integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==",
"optional": true
},
"node_modules/spdx-expression-parse": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz",
"integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==",
"optional": true,
"dependencies": {
"spdx-exceptions": "^2.1.0",
"spdx-license-ids": "^3.0.0"
@ -15893,7 +15905,8 @@
"node_modules/spdx-license-ids": {
"version": "3.0.17",
"resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.17.tgz",
"integrity": "sha512-sh8PWc/ftMqAAdFiBu6Fy6JUOYjqDJBJvIhpfDMyHrr0Rbp5liZqd4TjtQ/RgfLjKFZb+LMx5hpml5qOWy0qvg=="
"integrity": "sha512-sh8PWc/ftMqAAdFiBu6Fy6JUOYjqDJBJvIhpfDMyHrr0Rbp5liZqd4TjtQ/RgfLjKFZb+LMx5hpml5qOWy0qvg==",
"optional": true
},
"node_modules/ssf": {
"version": "0.8.2",
@ -17442,6 +17455,7 @@
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
"integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==",
"optional": true,
"dependencies": {
"spdx-correct": "^3.0.0",
"spdx-expression-parse": "^3.0.0"

View File

@ -1,6 +1,6 @@
{
"name": "pwwa",
"version": "1.1.2",
"version": "1.2.0",
"private": true,
"description": "JW PW Web App",
"repository": "http://git.d-popov.com/popov/next-cart-app.git",
@ -12,7 +12,7 @@
"debug": "node server.js",
"debug-env": "dotenv -e .env.$APP_ENV -- nodemon --inspect server.js",
"nodeenv": "dotenv -e .env.$APP_ENV -- node server.js",
"prod": "npx next build && dotenv -e .env.production -- node server.js",
"prod": "dotenv -e .env.production -- node server.js",
"build": "next build",
"buildWin": "npm run build",
"start": "next start",
@ -113,4 +113,4 @@
"depcheck": "^1.4.7",
"prisma": "^5.12.1"
}
}
}

View File

@ -18,6 +18,7 @@ const common = require("../../../src/helpers/common");
import { isLoggedIn, setAuthTokens, clearAuthTokens, getAccessToken, getRefreshToken } from 'axios-jwt'
console.log("appleID:", process.env.APPLE_APP_ID);
// console.log(process.env.EMAIL_SERVER)
// For more information on each option (and a full list of options) go to
// https://next-auth.js.org/configuration/options
@ -41,10 +42,10 @@ export const authOptions: NextAuthOptions = {
}
}
}),
AppleProvider({
clientId: process.env.APPLE_ID,
clientSecret: process.env.APPLE_SECRET
}),
// AppleProvider({
// clientId: process.env.APPLE_APP_ID,
// clientSecret: process.env.APPLE_SECRET
// }),
// AzureADProvider({
// clientId: process.env.AZURE_AD_CLIENT_ID,
// clientSecret: process.env.AZURE_AD_CLIENT_SECRET,

View File

@ -0,0 +1,45 @@
// pages/api/auth/apple.js
import jwt from 'jsonwebtoken';
import axios from 'axios';
import fs from 'fs';
import path from 'path';
const dotenv = require("dotenv");
export default async function handler(req, res) {
if (req.method === 'GET') {
// Generate the client secret
const clientSecret = generateClientSecret();
// const redirectUri = `${req.headers.origin}/api/auth/apple/callback`;
const redirectUri = `https://sofia.mwitnessing.com/api/auth/callback/apple`;
// Redirect to Apple's authorization page
const url = `https://appleid.apple.com/auth/authorize?response_type=code&client_id=${process.env.APPLE_APP_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=email&response_mode=form_post&state=initial&usePopup=true&client_secret=${encodeURIComponent(clientSecret)}`;
res.redirect(url);
} else {
res.status(405).json({ error: 'Method not allowed' });
}
}
function generateClientSecret() {
const appleKey = fs.readFileSync(path.resolve('./_deploy/appleKey.p8'), 'utf8');
const teamID = process.env.APPLE_TEAM_ID || "XC57P9SXDK";
const keyID = process.env.APPLE_KEY_ID || "TB3V355G5Y";
const appleAppID = process.env.APPLE_APP_ID;
// Token expiration
const now = Math.floor(Date.now() / 1000);
const exp = now + 86400 * 180; // 6 months
const claims = {
iss: teamID,
iat: now,
exp: exp,
aud: 'https://appleid.apple.com',
sub: appleAppID,
};
const token = jwt.sign(claims, appleKey, { algorithm: 'ES256', header: { alg: 'ES256', kid: keyID } });
console.log("generated new token:" + token);
return token;
}

View File

@ -0,0 +1,42 @@
// pages/api/auth/apple-token.js
import jwt from 'jsonwebtoken';
import fs from 'fs';
import path from 'path';
const dotenv = require("dotenv");
export default async function handler(req, res) {
if (req.method === 'GET') {
try {
const appleKey = fs.readFileSync(path.resolve('./_deploy/appleKey.p8'), 'utf8');
const teamID = process.env.APPLE_TEAM_ID || "XC57P9SXDK";
const keyID = process.env.APPLE_KEY_ID || "TB3V355G5Y";
const appleAppID = process.env.APPLE_APP_ID || "com.mwitnessing.mwitnessing";
const token = jwt.sign({}, appleKey, {
algorithm: 'ES256',
expiresIn: '180d',
issuer: teamID,
header: {
alg: 'ES256',
kid: keyID,
},
audience: 'https://appleid.apple.com',
subject: appleAppID,
});
// Redirect to Apple's authentication page, or send the token to the client to do so
console.log(token);
res.status(200).send({
message: 'Generated token for Apple Sign In',
token: token
});
} catch (error) {
console.error('Error signing in with Apple:', error);
res.status(500).send({ error: 'Failed to sign in with Apple' });
}
} else {
// Handle any non-GET requests
res.setHeader('Allow', ['GET']);
res.status(405).end(`Method ${req.method} Not Allowed`);
}
}

View File

@ -0,0 +1,40 @@
// pages/api/auth/login-as.js
import { getSession } from "next-auth/react";
import prisma from '../../../lib/prisma'; // Adjust the path as per your setup
export default async function handler(req, res) {
const session = await getSession({ req });
if (session && session.user.role === 'admin') {
const { userId } = req.body;
const userToImpersonate = await prisma.publisher.findUnique({
where: { id: userId }
});
if (userToImpersonate) {
// Create a custom session object for the impersonated user
const impersonatedSession = {
...session,
user: {
...session.user,
id: userToImpersonate.id,
email: userToImpersonate.email,
name: userToImpersonate.name,
role: userToImpersonate.role,
// add other necessary fields
},
impersonating: true, // flag to indicate impersonation
originalUser: session.user // save the original user for later
};
// Here you would typically use some method to create a session server-side
// For this example, we'll just send the impersonated session as a response
res.status(200).json({ session: impersonatedSession });
} else {
res.status(404).json({ error: 'User not found' });
}
} else {
res.status(403).json({ error: 'Unauthorized' });
}
}

0
pages/api/content.ts Normal file
View File

View File

@ -0,0 +1,145 @@
import path from 'path';
import { promises as fs } from 'fs';
import express from 'express';
import type { NextApiRequest, NextApiResponse } from 'next';
import nc from 'next-connect';
const handler = nc({
onError: (err, req, res, next) => {
console.error(err.stack);
res.status(500).end('Something broke!');
},
onNoMatch: (req, res) => {
res.status(404).end('Page is not found');
}
});
handler.use((req: NextApiRequest, res: NextApiResponse, next) => {
const subfolder = req.query.subfolder as string;
const upload = createUploadMiddleware(subfolder).array('image');
upload(req, res, (err) => {
if (err) {
return res.status(500).json({ error: 'Failed to upload files.', details: err.message });
}
next();
});
});
handler.post((req: NextApiRequest, res: NextApiResponse) => {
// Process uploaded files
// Example response
res.json({ message: 'Files uploaded successfully', files: req.files });
});
handler.get((req: NextApiRequest, res: NextApiResponse) => {
// Handle listing files
//listFiles(req, res, req.subfolder);
listFiles(req, res, req.query.subfolder as string);
});
handler.delete((req: NextApiRequest, res: NextApiResponse) => {
// Handle deleting files
deleteFile(req, res, req.query.subfolder as string);
});
export const config = {
api: {
bodyParser: false,
},
};
export default handler;
// ------------------------------------------------------------
//handling file uploads
import multer from 'multer';
import sharp from 'sharp';
// Generalized Multer configuration
export const createUploadMiddleware = (folder: string) => {
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const uploadPath = path.join(process.cwd(), 'public/content', folder);
if (!fs.existsSync(uploadPath)) {
fs.mkdirSync(uploadPath, { recursive: true });
}
cb(null, uploadPath);
},
filename: (req, file, cb) => {
const prefix = req.body.prefix || path.parse(file.originalname).name;
cb(null, `${prefix}${path.extname(file.originalname)}`);
}
});
return multer({ storage });
};
async function processFiles(req, res, folder) {
if (!req.files || req.files.length === 0) {
return res.status(400).json({ error: 'No files uploaded.' });
}
const uploadDir = path.join(process.cwd(), 'public/content', folder);
const thumbDir = path.join(uploadDir, "thumb");
if (!fs.existsSync(thumbDir)) {
fs.mkdirSync(thumbDir, { recursive: true });
}
try {
const processedFiles = await Promise.all(req.files.map(async (file) => {
const originalPath = path.join(uploadDir, file.filename);
const thumbPath = path.join(thumbDir, file.filename);
await sharp(file.path)
.resize({ width: 1920, fit: sharp.fit.inside, withoutEnlargement: true })
.jpeg({ quality: 80 })
.toFile(originalPath);
await sharp(file.path)
.resize(320, 320, { fit: sharp.fit.inside, withoutEnlargement: true })
.toFile(thumbPath);
fs.unlinkSync(file.path); // Remove temp file
return {
originalUrl: `/content/${folder}/${file.filename}`,
thumbUrl: `/content/${folder}/thumb/${file.filename}`
};
}));
res.json(processedFiles);
} catch (error) {
console.error('Error processing files:', error);
res.status(500).json({ error: 'Error processing files.' });
}
}
// List files in a directory
async function listFiles(req, res, folder) {
const directory = path.join(process.cwd(), 'public/content', folder);
try {
const files = await fs.promises.readdir(directory);
const imageUrls = files.map(file => `${req.protocol}://${req.get('host')}/content/${folder}/${file}`);
res.json({ imageUrls });
} catch (err) {
console.error('Error reading uploads directory:', err);
res.status(500).json({ error: 'Internal Server Error' });
}
}
// Delete a file
async function deleteFile(req, res, folder) {
const filename = req.query.file;
if (!filename) {
return res.status(400).send('Filename is required.');
}
try {
const filePath = path.join(process.cwd(), 'public/content', folder, filename);
await fs.unlink(filePath);
res.status(200).send('File deleted successfully.');
} catch (error) {
res.status(500).send('Failed to delete the file.');
}
}

View File

@ -1,15 +0,0 @@
import path from 'path';
import { promises as fs } from 'fs';
export default async function handler(req, res) {
//Find the absolute path of the json directory and the requested file contents
const jsonDirectory = path.join(process.cwd(), 'content');
const requestedFile = req.query.nextcrud[0];
const fileContents = await fs.readFile(path.join(jsonDirectory, requestedFile), 'utf8');
// try to determine the content type from the file extension
const contentType = requestedFile.endsWith('.json') ? 'application/json' : 'text/plain';
// return the file contents with the appropriate content type
res.status(200).setHeader('Content-Type', contentType).end(fileContents);
}

View File

@ -4,9 +4,11 @@ import { getToken } from "next-auth/jwt";
import type { NextApiRequest, NextApiResponse } from 'next';
import { createRouter, expressWrapper } from "next-connect";
const common = require('../../src/helpers/common');
const data = require('../../src/helpers/data');
const emailHelper = require('../../src/helpers/email');
const { v4: uuidv4 } = require('uuid');
const CON = require("../../src/helpers/const");
import { EventLogType } from "@prisma/client";
import fs from 'fs';
import path from 'path';
@ -127,6 +129,15 @@ export default async function handler(req, res) {
}
}
});
await prisma.eventLog.create({
data: {
date: new Date(),
publisher: { connect: { id: publisher.id } },
shift: { connect: { id: assignment.shiftId } },
type: EventLogType.AssignmentReplacementAccepted,
content: "Заявка за заместване приета от " + publisher.firstName + " " + publisher.lastName
}
});
const shiftStr = `${CON.weekdaysBG[assignment.shift.startTime.getDay()]} ${CON.GetDateFormat(assignment.shift.startTime)} at ${assignment.shift.cartEvent.location.name} from ${CON.GetTimeFormat(assignment.shift.startTime)} to ${CON.GetTimeFormat(assignment.shift.endTime)}`;
@ -201,7 +212,7 @@ export default async function handler(req, res) {
return res.status(401).json({ message: "Unauthorized to call this API endpoint" });
}
const user = await prisma.publisher.findUnique({
const publisher = await prisma.publisher.findUnique({
where: {
email: token.email
}
@ -209,13 +220,14 @@ export default async function handler(req, res) {
switch (action) {
case "sendCoverMeRequestByEmail":
// Send CoverMe request to the user
// Send CoverMe request to the users
//get from POST data: shiftId, assignmentId, date
//let shiftId = req.body.shiftId;
let assignmentId = req.body.assignmentId;
let date = req.body.date;
console.log("User: " + user.email + " sent a 'CoverMe' request for his assignment " + assignmentId + " " + date);
let toSubscribed = req.body.toSubscribed;
let toAvailable = req.body.toAvailable;
let assignment = await prisma.assignment.findUnique({
where: {
@ -233,6 +245,8 @@ export default async function handler(req, res) {
}
}
});
console.log("User: " + publisher.email + " sent a 'CoverMe' request for his assignment " + assignmentId + " - " + assignment.shift.cartEvent.location.name + " " + assignment.shift.startTime.toISOString());
// update the assignment. generate new publicGuid, isConfirmed to false
let newPublicGuid = uuidv4();
@ -246,29 +260,55 @@ export default async function handler(req, res) {
}
});
//get all subscribed publisers
const subscribedPublishers = await prisma.publisher.findMany({
where: {
isSubscribedToCoverMe: true
let subscribedPublishers = [], availablePublishers = [];
if (toSubscribed) {
//get all subscribed publisers
subscribedPublishers = await prisma.publisher.findMany({
where: {
isSubscribedToCoverMe: true
}
});
}
if (toAvailable) {
availablePublishers = await data.filterPublishersNew("id,firstName,lastName,email", new Date(assignment.shift.startTime), true, false);
}
//concat and remove duplicate emails
let pubsToSend = subscribedPublishers.concat(availablePublishers).
filter((item, index, self) =>
index === self.findIndex((t) => (
t.email === item.email && item.email !== publisher.email//and exclude the user himself
))
);
console.log("Sending CoverMe request to " + pubsToSend.length + " publishers");
await prisma.eventLog.create({
data: {
date: new Date(),
publisher: { connect: { id: publisher.id } },
shift: { connect: { id: assignment.shiftId } },
type: EventLogType.AssignmentReplacementRequested,
content: "Заявка за заместване от " + publisher.firstName + " " + publisher.lastName
+ "до: " + pubsToSend.map(p => p.firstName + " " + p.lastName + "<" + p.email + ">").join(", "),
}
});
//send email to all subscribed publishers
for (let i = 0; i < subscribedPublishers.length; i++) {
if (subscribedPublishers[i].id == user.id) {
continue;
}
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=" + subscribedPublishers[i].id + "&shiftId=" + assignment.shiftId + "&assignmentPID=" + newPublicGuid;
let acceptUrl = process.env.NEXTAUTH_URL + "/api/email?action=email_response&emailaction=coverMeAccept&userId=" + pubsToSend[i].id + "&shiftId=" + assignment.shiftId + "&assignmentPID=" + newPublicGuid;
publisher.prefix = publisher.isMale ? "Брат" : "Сестра";
let model = {
user: user,
user: publisher,
shiftId: assignment.shiftId,
acceptUrl: acceptUrl,
prefix: user.isMale ? "Брат" : "Сестра",
firstName: subscribedPublishers[i].firstName,
lastName: subscribedPublishers[i].lastName,
email: subscribedPublishers[i].email,
firstName: pubsToSend[i].firstName,
lastName: pubsToSend[i].lastName,
email: pubsToSend[i].email,
placeName: assignment.shift.cartEvent.location.name,
dateStr: common.getDateFormated(assignment.shift.startTime),
time: common.formatTimeHHmm(assignment.shift.startTime),
@ -276,8 +316,8 @@ export default async function handler(req, res) {
};
let results = emailHelper.SendEmailHandlebars(
{
name: subscribedPublishers[i].firstName + " " + subscribedPublishers[i].lastName,
email: subscribedPublishers[i].email
name: pubsToSend[i].firstName + " " + pubsToSend[i].lastName,
email: pubsToSend[i].email
}, "coverMe", model);
// if (results) {
// console.log("Error sending email: " + error);
@ -285,10 +325,12 @@ export default async function handler(req, res) {
//}
if (results) {
console.log("Email sent to: " + subscribedPublishers[i].email);
console.log("Email sent to: " + pubsToSend[i].email);
}
}
res.status(200).json({ message: "CoverMe request sent" });
break;
default:
return res.status(400).json({ message: "Invalid action" });

View File

@ -30,7 +30,7 @@ export default async function handler(req, res) {
var action = req.query.action;
var filter = req.query.filter;
let day: Date, monthInfo: any;
let day: Date;
let isExactTime;
if (req.query.date) {
day = new Date(req.query.date);
@ -42,6 +42,7 @@ export default async function handler(req, res) {
isExactTime = true;
}
let monthInfo = common.getMonthDatesInfo(day);
const searchText = req.query.searchText?.normalize('NFC');
try {
@ -123,10 +124,10 @@ export default async function handler(req, res) {
const availabilities = req.body;
//! console.log("createAvailabilities: " + JSON.stringify(availabilities));
try {
await prisma.availability.createMany({
let createResults = await prisma.availability.createMany({
data: availabilities
});
res.status(200).json({ "message": "ok" });
res.status(200).json({ "message": "ok", "results": createResults });
} catch (error) {
console.error("Error creating availabilities: " + error);
res.status(500).json({ error });
@ -161,8 +162,9 @@ export default async function handler(req, res) {
res.status(200).json(publishers);
break;
case "filterPublishersNew":
let includeOldAvailabilities = common.parseBool(req.query.includeOldAvailabilities);
let results = await filterPublishersNew_Available(req.query.select, day,
common.parseBool(req.query.isExactTime), common.parseBool(req.query.isForTheMonth));
common.parseBool(req.query.isExactTime), common.parseBool(req.query.isForTheMonth), true, includeOldAvailabilities);
res.status(200).json(results);
break;
@ -219,6 +221,7 @@ export default async function handler(req, res) {
res.status(200).json(shiftsForDate);
break;
case "copyOldAvailabilities":
//get all publishers that don't have availabilities for the current month
monthInfo = common.getMonthDatesInfo(day);
@ -282,12 +285,12 @@ export default async function handler(req, res) {
type: AvailabilityType.Monthly,
isFromPreviousMonth: true,
name: avail.name || "старо предпочитание",
// parentAvailabilityId: avail.id
parentAvailability: {
connect: {
id: avail.id
}
}
parentAvailabilityId: avail.id,
// parentAvailability: {
// connect: {
// id: avail.id
// }
// },
}
await prisma.availability.create({ data: data });
@ -330,12 +333,11 @@ export default async function handler(req, res) {
case "updateShifts":
//get all shifts for the month and publish them (we pass date )
let monthInfo = common.getMonthDatesInfo(day);
let isPublished = common.parseBool(req.query.isPublished);
let updated = await prisma.shift.updateMany({
where: {
startTime: {
gte: new Date(monthInfo.firstMonday.getFullYear(), monthInfo.firstMonday.getMonth(), 1),
gte: new Date(monthInfo.firstMonday.getFullYear(), monthInfo.firstMonday.getMonth(), monthInfo.firstMonday.getDate()),
lt: new Date(monthInfo.lastSunday.getFullYear(), monthInfo.lastSunday.getMonth() + 1, 1),
}
},
@ -347,6 +349,43 @@ export default async function handler(req, res) {
res.status(200).json({ "message": "ok" });
break;
case "getPossibleShiftPublisherEmails":
const subscribedPublishers = await prisma.publisher.findMany({
where: {
isSubscribedToCoverMe: true
},
select: {
id: true,
firstName: true,
lastName: true,
email: true
}
}).then(pubs => {
return pubs.map(pub => {
return {
id: pub.id,
name: pub.firstName + " " + pub.lastName,
email: pub.email
}
});
});
let shift = await prisma.shift.findUnique({
where: {
id: parseInt(req.query.shiftId)
}
});
let availablePublishers = await filterPublishersNew_Available("id,firstName,lastName,email", new Date(shift.startTime), true, false);
//return names and email info only
availablePublishers = availablePublishers.map(pub => {
return {
id: pub.id,
name: pub.firstName + " " + pub.lastName,
email: pub.email
}
});
res.status(200).json({ shift, availablePublishers: availablePublishers, subscribedPublishers });
break;
default:
res.status(200).json({
@ -424,255 +463,8 @@ export async function getMonthlyStatistics(selectFields, filterDate) {
}
export async function filterPublishersNew_Available(selectFields, filterDate, isExactTime = false, isForTheMonth = false, isWithStats = true) {
// Only attempt to split if selectFields is a string; otherwise, use it as it is.
selectFields = typeof selectFields === 'string' ? selectFields.split(",") : selectFields;
let selectBase = selectFields.reduce((acc, curr) => {
acc[curr] = true;
return acc;
}, {});
selectBase.assignments = {
select: {
id: true,
shift: {
select: {
id: true,
startTime: true,
endTime: true
}
}
},
where: {
shift: {
startTime: {
gte: filterDate,
}
}
}
};
var monthInfo = common.getMonthDatesInfo(filterDate);
var weekNr = common.getWeekOfMonth(filterDate); //getWeekNumber
let dayOfWeekEnum = common.getDayOfWeekNameEnEnumForDate(filterDate);
if (!isExactTime) {
filterDate.setHours(0, 0, 0, 0); // Set to midnight
}
const filterDateEnd = new Date(filterDate);
filterDateEnd.setHours(23, 59, 59, 999);
let whereClause = {};
//if full day, match by date only
if (!isExactTime) { // Check only by date without considering time ( Assignments on specific days without time)
whereClause["availabilities"] = {
some: {
OR: [
{
startTime: { gte: filterDate },
endTime: { lte: filterDateEnd },
}
,
// Check if dayOfMonth is null and match by day of week using the enum (Assigments every week)
// This includes availabilities from previous assignments but not with preference
{
dayOfMonth: null, // includes monthly and weekly repeats
dayofweek: dayOfWeekEnum,
// ToDo: and weekOfMonth
startTime: { lte: filterDate },
AND: [
{
OR: [ // OR condition for repeatUntil to handle events that either end after filterDate or repeat forever
{ endDate: { gte: filterDate } },
{ endDate: null }
]
}
]
}
]
}
};
}
//if not full day, match by date and time
else {
//match exact time (should be same as data.findPublisherAvailability())
whereClause["availabilities"] = {
some: {
OR: [
// Check if dayOfMonth is set and filterDate is between start and end dates (Assignments on specific days AND time)
{
// dayOfMonth: filterDate.getDate(),
startTime: { lte: filterDate },
endTime: { gte: filterDate }
},
// Check if dayOfMonth is null and match by day of week using the enum (Assigments every week)
{
dayOfMonth: null,
dayofweek: dayOfWeekEnum,
startTime: { gte: filterDate },
AND: [
{
OR: [ // OR condition for repeatUntil to handle events that either end after filterDate or repeat forever
{ endDate: { gte: filterDate } },
{ endDate: null }
]
}
]
}
]
}
};
}
if (isForTheMonth) {
// If no filter date, return all publishers's availabilities for currentMonthStart
whereClause["availabilities"] = {
some: {
OR: [
// Check if dayOfMonth is not null and startTime is after currentMonthStart (Assignments on specific days AND time)
{
dayOfMonth: { not: null },
startTime: { gte: currentMonthStart },
endTime: { lte: currentMonthEnd }
},
// Check if dayOfMonth is null and match by day of week using the enum (Assigments every week)
{
dayOfMonth: null,
AND: [
{
OR: [ // OR condition for repeatUntil to handle events that either end after filterDate or repeat forever
{ endDate: { gte: filterDate } },
{ endDate: null }
]
}
]
}
]
}
};
}
console.log(`getting publishers for date: ${filterDate}, isExactTime: ${isExactTime}, isForTheMonth: ${isForTheMonth}`);
//include availabilities if flag is true
const prisma = common.getPrismaClient(); //why we need to get it again?
let publishers = await prisma.publisher.findMany({
where: whereClause,
select: {
...selectBase,
availabilities: true
}
});
console.log(`publishers: ${publishers.length}, WhereClause: ${JSON.stringify(whereClause)}`);
// convert matching weekly availabilities to availabilities for the day to make furter processing easier on the client.
// we trust that the filtering was OK, so we use the dateFilter as date.
publishers.forEach(pub => {
pub.availabilities = pub.availabilities.map(avail => {
if (avail.dayOfMonth == null) {
let newStart = new Date(filterDate);
newStart.setHours(avail.startTime.getHours(), avail.startTime.getMinutes(), 0, 0);
let newEnd = new Date(filterDate);
newEnd.setHours(avail.endTime.getHours(), avail.endTime.getMinutes(), 0, 0);
return {
...avail,
startTime: newStart,
endTime: newEnd
}
}
return avail;
});
});
let currentWeekStart: Date, currentWeekEnd: Date,
currentMonthStart: Date, currentMonthEnd: Date,
previousMonthStart: Date, previousMonthEnd: Date;
if (isWithStats) {
currentWeekStart = common.getStartOfWeek(filterDate);
currentWeekEnd = common.getEndOfWeek(filterDate);
currentMonthStart = monthInfo.firstMonday;
currentMonthEnd = monthInfo.lastSunday;
let prevMnt = new Date(filterDate)
prevMnt.setMonth(prevMnt.getMonth() - 1);
monthInfo = common.getMonthDatesInfo(prevMnt);
previousMonthStart = monthInfo.firstMonday;
previousMonthEnd = monthInfo.lastSunday;
//get if publisher has assignments for current weekday, week, current month, previous month
publishers.forEach(pub => {
// Filter assignments for current day
pub.currentDayAssignments = pub.assignments?.filter(assignment => {
return assignment.shift.startTime >= filterDate && assignment.shift.startTime <= filterDateEnd;
}).length;
// Filter assignments for current week
pub.currentWeekAssignments = pub.assignments?.filter(assignment => {
return assignment.shift.startTime >= currentWeekStart && assignment.shift.startTime <= currentWeekEnd;
}).length;
// Filter assignments for current month
pub.currentMonthAssignments = pub.assignments?.filter(assignment => {
return assignment.shift.startTime >= currentMonthStart && assignment.shift.startTime <= currentMonthEnd;
}).length;
// Filter assignments for previous month
pub.previousMonthAssignments = pub.assignments?.filter(assignment => {
return assignment.shift.startTime >= previousMonthStart && assignment.shift.startTime <= previousMonthEnd;
}).length;
});
}
//get the availabilities for the day. Calcullate:
//1. how many days the publisher is available for the current month - only with dayOfMonth
//2. how many days the publisher is available without dayOfMonth (previous months count)
//3. how many hours in total the publisher is available for the current month
publishers.forEach(pub => {
if (isWithStats) {
pub.currentMonthAvailability = pub.availabilities?.filter(avail => {
// return avail.dayOfMonth != null && avail.startTime >= currentMonthStart && avail.startTime <= currentMonthEnd;
return avail.startTime >= currentMonthStart && avail.startTime <= currentMonthEnd;
})
pub.currentMonthAvailabilityDaysCount = pub.currentMonthAvailability.length || 0;
// pub.currentMonthAvailabilityDaysCount += pub.availabilities.filter(avail => {
// return avail.dayOfMonth == null;
// }).length;
pub.currentMonthAvailabilityHoursCount = pub.currentMonthAvailability.reduce((acc, curr) => {
return acc + (curr.endTime.getTime() - curr.startTime.getTime()) / (1000 * 60 * 60);
}, 0);
//if pub has up-to-date availabilities (with dayOfMonth) for the current month
pub.hasUpToDateAvailabilities = pub.availabilities?.some(avail => {
return avail.dayOfMonth != null && avail.startTime >= currentMonthStart; // && avail.startTime <= currentMonthEnd;
});
}
//if pub has ever filled the form - if has availabilities which are not from previous assignments
pub.hasEverFilledForm = pub.availabilities?.some(avail => {
return avail.isFromPreviousAssignments == false;
});
//if pub has availabilities for the current day
pub.hasAvailabilityForCurrentDay = pub.availabilities?.some(avail => {
return avail.startTime >= filterDate && avail.startTime <= filterDateEnd;
});
});
if (isExactTime) {
// Post filter for time if dayOfMonth is null as we can't only by time for multiple dates in SQL
// Modify the availabilities array of the filtered publishers
publishers.forEach(pub => {
pub.availabilities = pub.availabilities?.filter(avail => matchesAvailability(avail, filterDate));
});
}
return publishers;
export async function filterPublishersNew_Available(selectFields, filterDate, isExactTime = false, isForTheMonth = false, isWithStats = true, includeOldAvailabilities = false) {
return data.filterPublishersNew(selectFields, filterDate, isExactTime, isForTheMonth, isWithStats, includeOldAvailabilities);
}
// availabilites filter:
@ -759,7 +551,8 @@ export async function filterPublishers(selectFields, searchText, filterDate, fet
select: {
id: true,
startTime: true,
endTime: true
endTime: true,
isPublished: true
}
}
},
@ -1062,7 +855,11 @@ async function getCalendarEvents(publisherId, date, availabilities = true, assig
});
}
if (assignments) {
publisher.assignments?.forEach(item => {
//only published shifts
publisher.assignments?.filter(
assignment => assignment.shift.isPublished
).forEach(item => {
result.push({
...item,
title: common.getTimeFomatted(new Date(item.shift.startTime)) + "-" + common.getTimeFomatted(new Date(item.shift.endTime)),

37
pages/api/notify.ts Normal file
View File

@ -0,0 +1,37 @@
const webPush = require('web-push')
webPush.setVapidDetails(
`mailto:${process.env.WEB_PUSH_EMAIL}`,
process.env.NEXT_PUBLIC_WEB_PUSH_PUBLIC_KEY,
process.env.WEB_PUSH_PRIVATE_KEY
)
const Notification = (req, res) => {
if (req.method == 'POST') {
const { subscription } = req.body
webPush
.sendNotification(
subscription,
JSON.stringify({ title: 'Hello Web Push', message: 'Your web push notification is here!' })
)
.then(response => {
res.writeHead(response.statusCode, response.headers).end(response.body)
})
.catch(err => {
if ('statusCode' in err) {
res.writeHead(err.statusCode, err.headers).end(err.body)
} else {
console.error(err)
res.statusCode = 500
res.end()
}
})
} else {
res.statusCode = 405
res.end()
}
}
export default Notification

View File

@ -73,17 +73,20 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
if (req.method === 'GET') {
// const { year, month } = req.query;
const { year, month } = req.query;
// let monthIndex = parseInt(month as string) - 1;
// const monthInfo = common.getMonthDatesInfo(new Date(year, month, 1));
// let fromDate = monthInfo.firstMonday;
// const toDate = monthInfo.lastSunday;
//ToDo: maybe we don't need that anymore as we are publishing the shifts and show all published shifts
let fromDate = new Date();
fromDate.setDate(fromDate.getDate() - 1);
fromDate.setHours(0, 0, 0, 0);
let toDate = new Date(fromDate);
toDate.setDate(toDate.getDate() + 40);
if (year && month) {
fromDate = new Date(parseInt(year as string), parseInt(month as string) - 1, 1);
}
const monthInfo = common.getMonthDatesInfo(fromDate);
//let toDate = new Date(monthInfo.lastSunday);
if (year && month) {
fromDate = monthInfo.firstMonday;
}
try {
@ -93,7 +96,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
isPublished: true,
startTime: {
gte: fromDate,
lt: toDate,
//lt: toDate,
},
},
orderBy: {
@ -131,12 +134,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
groupedShifts[day][time] = [];
}
let { notes, notes_bold } = splitNotes(shift.notes);//.substring(Math.max(shift.notes.lastIndexOf("-"), shift.notes.lastIndexOf("")) + 1).trim() || "";
// let { notes, notes_bold } = splitNotes(shift.notes);//.substring(Math.max(shift.notes.lastIndexOf("-"), shift.notes.lastIndexOf("")) + 1).trim() || "";
let notes = "", notes_bold = "";
if (shift.assignments.some(a => a.isWithTransport)) {
if (shift.requiresTransport) {
notes = "Транспорт: ";
notes_bold = " " + shift.assignments.filter(a => a.isWithTransport).map(a => common.getInitials(a.publisher.firstName + " " + a.publisher.lastName)).join(", ");
}
}
let shiftSchedule = {
date: date,
placeOfEvent: shift.cartEvent.location.name,
time: time,
requiresTransport: shift.requiresTransport,
//bold the text after - in the notes
notes: notes,
notes_bold: notes_bold,
@ -159,6 +171,24 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
console.log(err + " " + JSON.stringify(shifts[i]));
}
for (const day in groupedShifts) {
const times = Object.keys(groupedShifts[day]);
for (const time of times) {
const shift = groupedShifts[day][time][0];
if (shift) {
// Determine the first shift of the day if it requires transport
if (time === times[0] && shift.requiresTransport) { // Check if this is the first time slot of the day
shift.notes = "Докарва количка от Люлин -"; // Update the first shift in the first time slot
}
// Determine the last shift of the day if it requires transport
if (time === times[times.length - 1] && shift.requiresTransport) { // Check if this is the last time slot of the day
shift.notes = "Прибира количка в Люлин -"; // Update the last shift in the last time slot
}
}
}
}
// Create the output object in the format of the second JSON file
const monthlySchedule = {
month: common.getMonthName(shifts[0].startTime.getMonth()),
@ -176,6 +206,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
console.log("shift is null");
continue;
}
let weekday = common.getDayOfWeekName(shift.date);
let monthName = common.getMonthName(shift.date.getMonth());
weekday = weekday.charAt(0).toUpperCase() + weekday.slice(1);

View File

@ -76,15 +76,15 @@ export default function CalendarPage({ initialEvents, initialShifts }) {
const [modalPub, setModalPub] = useState(null);
// ------------------ no assignments checkbox ------------------
const [isCheckboxChecked, setIsCheckboxChecked] = useState(false);
const [filterShowWithoutAssignments, setFilterShowWithoutAssignments] = useState(false);
const handleCheckboxChange = (event) => {
setIsCheckboxChecked(!isCheckboxChecked); // Toggle the checkbox state
setFilterShowWithoutAssignments(!filterShowWithoutAssignments); // Toggle the checkbox state
};
useEffect(() => {
console.log("checkbox checked: " + isCheckboxChecked);
console.log("checkbox checked: " + filterShowWithoutAssignments);
handleCalDateChange(value); // Call handleCalDateChange whenever isCheckboxChecked changes
}, [isCheckboxChecked]); // Dependency array
}, [filterShowWithoutAssignments]); // Dependency array
const [selectedMonth, setSelectedMonth] = useState(new Date().getMonth());
useEffect(() => {
@ -99,7 +99,7 @@ export default function CalendarPage({ initialEvents, initialShifts }) {
var date = new Date(common.getDateFromDateTime(selectedDate));//ToDo: check if seting the timezone affects the selectedDate?!
var dateStr = common.getISODateOnly(date);
console.log("Setting date to '" + date.toLocaleDateString() + "' from '" + selectedDate.toLocaleDateString() + "'. ISO: " + date.toISOString(), "locale ISO:", common.getISODateOnly(date));
if (isCheckboxChecked) {
if (filterShowWithoutAssignments) {
console.log(`getting unassigned publishers for ${common.getMonthName(date.getMonth())} ${date.getFullYear()}`);
const { data: availablePubsForDate } = await axiosInstance.get(`/api/?action=getUnassignedPublishers&date=${dateStr}&select=id,firstName,lastName,isActive,desiredShiftsPerMonth`);
setAvailablePubs(availablePubsForDate);
@ -110,7 +110,7 @@ export default function CalendarPage({ initialEvents, initialShifts }) {
const { data: shiftsForDate } = await axiosInstance.get(`/api/?action=getShiftsForDay&date=${dateStr}`);
setShifts(shiftsForDate);
setIsPublished(shiftsForDate.some(shift => shift.isPublished));
let { data: availablePubsForDate } = await axiosInstance.get(`/api/?action=filterPublishersNew&date=${dateStr}&select=id,firstName,lastName,isActive,desiredShiftsPerMonth`);
let { data: availablePubsForDate } = await axiosInstance.get(`/api/?action=filterPublishersNew&date=${dateStr}&select=id,firstName,lastName,isActive,desiredShiftsPerMonth&includeOldAvailabilities=${filterShowWithoutAssignments}`);
availablePubsForDate.forEach(pub => {
pub.canTransport = pub.availabilities.some(av =>
@ -140,6 +140,7 @@ export default function CalendarPage({ initialEvents, initialShifts }) {
onChange(selectedDate);
}
}
const handleShiftSelection = (selectedShift) => {
setSelectedShiftId(selectedShift.id);
const updatedPubs = availablePubs.map(pub => {
@ -535,6 +536,38 @@ export default function CalendarPage({ initialEvents, initialShifts }) {
await axiosInstance.get(`/api/?action=copyOldAvailabilities&date=${common.getISODateOnly(value)}`);
}
async function handleCreateNewShift(event: MouseEvent<HTMLButtonElement, MouseEvent>): Promise<void> {
//get last shift end time
let lastShift = shifts.sort((a, b) => new Date(b.endTime).getTime() - new Date(a.endTime).getTime())[0];
//default to 9:00 if no shifts
if (!lastShift) {
//get cart event id
var dayName = common.DaysOfWeekArray[value.getDayEuropean()];
const cartEvent = events.find(event => event.dayofweek == dayName);
lastShift = {
endTime: new Date(value.setHours(9, 0, 0, 0)),
cartEventId: cartEvent.id
};
}
const lastShiftEndTime = new Date(lastShift.endTime);
//add 90 minutes
const newShiftEndTime = new Date(lastShiftEndTime.getTime() + 90 * 60000);
await axiosInstance.post(`/api/data/shifts`, {
name: "Нова смяна",
startTime: lastShiftEndTime,
endTime: newShiftEndTime,
isPublished: false,
cartEvent: { connect: { id: lastShift.cartEventId } }
}).then((response) => {
console.log("New shift created:", response.data);
// setShifts([...shifts, response.data]);
handleCalDateChange(value);
}
).catch((error) => {
console.error("Error creating new shift:", error);
});
}
return (
<>
<Layout>
@ -621,22 +654,6 @@ export default function CalendarPage({ initialEvents, initialShifts }) {
</div>
</div>
)}
{/* <button className={`button m-2 bg-blue-800 ${isOperationInProgress ? 'disabled' : ''}`} onClick={importShifts}>
{isOperationInProgress ? <div className="spinner"></div> : 'Import shifts (and missing Publishers) from WORD'}
</button>
// <button className="button m-2 bg-blue-800" onClick={() => generateShifts()}>Generate empty shifts</button>
// <button className="button m-2 bg-blue-800" onClick={() => generateShifts(true)}>Copy last month shifts</button>
// <button className="button m-2 bg-blue-800" onClick={() => generateShifts(true, true)}>Generate Auto shifts</button>
// <button className="button m-2 bg-blue-800" onClick={() => generateShifts(false, true, value)}>Generate Auto shifts DAY</button>
// <button className="button m-2" onClick={fetchShifts}>Fetch shifts</button>
// <button className="button m-2" onClick={sendMails}>Send mails</button>
// <button className="button m-2" onClick={generateXLS}>Generate XLSX</button>
// <button className="button m-2" onClick={async () => {
// await axiosInstance.get(`/api/shiftgenerate?action=delete&date=${common.getISODateOnly(value)}`);
// }
// }>Delete shifts (selected date's month)</button>
// <button className="button m-2" onClick={generateMonthlyStatistics}>Generate statistics</button>
*/}
</div>
</div>
{/* progress bar holder */}
@ -664,9 +681,10 @@ export default function CalendarPage({ initialEvents, initialShifts }) {
<h2 className="text-lg font-semibold mb-4">Достъпни за този ден: <span className="text-blue-600">{availablePubs.length}</span></h2>
<label className="toggle pb-3">
<input type="checkbox" className="toggle-checkbox" onChange={handleCheckboxChange} />
<input type="checkbox" className="toggle-checkbox" id="filterShowWithoutAssignments" onChange={handleCheckboxChange} />
<span className="toggle-slider m-1">без назначения за месеца</span>
<input type="checkbox" className="toggle-checkbox" id="filterIncludeOldAvailabilities" onChange={handleCheckboxChange} />
<span className="toggle-slider m-1">със стари предпочитания</span>
</label>
<ul className="w-full max-w-md">
{Array.isArray(availablePubs) && availablePubs?.map((pub, index) => {
@ -736,6 +754,12 @@ export default function CalendarPage({ initialEvents, initialShifts }) {
allPublishersInfo={availablePubs} />
))}
</div>
<button
className="mt-4 bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
onClick={handleCreateNewShift}
>
Добави нова смяна
</button>
</div>
</div>
@ -891,6 +915,7 @@ export default function CalendarPage({ initialEvents, initialShifts }) {
import axiosServer from '../../../src/axiosServer';
import { start } from 'repl';
import { filter } from 'jszip';
export const getServerSideProps = async (context) => {
const axios = await axiosServer(context);
// const baseUrl = common.getBaseUrl();
@ -905,13 +930,6 @@ export const getServerSideProps = async (context) => {
const url = `/api/data/shifts?where={"startTime":{"$and":[{"$gte":"${common.getISODateOnly(firstDayOfMonth)}","$lt":"${common.getISODateOnly(lastDayOfMonth)}"}]}}`;
const prismaClient = common.getPrismaClient();
// let events = await prismaClient.cartEvent.findMany({ where: { isActive: true } });
// events = events.map(event => ({
// ...event,
// // Convert Date objects to ISO strings
// startTime: event.startTime.toISOString(),
// endTime: event.endTime.toISOString(),
// }));
const { data: events } = await axios.get(`/api/data/cartevents?where={"isActive":true}`);
//const { data: shifts } = await axios.get(url);

View File

@ -22,7 +22,8 @@ const SchedulePage = () => {
useEffect(() => {
const fetchHtmlContent = async () => {
try {
const response = await axiosInstance.get('/api/schedule?year=2024&month=1', { responseType: 'text' });
// const response = await axiosInstance.get('/api/schedule?year=2024&month=1', { responseType: 'text' });
const response = await axiosInstance.get('/api/schedule', { responseType: 'text' });
setHtmlContent(response.data); // Set the fetched HTML content in state
} catch (error) {
console.error("Failed to fetch schedule:", error);

View File

@ -51,7 +51,8 @@ export default function ImportPage() {
desiredShiftsIndex: -1,
dataStartIndex: -1,
isActiveIndex: -1,
pubTypeIndex: -1
pubTypeIndex: -1,
gender: -1
});
const handleFile = (e) => {
@ -111,6 +112,7 @@ export default function ImportPage() {
headerRef.current.desiredShiftsIndex = header.indexOf('Желан брой участия');
headerRef.current.isActiveIndex = header.indexOf("Неактивен");
headerRef.current.pubTypeIndex = header.indexOf("Назначение");
headerRef.current.gender = header.indexOf("Пол");
const filteredData = sheetData.slice(headerRef.current.dataStartIndex).map((row) => {
let date;
@ -147,12 +149,16 @@ export default function ImportPage() {
let isOld = false;
const row = rawData[i];
let email, phone, names, dateOfInput, oldAvDeleted = false, isTrained = false, desiredShiftsPerMonth = 4, isActive = true, publisherType = PublisherType.Publisher;
//const date = new Date(row[0]).toISOS{tring().slice(0, 10);
let email, phone, names, dateOfInput, oldAvDeleted = false,
isTrained = false, desiredShiftsPerMonth = 4, isActive = true,
publisherType = PublisherType.Publisher,
isMale = 0
;
//ToDo: structure all vars above as single object:
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, '');
@ -165,11 +171,11 @@ export default function ImportPage() {
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;
publisherType = row[headerRef.current.pubTypeIndex];
isMale = row[headerRef.current.gender].trim().toLowerCase() === 'брат';
}
else {
dateOfInput = common.excelSerialDateToDate(row[0]);
@ -183,7 +189,7 @@ export default function ImportPage() {
let day = new Date();
day.setDate(1); // Set to the first day of the month to avoid overflow
if (importDate && importDate.value) {
let monthOfIportInfo = common.getMonthInfo(importDate.value);
let monthOfIportInfo = common.getMonthInfo(new Date(importDate.value));
day = monthOfIportInfo.firstMonday;
}
@ -201,7 +207,7 @@ export default function ImportPage() {
let personNames = names.join(' ');
try {
try {
const select = "&select=id,firstName,lastName,email,phone,isTrained,desiredShiftsPerMonth,isActive,type,availabilities";
const select = "&select=id,firstName,lastName,email,phone,isTrained,desiredShiftsPerMonth,isActive,isMale,type,availabilities";
const responseByName = await axiosInstance.get(`/api/?action=findPublisher&filter=${names.join(' ')}${select}`);
let existingPublisher = responseByName.data[0];
if (!existingPublisher) {
@ -244,11 +250,8 @@ export default function ImportPage() {
} 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: 'email', value: email },
@ -256,6 +259,7 @@ export default function ImportPage() {
{ key: 'desiredShiftsPerMonth', value: desiredShiftsPerMonth, parse: parseInt },
{ key: 'isTrained', value: isTrained },
{ key: 'isActive', value: isActive },
{ key: "isMale", value: isMale },
{ key: 'type', value: publisherType, parse: common.getPubTypeEnum }
];
@ -277,6 +281,7 @@ export default function ImportPage() {
data[i - mode.headerRow][4] = fieldsToUpdateString.substring(0, fieldsToUpdateString.length - 2)
+ " updated";
} catch (error) {
data[i - mode.headerRow][4] = "error updating!";
console.error(`Failed to update publisher ${personId} - Fields Attempted: ${fieldsToUpdateString}`, error);
}
}
@ -309,6 +314,7 @@ export default function ImportPage() {
firstName: firstname,
lastName: names[names.length - 1],
isActive: isActive,
isMale: isMale,
isTrained,
desiredShiftsPerMonth
});

View File

@ -7,6 +7,7 @@ import common from '../../../src/helpers/common';
import Modal from 'components/Modal';
import ConfirmationModal from 'components/ConfirmationModal';
import PublisherSearchBox from '../../../components/publisher/PublisherSearchBox'; // Update the path
import SearchReplacement from '../../../components/publisher/SearchReplacement'; // Update the path
import { monthNamesBG, GetTimeFormat, GetDateFormat } from "../../../src/helpers/const"
import { useSession, getSession } from 'next-auth/react';
@ -52,27 +53,12 @@ export default function MySchedulePage({ assignments }) {
});
};
const searchReplacement = (assignmentId) => {
axiosInstance.post('/api/email?action=sendCoverMeRequestByEmail', {
assignmentId: assignmentId,
}).then(response => {
console.log("response", response);
//toast success and confirm the change
toast.success("Заявката за заместник е изпратена!", {
onClose: () => {
window.location.reload();
}
});
}).catch(error => {
console.log("error", error);
});
}
return (
<Layout>
<ProtectedRoute allowedRoles={[UserRole.ADMIN, UserRole.POWERUSER, UserRole.USER]}>
<div className="container mx-auto p-4">
<h1 className="text-2xl md:text-3xl font-bold text-center my-4">Моите смени</h1>
<div className="container ">
<h1 className="text-xl sm:text-2xl md:text-3xl font-bold text-center my-4">Моите смени</h1>
<div className="space-y-4">
{assignments && assignments.map((assignment) => (
<div key={assignment.dateStr + assignments.indexOf(assignment)} className="bg-white shadow overflow-hidden rounded-lg">
@ -81,18 +67,18 @@ export default function MySchedulePage({ assignments }) {
</div>
<div className="border-t border-gray-200">
<dl>
<div className="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<div className="bg-gray-50 px-4 py-5 grid grid-cols-1 sm:grid-cols-3 gap-4 xs:gap-1 px-6 xs:py-1">
<dt className="text-sm font-medium text-gray-500">Час</dt>
<dd className="mt-1 text text-gray-900 sm:mt-0 sm:col-span-2">
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
{GetTimeFormat(assignment.shift.startTime)} - {GetTimeFormat(assignment.shift.endTime)}
</dd>
</div>
<div className="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<div className="bg-gray-50 px-4 py-5 grid grid-cols-1 sm:grid-cols-3 gap-4 xs:gap-1 px-6 xs:py-1">
<dt className="text-sm font-medium text-gray-500">Смяна</dt>
<dd className="mt-1 text text-gray-900 sm:mt-0 sm:col-span-2">
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
{assignment.shift.assignments.map((a, index) => {
return (
<span key={index} className="inline-flex items-center mr-1 px-2 py-0.5 border border-gray-300 rounded-full text-sm font-medium bg-gray-100">
<span key={index} className="inline-flex items-center mr-1 px-2 py-0.5 my-0.5 border border-gray-300 rounded-full text-sm font-medium bg-gray-100">
{a.publisher.firstName} {a.publisher.lastName}
{a.isWithTransport && <LocalShippingIcon style={{ marginLeft: '4px' }} />}
</span>
@ -101,7 +87,7 @@ export default function MySchedulePage({ assignments }) {
)}
</dd>
</div>
<div className="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<div className="bg-gray-50 px-4 py-5 grid grid-cols-1 sm:grid-cols-3 gap-4 xs:gap-1 px-6 xs:py-1">
<dt className="text-sm font-medium text-gray-500">Действия</dt>
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
<button
@ -121,12 +107,8 @@ export default function MySchedulePage({ assignments }) {
>
Избери Заместник
</button>
<button
className="mr-2 mb-2 inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
onClick={() => searchReplacement(assignment.id)}
>
Търси заместник
</button>
<SearchReplacement shiftId={assignment.shift.id} assignmentId={assignment.id} />
</dd>
</div>
</dl>
@ -223,7 +205,7 @@ export const getServerSideProps = async (context) => {
},
});
const assignments = publisher?.assignments || [];
const assignments = publisher?.assignments.filter(assignment => assignment.shift.startTime >= lastSunday) || [];
const transformedAssignments = assignments?.map(assignment => {
if (assignment.shift && assignment.shift.startTime) {

View File

@ -1,15 +1,18 @@
import { useState } from 'react';
import Layout from "../../../components/layout";
import ProtectedRoute from '../../../components/protectedRoute';
import { UserRole } from '@prisma/client';
import { Prisma, UserRole } from '@prisma/client';
import axiosServer from '../../../src/axiosServer';
import common from '../../../src/helpers/common';
import { filterPublishers, /* other functions */ } from '../../api/index';
// import { filterPublishers, /* other functions */ } from '../../api/index';
import data from '../../../src/helpers/data';
function ContactsPage({ publishers }) {
// const data = require('../../src/helpers/data');
function ContactsPage({ publishers, allPublishers }) {
const [searchQuery, setSearchQuery] = useState('');
const filteredPublishers = publishers.filter((publisher) =>
const filteredPublishers = allPublishers.filter((publisher) =>
publisher.firstName.toLowerCase().includes(searchQuery.toLowerCase()) ||
publisher.lastName.toLowerCase().includes(searchQuery.toLowerCase()) ||
publisher.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
@ -21,7 +24,7 @@ function ContactsPage({ publishers }) {
<ProtectedRoute allowedRoles={[UserRole.ADMIN, UserRole.POWERUSER, UserRole.USER]}>
<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} участника с предпочитания</h5>
<h5 className="text-lg font-semibold mb-4">{publishers.length} участника с предпочитания за месеца (от {allPublishers.length} )</h5>
<input
type="text"
placeholder="Търси по име, имейл или телефон..."
@ -39,26 +42,55 @@ function ContactsPage({ publishers }) {
</tr>
</thead>
<tbody>
{filteredPublishers.map((pub) => (
<tr key={pub.id}>
<td className="border-b p-4 pl-8" title={pub.lastUpdate}>{pub.firstName} {pub.lastName}</td>
<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>
</td>
<td className="border-b p-4">
<div className="flex items-center justify-between">
<div className="flex items-center">
<span title="участия тази седмица" className={`badge py-1 px-2 rounded-full text-xs ${pub.currentWeekAssignments ? 'bg-yellow-500 text-white' : 'bg-yellow-200 text-gray-400'}`}>{pub.currentWeekAssignments || 0}</span>
<span title="участия този месец" className={`badge py-1 px-2 rounded-full text-xs ${pub.currentMonthAssignments ? 'bg-green-500 text-white' : 'bg-green-200 text-gray-400'}`}>{pub.currentMonthAssignments || 0}</span>
<span tooltip="участия миналия месец" title="участия миналия месец" className={`badge py-1 px-2 rounded-full text-xs ${pub.previousMonthAssignments ? 'bg-blue-500 text-white' : 'bg-blue-200 text-gray-400'}`}>{pub.previousMonthAssignments || 0}</span>
<button tooltip="желани участия този месец" title="желани участия" className={`badge py-1 px-2 rounded-md text-xs ${pub.desiredShiftsPerMonth ? 'bg-purple-500 text-white' : 'bg-purple-200 text-gray-400'}`}>{pub.desiredShiftsPerMonth || 0}</button>
</div>
</div>
</td>
</tr>
))}
{filteredPublishers.map((allPub) => {
// Find the publisher in the publishers collection to access statistics
const pub = publishers.find(publisher => publisher.id === allPub.id);
return (
<tr key={allPub.id}>
<td className="border-b p-4 pl-8" title={allPub.lastUpdate}>{allPub.firstName} {allPub.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>
</td>
<td className="border-b p-4">
<div className="flex items-center justify-between">
<div className="flex items-center">
<span title="участия тази седмица" className={`badge py-1 px-2 rounded-full text-xs ${pub.currentWeekAssignments ? 'bg-yellow-500 text-white' : 'bg-yellow-200 text-gray-400'}`}>{pub.currentWeekAssignments || 0}</span>
<span title="участия този месец" className={`badge py-1 px-2 rounded-full text-xs ${pub.currentMonthAssignments ? 'bg-green-500 text-white' : 'bg-green-200 text-gray-400'}`}>{pub.currentMonthAssignments || 0}</span>
<span title="участия миналия месец" className={`badge py-1 px-2 rounded-full text-xs ${pub.previousMonthAssignments ? 'bg-blue-500 text-white' : 'bg-blue-200 text-gray-400'}`}>{pub.previousMonthAssignments || 0}</span>
<button title="желани участия" className={`badge py-1 px-2 rounded-md text-xs ${pub.desiredShiftsPerMonth ? 'bg-purple-500 text-white' : 'bg-purple-200 text-gray-400'}`}>{pub.desiredShiftsPerMonth || 0}</button>
</div>
</div>
</td>
</>
) : (
<>
<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}
</span>
<span className="badge py-1 px-2 rounded-md text-xs bg-gray-300 text-gray-500" title="участия миналия месец">
{allPub.previousMonthAssignments || 0}
</span>
</div>
</div>
</td>
<td className="border-b p-4"></td> {/* Empty cell for alignment */}
</>
)}
</tr>
);
})}
</tbody>
</table>
</div>
@ -71,10 +103,29 @@ function ContactsPage({ publishers }) {
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 filterPublishers('id,firstName,lastName,email,isActive,desiredShiftsPerMonth', "", new Date(), true, true, false);
let publishers = await data.filterPublishersNew('id,firstName,lastName,email,isActive,desiredShiftsPerMonth', dateStr, false, 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`);
@ -115,9 +166,67 @@ export const getServerSideProps = async (context) => {
//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,
assignments: {
select: {
id: true,
shift: {
select: {
startTime: true,
endTime: true,
},
},
},
},
},
});
let monthInfo,
currentMonthStart, currentMonthEnd,
previousMonthStart, previousMonthEnd;
monthInfo = common.getMonthDatesInfo(new Date());
currentMonthStart = monthInfo.firstMonday;
currentMonthEnd = monthInfo.lastSunday;
let prevMnt = new Date();
prevMnt.setMonth(prevMnt.getMonth() - 1);
monthInfo = common.getMonthDatesInfo(prevMnt);
previousMonthStart = monthInfo.firstMonday;
previousMonthEnd = monthInfo.lastSunday;
allPublishers.forEach(publisher => {
// Use helper functions to calculate and assign assignment counts
publisher.currentMonthAssignments = countAssignments(publisher.assignments, currentMonthStart, currentMonthEnd);
publisher.previousMonthAssignments = countAssignments(publisher.assignments, previousMonthStart, previousMonthEnd);
// Convert date formats within the same iteration
convertShiftDates(publisher.assignments);
});
// Optionally, if you need a transformed list or additional properties, map the publishers
allPublishers = allPublishers.map(publisher => ({
...publisher,
// Potentially add more computed properties or transformations here if needed
}));
allPublishers.sort((a, b) => a.firstName.localeCompare(b.firstName) || a.lastName.localeCompare(b.lastName));
return {
props: {
publishers,
allPublishers,
},
};
};

View File

@ -9,16 +9,35 @@ import { useSession } from "next-auth/react"
import common from '../../../src/helpers/common';
import Layout from "../../../components/layout";
import ProtectedRoute from '../../../components/protectedRoute';
import { Location, UserRole } from "@prisma/client";
import { Location, UserRole, ReportType } from "@prisma/client";
export default function Reports() {
const [reports, setReports] = useState([]);
const [filteredReports, setFilteredReports] = useState([]);
const router = useRouter();
const { data: session } = useSession();
const [filterType, setFilterType] = useState('ServiceReport');
const handleFilterChange = (event) => {
setFilterType(event.target.value);
console.log("event filter set to:" + event.target.value);
};
useEffect(() => {
const isFeedbackType = type => [
'Feedback_Problem',
'Feedback_Suggestion',
'Feedback'
].includes(type);
setFilteredReports(reports.filter(report =>
filterType === 'Feedback' ? isFeedbackType(report.type) : report.type === filterType
));
}, [reports, filterType]);
const deleteReport = (id) => {
axiosInstance
@ -66,7 +85,7 @@ export default function Reports() {
<Layout>
<ProtectedRoute allowedRoles={[UserRole.POWERUSER, UserRole.ADMIN, UserRole.USER, UserRole.EXTERNAL]}>
<div className="h-5/6 grid place-items-center">
<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>
<Link href="/cart/reports/report">
@ -74,18 +93,21 @@ export default function Reports() {
Добави нов отчет
</button>
</Link>
<label className="mr-4">
<input type="radio" name="reportType" value="ServiceReport" defaultChecked />
Отчети
</label>
<label className="mr-4">
<input type="radio" name="reportType" value="Experience" />
Случка
</label>
<label className="mr-4">
<input type="radio" name="reportType" value="Feedback" />
Отзиви
</label>
<div className="flex gap-2 mb-4">
<label className={`cursor-pointer px-4 py-2 rounded-full ${filterType === 'ServiceReport' ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-800'}`}>
<input type="radio" name="reportType" value="ServiceReport" checked={filterType === 'ServiceReport'} onChange={handleFilterChange} className="sr-only" />
Отчети
</label>
<label className={`cursor-pointer px-4 py-2 rounded-full ${filterType === 'Experience' ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-800'}`}>
<input type="radio" name="reportType" value="Experience" checked={filterType === 'Experience'} onChange={handleFilterChange} className="sr-only" />
Случка
</label>
<label className={`cursor-pointer px-4 py-2 rounded-full ${filterType === 'Feedback' ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-800'}`}>
<input type="radio" name="reportType" value="Feedback" checked={filterType === 'Feedback'} onChange={handleFilterChange} className="sr-only" />
Отзиви
</label>
</div>
<div className="mt-4 w-full overflow-x-auto">
<table className="w-full table-auto">
<thead>
@ -98,13 +120,13 @@ export default function Reports() {
</tr>
</thead>
<tbody>
{reports.map((report) => (
{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">
{(report.experienceInfo === null || report.experienceInfo === "")
{(report.type === ReportType.ServiceReport)
? (
<>
<div><strong>Отчет</strong></div>
@ -113,9 +135,16 @@ export default function Reports() {
Клипове: {report.videoCount} <br />
Адреси / Телефони: {report.returnVisitInfoCount} <br />
</>
) : (report.placementCount > 0) ? (
) : (report.type === ReportType.Feedback || report.type === ReportType.Feedback_Problem || report.type === ReportType.Feedback_Suggestion) ? (
<>
<div><strong>Отзив</strong></div>
<div>
<strong style={{ fontWeight: 'bold' }}>Отзив</strong>
{report.type === "Feedback_Problem" ?
<span style={{ color: 'red', fontWeight: 'bold' }}> - Проблем</span> :
report.type === "Feedback_Suggestion" ?
<span style={{ color: 'blue' }}> - Предложение</span> :
""}
</div>
<div dangerouslySetInnerHTML={{ __html: report.experienceInfo }} />
</>
) : (

View File

@ -88,6 +88,7 @@ async function getAvailabilities(userId) {
name: true,
isActive: true,
isFromPreviousAssignment: true,
isFromPreviousMonth: true,
dayofweek: true,
dayOfMonth: true,
startTime: true,

View File

@ -3,35 +3,61 @@ import Layout from "../components/layout";
import fs from 'fs';
import path from 'path';
import { url } from 'inspector';
import ProtectedRoute, { serverSideAuth } from "/components/protectedRoute";
import axiosInstance from '../src/axiosSecure';
const PDFViewerPage = ({ pdfFiles }) => {
const [files, setFiles] = useState(pdfFiles);
const handleFileDelete = async (fileName) => {
const subfolder = 'permits'; // Change this as needed based on your subfolder structure
try {
await axiosInstance.delete(`/api/content/${subfolder}?file=${fileName}`);
setFiles(files.filter(file => file.name !== fileName));
} catch (error) {
console.error('Error deleting file:', error);
}
};
const handleFileUpload = async (event) => {
const file = event.target.files[0];
const formData = new FormData();
formData.append('file', file);
const subfolder = 'permits'; // Change this as needed based on your subfolder structure
try {
const response = await axiosInstance.post(`/api/content/${subfolder}`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
setFiles([...files, response.data]);
} catch (error) {
console.error('Error uploading file:', error);
}
};
return (
<Layout>
<h1 className="text-3xl font-bold p-4 pt-8">Разрешителни</h1>
<div style={{ width: '100%', height: 'calc(100vh - 100px)' }}> {/* Adjust the 100px based on your header/footer size */}
{/* <p className="p-1">
{pdfFiles.map((file, index) => (
<p className="p-2">
<a href={file.url} className="text-blue-600 hover:text-blue-800 visited:text-purple-600 underline" target='_blank'>
Свали: {file.name}
</a>
</p>
))}
</p> */}
{pdfFiles.map((file, index) => (
<ProtectedRoute>
<input type="file" onChange={handleFileUpload} className="mb-4" />
{files.map((file, index) => (
<div key={file.name} className="py-2">
<a href={file.url} className="text-blue-600 hover:text-blue-800 visited:text-purple-600 underline" target='_blank'>
{file.name}
</a>
<button onClick={() => handleFileDelete(file.name)} className="ml-4 bg-red-500 hover:bg-red-700 text-white font-bold py-1 px-2 rounded">
изтрий
</button>
</div>
))}
</ProtectedRoute>
// <React.Fragment key={file.name}>
// {index > 0 && <div className="bg-gray-400 w-px h-6"></div>} {/* Vertical line separator */}
// <a
// href={file.url}
// target="_blank"
// className={`text-lg py-2 px-4 bg-gray-200 text-gray-800 hover:bg-blue-500 hover:text-white ${index === 0 ? 'rounded-l-full' : index === pdfFiles.length - 1 ? 'rounded-r-full' : ''}`}
// >
// {file.name}
// </a>
// </React.Fragment>
<div style={{ width: '100%', height: 'calc(100vh - 100px)' }}> {/* Adjust the 100px based on your header/footer size */}
{pdfFiles.map((file, index) => (
<> <p className="pt-2">
<a href={file.url} className="text-blue-600 hover:text-blue-800 visited:text-purple-600 underline" target='_blank'>
Свали: {file.name}

View File

@ -0,0 +1,20 @@
-- CreateTable
CREATE TABLE `EventLog` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`date` DATETIME(3) NOT NULL,
`publisherId` VARCHAR(191) NULL,
`shiftId` INTEGER NULL,
`content` VARCHAR(5000) NOT NULL,
`type` ENUM('AssignmentReplacementRequested', 'AssignmentReplacementAccepted', 'SentEmail') NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `EventLog`
ADD CONSTRAINT `EventLog_publisherId_fkey` FOREIGN KEY (`publisherId`) REFERENCES `Publisher` (`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `EventLog`
ADD CONSTRAINT `EventLog_shiftId_fkey` FOREIGN KEY (`shiftId`) REFERENCES `Shift` (`id`) ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -121,6 +121,7 @@ model Publisher {
comments String?
reports Report[]
Message Message[]
EventLog EventLog[]
}
model Availability {
@ -179,6 +180,7 @@ model Shift {
reportId Int? @unique
Report Report? @relation(fields: [reportId], references: [id])
isPublished Boolean @default(false) //NEW v1.0.1
EventLog EventLog[]
@@map("Shift")
}
@ -256,6 +258,23 @@ model Message {
type MessageType @default(Email)
}
enum EventLogType {
AssignmentReplacementRequested
AssignmentReplacementAccepted
SentEmail
}
model EventLog {
id Int @id @default(autoincrement())
date DateTime
publisherId String?
publisher Publisher? @relation(fields: [publisherId], references: [id])
shiftId Int?
shift Shift? @relation(fields: [shiftId], references: [id])
content String @db.VarChar(5000)
type EventLogType
}
//user auth and session management
model User {
id String @id @default(cuid())

View File

@ -20,7 +20,7 @@
"dir": "auto",
"lang": "en-US",
"name": "Специално Свидетелстване София",
"short_name": "ССС",
"short_name": "ССОМ",
"start_url": "/",
"scope": "/cart"
}

View File

@ -40,6 +40,20 @@ console.log("process.env.PORT = ", process.env.PORT);
console.log("process.env.TELEGRAM_BOT = ", process.env.TELEGRAM_BOT);
console.log("process.env.DATABASE_URL = ", process.env.DATABASE_URL);
console.log("process.env.DATABASE = ", process.env.DATABASE);
console.log("process.env.APPLE_APP_ID = ", process.env.APPLE_APP_ID);
// update GIT_COMMIT_ID
const { exec } = require("child_process");
exec("git rev-parse HEAD", (error, stdout, stderr) => {
if (error) {
console.error(`exec error: ${error}`);
process.env.GIT_COMMIT_ID = "unknown";
return;
}
process.env.GIT_COMMIT_ID = stdout.trim();
console.log("GIT_COMMIT_ID = ", process.env.GIT_COMMIT_ID);
});
//require('module-alias/register');
@ -92,29 +106,13 @@ nextApp
// Add the middleware to set 'x-forwarded-host' header
server.use((req, res, next) => {
req.headers['x-forwarded-host'] = req.headers['x-forwarded-host'] || req.headers.host;
// ---------------
// if (!baseUrlGlobal) {
// const protocol = req.headers['x-forwarded-proto'] || 'http';
// const host = req.headers.host;
// const baseUrl = `${protocol}://${host}`;
// baseUrlGlobal = baseUrl;
// fs.writeFileSync(path.join(__dirname, 'baseUrl.txt'), baseUrlGlobal, 'utf8');
// console.log("baseUrlGlobal set to: " + baseUrlGlobal);
// }
next();
});
server.use("/favicon.ico", express.static("styles/favicon_io/favicon.ico"));
server.use("/favicon.ico", express.static("public/favicon.png"));
// server.use("/robots.txt", express.static("styles/favicon_io/robots.txt"));
// server.use("/sitemap.xml", express.static("styles/favicon_io/sitemap.xml"));
server.get("/last_schedule_json", (req, res) => {
// var data = JSON.parse(fs.readFileSync("./content/sources/march_flat.json", "utf8"));
// const newData = data.map((item) => {
// const names = item.names.filter((name) => {
// return !name.startsWith('Прибира количка') && !name.startsWith('Докарва количка');
// });
// return { ...item, names };
// });
res.json(fs.readFileSync("./content/sources/march_flat.json", "utf8"));
});

View File

@ -6,18 +6,11 @@ const levenshtein = require('fastest-levenshtein');
const fs = typeof window === 'undefined' ? require('fs') : undefined;
const path = typeof window === 'undefined' ? require('path') : undefined;
const { PrismaClient } = require('@prisma/client');
const { PrismaClient, UserRole } = require('@prisma/client');
const DayOfWeek = require("@prisma/client").DayOfWeek;
const winston = require('winston');
// User and auth functions
// import { getSession } from "next-auth/react";
// import { UserRole } from "@prisma/client";
//convert to es6 import
const { getSession } = require("next-auth/react");
const { UserRole } = require("@prisma/client");
// const { set } = require('date-fns');
const logger = winston.createLogger({
level: 'info', // Set the default log level
@ -249,6 +242,8 @@ exports.getDateFromWeekNrAndDayOfWeek = function (firstMonday, weekNr, dayOfWeek
}
exports.getMonthDatesInfo = function (date) {
// cast to date if not daate
date = new Date(date);
// get first day of the month
var firstDay = new Date(date.getFullYear(), date.getMonth(), 1);
// get first day of next month
@ -327,7 +322,23 @@ exports.getWeekNumber = function (date) {
return Math.ceil((date.getDate() - info.firstMonday.getDate() + 1) / 7);
};
exports.compareTimes = function (time1, time2) {
const time1String = `${getHours(time1)}:${getMinutes(time1)}`;
const time2String = `${getHours(time2)}:${getMinutes(time2)}`;
return time1String.localeCompare(time2String);
};
exports.normalizeTime = function (date, baseDate) {
// return set(baseDate, {
// hours: getHours(date),
// minutes: getMinutes(date),
// seconds: getSeconds(date),
// milliseconds: 0
// });
//don't use date-fns
let newDate = new Date(baseDate);
newDate.setHours(date.getHours(), date.getMinutes(), date.getSeconds(), 0);
return newDate;
}
exports.getTimeRange = function (start, end) {
start = new Date(start);
@ -346,6 +357,12 @@ exports.getDateFormated = function (date) {
return `${dayOfWeekName} ${day} ${monthName} ${year} г.`;
}
exports.getDateFormatedShort = function (date) {
const day = date.getDate();
const monthName = exports.getMonthName(date.getMonth());
return `${day} ${monthName}`;
}
exports.getTimeFomatted = function (date) {
return date.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Sofia' });//timeZone: 'local'
@ -735,3 +752,23 @@ exports.getLocalStorage = function (key, defaultValue) {
exports.root = function (req) {
return process.env.NEXT_PUBLIC_PUBLIC_URL;
}
exports.getInitials = function (names) {
const parts = names.split(' ');
return parts.map(part => part[0] + ".").join('');
}
// exports.getInitials = function (names) {
// const parts = names.split(' '); // Split the full name into parts
// if (parts.length === 0) {
// return '';
// }
// // Extract the first two letters of the first name
// let initials = parts[0].substring(0, 2) + ".";
// // If there is a last name, add the first letter of the last name
// if (parts.length > 1) {
// initials += parts[parts.length - 1][0] + ".";
// }
// return initials;
// }

View File

@ -226,6 +226,273 @@ async function getAvailabilities(userId) {
return serializableItems;
}
async function filterPublishersNew(selectFields, filterDate, isExactTime = false, isForTheMonth = false, isWithStats = true, includeOldAvailabilities = false) {
filterDate = new Date(filterDate); // Convert to date object if not already
// Only attempt to split if selectFields is a string; otherwise, use it as it is.
selectFields = typeof selectFields === 'string' ? selectFields.split(",") : selectFields;
let selectBase = selectFields.reduce((acc, curr) => {
acc[curr] = true;
return acc;
}, {});
selectBase.assignments = {
select: {
id: true,
shift: {
select: {
id: true,
startTime: true,
endTime: true
}
}
},
where: {
shift: {
startTime: {
gte: filterDate,
}
}
}
};
var monthInfo = common.getMonthDatesInfo(filterDate);
var weekNr = common.getWeekOfMonth(filterDate); //getWeekNumber
let dayOfWeekEnum = common.getDayOfWeekNameEnEnumForDate(filterDate);
if (!isExactTime) {
filterDate.setHours(0, 0, 0, 0); // Set to midnight
}
const filterDateEnd = new Date(filterDate);
filterDateEnd.setHours(23, 59, 59, 999);
let whereClause = {};
//if full day, match by date only
if (!isExactTime) { // Check only by date without considering time ( Assignments on specific days without time)
whereClause["availabilities"] = {
some: {
OR: [
{
startTime: { gte: filterDate },
endTime: { lte: filterDateEnd },
}
,
// Check if dayOfMonth is null and match by day of week using the enum (Assigments every week)
// This includes availabilities from previous assignments but not with preference
{
dayOfMonth: null, // includes monthly and weekly repeats
dayofweek: dayOfWeekEnum,
// ToDo: and weekOfMonth
startTime: { lte: filterDate },
AND: [
{
OR: [ // OR condition for repeatUntil to handle events that either end after filterDate or repeat forever
{ endDate: { gte: filterDate } },
{ endDate: null }
]
}
]
}
]
}
};
}
//if not full day, match by date and time
else {
//match exact time (should be same as data.findPublisherAvailability())
whereClause["availabilities"] = {
some: {
OR: [
// Check if dayOfMonth is set and filterDate is between start and end dates (Assignments on specific days AND time)
{
// dayOfMonth: filterDate.getDate(),
startTime: { lte: filterDate },
endTime: { gte: filterDate }
},
// Check if dayOfMonth is null and match by day of week using the enum (Assigments every week)
{
dayOfMonth: null,
dayofweek: dayOfWeekEnum,
startTime: { gte: filterDate },
AND: [
{
OR: [ // OR condition for repeatUntil to handle events that either end after filterDate or repeat forever
{ endDate: { gte: filterDate } },
{ endDate: null }
]
}
]
}
]
}
};
}
if (isForTheMonth) {
// If no filter date, return all publishers's availabilities for currentMonthStart
whereClause["availabilities"] = {
some: {
OR: [
// Check if dayOfMonth is not null and startTime is after monthInfo.firstMonday (Assignments on specific days AND time)
{
dayOfMonth: { not: null },
startTime: { gte: monthInfo.firstMonday },
endTime: { lte: monthInfo.lastSunday }
},
// Check if dayOfMonth is null and match by day of week using the enum (Assigments every week)
{
dayOfMonth: null,
AND: [
{
OR: [ // OR condition for repeatUntil to handle events that either end after filterDate or repeat forever
{ endDate: { gte: filterDate } },
{ endDate: null }
]
}
]
}
]
}
};
}
console.log(`getting publishers for date: ${filterDate}, isExactTime: ${isExactTime}, isForTheMonth: ${isForTheMonth}`);
//include availabilities if flag is true
const prisma = common.getPrismaClient(); //why we need to get it again?
let publishers = await prisma.publisher.findMany({
where: whereClause,
select: {
...selectBase,
availabilities: true
}
});
console.log(`publishers: ${publishers.length}, WhereClause: ${JSON.stringify(whereClause)}`);
// convert matching weekly availabilities to availabilities for the day to make furter processing easier on the client.
// we trust that the filtering was OK, so we use the dateFilter as date.
publishers.forEach(pub => {
pub.availabilities = pub.availabilities.map(avail => {
if (avail.dayOfMonth == null) {
let newStart = new Date(filterDate);
newStart.setHours(avail.startTime.getHours(), avail.startTime.getMinutes(), 0, 0);
let newEnd = new Date(filterDate);
newEnd.setHours(avail.endTime.getHours(), avail.endTime.getMinutes(), 0, 0);
return {
...avail,
startTime: newStart,
endTime: newEnd
}
}
return avail;
});
});
let currentWeekStart, currentWeekEnd,
currentMonthStart, currentMonthEnd,
previousMonthStart, previousMonthEnd;
if (isWithStats) {
currentWeekStart = common.getStartOfWeek(filterDate);
currentWeekEnd = common.getEndOfWeek(filterDate);
currentMonthStart = monthInfo.firstMonday;
currentMonthEnd = monthInfo.lastSunday;
let prevMnt = new Date(filterDate)
prevMnt.setMonth(prevMnt.getMonth() - 1);
monthInfo = common.getMonthDatesInfo(prevMnt);
previousMonthStart = monthInfo.firstMonday;
previousMonthEnd = monthInfo.lastSunday;
//get if publisher has assignments for current weekday, week, current month, previous month
publishers.forEach(pub => {
// Filter assignments for current day
pub.currentDayAssignments = pub.assignments?.filter(assignment => {
return assignment.shift.startTime >= filterDate && assignment.shift.startTime <= filterDateEnd;
}).length;
// Filter assignments for current week
pub.currentWeekAssignments = pub.assignments?.filter(assignment => {
return assignment.shift.startTime >= currentWeekStart && assignment.shift.startTime <= currentWeekEnd;
}).length;
// Filter assignments for current month
pub.currentMonthAssignments = pub.assignments?.filter(assignment => {
return assignment.shift.startTime >= currentMonthStart && assignment.shift.startTime <= currentMonthEnd;
}).length;
// Filter assignments for previous month
pub.previousMonthAssignments = pub.assignments?.filter(assignment => {
return assignment.shift.startTime >= previousMonthStart && assignment.shift.startTime <= previousMonthEnd;
}).length;
});
}
//get the availabilities for the day. Calcullate:
//1. how many days the publisher is available for the current month - only with dayOfMonth
//2. how many days the publisher is available without dayOfMonth (previous months count)
//3. how many hours in total the publisher is available for the current month
publishers.forEach(pub => {
if (isWithStats) {
pub.currentMonthAvailability = pub.availabilities?.filter(avail => {
// return avail.dayOfMonth != null && avail.startTime >= currentMonthStart && avail.startTime <= currentMonthEnd;
return avail.startTime >= currentMonthStart && avail.startTime <= currentMonthEnd;
})
pub.currentMonthAvailabilityDaysCount = pub.currentMonthAvailability.length || 0;
// pub.currentMonthAvailabilityDaysCount += pub.availabilities.filter(avail => {
// return avail.dayOfMonth == null;
// }).length;
pub.currentMonthAvailabilityHoursCount = pub.currentMonthAvailability.reduce((acc, curr) => {
return acc + (curr.endTime.getTime() - curr.startTime.getTime()) / (1000 * 60 * 60);
}, 0);
//if pub has up-to-date availabilities (with dayOfMonth) for the current month
pub.hasUpToDateAvailabilities = pub.availabilities?.some(avail => {
return avail.dayOfMonth != null && avail.startTime >= currentMonthStart; // && avail.startTime <= currentMonthEnd;
});
}
//if pub has ever filled the form - if has availabilities which are not from previous assignments
pub.hasEverFilledForm = pub.availabilities?.some(avail => {
return avail.isFromPreviousAssignments == false;
});
//if pub has availabilities for the current day
pub.hasAvailabilityForCurrentDay = pub.availabilities?.some(avail => {
return avail.startTime >= filterDate && avail.startTime <= filterDateEnd;
});
});
if (isExactTime) {
// Post filter for time if dayOfMonth is null as we can't only by time for multiple dates in SQL
// Modify the availabilities array of the filtered publishers
publishers.forEach(pub => {
pub.availabilities = pub.availabilities?.filter(avail => matchesAvailability(avail, filterDate));
});
}
return publishers;
}
function matchesAvailability(avail, filterDate) {
// Setting the start and end time of the filterDate
filterDate.setHours(0, 0, 0, 0);
const filterDateEnd = new Date(filterDate);
filterDateEnd.setHours(23, 59, 59, 999);
// Return true if avail.startTime is between filterDate and filterDateEnd
return avail.startTime >= filterDate && avail.startTime <= filterDateEnd;
}
const fs = require('fs');
const path = require('path');
@ -255,5 +522,6 @@ module.exports = {
findPublisher,
findPublisherAvailability,
runSqlFile,
getAvailabilities
getAvailabilities,
filterPublishersNew
};

View File

@ -13,45 +13,32 @@ const Handlebars = require('handlebars');
const { Shift, Publisher, PrismaClient } = require("@prisma/client");
const { env } = require("../../next.config");
const SMTPTransport = require("nodemailer/lib/smtp-transport");
// const TOKEN = process.env.TOKEN || "a7d7147a530235029d74a4c2f228e6ad";
// const SENDER_EMAIL = "sofia@mwitnessing.com";
// const sender = { name: "Специално Свидетелстване София", email: SENDER_EMAIL };
// const client = new MailtrapClient({ token: TOKEN });
var transporter;
if (process.env.EMAIL_SERVICE.toLowerCase() === "mailtrap") {
let mailtrapTestClient = null;
// const mailtrapTestClient = new MailtrapClient({
// username: '8ec69527ff2104',//not working now
// password: 'c7bc05f171c96c'
// });
transporter = nodemailer.createTransport({
host: process.env.MAILTRAP_HOST || "sandbox.smtp.mailtrap.io",
port: process.env.MAILTRAP_PORT || 2525,
auth: {
user: process.env.MAILTRAP_USER,
pass: process.env.MAILTRAP_PASS
}
});
}
if (process.env.EMAIL_SERVICE.toLowerCase() === "gmail") {
transporter = nodemailer.createTransport({
service: "gmail",
auth: {
user: process.env.EMAIL_GMAIL_USERNAME,
pass: process.env.EMAIL_GMAIL_APP_PASS
}
});
}
//test
var transporter = nodemailer.createTransport({
host: process.env.MAILTRAP_HOST || "sandbox.smtp.mailtrap.io",
port: 2525,
auth: {
user: process.env.MAILTRAP_USER,
pass: process.env.MAILTRAP_PASS
}
});
// production
// var transporter = nodemailer.createTransport({
// host: "live.smtp.mailtrap.io",
// port: 587,
// auth: {
// user: "api",
// pass: "1cfe82e747b8dc3390ed08bb16e0f48d"
// }
// });
var transporterBulk = nodemailer.createTransport({
host: "bulk.smtp.mailtrap.io",
port: 587,
auth: {
user: "api",
pass: "1cfe82e747b8dc3390ed08bb16e0f48d"
}
});
// ------------------ Email sending ------------------
var lastResult = null;
function setResult(result) {
@ -87,8 +74,14 @@ function normalizeEmailAddresses(to) {
exports.SendEmail = async function (to, subject, text, html, attachments = []) {
let sender = '"Специално Свидетелстване София - тест" <demo@mwitnessing.com>';
const emailAddresses = normalizeEmailAddresses(to)
let sender = process.env.EMAIL_SENDER || '"Специално Свидетелстване София" <sofia@mwitnessing.com>';
let emailAddresses = normalizeEmailAddresses(to)
const bypassEmailReccipients = process.env.EMAIL_BYPASS_TO || null;
if (bypassEmailReccipients !== null && bypassEmailReccipients.length > 0) {
emailAddresses = bypassEmailReccipients;
console.log("Emails bypassed. All mails sent to: " + emailAddresses);
}
const message = {
from: sender,
@ -99,22 +92,11 @@ exports.SendEmail = async function (to, subject, text, html, attachments = []) {
attachments
};
if (mailtrapTestClient !== null) {
// Assuming mailtrapTestClient is correctly set up to send emails
await mailtrapTestClient
.send(message)
.then(console.log)
.catch(console.error);
} else {
let result = await transporter
.sendMail(message)
.then(console.log)
.catch(console.error);
return result;
}
let result = await transporter
.sendMail(message)
.then(console.log)
.catch(console.error);
return result;
};
exports.SendEmailHandlebars = async function (to, templateName, model, attachments = []) {
@ -132,7 +114,7 @@ exports.SendEmailHandlebars = async function (to, templateName, model, attachmen
const subjectMatch = templateSource.match(/{{!--\s*Subject:\s*(.*?)\s*--}}/);
const textMatch = templateSource.match(/{{!--\s*Text:\s*([\s\S]*?)\s*--}}/);
let subject = subjectMatch ? subjectMatch[1].trim() : 'ССС: Известие';
let subject = subjectMatch ? subjectMatch[1].trim() : 'ССОМ: Известие';
let textVersion = textMatch ? textMatch[1].trim() : null;
// Remove the subject and text annotations from the template source
@ -228,7 +210,7 @@ exports.SendEmail_NewShifts = async function (publisher, shifts) {
// ],
// subject: "[CCC]: вашите смени през " + CON.monthNamesBG[date.getMonth()],
// text:
// "Здравейте, " + publisher.firstName + " " + publisher.lastName + "!\n\n" +
// "Здравей, " + publisher.firstName + " " + publisher.lastName + "!\n\n" +
// "Ти регистриран да получавате известия за нови смени на количка.\n" +
// `За месец ${CON.monthNamesBG[date.getMonth()]} имате следните смени:\n` +
// ` ${shftStr} \n\n\n` +

View File

@ -1,11 +1,11 @@
{{!--Subject: ССС: Нужен е заместник --}}
{{!--Subject: ССОМ: Нужен е заместник --}}
<section>
<h3>Търси се зместник:
{{!-- за смяна на {{placeName}} за {{dateStr}}! --}}
</h3>
<p>Здравей {{firstName}},</p>
<p>{{prefix}} {{user.firstName}} {{user.lastName}} търси заместник.</p>
<p>{{user.prefix}} {{user.firstName}} {{user.lastName}} търси заместник.</p>
{{!-- <p><strong>Shift Details:</strong></p> --}}
<p>Дата: {{dateStr}} <br>Час: {{time}}<br>Място: {{placeName}}</p>
<p>С натискането на бутона по-долу можеш да премеш да го заместваш.

View File

@ -1,8 +1,8 @@
{{!-- Subject: ССС: Промени в твоята смяна --}}
{{!-- Subject: ССОМ: Промени в твоята смяна --}}
<section>
<h2>Промяна твоята смяна на {{placeName}} {{dateStr}} </h2>
<p>Здравейте {{firstName}}, </p>
<p>Здравей {{firstName}}, </p>
<p>{{firstName}} {{lastName}} ще замести {{oldPubName}} на смяната ви в {{dateStr}} от {{time}}</p>
<p>Новаия списък с участници за тази смяна е:</p>
<ul>

View File

@ -1,10 +1,10 @@
{{!-- Subject: ССС: Нужен е заместник--}}
{{!-- Subject: ССОМ: Нужен е заместник--}}
{{!-- Text: Plain text version of your email. If not provided, HTML tags will be stripped from the HTML version for the
text version. --}}
<section>
<h3>Търси се зместник за смяна на {{placeName}} за {{dateStr}}!</h3>
<p>Здравейте,</p>
<p>Здравей,</p>
<p>{{prefix}} {{firstName}} {{lastName}} търси заместник.</p>
{{!-- <p><strong>Shift Details:</strong></p> --}}
<p>Дата: {{dateStr}} <br>Час: {{time}}<br>Място: {{placeName}}</p>

View File

@ -5,7 +5,7 @@
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, initial-scale=1.0">
<title>ССС известия</title>
<title>ССОМ известия</title>
</head>
<body>
@ -18,7 +18,7 @@
</main>
<footer style="background-color: #f3f3f3; padding: 20px; text-align: center;">
© 2024 ССС. All rights reserved.
© 2024 ССОМ. Openly licensed.
</footer>
</body>

View File

@ -1,7 +1,7 @@
{{!-- Subject: ССС: Нови назначени смени--}}
{{!-- Subject: ССОМ: Нови назначени смени--}}
<section>
<h2>Здравейте, {{publisherFirstName}} {{publisherLastName}}!</h2>
<h2>Здравей {{publisherFirstName}} {{publisherLastName}}!</h2>
<p>Ти регистриран да получавате известия за нови смени на количка.</p>
<p>За месец {{month}} имате следните смени:</p>
<div>

View File

@ -267,3 +267,10 @@ iframe {
text-align: left;
}
}
.rbc-toolbar {
display: flex;
flex-wrap: nowrap;
align-items: center;
justify-content: center; /* Optional: center buttons in the group */
}

View File

@ -31,7 +31,8 @@
"pages/cart/locations/[id].tsx.typed",
"components/location/LocationForm.js",
"pages/cart/locations/[id].tsx.old",
"components/publisher/ShiftsList.js"
"components/publisher/ShiftsList.js",
"src/helpers/data.js"
],
"exclude": [
"node_modules"

View File

@ -2,6 +2,19 @@
console.log('Service Worker Loaded...')
self.addEventListener('fetch', (event) => {
try {
if (event.request.url.includes('/api/auth/callback/')) {
// Use network only strategy for auth routes, or bypass SW completely
event.respondWith(fetch(event.request));
return;
}
// other caching strategies...
} catch (error) {
console.error(error)
}
});
self.addEventListener('push', function (event) {
console.log('Push message', event)
if (!(self.Notification && self.Notification.permission === 'granted')) {