diff --git a/.vscode/settings.json b/.vscode/settings.json index 14e7153..219cf49 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -144,5 +144,8 @@ "components/x-date-pickers/locales" ], "i18n-ally.keystyle": "nested", - "i18n-ally.sourceLanguage": "bg" + "i18n-ally.sourceLanguage": "bg", + "[shellscript]": { + "editor.defaultFormatter": "foxundermoon.shell-format" + } } \ No newline at end of file diff --git a/_deploy/deoloy.azure.production.yml b/_deploy/deoloy.azure.production.yml index 55a11d4..9e3a9af 100644 --- a/_deploy/deoloy.azure.production.yml +++ b/_deploy/deoloy.azure.production.yml @@ -9,6 +9,7 @@ services: # - "3000:3000" volumes: - /mnt/docker_volumes/pw/app/public/content/uploads/:/app/public/content/uploads + - /mnt/docker_volumes/pw/app/logs:/app/logs environment: - NODE_ENV=production - TZ=Europe/Sofia @@ -19,7 +20,7 @@ services: - GIT_USERNAME=deploy - GIT_PASSWORD=L3Kr2R438u4F7 - ADMIN_PASSWORD=changeme - command: sh -c " cd /app && npm install && npm run prod; tail -f /dev/null" + command: sh -c " cd /app && npm install && npx next build && npm run prod; tail -f /dev/null" #command: sh -c " cd /app && tail -f /dev/null" tty: true stdin_open: true @@ -56,15 +57,15 @@ services: networks: - infrastructure_default command: | - apk update && \ + "apk update && \ apk add --no-cache mariadb-client mariadb-connector-c && \ echo '0 2 * * * mysqldump -h $$MYSQL_HOST -P 3306 -u$$MYSQL_USER -p$$MYSQL_PASSWORD $$MYSQL_DATABASE > /backup/$$(date +\\%Y-\\%m-\\%d-\\%H\\%M\\%S)-$$MYSQL_DATABASE.sql' > /etc/crontabs/root && \ - echo '0 7 * * * rclone sync /backup nextcloud:/mwitnessing' >> /etc/crontabs/root && \ - crond -f -d 8 + crond -f -d 8" # wget -q https://github.com/prasmussen/gdrive/releases/download/2.1.0/gdrive-linux-x64 -O /usr/bin/gdrive && \ # chmod +x /usr/bin/gdrive && \ # gdrive about --service-account /root/.gdrive_service_account.json && \ - # echo '0 * * * * /usr/bin/mysqldump -h $$MYSQL_HOST -u$$MYSQL_USER -p$$MYSQL_PASSWORD $$MYSQL_DATABASE | gzip > /backup/$$(date +\\%Y-\\%m-\\%d-\\%H\\%M\\%S)-$$MYSQL_DATABASE.sql.gz && gdrive upload --parent $$GOOGLE_DRIVE_FOLDER_ID --service-account /root/.gdrive_service_account.json /backup/$$(date +\\%Y-\\%m-\\%d-\\%H\\%M\\%S)-$$MYSQL_DATABASE.sql.gz' > /etc/crontabs/root && crond -f -d 8" + # echo '0 * * * * /usr/bin/mysqldump -h $$MYSQL_HOST -u$$MYSQL_USER -p$$MYSQL_PASSWORD $$MYSQL_DATABASE | gzip > /backup/$$(date +\\%Y-\\%m-\\%d-\\%H\\%M\\%S)-$$MYSQL_DATABASE.sql.gz && gdrive upload --parent $$GOOGLE_DRIVE_FOLDER_ID --service-account /root/.gdrive_service_account.json /backup/$$(date +\\%Y-\\%m-\\%d-\\%H\\%M\\%S)-$$MYSQL_DATABASE.sql.gz' > /etc/crontabs/root && crond -f -d 8 \ + # echo '0 7 * * * rclone sync /backup nextcloud:/mwitnessing --delete-excluded ' >> /etc/crontabs/root && \" networks: infrastructure_default: external: true diff --git a/_deploy/entrypoint.sh b/_deploy/entrypoint.sh index db16b09..963c99f 100644 --- a/_deploy/entrypoint.sh +++ b/_deploy/entrypoint.sh @@ -3,18 +3,72 @@ if [ "$UPDATE_CODE_FROM_GIT" = "true" ]; then # Install necessary packages apk add git nano rsync - echo "Updating code from git.d-popov.com...(as '$GIT_USERNAME')" + echo "Updating code from git.d-popov.com...(as '$GIT_USERNAME')" > /app/logs/deploy.txt # Create a temporary directory for the new clone rm -rf /tmp/clone mkdir /tmp/clone + mkdir -p /app/logs + # Clear previous log + echo "Starting sync process at $(date)" > /app/logs/deploy.txt # Clone the repository - git clone -b ${GIT_BRANCH:-main} --depth 1 https://$GIT_USERNAME:${GIT_PASSWORD//@/%40}@git.d-popov.com/popov/mwitnessing.git /tmp/clone || exit 1 + echo "\r\n\r\n Cloning repository..." | tee -a logs/deploy.txt + git clone -b ${GIT_BRANCH:main} --depth 1 https://$GIT_USERNAME:${GIT_PASSWORD//@/%40}@git.d-popov.com/popov/mwitnessing.git /tmp/clone || exit 1 - # Synchronize all files except package.json and package-lock.json to /app - rsync -av --delete --exclude 'package.json' --exclude 'package-lock.json' /tmp/clone/ /app/ || echo "Rsync failed: Issue synchronizing files" + # Synchronize all files except package.json, package-lock.json, and the contents of /public/content + # rsync -av --filter='P /public/content/' --exclude 'package.json' --exclude 'package-lock.json' /tmp/clone/ /app/ || echo "Rsync failed: Issue synchronizing files" + echo "\r\n\r\n Synchronizing files..." + rsync -av /tmp/clone/_deploy/entrypoint.sh /app/entrypoint.sh || echo "Rsync failed: Issue copying entrypoint.sh" + + + # rsync -av --update --exclude '/public/content' --exclude 'package.json' --exclude 'package-lock.json' /tmp/clone/ /app/ || echo "Rsync failed: Issue synchronizing files" | tee -a /app/logs/deploy.txt + + + ######################################################################################## + if [ -d "/app/public/content/permits" ]; then + mv /app/public/content/permits /tmp/content/permits + echo "Permits folder backed up successfully." | tee -a /app/logs/deploy.txt + else + echo "Permits folder not found, skipping backup." | tee -a /app/logs/deploy.txt + fi + + # Run rsync with verbose output and itemize-changes + echo "Running rsync..." | tee -a /app/logs/deploy.txt + rsync -av --itemize-changes \ + --exclude='package.json' \ + --exclude='package-lock.json' \ + /tmp/clone/ /app/ >> /app/logs/deploy.txt 2>&1 + + # Check rsync exit status + if [ $? -ne 0 ]; then + echo "Rsync failed: Issue synchronizing files" | tee -a /app/logs/deploy.txt + cat /app/logs/deploy.txt # Display the log contents + else + echo "Rsync completed successfully" | tee -a /app/logs/deploy.txt + echo "Last few lines of rsync log:" | tee -a /app/logs/deploy.txt + tail -n 20 /app/logs/deploy.txt # Display the last 20 lines of the log + fi + + # Restore permits folder + echo "Restoring permits folder..." | tee -a /app/logs/deploy.txt + if [ -d "/tmp/content/permits" ]; then + # Ensure the destination directory exists + mkdir -p /app/public/content + mv /tmp/content/permits /app/public/content/permits + echo "Permits folder restored successfully." | tee -a /app/logs/deploy.txt + else + echo "No permits folder to restore." | tee -a /app/logs/deploy.txt + fi + + # Check contents after restoration + echo "Contents of /app/public/content after restoration:" >> /app/logs/deploy.txt + ls -la /app/public/content >> /app/logs/deploy.txt 2>&1 + ######################################################################################## + + + echo "\r\n\r\n Checking for changes in package files..." # Determine if package.json or package-lock.json has changed PACKAGE_CHANGE=0 if ! cmp -s /tmp/clone/package.json /app/package.json || ! cmp -s /tmp/clone/package-lock.json /app/package-lock.json; then @@ -37,7 +91,7 @@ if [ "$UPDATE_CODE_FROM_GIT" = "true" ]; then npx next build # Clean up - rm -rf /tmp/clone + # rm -rf /tmp/clone echo "Update process completed." fi diff --git a/_deploy/entrypoint.staging.sh b/_deploy/entrypoint.staging.sh new file mode 100644 index 0000000..c1f53cb --- /dev/null +++ b/_deploy/entrypoint.staging.sh @@ -0,0 +1,43 @@ +# Check if the environment variable to update code from git is set to true +if [ "$UPDATE_CODE_FROM_GIT" = "true" ]; then + # Install necessary packages + apk add git nano rsync + echo "Updating code from git.d-popov.com...(as '$GIT_USERNAME')" + + # Remove the previous clone directory to ensure a fresh start + rm -rf /tmp/clone + mkdir /tmp/clone + + # Clone the specific branch of the new repository + git clone -b ${GIT_BRANCH:-main} --depth 1 https://$GIT_USERNAME:${GIT_PASSWORD//@/%40}@git.d-popov.com/popov/mwitnessing.git /tmp/clone || exit 1 + # Fetch the latest commit ID and message from the cloned repository + GIT_COMMIT_ID=$(git -C /tmp/clone rev-parse HEAD) + LAST_COMMIT_MESSAGE=$(git -C /tmp/clone log -1 --pretty=%B) + echo "Current Git Commit: $LAST_COMMIT_MESSAGE: $GIT_COMMIT_ID" + export GIT_COMMIT_ID + + # Use rsync to synchronize the files to /app, including deletion of files not in the source + rsync -av --delete --exclude '/public/content' /tmp/clone/ /app/ || echo "Rsync failed: Issue synchronizing files" + # Copy .env files + rsync -av /tmp/clone/.env* /app/ || echo "Rsync failed: Issue copying .env files" + # Copy the entrypoint.sh if exists in the new structure + [ -f /tmp/clone/entrypoint.sh ] && rsync -av /tmp/clone/entrypoint.sh /app/entrypoint.sh || echo "Rsync failed: Issue copying entrypoint.sh" + chmod +x /app/entrypoint.sh + + # Clean up the temporary clone directory + rm -rf /tmp/clone + + cd /app + echo "Installing packages in /app" + npm install --no-audit --no-fund --no-optional --omit=optional + yes | npx prisma generate + # Uncomment the next line if database migrations are necessary + # npx prisma migrate deploy + echo "Done cloning. Current Git Commit ID: $GIT_COMMIT_ID" + # Uncomment the following lines for production deployment + # npx next build + # npx next start +fi + +echo "Running the main process" +exec "$@" diff --git a/_deploy/new-lite/Dockerfile b/_deploy/new-lite/Dockerfile new file mode 100644 index 0000000..811d9b1 --- /dev/null +++ b/_deploy/new-lite/Dockerfile @@ -0,0 +1,19 @@ +FROM node:current-alpine + +# Install git and curl +RUN apk add --no-cache git curl + +# Set environment variables for repo and branch +# These will be overridden by docker-compose.yml +ENV REPO="" +ENV BRANCH="" + +# Create a directory for the app +WORKDIR /app + +# Download the entrypoint script +CMD git clone --depth 1 --branch $BRANCH $REPO /tmp/repo && \ + cp /tmp/repo/_deploy/entrypoint.sh /app/entrypoint.sh && \ + chmod +x /app/entrypoint.sh && \ + rm -rf /tmp/repo && \ + /app/entrypoint.sh \ No newline at end of file diff --git a/_doc/ToDo.md b/_doc/ToDo.md index 1bc87ef..8a37727 100644 --- a/_doc/ToDo.md +++ b/_doc/ToDo.md @@ -262,4 +262,12 @@ in schedule admin - if a publisher is always pair & family is not in the shift - [] allow blocking of inputs (different from publishing) TODO: fix to keep previous occurances when repeating evert week [] user - add createdAt field -[] FIX insecure logins \ No newline at end of file +[x] FIX insecure logins + +[] nove push to form, - reorganize pWAManager to have session, role, subscriptions, etc... +[] add shift name in calendar/ show in schedule if no assignments. +[] show unpublished schedule if admin + + + + diff --git a/_doc/notes.mb b/_doc/notes.mb index aab4534..1236258 100644 --- a/_doc/notes.mb +++ b/_doc/notes.mb @@ -25,8 +25,8 @@ apt install nodejs -y ##### ----------------- compose/deploy ----------------- ### # install docker if inside docker (vscode-server)# apt-get update && apt-get install -y docker.io -# .10 > /mnt/apps/DEV/SSS/next-cart-app/next-cart-app/ -#.11 > cd /mnt/storage/DEV/workspace/repos/git.d-popov.com/next-cart-app/next-cart-app +# !!! .10 > /mnt/apps/DEV/SSS/next-cart-app/next-cart-app/ +# !!! .11 > cd /mnt/storage/DEV/workspace/repos/git.d-popov.com/next-cart-app/next-cart-app # using dockerfile and image: docker build -t jwpw:latest -f _deploy/prod.Dockerfile . @@ -46,9 +46,9 @@ docker push docker.d-popov.com/jwpw:test --LATEST/ cd /mnt/storage/DEV/workspace/repos/git.d-popov.com/mwhitnessing docker build -t docker.d-popov.com/jwpw:latest -f _deploy/prod.Dockerfile . -docker tag docker.d-popov.com/jwpw:latest docker.d-popov.com/jwpw:0.9.95 +docker tag docker.d-popov.com/jwpw:latest docker.d-popov.com/jwpw:1.3.5 docker push docker.d-popov.com/jwpw:latest -docker push docker.d-popov.com/jwpw:0.9.95 +docker push docker.d-popov.com/jwpw:1.3.5 #--- @@ -221,7 +221,8 @@ curl https://gist.githubusercontent.com/balazsorban44/09613175e7b37ec03f676dcefb ################### sync folders # nc: WebDAV apk add rclone -rclone config +rclone config +/52 https://cloud.d-popov.com sync syncsyncsyncsyncsyncsyncsync rclone sync /path/to/local/folder yourRemoteName:target-folder # nc sudo add-apt-repository ppa:nextcloud-devs/client @@ -236,9 +237,11 @@ rclone config rclone lsd nextcloud: # {nc=remotename} rclone sync /path/to/local/folder gdrive:target-folder rclone sync /backup nextcloud:/mwitnessing [--dry-run] [--progress] -rclone sync /backup nextcloud:/mwitnessing --dry-run --progress +# pw-mariadb_backup-1 +rclone sync /backup nextcloud:/mwitnessing --dry-run --progress --delete-excluded +rclone sync /mnt/docker_volumes/pw/data/backup nc:/mwitnessing --dry-run --progress crontab -e -0 7 * * * rclone sync /backup nextcloud:/mwitnessing +0 7 * * * rclone sync /backup nextcloud:/mwitnessing --delete-excluded diff --git a/components/ErrorBoundary.tsx b/components/ErrorBoundary.tsx index 2dbbd89..87a148f 100644 --- a/components/ErrorBoundary.tsx +++ b/components/ErrorBoundary.tsx @@ -27,7 +27,7 @@ class ErrorBoundary extends React.Component { render() { if (this.state.hasError) { // Render any custom fallback UI - return

