Merge branch 'main' into production

This commit is contained in:
Dobromir Popov
2025-01-04 20:43:54 +02:00
38 changed files with 3774 additions and 3747 deletions

15
.env
View File

@ -8,24 +8,23 @@ NEXTAUTH_SECRET=ed8a9681efc414df89dfd03cd188ed58
NODE_ENV=development
# mysql. ONLY THIS ENV is respected when generating/applying migrations in prisma
DATABASE=mysql://cart:cartpw@localhost:3306/cart
# DATABASE=mysql://cart:cartpw@192.168.0.10:3306/cart_dev
NEXT_PUBLIC_PUBLIC_URL=https://localhost:3003
# DATABASE=mysql://cart:cartpw@localhost:3306/cart
DATABASE=mysql://db:db@192.168.0.10:3306/cart
# NEXT_PUBLIC_PUBLIC_URL=https://localhost:3003
ADMIN_PASSWORD=123456
# // owner: dobromir.popov@gmail.com | Специално Свидетелстване София
# // https://console.cloud.google.com/apis/credentials/oauthclient/926212607479-d3m8hm8f8esp3rf1639prskn445sa01v.apps.googleusercontent.com?project=grand-forge-108716
# // https://console.cloud.google.com/apis/credentials/oauthclient/926212607479-d3m8hm8f8esp3rf1639prskn445sa01v.apps.googleusercontent.com?project=grand-forge-108716
# callback https://sofia.mwitnessing.com/api/auth/callback/google
GOOGLE_ID=926212607479-d3m8hm8f8esp3rf1639prskn445sa01v.apps.googleusercontent.com
GOOGLE_SECRET=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
# //https://sofia.mwitnessing.com/api/auth/callback/microsoft
# https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app
# owner: dobromirpopovgateway.onmicrosoft.com dobromir.popov@gateway.one (personal) Doby Popov P One
# callback https://sofia.mwhitnessing.com/api/auth/callback/azure-ad
AZURE_AD_CLIENT_ID=9e13bedd-1f9d-4c23-910e-a806aba308b6 # Application (client) ID
AZURE_AD_CLIENT_ID=9e13bedd-1f9d-4c23-910e-a806aba308b6 # Application (client) ID
AZURE_AD_CLIENT_SECRET=5ic8Q~GQmW-IUhuxzVGx3BE-i30GXDSpjfMHcb~z #client secret value
AZURE_AD_TENANT_ID=f69d1a93-bfba-498a-9b60-e87c1bc26276
@ -55,8 +54,6 @@ FACEBOOK_SECRET=
GITHUB_ID=
GITHUB_SECRET=
TWITTER_ID=
TWITTER_SECRET=

View File

@ -1,4 +1,4 @@
NODE_TLS_REJECT_UNAUTHORIZED=0
NODE_TLS_REJECT_UNAUTHORIZED=0
# NODE_EXTRA_CA_CERTS=C:\\Users\\popov\\AppData\\Local\\mkcert
ENV_ENV=.env.development
PROTOCOL=https
@ -8,7 +8,6 @@ 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

1
.gitignore vendored
View File

