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

11
.env
View File

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

View File

@ -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@192.168.0.10:3306/cart_dev
DATABASE=mysql://cart:cartpw@localhost:3306/cart DATABASE=mysql://cart:cartpw@localhost:3306/cart
EMAIL_SENDER='"ССОМ [ТЕСТ] " <mwitnessing@gmail.com>' EMAIL_SENDER='"ССОМ [ТЕСТ] " <mwitnessing@gmail.com>'
# MAILTRAP_HOST_BULK=bulk.smtp.mailtrap.io # MAILTRAP_HOST_BULK=bulk.smtp.mailtrap.io
# MAILTRAP_HOST=sandbox.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/* !public/content/uploads/*
.aider* .aider*
/shift_generate_log_*.txt /shift_generate_log_*.txt
.env

View File

@ -16,7 +16,18 @@ services:
- GIT_USERNAME=deploy - GIT_USERNAME=deploy
- GIT_PASSWORD=L3Kr2R438u4F7 - GIT_PASSWORD=L3Kr2R438u4F7
- ADMIN_PASSWORD=kolichkisofia2024 - 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 tty: true
stdin_open: true stdin_open: true
restart: always restart: always

View File

@ -3,7 +3,7 @@
if [ "$UPDATE_CODE_FROM_GIT" = "true" ]; then if [ "$UPDATE_CODE_FROM_GIT" = "true" ]; then
# Install necessary packages # Install necessary packages
apk add git nano rsync 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 # Create a temporary directory for the new clone
rm -rf /tmp/clone rm -rf /tmp/clone

View File