Нещо се обърка при изтриването. Моля, опитай отново и се свържете с нас ако проблема продължи.

; + return

Нещо се обърка. Моля, опитай отново и се свържете с нас ако проблема продължи.

; } return this.props.children; diff --git a/components/PwaManager.tsx b/components/PwaManager.tsx index 464afda..852fd0d 100644 --- a/components/PwaManager.tsx +++ b/components/PwaManager.tsx @@ -6,7 +6,7 @@ import e from 'express'; import ProtectedRoute from './protectedRoute'; import { UserRole } from '@prisma/client'; -function PwaManager({ subs }) { +function PwaManager({ userId, subs }) { //ToDo: for iOS, try to use apn? https://github.com/node-apn/node-apn/blob/master/doc/apn.markdown const isSupported = () => 'Notification' in window && @@ -271,6 +271,37 @@ function PwaManager({ subs }) { { action: 'close', title: 'Затвори', icon: '❌' }] }) }); + + /* + await fetch('/api/notify', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + id: pub.id, + message: "Тестово съобщение", + title: "Това е тестово съобщение от https://sofia.mwitnessing.com", + actions: [ + { action: 'OK', title: 'OK', icon: '✅' }, + { action: 'close', title: 'Затвори', icon: '❌' } + ] + // actions: [ + // { + // title: 'Open URL', + // action: 'open_url', + // icon: '/images/open-url.png' + // }, + // { + // title: 'Dismiss', + // action: 'dismiss', + // icon: '/images/dismiss.png' + // } + // ] + }) + }) + */ + }; // async function sendTestReminder(event: MouseEvent): Promise { @@ -382,7 +413,7 @@ function PwaManager({ subs }) { > Тестово уведомление - + {isAdmin &&
{/*
} - {notificationPermission !== "granted" && ( - - )} + { + notificationPermission !== "granted" && ( + + ) + } - {isAdmin &&
-
- - Телеграм - Телеграм - + { + isAdmin &&
+
-
} ); diff --git a/components/availability/AvailabilityForm.js b/components/availability/AvailabilityForm.js index 454010f..93a451d 100644 --- a/components/availability/AvailabilityForm.js +++ b/components/availability/AvailabilityForm.js @@ -22,7 +22,7 @@ const fetchConfig = async () => { return config.default; }; -export default function AvailabilityForm({ publisherId, existingItems, inline, onDone, date, cartEvent, datePicker = false }) { +export default function AvailabilityForm({ publisherId, existingItems, inline, onDone, date, cartEvent, datePicker = false, lockedBeforeDate }) { const router = useRouter(); const urls = { @@ -31,14 +31,15 @@ export default function AvailabilityForm({ publisherId, existingItems, inline, o }; const id = parseInt(router.query.id); - //coalsce existingItems to empty array - existingItems = existingItems || []; + + const originalAvailabilities = existingItems || []; const [editMode, setEditMode] = useState(existingItems.length > 0); const [publisher, setPublisher] = useState({ id: publisherId }); const [day, setDay] = useState(new Date(date)); const [canUpdate, setCanUpdate] = useState(true); + const [timeSlots, setTimeSlots] = useState([]); const [availabilities, setAvailabilities] = useState(existingItems && existingItems.length > 0 ? existingItems : [{ publisherId: publisher.id, @@ -143,6 +144,35 @@ export default function AvailabilityForm({ publisherId, existingItems, inline, o setAvailabilities(avs); } + + // Handle repetition logic + // const parentAvailabilityId = avs[0].id; + // originalAvailabilities.forEach(async av => { + // if (av.repeatWeekly && av.startTime < lockedBeforeDate) { + // const newDate = av.startTime; + // while (newDate < lockedBeforeDate) { + // const newAvailability = { + // ...av, + // startTime: newDate, + // parentAvailability: { connect: { id: parentAvailabilityId } }, + // publisher: { connect: { id: publisher.id } }, + // dateOfEntry: new Date(), + // type: "OneTime" + // }; + + // delete newAvailability.id; + // delete newAvailability.title; + // delete newAvailability.date; + // delete newAvailability.publisherId + + // await axiosInstance.post(urls.apiUrl, newAvailability); + // newDate.setDate(newDate.getDate() + 7); // Repeat weekly + // } + // } + // console.log("Updated availability: ", av) + // } + // ); + handleCompletion({ updated: true }); } catch (error) { alert("Нещо се обърка. Моля, опитайте отново по-късно."); @@ -220,9 +250,9 @@ export default function AvailabilityForm({ publisherId, existingItems, inline, o availability.dateOfEntry = new Date(); } - function createAvailabilityFromGroup(group) { + function createAvailabilityFromGroup(group, publisherId) { let availability = { - publisherId: publisher.id, + publisherId: publisherId, dayofweek: common.getDayOfWeekNameEnEnumForDate(day), }; diff --git a/components/calendar/avcalendar.tsx b/components/calendar/avcalendar.tsx index a829268..f83f238 100644 --- a/components/calendar/avcalendar.tsx +++ b/components/calendar/avcalendar.tsx @@ -62,6 +62,28 @@ const AvCalendar = ({ publisherId, events, selectedDate, cartEvents, lastPublish }, []); //const isAdmin = ProtectedRoute.IsInRole(UserRole.ADMIN); + //block dates between 1 and 18 august 2024 + const blockedDates = [ + new Date(2024, 7, 1), + new Date(2024, 7, 2), + new Date(2024, 7, 3), + new Date(2024, 7, 4), + new Date(2024, 7, 5), + new Date(2024, 7, 6), + new Date(2024, 7, 7), + new Date(2024, 7, 8), + new Date(2024, 7, 9), + new Date(2024, 7, 10), + new Date(2024, 7, 11), + new Date(2024, 7, 12), + new Date(2024, 7, 13), + new Date(2024, 7, 14), + new Date(2024, 7, 15), + new Date(2024, 7, 16), + new Date(2024, 7, 17), + new Date(2024, 7, 18), + ]; + const [date, setDate] = useState(new Date()); //ToDo: see if we can optimize this const [evts, setEvents] = useState(events); // Existing events @@ -243,6 +265,11 @@ const AvCalendar = ({ publisherId, events, selectedDate, cartEvents, lastPublish return; } + + if (blockedDates[0] <= startdate && startdate <= blockedDates[blockedDates.length - 1]) { + toast.error(`Не можете да въвеждате предпочитания за ${common.getDateFormattedShort(startdate)}`, { autoClose: 5000 }); + return; + } } // Check if start and end are on the same day if (startdate.toDateString() !== enddate.toDateString()) { @@ -539,6 +566,27 @@ const AvCalendar = ({ publisherId, events, selectedDate, cartEvents, lastPublish }, // ... other custom components }} + dayPropGetter={(date) => { + // Highlight the current day + // if (date.toDateString() === new Date().toDateString()) { + // return { + // style: { + // // white-500 from Tailwind CSS + // backgroundColor: '#f9fafb', + // color: 'white' + // } + // }; + // } + if (blockedDates[0] <= date && date <= blockedDates[blockedDates.length - 1]) { + return { + style: { + // red-100 from Tailwind CSS + backgroundColor: '#fee2e2', + color: 'white' + } + }; + } + }} eventPropGetter={(eventStyleGetter)} date={date} showAllEvents={true} @@ -556,6 +604,7 @@ const AvCalendar = ({ publisherId, events, selectedDate, cartEvents, lastPublish onDone={handleDialogClose} inline={true} cartEvent={cartEvent} + lockedBeforeDate={editLockedBefore} // Pass other props as needed />
diff --git a/components/publisher/PublisherForm.js b/components/publisher/PublisherForm.js index 7d76de3..85cdb6f 100644 --- a/components/publisher/PublisherForm.js +++ b/components/publisher/PublisherForm.js @@ -303,7 +303,8 @@ export default function PublisherForm({ item, me }) { {/* In-App notifications group */}

Известия в приложението

- + +
diff --git a/components/publisher/PublisherShiftsModal.js b/components/publisher/PublisherShiftsModal.js new file mode 100644 index 0000000..d8d1bd7 --- /dev/null +++ b/components/publisher/PublisherShiftsModal.js @@ -0,0 +1,189 @@ +import React, { useEffect } from 'react'; +import Link from 'next/link'; +import common from 'src/helpers/common'; +import axiosInstance from 'src/axiosSecure'; + +const PublisherShiftsModal = ({ publisher, _shifts, onClose, date, onAssignmentChange }) => { + + const [shifts, setShifts] = React.useState([..._shifts]); + //Refactor ToDo: show the whole month instead of just the current week by showing the shift start time in front of the rows, and show all shifts in the month from the first to the last week in the cell where we show one shift now + + const monthInfo = common.getMonthDatesInfo(new Date(date)); + const monthShifts = shifts.filter(shift => { + const shiftDate = new Date(shift.startTime); + return shiftDate > monthInfo.firstDay && shiftDate < monthInfo.lastDay; + }); + const weekShifts = monthShifts.filter(shift => { + const shiftDate = new Date(shift.startTime); + return common.getStartOfWeek(date) <= shiftDate && shiftDate <= common.getEndOfWeek(date); + }); + const dayShifts = weekShifts.map(shift => { + const isAvailable = publisher?.availabilities?.some(avail => + avail.startTime <= shift.startTime && avail.endTime >= shift.endTime + ); + let color = isAvailable ? getColorForShift(shift) : 'bg-gray-300'; + if (shift.isFromPreviousMonth) { + color += ' border-l-4 border-orange-500 '; + } + if (shift.isFromPreviousAssignment) { + color += ' border-l-4 border-red-500 '; + } + return { ...shift, isAvailable, color }; + }).reduce((acc, shift) => { + const dayIndex = new Date(shift.startTime).getDay(); + acc[dayIndex] = acc[dayIndex] || []; + acc[dayIndex].push(shift); + return acc; + }, {}); + console.log("dayShifts:", dayShifts); + + const hasAssignment = (shiftId) => { + // return publisher.assignments.some(ass => ass.shift.id == shiftId); + return publisher.assignments?.some(ass => { + //console.log(`Comparing: ${ass.shift.id} to ${shiftId}: ${ass.shift.id === shiftId}`); + return ass.shift.id === shiftId; + }); + }; + + + useEffect(() => { + const handleKeyDown = (event) => { + if (event.key === 'Escape') { + console.log('ESC: closing modal.'); + onClose(); // Call the onClose function when ESC key is pressed + } + }; + + // Add event listener + window.addEventListener('keydown', handleKeyDown); + + // Remove event listener on cleanup + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [onClose]); // Include onClose in the dependency array + + return ( +
+
+

График на + {publisher.firstName} {publisher.lastName} + {publisher.email} + тази седмица:

+ + {/* ... Display shifts in a calendar-like UI ... */} +
+ {Object.entries(dayShifts).map(([dayIndex, shiftsForDay]) => ( +
+ {/* Day header */} +
{new Date(shiftsForDay[0].startTime).getDate()}-ти
+ + {shiftsForDay.map((shift, index) => { + const assignmentExists = hasAssignment(shift.id); + const availability = publisher.availabilities.find(avail => + avail.startTime <= shift.startTime && avail.endTime >= shift.endTime + ); + const isFromPrevMonth = availability && availability.isFromPreviousMonth; + return ( +
+ {common.getTimeRange(shift.startTime, shift.endTime)} {shift.id} + + {!assignmentExists && shift.isAvailable && ( + + )} + {assignmentExists && ( + + )} +
+ ); + } + )} +
+ ))} +
+ + {/* Close button in the top right corner */} + + + {/* + + */} + {/* Edit button in the top right corner, next to the close button */} + + + + +
+
+ ); +} + +function getColorForShift(shift) { + const assignedCount = shift.assignedCount || 0; // Assuming each shift has an assignedCount property + switch (assignedCount) { + case 0: return 'bg-blue-300'; + case 1: return 'bg-green-300'; + case 2: return 'bg-yellow-300'; + case 3: return 'bg-orange-300'; + case 4: return 'bg-red-200'; + default: return 'bg-gray-300'; + } +} + +//ToDo: DRY - move to common +const addAssignment = async (publisher, shiftId) => { + try { + console.log(`calendar.idx: new assignment for publisher ${publisher.id} - ${publisher.firstName} ${publisher.lastName}`); + const newAssignment = { + publisher: { connect: { id: publisher.id } }, + shift: { connect: { id: shiftId } }, + isConfirmed: true + }; + const { data } = await axiosInstance.post("/api/data/assignments", newAssignment); + + // Update the 'publisher' property of the returned data with the full publisher object + data.publisher = publisher; + data.shift = shifts.find(shift => shift.id === shiftId); + publisher.assignments = [...publisher.assignments, data]; + // handleAssignmentChange(publisher.id, 'add'); + if (onAssignmentChange) { onAssignmentChange(publisher.id, 'add'); } + } catch (error) { + console.error("Error adding assignment:", error); + } +}; +const removeAssignment = async (publisher, shiftId) => { + try { + const assignment = publisher.assignments.find(ass => ass.shift.id === shiftId); + console.log(`calendar.idx: remove assignment for shift ${shiftId}`); + const { data } = await axiosInstance.delete(`/api/data/assignments/${assignment.id}`); + //remove from local assignments: + publisher.assignments = publisher.assignments.filter(a => a.id !== assignment.id) + // + // handleAssignmentChange(publisher.id, 'remove') + if (onAssignmentChange) { + onAssignmentChange(publisher.id, 'remove') + } + } catch (error) { + console.error("Error removing assignment:", error); + } +} + + +export default PublisherShiftsModal; \ No newline at end of file diff --git a/components/reports/ReportForm.js b/components/reports/ReportForm.js index 3672487..ae183e0 100644 --- a/components/reports/ReportForm.js +++ b/components/reports/ReportForm.js @@ -158,7 +158,7 @@ export default function ReportForm({ shiftId, existingItem, onDone }) { ))} -
+ {/*
-
+
*/}
diff --git a/components/survey/SurveyForm.tsx b/components/survey/SurveyForm.tsx index 25f4373..f1b7770 100644 --- a/components/survey/SurveyForm.tsx +++ b/components/survey/SurveyForm.tsx @@ -62,8 +62,8 @@ const SurveyForm: React.FC = ({ existingItem }) => { ...existingItem, content: existingItem?.content || "Нова анкета", answers: existingItem?.answers.split(",") || [], - publicFrom: existingItem?.publicFrom ? dayjs(existingItem.publicFrom).toISOString() : new Date().toISOString(), - publicUntil: existingItem?.publicUntil ? dayjs(existingItem.publicUntil).toISOString() : new Date().toISOString(), + publicFrom: existingItem?.publicFrom ? dayjs(existingItem.publicFrom).toISOString() : '', + publicUntil: existingItem?.publicUntil ? dayjs(existingItem.publicUntil).toISOString() : null, }); @@ -104,7 +104,7 @@ const SurveyForm: React.FC = ({ existingItem }) => { } else { //get all publisherIds and create a message for each - const messages = pubs.data.map(pub => { + const messages = pubs.map(pub => { return { publisherId: pub.id, content: JSON.stringify({ message: item.content, options: item.answers }), @@ -211,7 +211,7 @@ const SurveyForm: React.FC = ({ existingItem }) => { }, body: JSON.stringify({ id, - title: 'Нямаме отговор', + title: 'Напомняне', message: `${message}`, }) }); @@ -236,13 +236,16 @@ const SurveyForm: React.FC = ({ existingItem }) => { - handleDateChange('publicFrom', newDate)} value={dayjs(item?.publicFrom)} /> + handleDateChange('publicFrom', newDate)} + value={item && item.publicFrom ? dayjs(item.publicFrom) : null} />
- handleDateChange('publicUntil', newDate)} value={dayjs(item?.publicUntil)} /> + handleDateChange('publicUntil', newDate)} + value={item && item.publicUntil ? dayjs(item.publicUntil) : null} + />
-
    +
      {Array.isArray(availablePubs) && availablePubs?.map((pub, index) => { // Determine background and border classes based on conditions let bgAndBorderColorClass; @@ -814,8 +820,8 @@ export default function CalendarPage({ initialEvents, initialShifts }) {
    • = pub.desiredShiftsPerMonth ? 'text-gray-400' : 'text-gray-800'}`} - onDoubleClick={(handlePublisherModalOpen.bind(this, pub))} + ${pub.currentMonthAssignments === pub.desiredShiftsPerMonth ? 'text-gray-400' : pub.currentMonthAssignments > pub.desiredShiftsPerMonth ? 'text-orange-300' : 'text-gray-800'}`} + onDoubleClick={() => handlePublisherModalOpen(pub)} > {pub.firstName} {pub.lastName} @@ -831,36 +837,10 @@ export default function CalendarPage({ initialEvents, initialShifts }) { -
); @@ -899,151 +879,22 @@ export default function CalendarPage({ initialEvents, initialShifts }) {
{/* */}
- {isModalOpen && setIsModalOpen(false)} />} + {isModalOpen && ( + setIsModalOpen(false)} + date={value} + onAssignmentChange={handleAssignmentChange} + /> + )} ); - function PublisherShiftsModal({ publisher, shifts, onClose }) { - const monthInfo = common.getMonthDatesInfo(new Date(value)); - const monthShifts = shifts.filter(shift => { - const shiftDate = new Date(shift.startTime); - return shiftDate > monthInfo.firstDay && shiftDate < monthInfo.lastDay; - }); - const weekShifts = monthShifts.filter(shift => { - const shiftDate = new Date(shift.startTime); - return common.getStartOfWeek(value) <= shiftDate && shiftDate <= common.getEndOfWeek(value); - }); - const dayShifts = weekShifts.map(shift => { - const isAvailable = publisher.availabilities?.some(avail => - avail.startTime <= shift.startTime && avail.endTime >= shift.endTime - ); - let color = isAvailable ? getColorForShift(shift) : 'bg-gray-300'; - if (shift.isFromPreviousMonth) { - color += ' border-l-4 border-orange-500 '; - } - if (shift.isFromPreviousAssignment) { - color += ' border-l-4 border-red-500 '; - } - return { ...shift, isAvailable, color }; - }).reduce((acc, shift) => { - const dayIndex = new Date(shift.startTime).getDay(); - acc[dayIndex] = acc[dayIndex] || []; - acc[dayIndex].push(shift); - return acc; - }, {}); - console.log("dayShifts:", dayShifts); - - const hasAssignment = (shiftId) => { - // return publisher.assignments.some(ass => ass.shift.id == shiftId); - return publisher.assignments?.some(ass => { - //console.log(`Comparing: ${ass.shift.id} to ${shiftId}: ${ass.shift.id === shiftId}`); - return ass.shift.id === shiftId; - }); - }; - useEffect(() => { - const handleKeyDown = (event) => { - if (event.key === 'Escape') { - console.log('ESC: closing modal.'); - onClose(); // Call the onClose function when ESC key is pressed - } - }; - - // Add event listener - window.addEventListener('keydown', handleKeyDown); - - // Remove event listener on cleanup - return () => { - window.removeEventListener('keydown', handleKeyDown); - }; - }, [onClose]); // Include onClose in the dependency array - - return ( -
-
-

График на - {publisher.firstName} {publisher.lastName} - {publisher.email} - тази седмица:

- - {/* ... Display shifts in a calendar-like UI ... */} -
- {Object.entries(dayShifts).map(([dayIndex, shiftsForDay]) => ( -
- {/* Day header */} -
{new Date(shiftsForDay[0].startTime).getDate()}-ти
- - {shiftsForDay.map((shift, index) => { - const assignmentExists = hasAssignment(shift.id); - const availability = publisher.availabilities.find(avail => - avail.startTime <= shift.startTime && avail.endTime >= shift.endTime - ); - const isFromPrevMonth = availability && availability.isFromPreviousMonth; - return ( -
- {common.getTimeRange(shift.startTime, shift.endTime)} {shift.id} - - {!assignmentExists && shift.isAvailable && ( - - )} - {assignmentExists && ( - - )} -
- ); - } - )} -
- ))} -
- - {/* Close button in the top right corner */} - - - {/* - - */} - {/* Edit button in the top right corner, next to the close button */} - - - - -
-
- ); - } - - function getColorForShift(shift) { - const assignedCount = shift.assignedCount || 0; // Assuming each shift has an assignedCount property - switch (assignedCount) { - case 0: return 'bg-blue-300'; - case 1: return 'bg-green-300'; - case 2: return 'bg-yellow-300'; - case 3: return 'bg-orange-300'; - case 4: return 'bg-red-200'; - default: return 'bg-gray-300'; - } - } } import axiosServer from '../../../src/axiosServer'; diff --git a/pages/cart/publishers/index.tsx b/pages/cart/publishers/index.tsx index 493fe3e..8e8b55d 100644 --- a/pages/cart/publishers/index.tsx +++ b/pages/cart/publishers/index.tsx @@ -15,8 +15,7 @@ import { levenshteinEditDistance } from "levenshtein-edit-distance"; import ProtectedRoute from '../../../components/protectedRoute'; import ConfirmationModal from '../../../components/ConfirmationModal'; import { relative } from "path"; - - +import { set } from "lodash"; interface IProps { initialItems: Publisher[]; @@ -24,14 +23,31 @@ interface IProps { function PublishersPage({ publishers = [] }: IProps) { const [shownPubs, setShownPubs] = useState(publishers); + const [filter, setFilter] = useState(""); + const [showZeroShiftsOnly, setShowZeroShiftsOnly] = useState(false); const [filterIsImported, setFilterIsImported] = useState({ checked: false, indeterminate: true, }); - const [showZeroShiftsOnly, setShowZeroShiftsOnly] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); + // const cbRefFilterTraining = useRef(null); + + // const getCheckboxState = (currentState: boolean | null) => { + // if (currentState === true) return 'unchecked'; + // if (currentState === false) return 'checked'; + // return 'indeterminate'; + // }; + // const cbRefFilterTraining = useRef(null); + // const [cbFilterTrainingState, setcbFilterTrainingState] = useState(null); + // useEffect(() => { + // if (cbRefFilterTraining.current) { + // cbRefFilterTraining.current.indeterminate = cbFilterTrainingState === null; + // } + // }, [cbFilterTrainingState]); + const [flterNoTraining, setFilterNoTraining] = useState(false); + + const [isDeleting, setIsDeleting] = useState(false); const [isModalOpenDeleteAllVisible, setIsModalOpenDeleteAllVisible] = useState(false); const [isModalOpenDeleteAllAvaillabilities, setIsModalOpenDeleteAllAvaillabilities] = useState(false); @@ -102,10 +118,15 @@ function PublishersPage({ publishers = [] }: IProps) { ? filteredPublishers.filter(p => p.assignments.length === 0) : filteredPublishers; - setShownPubs(filteredPublishers); - }, [filter, showZeroShiftsOnly]); + // trained filter + if (flterNoTraining) { + filteredPublishers = filteredPublishers.filter(p => p.isTrained === false); + } + + setShownPubs(filteredPublishers); + }, [filter, showZeroShiftsOnly, flterNoTraining]); + - const checkboxRef = useRef(); const renderPublishers = () => { if (shownPubs.length === 0) { @@ -138,31 +159,33 @@ function PublishersPage({ publishers = [] }: IProps) { if (type === 'text') { setFilter(value); } else if (type === 'checkbox') { - // setFilterIsImported({ ...checkboxFilter, [name]: checked }); - const { checked, indeterminate } = checkboxRef.current; - if (!checked && !indeterminate) { - // Checkbox was unchecked, set it to indeterminate state - checkboxRef.current.indeterminate = true; - setFilterIsImported({ checked: false, indeterminate: true }); - } else if (!checked && indeterminate) { - // Checkbox was indeterminate, set it to checked state - checkboxRef.current.checked = true; - checkboxRef.current.indeterminate = false; - setFilterIsImported({ checked: true, indeterminate: false }); - } else if (checked && !indeterminate) { - // Checkbox was checked, set it to unchecked state - checkboxRef.current.checked = false; - checkboxRef.current.indeterminate = false; - setFilterIsImported({ checked: false, indeterminate: false }); - } else { - // Checkbox was checked and indeterminate (should not happen), set it to unchecked state - checkboxRef.current.checked = false; - checkboxRef.current.indeterminate = false; - setFilterIsImported({ checked: false, indeterminate: false }); + if (name === 'filterIsImported') { + setFilterIsImported({ checked, indeterminate: false }); + } + if (name === 'filterTrained') { + // const nextState = cbFilterTrainingState === false ? null : cbFilterTrainingState === null ? true : false; + // setcbFilterTrainingState(nextState); + setFilterNoTraining(checked); } } }; + const exportPublishers = async () => { + try { + const response = await axiosInstance.get('/api/?action=exportPublishersExcel'); + const blob = new Blob([response.data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'publishers.xlsx'; + a.click(); + } catch (error) { + console.error(JSON.stringify(error)); // Log the error + toast.error("Грешка при експорт на данни"); + } + + } + return ( @@ -195,8 +218,13 @@ function PublishersPage({ publishers = [] }: IProps) { + {/* export by calling excel helper .ExportPublishersToExcel() */} +
+ +
+
@@ -212,7 +240,17 @@ function PublishersPage({ publishers = [] }: IProps) { само без смени - {publishers.length} от {publishers.length} вестителя + + + {shownPubs.length} от {publishers.length} вестителя
@@ -226,12 +264,12 @@ function PublishersPage({ publishers = [] }: IProps) { export default PublishersPage; -//import { set } from "date-fns"; +//import {set} from "date-fns"; export const getServerSideProps = async (context) => { // const axios = await axiosServer(context); // //ToDo: refactor all axios calls to use axiosInstance and this URL - // const { data: publishers } = await axios.get('/api/data/publishers?select=id,firstName,lastName,email,isActive,isTrained,isImported,assignments.shift.startTime,availabilities.startTime&dev=fromuseefect'); + // const {data: publishers } = await axios.get('/api/data/publishers?select=id,firstName,lastName,email,isActive,isTrained,isImported,assignments.shift.startTime,availabilities.startTime&dev=fromuseefect'); //use prisma instead of axios const prisma = common.getPrismaClient(); let publishers = await prisma.publisher.findMany({ diff --git a/pages/cart/reports/experience.tsx b/pages/cart/reports/experience.tsx index b93cfd6..5f07dbf 100644 --- a/pages/cart/reports/experience.tsx +++ b/pages/cart/reports/experience.tsx @@ -9,7 +9,7 @@ import ProtectedRoute from '../../../components/protectedRoute'; function NewPage(loc: Location) { return ( - +
diff --git a/pages/permits.tsx b/pages/permits.tsx index 07bdf33..82bcdf1 100644 --- a/pages/permits.tsx +++ b/pages/permits.tsx @@ -105,7 +105,12 @@ export default PDFViewerPage; export const getServerSideProps = async (context) => { - const permitsFolder = '/public/content/permits/'; + const permitsFolder = path.join('public', 'content', 'permits'); + + // Create folders if they do not exist + if (!fs.existsSync(permitsFolder)) { + fs.mkdirSync(permitsFolder, { recursive: true }); + } //get all the files in the permits folder order them by date desc and display them const pdfFiles = fs.readdirSync(path.join(process.cwd(), permitsFolder)).map(file => { return { diff --git a/prisma/migrations/20240621124135_/migration.sql b/prisma/migrations/20240621124135_/migration.sql new file mode 100644 index 0000000..5dc706d --- /dev/null +++ b/prisma/migrations/20240621124135_/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE `Message` MODIFY `content` TEXT NOT NULL; + +-- AlterTable +ALTER TABLE `Survey` MODIFY `content` TEXT NOT NULL; \ No newline at end of file diff --git a/prisma/migrations/20240911233923_relax_constraints/migration.sql b/prisma/migrations/20240911233923_relax_constraints/migration.sql new file mode 100644 index 0000000..0d9769c --- /dev/null +++ b/prisma/migrations/20240911233923_relax_constraints/migration.sql @@ -0,0 +1,6 @@ +-- DropForeignKey +ALTER TABLE `Message` DROP FOREIGN KEY `Message_publisherId_fkey`; + +-- AddForeignKey +ALTER TABLE `Message` +ADD CONSTRAINT `Message_publisherId_fkey` FOREIGN KEY (`publisherId`) REFERENCES `Publisher` (`id`) ON DELETE CASCADE ON UPDATE CASCADE; \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 35996b6..d1d1bf0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -266,7 +266,7 @@ enum MessageType { model Survey { id Int @id @default(autoincrement()) - content String + content String @db.Text answers Json? messages Message[] publicFrom DateTime? @@ -275,10 +275,10 @@ model Survey { model Message { id Int @id @default(autoincrement()) - publisher Publisher @relation(fields: [publisherId], references: [id]) + publisher Publisher @relation(fields: [publisherId], references: [id], onDelete: Cascade) publisherId String date DateTime - content String + content String @db.Text isRead Boolean @default(false) isPublic Boolean @default(false) type MessageType @default(Email) @@ -305,7 +305,7 @@ model EventLog { id Int @id @default(autoincrement()) date DateTime publisherId String? - publisher Publisher? @relation(fields: [publisherId], references: [id]) + publisher Publisher? @relation(fields: [publisherId], references: [id], onDelete: SetNull) shiftId Int? shift Shift? @relation(fields: [shiftId], references: [id]) content String @db.VarChar(5000) diff --git a/public/content/permits/Разрешително за Април - 24г..pdf b/public/content/permits/Разрешително за Април - 24г..pdf deleted file mode 100644 index 0c32d80..0000000 Binary files a/public/content/permits/Разрешително за Април - 24г..pdf and /dev/null differ diff --git a/server.js b/server.js index 601079c..7fbafbd 100644 --- a/server.js +++ b/server.js @@ -574,7 +574,7 @@ nextApp // --------------- EXCEL EXPORT ROUTE ---------------- server.get("/generatexcel/:year/:month/:process", async (req, res) => { - await excel.GenerateExcel(req, res); + await excel.ScheduleGenerateExcel(req, res); }); diff --git a/src/helpers/excel.js b/src/helpers/excel.js index 4bba712..13f057a 100644 --- a/src/helpers/excel.js +++ b/src/helpers/excel.js @@ -14,8 +14,12 @@ const data = require("./data"); // for nodejs //const api = require("./pages/api/index"); +// import dynamic from 'next/dynamic'; -exports.GenerateExcel = async function (req, res) { +// const XLSX = dynamic(() => import('xlsx-style'), { ssr: false }); + + +exports.ScheduleGenerateExcel = async function (req, res) { const prisma = common.getPrismaClient(); const year = req.params.year; @@ -162,7 +166,7 @@ exports.GenerateExcel = async function (req, res) { const ws = XLSX.utils.aoa_to_sheet(ws_data); wb.Sheets["График КОЛИЧКИ"] = ws; - const xlsxstyle = require("xlsx-style"); + const xlsxstyle = require("xlsx-js-style"); try { const workbook = xlsxstyle.readFile(filePath); const sheetNames = workbook.SheetNames; @@ -611,6 +615,55 @@ exports.ReadDocxFileForMonth = async function (filePath, buffer, month, year, pr } }; + +exports.ExportPublishersToExcel = async function (req, res) { + const prisma = common.getPrismaClient(); + const publishers = await prisma.publisher.findMany({ + // where: { isActive: true, }, + include: { + // availabilities: { where: { isActive: true, }, }, + // assignments: { include: { shift: true, }, }, + congregation: true, + + }, + }); + const ExcelJS = require("exceljs"); + const xjswb = new ExcelJS.Workbook(); + const sheet = xjswb.addWorksheet("Publishers"); + sheet.columns = [ + { header: "Name", key: "name", width: 32 }, + { header: "Trained", key: "trained", width: 10 }, + { header: "Email", key: "email", width: 32 }, + { header: "Phone", key: "phone", width: 32 }, + { header: "Role", key: "role", width: 32 }, + { header: "Congregation", key: "congregationName", width: 32 }, + { header: "Last Login", key: "lastLogin", width: 32 }, + { header: "Type", key: "PublisherTypeText", width: 32 }, + { header: "Active", key: "isActive", width: 10 }, + { header: "Created At", key: "createdAt", width: 32 }, + { header: "Updated At", key: "updatedAt", width: 32 }, + ]; + publishers.forEach((publisher) => { + sheet.addRow({ + name: publisher.firstName + " " + publisher.lastName, + trained: publisher.isTrained, + email: publisher.email, + phone: publisher.phone, + role: publisher.role, + congregationName: publisher.congregation.name, + lastLogin: publisher.lastLogin, + PublisherTypeText: publisher.PublisherTypeText, + isActive: publisher.isActive, + createdAt: publisher.createdAt, + updatedAt: publisher.updatedAt, + + }); + }); + res.setHeader("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + res.setHeader("Content-Disposition", "attachment; filename=" + encodeURI("Publishers.xlsx")); + xjswb.xlsx.write(res); +} + const weekNames = [ "Понеделник", "Вторник",