@ -37,3 +37,4 @@ public/content/output/shifts 2024.1.json
!public/content/uploads/*
.aider*
/shift_generate_log_*.txt
.env

View File

@ -16,7 +16,18 @@ services:
- GIT_USERNAME=deploy
- GIT_PASSWORD=L3Kr2R438u4F7
- ADMIN_PASSWORD=kolichkisofia2024
command: sh -c " cd /app && npm install && npx next build && npm run start-env; tail -f /dev/null"
command: >
sh -c "
cd /app &&
rm -rf node_modules package-lock.json &&
npm install --no-package-lock &&
npm uninstall @prisma/client prisma &&
npm install @prisma/client@5.22.0 prisma@5.22.0 --save-exact &&
npx next build &&
npx prisma migrate deploy &&
npx prisma generate &&
npm run start-env;
tail -f /dev/null"
tty: true
stdin_open: true
restart: always

View File

@ -3,7 +3,7 @@
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')" > /app/logs/deploy.txt
echo "Updating code from git.d-popov.com...(as '$GIT_USERNAME'). Branch: ${GIT_BRANCH:main}" > /app/logs/deploy.txt
# Create a temporary directory for the new clone
rm -rf /tmp/clone

View File

@ -31,7 +31,7 @@ services:
ports:
- 8083:8080
# THE APP
# THE APP
nextjs-app:
image: sachinvashist/nextjs-docker
ports:
@ -61,5 +61,3 @@ services:
stdin_open: true
# depends_on:
# - mariadb

View File

@ -148,6 +148,12 @@ npx prisma migrate resolve --applied 20240201214719_assignment_add_repeat_freque
npx prisma migrate dev --schema "mysql://cart:cart2023@192.168.0.10:3306/cart_dev" # -- does not work
# reset to version:
npm uninstall @prisma/client prisma
npm install @prisma/client@5.22.0 prisma@5.22.0
# check current:
npm why prisma
## ---------------------- import database --------------------------------- ##
gunzip < /prisma/backups/jwpwsofia-20240430-bak.gz | mysql -u mysql_username -p database_name

View File

@ -9,6 +9,7 @@ import FileUploadWithPreview from 'components/FileUploadWithPreview ';
import ProtectedRoute, { serverSideAuth } from "../../components/protectedRoute";
import { UserRole } from "@prisma/client";
import { Input } from '@mui/base';
const common = require('src/helpers/common');
@ -37,8 +38,9 @@ export default function LocationForm() {
useEffect(() => {
const fetchUploadedImages = async () => {
try {
const response = await axiosInstance.get('/uploaded-images');
setUploadedImages(response.data.imageUrls);
// ToDo: we don't have this endpoint yet and we don't use the uploaded images collection
// const response = await axiosInstance.get('/uploaded-images');
// setUploadedImages(response.data.imageUrls);
} catch (error) {
console.error('Error fetching uploaded images:', error);
}
@ -67,6 +69,7 @@ export default function LocationForm() {
address: "",
isActive: true,
});
const [isRawHtml, setIsRawHtml] = useState(false);
// const [isEdit, setIsEdit] = useState(false);
@ -178,6 +181,13 @@ export default function LocationForm() {
<label className="label" htmlFor="isActive">Активна</label>
</div>
</div>
{/* is on main menu */}
<div className="mb-4">
<div className="form-check">
<input className="checkbox form-input" type="checkbox" id="isOnMainMenu" name="isOnMainMenu" onChange={handleChange} checked={location.isOnMainMenu} autoComplete="off" />
<label className="label" htmlFor="isOnMainMenu">Покажи в главното меню</label>
</div>
</div>
{/* backupLocation */}
<div className="mb-4">
<label className="label" htmlFor="backupLocation">При дъжд и лошо време</label>
@ -238,12 +248,50 @@ export default function LocationForm() {
<label className="label" htmlFor="content">Content</label>
<TextEditor
ref={quillRef}
value={content}
onChange={setContent}
placeholder="Описание на локацията. Снимки"
prefix={`location-${router.query.id}`} />
<ProtectedRoute allowedRoles={[UserRole.ADMIN]} deniedMessage=" ">
<label className="label" htmlFor="backupLocation">Admin Edit Location HTML</label>
<div className="field mb-4">
<label className="switch">
<input
type="checkbox"
checked={isRawHtml}
onChange={(e) => {
setIsRawHtml(e.target.checked);
}}
/>
<span className="slider round"></span>
<span className="ml-2">Edit Raw HTML</span>
</label>
{isRawHtml && <>
<label className="label" htmlFor="backupLocation">Admin Edit Location HTML</label>
<textarea
className="w-full min-h-[200px] p-3 border rounded-lg shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 dark:border-gray-700 dark:text-gray-200 font-mono resize-y"
placeholder="HTML Content"
id="contentHTML_Raw"
name="contentHTML_Raw"
onChange={(e) => setContent(e.target.value)}
value={content}
autoComplete="off"
spellCheck="false"
/>
</>
}
</div>
</ProtectedRoute>
{!isRawHtml && <>
<TextEditor
ref={quillRef}
value={content}
onChange={setContent}
placeholder="Описание на локацията. Снимки"
prefix={`location-${router.query.id}`} />
</>}
</>)}
</div>
<div className="panel-actions pt-12">
@ -270,3 +318,4 @@ export default function LocationForm() {
);
}

View File

@ -95,6 +95,7 @@ export default function ReportForm({ shiftId, existingItem, onDone }) {
delete item.shift;
} else {
item.shift = { connect: { id: parseInt(item.shiftId) } };
// item.shift = { connect: { id: item.shiftId.toString() } };
}
delete item.shiftId;
item.date = new Date(item.date);

View File