@ -61,5 +61,3 @@ services:
stdin_open: true stdin_open: true
# depends_on: # depends_on:
# - mariadb # - 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 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 --------------------------------- ## ## ---------------------- import database --------------------------------- ##
gunzip < /prisma/backups/jwpwsofia-20240430-bak.gz | mysql -u mysql_username -p database_name 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 ProtectedRoute, { serverSideAuth } from "../../components/protectedRoute";
import { UserRole } from "@prisma/client"; import { UserRole } from "@prisma/client";
import { Input } from '@mui/base';
const common = require('src/helpers/common'); const common = require('src/helpers/common');
@ -37,8 +38,9 @@ export default function LocationForm() {
useEffect(() => { useEffect(() => {
const fetchUploadedImages = async () => { const fetchUploadedImages = async () => {
try { try {
const response = await axiosInstance.get('/uploaded-images'); // ToDo: we don't have this endpoint yet and we don't use the uploaded images collection
setUploadedImages(response.data.imageUrls); // const response = await axiosInstance.get('/uploaded-images');
// setUploadedImages(response.data.imageUrls);
} catch (error) { } catch (error) {
console.error('Error fetching uploaded images:', error); console.error('Error fetching uploaded images:', error);
} }
@ -67,6 +69,7 @@ export default function LocationForm() {
address: "", address: "",
isActive: true, isActive: true,
}); });
const [isRawHtml, setIsRawHtml] = useState(false);
// const [isEdit, setIsEdit] = useState(false); // const [isEdit, setIsEdit] = useState(false);
@ -178,6 +181,13 @@ export default function LocationForm() {
<label className="label" htmlFor="isActive">Активна</label> <label className="label" htmlFor="isActive">Активна</label>
</div> </div>
</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 */} {/* backupLocation */}
<div className="mb-4"> <div className="mb-4">
<label className="label" htmlFor="backupLocation">При дъжд и лошо време</label> <label className="label" htmlFor="backupLocation">При дъжд и лошо време</label>
@ -238,12 +248,50 @@ export default function LocationForm() {
<label className="label" htmlFor="content">Content</label> <label className="label" htmlFor="content">Content</label>
<TextEditor
ref={quillRef} <ProtectedRoute allowedRoles={[UserRole.ADMIN]} deniedMessage=" ">
value={content} <label className="label" htmlFor="backupLocation">Admin Edit Location HTML</label>
onChange={setContent} <div className="field mb-4">
placeholder="Описание на локацията. Снимки" <label className="switch">
prefix={`location-${router.query.id}`} /> <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>
<div className="panel-actions pt-12"> <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; delete item.shift;
} else { } else {
item.shift = { connect: { id: parseInt(item.shiftId) } }; item.shift = { connect: { id: parseInt(item.shiftId) } };
// item.shift = { connect: { id: item.shiftId.toString() } };
} }
delete item.shiftId; delete item.shiftId;
item.date = new Date(item.date); item.date = new Date(item.date);

View File

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

View File

@ -34,6 +34,12 @@ const sidemenu = [
collapsable: true, collapsable: true,
url: "/cart/locations", 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", id: "cart-report",
text: "Отчет", text: "Отчет",

View File

@ -31,13 +31,19 @@
"cart-reports": "Отчети", "cart-reports": "Отчети",
"statistics": "Статистика", "statistics": "Статистика",
"coverMeLogs": "Замествания", "coverMeLogs": "Замествания",
"translations": "Преводи" "translations": "Преводи",
"warehouse": "Склад Сердика"
}, },
"content": { "content": {
"location": { "location": {
"warehouse": { "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": "Статистика", "statistics": "Статистика",
"coverMeLogs": "Замествания", "coverMeLogs": "Замествания",
"translations": "Преводи" "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": { "content": {
"location": { "location": {
"warehouse": { "warehouse": {
"description": "- снимки как да се поставят количките\n- снимка с код за катинар\nвсеки може да всима/връща\n- преди да влизаме трябва да почистим краката.\n- зареждаме/почистваме извън\n- влизане/озлизане няма код\n- влизане/излизане има код", "title": "Serdika Storage",
"title": "СНЛАД" "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": { "content": {
"location": { "location": {
"warehouse": { "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" "@helpers": "./src/helpers"
}, },
"dependencies": { "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", "@auth/prisma-adapter": "^1.4.0",
"@emotion/react": "^11.11.3", "@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
@ -33,8 +37,6 @@
"@mui/icons-material": "^5.15.10", "@mui/icons-material": "^5.15.10",
"@mui/material": "^5.15.10", "@mui/material": "^5.15.10",
"@mui/x-date-pickers": "^6.19.4", "@mui/x-date-pickers": "^6.19.4",
"@premieroctet/next-crud": "^3.0.0",
"@prisma/client": "^5.19.1",
"@react-pdf/renderer": "^3.3.8", "@react-pdf/renderer": "^3.3.8",
"@tailwindcss/forms": "^0.5.7", "@tailwindcss/forms": "^0.5.7",
"@types/multer": "^1.4.11", "@types/multer": "^1.4.11",
@ -96,10 +98,13 @@
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hot-toast": "^2.4.1", "react-hot-toast": "^2.4.1",
"react-icons": "^5.0.1", "react-icons": "^5.0.1",
"react-markdown": "^9.0.1",
"react-quill": "^2.0.0", "react-quill": "^2.0.0",
"react-responsive-carousel": "^3.2.23", "react-responsive-carousel": "^3.2.23",
"react-swipeable": "^7.0.1", "react-swipeable": "^7.0.1",
"react-toastify": "^10.0.4", "react-toastify": "^10.0.4",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.0",
"sharp": "^0.33.2", "sharp": "^0.33.2",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"tw-elements": "^1.1.0", "tw-elements": "^1.1.0",
@ -118,7 +123,6 @@
"devDependencies": { "devDependencies": {
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"depcheck": "^1.4.7", "depcheck": "^1.4.7",
"eslint-plugin-no-unsanitized": "^4.1.0", "eslint-plugin-no-unsanitized": "^4.1.0"
"prisma": "^5.19.1"
} }
} }

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 { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth/next"; import { getServerSession } from "next-auth/next";
import { authOptions } from "../auth/[...nextauth]"; import { authOptions } from "../auth/[...nextauth]";
// import { getToken } from "next-auth/jwt"; import { JWT } from "next-auth/jwt";
// import { getSession } from "next-auth/client";
const common = require("../../../src/helpers/common"); const common = require("../../../src/helpers/common");
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { decode } from 'next-auth/jwt';
const logger = require('../../../src/logger'); 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 handler = async (req: NextApiRequest, res: NextApiResponse) => {
const prismaClient = common.getPrismaClient(); const prismaClient = common.getPrismaClient();
@ -19,27 +25,35 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
adapter: new PrismaAdapter({ prismaClient }), adapter: new PrismaAdapter({ prismaClient }),
models: { models: {
[Prisma.ModelName.CartEvent]: { name: "cartevents" }, [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 //1: check session
const session = await getServerSession(req, res, authOptions); const session = (await getServerSession(req, res, authOptions)) as Session | null;
//console.log("Session:", session); // Log the session
const authHeader = req.headers.authorization || ''; const authHeader = req.headers.authorization || '';
//console.log('authHeader', authHeader);
if (session) { if (session && req.query.nextcrud) {
//get target table //get target table
const targetTable = req.query.nextcrud[0]; const targetTable = req.query.nextcrud[0];
//get target action //get target action
if (req.method === 'DELETE') { if (req.method === 'DELETE') {
switch (targetTable) { switch (targetTable) {
// case 'publishers':
// case 'availabilities':
default: default:
const targetId = req.query.nextcrud[1]; 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; break;
} }
} }
console.log('[nextCrud]: request for ' + targetTable + '. params:', req.query);
return nextCrudHandler(req, res); return nextCrudHandler(req, res);
} }
else { else {
@ -49,21 +63,18 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
//2: check jwt //2: check jwt
const secret = process.env.NEXTAUTH_SECRET; const secret = process.env.NEXTAUTH_SECRET;
const bearerHeader = req.headers['authorization']; const bearerHeader = req.headers['authorization'];
if (bearerHeader) { if (bearerHeader && secret) {
const token = bearerHeader.split(' ')[1]; // Assuming "Bearer <token>" const token = bearerHeader.split(' ')[1]; // Assuming "Bearer <token>"
try { // try {
const decoded = await decode({ // const decodedToken = await getToken({ req, secret });
token: token, // if (decodedToken) {
secret: process.env.NEXTAUTH_SECRET, // return nextCrudHandler(req, res);
}); // }
//console.log('Decoded JWT:'); // } catch (err) {
} catch (err) { // console.error('[nextCrud]: Error decoding token:', err);
console.error('[nextCrud]: Error decoding token:', err); // }
}
try { try {
const verified = jwt.verify(token, secret); const verified = jwt.verify(token, secret);
//console.log('Verified JWT:');
return nextCrudHandler(req, res); return nextCrudHandler(req, res);
} catch (err) { } catch (err) {
console.error('[nextCrud]: Invalid token:', err); console.error('[nextCrud]: Invalid token:', err);
@ -76,7 +87,6 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
return nextCrudHandler(req, res); return nextCrudHandler(req, res);
} }
return res.status(401).json({ message: '[nextCrud]: Unauthorized' }); 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 { PrismaClient } from '@prisma/client';
import { getServerSession } from "next-auth/next"; import { getServerSession } from "next-auth/next";
import { authOptions } from "../../auth/[...nextauth]"; import { authOptions } from "../../auth/[...nextauth]";
import e from 'express';
const common = require('../../../../src/helpers/common'); 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')) { if (req.method === 'POST' && req.headers['content-type']?.includes('application/json')) {
// Handle POST request // Handle POST request
queryOptions = req.body; queryOptions = req.body;
} else { } else if (req.method === 'GET') {
// Handle GET request // Handle GET request
queryOptions = parseQueryParams(req.query); queryOptions = parseQueryParams(req.query);
} }
else if (req.method === 'DELETE') {
// Handle DELETE request
queryOptions = req.body;
}
try { try {
if (!modelArray || modelArray.length === 0) { if (!modelArray || modelArray.length === 0) {
@ -84,6 +90,20 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
if (!prisma[modelName]) { if (!prisma[modelName]) {
throw new Error(`Model ${modelName} not found in Prisma client.`); 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); const result = await prisma[modelName].findMany(queryOptions);
if (req.query.format === 'sql') { if (req.query.format === 'sql') {
// Generate SQL if requested via query parameter // Generate SQL if requested via query parameter

View File

@ -12,7 +12,7 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import { all } from "axios"; import { all } from "axios";
import { logger } from "src/helpers/common"; 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)); res.status(200).json(await dataHelper.getAllPublishersWithStatisticsMonth(day, noEndDate));
case "exportPublishersExcel": case "exportPublishersExcel":
try { 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) { } catch (error) {
console.error(JSON.stringify(error)); console.error(JSON.stringify(error));
res.status(500).json({ error: "Failed to generate Excel file" });
} }
break; break;
default: default:

View File

@ -53,6 +53,12 @@ const Notification = async (req, res) => {
select: { pushSubscription: true } select: { pushSubscription: true }
}); });
if (!publisher) {
res.statusCode = 404
res.end()
return
}
let subscriptions = Array.isArray(publisher.pushSubscription) ? publisher.pushSubscription : (publisher.pushSubscription ? [publisher.pushSubscription] : []); let subscriptions = Array.isArray(publisher.pushSubscription) ? publisher.pushSubscription : (publisher.pushSubscription ? [publisher.pushSubscription] : []);
const index = subscriptions.findIndex(sub => sub.endpoint === subscription.endpoint); 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 { Location, UserRole } from "@prisma/client";
import axiosServer from '../../../src/axiosServer'; import axiosServer from '../../../src/axiosServer';
import { useTranslations, createTranslator } from 'next-intl'; 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'; // import { getTranslations } from 'next-intl/server';
const ViewLocationPage: React.FC<ViewLocationPageProps> = ({ location }) => { 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'}`} 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')} onClick={() => handleTabChange('mainLocation')}
> >
{location.name} {location.title || location.name}
</button> </button>
{/* Backup Location Tab */} {/* Backup Location Tab */}
<button {location.backupLocationId !== null && (
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'}`} <button
onClick={() => handleTabChange('backupLocation')} 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> При лошо време: <strong>{location.backupLocationName}</strong>
</button>
)}
</div> </div>
{/* Carousel */} {/* Carousel */}
@ -85,8 +90,27 @@ const ViewLocationPage: React.FC<ViewLocationPageProps> = ({ location }) => {
{/* Tab Content */} {/* Tab Content */}
{(location.content || location.backupLocationContent) && ( {(location.content || location.backupLocationContent) && (
<div className="tab-content mt-4"> <div className="tab-content mt-4">
{activeTab === 'mainLocation' && ( {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 && ( {activeTab === 'backupLocation' && location.backupLocationContent && (
<div className="p-4 bg-white shadow rounded-lg" dangerouslySetInnerHTML={{ __html: 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) => { export const getServerSideProps: GetServerSideProps = async (context) => {
const axios = await axiosServer(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 messages = (await import(`../../../content/i18n/${locale}.json`)).default;
const t = createTranslator({ locale, messages }); 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) => { const replacePlaceholders = (content: string) => {
if (!content) return ''; if (!content) return '';
const placeholderPattern = /{([^}]+)}/g; const placeholderPattern = /{([^}]+)}/g;
@ -121,22 +147,25 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
return `[${locale}:${key}]`; return `[${locale}:${key}]`;
} catch (error) { } catch (error) {
// Return formatted placeholder on error // Return formatted placeholder on error
return `[${locale}:${key}]`; return `[${locale}:'content.'${key}]`;
} }
}); });
}; };
const prismaClient = common.getPrismaClient();
const location = await getLocation(prismaClient, context);
const { data: location } = await axios.get(
`${process.env.NEXT_PUBLIC_PUBLIC_URL}/api/data/locations/${context.params.id}`
);
location.content = replacePlaceholders(location.content); location.content = replacePlaceholders(location.content);
location.title = t("content.location." + location.name + ".title");
if (location.backupLocationId !== null) { if (location.backupLocationId !== null) {
const { data: backupLocation } = await axios.get( // const { data: backupLocation } = await axios.get(
process.env.NEXT_PUBLIC_PUBLIC_URL + "/api/data/locations/" + location.backupLocationId // 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.backupLocationName = backupLocation.name;
location.backupLocationContent = backupLocation ? replacePlaceholders(backupLocation.content) : ""; location.backupLocationContent = backupLocation ? replacePlaceholders(backupLocation.content) : "";
location.backupLocationImages = backupLocation ? [backupLocation.picture1, backupLocation.picture2, backupLocation.picture3].filter(Boolean) : []; 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; export default ViewLocationPage;

View File

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

View File

@ -1,6 +1,12 @@
// Next.js page to show all locations in the database with a link to the location page // Next.js page to show all locations in the database with a link to the location page
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useEffect, useState, useRef, use } from "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 { getSession } from 'next-auth/client'
// import { NextAuth } from 'next-auth/client' // import { NextAuth } from 'next-auth/client'
import { Publisher, UserRole } from "@prisma/client"; import { Publisher, UserRole } from "@prisma/client";
@ -26,6 +32,7 @@ function PublishersPage({ publishers = [] }: IProps) {
const [filter, setFilter] = useState(""); const [filter, setFilter] = useState("");
const [showZeroShiftsOnly, setShowZeroShiftsOnly] = useState(false); const [showZeroShiftsOnly, setShowZeroShiftsOnly] = useState(false);
const [selectedDate, setSelectedDate] = useState<dayjs.Dayjs | null>(null);
const [filterIsImported, setFilterIsImported] = useState({ const [filterIsImported, setFilterIsImported] = useState({
checked: false, checked: false,
indeterminate: true, indeterminate: true,
@ -113,10 +120,22 @@ function PublishersPage({ publishers = [] }: IProps) {
// Combine name and email filters, removing duplicates // Combine name and email filters, removing duplicates
let filteredPublishers = [...new Set([...filteredPublishersByName, ...filteredPublishersByEmail])]; let filteredPublishers = [...new Set([...filteredPublishersByName, ...filteredPublishersByEmail])];
// inactive publishers filter // Zero shifts (inactive) and date filter
filteredPublishers = showZeroShiftsOnly if (showZeroShiftsOnly && selectedDate) {
? filteredPublishers.filter(p => p.assignments.length === 0) filteredPublishers = publishers.filter(publisher => {
: filteredPublishers; // 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 // trained filter
if (flterNoTraining) { if (flterNoTraining) {
@ -124,9 +143,15 @@ function PublishersPage({ publishers = [] }: IProps) {
} }
setShownPubs(filteredPublishers); 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 = () => { const renderPublishers = () => {
if (shownPubs.length === 0) { if (shownPubs.length === 0) {
@ -172,20 +197,34 @@ function PublishersPage({ publishers = [] }: IProps) {
const exportPublishers = async () => { const exportPublishers = async () => {
try { try {
const response = await axiosInstance.get('/api/?action=exportPublishersExcel'); const response = await axiosInstance.get('/api/?action=exportPublishersExcel', { responseType: 'arraybuffer' });
const blob = new Blob([response.data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
// 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 url = window.URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = 'publishers.xlsx'; a.download = filename;
document.body.appendChild(a);
a.click(); a.click();
a.remove();
window.URL.revokeObjectURL(url); // Clean up
} catch (error) { } catch (error) {
console.error(JSON.stringify(error)); // Log the error console.error(JSON.stringify(error));
toast.error("Грешка при експорт на данни"); toast.error("Грешка при експорт на данни");
} }
} }
return ( return (
<Layout> <Layout>
<ProtectedRoute allowedRoles={[UserRole.ADMIN, UserRole.POWERUSER]}> <ProtectedRoute allowedRoles={[UserRole.ADMIN, UserRole.POWERUSER]}>
@ -220,7 +259,7 @@ function PublishersPage({ publishers = [] }: IProps) {
</div> </div>
{/* export by calling excel helper .ExportPublishersToExcel() */} {/* export by calling excel helper .ExportPublishersToExcel() */}
<div className="flex justify-center m-4"> <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>
</div> </div>
@ -239,6 +278,36 @@ function PublishersPage({ publishers = [] }: IProps) {
/> />
<span className="ml-2">само без смени</span> <span className="ml-2">само без смени</span>
</label> </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"> <label htmlFor="filterTrained" className="ml-4 inline-flex items-center">
<input type="checkbox" id="filterTrained" name="filterTrained" <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? backupLocationId Int?
backupLocation Location? @relation("BackupLocation", fields: [backupLocationId], references: [id]) backupLocation Location? @relation("BackupLocation", fields: [backupLocationId], references: [id])
BackupForLocations Location[] @relation("BackupLocation") BackupForLocations Location[] @relation("BackupLocation")
isOnMainMenu Boolean @default(false)
@@map("Location") @@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); logger.debug("getPrismaClient: process.env.DATABASE = ", process.env.DATABASE);
prisma = new PrismaClient({ prisma = new PrismaClient({
// Optional: Enable Prisma logging // Optional: Enable Prisma logging
//log: ['query', 'info', 'warn', 'error'], log: ['query', 'info', 'warn', 'error'],
datasources: { db: { url: process.env.DATABASE } }, 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 prisma = common.getPrismaClient();
const publishers = await prisma.publisher.findMany({ const publishers = await prisma.publisher.findMany({
// where: { isActive: true, }, // where: { isActive: true, },
@ -624,9 +624,9 @@ exports.ExportPublishersToExcel = async function (req, res) {
// availabilities: { where: { isActive: true, }, }, // availabilities: { where: { isActive: true, }, },
// assignments: { include: { shift: true, }, }, // assignments: { include: { shift: true, }, },
congregation: true, congregation: true,
}, },
}); });
const ExcelJS = require("exceljs"); const ExcelJS = require("exceljs");
const xjswb = new ExcelJS.Workbook(); const xjswb = new ExcelJS.Workbook();
const sheet = xjswb.addWorksheet("Publishers"); const sheet = xjswb.addWorksheet("Publishers");
@ -639,10 +639,11 @@ exports.ExportPublishersToExcel = async function (req, res) {
{ header: "Congregation", key: "congregationName", width: 32 }, { header: "Congregation", key: "congregationName", width: 32 },
{ header: "Last Login", key: "lastLogin", width: 32 }, { header: "Last Login", key: "lastLogin", width: 32 },
{ header: "Type", key: "PublisherTypeText", 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: "Created At", key: "createdAt", width: 32 },
{ header: "Updated At", key: "updatedAt", width: 32 }, { header: "Updated At", key: "updatedAt", width: 32 },
]; ];
publishers.forEach((publisher) => { publishers.forEach((publisher) => {
sheet.addRow({ sheet.addRow({
name: publisher.firstName + " " + publisher.lastName, name: publisher.firstName + " " + publisher.lastName,
@ -650,18 +651,17 @@ exports.ExportPublishersToExcel = async function (req, res) {
email: publisher.email, email: publisher.email,
phone: publisher.phone, phone: publisher.phone,
role: publisher.role, role: publisher.role,
congregationName: publisher.congregation.name, congregationName: publisher.congregation?.name || 'не е зададен', // Add null check here
lastLogin: publisher.lastLogin, lastLogin: publisher.lastLogin,
PublisherTypeText: publisher.PublisherTypeText, PublisherTypeText: publisher.PublisherTypeText,
isActive: publisher.isActive, // isActive: publisher.isActive,
createdAt: publisher.createdAt, createdAt: publisher.createdAt,
updatedAt: publisher.updatedAt, updatedAt: publisher.updatedAt,
}); });
}); });
res.setHeader("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
res.setHeader("Content-Disposition", "attachment; filename=" + encodeURI("Publishers.xlsx")); // Return a buffer with the Excel data
xjswb.xlsx.write(res); return await xjswb.xlsx.writeBuffer();
} }
const weekNames = [ const weekNames = [