diff --git a/.vscode/launch.json b/.vscode/launch.json index ed8ac12..8cce96d 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -60,6 +60,17 @@ "APP_ENV": "development.devserver" } }, + { + "name": "!Run npm DEV (UI REDESIGN)", + "request": "launch", + "type": "node-terminal", + "cwd": "${workspaceFolder}", + "command": "npm run start-env", + "env": { + // "NODE_ENV": "test", + "APP_ENV": "development.devserver" + } + }, { "name": "Run conda npm TEST", "request": "launch", diff --git a/_doc/ToDo.md b/_doc/ToDo.md index fa13d7e..1bc87ef 100644 --- a/_doc/ToDo.md +++ b/_doc/ToDo.md @@ -259,7 +259,7 @@ in schedule admin - if a publisher is always pair & family is not in the shift - [] fix transport UI [] revise import/export to word [] allow keyman/scheduler role -[] allow blocking of inputs (different from publishing) +[] 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 diff --git a/components/ErrorBoundary.tsx b/components/ErrorBoundary.tsx new file mode 100644 index 0000000..2dbbd89 --- /dev/null +++ b/components/ErrorBoundary.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +class ErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { hasError: false }; + this.logger = null; + + if (typeof window === 'undefined') { + this.logger = require('../src/logger'); + } + } + + static getDerivedStateFromError(error) { + // Update state so the next render will show the fallback UI. + return { hasError: true }; + } + + componentDidCatch(error, info) { + // Log the error to an error reporting service + console.error(error, info); + if (this.logger) { + this.logger.error(`${error}: ${info.componentStack}`); + } + } + + render() { + if (this.state.hasError) { + // Render any custom fallback UI + return

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

; + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/components/PwaManager.tsx b/components/PwaManager.tsx index 5fe808f..464afda 100644 --- a/components/PwaManager.tsx +++ b/components/PwaManager.tsx @@ -35,6 +35,7 @@ function PwaManager({ subs }) { useEffect(() => { if (isSupported()) { setNotificationPermission(Notification.permission); + getSubscriptionCount(); } // Handle Push Notification Subscription @@ -77,6 +78,10 @@ function PwaManager({ subs }) { window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt); window.removeEventListener('appinstalled', handleAppInstalled); }; + + + + }, []); @@ -127,6 +132,7 @@ function PwaManager({ subs }) { throw new Error("Failed to fetch VAPID public key from server."); } } + const sub = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: common.base64ToUint8Array(vapidPublicKey) @@ -197,6 +203,19 @@ function PwaManager({ subs }) { } }; + const getSubscriptionCount = async () => { + try { + const response = await fetch('/api/notify?id=' + session.user.id, { method: 'GET' }); + if (!response.ok) { + throw new Error('Failed to fetch subscription data.'); + } + const result = await response.json(); + setSubs(result.subs); + } catch (error) { + console.error('Error fetching subscription data:', error); + } + }; + // Function to request push notification permission const requestNotificationPermission = async (e) => { e.preventDefault(); @@ -243,48 +262,56 @@ function PwaManager({ subs }) { headers: { 'Content-Type': 'application/json' }, - //sends test notification to the current subscription - // body: JSON.stringify({ subscription }) - //sends test notification to all subscriptions of this user - body: JSON.stringify({ id: session.user.id, title: "Тестово уведомление", message: "Това е тестово уведомление" }) + body: JSON.stringify( + { + id: session.user.id, + title: "Тестово уведомление", + message: "Това е тестово уведомление", + actions: [{ action: 'test', title: 'Тест', icon: '✅' }, + { action: 'close', title: 'Затвори', icon: '❌' }] + }) }); }; - async function sendTestReminder(event: MouseEvent): Promise { - event.preventDefault(); - if (!subscription) { - console.error('Web push not subscribed'); - return; - } + // async function sendTestReminder(event: MouseEvent): Promise { + // event.preventDefault(); + // if (!subscription) { + // console.error('Web push not subscribed'); + // return; + // } - await fetch('/api/notify', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ broadcast: true, message: "Мили братя, искаме да ви напомним да ни изпратите вашите предпочитания за юни до 25-то число като използвате меню 'Възможности'. Ако имате проблем, моля пишете ни на 'specialnosvidetelstvanesofia@gmail.com'" }) - }); - } + // await fetch('/api/notify', { + // method: 'POST', + // headers: { + // 'Content-Type': 'application/json' + // }, + // body: JSON.stringify({ + // broadcast: true, + // message: "Мили братя, искаме да ви напомним да ни изпратите вашите предпочитания за юни до 25-то число като използвате меню 'Възможности'. Ако имате проблем, моля пишете ни на 'specialnosvidetelstvanesofia@gmail.com'" + // }) + // }); + // } - async function sendTestCoverMe(event: MouseEvent): Promise { - event.preventDefault(); - if (!subscription) { - console.error('Web push not subscribed'); - return; - } + // async function sendTestCoverMe(event: MouseEvent): Promise { + // event.preventDefault(); + // if (!subscription) { + // console.error('Web push not subscribed'); + // return; + // } - await fetch('/api/notify', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - broadcast: true, message: "Брат ТЕСТ търси заместник за 24-ти май от 10:00 ч. Можеш ли да го покриеш?", - //use fontawesome icons for actions - actions: [{ action: 'covermeaccepted', title: 'Да ', icon: '✅' }] - }) - }); - } + // await fetch('/api/notify', { + // method: 'POST', + // headers: { + // 'Content-Type': 'application/json' + // }, + // body: JSON.stringify({ + // id: session.user.id, + // message: "Брат ТЕСТ търси заместник за 24-ти май от 10:00 ч. Можеш ли да го покриеш?", + // //use fontawesome icons for actions + // actions: [{ action: 'covermeaccepted', title: 'Да ', icon: '✅' }] + // }) + // }); + // } async function deleteAllSubscriptions(event: MouseEvent): Promise { event.preventDefault(); @@ -358,22 +385,22 @@ function PwaManager({ subs }) { {isAdmin &&
- - + */}
} {notificationPermission !== "granted" && ( diff --git a/components/availability/AvailabilityForm.js b/components/availability/AvailabilityForm.js index aa1f8a4..454010f 100644 --- a/components/availability/AvailabilityForm.js +++ b/components/availability/AvailabilityForm.js @@ -10,7 +10,7 @@ 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'; //ToDo obsolete +import { isBefore, addMinutes, isAfter, isEqual, set, getHours, getMinutes, getSeconds } from 'date-fns'; //ToDo obsolete? import { stat } from 'fs'; const { DateTime, FixedOffsetZone } = require('luxon'); diff --git a/components/availability/AvailabilityList.js b/components/availability/AvailabilityList.js index 6447c00..0add9e9 100644 --- a/components/availability/AvailabilityList.js +++ b/components/availability/AvailabilityList.js @@ -17,11 +17,22 @@ export default function AvailabilityList({ publisher, showNew }) { const [showAv, setShowAv] = useState(showNew || false); const [selectedItem, setSelectedItem] = useState(null); const [items, setItems] = useState(publisher.availabilities); // Convert items prop to state + const [blockedAvailabilityDate, setBlockedAvailabilityDate] = useState(null); useEffect(() => { console.log('items set to:', items?.map(item => item.id)); }, [items]) + useEffect(() => { + axiosInstance.get(`/api/?action=settings&key=AvailabilityBlockDate`) + .then(({ data }) => { + setBlockedAvailabilityDate(new Date(data.value)); + }) + .catch(error => { + console.error("Error getting blocked date:", error); + }); + }, []); + const toggleAv = () => setShowAv(!showAv); const editAvailability = (item) => { setSelectedItem(item); @@ -72,9 +83,16 @@ export default function AvailabilityList({ publisher, showNew }) { {/* */} - + {(blockedAvailabilityDate && new Date(item.startTime) < blockedAvailabilityDate) ? ( + + ) : ( + + )} + ))} diff --git a/components/calendar/avcalendar.tsx b/components/calendar/avcalendar.tsx index 27b66d9..a829268 100644 --- a/components/calendar/avcalendar.tsx +++ b/components/calendar/avcalendar.tsx @@ -511,7 +511,8 @@ const AvCalendar = ({ publisherId, events, selectedDate, cartEvents, lastPublish <>
{/* достъпности на {publisherId} */} - + {/* having multiple ToastContainers causes double rendering of toasts and all kind of problems */} + {/* */}
- {children} + + {children} +
{/* Modal container */}
diff --git a/components/protectedRoute.tsx b/components/protectedRoute.tsx index 5b044a3..102e2a1 100644 --- a/components/protectedRoute.tsx +++ b/components/protectedRoute.tsx @@ -46,7 +46,8 @@ const ProtectedRoute = ({ children, allowedRoles, deniedMessage, bypass = false,

{session?.user?.email},

-

{`Нямате достъп до тази страница. Ако мислите, че това е грешка, моля, свържете се с администраторите`}

+

{`Нямате достъп до тази страница.`}

+

{`Ако мислите, че това е грешка, моля, свържете се с администраторите`}

); diff --git a/components/publisher/PublisherForm.js b/components/publisher/PublisherForm.js index 51ae652..7d76de3 100644 --- a/components/publisher/PublisherForm.js +++ b/components/publisher/PublisherForm.js @@ -92,15 +92,15 @@ export default function PublisherForm({ item, me }) { let { familyHeadId, userId, congregationId, ...rest } = publisher; // Set the familyHead relation based on the selected head - const familyHeadRelation = familyHeadId ? { connect: { id: familyHeadId } } : { disconnect: true }; - const userRel = userId ? { connect: { id: userId } } : { disconnect: true }; - const congregationRel = congregationId ? { connect: { id: parseInt(congregationId) } } : { disconnect: true }; + const familyHeadRelation = familyHeadId ? { connect: { id: familyHeadId } } : null; + const userRel = userId ? { connect: { id: userId } } : null; + const congregationRel = congregationId ? { connect: { id: parseInt(congregationId) } } : null; // Return the new state without familyHeadId and with the correct familyHead relation rest = { ...rest, - familyHead: familyHeadRelation, - user: userRel, - congregation: congregationRel + ...(familyHeadRelation ? { familyHead: familyHeadRelation } : {}), + ...(userRel ? { user: userRel } : {}), + ...(congregationRel ? { congregation: congregationRel } : {}), }; try { @@ -112,7 +112,7 @@ export default function PublisherForm({ item, me }) { position: "bottom-center", }); } else { - await axiosInstance.post(urls.apiUrl, publisher); + await axiosInstance.post(urls.apiUrl, rest); toast.success("Task Saved", { position: "bottom-center", }); diff --git a/components/reports/ReportForm.js b/components/reports/ReportForm.js index 998a236..3672487 100644 --- a/components/reports/ReportForm.js +++ b/components/reports/ReportForm.js @@ -81,6 +81,7 @@ export default function ReportForm({ shiftId, existingItem, onDone }) { }; fetchData(); }, [item.date, existingItem]); + const handleChange = ({ target }) => { setItem({ ...item, [target.name]: target.value }); }; diff --git a/components/sidemenuData.js b/components/sidemenuData.js index e884535..fb62d4b 100644 --- a/components/sidemenuData.js +++ b/components/sidemenuData.js @@ -1,5 +1,9 @@ import { UserRole } from "@prisma/client"; +// import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +// import { faStar } from '@fortawesome/free-solid-svg-icons'; // Star icon +// import { faClipboardList } from '@fortawesome/free-solid-svg-icons'; // Clipboard icon +import { FaStar } from 'react-icons/fa'; // Import FontAwesome icons const sidemenu = [ @@ -103,6 +107,19 @@ const sidemenu = [ text: "Календар", url: "/cart/calendar", roles: [UserRole.ADMIN, UserRole.POWERUSER], + }, + { + id: "surveys", + // text: "Анкети", + // add new icon before text + // text: "Анкети", + text: ( + + + Анкети + + ), + url: "/cart/surveys", }, { id: "cart-reports", text: "Отчети", diff --git a/components/survey/SurveyForm.tsx b/components/survey/SurveyForm.tsx new file mode 100644 index 0000000..25f4373 --- /dev/null +++ b/components/survey/SurveyForm.tsx @@ -0,0 +1,354 @@ +import axiosInstance from '../../src/axiosSecure'; +import { use, useEffect, useState } from "react"; +import { toast } from "react-hot-toast"; +import { useRouter } from "next/router"; +import Link from "next/link"; +import { useSession } from "next-auth/react" +import { MessageType, Message, Survey } from "@prisma/client"; +// import { content } from 'googleapis/build/src/apis/content'; +import { DatePicker } from '@mui/x-date-pickers/DatePicker'; +// import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFnsV3'; +// import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs' +import dayjs from 'dayjs'; +import { set } from 'lodash'; + +const common = require('src/helpers/common'); + + +// ------------------ ------------------ +// This component is used to create and edit +/* location model: + +model Survey { + id Int @id @default(autoincrement()) + content String + answers Json? + messages Message[] + publicFrom DateTime? + publicUntil DateTime? +} + +model Message { + id Int @id @default(autoincrement()) + publisher Publisher @relation(fields: [publisherId], references: [id]) + publisherId String + date DateTime + content String + isRead Boolean @default(false) + isPublic Boolean @default(false) + type MessageType @default(Email) + publicUntil DateTime? + shownDate DateTime? + answer String? + answerDate DateTime? + + Survey Survey? @relation(fields: [surveyId], references: [id]) + surveyId Int? +} +*/ + +interface SurveyFormProps { + existingItem: Survey | null; +} + +const SurveyForm: React.FC = ({ existingItem }) => { + + const router = useRouter(); + const [editMode, setEditMode] = useState(existingItem ? true : false); + const [pubs, setPubs] = useState([]); + + + const [item, setItem] = useState(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(), + }); + + + useEffect(() => { + let transformedItem = { ...existingItem }; + transformedItem.answersCount = existingItem?.answers.split(",") || []; + setEditMode(existingItem ? true : false); + setItem(transformedItem); + }, [existingItem]); + + useEffect(async () => { + const pubs = await axiosInstance.get("/api/data/publishers?select=id,firstName,lastName,email"); + setPubs(pubs.data); + }, []); + + + + const handleChange = ({ target }) => { + setItem({ ...item, [target.name]: target.value }); + }; + + const handleDateChange = (fieldName, newDate) => { + setItem((prevItem) => ({ + ...prevItem, + [fieldName]: newDate + })); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + delete item.answersCount; + try { + + if (editMode) { + delete item.messages; + const { data } = await axiosInstance.put(`/api/data/surveys/${existingItem.id}`, item); + toast.success("Анкетата е обновена успешно"); + } + else { + //get all publisherIds and create a message for each + const messages = pubs.data.map(pub => { + return { + publisherId: pub.id, + content: JSON.stringify({ message: item.content, options: item.answers }), + date: new Date(), + isPublic: false, + type: MessageType.InApp, + publicUntil: item.publicUntil, + } + }); + item.messages = { create: messages }; + const { data } = await axiosInstance.post("/api/data/surveys", item); + toast.success("Анкетата е създадена успешно"); + } + router.push("/cart/surveys"); + } catch (error) { + toast.error("Възникна грешка при създаването на анкетата"); + console.error("Error creating survey:", error); + } + } + + const handleDelete = async (e) => { + e.preventDefault(); + if (!editMode) return; + try { + await axiosInstance.delete(`/api/data/surveys/${existingItem.id}`); + + toast.success("Записът изтрит", { + position: "bottom-center", + }); + router.push("/cart/surveys"); + + } catch (error) { + //alert("Нещо се обърка при изтриването. Моля, опитайте отново или се свържете с нас"); + console.log(JSON.stringify(error)); + toast.error(error.response?.data?.message || "Нещо се обърка при изтриването. Моля, опитай отново и се свържете с нас ако проблема продължи."); + } + }; + + function handleDeleteAnswers(e: MouseEvent): void { + e.preventDefault(); + if (!editMode) return; + + Promise.all(existingItem.messages.map(message => + axiosInstance.put(`/api/data/messages/${message.id}`, { answer: null }) + )) + .then(() => { + toast.success("Отговорите изтрити", { + position: "bottom-center", + }); + }) + .catch((error) => { + console.log(JSON.stringify(error)); + toast.error(error.response?.data?.message || "Нещо се обърка при изтриването. Моля, опитай отново и се свържете с нас ако проблема продължи."); + }); + } + + + const getNamesByIds = (ids) => { + return ids + .map((id) => { + const pub = pubs.find((p) => p.id === id); + return pub ? `${pub.firstName} ${pub.lastName}` : null; + }) + .filter((name) => name !== null) + .join(", "); + }; + + const getIdsForAnswer = (answer) => { + return item.messages + .filter((message) => message.answer === answer) + .map((message) => message.publisherId); + }; + + const getIdsForAnswered = () => { + return item.messages + .filter((message) => message.answer) + .map((message) => message.publisherId); + }; + + const getIdsForUnanswered = () => { + return item.messages + .filter((message) => !message.answer) + .map((message) => message.publisherId); + }; + + // const copyToClipboard = (text) => { + // navigator.clipboard.writeText(text).then( + // () => toast.success('Copied to clipboard!'), + // (err) => toast.error('Failed to copy text: ', err) + // ); + // }; + + const copyToClipboard = (text) => { + navigator.clipboard.writeText(text).then( + () => alert('Имената са копирани: ' + text), + (err) => alert('Не успяхме да копираме имената: ', err) + ); + }; + const sendIndividualNotification = async (id, message) => { + const response = await fetch('/api/notify', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + id, + title: 'Нямаме отговор', + message: `${message}`, + }) + }); + + if (response.ok) { + console.log(`Notification sent successfully to ${name}`); + } else { + console.error(`Failed to send notification to ${name}`); + } + }; + + const handleSendNotificationsToAllUnanswered = async (message) => { + getIdsForUnanswered().forEach((id, index) => sendIndividualNotification(id, message)); + }; + + return ( +
+ < form className="bg-white dark:bg-gray-800 shadow-md rounded px-8 pt-6 pb-8 mb-4" onSubmit={handleSubmit} > + +

Анкета {existingItem?.id}

+
+ + handleDateChange('publicFrom', newDate)} value={dayjs(item?.publicFrom)} /> +
+
+ + handleDateChange('publicUntil', newDate)} value={dayjs(item?.publicUntil)} /> +
+
+ +