@ -112,20 +112,35 @@ export default function Sidebar({ isSidebarOpen, toggleSidebar }) {
useEffect(() => {
const fetchLocations = async () => {
try {
if (session) { // Don't fetch locations if the user is not authenticated
// if (session)
{ // Don't fetch locations if the user is not authenticated
const response = await axiosInstance.get('/api/data/locations'); // Adjust the API endpoint as needed
const locationsData = response.data
.filter(location => location.isActive === true)
const subMenuLocations = response.data
.filter(location => location.isActive === true && location.isOnMainMenu === false)
.map(location => ({
text: location.name,
url: `/cart/locations/${location.id}`,
isOnMainMenu: location.isOnMainMenu,
}));
const mainMenuLocations = response.data.filter(l => l.isOnMainMenu === true)
.map(location => ({
key: location.name,
text: t('location.' + location.name + '.menu') || location.name,
url: location.url,
}));
console.log("locationsData: ", response.data);
console.log("subMenuLocations: ", subMenuLocations);
console.log("mainMenuLocations: ", mainMenuLocations);
// Find the "Locations" menu item and populate its children with locationsData
const menuIndex = sidemenu.findIndex(item => item.id === "locations");
if (menuIndex !== -1) {
sidemenu[menuIndex].children = locationsData;
sidemenu[menuIndex].children = subMenuLocations;
}
// insert main menu items after the locations (sidemenu[menuIndex+1])
sidemenu.splice(menuIndex + 1, 0, ...mainMenuLocations);
//setLocations(locationsData); // Optional, if you need to use locations elsewhere
}
} catch (error) {
@ -164,7 +179,7 @@ export default function Sidebar({ isSidebarOpen, toggleSidebar }) {
<div className="mt-auto">
<hr className="border-gray-200 dark:border-gray-600 text-align-bottom" />
<FooterSection />
<ProtectedRoute allowedRoles={[UserRole.ADMIN]} deniedMessage="">
<ProtectedRoute allowedRoles={[UserRole.ADMIN, UserRole.POWERUSER, UserRole.USER]} deniedMessage="">
<LanguageSwitcher />
</ProtectedRoute >
</div>

View File

@ -34,6 +34,12 @@ const sidemenu = [
collapsable: true,
url: "/cart/locations",
},
{
id: "warehouse",
text: "Склад",
url: "/cart/locations/warehouse",
svgData: "M7 21V11.6C7 11.0399 7 10.7599 7.10899 10.546C7.20487 10.3578 7.35785 10.2048 7.54601 10.109C7.75992 9.99996 8.03995 9.99996 8.6 9.99996H15.4C15.9601 9.99996 16.2401 9.99996 16.454 10.109C16.6422 10.2048 16.7951 10.3578 16.891 10.546C17 10.7599 17 11.0399 17 11.6V21M10 14H14M10 18H14M3 10.4881V19.4C3 19.96 3 20.24 3.10899 20.454C3.20487 20.6421 3.35785 20.7951 3.54601 20.891C3.75992 21 4.03995 21 4.6 21H19.4C19.9601 21 20.2401 21 20.454 20.891C20.6422 20.7951 20.7951 20.6421 20.891 20.454C21 20.24 21 19.96 21 19.4V10.4881C21 9.41436 21 8.87747 20.8368 8.40316C20.6925 7.98371 20.457 7.60148 20.1472 7.28399C19.797 6.92498 19.3174 6.68357 18.3583 6.20075L14.1583 4.08645C13.3671 3.68819 12.9716 3.48905 12.5564 3.41069C12.1887 3.34129 11.8113 3.34129 11.4436 3.41069C11.0284 3.48905 10.6329 3.68818 9.84171 4.08645L5.64171 6.20075C4.6826 6.68357 4.20304 6.92498 3.85275 7.28399C3.54298 7.60148 3.30746 7.98371 3.16317 8.40316C3 8.87747 3 9.41437 3 10.4881Z"
},
{
id: "cart-report",
text: "Отчет",

View File

@ -31,13 +31,19 @@
"cart-reports": "Отчети",
"statistics": "Статистика",
"coverMeLogs": "Замествания",
"translations": "Преводи"
"translations": "Преводи",
"warehouse": "Склад Сердика"
},
"content": {
"location": {
"warehouse": {
"description": "- снимки как да се поставят количките\n- снимка с код за катинар\nвсеки може да всима/връща\n- преди да влизаме трябва да почистим краката.\n- зареждаме/почистваме извън\n- влизане/озлизане няма код\n- влизане/излизане има код",
"title": "СНЛАД"
"title": "Склад Сердика",
"description_1": "**1. Катинар.** Шифъра на катинара е: 1914.\n![](/content/warehouse/1.jpg)",
"description_2": "**2. Място за съхранение.** (Подлеза на метростанция Сердика в близост до църквата \"Света Петка Самарджийска\")\n\nЛинк: [LockStars Lockers](https://www.google.com/maps/place/LockStars+Lockers+-+luggage+storage+in+Sofia/@42.697779,23.3218397,20.15z/data=!4m6!3m5!1s0x40aa85dd76aed8a9:0xa0b10cc277013ee9!8m2!3d42.6978913!4d23.3223441!16s%2Fg%2F11vlhc26h5?authuser=0&entry=tts&g_ep)\n![](/content/warehouse/2.jpg)\n![](/content/warehouse/3.jpg)\n![](/content/warehouse/4.jpg)",
"description_3": "**3. Позицията на щандовете**, начин на съхранение, както и мястото, може да видите в прикачените снимки.\n\n ***Вратата на гардероба е винаги отворена. Няма нужда да натискаме бутона за отваряне**\n![](/content/warehouse/5.jpg)\n![](/content/warehouse/6.jpg)",
"description_4": "**4. Зареждането и почистване на щандовете трябва да става извън мястото за съхранение. ** Също когато прибираме щандовете е необходимо да са почистени и в добър външен вид с калъф на тях(гумите/колелата да са почистени от кал за да не оставяме следи по пода)\n\n**Важно:** посоката на плакатите и на двата щанда трябва да е към стената! Целта е да не се виждат, тъй като **в помещението са забранени рекламни и други материали извън дейността на фирмата.**",
"description_5": "**5. Литература.** Ще има метална кутия за съхранение на литература в непосредствена близост над щандовете на единия от сейфовете.\n\nМоля бъдете особено внимателни когато сваляте и качвате кутията за да не се нараните.",
"description_6": "**6. Престой.** Хубаво ще е да не се задържаме прекалено дълго в помещението, повече от необходимо да вземем количките и нужнаta литература."
}
}
}

View File

@ -32,5 +32,12 @@
"statistics": "Статистика",
"coverMeLogs": "Замествания",
"translations": "Преводи"
},
"content": {
"location": {
"warehouse": {
"description": "СКЛАД СЕРДИКА\n1. Катинар. Шифъра на катинара е: 1914. \n2. Място за съхранение (Подлеза на метростанция Сердика в близост до църквата \"Света Петка Самарджийска\") \nЛинк: https: //maps.app.goo.gl/FfzZww2EfxeP2y9S7\n3. Позицията на щандовете, начин на съхранение, както и мястото, може да видите в прикачените снимки. \n* Вратата на гардероба е винаги отворена. Няма нужда да натискаме бутона за отваряне\n4. Зареждането и почистване на щандовете трябва да става извън мястото за съхранение. Също когато прибираме щандовете е необходимо да са почистени и в добър външен вид с калъф на тях(гумите/колелата да са почистени от кал за да не оставяме следи по пода)\nВажно: посоката на плакатите и на двата щанда трябва да е към стената! Целта е да не се виждат, тъй като в помещението са забранени рекламни и други материали извън дейността на фирмата.\n5. Литература. Ще има метална кутия за съхранение на литература в непосредствена близост над щандовете на единия от сейфовете.\nМоля бъдете особено внимателни когато сваляте и качвате кутията за да не се нараните. \n6. Престой. Хубаво ще е да не се задържаме прекалено дълго в помещението, повече от необходимо да вземем количките и нужнаta литература. "
}
}
}
}

View File

@ -36,8 +36,13 @@
"content": {
"location": {
"warehouse": {
"description": "- снимки как да се поставят количките\n- снимка с код за катинар\nвсеки може да всима/връща\n- преди да влизаме трябва да почистим краката.\n- зареждаме/почистваме извън\n- влизане/озлизане няма код\n- влизане/излизане има код",
"title": "СНЛАД"
"title": "Serdika Storage",
"description_1": "**1. Padlock.** The padlock code is: 1914.",
"description_2": "**2. Storage Location.** (Serdika Metro Station underpass near the 'St. Petka Samardzhiyska' church)\n\nLink: [LockStars Lockers]",
"description_3": "**3. Stand Positions**, storage method, and location can be seen in the attached photos.\n\n***The locker door is always open. No need to press the opening button***",
"description_4": "**4. Loading and cleaning of stands must be done outside the storage area.** Also, when storing the stands, they need to be clean and in good external condition with covers on them (tires/wheels should be cleaned of mud to avoid leaving traces on the floor)\n\n**Important:** the direction of posters on both stands should face the wall! The goal is to keep them hidden, as **advertising and other materials unrelated to the company's activities are prohibited in the premises.**",
"description_5": "**5. Literature.** There will be a metal box for storing literature in close proximity above the stands on one of the safes.\n\nPlease be especially careful when lowering and raising the box to avoid injury.",
"description_6": "**6. Stay Duration.** It's good not to stay in the premises longer than necessary to take the carts and required literature."
}
}
}

View File

@ -16,8 +16,13 @@
"content": {
"location": {
"warehouse": {
"description": "- снимки как да се поставят количките\n- снимка с код за катинар\nвсеки може да всима/връща\n- преди да влизаме трябва да почистим краката.\n- зареждаме/почистваме извън\n- влизане/озлизане няма код\n- влизане/излизане има код",
"title": "СНЛАД"
"title": "Склад Сердика",
"description_1": "**1. Замок.** Код замка: 1914.",
"description_2": "**2. Место хранения.** (Подземный переход станции метро Сердика рядом с церковью 'Св. Петки Самарджийской')\n\nСсылка: [LockStars Lockers]",
"description_3": "**3. Расположение стендов**, способ хранения и место можно увидеть на прикрепленных фотографиях.\n\n***Дверь шкафа всегда открыта. Нет необходимости нажимать кнопку открытия***",
"description_4": "**4. Загрузка и очистка стендов должны производиться вне места хранения.** Также при размещении стендов они должны быть чистыми и в хорошем внешнем состоянии с чехлами (шины/колёса должны быть очищены от грязи, чтобы не оставлять следов на полу)\n\n**Важно:** направление плакатов на обоих стендах должно быть к стене! Цель - сделать их невидимыми, так как **в помещении запрещены рекламные и другие материалы, не связанные с деятельностью компании.**",
"description_5": "**5. Литература.** Будет металлический ящик для хранения литературы в непосредственной близости над стендами на одном из сейфов.\n\nПожалуйста, будьте особенно осторожны при опускании и поднятии ящика, чтобы избежать травм.",
"description_6": "**6. Продолжительность пребывания.** Желательно не задерживаться в помещении дольше, чем необходимо для того, чтобы взять тележки и нужную литературу."
}
}
}

6900
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -26,6 +26,10 @@
"@helpers": "./src/helpers"
},
"dependencies": {
"prisma": "^5.22.0",
"@prisma/client": "^5.22.0",
"prisma-json-schema-generator": "^5.1.1",
"@premieroctet/next-crud": "^3.0.0",
"@auth/prisma-adapter": "^1.4.0",
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
@ -33,8 +37,6 @@
"@mui/icons-material": "^5.15.10",
"@mui/material": "^5.15.10",
"@mui/x-date-pickers": "^6.19.4",
"@premieroctet/next-crud": "^3.0.0",
"@prisma/client": "^5.19.1",
"@react-pdf/renderer": "^3.3.8",
"@tailwindcss/forms": "^0.5.7",
"@types/multer": "^1.4.11",
@ -96,10 +98,13 @@
"react-dom": "^18.2.0",
"react-hot-toast": "^2.4.1",
"react-icons": "^5.0.1",
"react-markdown": "^9.0.1",
"react-quill": "^2.0.0",
"react-responsive-carousel": "^3.2.23",
"react-swipeable": "^7.0.1",
"react-toastify": "^10.0.4",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.0",
"sharp": "^0.33.2",
"tailwindcss": "^3.4.1",
"tw-elements": "^1.1.0",
@ -118,7 +123,6 @@
"devDependencies": {
"cross-env": "^7.0.3",
"depcheck": "^1.4.7",
"eslint-plugin-no-unsanitized": "^4.1.0",
"prisma": "^5.19.1"
"eslint-plugin-no-unsanitized": "^4.1.0"
}
}
}

3
pages/404.js Normal file
View File

@ -0,0 +1,3 @@
export default function Custom404() {
return <h1>404 - Страницата не е намерена</h1>
}

View File

@ -3,14 +3,20 @@ import { Prisma } from "@prisma/client";
import { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth/next";
import { authOptions } from "../auth/[...nextauth]";
// import { getToken } from "next-auth/jwt";
// import { getSession } from "next-auth/client";
import { JWT } from "next-auth/jwt";
const common = require("../../../src/helpers/common");
import jwt from 'jsonwebtoken';
import { decode } from 'next-auth/jwt';
const logger = require('../../../src/logger');
// import { getToken } from "next-auth/jwt";
interface SessionUser {
email?: string;
name?: string;
}
interface Session {
user?: SessionUser;
expires: string;
}
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const prismaClient = common.getPrismaClient();
@ -19,27 +25,35 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
adapter: new PrismaAdapter({ prismaClient }),
models: {
[Prisma.ModelName.CartEvent]: { name: "cartevents" },
[Prisma.ModelName.Publisher]: { name: "publishers" },
[Prisma.ModelName.Availability]: { name: "availabilities" },
[Prisma.ModelName.Location]: { name: "locations" },
[Prisma.ModelName.Shift]: { name: "shifts" },
[Prisma.ModelName.Assignment]: { name: "assignments" },
[Prisma.ModelName.Report]: { name: "reports" },
[Prisma.ModelName.Message]: { name: "messages" },
[Prisma.ModelName.Survey]: { name: "surveys" },
[Prisma.ModelName.EventLog]: { name: "eventlogs" },
},
});
//1: check session
const session = await getServerSession(req, res, authOptions);
//console.log("Session:", session); // Log the session
const session = (await getServerSession(req, res, authOptions)) as Session | null;
const authHeader = req.headers.authorization || '';
//console.log('authHeader', authHeader);
if (session) {
if (session && req.query.nextcrud) {
//get target table
const targetTable = req.query.nextcrud[0];
//get target action
if (req.method === 'DELETE') {
switch (targetTable) {
// case 'publishers':
// case 'availabilities':
default:
const targetId = req.query.nextcrud[1];
logger.info('[nextCrud] ' + targetTable + ': ' + targetId + ' DELETED by ' + session.user.email);
logger.info('[nextCrud] ' + targetTable + ': ' + targetId + ' DELETED by ' + session.user?.email);
break;
}
}
console.log('[nextCrud]: request for ' + targetTable + '. params:', req.query);
return nextCrudHandler(req, res);
}
else {
@ -49,21 +63,18 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
//2: check jwt
const secret = process.env.NEXTAUTH_SECRET;
const bearerHeader = req.headers['authorization'];
if (bearerHeader) {
if (bearerHeader && secret) {
const token = bearerHeader.split(' ')[1]; // Assuming "Bearer <token>"
try {
const decoded = await decode({
token: token,
secret: process.env.NEXTAUTH_SECRET,
});
//console.log('Decoded JWT:');
} catch (err) {
console.error('[nextCrud]: Error decoding token:', err);
}
// try {
// const decodedToken = await getToken({ req, secret });
// if (decodedToken) {
// return nextCrudHandler(req, res);
// }
// } catch (err) {
// console.error('[nextCrud]: Error decoding token:', err);
// }
try {
const verified = jwt.verify(token, secret);
//console.log('Verified JWT:');
return nextCrudHandler(req, res);
} catch (err) {
console.error('[nextCrud]: Invalid token:', err);
@ -76,7 +87,6 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
return nextCrudHandler(req, res);
}
return res.status(401).json({ message: '[nextCrud]: Unauthorized' });
};

View File

@ -2,6 +2,7 @@ import { NextApiRequest, NextApiResponse } from 'next';
import { PrismaClient } from '@prisma/client';
import { getServerSession } from "next-auth/next";
import { authOptions } from "../../auth/[...nextauth]";
import e from 'express';
const common = require('../../../../src/helpers/common');
@ -71,10 +72,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
if (req.method === 'POST' && req.headers['content-type']?.includes('application/json')) {
// Handle POST request
queryOptions = req.body;
} else {
} else if (req.method === 'GET') {
// Handle GET request
queryOptions = parseQueryParams(req.query);
}
else if (req.method === 'DELETE') {
// Handle DELETE request
queryOptions = req.body;
}
try {
if (!modelArray || modelArray.length === 0) {
@ -84,6 +90,20 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
if (!prisma[modelName]) {
throw new Error(`Model ${modelName} not found in Prisma client.`);
}
if (req.method === 'POST') {
// Create a new record
const result = await prisma[modelName].create({ data: queryOptions });
res.status(201).json(result);
return;
} else if (req.method === 'DELETE') {
// Delete a record
const result = await prisma[modelName].delete({ where: queryOptions });
res.status(200).json(result);
return;
}
// Fetch records
const result = await prisma[modelName].findMany(queryOptions);
if (req.query.format === 'sql') {
// Generate SQL if requested via query parameter

View File

@ -12,7 +12,7 @@ import fs from 'fs';
import path from 'path';
import { all } from "axios";
import { logger } from "src/helpers/common";
import { ExportPublishersToExcel } from "src/helpers/excel";
import { generatePublishersExcel } from "src/helpers/excel";
/**
*
@ -435,9 +435,16 @@ export default async function handler(req, res) {
res.status(200).json(await dataHelper.getAllPublishersWithStatisticsMonth(day, noEndDate));
case "exportPublishersExcel":
try {
await ExportPublishersToExcel(req, res);
const today = new Date();
const dateStr = today.toISOString().split('T')[0]; // Gets YYYY-MM-DD format
const excelBuffer = await generatePublishersExcel();
res.setHeader("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
res.setHeader("Content-Disposition", "attachment; filename=" + encodeURI(`Publishers_${dateStr}.xlsx`));
res.send(excelBuffer);
} catch (error) {
console.error(JSON.stringify(error));
res.status(500).json({ error: "Failed to generate Excel file" });
}
break;
default:

View File

@ -53,6 +53,12 @@ const Notification = async (req, res) => {
select: { pushSubscription: true }
});
if (!publisher) {
res.statusCode = 404
res.end()
return
}
let subscriptions = Array.isArray(publisher.pushSubscription) ? publisher.pushSubscription : (publisher.pushSubscription ? [publisher.pushSubscription] : []);
const index = subscriptions.findIndex(sub => sub.endpoint === subscription.endpoint);

View File

@ -6,6 +6,9 @@ import { GetServerSideProps } from 'next';
import { Location, UserRole } from "@prisma/client";
import axiosServer from '../../../src/axiosServer';
import { useTranslations, createTranslator } from 'next-intl';
import ReactMarkdown from 'react-markdown';
import rehypeRaw from 'rehype-raw';
import remarkGfm from 'remark-gfm';
// import { getTranslations } from 'next-intl/server';
const ViewLocationPage: React.FC<ViewLocationPageProps> = ({ location }) => {
@ -54,15 +57,17 @@ const ViewLocationPage: React.FC<ViewLocationPageProps> = ({ location }) => {
className={`tab flex-1 text-lg py-2 px-4 ${activeTab === 'mainLocation' ? 'border-b-4 border-blue-500 text-blue-600 font-semibold' : 'text-gray-600 hover:text-blue-500'}`}
onClick={() => handleTabChange('mainLocation')}
>
{location.name}
{location.title || location.name}
</button>
{/* Backup Location Tab */}
<button
className={`tab flex-1 text-lg py-2 px-4 ${activeTab === 'backupLocation' ? 'border-b-4 border-blue-500 text-blue-600 font-semibold' : 'text-gray-600 hover:text-blue-500'}`}
onClick={() => handleTabChange('backupLocation')}
>
При лошо време: <strong>{location.backupLocationName}</strong>
</button>
{location.backupLocationId !== null && (
<button
className={`tab flex-1 text-lg py-2 px-4 ${activeTab === 'backupLocation' ? 'border-b-4 border-blue-500 text-blue-600 font-semibold' : 'text-gray-600 hover:text-blue-500'}`}
onClick={() => handleTabChange('backupLocation')}
>
При лошо време: <strong>{location.backupLocationName}</strong>
</button>
)}
</div>
{/* Carousel */}
@ -85,8 +90,27 @@ const ViewLocationPage: React.FC<ViewLocationPageProps> = ({ location }) => {
{/* Tab Content */}
{(location.content || location.backupLocationContent) && (
<div className="tab-content mt-4">
{activeTab === 'mainLocation' && (
<div className="p-4 bg-white shadow rounded-lg" dangerouslySetInnerHTML={{ __html: location.content }} />
// <div className="p-4 bg-white shadow rounded-lg" dangerouslySetInnerHTML={{ __html: location.content }} />
<div className="p-4 bg-white shadow rounded-lg">
<ReactMarkdown
rehypePlugins={[rehypeRaw]}
remarkPlugins={[remarkGfm]}
components={{
img: ({ node, ...props }) => (
<img {...props} className="max-w-full h-auto my-4" alt="" />
),
a: ({ node, ...props }) => (
<a {...props} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline" />
)
}}
>
{location.content}
</ReactMarkdown>
</div>
)}
{activeTab === 'backupLocation' && location.backupLocationContent && (
<div className="p-4 bg-white shadow rounded-lg" dangerouslySetInnerHTML={{ __html: location.backupLocationContent }} />
@ -98,6 +122,8 @@ const ViewLocationPage: React.FC<ViewLocationPageProps> = ({ location }) => {
);
};
const common = require("../../../src/helpers/common");
export const getServerSideProps: GetServerSideProps = async (context) => {
const axios = await axiosServer(context);
@ -106,7 +132,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
const messages = (await import(`../../../content/i18n/${locale}.json`)).default;
const t = createTranslator({ locale, messages });
// Function to replace placeholders in HTML content
// Function to replace placeholders in HTML content. Placeholders are in the format {key}
const replacePlaceholders = (content: string) => {
if (!content) return '';
const placeholderPattern = /{([^}]+)}/g;
@ -121,22 +147,25 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
return `[${locale}:${key}]`;
} catch (error) {
// Return formatted placeholder on error
return `[${locale}:${key}]`;
return `[${locale}:'content.'${key}]`;
}
});
};
const { data: location } = await axios.get(
`${process.env.NEXT_PUBLIC_PUBLIC_URL}/api/data/locations/${context.params.id}`
);
const prismaClient = common.getPrismaClient();
const location = await getLocation(prismaClient, context);
location.content = replacePlaceholders(location.content);
location.title = t("content.location." + location.name + ".title");
if (location.backupLocationId !== null) {
const { data: backupLocation } = await axios.get(
process.env.NEXT_PUBLIC_PUBLIC_URL + "/api/data/locations/" + location.backupLocationId
);
// const { data: backupLocation } = await axios.get(
// process.env.NEXT_PUBLIC_PUBLIC_URL + "/api/data/locations/" + location.backupLocationId
// );
const backupLocation = await prismaClient.location.findFirst({
where: {
id: location.backupLocationId,
},
});
location.backupLocationName = backupLocation.name;
location.backupLocationContent = backupLocation ? replacePlaceholders(backupLocation.content) : "";
location.backupLocationImages = backupLocation ? [backupLocation.picture1, backupLocation.picture2, backupLocation.picture3].filter(Boolean) : [];
@ -151,4 +180,30 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
};
};
async function getLocation(prismaClient, context) {
// Try to parse the ID as a number
const numericId = parseInt(context.params.id);
let location;
// If it's a valid number, search by ID
if (!isNaN(numericId)) {
location = await prismaClient.location.findFirst({
where: {
id: numericId
}
});
}
// If no location found by ID or if ID is not numeric, search by name
if (!location) {
location = await prismaClient.location.findFirst({
where: {
name: context.params.id
}
});
}
return location;
}
export default ViewLocationPage;

View File

@ -43,7 +43,7 @@ export default LocationsPage;
export const getServerSideProps = async (context) => {
const axios = await axiosServer(context);
const { data: items } = await axios.get("/api/data/locations");
//console.log('get server props - locations:' + items.length);
console.log('get server props - locations:' + items.length);
//console.log(items);
return {
props: {

View File

@ -1,6 +1,12 @@
// Next.js page to show all locations in the database with a link to the location page
import { useSession } from "next-auth/react";
import { useEffect, useState, useRef, use } from "react";
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import { DesktopDatePicker } from '@mui/x-date-pickers/DesktopDatePicker';
import dayjs from 'dayjs';
// import { getSession } from 'next-auth/client'
// import { NextAuth } from 'next-auth/client'
import { Publisher, UserRole } from "@prisma/client";
@ -26,6 +32,7 @@ function PublishersPage({ publishers = [] }: IProps) {
const [filter, setFilter] = useState("");
const [showZeroShiftsOnly, setShowZeroShiftsOnly] = useState(false);
const [selectedDate, setSelectedDate] = useState<dayjs.Dayjs | null>(null);
const [filterIsImported, setFilterIsImported] = useState({
checked: false,
indeterminate: true,
@ -113,10 +120,22 @@ function PublishersPage({ publishers = [] }: IProps) {
// Combine name and email filters, removing duplicates
let filteredPublishers = [...new Set([...filteredPublishersByName, ...filteredPublishersByEmail])];
// inactive publishers filter
filteredPublishers = showZeroShiftsOnly
? filteredPublishers.filter(p => p.assignments.length === 0)
: filteredPublishers;
// Zero shifts (inactive) and date filter
if (showZeroShiftsOnly && selectedDate) {
filteredPublishers = publishers.filter(publisher => {
// If no assignments at all, include in results
if (publisher.assignments.length === 0) return true;
// Only include publishers who don't have any assignments after the selected date
return !publisher.assignments.some(assignment => {
const shiftDate = dayjs(assignment.shift.startTime);
return shiftDate.isAfter(selectedDate);
});
});
} else if (showZeroShiftsOnly) {
// If checkbox is checked but no date selected, show publishers with no assignments
filteredPublishers = publishers.filter(publisher => publisher.assignments.length === 0);
}
// trained filter
if (flterNoTraining) {
@ -124,9 +143,15 @@ function PublishersPage({ publishers = [] }: IProps) {
}
setShownPubs(filteredPublishers);
}, [filter, showZeroShiftsOnly, flterNoTraining]);
}, [filter, showZeroShiftsOnly, selectedDate, flterNoTraining]);
// Separate effect to handle date reset when checkbox is unchecked
useEffect(() => {
if (!showZeroShiftsOnly) {
setSelectedDate(null);
}
}, [showZeroShiftsOnly]);
const renderPublishers = () => {
if (shownPubs.length === 0) {
@ -172,20 +197,34 @@ function PublishersPage({ publishers = [] }: IProps) {
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 response = await axiosInstance.get('/api/?action=exportPublishersExcel', { responseType: 'arraybuffer' });
// Get filename from Content-Disposition header
const contentDisposition = response.headers['content-disposition'];
let filename = 'publishers.xlsx'; // default fallback
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename=(.*?)(;|$)/);
if (filenameMatch) {
filename = decodeURI(filenameMatch[1]);
}
}
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.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url); // Clean up
} catch (error) {
console.error(JSON.stringify(error)); // Log the error
console.error(JSON.stringify(error));
toast.error("Грешка при експорт на данни");
}
}
return (
<Layout>
<ProtectedRoute allowedRoles={[UserRole.ADMIN, UserRole.POWERUSER]}>
@ -220,7 +259,7 @@ function PublishersPage({ publishers = [] }: IProps) {
</div>
{/* export by calling excel helper .ExportPublishersToExcel() */}
<div className="flex justify-center m-4">
<button className="btn" onClick={exportPublishers}>Export to Excel</button>
<button className="button m-2 btn btn-primary" onClick={exportPublishers}>Export to Excel</button>
</div>
</div>
@ -239,6 +278,36 @@ function PublishersPage({ publishers = [] }: IProps) {
/>
<span className="ml-2">само без смени</span>
</label>
{showZeroShiftsOnly && (
<div className="flex items-center space-x-2 ml-2">
<span className="whitespace-nowrap">след:</span>
<DatePicker
value={selectedDate}
onChange={(newDate) => setSelectedDate(newDate)}
slotProps={{
textField: {
size: "small",
sx: {
width: '140px',
'& .MuiInputBase-input': {
padding: '4px 8px',
fontSize: '0.875rem'
}
}
},
mobileWrapper: {
sx: {
'& .MuiPickersLayout-root': {
minWidth: 'unset'
}
}
}
}}
format="DD.MM.YYYY"
className="min-w-[120px]"
/>
</div>
)}
<label htmlFor="filterTrained" className="ml-4 inline-flex items-center">
<input type="checkbox" id="filterTrained" name="filterTrained"

View File

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE `Location`
ADD COLUMN `isOnMainMenu` BOOLEAN NOT NULL DEFAULT false;

View File

@ -231,6 +231,7 @@ model Location {
backupLocationId Int?
backupLocation Location? @relation("BackupLocation", fields: [backupLocationId], references: [id])
BackupForLocations Location[] @relation("BackupLocation")
isOnMainMenu Boolean @default(false)
@@map("Location")
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 KiB

View File

@ -0,0 +1,119 @@
<p><strong>1. Катинар.</strong>&nbsp;Шифъра на катинара е: 1914.</p>
<p><br /></p>
<p>
<img
src="/content/warehouse/1.jpg"
class="w-full rounded-lg cursor-pointer hover:opacity-90 transition-opacity"
/>
</p>
<p>
<strong>2. Място за съхранение</strong>&nbsp;(Подлеза на метростанция Сердика
в близост до църквата "Света Петка Самарджийска")&nbsp;
</p>
<p>
Линк:&nbsp;<a
href="https://maps.app.goo.gl/FfzZww2EfxeP2y9S7"
rel="noopener noreferrer"
target="_blank"
style="color: rgb(17, 85, 204)"
>https://maps.app.goo.gl/FfzZww2EfxeP2y9S7</a
>
</p>
<p>
<img
src="/content/warehouse/2.jpg"
class="w-full rounded-lg cursor-pointer hover:opacity-90 transition-opacity"
/>
</p>
<p>
<img
src="/content/warehouse/3.jpg"
class="w-full rounded-lg cursor-pointer hover:opacity-90 transition-opacity"
/>
</p>
<p>
<img
src="/content/warehouse/4.jpg"
class="w-full rounded-lg cursor-pointer hover:opacity-90 transition-opacity"
/>
</p>
<p><br /></p>
<p><br /></p>
<p>
<strong>3. Позицията на щандовете</strong>, начин на съхранение, както и
мястото, може да видите в прикачените снимки.&nbsp;
</p>
<p>
<strong>
* Вратата на гардероба е винаги отворена. Няма нужда да натискаме бутона за
отваряне
</strong>
</p>
<p>
<img
src="/content/warehouse/5.jpg"
class="w-full rounded-lg cursor-pointer hover:opacity-90 transition-opacity"
/>
</p>
<p>
<img
src="/content/warehouse/6.jpg"
class="w-full rounded-lg cursor-pointer hover:opacity-90 transition-opacity"
/>
</p>
<p><br /></p>
<p><br /></p>
<p><br /></p>
<p><br /></p>
<p>
<strong>4</strong>.&nbsp;<strong
>Зареждането и почистване на щандовете трябва да става извън мястото за
съхранение</strong
>. Също когато прибираме щандовете е необходимо да са почистени и в добър
външен вид с калъф на тях(гумите/колелата да са почистени от кал за да не
оставяме следи по пода)
</p>
<p>
<strong>Важно:&nbsp;</strong>посоката на плакатите и на двата щанда трябва да
е към стената! Целта е да не се виждат, тъй като&nbsp;<strong
>в помещението са забранени рекламни и други материали извън дейността на
фирмата.</strong
>
</p>
<p><br /></p>
<p>
<strong>5. Литература.&nbsp;</strong>Ще има метална кутия за съхранение на
литература в непосредствена близост над щандовете на единия от сейфовете.
</p>
<p>
Моля бъдете особено внимателни когато сваляте и качвате кутията за да не се
нараните.&nbsp;
</p>
<p><br /></p>
<p>
<strong>6. Престой.</strong>&nbsp;Хубаво ще е да не се задържаме прекалено
дълго в помещението, повече от необходимо да вземем количките и нужнаta
литература.&nbsp;
</p>
**1. Катинар.** Шифъра на катинара е: 1914.\n![](/content/warehouse/1.jpg) **2.
Място за съхранение** (Подлеза на метростанция Сердика в близост до църквата
"Света Петка Самарджийска")\nЛинк:
[https://maps.app.goo.gl/FfzZww2EfxeP2y9S7](https://maps.app.goo.gl/FfzZww2EfxeP2y9S7)\n![](/content/warehouse/2.jpg)\n![](/content/warehouse/3.jpg)\n![](/content/warehouse/4.jpg)\n
**3. Позицията на щандовете**, начин на съхранение, както и мястото, може да
видите в прикачените снимки.\n**\* Вратата на гардероба е винаги отворена. Няма
нужда да натискаме бутона за
отваряне**\n![](/content/warehouse/5.jpg)\n![](/content/warehouse/6.jpg) **4.**
**Зареждането и почистване на щандовете трябва да става извън мястото за
съхранение**. Също когато прибираме щандовете е необходимо да са почистени и в
добър външен вид с калъф на тях(гумите/колелата да са почистени от кал за да не
оставяме следи по пода)\n**Важно:** посоката на плакатите и на двата щанда
трябва да е към стената! Целта е да не се виждат, тъй като **в помещението са
забранени рекламни и други материали извън дейността на фирмата.** **5.
Литература.** Ще има метална кутия за съхранение на литература в непосредствена
близост над щандовете на единия от сейфовете. Моля бъдете особено внимателни
когато сваляте и качвате кутията за да не се нараните. **6. Престой.** Хубаво ще
е да не се задържаме прекалено дълго в помещението, повече от необходимо да
вземем количките и нужнаta литература.

View File

@ -89,7 +89,7 @@ exports.getPrismaClient = function getPrismaClient() {
logger.debug("getPrismaClient: process.env.DATABASE = ", process.env.DATABASE);
prisma = new PrismaClient({
// Optional: Enable Prisma logging
//log: ['query', 'info', 'warn', 'error'],
log: ['query', 'info', 'warn', 'error'],
datasources: { db: { url: process.env.DATABASE } },
});
}

View File

@ -616,7 +616,7 @@ exports.ReadDocxFileForMonth = async function (filePath, buffer, month, year, pr
};
exports.ExportPublishersToExcel = async function (req, res) {
exports.generatePublishersExcel = async function () {
const prisma = common.getPrismaClient();
const publishers = await prisma.publisher.findMany({
// where: { isActive: true, },
@ -624,9 +624,9 @@ exports.ExportPublishersToExcel = async function (req, res) {
// availabilities: { where: { isActive: true, }, },
// assignments: { include: { shift: true, }, },
congregation: true,
},
});
const ExcelJS = require("exceljs");
const xjswb = new ExcelJS.Workbook();
const sheet = xjswb.addWorksheet("Publishers");
@ -639,10 +639,11 @@ exports.ExportPublishersToExcel = async function (req, res) {
{ 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: "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,
@ -650,18 +651,17 @@ exports.ExportPublishersToExcel = async function (req, res) {
email: publisher.email,
phone: publisher.phone,
role: publisher.role,
congregationName: publisher.congregation.name,
congregationName: publisher.congregation?.name || 'не е зададен', // Add null check here
lastLogin: publisher.lastLogin,
PublisherTypeText: publisher.PublisherTypeText,
isActive: publisher.isActive,
// 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);
// Return a buffer with the Excel data
return await xjswb.xlsx.writeBuffer();
}
const weekNames = [