initial commit - code moved to separate repo
This commit is contained in:
26
.devcontainer/Dockerfile
Normal file
26
.devcontainer/Dockerfile
Normal file
@ -0,0 +1,26 @@
|
||||
FROM node:18-slim
|
||||
|
||||
# Set the working directory in the container
|
||||
WORKDIR /workspace
|
||||
|
||||
# Copy package.json and package-lock.json to the container
|
||||
# skip if we use bind
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy the rest of the application code to the container
|
||||
# skip if we use bind
|
||||
COPY . .
|
||||
|
||||
# Expose the default Next.js port
|
||||
EXPOSE 5011
|
||||
|
||||
# Start the Next.js development server
|
||||
CMD ["npm", "run", "run"]
|
||||
# CMD ["npm", "run", "debug"]
|
||||
|
||||
# RUN npm run build
|
||||
# the -- in the command is used to pass the following arguments directly to the script (or command) that npm start runs.
|
||||
# CMD ["npm", "start", "--", "-p", "3010"]
|
39
.devcontainer/devcontainer.json
Normal file
39
.devcontainer/devcontainer.json
Normal file
@ -0,0 +1,39 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-docker-compose
|
||||
{
|
||||
"name": "Existing Docker Compose (Extend)",
|
||||
// Update the 'dockerComposeFile' list if you have more compose files or use different names.
|
||||
// The .devcontainer/docker-compose.yml file contains any overrides you need/want to make.
|
||||
"dockerComposeFile": [
|
||||
"../docker-compose.yml"
|
||||
// "docker-compose.yml"
|
||||
],
|
||||
// The 'service' property is the name of the service for the container that VS Code should
|
||||
// use. Update this value and .devcontainer/docker-compose.yml to the real service name.
|
||||
"service": "next-cart-app",
|
||||
// The optional 'workspaceFolder' property is the path VS Code should open by default when
|
||||
// connected. This is typically a file mount in .devcontainer/docker-compose.yml
|
||||
// "workspaceFolder": "/workspace/${localWorkspaceFolderBasename}",
|
||||
"workspaceFolder": "/workspace",
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {
|
||||
// "ghcr.io/devcontainers-contrib/features/prisma:2": {}
|
||||
// },
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [
|
||||
// 3010
|
||||
// ],
|
||||
// Uncomment the next line if you want start specific services in your Docker Compose config.
|
||||
// "runServices": [],
|
||||
// Uncomment the next line if you want to keep your containers running after VS Code shuts down.
|
||||
// "shutdownAction": "none",
|
||||
// Uncomment the next line to run commands after the container is created.
|
||||
// "postCreateCommand": "cat /etc/os-release",
|
||||
// Configure tool-specific properties.
|
||||
// "customizations": {},
|
||||
// Uncomment to connect as an existing user other than the container default. More info: https://aka.ms/dev-containers-non-root.
|
||||
// "remoteUser": "devcontainer"
|
||||
"mounts": [
|
||||
"source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=cached"
|
||||
]
|
||||
}
|
49
.env
Normal file
49
.env
Normal file
@ -0,0 +1,49 @@
|
||||
|
||||
#NODE_TLS_REJECT_UNAUTHORIZED='0'
|
||||
NEXT_PUBLIC_PROTOCOL=https
|
||||
NEXT_PUBLIC_HOST=localhost
|
||||
NEXT_PUBLIC_PORT=3003
|
||||
NEXTAUTH_URL=https://localhost:3003
|
||||
# NEXTAUTH_URL_INTERNAL=http://127.0.0.1:3003
|
||||
|
||||
# Linux: `openssl rand -hex 32` or go to https://generate-secret.now.sh/32
|
||||
NEXTAUTH_SECRET=ed8a9681efc414df89dfd03cd188ed58
|
||||
|
||||
# mysql
|
||||
DATABASE_PROVIDER=mysql
|
||||
# DATABASE_URL=mysql://cart:cart2023@192.168.0.10:3306/cart_dev
|
||||
DATABASE_URL=mysql://root:Zelen0ku4e@192.168.0.10:3306/cart_dev
|
||||
|
||||
# DATABASE_URL=mysql://cart:cartpw@20.101.62.76:3307/cart
|
||||
|
||||
# sqlite
|
||||
# DATABASE_PROVIDER=sqlite
|
||||
# DATABASE_URL=file:./prisma/local.db
|
||||
|
||||
APPLE_ID=
|
||||
APPLE_TEAM_ID=
|
||||
APPLE_PRIVATE_KEY=
|
||||
APPLE_KEY_ID=
|
||||
|
||||
AUTH0_ID=Aa9f3HJowauUrmBVY4iQzQJ7fYsaZDbK
|
||||
AUTH0_SECRET=_c0O9GkyRXkoWMQW7jNExnl6UoXN6O4oD3mg7NZ_uHVeAinCUtcTAkeQmcKXpZ4x
|
||||
AUTH0_ISSUER=https://dev-wkzi658ckibr1amv.us.auth0.com
|
||||
|
||||
FACEBOOK_ID=
|
||||
FACEBOOK_SECRE
|
||||
GITHUB_ID=
|
||||
GITHUB_SECRET=
|
||||
|
||||
# // owner: dobromir.popov@gmail.com | Специално Свидетелстване София
|
||||
# // https://console.cloud.google.com/apis/credentials/oauthclient/926212607479-d3m8hm8f8esp3rf1639prskn445sa01v.apps.googleusercontent.com?project=grand-forge-108716
|
||||
GOOGLE_ID=926212607479-d3m8hm8f8esp3rf1639prskn445sa01v.apps.googleusercontent.com
|
||||
GOOGLE_SECRET=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
|
||||
|
||||
TWITTER_ID=
|
||||
TWITTER_SECRET=
|
||||
|
||||
EMAIL_SERVER=smtp://8ec69527ff2104:c7bc05f171c96c@smtp.mailtrap.io:2525
|
||||
EMAIL_FROM=noreply@example.com
|
||||
|
||||
GMAIL_EMAIL_USERNAME=
|
||||
GMAIL_EMAIL_APP_PASS=
|
37
.env.demo
Normal file
37
.env.demo
Normal file
@ -0,0 +1,37 @@
|
||||
NODE_TLS_REJECT_UNAUTHORIZED='0'
|
||||
# DATABASE_URL="file:./src/data/dev.db"
|
||||
# DATABASE_URL="mysql://root:Zelen0ku4e@192.168.0.10:3306/cart"
|
||||
|
||||
NEXT_PUBLIC_PORT=
|
||||
# NEXT_PUBLIC_NEXTAUTH_URL=https://cart.d-popov.com
|
||||
NEXT_PUBLIC_PROTOCOL=https
|
||||
NEXT_PUBLIC_HOST=cart.d-popov.com
|
||||
NEXTAUTH_URL=https://cart.d-popov.com
|
||||
# NEXTAUTH_URL= https://demo.mwhitnessing.com
|
||||
|
||||
# Linux: `openssl rand -hex 32` or go to https://generate-secret.now.sh/32
|
||||
NEXTAUTH_SECRET=ed8a9681efc414df89dfd03cd188ed58
|
||||
DATABASE_URL=mysql://cart:cart2023@192.168.0.10:3306/cart_demo
|
||||
|
||||
APPLE_ID=
|
||||
APPLE_TEAM_ID=
|
||||
APPLE_PRIVATE_KEY=
|
||||
APPLE_KEY_ID=
|
||||
|
||||
AUTH0_ID=Aa9f3HJowauUrmBVY4iQzQJ7fYsaZDbK
|
||||
AUTH0_SECRET=_c0O9GkyRXkoWMQW7jNExnl6UoXN6O4oD3mg7NZ_uHVeAinCUtcTAkeQmcKXpZ4x
|
||||
AUTH0_ISSUER=https://dev-wkzi658ckibr1amv.us.auth0.com
|
||||
|
||||
FACEBOOK_ID=
|
||||
FACEBOOK_SECRET=
|
||||
|
||||
GITHUB_ID=
|
||||
GITHUB_SECRET=
|
||||
# GOOGLE_ID=926212607479-d3m8hm8f8esp3rf1639prskn445sa01v.apps.googleusercontent.com
|
||||
# GOOGLE_SECRET=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
|
||||
|
||||
TWITTER_ID=
|
||||
TWITTER_SECRET=
|
||||
|
||||
EMAIL_SERVER=smtp://8ec69527ff2104:c7bc05f171c96c@smtp.mailtrap.io:2525
|
||||
EMAIL_FROM=noreply@example.com
|
9
.env.prod
Normal file
9
.env.prod
Normal file
@ -0,0 +1,9 @@
|
||||
NEXT_PUBLIC_PROTOCOL=https
|
||||
NEXT_PUBLIC_PORT=
|
||||
NEXT_PUBLIC_HOST=sofia.mwhitnessing.com
|
||||
NEXTAUTH_URL= https://sofia.mwhitnessing.com
|
||||
|
||||
# Linux: `openssl rand -hex 32` or go to https://generate-secret.now.sh/32
|
||||
NEXTAUTH_SECRET=1dd8a5457970d1dda50600be28e935ecc4513ff27c49c431849e6746f158d638
|
||||
# ? do we need to duplicate this? already defined in the deoployment yml file
|
||||
DATABASE_URL=mysql://jwpwsofia:dwxhns9p9vp248V39xJyRthUsZ2gR9@mariadb:3306/jwpwsofia
|
9
.env.production
Normal file
9
.env.production
Normal file
@ -0,0 +1,9 @@
|
||||
NEXT_PUBLIC_PROTOCOL=https
|
||||
NEXT_PUBLIC_PORT=
|
||||
NEXT_PUBLIC_HOST=sofia.mwhitnessing.com
|
||||
NEXTAUTH_URL= https://sofia.mwhitnessing.com
|
||||
|
||||
# Linux: `openssl rand -hex 32` or go to https://generate-secret.now.sh/32
|
||||
NEXTAUTH_SECRET=1dd8a5457970d1dda50600be28e935ecc4513ff27c49c431849e6746f158d638
|
||||
# ? do we need to duplicate this? already defined in the deoployment yml file
|
||||
DATABASE_URL=mysql://jwpwsofia:dwxhns9p9vp248V39xJyRthUsZ2gR9@mariadb:3306/jwpwsofia
|
36
.env.test
Normal file
36
.env.test
Normal file
@ -0,0 +1,36 @@
|
||||
NODE_TLS_REJECT_UNAUTHORIZED='0'
|
||||
|
||||
NEXT_PUBLIC_PORT=5001
|
||||
NEXT_PUBLIC_PROTOCOL=https
|
||||
NEXT_PUBLIC_HOST=cart.d-popov.com
|
||||
NEXTAUTH_URL=https://cart.d-popov.com
|
||||
|
||||
# Linux: `openssl rand -hex 32` or go to https://generate-secret.now.sh/32
|
||||
NEXTAUTH_SECRET=ed8a9681efc414df89dfd03cd188ed58
|
||||
DATABASE_URL=mysql://cart:cartpw@192.168.0.10:3306/cart_dev
|
||||
|
||||
APPLE_ID=
|
||||
APPLE_TEAM_ID=
|
||||
APPLE_PRIVATE_KEY=
|
||||
APPLE_KEY_ID=
|
||||
|
||||
AUTH0_ID=Aa9f3HJowauUrmBVY4iQzQJ7fYsaZDbK
|
||||
AUTH0_SECRET=_c0O9GkyRXkoWMQW7jNExnl6UoXN6O4oD3mg7NZ_uHVeAinCUtcTAkeQmcKXpZ4x
|
||||
AUTH0_ISSUER=https://dev-wkzi658ckibr1amv.us.auth0.com
|
||||
|
||||
FACEBOOK_ID=
|
||||
FACEBOOK_SECRET=
|
||||
|
||||
GITHUB_ID=
|
||||
GITHUB_SECRET=
|
||||
GOOGLE_ID=926212607479-d3m8hm8f8esp3rf1639prskn445sa01v.apps.googleusercontent.com
|
||||
GOOGLE_SECRET=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
|
||||
|
||||
TWITTER_ID=
|
||||
TWITTER_SECRET=
|
||||
|
||||
EMAIL_SERVER=smtp://8ec69527ff2104:c7bc05f171c96c@smtp.mailtrap.io:2525
|
||||
EMAIL_FROM=noreply@example.com
|
||||
|
||||
GMAIL_EMAIL_USERNAME=
|
||||
GMAIL_EMAIL_APP_PASS=
|
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
.DS_Store
|
||||
|
||||
node_modules/
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.yarn-integrity
|
||||
.npm
|
||||
|
||||
.eslintcache
|
||||
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
.next
|
||||
.vercel
|
||||
.env*.local
|
||||
|
||||
*.zip
|
||||
next-cart-app.zip
|
||||
|
||||
!public/uploads/thumb/
|
||||
certificates
|
||||
content/output/*
|
8
.hintrc
Normal file
8
.hintrc
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": [
|
||||
"development"
|
||||
],
|
||||
"hints": {
|
||||
"no-inline-styles": "off"
|
||||
}
|
||||
}
|
13
.vs/VSWorkspaceState.json
Normal file
13
.vs/VSWorkspaceState.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"ExpandedNodes": [
|
||||
"",
|
||||
"\\components",
|
||||
"\\components\\cartevent",
|
||||
"\\pages",
|
||||
"\\pages\\cart",
|
||||
"\\pages\\cart\\cartevents",
|
||||
"\\pages\\cart\\publishers",
|
||||
"\\prisma"
|
||||
],
|
||||
"PreviewInSolutionExplorer": false
|
||||
}
|
1011
.vs/next-cart-app/config/applicationhost.config
Normal file
1011
.vs/next-cart-app/config/applicationhost.config
Normal file
File diff suppressed because it is too large
Load Diff
BIN
.vs/next-cart-app/v17/.wsuo
Normal file
BIN
.vs/next-cart-app/v17/.wsuo
Normal file
Binary file not shown.
BIN
.vs/next-cart-app/v17/TestStore/0/000.testlog
Normal file
BIN
.vs/next-cart-app/v17/TestStore/0/000.testlog
Normal file
Binary file not shown.
BIN
.vs/next-cart-app/v17/TestStore/0/testlog.manifest
Normal file
BIN
.vs/next-cart-app/v17/TestStore/0/testlog.manifest
Normal file
Binary file not shown.
BIN
.vs/slnx.sqlite
Normal file
BIN
.vs/slnx.sqlite
Normal file
Binary file not shown.
10
.vs/tasks.vs.json
Normal file
10
.vs/tasks.vs.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"version": "0.2.1",
|
||||
"tasks": [
|
||||
{
|
||||
"taskLabel": "task-server",
|
||||
"appliesTo": "server.js",
|
||||
"type": "launch"
|
||||
}
|
||||
]
|
||||
}
|
75
.vscode/launch.json
vendored
Normal file
75
.vscode/launch.json
vendored
Normal file
@ -0,0 +1,75 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Chrome Debug",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"url": "http://localhost:3003",
|
||||
"webRoot": "${workspaceFolder}/src",
|
||||
"runtimeExecutable": "C:\\Program Files\\BraveSoftware\\Brave-Browser\\Application\\brave.exe",
|
||||
"sourceMapPathOverrides": {
|
||||
"webpack:///src/*": "${webRoot}/*"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Run npm nodemon",
|
||||
"command": "npm run debug ", // > _logs/debug.log
|
||||
"request": "launch",
|
||||
"type": "node-terminal",
|
||||
"preLaunchTask": "killInspector", // <-- Add this line
|
||||
},
|
||||
{
|
||||
// "type": "pwa-node",
|
||||
// "request": "launch",
|
||||
// "name": "Next: Node",
|
||||
// "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/next",
|
||||
"name": "next dev",
|
||||
"command": "npm run devNext",
|
||||
"request": "launch",
|
||||
"type": "node-terminal"
|
||||
},
|
||||
{
|
||||
// "type": "pwa-node",
|
||||
// "request": "launch",
|
||||
// "name": "Next: Node",
|
||||
// "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/next",
|
||||
"name": "Run npm next start",
|
||||
"command": "npm run start",
|
||||
"request": "launch",
|
||||
"type": "node-terminal"
|
||||
},
|
||||
{
|
||||
"name": "Run conda npm debug",
|
||||
"request": "launch",
|
||||
"type": "node-terminal",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"command": "conda activate node && npm run debug",
|
||||
},
|
||||
{
|
||||
"name": "Run conda npm TEST",
|
||||
"request": "launch",
|
||||
"type": "node-terminal",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"command": "conda activate node && npm run test",
|
||||
"env": {
|
||||
"NODE_ENV": "test"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "attach",
|
||||
"name": "Attach nodemon",
|
||||
"processId": "${command:PickProcess}",
|
||||
"restart": true,
|
||||
// "protocol": "inspector",
|
||||
"internalConsoleOptions": "neverOpen",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
124
.vscode/settings.json
vendored
Normal file
124
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,124 @@
|
||||
{
|
||||
"diffEditor.codeLens": true,
|
||||
"editor.tabCompletion": "on",
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"html.format.wrapAttributes": "force",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": true,
|
||||
// "source.organizeImports": true
|
||||
},
|
||||
"[dotenv]": {
|
||||
"editor.defaultFormatter": "foxundermoon.shell-format"
|
||||
},
|
||||
"[ignore]": {
|
||||
"editor.defaultFormatter": "foxundermoon.shell-format"
|
||||
},
|
||||
"sqltools.connections": [
|
||||
{
|
||||
"mysqlOptions": {
|
||||
"authProtocol": "default"
|
||||
},
|
||||
"previewLimit": 50,
|
||||
"server": "192.168.0.10",
|
||||
"port": 3306,
|
||||
"driver": "MariaDB",
|
||||
"name": "docker",
|
||||
"database": "mysql",
|
||||
"username": "root"
|
||||
},
|
||||
{
|
||||
"mssqlOptions": {
|
||||
"appName": "SQLTools",
|
||||
"useUTC": true,
|
||||
"encrypt": true
|
||||
},
|
||||
"previewLimit": 50,
|
||||
"server": "192.168.0.10",
|
||||
"port": 1433,
|
||||
"askForPassword": true,
|
||||
"driver": "MSSQL",
|
||||
"name": "mssql.d-popov.com",
|
||||
"database": "master",
|
||||
"username": "sa"
|
||||
},
|
||||
{
|
||||
"mysqlOptions": {
|
||||
"authProtocol": "default"
|
||||
},
|
||||
"previewLimit": 50,
|
||||
"server": "172.160.240.73",
|
||||
"port": 3306,
|
||||
"driver": "MariaDB",
|
||||
"name": "Azure cart jwpw",
|
||||
"database": "jwpwsofia",
|
||||
"username": "jwpwsofia"
|
||||
},
|
||||
{
|
||||
"mysqlOptions": {
|
||||
"authProtocol": "default",
|
||||
"enableSsl": "Disabled"
|
||||
},
|
||||
"previewLimit": 50,
|
||||
"server": "localhost",
|
||||
"port": 3306,
|
||||
"driver": "MariaDB",
|
||||
"name": "local dev cart",
|
||||
"database": "cart",
|
||||
"username": "root",
|
||||
"password": "7isg3FCqP1e9aSFw"
|
||||
},
|
||||
{
|
||||
"mysqlOptions": {
|
||||
"authProtocol": "default"
|
||||
},
|
||||
"previewLimit": 50,
|
||||
"server": "jwpw.mysql.database.azure.com",
|
||||
"port": 3306,
|
||||
"driver": "MariaDB",
|
||||
"name": "mysql on Azure",
|
||||
"database": "mysql",
|
||||
"username": "popov",
|
||||
"password": "F7%WZE%@1G&Bjm"
|
||||
}
|
||||
],
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "vscode.typescript-language-features"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "vscode.typescript-language-features"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "vscode.typescript-language-features"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
},
|
||||
"vsmqtt.brokerProfiles": [
|
||||
{
|
||||
"name": "Home",
|
||||
"host": "192.168.0.10",
|
||||
"port": 1883,
|
||||
"clientId": "vsmqtt_client"
|
||||
}
|
||||
],
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
},
|
||||
"git-blame.gitWebUrl": "",
|
||||
"[csharp]": {
|
||||
"editor.defaultFormatter": "ms-dotnettools.csharp"
|
||||
},
|
||||
"[yaml]": {
|
||||
"editor.defaultFormatter": "redhat.vscode-yaml"
|
||||
},
|
||||
"[sql]": {
|
||||
"editor.defaultFormatter": "cweijan.vscode-mysql-client2"
|
||||
},
|
||||
"[prisma]": {
|
||||
"editor.defaultFormatter": "Prisma.prisma"
|
||||
},
|
||||
"[javascriptreact]": {
|
||||
"editor.defaultFormatter": "vscode.typescript-language-features"
|
||||
}
|
||||
}
|
129
.vscode/tasks.json
vendored
Normal file
129
.vscode/tasks.json
vendored
Normal file
@ -0,0 +1,129 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Install Dependencies conda test",
|
||||
"type": "shell",
|
||||
"command": "source activate node && export NODE_ENV=test && npm install",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Run Server conda test",
|
||||
"type": "shell",
|
||||
"command": "source activate node && export NODE_ENV=test && node server.js",
|
||||
"dependsOn": [
|
||||
"Install Dependencies"
|
||||
],
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Run Server conda test",
|
||||
"type": "shell",
|
||||
"command": "source activate node && export NODE_ENV=test && node server.js",
|
||||
"dependsOn": [
|
||||
"Install Dependencies"
|
||||
],
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "killInspector",
|
||||
"type": "shell",
|
||||
"command": "FOR /F \"tokens=5\" %p IN ('netstat -ano ^| find \"9229\" ^| find \"LISTENING\"') DO taskkill /PID %p",
|
||||
"problemMatcher": [],
|
||||
"windows": {
|
||||
"command": "FOR /F \"tokens=5\" %p IN ('netstat -ano ^| find \"9229\" ^| find \"LISTENING\"') DO taskkill /F /PID %p",
|
||||
"options": {
|
||||
"shell": {
|
||||
"executable": "cmd.exe",
|
||||
"args": [
|
||||
"/d",
|
||||
"/c"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"linux": {
|
||||
"command": "lsof -t -i:9229 | xargs -r kill -9"
|
||||
},
|
||||
},
|
||||
{
|
||||
"label": "Remove node_modules",
|
||||
"type": "shell",
|
||||
"command": "Remove-Item -Recurse -Force node_modules",
|
||||
"problemMatcher": [],
|
||||
"windows": {
|
||||
"command": "Remove-Item -Recurse -Force node_modules",
|
||||
"options": {
|
||||
"shell": {
|
||||
"executable": "powershell.exe",
|
||||
"args": [
|
||||
"-Command"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"linux": {
|
||||
"command": "rm -rf node_modules"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Build Docker Image",
|
||||
"type": "shell",
|
||||
"command": "docker build -t next-cart-app:dev .",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Docker Compose Up",
|
||||
"type": "shell",
|
||||
"command": "docker-compose up --build",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Deploy to Remote Server",
|
||||
"type": "shell",
|
||||
"command": "rsync",
|
||||
"args": [
|
||||
"-avz",
|
||||
".next/",
|
||||
"public/",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"server.js",
|
||||
"${env:REMOTE_USER}@${env:REMOTE_SERVER}:${env:REMOTE_DESTINATION}"
|
||||
],
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "new"
|
||||
},
|
||||
"problemMatcher": [],
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
},
|
||||
"options": {
|
||||
"env": {
|
||||
"REMOTE_USER": "azureuser", // Replace with your remote username
|
||||
"REMOTE_SERVER": "172.160.240.73", // Replace with your remote server IP or hostname
|
||||
"REMOTE_DESTINATION": "/mnt/docker_volumes/pw/app/" // Replace with your destination path on the remote server
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
113
README.md
Normal file
113
README.md
Normal file
@ -0,0 +1,113 @@
|
||||
> The example repository is maintained from a [monorepo](https://github.com/nextauthjs/next-auth/tree/main/apps/example-nextjs). Pull Requests should be opened against [`nextauthjs/next-auth`](https://github.com/nextauthjs/next-auth).
|
||||
|
||||
<p align="center">
|
||||
<br/>
|
||||
<a href="https://next-auth.js.org" target="_blank"><img width="150px" src="https://next-auth.js.org/img/logo/logo-sm.png" /></a>
|
||||
<h3 align="center">NextAuth.js Example App</h3>
|
||||
<p align="center">
|
||||
Open Source. Full Stack. Own Your Data.
|
||||
</p>
|
||||
<p align="center" style="align: center;">
|
||||
<a href="https://npm.im/next-auth">
|
||||
<img alt="npm" src="https://img.shields.io/npm/v/next-auth?color=green&label=next-auth">
|
||||
</a>
|
||||
<a href="https://bundlephobia.com/result?p=next-auth-example">
|
||||
<img src="https://img.shields.io/bundlephobia/minzip/next-auth?label=next-auth" alt="Bundle Size"/>
|
||||
</a>
|
||||
<a href="https://www.npmtrends.com/next-auth">
|
||||
<img src="https://img.shields.io/npm/dm/next-auth?label=next-auth%20downloads" alt="Downloads" />
|
||||
</a>
|
||||
<a href="https://npm.im/next-auth">
|
||||
<img src="https://img.shields.io/badge/npm-TypeScript-blue" alt="TypeScript" />
|
||||
</a>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
## Overview
|
||||
|
||||
NextAuth.js is a complete open source authentication solution.
|
||||
|
||||
This is an example application that shows how `next-auth` is applied to a basic Next.js app.
|
||||
|
||||
The deployed version can be found at [`next-auth-example.vercel.app`](https://next-auth-example.vercel.app)
|
||||
|
||||
### About NextAuth.js
|
||||
|
||||
NextAuth.js is an easy to implement, full-stack (client/server) open source authentication library originally designed for [Next.js](https://nextjs.org) and [Serverless](https://vercel.com). Our goal is to [support even more frameworks](https://github.com/nextauthjs/next-auth/issues/2294) in the future.
|
||||
|
||||
Go to [next-auth.js.org](https://next-auth.js.org) for more information and documentation.
|
||||
|
||||
> *NextAuth.js is not officially associated with Vercel or Next.js.*
|
||||
|
||||
## Getting Started
|
||||
|
||||
### 1. Clone the repository and install dependencies
|
||||
|
||||
```
|
||||
git clone https://github.com/nextauthjs/next-auth-example.git
|
||||
cd next-auth-example
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. Configure your local environment
|
||||
|
||||
Copy the .env.local.example file in this directory to .env.local (which will be ignored by Git):
|
||||
|
||||
```
|
||||
cp .env.local.example .env.local
|
||||
```
|
||||
|
||||
Add details for one or more providers (e.g. Google, Twitter, GitHub, Email, etc).
|
||||
|
||||
#### Database
|
||||
|
||||
A database is needed to persist user accounts and to support email sign in. However, you can still use NextAuth.js for authentication without a database by using OAuth for authentication. If you do not specify a database, [JSON Web Tokens](https://jwt.io/introduction) will be enabled by default.
|
||||
|
||||
You **can** skip configuring a database and come back to it later if you want.
|
||||
|
||||
For more information about setting up a database, please check out the following links:
|
||||
|
||||
* Docs: [next-auth.js.org/adapters/overview](https://next-auth.js.org/adapters/overview)
|
||||
|
||||
### 3. Configure Authentication Providers
|
||||
|
||||
1. Review and update options in `pages/api/auth/[...nextauth].js` as needed.
|
||||
|
||||
2. When setting up OAuth, in the developer admin page for each of your OAuth services, you should configure the callback URL to use a callback path of `{server}/api/auth/callback/{provider}`.
|
||||
|
||||
e.g. For Google OAuth you would use: `http://localhost:3000/api/auth/callback/google`
|
||||
|
||||
A list of configured providers and their callback URLs is available from the endpoint `/api/auth/providers`. You can find more information at https://next-auth.js.org/configuration/providers/oauth
|
||||
|
||||
3. You can also choose to specify an SMTP server for passwordless sign in via email.
|
||||
|
||||
### 4. Start the application
|
||||
|
||||
To run your site locally, use:
|
||||
|
||||
```
|
||||
npm run dev
|
||||
```
|
||||
|
||||
To run it in production mode, use:
|
||||
|
||||
```
|
||||
npm run build
|
||||
npm run start
|
||||
```
|
||||
|
||||
### 5. Preparing for Production
|
||||
|
||||
Follow the [Deployment documentation](https://next-auth.js.org/deployment)
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
<a href="https://vercel.com?utm_source=nextauthjs&utm_campaign=oss">
|
||||
<img width="170px" src="https://raw.githubusercontent.com/nextauthjs/next-auth/canary/www/static/img/powered-by-vercel.svg" alt="Powered By Vercel" />
|
||||
</a>
|
||||
<p align="left">Thanks to Vercel sponsoring this project by allowing it to be deployed for free for the entire NextAuth.js Team</p>
|
||||
|
||||
## License
|
||||
|
||||
ISC
|
||||
|
19
_deploy/demo.10.yml
Normal file
19
_deploy/demo.10.yml
Normal file
@ -0,0 +1,19 @@
|
||||
version: "3.1"
|
||||
services:
|
||||
nextjs-app:
|
||||
image: node:20.11.0-alpine #sachinvashist/nextjs-docker #
|
||||
# jitesoft/node-base
|
||||
# paketobuildpacks/nodejs
|
||||
ports:
|
||||
- "8005:3000"
|
||||
volumes:
|
||||
- /mnt/apps/docker_volumes/cart/app/next-cart-app:/app
|
||||
environment:
|
||||
- NODE_ENV=demo
|
||||
- DATABASE_URL=mysql://cart:cart2023@192.168.0.10:3306/cart
|
||||
#command: sh -c "apk update && apk add git && rm -rf /tmp/clone && git clone https://git.d-popov.com/popov/next-cart-app.git /tmp/clone && rm -rf /app/* && cp -R /tmp/clone/next-cart-app/* /app/ && rm -rf /tmp/clone && npm cache clean --force && rm -rf /app/node_modules /app/package-lock.json && npm --silent --prefix /app install /app && npx --prefix /app prisma generate && npm --prefix /app run test; tail -f /dev/null"
|
||||
#command: sh -c "rm -rf /tmp/clone && git clone https://git.d-popov.com/popov/next-cart-app.git /tmp/clone && rm -rf /app/* && cp -R /tmp/clone/next-cart-app/* /app/ && rm -rf /tmp/clone && npm cache clean --force && rm -rf /app/node_modules /app/package-lock.json && npm --silent --prefix /app install /app && npx --prefix /app prisma generate && npm --prefix /app run test; tail -f /dev/null"
|
||||
command: sh -c "npm cache clean --force && rm -rf /app/node_modules /app/package-lock.json && npm --silent --prefix /app install /app && npx --prefix /app prisma generate && npm --prefix /app run test; tail -f /dev/null"
|
||||
#command: npm run test; tail -f /dev/null
|
||||
tty: true
|
||||
stdin_open: true
|
14
_deploy/demo.11-demo.yml
Normal file
14
_deploy/demo.11-demo.yml
Normal file
@ -0,0 +1,14 @@
|
||||
version: "3.1"
|
||||
services:
|
||||
nextjs-app:
|
||||
image: node:20.11.0-alpine #sachinvashist/nextjs-docker #
|
||||
ports:
|
||||
- "5001:3000" # Map port 3000 from container to host
|
||||
volumes:
|
||||
- /mnt/apps/DEV/cart-demo:/app
|
||||
environment:
|
||||
- NODE_ENV=demo
|
||||
- DATABASE_URL=mysql://cart:cart2023@192.168.0.10:3306/cart
|
||||
command: sh -c " cd /app && npm run test; tail -f /dev/null"
|
||||
tty: true
|
||||
stdin_open: true
|
112
_deploy/deoloy.azure.prod.yml
Normal file
112
_deploy/deoloy.azure.prod.yml
Normal file
@ -0,0 +1,112 @@
|
||||
version: "3"
|
||||
services:
|
||||
nextjs-app: # https://sofia.mwhitnessing.com/
|
||||
hostname: jwpw-app # jwpw-nextjs-app-1
|
||||
image: docker.d-popov.com/jwpw:latest
|
||||
#ports:
|
||||
# - "3000:3000"
|
||||
volumes:
|
||||
- /mnt/docker_volumes/pw/app/public/content/uploads/:/app/public/content/uploads
|
||||
environment:
|
||||
- NODE_ENV=prod
|
||||
- TZ=Europe/Sofia
|
||||
#- DATABASE_URL=mysql://jwpwsofia:dwxhns9p9vp248V39xJyRthUsZ2gR9@mariadb:3306/jwpwsofia
|
||||
#- DATABASE_URL=postgres://jwpwsofia:dwxhns9p9vp248V39xJyRthUsZ2gR9@mariadb:3306/jwpwsofia
|
||||
- UPDATE_CODE_FROM_GIT=true # Set to true to pull latest code from Git
|
||||
- GIT_BRANCH=mariadb
|
||||
- GIT_USERNAME=deploy
|
||||
- GIT_PASSWORD=%L3Kr2R438u4F7^%40
|
||||
command: sh -c " cd /app && npm install && npm run nodeenv; tail -f /dev/null"
|
||||
#command: sh -c " cd /app && n
|
||||
tty: true
|
||||
stdin_open: true
|
||||
restart: always
|
||||
networks:
|
||||
- infrastructure_default
|
||||
mariadb:
|
||||
hostname: mariadb
|
||||
image: bitnami/mariadb:latest #mariadb:10.4
|
||||
volumes:
|
||||
- /mnt/docker_volumes/pw/data/mysql:/var/lib/mysql
|
||||
environment:
|
||||
MYSQL_DATABASE: jwpwsofia
|
||||
MYSQL_USER: jwpwsofia
|
||||
MYSQL_PASSWORD: dwxhns9p9vp248V39xJyRthUsZ2gR9
|
||||
#MARIADB_ROOT_PASSWORD: i4966cWBtP3xJ7BLsbsgo93C8Q5262
|
||||
MYSQL_ROOT_PASSWORD: i4966cWBtP3xJ7BLsbsgo93C8Q5262
|
||||
command:
|
||||
[
|
||||
"mysqld",
|
||||
"--max-connections=1000",
|
||||
"--sql-mode=ALLOW_INVALID_DATES,ANSI_QUOTES,ERROR_FOR_DIVISION_BY_ZERO,HIGH_NOT_PRECEDENCE,IGNORE_SPACE,NO_AUTO_CREATE_USER,NO_AUTO_VALUE_ON_ZERO,NO_BACKSLASH_ESCAPES,NO_DIR_IN_CREATE,NO_ENGINE_SUBSTITUTION,NO_FIELD_OPTIONS,NO_KEY_OPTIONS,NO_TABLE_OPTIONS,NO_UNSIGNED_SUBTRACTION,NO_ZERO_DATE,NO_ZERO_IN_DATE,ONLY_FULL_GROUP_BY,PIPES_AS_CONCAT,REAL_AS_FLOAT,STRICT_ALL_TABLES,STRICT_TRANS_TABLES,ANSI,DB2,MAXDB,MSSQL,MYSQL323,MYSQL40,ORACLE,POSTGRESQL,TRADITIONAL",
|
||||
"--wait-timeout=28800",
|
||||
]
|
||||
networks:
|
||||
- infrastructure_default
|
||||
|
||||
postgres:
|
||||
hostname: postgres
|
||||
image: postgres
|
||||
restart: always
|
||||
# set shared memory limit when using docker-compose
|
||||
shm_size: 128mb
|
||||
# or set shared memory limit when deploy via swarm stack
|
||||
#volumes:
|
||||
# - type: tmpfs
|
||||
# target: /dev/shm
|
||||
# tmpfs:
|
||||
# size: 134217728 # 128*2^20 bytes = 128Mb
|
||||
environment:
|
||||
POSTGRES_PASSWORD: i4966cWBtP3xJ7BLsbsgo93C8Q5262
|
||||
|
||||
networks:
|
||||
- infrastructure_default
|
||||
|
||||
# nextjs-app-staging: # https://sofia.mwhitnessing.com/
|
||||
# image: docker.d-popov.com/jwpw:latest
|
||||
# ports:
|
||||
# - "3000:3000"
|
||||
# volumes:
|
||||
# - /mnt/docker_volumes/pw/app/.env.sofia:/app/.env.prod
|
||||
# environment:
|
||||
# - NODE_ENV=demo
|
||||
# - TZ=Europe/Sofia
|
||||
# - DATABASE_URL=mysql://jwpwsofia:dwxhns9p9vp248V39xJyRthUsZ2gR9@mariadb:3306/jwpwsofia
|
||||
# - UPDATE_CODE_FROM_GIT=true # Set to true to pull latest code from Git
|
||||
# - GIT_USERNAME=deploy
|
||||
# - GIT_PASSWORD=%L3Kr2R438u4F7^%40
|
||||
# - NEXT_PUBLIC_HOST=demo.mwhitnessing.com
|
||||
# - NEXTAUTH_URL= https://demo.mwhitnessing.com
|
||||
# command: sh -c " cd /app && npm install && npm run nodeenv; tail -f /dev/null"
|
||||
# tty: true
|
||||
# stdin_open: true
|
||||
# restart: always
|
||||
# networks:
|
||||
# - infrastructure_default
|
||||
|
||||
mariadb_backup:
|
||||
image: alpine:latest
|
||||
volumes:
|
||||
- /mnt/docker_volumes/pw/data/backup:/backup
|
||||
# - ./gdrive_service_account.json:/root/.gdrive_service_account.json
|
||||
environment:
|
||||
MYSQL_USER: jwpwsofia
|
||||
MYSQL_PASSWORD: dwxhns9p9vp248V39xJyRthUsZ2gR9
|
||||
MYSQL_DATABASE: jwpwsofia
|
||||
MYSQL_HOST: mariadb
|
||||
# GOOGLE_DRIVE_FOLDER_ID: your_google_drive_folder_id
|
||||
entrypoint: /bin/sh -c
|
||||
|
||||
networks:
|
||||
- infrastructure_default
|
||||
command: |
|
||||
"apk add --no-cache mysql-client curl && \
|
||||
echo '* 2 * * * mysqldump -h $$MYSQL_HOST -P 3306 -u$$MYSQL_USER -p$$MYSQL_PASSWORD $$MYSQL_DATABASE > /backup/$$(date +\\%Y-\\%m-\\%d-\\%H\\%M\\%S)-$$MYSQL_DATABASE.sql' > /etc/crontabs/root && \
|
||||
crond -f -d 8"
|
||||
# wget -q https://github.com/prasmussen/gdrive/releases/download/2.1.0/gdrive-linux-x64 -O /usr/bin/gdrive && \
|
||||
# chmod +x /usr/bin/gdrive && \
|
||||
# gdrive about --service-account /root/.gdrive_service_account.json && \
|
||||
# echo '0 * * * * /usr/bin/mysqldump -h $$MYSQL_HOST -u$$MYSQL_USER -p$$MYSQL_PASSWORD $$MYSQL_DATABASE | gzip > /backup/$$(date +\\%Y-\\%m-\\%d-\\%H\\%M\\%S)-$$MYSQL_DATABASE.sql.gz && gdrive upload --parent $$GOOGLE_DRIVE_FOLDER_ID --service-account /root/.gdrive_service_account.json /backup/$$(date +\\%Y-\\%m-\\%d-\\%H\\%M\\%S)-$$MYSQL_DATABASE.sql.gz' > /etc/crontabs/root && crond -f -d 8"
|
||||
networks:
|
||||
infrastructure_default:
|
||||
external: true
|
39
_deploy/deoloy.azure.yml
Normal file
39
_deploy/deoloy.azure.yml
Normal file
@ -0,0 +1,39 @@
|
||||
version: "3"
|
||||
services:
|
||||
nextjs-app:
|
||||
image: node:20.11.0-alpine
|
||||
ports:
|
||||
- "3000:3000"
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- /mnt/docker_volumes/pw/app:/app
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=mysql://jwpwsofia:dwxhns9p9vp248V39xJyRthUsZ2gR9@mariadb:3306/jwpwsofia
|
||||
command: /bin/sh -c "npm install && npm install -g dotenv-cli next && npx prisma generate && next dev; tail -f /dev/null" # Install dependencies and start the app
|
||||
#command: sh -c " cd /app && npm run prod; tail -f /dev/null"
|
||||
#HOST: fallocate -l 1G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile && echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
|
||||
# apk add git && rm -rf /tmp/clone && git clone --depth 1 https://git.d-popov.com/popov/next-cart-app.git /tmp/clone
|
||||
# cp -R /tmp/clone/next-cart-app/* /app/
|
||||
# rm -rf /tmp/clone
|
||||
# npm cache clean --force && rm -rf /app/node_modules /app/package-lock.json
|
||||
# npm --silent --prefix /app install /app && npx --prefix /app prisma generate && npm --prefix /app run test; tail -f /dev/null
|
||||
|
||||
tty: true
|
||||
stdin_open: true
|
||||
|
||||
mariadb:
|
||||
image: mariadb:10.6
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: i4966cWBtP3xJ7BLsbsgo93C8Q5262
|
||||
MYSQL_DATABASE: jwpwsofia
|
||||
MYSQL_USER: jwpwsofia
|
||||
MYSQL_PASSWORD: dwxhns9p9vp248V39xJyRthUsZ2gR9
|
||||
ports:
|
||||
- "3306:3306"
|
||||
volumes:
|
||||
- /mnt/docker_volumes/pw/data/mysql:/var/lib/mysql
|
||||
|
||||
volumes:
|
||||
nextjs-app-data:
|
||||
mysql-data:
|
27
_deploy/deploy.homelab.production.yml
Normal file
27
_deploy/deploy.homelab.production.yml
Normal file
@ -0,0 +1,27 @@
|
||||
version: "3.1"
|
||||
services:
|
||||
nextjs-app:
|
||||
image: docker.d-popov.com/jwpw:latest
|
||||
ports:
|
||||
- "5001:3000"
|
||||
environment:
|
||||
- NODE_ENV=prod
|
||||
- DATABASE_URL=mysql://cart:o74x642Rc8@mariadb:3306/cart
|
||||
- UPDATE_CODE_FROM_GIT=true # Set to true to pull latest code from Git
|
||||
- GIT_USERNAME=deploy
|
||||
- GIT_PASSWORD=%L3Kr2R438u4F7^%40
|
||||
command: sh -c " cd /app && npm install && npm run nodeenv; tail -f /dev/null"
|
||||
tty: true
|
||||
stdin_open: true
|
||||
mariadb:
|
||||
hostname: mariadb
|
||||
image: mariadb #bitnami/mariadb:latest #mariadb:10.4
|
||||
environment:
|
||||
MARIADB_ROOT_PASSWORD: Pw62L$3332JH
|
||||
MYSQL_ROOT_PASSWORD: Pw62L$3332JH
|
||||
MYSQL_DATABASE: cart
|
||||
MYSQL_USER: cart
|
||||
MYSQL_PASSWORD: o74x642Rc8
|
||||
networks:
|
||||
- default
|
||||
- mysql_default
|
34
_deploy/docker.init/.dockerignore
Normal file
34
_deploy/docker.init/.dockerignore
Normal file
@ -0,0 +1,34 @@
|
||||
# Include any files or directories that you don't want to be copied to your
|
||||
# container here (e.g., local build artifacts, temporary files, etc.).
|
||||
#
|
||||
# For more help, visit the .dockerignore file reference guide at
|
||||
# https://docs.docker.com/engine/reference/builder/#dockerignore-file
|
||||
|
||||
**/.classpath
|
||||
**/.dockerignore
|
||||
**/.env
|
||||
**/.git
|
||||
**/.gitignore
|
||||
**/.project
|
||||
**/.settings
|
||||
**/.toolstarget
|
||||
**/.vs
|
||||
**/.vscode
|
||||
**/.next
|
||||
**/.cache
|
||||
**/*.*proj.user
|
||||
**/*.dbmdl
|
||||
**/*.jfm
|
||||
**/charts
|
||||
**/docker-compose*
|
||||
**/compose*
|
||||
**/Dockerfile*
|
||||
**/node_modules
|
||||
**/npm-debug.log
|
||||
**/obj
|
||||
**/secrets.dev.yaml
|
||||
**/values.dev.yaml
|
||||
**/build
|
||||
**/dist
|
||||
LICENSE
|
||||
README.md
|
70
_deploy/docker.init/Dockerfile
Normal file
70
_deploy/docker.init/Dockerfile
Normal file
@ -0,0 +1,70 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Comments are provided throughout this file to help you get started.
|
||||
# If you need more help, visit the Dockerfile reference guide at
|
||||
# https://docs.docker.com/engine/reference/builder/
|
||||
|
||||
ARG NODE_VERSION=18.17.1
|
||||
|
||||
################################################################################
|
||||
# Use node image for base image for all stages.
|
||||
FROM node:${NODE_VERSION}-alpine as base
|
||||
|
||||
# Set working directory for all build stages.
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
|
||||
################################################################################
|
||||
# Create a stage for installing production dependecies.
|
||||
FROM base as deps
|
||||
|
||||
# Download dependencies as a separate step to take advantage of Docker's caching.
|
||||
# Leverage a cache mount to /root/.npm to speed up subsequent builds.
|
||||
# Leverage bind mounts to package.json and package-lock.json to avoid having to copy them
|
||||
# into this layer.
|
||||
RUN --mount=type=bind,source=package.json,target=package.json \
|
||||
--mount=type=bind,source=package-lock.json,target=package-lock.json \
|
||||
--mount=type=cache,target=/root/.npm \
|
||||
npm ci --omit=dev
|
||||
|
||||
################################################################################
|
||||
# Create a stage for building the application.
|
||||
FROM deps as build
|
||||
|
||||
# Download additional development dependencies before building, as some projects require
|
||||
# "devDependencies" to be installed to build. If you don't need this, remove this step.
|
||||
RUN --mount=type=bind,source=package.json,target=package.json \
|
||||
--mount=type=bind,source=package-lock.json,target=package-lock.json \
|
||||
--mount=type=cache,target=/root/.npm \
|
||||
npm ci
|
||||
|
||||
# Copy the rest of the source files into the image.
|
||||
COPY . .
|
||||
# Run the build script.
|
||||
RUN npm run build
|
||||
|
||||
################################################################################
|
||||
# Create a new stage to run the application with minimal runtime dependencies
|
||||
# where the necessary files are copied from the build stage.
|
||||
FROM base as final
|
||||
|
||||
# Use production node environment by default.
|
||||
ENV NODE_ENV production
|
||||
|
||||
# Run the application as a non-root user.
|
||||
USER node
|
||||
|
||||
# Copy package.json so that package manager commands can be used.
|
||||
COPY package.json .
|
||||
|
||||
# Copy the production dependencies from the deps stage and also
|
||||
# the built application from the build stage into the image.
|
||||
COPY --from=deps /usr/src/app/node_modules ./node_modules
|
||||
COPY --from=build /usr/src/app/deploy ./deploy
|
||||
|
||||
|
||||
# Expose the port that the application listens on.
|
||||
EXPOSE 3000
|
||||
|
||||
# Run the application.
|
||||
CMD node server.js
|
51
_deploy/docker.init/compose.yaml
Normal file
51
_deploy/docker.init/compose.yaml
Normal file
@ -0,0 +1,51 @@
|
||||
# Comments are provided throughout this file to help you get started.
|
||||
# If you need more help, visit the Docker compose reference guide at
|
||||
# https://docs.docker.com/compose/compose-file/
|
||||
|
||||
# Here the instructions define your application as a service called "server".
|
||||
# This service is built from the Dockerfile in the current directory.
|
||||
# You can add other services your application may depend on here, such as a
|
||||
# database or a cache. For examples, see the Awesome Compose repository:
|
||||
# https://github.com/docker/awesome-compose
|
||||
services:
|
||||
server:
|
||||
build:
|
||||
context: .
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
ports:
|
||||
- 3000:3000
|
||||
|
||||
# The commented out section below is an example of how to define a PostgreSQL
|
||||
# database that your application can use. `depends_on` tells Docker Compose to
|
||||
# start the database before your application. The `db-data` volume persists the
|
||||
# database data between container restarts. The `db-password` secret is used
|
||||
# to set the database password. You must create `db/password.txt` and add
|
||||
# a password of your choosing to it before running `docker-compose up`.
|
||||
# depends_on:
|
||||
# db:
|
||||
# condition: service_healthy
|
||||
# db:
|
||||
# image: postgres
|
||||
# restart: always
|
||||
# user: postgres
|
||||
# secrets:
|
||||
# - db-password
|
||||
# volumes:
|
||||
# - db-data:/var/lib/postgresql/data
|
||||
# environment:
|
||||
# - POSTGRES_DB=example
|
||||
# - POSTGRES_PASSWORD_FILE=/run/secrets/db-password
|
||||
# expose:
|
||||
# - 5432
|
||||
# healthcheck:
|
||||
# test: [ "CMD", "pg_isready" ]
|
||||
# interval: 10s
|
||||
# timeout: 5s
|
||||
# retries: 5
|
||||
# volumes:
|
||||
# db-data:
|
||||
# secrets:
|
||||
# db-password:
|
||||
# file: db/password.txt
|
||||
|
35
_deploy/entrypoint.sh
Normal file
35
_deploy/entrypoint.sh
Normal file
@ -0,0 +1,35 @@
|
||||
#!/bin/sh
|
||||
|
||||
if [ "$UPDATE_CODE_FROM_GIT" = "true" ]; then
|
||||
apk add git nano rsync
|
||||
echo "Updating code from git.d-popov.com...(as '$GIT_USERNAME')"
|
||||
rm -rf /tmp/clone
|
||||
mkdir /tmp/clone
|
||||
|
||||
git clone -b ${GIT_BRANCH:-master} --depth 1 https://$GIT_USERNAME:${GIT_PASSWORD//@/%40}@git.d-popov.com/popov/next-cart-app.git /tmp/clone || exit 1
|
||||
GIT_COMMIT_ID=$(git -C /tmp/clone/next-cart-app rev-parse HEAD)
|
||||
LAST_COMMIT_MESSAGE=$(git log -1 --pretty=%B)
|
||||
echo "Current Git Commit: $LAST_COMMIT_MESSAGE: $GIT_COMMIT_ID"
|
||||
export GIT_COMMIT_ID
|
||||
|
||||
# Use rsync for synchronizing files, ensuring deletion of files not in source
|
||||
rsync -av --delete /tmp/clone/next-cart-app/ /app/ || echo "Rsync failed: Issue synchronizing files"
|
||||
rsync -av /tmp/clone/next-cart-app/.env* /app/ || echo "Rsync failed: Issue copying .env files"
|
||||
rsync -av /tmp/clone/next-cart-app/_deploy/entrypoint.sh /app/entrypoint.sh || echo "Rsync failed: Issue copying entrypoint.sh"
|
||||
chmod +x /app/entrypoint.sh
|
||||
# Consider uncommenting the next line to clean up after successful copy
|
||||
rm -rf /tmp/clone
|
||||
|
||||
cd /app
|
||||
echo "Installing packages in /app"
|
||||
npm install --no-audit --no-fund --no-optional --omit=optional
|
||||
yes | npx prisma generate
|
||||
#npx prisma migrate deploy
|
||||
echo "Done cloning. Current Git Commit ID: $GIT_COMMIT_ID"
|
||||
# prod script
|
||||
# npx next build
|
||||
# npx next start
|
||||
fi
|
||||
|
||||
echo "Running the main process"
|
||||
exec "$@"
|
40
_deploy/prod.Dockerfile
Normal file
40
_deploy/prod.Dockerfile
Normal file
@ -0,0 +1,40 @@
|
||||
# Dockerfile in _deploy subfolder
|
||||
|
||||
FROM node:current-alpine
|
||||
|
||||
# Set environment variables for Node.js
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Create and set the working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies required for building certain npm packages
|
||||
# Install git if your npm dependencies require it
|
||||
RUN apk --no-cache add git
|
||||
|
||||
# Copy package.json and package-lock.json (or yarn.lock) first to leverage Docker cache
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies, including Prisma if used in your project
|
||||
RUN npm install --omit=dev
|
||||
RUN npm install -g dotenv-cli
|
||||
|
||||
# Copy the rest of your app's source code from the project root to the container
|
||||
COPY . .
|
||||
|
||||
# Run Prisma generate (if needed)
|
||||
RUN npx prisma generate
|
||||
|
||||
# Copy the entrypoint script and give it execute permissions
|
||||
COPY _deploy/entrypoint.sh /usr/local/bin/
|
||||
COPY _deploy/entrypoint.sh ./
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
# Expose a port (e.g., 3000) to access the app
|
||||
EXPOSE 3000
|
||||
|
||||
# Use the entrypoint script as the entrypoint
|
||||
ENTRYPOINT ["entrypoint.sh"]
|
||||
|
||||
# Start the Next.js app
|
||||
CMD ["npm", "start"]
|
65
_deploy/sample.docker-compose.yml
Normal file
65
_deploy/sample.docker-compose.yml
Normal file
@ -0,0 +1,65 @@
|
||||
version: "3.1"
|
||||
#name: gw02-mysql
|
||||
services:
|
||||
db:
|
||||
image: mariadb
|
||||
hostname: mariadb
|
||||
restart: always
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: Kc8P4h573W67Dd
|
||||
MYSQL_USER: cart # GRANT ALL PRIVILEGES ON database.* TO 'user'@'%' IDENTIFIED BY 'password';
|
||||
MYSQL_PASSWORD: cartpw2024
|
||||
MYSQL_DATABASE: cart # This will create a database named 'cart'
|
||||
stdin_open: true
|
||||
tty: true
|
||||
ports:
|
||||
- 3307:3306
|
||||
volumes:
|
||||
- /mnt/data/apps/docker_volumes/cart/database:/var/lib/mysql
|
||||
# command: >
|
||||
# sh -c '
|
||||
# export MYSQL_ROOT_PASSWORD=$$(openssl rand -base64 12);
|
||||
# echo "Generated MariaDB root password: $$MYSQL_ROOT_PASSWORD";
|
||||
# docker-entrypoint.sh mysqld
|
||||
# '
|
||||
adminer:
|
||||
image: adminer
|
||||
restart: always
|
||||
environment:
|
||||
- PHP_INI_UPLOAD_MAX_FILESIZE=500M
|
||||
- PHP_INI_POST_MAX_SIZE=500M
|
||||
ports:
|
||||
- 8083:8080
|
||||
|
||||
# THE APP
|
||||
nextjs-app:
|
||||
image: sachinvashist/nextjs-docker
|
||||
ports:
|
||||
- "8005:8005"
|
||||
volumes:
|
||||
- /mnt/data/apps/docker_volumes/cart/app:/app
|
||||
environment:
|
||||
- NODE_ENV=demo
|
||||
- DATABASE_URL=mysql://cart:cartpw2024@mariadb:3306/cart
|
||||
#! entrypoint: ["/bin/sh", "/entrypoint.sh"]
|
||||
#run: npm install && npx prisma generate && npm run test;
|
||||
# command: "npx prisma migrate deploy && npx prisma migrate deploy && npm run build && npm run start"
|
||||
# command: "npx prisma migrate resolve --applied 20231227185411_remove_email_unique_constraint && npx prisma migrate deploy && npx prisma migrate deploy && npm run build && npm run start"
|
||||
#command: /bin/sh -c "npm install && npx prisma generate && npm run test" # Install dependencies and start the app
|
||||
command: "npm run test"
|
||||
# command: >
|
||||
# /bin/sh -c "
|
||||
# apt-get update && apt-get install -y git || exit 1;
|
||||
# if [ ! -d '/app/.git' ]; then
|
||||
# echo 'Cloning stable branch from git.d-popov.com...';
|
||||
# git clone -b stable https://git.d-popov.com/repository.git /app || exit 1;
|
||||
# fi;
|
||||
# cd /app;
|
||||
# npm install && npx prisma migrate deploy && npm run build && npm run prod
|
||||
# "
|
||||
tty: true
|
||||
stdin_open: true
|
||||
# depends_on:
|
||||
# - mariadb
|
||||
|
||||
|
41
_deploy/setup-mariadb.sh
Normal file
41
_deploy/setup-mariadb.sh
Normal file
@ -0,0 +1,41 @@
|
||||
#!/bin/sh
|
||||
# Replace mariadb_root_password, mydb, myuser, and mypassword with your desired root password, MariaDB username, and password.
|
||||
|
||||
# Update APK repositories
|
||||
apk update
|
||||
|
||||
# Install MariaDB
|
||||
apk add mariadb mariadb-client
|
||||
|
||||
# Initialize the database
|
||||
mysql_install_db --user=mysql --ldata=/var/lib/mysql
|
||||
|
||||
# Start MariaDB
|
||||
rc-service mariadb start
|
||||
|
||||
# Secure the MariaDB installation (optional but recommended)
|
||||
mysql_secure_installation <<EOF
|
||||
|
||||
y # Set root password? [Y/n]
|
||||
9F2*M5*43s4& # New password
|
||||
9F2*M5*43s4& # Repeat password
|
||||
y # Remove anonymous users? [Y/n]
|
||||
y # Disallow root login remotely? [Y/n]
|
||||
y # Remove test database and access to it? [Y/n]
|
||||
y # Reload privilege tables now? [Y/n]
|
||||
EOF
|
||||
|
||||
# Create a new user and database
|
||||
mysql -u root -p9F2*M5*43s4& <<EOF
|
||||
CREATE USER 'cart'@'%' IDENTIFIED BY 'o74x642Rc8%6';
|
||||
CREATE DATABASE cart;
|
||||
GRANT ALL PRIVILEGES ON cart.* TO 'cart'@'%';
|
||||
FLUSH PRIVILEGES;
|
||||
EOF
|
||||
|
||||
# Allow remote connections (optional)
|
||||
echo "[mysqld]" >> /etc/my.cnf
|
||||
echo "bind-address = 0.0.0.0" >> /etc/my.cnf
|
||||
|
||||
# Restart MariaDB to apply changes
|
||||
rc-service mariadb restart
|
27
_deploy/setup-postgres.sh
Normal file
27
_deploy/setup-postgres.sh
Normal file
@ -0,0 +1,27 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Update APK repositories
|
||||
apk update
|
||||
|
||||
# Install PostgreSQL
|
||||
apk add postgresql postgresql-client
|
||||
|
||||
# Create a new database directory
|
||||
mkdir -p /var/lib/postgresql/data
|
||||
|
||||
# Initialize the database
|
||||
su - postgres -c "initdb /var/lib/postgresql/data"
|
||||
|
||||
# Start PostgreSQL
|
||||
su - postgres -c "pg_ctl start -D /var/lib/postgresql/data -l logfile"
|
||||
|
||||
# Create a new user and database
|
||||
su - postgres -c "psql -c \"CREATE USER myuser WITH PASSWORD 'mypassword';\""
|
||||
su - postgres -c "psql -c \"CREATE DATABASE mydb OWNER myuser;\""
|
||||
|
||||
# Enable remote connections (optional)
|
||||
echo "host all all 0.0.0.0/0 md5" >> /var/lib/postgresql/data/pg_hba.conf
|
||||
echo "listen_addresses='*'" >> /var/lib/postgresql/data/postgresql.conf
|
||||
|
||||
# Restart PostgreSQL to apply changes
|
||||
su - postgres -c "pg_ctl restart -D /var/lib/postgresql/data"
|
64
_deploy/terraform.tf
Normal file
64
_deploy/terraform.tf
Normal file
@ -0,0 +1,64 @@
|
||||
# Virtual Machines BS Series B2s EU West 736.9334 0.0000 736.9334 0.0449 $33,11
|
||||
# Virtual Network IP Addresses Standard IPv4 Static Public IP 2,217.0000 0.0000 2,217.0000 0.0047 $10,38
|
||||
|
||||
provider "azurerm" {
|
||||
features {}
|
||||
}
|
||||
|
||||
resource "azurerm_resource_group" "example" {
|
||||
name = "example-resources"
|
||||
location = "East US"
|
||||
}
|
||||
|
||||
resource "azurerm_virtual_network" "example" {
|
||||
name = "example-network"
|
||||
address_space = ["10.0.0.0/16"]
|
||||
location = azurerm_resource_group.example.location
|
||||
resource_group_name = azurerm_resource_group.example.name
|
||||
}
|
||||
|
||||
resource "azurerm_subnet" "example" {
|
||||
name = "example-subnet"
|
||||
resource_group_name = azurerm_resource_group.example.name
|
||||
virtual_network_name = azurerm_virtual_network.example.name
|
||||
address_prefixes = ["10.0.1.0/24"]
|
||||
}
|
||||
|
||||
resource "azurerm_network_interface" "example" {
|
||||
name = "example-nic"
|
||||
location = azurerm_resource_group.example.location
|
||||
resource_group_name = azurerm_resource_group.example.name
|
||||
|
||||
ip_configuration {
|
||||
name = "internal"
|
||||
subnet_id = azurerm_subnet.example.id
|
||||
private_ip_address_allocation = "Dynamic"
|
||||
}
|
||||
}
|
||||
|
||||
resource "azurerm_linux_virtual_machine" "example" {
|
||||
name = "example-vm"
|
||||
location = azurerm_resource_group.example.location
|
||||
resource_group_name = azurerm_resource_group.example.name
|
||||
network_interface_ids = [
|
||||
azurerm_network_interface.example.id,
|
||||
]
|
||||
|
||||
size = "Standard_DS2_v2"
|
||||
admin_username = "adminuser"
|
||||
admin_password = "Password1234!"
|
||||
disable_password_authentication = false
|
||||
computer_name = "hostname"
|
||||
|
||||
os_disk {
|
||||
caching = "ReadWrite"
|
||||
storage_account_type = "Premium_LRS"
|
||||
}
|
||||
|
||||
source_image_reference {
|
||||
publisher = "Canonical"
|
||||
offer = "UbuntuServer"
|
||||
sku = "18.04-LTS"
|
||||
version = "latest"
|
||||
}
|
||||
}
|
13
_deploy/test.11.yml
Normal file
13
_deploy/test.11.yml
Normal file
@ -0,0 +1,13 @@
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
nextjs-app:
|
||||
image: node:20.11.0-alpine
|
||||
ports:
|
||||
- "5002:3000"
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- /mnt/storage/DEV/workspace/repos/git.d-popov.com/next-cart-app/next-cart-app:/app
|
||||
environment:
|
||||
- NODE_ENV=test
|
||||
command: /bin/sh -c "npm install && npx prisma generate && npm run test; tail -f /dev/null" # Install dependencies and start the app
|
54
_deploy/testBuild.Dockerfile
Normal file
54
_deploy/testBuild.Dockerfile
Normal file
@ -0,0 +1,54 @@
|
||||
# Use a specific version of node to ensure consistent builds
|
||||
FROM node:current-alpine AS builder
|
||||
|
||||
# Set environment variables for Node.js
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Create and set the working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies required for building certain npm packages
|
||||
# Install git if your npm dependencies require it
|
||||
RUN apk --no-cache add git
|
||||
|
||||
# Copy package.json and package-lock.json (or yarn.lock) first to leverage Docker cache
|
||||
COPY package*.json ./
|
||||
|
||||
# Optionally, if you're using Prisma, copy the Prisma schema file
|
||||
# This is necessary for the `prisma generate` command
|
||||
COPY prisma ./prisma/
|
||||
|
||||
# Install dependencies, including Prisma if used in your project
|
||||
RUN npm install --omit=dev
|
||||
|
||||
# Install global packages if necessary
|
||||
RUN npm install -g dotenv-cli
|
||||
|
||||
# Build stage for the actual source code
|
||||
FROM node:current-alpine AS app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy installed node_modules from builder stage
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
|
||||
# Copy the rest of your app's source code from the project root to the container
|
||||
COPY . .
|
||||
|
||||
# Copy over any generated files from the builder stage if necessary
|
||||
# For example, the prisma client if you're using Prisma
|
||||
COPY --from=builder /app/node_modules/.prisma/client /app/node_modules/.prisma/client
|
||||
|
||||
# Copy the entrypoint script and give it execute permissions
|
||||
COPY _deploy/entrypoint.sh /usr/local/bin/
|
||||
COPY _deploy/entrypoint.sh ./
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
# Expose a port (e.g., 3000) to access the app
|
||||
EXPOSE 3000
|
||||
|
||||
# Use the entrypoint script as the entrypoint
|
||||
ENTRYPOINT ["entrypoint.sh"]
|
||||
|
||||
# Start the Next.js app
|
||||
CMD ["npm", "start"]
|
223
_doc/GoogleFormsScript.gs
Normal file
223
_doc/GoogleFormsScript.gs
Normal file
@ -0,0 +1,223 @@
|
||||
function main() {
|
||||
var form = FormApp.openById("11VuGKjoywNA0ajrfeRVwdNYDXSM0ge2qAn6bhiXOZj0"); // Replace with your Form's ID
|
||||
var sheet = SpreadsheetApp.openById("17CFpEDZ2Bn5GJSfMUhliwBBdbCaghATxZ5lYwRSbGsY").getSheetByName("Sheet1");
|
||||
var sheetResults = SpreadsheetApp.openById("1jIW27zh-502WIBpFVWGuNSZp7vBuB-SW6L_NPKgf4Ys").getSheetByName("Form Responses 1");
|
||||
|
||||
setNamesDropdownOptions(form, sheet);
|
||||
//updateWeeklyQuestions(form, new Date(2023,11 - 1,1));
|
||||
//mergeColumnsWithSameNames(sheetResults);
|
||||
}
|
||||
|
||||
function setNamesDropdownOptions(form, sheet) {
|
||||
var names = sheet.getRange("A4:A").getValues(); // Replace 'Sheet1' and 'A:A' with your sheet name and range respectively
|
||||
var colAFontWeights = sheet.getRange("A4:A").getFontWeights();
|
||||
|
||||
var flatList = names
|
||||
.flat()
|
||||
.map(function (name) {
|
||||
return normalizeName(name[0]);
|
||||
})
|
||||
.filter(String); // Flatten the array, normalize names, and remove any empty strings
|
||||
|
||||
var title = "Име, Фамилия";
|
||||
var item = form.getItems(FormApp.ItemType.LIST); // or FormApp.ItemType.LIST or MULTIPLE_CHOICE
|
||||
var multipleChoiceItem = item[0].asListItem(); // or .asMultipleChoiceItem()
|
||||
|
||||
var boldValues = [];
|
||||
for (var i = 0; i < colAFontWeights.length; i++) {
|
||||
if (colAFontWeights[i][0] === "bold") {
|
||||
boldValues.push(names[i][0]);
|
||||
}
|
||||
}
|
||||
var bulgarianOrder = "АБВГДЕЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЬЮЯ";
|
||||
|
||||
function customBulgarianSort(a, b) {
|
||||
for (var i = 0; i < Math.min(a.length, b.length); i++) {
|
||||
var indexA = bulgarianOrder.indexOf(a[i]);
|
||||
var indexB = bulgarianOrder.indexOf(b[i]);
|
||||
|
||||
// Default behavior for characters not in the bulgarianOrder string
|
||||
if (indexA === -1 && indexB === -1) {
|
||||
if (a[i] < b[i]) return -1;
|
||||
if (a[i] > b[i]) return 1;
|
||||
}
|
||||
|
||||
if (indexA === -1) return 1;
|
||||
if (indexB === -1) return -1;
|
||||
if (indexA < indexB) return -1;
|
||||
if (indexA > indexB) return 1;
|
||||
}
|
||||
|
||||
if (a.length < b.length) return -1;
|
||||
if (a.length > b.length) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
boldValues.sort(customBulgarianSort);
|
||||
|
||||
multipleChoiceItem.setChoiceValues(boldValues);
|
||||
}
|
||||
|
||||
// Helper function to remove leading, trailing, and consecutive spaces
|
||||
function normalizeName(name) {
|
||||
if (typeof name !== "string") {
|
||||
return ""; // Return an empty string if the name is not a valid string
|
||||
}
|
||||
return name.split(" ").filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
function ReverseNames(names, destination = "D") {
|
||||
// Reverse the name order for each name
|
||||
var reversedNames = names.map(function (name) {
|
||||
var splitName = name[0].trim().split(" ");
|
||||
if (splitName.length === 2) {
|
||||
// Ensure there's a Lastname and Firstname to swap
|
||||
return [splitName[1] + " " + splitName[0]];
|
||||
} else {
|
||||
return [name[0]]; // Return the original name if it doesn't fit the "Lastname Firstname" format
|
||||
}
|
||||
});
|
||||
|
||||
// Write reversed names to column D
|
||||
sheet.getRange(destination + "4:" + destination + "" + (3 + reversedNames.length)).setValues(reversedNames);
|
||||
}
|
||||
|
||||
function updateWeeklyQuestions(form, forDate) {
|
||||
var items = form.getItems(FormApp.ItemType.CHECKBOX_GRID);
|
||||
|
||||
var today = forDate;
|
||||
var firstDayOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
var weeks = getWeeksInMonth(today.getMonth(), today.getFullYear());
|
||||
|
||||
var columns = [];
|
||||
columns.push("Не мога");
|
||||
var weekDays = ["пон", "вт", "ср", "четв", "пет", "съб"];
|
||||
|
||||
//Summer
|
||||
//for (var i = 8; i <= 18; i += 2) {
|
||||
// columns.push("(" + i + "ч-" + (i + 2) + "ч)");
|
||||
//Winter
|
||||
for (var i = 9; i <= 18; i += 1.5) {
|
||||
columns.push("" + formatTime(i) + "-" + formatTime(i + 1.5) + "");
|
||||
}
|
||||
|
||||
for (var i = 0; i < weeks.length; i++) {
|
||||
var week = weeks[i];
|
||||
// Skip weeks that started in the previous month
|
||||
if (week[0].getMonth() !== firstDayOfMonth.getMonth()) {
|
||||
continue;
|
||||
}
|
||||
console.log("Week from " + week[0].getDate());
|
||||
|
||||
var title = "Седмица " + (i + 1) + " (" + week[0].getDate() + "-" + week[5].getDate() + " " + getMonthName(week[0].getMonth()) + ")"; // Always represent Monday to Saturday
|
||||
if (week[5].getMonth() !== week[0].getMonth()) {
|
||||
title = "Седмица " + (i + 1) + " (" + week[0].getDate() + " " + getMonthName(week[0].getMonth()) + " - " + week[5].getDate() + " " + getMonthName(week[5].getMonth()) + ")"; // Always represent Monday to Saturday
|
||||
}
|
||||
if (i < items.length) {
|
||||
// Modify existing checkbox grid
|
||||
var checkboxGrid = items[i].asCheckboxGridItem();
|
||||
checkboxGrid.setTitle(title);
|
||||
checkboxGrid.setColumns(columns);
|
||||
checkboxGrid.setRows(weekDays);
|
||||
} else {
|
||||
// Create a new checkbox grid
|
||||
var checkboxGrid = form.addCheckboxGridItem();
|
||||
checkboxGrid.setTitle(title);
|
||||
checkboxGrid.setColumns(columns);
|
||||
checkboxGrid.setRows(weekDays);
|
||||
checkboxGrid.setRequired(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete extra checkbox grids
|
||||
for (var j = weeks.length; j < items.length; j++) {
|
||||
form.deleteItem(items[j]);
|
||||
}
|
||||
}
|
||||
|
||||
function getWeeksInMonth(month, year) {
|
||||
var weeks = [],
|
||||
firstDate = new Date(year, month, 1),
|
||||
lastDate = new Date(year, month + 1, 0);
|
||||
|
||||
// Find the first Monday of the month
|
||||
while (firstDate.getDay() !== 1) {
|
||||
firstDate.setDate(firstDate.getDate() + 1);
|
||||
}
|
||||
|
||||
var currentDate = new Date(firstDate);
|
||||
|
||||
while (currentDate <= lastDate) {
|
||||
var week = [];
|
||||
|
||||
// For 6 days (Monday to Saturday)
|
||||
for (var i = 0; i < 6; i++) {
|
||||
week.push(new Date(currentDate));
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
}
|
||||
|
||||
currentDate.setDate(currentDate.getDate() + 1); //Sunday
|
||||
weeks.push(week);
|
||||
|
||||
// If we are already in the next month, break out of the loop
|
||||
if (currentDate.getMonth() !== month) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return weeks;
|
||||
}
|
||||
|
||||
function getMonthName(monthIndex) {
|
||||
var months = ["януари", "февруари", "март", "април", "май", "юни", "юли", "август", "септември", "октомври", "ноември", "декември"];
|
||||
return months[monthIndex];
|
||||
}
|
||||
|
||||
function formatTime(hourDecimal) {
|
||||
var hours = Math.floor(hourDecimal);
|
||||
var minutes = (hourDecimal - hours) * 60;
|
||||
if (minutes === 0) {
|
||||
return hours.toString().padStart(2, "0");
|
||||
}
|
||||
return hours.toString().padStart(2, "0") + ":" + minutes.toString().padStart(2, "0");
|
||||
}
|
||||
|
||||
function mergeColumnsWithSameNames(sheet) {
|
||||
var headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
|
||||
var uniqueHeaders = [...new Set(headers)]; // Get unique headers
|
||||
|
||||
for (var u = 0; u < uniqueHeaders.length; u++) {
|
||||
var currentHeader = uniqueHeaders[u];
|
||||
var columnsToMerge = [];
|
||||
|
||||
// Identify columns with the same name
|
||||
for (var i = 0; i < headers.length; i++) {
|
||||
if (headers[i] === currentHeader) {
|
||||
columnsToMerge.push(i + 1); // '+1' because column indices are 1-based
|
||||
}
|
||||
}
|
||||
|
||||
if (columnsToMerge.length > 1) {
|
||||
// If there's more than one column with the same name
|
||||
var sumData = [];
|
||||
for (var col of columnsToMerge) {
|
||||
var columnData = sheet.getRange(2, col, sheet.getLastRow() - 1).getValues(); // '-1' to exclude the header
|
||||
for (var j = 0; j < columnData.length; j++) {
|
||||
if (!sumData[j]) sumData[j] = [];
|
||||
sumData[j][0] = (sumData[j][0] || 0) + (columnData[j][0] || 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Write back summed data to the first column in the list
|
||||
sheet.getRange(2, columnsToMerge[0], sumData.length, 1).setValues(sumData);
|
||||
|
||||
// Delete the other duplicate columns
|
||||
for (var k = columnsToMerge.length - 1; k > 0; k--) {
|
||||
sheet.deleteColumn(columnsToMerge[k]);
|
||||
}
|
||||
|
||||
// Adjust headers array to reflect the deleted columns
|
||||
headers = headers.slice(0, columnsToMerge[1] - 1).concat(headers.slice(columnsToMerge[columnsToMerge.length - 1], headers.length));
|
||||
}
|
||||
}
|
||||
}
|
25
_doc/Message.md
Normal file
25
_doc/Message.md
Normal file
@ -0,0 +1,25 @@
|
||||
|
||||
1. Щандове. Предлагаме следното:
|
||||
|
||||
А) Да има известно разстояние между нас и щандовете. Целта е да оставим хората свободно да се доближат до количките и ако някой прояви интерес може да се приближим.
|
||||
|
||||
Б) Когато сме двама или трима може да стоим заедно. Ако сме четирима би било хубаво да се разделим по двама на количка и количките да са на известно разстояние една от друга.
|
||||
Моля вижте и прикачените снимки!
|
||||
|
||||
2. Безопасност. Нека се страем зад нас винаги да има защита или препятствие за недобронамерени хора.
|
||||
|
||||
3. Плакати. Моля при придвижване на количките да слагате плакатите така, че илюстрацията да се вижда, когато калъфа е сложен. Целта е снимките да не се търкат в количката, защото се повреждат.
|
||||
|
||||
4. Литература. При проявен интерес на чужд език, използвайте списанията и трактатите на други езици в папките.
|
||||
|
||||
5. График. Моля да ни изпратите вашите предпочитания до 23-то число на месеца. Линк към анкетата:
|
||||
https://docs.google.com/forms/d/e/1FAIpQLSdLpuTi0o9laOYmenGJetY6MJ-CmphD9xFS5ZVz_7ipOxNGUQ/viewform?usp=sf_link
|
||||
|
||||
6. Случки. Ако сте имали хубави случки на количките, моля пишете ни.
|
||||
|
||||
За други въпроси може да се обръщате към брат Янко Ванчев и брат Крейг Смит.
|
||||
|
||||
Много ви благодарим за хубавото сътрудничество и за вашата усърдна работа в службата с подвижните щандове в София! Пожелаваме ви много благословии и радост в този вид служба!
|
||||
|
||||
Ваши братя,
|
||||
Специално свидетелстване на обществени места в София
|
168
_doc/ToDo.md
Normal file
168
_doc/ToDo.md
Normal file
@ -0,0 +1,168 @@
|
||||
# - Authentication : Done
|
||||
# - Fix Location form :
|
||||
- store and generate transport
|
||||
- fix auto generation
|
||||
|
||||
- fix problem with showing Наташа Перчекли available on 7-th november from 10:00-12:30
|
||||
# - fix loading of availabiities in publisher page
|
||||
# - fix importing of availabilities from the last week, when the end is in the next month
|
||||
#? - fix availability start/end borders while searching, as adjasecent shifts are shown as available while they are not
|
||||
# - unit test proximity search for
|
||||
+ 'Елена Йедлинска',
|
||||
+ 'Джейми Лий Смит',
|
||||
+ 'Стела Стоянова'@ 9th november last shift
|
||||
+ 'Юрий Чулак' при импорт достъпностите са ВСЯКА сряда
|
||||
# - защо не се изтриват достъпностите при импорт? заюото не се взимаха от базата за да се итерират
|
||||
# - why are some availabilities imported as weekly : current/next month logic when importing in the current month
|
||||
- show weekly availabilities in calendar publisher list?
|
||||
|
||||
- importing schedule should create availability if not available. importing preferences deletes old availabilities.
|
||||
- add option (checkbox) if it is a family availability
|
||||
|
||||
- on calendar - when click on a publisher - show his availabilities and assihnments in a popup, with the option to move assingments to another shift - where there are free slots or inconfirmed slots.
|
||||
for each shift withing availability - show the current slots and allow to drag and drop to another shift. each shift has maximum 4 slots.
|
||||
|
||||
- прибиране/докаване на количките - да се импортира
|
||||
- fix month end not to last sunday, but to last day of the month in availabilities
|
||||
- да проверя файла с участия
|
||||
|
||||
Run `npm audit` for details.
|
||||
PS D:\DEV\workspace\next-cart-app\next-cart-app> npm update
|
||||
npm WARN deprecated graphql-import@0.7.1: GraphQL Import has been deprecated and merged into GraphQL Tools, so it will no longer get updates. Use GraphQL Tools instead to stay up-to-date! Check out https://www.graphql-tools.com/docs/migration-from-import for migration and https://the-guild.dev/blog/graphql-tools-v6 for new changes.
|
||||
npm WARN deprecated uuid@3.4.0: Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.
|
||||
npm WARN deprecated subscriptions-transport-ws@0.9.16: The `subscriptions-transport-ws` package is no longer maintained. We recommend you use `graphql-ws` instead. For help migrating Apollo software to `graphql-ws`, see https://www.apollographql.com/docs/apollo-server/data/subscriptions/#switching-from-subscriptions-transport-ws For general help using `graphql-ws`, see https://github.com/enisdenjo/graphql-ws/blob/master/README.md
|
||||
npm WARN deprecated graphql-tools@4.0.5: This package has been deprecated and now it only exports makeExecutableSchema.
|
||||
npm WARN deprecated And it will no longer receive updates.
|
||||
npm WARN deprecated We recommend you to migrate to scoped packages such as @graphql-tools/schema, @graphql-tools/utils and etc.
|
||||
npm WARN deprecated Check out https://www.graphql-tools.com to learn what package you should use instead
|
||||
|
||||
added 85 packages, removed 94 packages, changed 50 packages, and audited 1366 packages in 3m
|
||||
|
||||
---
|
||||
три цвята на назначенията: актуални, минали, без потвърждение/от назначенията/автоматични;
|
||||
|
||||
- color code shifts: yellow if 3 publishers, red if <=2 publishers, green if 4 publishers
|
||||
- for a publisher - view all available shfts where there are free slots and allow to move assignments to another shift
|
||||
|
||||
|
||||
- bulk edit for publishers - change type, target shifts.
|
||||
- edit publisher availabilities in grid - isWithTransport
|
||||
|
||||
After meeting with Fabio:
|
||||
- passed training cb
|
||||
- option to search for replacements only for my assignments (directly replace, or ask for help - email)
|
||||
-
|
||||
|
||||
+ see the whole schedule - talk with Yanko
|
||||
|
||||
- sign up
|
||||
|
||||
|
||||
shift reports - show my shifts, store the report to the shift and norify if report already exists.
|
||||
|
||||
!!! make word imports create availability as option during import - create availabilities as tentative and repeat them every month
|
||||
fix import - it adds it to the next day
|
||||
elena qdlinska is always created
|
||||
dariush availabilities are not imported
|
||||
fix availability repeat checks
|
||||
|
||||
|
||||
Упътване потребители:
|
||||
Ако искат винаги да са заедно - може да използват един имейл. Ако не - трябва да имат различни имейли.
|
||||
|
||||
|
||||
|
||||
Упътване отговорен брат:
|
||||
1 - провери всеки вестител за предпочитания/назначения (календара)
|
||||
2 - провери графика за изпуснати вестители - в червено, които не са на разположение за назначението си
|
||||
3 - провери графика за празни смени
|
||||
4 - провери за пропуснати вестители - без назначения за месеца
|
||||
5 - да провери транспорта
|
||||
|
||||
|
||||
|
||||
|
||||
Цялата програма - като от ПДФ.
|
||||
|
||||
меню: пон-петък
|
||||
|
||||
|
||||
"При лошо време"
|
||||
|
||||
да се добави: контакти - янко и крейг
|
||||
|
||||
чекбоксове,
|
||||
до края на месеца
|
||||
динамичен график: да могат да търсят заместници
|
||||
|
||||
Рубен да провери вестителите: дали са според програмата.
|
||||
|
||||
|
||||
2024.02.14:
|
||||
Янко:
|
||||
1. графика на всички. отделно меню.
|
||||
2. разрешение от общината. "разрешително". да има и админ за обновяване?
|
||||
3. линк местоположение
|
||||
4. местоположение - имена
|
||||
5. ipad - scroll na PDF-s 148
|
||||
6. списък с участници.
|
||||
7. отделен вид разположение "заместник"
|
||||
|
||||
Фабио:
|
||||
1. да може участника да види тези, които са на разопложение и да види телефоните.
|
||||
2. да изпращат а) на разположение б) на всички
|
||||
3. желани смени на месец - в "календар"
|
||||
4. да се импортират данните за назначение.
|
||||
|
||||
Краси
|
||||
1. Push notifications
|
||||
|
||||
|
||||
|
||||
-- Bugs:
|
||||
- import all assignments from WORD
|
||||
- respect repetition of availabilities
|
||||
- new api to set availabilities per day (deleting the old ones, based on time periods array)
|
||||
- failover for the server
|
||||
|
||||
|
||||
Fabio:
|
||||
- може да оставим само тези, които са въвели, но от другия месец, за да можем този ако има проблеми да ги оправим. да не стане така, че не приемаме друг начин, а нещо не работи
|
||||
- можем да тестваме с 10=20 човека, и ако няма прооблеми да пратим до всички още този месец.
|
||||
- списък с видове назначение:
|
||||
Вестител
|
||||
Бетелит
|
||||
Редовен Пионер
|
||||
Специален Пионер
|
||||
Мисионер
|
||||
Пътуваща служба
|
||||
|
||||
-------------------------------------------------------
|
||||
|
||||
Янко:
|
||||
1. графика на всички. отделно меню.
|
||||
2. разрешение от общината. "разрешително". да има и админ за обновяване?
|
||||
3. линк местоположение
|
||||
4. местоположение - имена
|
||||
5. ipad - scroll na PDF-s 148
|
||||
6. списък с участници.
|
||||
7. отделен вид разположение "заместник"
|
||||
|
||||
Фабио:
|
||||
1. да може участника да види тези, които са на разопложение и да види телефоните.
|
||||
2. да изпращат а) на разположение б) на всички
|
||||
3. желани смени на месец - в "календар"
|
||||
4. да се импортират данните за назначение.
|
||||
|
||||
Краси
|
||||
1. Push notifications
|
||||
|
||||
--
|
||||
Проблеми: някои използват един и същи имейл - няма как да въведат графика и на двамата.
|
||||
|
||||
- да оправя дневните разположения
|
||||
- да се взимат в предвид повтарящите се разопложения
|
||||
|
||||
--
|
||||
- да оправя дневните разположения
|
||||
- да се взимат в предвид повтарящите се разопложения
|
53
_doc/additionalNotes.mb
Normal file
53
_doc/additionalNotes.mb
Normal file
@ -0,0 +1,53 @@
|
||||
# GIT clone last commit to current folder:
|
||||
git clone --depth 1 https://git.d-popov.com/popov/next-cart-app.git .
|
||||
|
||||
# git clone only /next-cart-app subfolder (the nextjs app)
|
||||
# [git init]
|
||||
# [git remote add origin https://git.d-popov.com/popov/next-cart-app.git]
|
||||
git config core.sparseCheckout true
|
||||
echo "next-cart-app/" >> .git/info/sparse-checkout
|
||||
git clone --depth 1 --branch master https://git.d-popov.com/popov/next-cart-app.git .
|
||||
|
||||
#git fetch --depth=1 origin master
|
||||
#git checkout master
|
||||
#git reset --hard HEAD
|
||||
|
||||
|
||||
|
||||
#Build the Docker Image:
|
||||
docker-compose build
|
||||
#Tag the Image for the Local Registry:
|
||||
docker tag pw-cart 192.168.0.10:5000/pw-cart
|
||||
#Push the Image to the Local Registry
|
||||
docker push 192.168.0.10:5000/pw-cart
|
||||
|
||||
|
||||
# deploy to azure XXX
|
||||
mkdir app2
|
||||
git clone https://git.d-popov.com/popov/next-cart-app.git app2
|
||||
mv app2 app/
|
||||
rm -rf app2
|
||||
# copy to tmp: #rm -rf /tmp/clone && git clone https://git.d-popov.com/popov/next-cart-app.git /tmp/clone
|
||||
# copy to /app: #rm -rf /app/* && cp -R /tmp/clone/next-cart-app/* /app/
|
||||
# cleanup #rm -rf /tmp/clone
|
||||
#[!opt] build: # npm cache clean --force && rm -rf /app/node_modules /app/package-lock.json && npm --silent --prefix /app install /app
|
||||
# cd /app && npm install
|
||||
|
||||
|
||||
####################### GIT ######################
|
||||
# git remove file from repo (ignore and delete)
|
||||
gitignore > + *.zip
|
||||
git commit -m "Ignore all .zip files"
|
||||
--
|
||||
git rm --cached '*.zip'
|
||||
git commit -m "Remove tracked .zip files from repository"
|
||||
git push
|
||||
|
||||
|
||||
## git clean/optimize
|
||||
git gc
|
||||
git prune
|
||||
git repack -ad
|
||||
#!Expire and prune older reflog entries to reduce the number of objects retained for undo purposes.
|
||||
#git reflog expire --expire=now --all
|
||||
#git gc --prune=now
|
78
_doc/auth/auth.md
Normal file
78
_doc/auth/auth.md
Normal file
@ -0,0 +1,78 @@
|
||||
location /authelia {
|
||||
internal;
|
||||
set $upstream_authelia http://authelia:9091/api/verify;
|
||||
proxy_pass_request_body off;
|
||||
proxy_pass $upstream_authelia;
|
||||
proxy_set_header Content-Length "";
|
||||
|
||||
# Timeout if the real server is dead
|
||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
||||
client_body_buffer_size 128k;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_set_header X-Forwarded-Uri $request_uri;
|
||||
proxy_set_header X-Forwarded-Ssl on;
|
||||
proxy_redirect http:// $scheme://;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_cache_bypass $cookie_session;
|
||||
proxy_no_cache $cookie_session;
|
||||
proxy_buffers 4 32k;
|
||||
|
||||
send_timeout 5m;
|
||||
proxy_read_timeout 240;
|
||||
proxy_send_timeout 240;
|
||||
proxy_connect_timeout 240;
|
||||
}
|
||||
|
||||
location / {
|
||||
set $upstream_app $forward_scheme://$server:$port;
|
||||
proxy_pass $upstream_app;
|
||||
|
||||
auth_request /authelia;
|
||||
auth_request_set $target_url https://$http_host$request_uri;
|
||||
auth_request_set $user $upstream_http_remote_user;
|
||||
auth_request_set $email $upstream_http_remote_email;
|
||||
auth_request_set $groups $upstream_http_remote_groups;
|
||||
proxy_set_header Remote-User $user;
|
||||
proxy_set_header Remote-Email $email;
|
||||
proxy_set_header Remote-Groups $groups;
|
||||
|
||||
error_page 401 =302 https://auth.d-popov.com/?rd=$target_url;
|
||||
|
||||
client_body_buffer_size 128k;
|
||||
|
||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
||||
|
||||
send_timeout 5m;
|
||||
proxy_read_timeout 360;
|
||||
proxy_send_timeout 360;
|
||||
proxy_connect_timeout 360;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection upgrade;
|
||||
proxy_set_header Accept-Encoding gzip;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_set_header X-Forwarded-Uri $request_uri;
|
||||
proxy_set_header X-Forwarded-Ssl on;
|
||||
proxy_redirect http:// $scheme://;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_cache_bypass $cookie_session;
|
||||
proxy_no_cache $cookie_session;
|
||||
proxy_buffers 64 256k;
|
||||
|
||||
set_real_ip_from 172.18.0.0/16;
|
||||
set_real_ip_from 172.19.0.0/16;
|
||||
real_ip_header CF-Connecting-IP;
|
||||
real_ip_recursive on;
|
||||
|
||||
}
|
25
_doc/auth/authelia-authrequest.conf
Normal file
25
_doc/auth/authelia-authrequest.conf
Normal file
@ -0,0 +1,25 @@
|
||||
## Send a subrequest to Authelia to verify if the user is authenticated and has permission to access the resource.
|
||||
auth_request /authelia;
|
||||
|
||||
## Set the $target_url variable based on the original request.
|
||||
|
||||
## Comment this line if you're using nginx without the http_set_misc module.
|
||||
set_escape_uri $target_url $scheme://$http_host$request_uri;
|
||||
|
||||
## Uncomment this line if you're using NGINX without the http_set_misc module.
|
||||
# set $target_url $scheme://$http_host$request_uri;
|
||||
|
||||
## Save the upstream response headers from Authelia to variables.
|
||||
auth_request_set $user $upstream_http_remote_user;
|
||||
auth_request_set $groups $upstream_http_remote_groups;
|
||||
auth_request_set $name $upstream_http_remote_name;
|
||||
auth_request_set $email $upstream_http_remote_email;
|
||||
|
||||
## Inject the response headers from the variables into the request made to the backend.
|
||||
proxy_set_header Remote-User $user;
|
||||
proxy_set_header Remote-Groups $groups;
|
||||
proxy_set_header Remote-Name $name;
|
||||
proxy_set_header Remote-Email $email;
|
||||
|
||||
## If the subreqest returns 200 pass to the backend, if the subrequest returns 401 redirect to the portal.
|
||||
error_page 401 =302 https://auth.d-popov.com/?redirect=$target_url;
|
36
_doc/auth/authelia-location.conf
Normal file
36
_doc/auth/authelia-location.conf
Normal file
@ -0,0 +1,36 @@
|
||||
set $upstream_authelia http://authelia:9091/api/verify;
|
||||
|
||||
## Virtual endpoint created by nginx to forward auth requests.
|
||||
location /authelia {
|
||||
## Essential Proxy Configuration
|
||||
internal;
|
||||
proxy_pass $upstream_authelia;
|
||||
|
||||
## Headers
|
||||
## The headers starting with X-* are required.
|
||||
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
|
||||
proxy_set_header X-Original-Method $request_method;
|
||||
proxy_set_header X-Forwarded-Method $request_method;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_set_header X-Forwarded-Uri $request_uri;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header Content-Length "";
|
||||
proxy_set_header Connection "";
|
||||
|
||||
## Basic Proxy Configuration
|
||||
proxy_pass_request_body off;
|
||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503; # Timeout if the real server is dead
|
||||
proxy_redirect http:// $scheme://;
|
||||
proxy_http_version 1.1;
|
||||
proxy_cache_bypass $cookie_session;
|
||||
proxy_no_cache $cookie_session;
|
||||
proxy_buffers 4 32k;
|
||||
client_body_buffer_size 128k;
|
||||
|
||||
## Advanced Proxy Configuration
|
||||
send_timeout 5m;
|
||||
proxy_read_timeout 240;
|
||||
proxy_send_timeout 240;
|
||||
proxy_connect_timeout 240;
|
||||
}
|
7
_doc/auth/nginx-protected-app-advanced-tab.conf
Normal file
7
_doc/auth/nginx-protected-app-advanced-tab.conf
Normal file
@ -0,0 +1,7 @@
|
||||
include /snippets/authelia-location.conf;
|
||||
|
||||
location / {
|
||||
include /snippets/proxy.conf;
|
||||
include /snippets/authelia-authrequest.conf;
|
||||
proxy_pass $forward_scheme://$server:$port;
|
||||
}
|
35
_doc/auth/proxy.conf
Normal file
35
_doc/auth/proxy.conf
Normal file
@ -0,0 +1,35 @@
|
||||
## Headers
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_set_header X-Forwarded-Uri $request_uri;
|
||||
proxy_set_header X-Forwarded-Ssl on;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Connection "";
|
||||
|
||||
## Basic Proxy Configuration
|
||||
client_body_buffer_size 128k;
|
||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503; ## Timeout if the real server is dead.
|
||||
proxy_redirect http:// $scheme://;
|
||||
proxy_http_version 1.1;
|
||||
proxy_cache_bypass $cookie_session;
|
||||
proxy_no_cache $cookie_session;
|
||||
proxy_buffers 64 256k;
|
||||
|
||||
## Trusted Proxies Configuration
|
||||
## Please read the following documentation before configuring this:
|
||||
## https://www.authelia.com/integration/proxies/nginx/#trusted-proxies
|
||||
# set_real_ip_from 10.0.0.0/8;
|
||||
# set_real_ip_from 172.16.0.0/12;
|
||||
# set_real_ip_from 192.168.0.0/16;
|
||||
# set_real_ip_from fc00::/7;
|
||||
real_ip_header X-Forwarded-For;
|
||||
real_ip_recursive on;
|
||||
|
||||
## Advanced Proxy Configuration
|
||||
send_timeout 5m;
|
||||
proxy_read_timeout 360;
|
||||
proxy_send_timeout 360;
|
||||
proxy_connect_timeout 360;
|
178
_doc/notes.mb
Normal file
178
_doc/notes.mb
Normal file
@ -0,0 +1,178 @@
|
||||
Nginx:
|
||||
Email: admin@example.com
|
||||
Password: changeme
|
||||
|
||||
google auth:
|
||||
# // owner: dobromir.popov@gmail.com | Специално Свидетелстване София
|
||||
# // https://console.cloud.google.com/apis/credentials/oauthclient/926212607479-d3m8hm8f8esp3rf1639prskn445sa01v.apps.googleusercontent.com?project=grand-forge-108716
|
||||
|
||||
# to spin up devcontainer:
|
||||
# ensure we have docker:
|
||||
apt-get update && apt-get install -y docker.io
|
||||
|
||||
docker build -t dev-next-cart-app-img .devcontainer
|
||||
docker run -d -v /path/to/your/project:/workspace --name dev-next-cart-app dev-next-cart-app-img
|
||||
docker exec -it dev-next-cart-app /bin/bash
|
||||
|
||||
##### ----------------- compose/deploy ----------------- ###
|
||||
# install docker if inside docker (vscode-server)# apt-get update && apt-get install -y docker.io
|
||||
# .10 > /mnt/apps/DEV/SSS/next-cart-app/next-cart-app/
|
||||
#.11 > cd /mnt/storage/DEV/workspace/repos/git.d-popov.com/next-cart-app/next-cart-app
|
||||
|
||||
# using dockerfile and image:
|
||||
docker build -t jwpw:latest -f _deploy/prod.Dockerfile .
|
||||
# or ----------------------
|
||||
docker build -t jwpw:test -f _deploy/testBuild.Dockerfile .
|
||||
|
||||
#---- or (testing 2 step docker image)
|
||||
docker build -t docker.d-popov.com/jwpw:test -f _deploy/testBuild.Dockerfile .
|
||||
docker run -p 3000:3000 docker.d-popov.com/jwpw:test
|
||||
docker run -it --name browse-jwse --entrypoint sh docker.d-popov.com/jwpw:test
|
||||
docker rm browse-jwpw
|
||||
|
||||
--- TEST
|
||||
docker build -t docker.d-popov.com/jwpw:test -f _deploy/testBuild.Dockerfile .
|
||||
docker push docker.d-popov.com/jwpw:test
|
||||
|
||||
--LATEST/
|
||||
cd /mnt/storage/DEV/workspace/repos/git.d-popov.com/next-cart-app/next-cart-app
|
||||
docker build -t docker.d-popov.com/jwpw:latest -f _deploy/prod.Dockerfile .
|
||||
docker tag docker.d-popov.com/jwpw:latest docker.d-popov.com/jwpw:0.9.94
|
||||
docker push docker.d-popov.com/jwpw:latest
|
||||
docker push docker.d-popov.com/jwpw:0.9.94
|
||||
|
||||
#---
|
||||
|
||||
docker tag jwpw:latest docker.d-popov.com/jwpw:latest
|
||||
docker push docker.d-popov.com/jwpw:latest # docker push registry.example.com/username/my-image:latest #docker login docker.d-popov.com
|
||||
# Tag the image for your local Docker registry
|
||||
docker tag jwpw:0.9 docker.d-popov.com/jwpw:0.9
|
||||
# deploy
|
||||
docker pull docker.d-popov.com/jwpw:latest
|
||||
docker run -p local-port:container-port docker.d-popov.com/jwpw:latest
|
||||
|
||||
|
||||
# docker-compose
|
||||
docker-compose up --build
|
||||
!
|
||||
docker-compose -f test.11.yml up --build # build test on .11:5002
|
||||
docker-compose -f deploy.azure.yml up --build
|
||||
|
||||
# OR git clone ------------------!!!
|
||||
apk add git && rm -rf /tmp/clone
|
||||
apk add git && git clone --depth 1 https://popov:6A5FvrV6jfF2BP@git.d-popov.com/popov/next-cart-app.git /tmp/clone && cp -Rf /tmp/clone/next-cart-app/* /app/
|
||||
# if cloned # cd /tmp/clone && git pull --depth 1
|
||||
cp -Rf /tmp/clone/next-cart-app/* /app/ && cd /app
|
||||
cp -Rf /tmp/clone/next-cart-app/* /mnt/docker_volumes/pw/app/
|
||||
rm -rf /tmp/clone
|
||||
npm cache clean --force && rm -rf /app/node_modules /app/package-lock.json
|
||||
NODE_OPTIONS="--max-old-space-size=4096" npm install
|
||||
|
||||
npm install && npm install -g dotenv-cli && npx prisma generate
|
||||
npm run test
|
||||
|
||||
# OR
|
||||
# copy with SSH
|
||||
# remote: #sudo chown -R azureuser:azureuser /mnt/docker_volumes/pw/app/
|
||||
# local: #rsync -avz ./node_modules/ azureuser@172.160.240.73:/mnt/docker_volumes/pw/app/node_modules/
|
||||
|
||||
# OR (2 cmd deploy PROD)
|
||||
# npm run build
|
||||
rsync -avz --include=".*" .next \
|
||||
.env .env.production \
|
||||
components \
|
||||
pages \
|
||||
prisma \
|
||||
src \
|
||||
styles \
|
||||
package.json \
|
||||
package-lock.json \
|
||||
server.js \
|
||||
azureuser@172.160.240.73:/mnt/docker_volumes/pw/app/
|
||||
|
||||
# or DOCKERFILE
|
||||
docker build -t jw-cart -f prod.Dockerfile .
|
||||
docker build -t jw-cart-prod -f testBuild.Dockerfile .
|
||||
|
||||
#build
|
||||
npm run build
|
||||
|
||||
#install next
|
||||
npm install -g next
|
||||
next build
|
||||
next start
|
||||
## ------------------------------- dev -----------------------------------###
|
||||
|
||||
|
||||
# aider:
|
||||
export OPENAI_API_KEY=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 # personal
|
||||
export OPENAI_API_KEY=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 # dev-bro
|
||||
|
||||
# -------------update PRISMA schema/sync database ------------------------ #
|
||||
# prisma migrate dev --create-only
|
||||
npx prisma generate
|
||||
npx prisma migrate dev --name fix_nextauth_schema --create-only
|
||||
>Prisma Migrate created the following migration without applying it 20231214163235_fix_nextauth_schema
|
||||
>You can now edit it and apply it by running "prisma migrate dev"
|
||||
|
||||
# #production/deploy: npx prisma migrate deploy
|
||||
## Reintrospect: If you have a database that's in the desired state, but your Prisma schema is out of date, you can use prisma db pull to introspect the database and update your Prisma schema to match the current state of the database.
|
||||
npx prisma generate
|
||||
|
||||
# mark migration as applied:
|
||||
npx prisma migrate resolve --applied 20240201214719_assignment_add_repeat_frequency
|
||||
|
||||
# update prisma package
|
||||
npm i prisma@latest
|
||||
npm i @prisma/client@latest
|
||||
|
||||
npx prisma migrate dev --schema "mysql://cart:cart2023@192.168.0.10:3306/cart_dev" # -- does not work
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
##########################################
|
||||
# yml leave the container running:
|
||||
command: sh -c "npm run test; tail -f /dev/null"
|
||||
|
||||
|
||||
|
||||
|
||||
fix build errors:
|
||||
cd path\to\your\project
|
||||
--
|
||||
rmdir /s /q node_modules
|
||||
del package-lock.json
|
||||
npm install
|
||||
--
|
||||
Remove-Item -Recurse -Force node_modules
|
||||
Remove-Item package-lock.json
|
||||
npm install
|
||||
|
||||
|
||||
# -- mysql
|
||||
# fix
|
||||
mysql -u root -pi4966cWBtP3xJ7BLsbsgo93C8Q5262
|
||||
--
|
||||
mysqld_safe --skip-grant-tables &
|
||||
mysql -u root
|
||||
FLUSH PRIVILEGES;
|
||||
SET PASSWORD FOR 'root'@'localhost' = PASSWORD('i4966cWBtP3xJ7BLsbsgo93C8Q5262');
|
||||
GRANT ALL PRIVILEGES ON jwpwsofia.* TO 'jwpwsofia'@'%' IDENTIFIED BY 'dwxhns9p9vp248V39xJyRthUsZ2gR9' WITH GRANT OPTION;
|
||||
GRANT ALL PRIVILEGES ON jwpwsofia.* TO 'jwpwsofia'@'172.22.0.3' IDENTIFIED BY 'dwxhns9p9vp248V39xJyRthUsZ2gR9' WITH GRANT OPTION;
|
||||
|
||||
FLUSH PRIVILEGES;
|
||||
exit;
|
||||
|
||||
|
||||
#Install depcheck:
|
||||
npm install --save-dev depcheck
|
||||
npx depcheck
|
||||
npx depcheck --detailed
|
||||
|
||||
#Check for Package Updates
|
||||
npm install -g npm-check-updates
|
||||
ncu
|
||||
ncu -u
|
27
components/ConfirmationModal.tsx
Normal file
27
components/ConfirmationModal.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
export default function ConfirmationModal({ isOpen, onClose, onConfirm, message }) {
|
||||
//export default function ConfirmationModal({ isOpen, onClose, onConfirm, message })
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-50">
|
||||
<div className="bg-white p-4 rounded-md shadow-lg modal-content">
|
||||
<p className="mb-4">{message}</p>
|
||||
<button
|
||||
className="bg-red-500 text-white px-4 py-2 rounded mr-2"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
Потвтрждавам
|
||||
</button>
|
||||
<button
|
||||
className="bg-gray-300 px-4 py-2 rounded"
|
||||
onClick={onClose}
|
||||
>
|
||||
Отказвам
|
||||
</button>
|
||||
</div>
|
||||
<div className="fixed inset-0 bg-black opacity-50 modal-overlay" onClick={onClose}></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
// const CustomCalendar = ({ month, year, shifts }) => {
|
||||
// export default function CustomCalendar({ date, shifts }: CustomCalendarProps) {
|
17
components/DayOfWeek.js
Normal file
17
components/DayOfWeek.js
Normal file
@ -0,0 +1,17 @@
|
||||
import React from 'react'
|
||||
const common = require('src/helpers/common');
|
||||
|
||||
function DayOfWeek({ onChange, selected, disabled }) {
|
||||
return (
|
||||
<select className="textbox form-select px-4 py-2 rounded"
|
||||
id="dayofweek" name="dayofweek" onChange={onChange} value={selected} autoComplete="off" disabled={disabled}>
|
||||
{common.DaysOfWeekArray.map((day, index) => (
|
||||
<option key={day} value={day}>
|
||||
{common.dayOfWeekNames[index]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
export default DayOfWeek
|
120
components/ExampleForm.js
Normal file
120
components/ExampleForm.js
Normal file
@ -0,0 +1,120 @@
|
||||
import axiosInstance from '../src/axiosSecure';
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useRouter } from "next/router";
|
||||
import Link from "next/link";
|
||||
|
||||
class ExampleForm extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
urls: {
|
||||
apiUrl: "/api/data/...",
|
||||
indexUrl: "/cart/..."
|
||||
},
|
||||
isEdit: false,
|
||||
};
|
||||
}
|
||||
componentDidMount() {
|
||||
if (this.props.id) {
|
||||
this.fetch(this.props.id);
|
||||
}
|
||||
}
|
||||
fetch = async (id) => {
|
||||
try {
|
||||
const { data } = await axiosInstance.get(this.state.urls.apiUrl + id);
|
||||
this.setState({ item: data });
|
||||
} catch (error) {
|
||||
|
||||
console.error(error);
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
render() {
|
||||
const urls = {
|
||||
apiUrl: "/api/data/...s",
|
||||
indexUrl: "/cart/...s"
|
||||
}
|
||||
|
||||
const [item, set] = useState({
|
||||
isactive: true,
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const fetch = async (id) => {
|
||||
try {
|
||||
const { data } = await axiosInstance.get(urls.apiUrl + id);
|
||||
set(data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
if (router.query?.id) {
|
||||
fetch(parseInt(router.query.id.toString()));
|
||||
}
|
||||
console.log("called");
|
||||
}, [router.query.id]);
|
||||
|
||||
|
||||
handleChange = ({ target }) => {
|
||||
if (target.name === "isactive") {
|
||||
set({ ...item, [target.name]: target.checked });
|
||||
} else if (target.name === "age") {
|
||||
set({ ...item, [target.name]: parseInt(target.value) });
|
||||
} else {
|
||||
set({ ...item, [target.name]: target.value });
|
||||
}
|
||||
}
|
||||
|
||||
handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (router.query?.id) {
|
||||
await axiosInstance.put(urls.apiUrl + router.query.id, {
|
||||
...item,
|
||||
});
|
||||
toast.success("Task Updated", {
|
||||
position: "bottom-center",
|
||||
});
|
||||
} else {
|
||||
await axiosInstance.post(urls.apiUrl, item);
|
||||
toast.success("Task Saved", {
|
||||
position: "bottom-center",
|
||||
});
|
||||
}
|
||||
|
||||
router.push(indexUrl);
|
||||
} catch (error) {
|
||||
toast.error(error.response.data.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-xs">
|
||||
<h3>{router.query?.id ? "Edit" : "Create"} Item </h3>
|
||||
<div className="mb-4">
|
||||
<div className="form-check">
|
||||
<input className="checkbox" type="checkbox" id="isactive" name="isactive" onChange={handleChange} checked={item.isactive} autoComplete="off" />
|
||||
<label className="label" htmlFor="isactive">
|
||||
Is Active</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="panel-actions">
|
||||
<Link href={urls.indexUrl} className="action-button"> обратно </Link>
|
||||
<button className="button bg-blue-500 hover:bg-blue-700 focus:outline-none focus:shadow-outline" type="submit">
|
||||
{router.query?.id ? "Update" : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
116
components/FileUploadWithPreview .tsx
Normal file
116
components/FileUploadWithPreview .tsx
Normal file
@ -0,0 +1,116 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import axiosInstance from '../src/axiosSecure'; // Adjust the import path as needed
|
||||
import { CircularProgress } from '@mui/material'; // Import MUI CircularProgress for loading indicator
|
||||
|
||||
const FileUploadWithPreview = ({ name, value, prefix, onUpload, label }) => {
|
||||
// Function to transform the original image URL to its corresponding thumbnail URL
|
||||
function transformToThumbnailUrl(originalUrl) {
|
||||
if (!originalUrl) return null;
|
||||
if (originalUrl.includes("thumb")) return originalUrl; // If the URL already contains 'thumb', return it as-is
|
||||
const parts = originalUrl.split('/');
|
||||
parts.splice(parts.length - 1, 0, 'thumb'); // Insert 'thumb' before the filename
|
||||
return parts.join('/');
|
||||
}
|
||||
|
||||
// State for the thumbnail URL
|
||||
const [thumbnail, setThumbnail] = useState(value ? transformToThumbnailUrl(value) : null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false); // New state for tracking upload status
|
||||
|
||||
useEffect(() => {
|
||||
setThumbnail(value ? transformToThumbnailUrl(value) : null);
|
||||
}, [value]);
|
||||
|
||||
const handleDragEnter = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
const { files: droppedFiles } = e.dataTransfer;
|
||||
processFile(droppedFiles[0]); // Process only the first dropped file
|
||||
}, []);
|
||||
|
||||
const processFile = async (file) => {
|
||||
if (!file) return;
|
||||
|
||||
setIsUploading(true); // Start uploading
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
formData.append('prefix', prefix);
|
||||
setThumbnail("/");
|
||||
|
||||
try {
|
||||
const response = await axiosInstance.post('upload', formData);
|
||||
const uploadedFile = response.data[0];
|
||||
|
||||
// Update state with the new thumbnail URL
|
||||
setThumbnail(uploadedFile.thumbUrl);
|
||||
|
||||
// Invoke callback if provided
|
||||
if (onUpload) {
|
||||
onUpload(name, uploadedFile.originalUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading image:', error);
|
||||
} finally {
|
||||
setIsUploading(false); // End uploading
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilesChange = (e) => {
|
||||
processFile(e.target.files[0]); // Process only the first selected file
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-2 border-dashed border-2 border-gray-200 dark:border-gray-600 rounded-lg">
|
||||
<label htmlFor={`${name}-file-upload`} className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{label || 'Upload Image'}
|
||||
</label>
|
||||
<input id={`${name}-file-upload`} type="file" accept="image/*" onChange={handleFilesChange} className="hidden" />
|
||||
<div className={`flex flex-col items-center justify-center p-4 ${isDragging ? 'bg-gray-100 dark:bg-gray-700' : 'bg-white dark:bg-gray-800'}`}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}>
|
||||
{isUploading ? (
|
||||
<CircularProgress /> // Show loading indicator while uploading
|
||||
) : (
|
||||
<>
|
||||
<p className="text-gray-600 dark:text-gray-300 text-xs mb-2">Поставете сникма тук, или изберете файл с бутона</p>
|
||||
<button className="btn bg-blue-500 hover:bg-blue-700 text-white font-bold text-xs py-1 px-2 rounded"
|
||||
onClick={(e) => { e.preventDefault(); document.getElementById(`${name}-file-upload`).click(); }}>
|
||||
Избери сникма
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{
|
||||
thumbnail && (
|
||||
<div className="mt-2 flex justify-center">
|
||||
<div className="max-w-xs max-h-64 overflow-hidden rounded-md">
|
||||
<img src={thumbnail} alt="Thumbnail" className="object-contain w-full h-full" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div >
|
||||
);
|
||||
};
|
||||
|
||||
export default FileUploadWithPreview;
|
196
components/TextEditor.tsx
Normal file
196
components/TextEditor.tsx
Normal file
@ -0,0 +1,196 @@
|
||||
import axiosInstance from '../src/axiosSecure';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
import React, { useState, useRef, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||
import 'react-quill/dist/quill.snow.css';
|
||||
|
||||
//!!! working
|
||||
//const ReactQuill = dynamic(() => import('react-quill'), { ssr: false });
|
||||
// const ImageResize = dynamic(() => import('quill-image-resize'), { ssr: false });
|
||||
|
||||
const ReactQuill = dynamic(
|
||||
async () => {
|
||||
const { default: RQ } = await import('react-quill');
|
||||
// const { default: ImageUploader } = await import('./ImageUploader/ImageUploader');
|
||||
const { default: ImageResize } = await import('quill-image-resize');
|
||||
RQ.Quill.register('modules/imageResize', ImageResize);
|
||||
return RQ;
|
||||
},
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
// import {
|
||||
// Dispatch,
|
||||
// LegacyRef,
|
||||
// memo,
|
||||
// SetStateAction,
|
||||
// useMemo,
|
||||
// } from 'react';
|
||||
|
||||
// interface IQuillEditor extends ReactQuillProps {
|
||||
// forwardedRef: LegacyRef<ReactQuill>;
|
||||
// }
|
||||
|
||||
// const QuillNoSSRWrapper = dynamic(
|
||||
// async () => {
|
||||
// const { default: RQ } = await import('react-quill');
|
||||
// // eslint-disable-next-line react/display-name
|
||||
// return function editor({ forwardedRef, ...props }: IQuillEditor) {
|
||||
// return <RQ ref={forwardedRef} {...props} />;
|
||||
// };
|
||||
// },
|
||||
// { ssr: false }
|
||||
// );
|
||||
|
||||
|
||||
const formats = [
|
||||
'header',
|
||||
'bold',
|
||||
'italic',
|
||||
'underline',
|
||||
'strike',
|
||||
'blockquote',
|
||||
'list',
|
||||
'bullet',
|
||||
'indent',
|
||||
'link',
|
||||
'image',
|
||||
'code',
|
||||
'color',
|
||||
'background',
|
||||
'code-block',
|
||||
'align',
|
||||
];
|
||||
|
||||
interface OnChangeHandler {
|
||||
(e: any): void;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
placeholder: string;
|
||||
onChange: OnChangeHandler;
|
||||
prefix: string;
|
||||
};
|
||||
|
||||
const TextEditor: React.FC<Props> = forwardRef((props, ref) => {
|
||||
const { value, placeholder, onChange, prefix } = props;
|
||||
const quillRef = useRef(ref);
|
||||
|
||||
const handleOnChange = (e) => {
|
||||
if (quillRef.current) {
|
||||
const delta = quillRef.current.getEditor().getContents();
|
||||
const html = quillRef.current.getEditor().getHTML();
|
||||
onChange(html);
|
||||
}
|
||||
};
|
||||
useImperativeHandle(ref, () => ({
|
||||
getQuill: () => quillRef.current,
|
||||
}));
|
||||
|
||||
const imageHandler = async () => {
|
||||
const input = document.createElement('input');
|
||||
input.setAttribute('type', 'file');
|
||||
input.setAttribute('accept', 'image/*');
|
||||
input.click();
|
||||
|
||||
input.onchange = async () => {
|
||||
const uploadPromises = Array.from(input.files).map(async (item) => {
|
||||
const formData = new FormData();
|
||||
formData.append('image', item);
|
||||
formData.append('prefix', prefix);
|
||||
|
||||
try {
|
||||
const response = await axiosInstance.post('upload', formData);
|
||||
const imageUrl = response.data.imageUrl;
|
||||
|
||||
if (quillRef.current) {
|
||||
// const editor = quillRef.current.getEditor();
|
||||
const editor = quillRef.current.getQuill(); // Adjust based on your useImperativeHandle setup
|
||||
const range = editor.getSelection(true);
|
||||
editor.insertEmbed(range.index, 'image', imageUrl);
|
||||
|
||||
return range.index + 1;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading image:', error);
|
||||
}
|
||||
});
|
||||
|
||||
const cursorPositions = await Promise.all(uploadPromises);
|
||||
const lastCursorPosition = cursorPositions.pop();
|
||||
if (lastCursorPosition !== undefined && quillRef.current) {
|
||||
const editor = quillRef.current.getEditor();
|
||||
editor.setSelection(lastCursorPosition);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const modules = React.useMemo(
|
||||
() => ({
|
||||
toolbar: {
|
||||
container: [
|
||||
// [{ 'size': ['small', false, 'large', 'huge'] }], // custom dropdown
|
||||
[{ header: [1, 2, 3, 4, 5, 6, false] }],
|
||||
['bold', 'italic', 'underline', 'strike', 'blockquote', 'code-block'],
|
||||
[{ list: 'ordered' }, { list: 'bullet' }, { indent: '-1' }, { indent: '+1' }],
|
||||
['link', 'image'],
|
||||
[{ color: [] }, { background: [] }, { align: [] }],
|
||||
['clean'],
|
||||
],
|
||||
// container: [
|
||||
// [{ 'font': [] }], // Dropdown to select font
|
||||
// [{ 'size': ['small', false, 'large', 'huge'] }], // Dropdown to select font size
|
||||
// [{ 'header': [1, 2, 3, 4, 5, 6, false] }],
|
||||
// ['bold', 'italic', 'underline', 'strike'], // toggled buttons
|
||||
// [{ 'script': 'sub' }, { 'script': 'super' }], // superscript/subscript
|
||||
// ['blockquote', 'code-block'],
|
||||
// [{ 'list': 'ordered' }, { 'list': 'bullet' }],
|
||||
// [{ 'indent': '-1' }, { 'indent': '+1' }], // indent/outdent
|
||||
// [{ 'direction': 'rtl' }], // text direction
|
||||
// [{ 'color': [] }, { 'background': [] }], // dropdown with defaults from theme
|
||||
// [{ 'align': [] }],
|
||||
// ['link', 'image', 'video'], // link and image, video
|
||||
// ['clean'] // remove formatting button
|
||||
// ],
|
||||
// we can't fix this so temporary disable it
|
||||
// handlers: {
|
||||
// image: imageHandler,
|
||||
// },
|
||||
},
|
||||
imageResize: true
|
||||
}),
|
||||
[imageHandler],
|
||||
);
|
||||
|
||||
//test if we ever get the ref!
|
||||
useEffect(() => {
|
||||
if (quillRef.current) {
|
||||
console.log('quillRef.current: ', quillRef.current);
|
||||
// You can now access the Quill instance directly via quillRef.current.getEditor()
|
||||
// This is useful for any setup or instance-specific adjustments you might need
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReactQuill
|
||||
ref={quillRef}
|
||||
theme="snow"
|
||||
value={value || ''}
|
||||
modules={modules}
|
||||
formats={formats}
|
||||
onChange={onChange}
|
||||
style={{ height: '500px', width: '100%' }}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
// return <QuillNoSSRWrapper forwardedRef={ref} {...props} />;
|
||||
|
||||
});
|
||||
|
||||
export default TextEditor;
|
20
components/access-denied.tsx
Normal file
20
components/access-denied.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { signIn } from "next-auth/react"
|
||||
|
||||
export default function AccessDenied() {
|
||||
return (
|
||||
<>
|
||||
<h1>Access Denied</h1>
|
||||
<p>
|
||||
<a
|
||||
href="/api/auth/signin"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
signIn()
|
||||
}}
|
||||
>
|
||||
You must be signed in to view this page
|
||||
</a>
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
578
components/availability/AvailabilityForm.js
Normal file
578
components/availability/AvailabilityForm.js
Normal file
@ -0,0 +1,578 @@
|
||||
import axiosInstance from '../../src/axiosSecure';
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useRouter } from "next/router";
|
||||
import DayOfWeek from "../DayOfWeek";
|
||||
const common = require('src/helpers/common');
|
||||
import { MobileTimePicker } from '@mui/x-date-pickers/MobileTimePicker';
|
||||
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
|
||||
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
|
||||
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFnsV3';
|
||||
// import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
|
||||
|
||||
import TextField from '@mui/material/TextField';
|
||||
import bg from 'date-fns/locale/bg'; // Bulgarian locale
|
||||
|
||||
|
||||
import { bgBG } from '../x-date-pickers/locales/bgBG'; // Your custom translation file
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
|
||||
|
||||
|
||||
const fetchConfig = async () => {
|
||||
const config = await import('../../config.json');
|
||||
return config.default;
|
||||
};
|
||||
|
||||
/*
|
||||
// ------------------ data model ------------------
|
||||
model Availability {
|
||||
id Int @id @default(autoincrement())
|
||||
publisher Publisher @relation(fields: [publisherId], references: [id], onDelete: Cascade)
|
||||
publisherId String
|
||||
name String
|
||||
dayofweek DayOfWeek
|
||||
dayOfMonth Int?
|
||||
weekOfMonth Int?
|
||||
startTime DateTime
|
||||
endTime DateTime
|
||||
isactive Boolean @default(true)
|
||||
type AvailabilityType @default(Weekly)
|
||||
isWithTransport Boolean @default(false)
|
||||
isFromPreviousAssignment Boolean @default(false)
|
||||
isFromPreviousMonth Boolean @default(false)
|
||||
repeatWeekly Boolean? // New field to indicate weekly repetition
|
||||
repeatFrequency Int? // New field to indicate repetition frequency
|
||||
endDate DateTime? // New field for the end date of repetition
|
||||
|
||||
@@map("Availability")
|
||||
}
|
||||
|
||||
|
||||
*/
|
||||
|
||||
//enum for abailability type - day of week or day of month; and array of values
|
||||
const AvailabilityType = {
|
||||
WeeklyRecurrance: 'WeeklyRecurrance',
|
||||
ExactDate: 'ExactDate'
|
||||
}
|
||||
//const AvailabilityTypeValues = Object.values(AvailabilityType);
|
||||
|
||||
|
||||
|
||||
export default function AvailabilityForm({ publisherId, existingItem, inline, onDone, itemsForDay }) {
|
||||
|
||||
const [availability, setAvailability] = useState(existingItem || {
|
||||
publisherId: publisherId || null,
|
||||
name: "Нов",
|
||||
dayofweek: "Monday",
|
||||
dayOfMonth: null,
|
||||
startTime: "08:00",
|
||||
endTime: "20:00",
|
||||
isactive: true,
|
||||
repeatWeekly: false,
|
||||
endDate: null,
|
||||
});
|
||||
const [items, setItems] = useState(itemsForDay || []); // [existingItem, ...items]
|
||||
|
||||
const [selectedType, setSelectedOption] = useState(AvailabilityType.WeeklyRecurrance);
|
||||
const [isInline, setInline] = useState(inline || false);
|
||||
const [timeSlots, setTimeSlots] = useState([]);
|
||||
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
// Check screen width to determine if the device is mobile
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsMobile(window.innerWidth < 768); // 768px is a common breakpoint for mobile devices
|
||||
};
|
||||
// Call the function to setAvailability the initial state
|
||||
handleResize();
|
||||
// Add event listener
|
||||
window.addEventListener('resize', handleResize);
|
||||
// Cleanup
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
// Inside your component
|
||||
|
||||
|
||||
const [config, setConfig] = useState(null);
|
||||
useEffect(() => {
|
||||
fetchConfig().then(config => {
|
||||
// Use config here to adjust form fields
|
||||
console.log("UI config: ", config);
|
||||
setConfig(config);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const [dataFetched, setDataFetched] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const initialId = existingItem?.id || router.query.id;
|
||||
|
||||
const urls = {
|
||||
apiUrl: "/api/data/availabilities/",
|
||||
indexUrl: "/cart/availabilities"
|
||||
};
|
||||
|
||||
// Define the minimum and maximum times
|
||||
const minTime = new Date();
|
||||
minTime.setHours(8, 0, 0, 0); // 8:00 AM
|
||||
const maxTime = new Date();
|
||||
maxTime.setHours(20, 0, 0, 0); // 8:00 PM
|
||||
|
||||
|
||||
//always setAvailability publisherId
|
||||
useEffect(() => {
|
||||
availability.publisherId = publisherId;
|
||||
console.log("availability.publisherId: ", availability.publisherId);
|
||||
}, [availability]);
|
||||
|
||||
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
useEffect(() => {
|
||||
// If component is not in inline mode and there's no existing availability, fetch the availability based on the query ID
|
||||
// Fetch availability from DB only if it's not fetched yet, and there's no existing availability
|
||||
if (!isInline && !existingItem && !dataFetched && router.query.id) {
|
||||
fetchItemFromDB(parseInt(router.query.id.toString()));
|
||||
setDataFetched(true); // Set data as fetched
|
||||
}
|
||||
}, [router.query.id, isInline, existingItem, dataFetched]);
|
||||
}
|
||||
|
||||
// const [isEdit, setIsEdit] = useState(false);
|
||||
const fetchItemFromDB = async (id) => {
|
||||
try {
|
||||
console.log("fetching availability " + id);
|
||||
const { data } = await axiosInstance.get(urls.apiUrl + id);
|
||||
data.startTime = formatTime(data.startTime);
|
||||
data.endTime = formatTime(data.endTime);
|
||||
setAvailability(data);
|
||||
console.log(data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = ({ target }) => {
|
||||
// const { name, value } = e.target;
|
||||
// setItem((prev) => ({ ...prev, [name]: value }));
|
||||
console.log("AvailabilityForm: handleChange: " + target.name + " = " + target.value);
|
||||
setAvailability({ ...availability, [target.name]: target.value });
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
|
||||
|
||||
if (!availability.name) {
|
||||
// availability.name = "От календара";
|
||||
availability.name = common.getTimeFomatted(availability.startTime) + "-" + common.getTimeFomatted(availability.endTime);
|
||||
}
|
||||
|
||||
availability.dayofweek = common.getDayOfWeekNameEnEnum(availability.startTime);
|
||||
if (availability.repeatWeekly) {
|
||||
availability.dayOfMonth = null;
|
||||
} else {
|
||||
availability.endDate = null;
|
||||
availability.dayOfMonth = availability.startTime.getDate();
|
||||
}
|
||||
|
||||
delete availability.date; //remove date from availability as it is not part of the db model
|
||||
// ---------------------- CB UI --------------
|
||||
if (config.checkboxUI.enabled) {
|
||||
const selectedSlots = timeSlots.filter(slot => slot.isChecked);
|
||||
// Sort the selected intervals by start time
|
||||
const sortedSlots = [...selectedSlots].sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
// Group continuous slots
|
||||
const groupedIntervals = [];
|
||||
let currentGroup = [sortedSlots[0]];
|
||||
|
||||
for (let i = 1; i < sortedSlots.length; i++) {
|
||||
const previousSlot = currentGroup[currentGroup.length - 1];
|
||||
const currentSlot = sortedSlots[i];
|
||||
// Calculate the difference in hours between slots
|
||||
const difference = (currentSlot.startTime - previousSlot.endTime) / (60 * 60 * 1000);
|
||||
|
||||
// Assuming each slot represents an exact match to the increment (1.5 hours), we group them
|
||||
if (difference === 0) {
|
||||
currentGroup.push(currentSlot);
|
||||
} else {
|
||||
groupedIntervals.push(currentGroup);
|
||||
currentGroup = [currentSlot];
|
||||
}
|
||||
}
|
||||
// Don't forget the last group
|
||||
if (currentGroup.length > 0) {
|
||||
groupedIntervals.push(currentGroup);
|
||||
}
|
||||
|
||||
// Create availability objects from grouped slots
|
||||
const availabilities = groupedIntervals.map(group => {
|
||||
const startTime = group[0].startTime;
|
||||
const endTime = group[group.length - 1].endTime;
|
||||
return {
|
||||
publisherId: availability.publisherId,
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
// isWithTransportIn: slots[0].isWithTransport,
|
||||
// Add other necessary fields, like isWithTransport if applicable
|
||||
};
|
||||
});
|
||||
|
||||
//if more than one interval, we delete and recreate the availability, as it is not possble to map them
|
||||
if (availability.id && availabilities.length > 1) {
|
||||
await axiosInstance.delete(urls.apiUrl + availability.id);
|
||||
delete availability.id;
|
||||
}
|
||||
|
||||
// const firstSlotWithTransport = timeSlots[0].checked && timeSlots[0]?.isWithTransport;
|
||||
// const lastSlotWithTransport = timeSlots[timeSlots.length - 1].checked && timeSlots[timeSlots.length - 1]?.isWithTransport;
|
||||
|
||||
availabilities.forEach(async av => {
|
||||
// expand availability
|
||||
const avToStore = {
|
||||
...availability,
|
||||
...av,
|
||||
startTime: av.startTime,
|
||||
endTime: av.endTime,
|
||||
name: "От календара",
|
||||
id: undefined,
|
||||
|
||||
// isWithTransportIn: firstSlotWithTransport,
|
||||
// isWithTransportOut: lastSlotWithTransport,
|
||||
|
||||
};
|
||||
console.log("AvailabilityForm: handleSubmit: " + av);
|
||||
if (availability.id) {
|
||||
// UPDATE EXISTING ITEM
|
||||
await axiosInstance.put(urls.apiUrl + availability.id, {
|
||||
...avToStore,
|
||||
});
|
||||
} else {
|
||||
// CREATE NEW ITEM
|
||||
await axiosInstance.post(urls.apiUrl, avToStore);
|
||||
}
|
||||
handleCompletion(avToStore); // Assuming `handleCompletion` is defined to handle post-save logic
|
||||
});
|
||||
|
||||
|
||||
|
||||
}
|
||||
// ---------------------- TimePicker UI --------------
|
||||
else {
|
||||
availability.publisher = { connect: { id: availability.publisherId } };
|
||||
delete availability.publisherId;
|
||||
|
||||
if (availability.id) {
|
||||
console.log('editing avail# ' + availability.id);
|
||||
//delete availability.id;
|
||||
|
||||
// UPDATE EXISTING ITEM
|
||||
var itemUpdate = { ...availability, id: undefined };
|
||||
await axiosInstance.put(urls.apiUrl + availability.id, {
|
||||
...itemUpdate,
|
||||
});
|
||||
toast.success("Task Updated", {
|
||||
position: "bottom-center",
|
||||
});
|
||||
} else {
|
||||
// CREATE NEW ITEM
|
||||
console.log('creating new avail: ' + availability);
|
||||
const response = await axiosInstance.post(urls.apiUrl, availability);
|
||||
const createdItem = response.data;
|
||||
availability.id = createdItem.id;
|
||||
toast.success("Task Saved", {
|
||||
position: "bottom-center",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleCompletion(availability);
|
||||
} catch (error) {
|
||||
alert("Нещо се обърка. Моля, опитайте отново по-късно.");
|
||||
toast.error("Нещо се обърка. Моля, опитайте отново по-късно.");
|
||||
console.error(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (availability.id) {
|
||||
// console.log("deleting publisher id = ", router.query.id, "; url=" + urls.apiUrl + router.query.id);
|
||||
await axiosInstance.delete(urls.apiUrl + availability.id);
|
||||
toast.success("Записът изтрит", {
|
||||
position: "bottom-center",
|
||||
});
|
||||
handleCompletion({ deleted: true }); // Assuming handleCompletion is defined and properly handles post-deletion logic
|
||||
}
|
||||
} catch (error) {
|
||||
alert("Нещо се обърка при изтриването. Моля, опитайте отново или се свържете с нас");
|
||||
console.log(JSON.stringify(error));
|
||||
toast.error(error.response?.data?.message || "An error occurred");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompletion = async (result) => {
|
||||
console.log("AvailabilityForm: handleCompletion");
|
||||
if (isInline) {
|
||||
if (onDone) {
|
||||
onDone(result);
|
||||
}
|
||||
} else {
|
||||
router.push(urls.indexUrl);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("AvailabilityForm: publisherId: " + availability.publisherId + ", id: " + availability.id, ", inline: " + isInline);
|
||||
|
||||
|
||||
const generateTimeSlots = (start, end, increment, item) => {
|
||||
const slots = [];
|
||||
// Ensure we're working with the correct date base
|
||||
const baseDate = new Date(item?.startTime || new Date());
|
||||
baseDate.setHours(start, 0, 0, 0); // Set start time on the base date
|
||||
let currentTime = baseDate.getTime();
|
||||
|
||||
const endDate = new Date(item?.startTime || new Date());
|
||||
endDate.setHours(end, 0, 0, 0); // Set end time on the same date
|
||||
const endTime = endDate.getTime();
|
||||
|
||||
// Parse availability's startTime and endTime into Date objects for comparison
|
||||
const itemStartDate = new Date(item?.startTime);
|
||||
const itemEndDate = new Date(item?.endTime);
|
||||
|
||||
|
||||
while (currentTime < endTime) {
|
||||
let slotStart = new Date(currentTime);
|
||||
let slotEnd = new Date(currentTime + increment * 60 * 60 * 1000); // Calculate slot end time
|
||||
|
||||
// Check if the slot overlaps with the availability's time range
|
||||
const isChecked = slotStart < itemEndDate && slotEnd > itemStartDate;
|
||||
|
||||
slots.push({
|
||||
startTime: slotStart,
|
||||
endTime: slotEnd,
|
||||
isChecked: isChecked,
|
||||
});
|
||||
currentTime += increment * 60 * 60 * 1000; // Move to the next slot
|
||||
}
|
||||
slots[0].isFirst = true;
|
||||
slots[slots.length - 1].isLast = true;
|
||||
slots[0].isWithTransport = item.isWithTransportIn;
|
||||
slots[slots.length - 1].isWithTransport = item.isWithTransportOut;
|
||||
|
||||
|
||||
return slots;
|
||||
};
|
||||
|
||||
|
||||
const TimeSlotCheckboxes = ({ slots, setSlots, item }) => {
|
||||
const [allDay, setAllDay] = useState(false);
|
||||
|
||||
const handleAllDayChange = (e) => {
|
||||
const updatedSlots = slots.map(slot => ({
|
||||
...slot,
|
||||
isChecked: e.target.checked,
|
||||
}));
|
||||
setSlots(updatedSlots);
|
||||
console.log("handleAllDayChange: allDay: " + allDay + ", updatedSlots: " + JSON.stringify(updatedSlots));
|
||||
};
|
||||
useEffect(() => {
|
||||
console.log("allDay updated to: ", allDay);
|
||||
const updatedSlots = slots.map(slot => ({
|
||||
...slot,
|
||||
isChecked: allDay
|
||||
}));
|
||||
//setSlots(updatedSlots);
|
||||
}, [allDay]);
|
||||
|
||||
const handleSlotCheckedChange = (changedSlot) => {
|
||||
const updatedSlots = slots.map(slot => {
|
||||
if (slot.startTime === changedSlot.startTime && slot.endTime === changedSlot.endTime) {
|
||||
return { ...slot, isChecked: !slot.isChecked };
|
||||
}
|
||||
return slot;
|
||||
});
|
||||
// If slot is either first or last and it's being unchecked, also uncheck and disable transport
|
||||
if ((changedSlot.isFirst || changedSlot.isLast) && !changedSlot.isChecked) {
|
||||
changedSlot.isWithTransport = false;
|
||||
}
|
||||
setSlots(updatedSlots);
|
||||
};
|
||||
|
||||
const handleTransportChange = (changedSlot) => {
|
||||
const updatedSlots = slots.map(slot => {
|
||||
if (slot.startTime === changedSlot.startTime && slot.endTime === changedSlot.endTime) {
|
||||
return { ...slot, isWithTransport: !slot.isWithTransport };
|
||||
}
|
||||
return slot;
|
||||
});
|
||||
setSlots(updatedSlots);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<label className="checkbox-container flex items-center mb-4">
|
||||
<input type="checkbox" checked={allDay} onChange={e => setAllDay(e.target.checked)} className="form-checkbox h-5 w-5 text-gray-600 mx-2" />
|
||||
Цял ден
|
||||
<span className="checkmark"></span>
|
||||
</label>
|
||||
{slots.map((slot, index) => {
|
||||
const slotLabel = `${slot.startTime.getHours()}:${slot.startTime.getMinutes() === 0 ? '00' : slot.startTime.getMinutes()} до ${slot.endTime.getHours()}:${slot.endTime.getMinutes() === 0 ? '00' : slot.endTime.getMinutes()}`;
|
||||
slot.transportNeeded = slot.isFirst || slot.isLast;
|
||||
// Determine if the current slot is the first or the last
|
||||
|
||||
return (
|
||||
<div key={index} className="mb-4 flex justify-between items-center">
|
||||
<label className={`checkbox-container flex items-center mb-2 ${allDay ? 'opacity-50' : ''}`}>
|
||||
<input type="checkbox" checked={slot.isChecked || allDay} onChange={() => handleSlotCheckedChange(slot)}
|
||||
disabled={allDay}
|
||||
className="form-checkbox h-5 w-5 text-gray-600 mx-2" />
|
||||
{slotLabel}
|
||||
<span className="checkmark"></span>
|
||||
</label>
|
||||
|
||||
{/* Conditionally render transport checkbox based on slot being first or last */}
|
||||
{slot.transportNeeded && (
|
||||
<label className={`checkbox-container flex items-center ${(!slot.isChecked || allDay) ? 'opacity-50' : ''}`}>
|
||||
<input type="checkbox"
|
||||
className="form-checkbox h-5 w-5 text-gray-600 mx-2"
|
||||
checked={slot.isWithTransport}
|
||||
disabled={!slot.isChecked || allDay}
|
||||
onChange={() => handleTransportChange(slot)} />
|
||||
{slot.isFirst ? 'Вземане' : 'Връщане'}
|
||||
<span className="checkmark"></span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setTimeSlots(generateTimeSlots(9, 18, 1.5, availability));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ width: isMobile ? '90%' : 'max-w-xs', margin: '0 auto' }} >
|
||||
<ToastContainer></ToastContainer>
|
||||
<form id="formAv" className="form p-5 bg-white shadow-md rounded-lg p-8 pr-12" onSubmit={handleSubmit}>
|
||||
<h3 className="text-xl font-semibold mb-5 text-gray-800 border-b pb-2">
|
||||
{availability.id ? "Редактирай" : "Създай"} Достъпност
|
||||
</h3>
|
||||
|
||||
<LocalizationProvider dateAdapter={AdapterDateFns} localeText={bgBG} adapterLocale={bg}>
|
||||
<div className="mb-4">
|
||||
<DatePicker label="Изберете дата" value={availability.startTime} onChange={(value) => setAvailability({ ...availability, endTime: value })} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{config?.checkboxUI && config.checkboxUI.enabled ? (
|
||||
<div className="mb-4">
|
||||
{/* Time slot checkboxes */}
|
||||
<TimeSlotCheckboxes slots={timeSlots} setSlots={setTimeSlots} item={availability} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Start Time Picker */}
|
||||
<div className="mb-4">
|
||||
<MobileTimePicker label="От" minutesStep={15} value={availability.startTime} minTime={minTime} maxTime={maxTime}
|
||||
onChange={(value) => setAvailability({ ...availability, startTime: value })} />
|
||||
</div>
|
||||
|
||||
{/* End Time Picker */}
|
||||
<div className="mb-4">
|
||||
<MobileTimePicker label="До" minutesStep={15} value={availability.endTime} minTime={minTime} maxTime={maxTime}
|
||||
onChange={(value) => setAvailability({ ...availability, endTime: value })} />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="checkbox-container">
|
||||
<input type="checkbox" checked={availability.isWithTransport} className="form-checkbox h-5 w-5 text-gray-600 mx-2"
|
||||
onChange={() => setAvailability({ ...availability, isWithTransport: !availability.isWithTransport })} />
|
||||
мога да взема/върна количките
|
||||
<span className="checkmark"></span>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="checkbox-container">
|
||||
<input type="checkbox" checked={availability.repeatWeekly} className="form-checkbox h-5 w-5 text-gray-600 mx-2"
|
||||
onChange={() => setAvailability({ ...availability, repeatWeekly: !availability.repeatWeekly })} />
|
||||
Повтаряй всяка {' '}
|
||||
{/* {availability.repeatWeekly && (
|
||||
<select
|
||||
style={{
|
||||
appearance: 'none',
|
||||
MozAppearance: 'none',
|
||||
WebkitAppearance: 'none',
|
||||
border: 'black solid 1px',
|
||||
background: 'transparent',
|
||||
padding: '0 4px',
|
||||
margin: '0 2px',
|
||||
height: 'auto',
|
||||
fontSize: '16px', // Adjust to match surrounding text
|
||||
textAlign: 'center',
|
||||
color: 'inherit',
|
||||
}}
|
||||
// className="appearance-none border border-black bg-transparent px-1 py-0 mx-0 mr-1 h-auto text-base text-center text-current align-middle cursor-pointer"
|
||||
|
||||
//className="form-select mx-2 h-8 text-gray-600"
|
||||
value={availability.repeatFrequency || 1}
|
||||
onChange={(e) => setAvailability({ ...availability, repeatFrequency: parseInt(e.target.value, 10) })}
|
||||
>
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
<option value="4">4</option>
|
||||
</select>
|
||||
)} */}
|
||||
седмица
|
||||
<span className="checkmark"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{availability.repeatWeekly && (
|
||||
|
||||
<div className="mb-4">
|
||||
<DatePicker label="До" value={availability.endDate} onChange={(value) => setAvailability({ ...availability, endDate: value })} />
|
||||
</div>
|
||||
)}
|
||||
</LocalizationProvider>
|
||||
|
||||
<div className="mb-4 hidden">
|
||||
<div className="form-check">
|
||||
<input className="checkbox form-input" type="checkbox" id="isactive" name="isactive" onChange={handleChange} checked={availability.isactive} autoComplete="off" />
|
||||
<label className="label" htmlFor="isactive">активно</label>
|
||||
</div>
|
||||
</div>
|
||||
{/* <input type="hidden" name="isactive" value={availability.isactive} /> */}
|
||||
|
||||
<div className="panel-actions">
|
||||
<button className="action-button" onClick={() => handleCompletion()}> обратно </button>
|
||||
{availability.id && (
|
||||
<><button className="button bg-red-500 hover:bg-red-700 focus:outline-none focus:shadow-outline" type="button" onClick={handleDelete}>
|
||||
Изтрий
|
||||
</button></>
|
||||
)}
|
||||
<button
|
||||
className="button bg-blue-500 hover:bg-blue-700 focus:outline-none focus:shadow-outline"
|
||||
> {availability.id ? "Обнови" : "Запиши"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
);
|
||||
}
|
||||
|
139
components/availability/AvailabilityList.js
Normal file
139
components/availability/AvailabilityList.js
Normal file
@ -0,0 +1,139 @@
|
||||
import axiosInstance from '../../src/axiosSecure';
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useRouter } from "next/router";
|
||||
import Link from "next/link";
|
||||
import { DayOfWeek } from "../DayOfWeek";
|
||||
const common = require('src/helpers/common');
|
||||
|
||||
import AvailabilityForm from "../availability/AvailabilityForm";
|
||||
|
||||
import { TrashIcon, PencilSquareIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
|
||||
|
||||
export default function AvailabilityList({ publisher, showNew }) {
|
||||
const [showAv, setShowAv] = useState(showNew || false);
|
||||
const [selectedItem, setSelectedItem] = useState(null);
|
||||
const [items, setItems] = useState(publisher.availabilities); // Convert items prop to state
|
||||
|
||||
useEffect(() => {
|
||||
console.log('items set to:', items?.map(item => item.id));
|
||||
}, [items])
|
||||
|
||||
const toggleAv = () => setShowAv(!showAv);
|
||||
const editAvailability = (item) => {
|
||||
setSelectedItem(item);
|
||||
setShowAv(true)
|
||||
};
|
||||
|
||||
const deleteAvailability = async (id) => {
|
||||
try {
|
||||
await axiosInstance.delete("/api/data/availabilities/" + id);
|
||||
// Handle the successful deletion, maybe refresh the list or show a success message
|
||||
const updatedItems = items.filter(item => item.id !== id);
|
||||
setItems(updatedItems);
|
||||
} catch (error) {
|
||||
// Handle the error, maybe show an error message
|
||||
console.error("Error deleting availability:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const renderTable = () => (
|
||||
<table className="min-w-full">
|
||||
<thead className="border-b">
|
||||
<tr>
|
||||
<th scope="col" className="text-sm font-medium text-gray-900 px-6 py-4 text-left">
|
||||
Ден от седмицата (дата)
|
||||
</th>
|
||||
<th scope="col" className="text-sm font-medium text-gray-900 px-6 py-4 text-left">
|
||||
От-до
|
||||
</th>
|
||||
<th scope="col" className="text-sm font-medium text-gray-900 px-6 py-4 text-left">
|
||||
Действия
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items?.sort((a, b) => new Date(a.startTime) - new Date(b.startTime)).map(item => (
|
||||
<tr key={item.id} availability={item} disabled={!item.isactive} >
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{item.dayOfMonth ? `${common.getDateFormated(new Date(item.startTime))}` : `Всеки(Всяка) ${common.getDayOfWeekName(new Date(item.startTime))}`}
|
||||
{/* {common.getDateFormated(new Date(item.startTime))} */}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{common.getTimeRange(new Date(item.startTime), new Date(item.endTime))}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<button className="bg-blue-200 hover:bg-blue-300 text-blue-600 py-1 px-2 rounded inline-flex items-center" onClick={() => editAvailability(item)}>
|
||||
<PencilSquareIcon className="h-6 w-6" />
|
||||
</button>
|
||||
<button className="bg-red-200 hover:bg-red-300 text-red-600 py-1 px-2 rounded ml-2 inline-flex items-center" onClick={() => deleteAvailability(item.id)}>
|
||||
<TrashIcon className="h-6 w-6" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
{items?.length === 0 ? (
|
||||
<h1>No Availabilities</h1>
|
||||
) : renderTable()}
|
||||
|
||||
{<div className="flex justify-center mt-2">
|
||||
<button className="btn bg-blue-500 hover:bg-blue-600 text-white font-semibold py-2 px-4 rounded transition duration-300"
|
||||
onClick={() => { setSelectedItem(null); setShowAv(true) }}>Ново разположение</button>
|
||||
</div>
|
||||
}
|
||||
<div className="h-4 p-10">
|
||||
{showAv && (
|
||||
<AvailabilityForm
|
||||
publisherId={publisher.id}
|
||||
inline={true}
|
||||
existingItem={selectedItem}
|
||||
onDone={(item) => {
|
||||
toggleAv();
|
||||
setSelectedItem(null);
|
||||
if (!item) {
|
||||
// remove selected item from state
|
||||
const updatedItems = items.filter(i => i.id !== selectedItem.id);
|
||||
setItems([...updatedItems]);
|
||||
return;
|
||||
};
|
||||
const itemIndex = items.findIndex(i => i.id === item.id); // assuming each item has a unique 'id' property
|
||||
|
||||
if (itemIndex !== -1) {
|
||||
// Replace the existing item with the updated item
|
||||
const updatedItems = [...items];
|
||||
updatedItems[itemIndex] = item;
|
||||
setItems(updatedItems);
|
||||
} else {
|
||||
// Append the new item to the end of the list
|
||||
setItems([...items, item]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
}
|
||||
|
||||
export const getServerSideProps = async () => {
|
||||
const { data: items } = await axiosInstance.get(
|
||||
common.getBaseUrl("/api/data/availabilities")
|
||||
);
|
||||
|
||||
return {
|
||||
props: {
|
||||
items,
|
||||
},
|
||||
};
|
||||
};
|
100
components/board/GoogleDriveFolderPreview.js
Normal file
100
components/board/GoogleDriveFolderPreview.js
Normal file
@ -0,0 +1,100 @@
|
||||
// import React, { useState, useEffect } from 'react';
|
||||
// //import gapi from 'gapi';
|
||||
|
||||
// // // import { Document, Page } from 'react-pdf';
|
||||
|
||||
|
||||
// // const CLIENT_ID = '926212607479-d3m8hm8f8esp3rf1639prskn445sa01v.apps.googleusercontent.com';
|
||||
// // const API_KEY = 'AIzaSyBUtqjxvCLv2GVcVFEPVym7vRtVW-qP4Jw';
|
||||
// // const DISCOVERY_DOCS = ["https://www.googleapis.com/discovery/v1/apis/drive/v3/rest"];
|
||||
// // const SCOPES = 'https://www.googleapis.com/auth/drive.readonly';
|
||||
|
||||
|
||||
const GoogleDriveFolderPreview = ({ folderId }) => {
|
||||
// const folderUrl = `https://drive.google.com/drive/folders/${folderId}`;
|
||||
// const [files, setFiles] = useState([]);
|
||||
|
||||
// useEffect(() =>
|
||||
// fetch(folderUrl)
|
||||
// .then(res => res.text())
|
||||
// .then(text => {
|
||||
// const parser = new DOMParser();
|
||||
// const htmlDocument = parser.parseFromString(text, 'text/html');
|
||||
// const fileElements = htmlDocument.querySelectorAll('.Q5txwe');
|
||||
// const files = Array.from(fileElements).map(fileElement => {
|
||||
// const fileUrl = fileElement.getAttribute('href');
|
||||
// const fileName = fileElement.querySelector('.Q5txwe').textContent;
|
||||
// return { fileUrl, fileName };
|
||||
// });
|
||||
// setFiles(files);
|
||||
// })
|
||||
// , [folderUrl]);
|
||||
|
||||
// return (
|
||||
// <div className="grid grid-cols-3 gap-4">
|
||||
// {files.map(file => (
|
||||
// <div key={file.fileUrl} className="pdf-preview">
|
||||
// <iframe src={`https://drive.google.com/file/d/${file.fileUrl}/preview`} width="640" height="480"></iframe>
|
||||
// </div>
|
||||
// ))}
|
||||
// </div>
|
||||
// );
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// // useEffect(() => {
|
||||
// // // Initialize the Google API client library
|
||||
// // gapi.load('client:auth2', () => {
|
||||
// // gapi.client.init({
|
||||
// // apiKey: API_KEY,
|
||||
// // clientId: CLIENT_ID,
|
||||
// // discoveryDocs: DISCOVERY_DOCS,
|
||||
// // scope: SCOPES
|
||||
// // }).then(() => {
|
||||
// // // Listen for sign-in state changes and handle the signed-in state
|
||||
// // gapi.auth2.getAuthInstance().isSignedIn.listen(updateSigninStatus);
|
||||
// // updateSigninStatus(gapi.auth2.getAuthInstance().isSignedIn.get());
|
||||
// // });
|
||||
// // });
|
||||
|
||||
// // const updateSigninStatus = (isSignedIn) => {
|
||||
// // if (isSignedIn) {
|
||||
// // fetchFiles();
|
||||
// // } else {
|
||||
// // gapi.auth2.getAuthInstance().signIn();
|
||||
// // }
|
||||
// // };
|
||||
|
||||
// // const fetchFiles = async () => {
|
||||
// // const response = await gapi.client.drive.files.list({
|
||||
// // q: `'${folderId}' in parents and mimeType = 'application/pdf'`,
|
||||
// // fields: 'files(id, name, webContentLink)'
|
||||
// // });
|
||||
// // setFiles(response.result.files);
|
||||
// // };
|
||||
// // }, [folderId]);
|
||||
|
||||
|
||||
// // const PdfDocument = ({ file }) => {
|
||||
// // return (
|
||||
// // <Document file={file}>
|
||||
// // <Page pageNumber={1} />
|
||||
// // {/* Render other pages as needed */}
|
||||
// // </Document>
|
||||
// // );
|
||||
// // };
|
||||
|
||||
// // return (
|
||||
// // <div className="grid grid-cols-3 gap-4">
|
||||
// // {files.map(file => (
|
||||
// // <div key={file.id} className="pdf-preview">
|
||||
// // <PdfDocument fileUrl={file.url} />
|
||||
// // </div>
|
||||
// // ))}
|
||||
// // </div>
|
||||
// // );
|
||||
};
|
||||
|
||||
export default GoogleDriveFolderPreview;
|
240
components/calendar/ShiftComponent.tsx
Normal file
240
components/calendar/ShiftComponent.tsx
Normal file
@ -0,0 +1,240 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axiosInstance from '../../src/axiosSecure';
|
||||
import PublisherSearchBox from '../publisher/PublisherSearchBox'; // Update the path
|
||||
|
||||
const common = require('src/helpers/common');
|
||||
|
||||
|
||||
interface ModalProps {
|
||||
children: React.ReactNode;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
forDate: Date;
|
||||
useFilterDate: boolean;
|
||||
onUseFilterDateChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
function Modal({ children, isOpen, onClose, forDate, useFilterDate, onUseFilterDateChange }: ModalProps) {
|
||||
if (!isOpen) return null;
|
||||
const isValidDate = forDate instanceof Date && !isNaN(forDate.getTime());
|
||||
console.log("forDate", forDate, isValidDate);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-50">
|
||||
<div className="bg-white p-4 rounded-md shadow-lg modal-content">
|
||||
{isValidDate && (
|
||||
<h2 className="text-xl font-bold mb-2">
|
||||
<label className="cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useFilterDate}
|
||||
onChange={(e) => onUseFilterDateChange(e.target.checked)}
|
||||
/>
|
||||
{` на разположение ${common.getDateFormated(forDate)} или ${common.getDayOfWeekName(forDate)}`}
|
||||
</label>
|
||||
</h2>
|
||||
)}
|
||||
{children}
|
||||
<button type="button" onClick={onClose} className="mt-4 text-red-500">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div className="fixed inset-0 bg-black opacity-50 modal-overlay" onClick={onClose}></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function ShiftComponent({ shift, onShiftSelect, isSelected, onPublisherSelect, allPublishersInfo }) {
|
||||
|
||||
const [assignments, setAssignments] = useState(shift.assignments);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [useFilterDate, setUseFilterDate] = useState(true);
|
||||
const [selectedPublisher, setSelectedPublisher] = useState(null);
|
||||
const [showCopyHint, setShowCopyHint] = useState(false);
|
||||
|
||||
// Update assignments when shift changes
|
||||
useEffect(() => {
|
||||
setAssignments(shift.assignments);
|
||||
}, [shift.assignments]);
|
||||
|
||||
const handleShiftClick = (shiftId) => {
|
||||
// console.log("onShiftSelect prop:", onShiftSelect);
|
||||
// console.log("Shift clicked:", shift);
|
||||
//shift.selectedPublisher = selectedPublisher;
|
||||
if (onShiftSelect) {
|
||||
onShiftSelect(shift);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublisherClick = (publisher) => {
|
||||
|
||||
//toggle selected
|
||||
// if (selectedPublisher != null) {
|
||||
// setSelectedPublisher(null);
|
||||
// }
|
||||
// else {
|
||||
setSelectedPublisher(publisher);
|
||||
|
||||
|
||||
console.log("Publisher clicked:", publisher, "selected publisher:", selectedPublisher);
|
||||
shift.selectedPublisher = publisher;
|
||||
if (onShiftSelect) {
|
||||
onShiftSelect(shift);
|
||||
}
|
||||
// if (onPublisherSelect) {
|
||||
// onPublisherSelect(publisher);
|
||||
// }
|
||||
}
|
||||
|
||||
const removeAssignment = async (id) => {
|
||||
try {
|
||||
console.log("Removing assignment with id:", id);
|
||||
await axiosInstance.delete("/api/data/assignments/" + id);
|
||||
setAssignments(prevAssignments => prevAssignments.filter(ass => ass.id !== id));
|
||||
} catch (error) {
|
||||
console.error("Error removing assignment:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const addAssignment = async (publisher, shiftId) => {
|
||||
try {
|
||||
console.log(`new assignment for publisher ${publisher.id} - ${publisher.firstName} ${publisher.lastName}`);
|
||||
const newAssignment = {
|
||||
publisher: { connect: { id: publisher.id } },
|
||||
shift: { connect: { id: shiftId } },
|
||||
isactive: true,
|
||||
isConfirmed: true
|
||||
};
|
||||
const { data } = await axiosInstance.post("/api/data/assignments", newAssignment);
|
||||
// Update the 'publisher' property of the returned data with the full publisher object
|
||||
data.publisher = publisher;
|
||||
setAssignments(prevAssignments => [...prevAssignments, data]);
|
||||
} catch (error) {
|
||||
console.error("Error adding assignment:", error);
|
||||
}
|
||||
};
|
||||
const copyAllPublisherNames = () => {
|
||||
const names = assignments.map(ass => `${ass.publisher.firstName} ${ass.publisher.lastName}`).join(', ');
|
||||
common.copyToClipboard(null, names);
|
||||
// Show hint and set a timer to hide it
|
||||
setShowCopyHint(true);
|
||||
setTimeout(() => setShowCopyHint(false), 1500);
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className={`flow w-full p-4 py-2 border-2 border-gray-300 rounded-md my-1 ${isSelected ? 'bg-gray-200' : ''}`}
|
||||
onClick={handleShiftClick} onDoubleClick={copyAllPublisherNames}>
|
||||
{/* Time Window Header */}
|
||||
<div className="flex justify-between items-center mb-2 border-b pb-1">
|
||||
<span className="text-lg font-semibold">
|
||||
{`${common.getTimeRange(new Date(shift.startTime), new Date(shift.endTime))}`}
|
||||
</span>
|
||||
|
||||
{/* Copy All Names Button */}
|
||||
<button onClick={copyAllPublisherNames} className="bg-green-500 text-white py-1 px-2 text-sm rounded-md">
|
||||
копирай имената {/* Placeholder for Copy icon */}
|
||||
</button>
|
||||
{/* Hint Message */}
|
||||
{showCopyHint && (
|
||||
<div className="absolute top-0 right-0 p-2 bg-green-200 text-green-800 rounded">
|
||||
Имената са копирани
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Assignments */}
|
||||
{assignments.map((ass, index) => {
|
||||
const publisherInfo = allPublishersInfo.find(info => info?.id === ass.publisher.id) || ass.publisher;
|
||||
|
||||
// Determine border styles
|
||||
let borderStyles = '';
|
||||
//if there is no publisherInfo - draw red border - publisher is no longer available for the day!
|
||||
if (!publisherInfo.availabilities || publisherInfo.availabilities.length == 0) {
|
||||
borderStyles = 'border-2 border-red-500 ';
|
||||
}
|
||||
else {
|
||||
//pub is not available for that shift assignment.
|
||||
if (publisherInfo.availabilities?.length === 0 ||
|
||||
publisherInfo.availabilities?.every(avail => avail.isFromPreviousAssignment)) {
|
||||
borderStyles += 'border-l-3 border-r-3 border-orange-500 '; // Top border for manual publishers
|
||||
}
|
||||
// checkig if the publisher is available for this assignment
|
||||
if (publisherInfo.availabilities?.some(av =>
|
||||
av.startTime <= ass.startTime &&
|
||||
av.endTime >= ass.endTime)) {
|
||||
borderStyles += 'border-t-2 border-red-500 '; // Left border for specific availability conditions
|
||||
}
|
||||
|
||||
//the pub is the same time as last month
|
||||
// if (publisherInfo.availabilities?.some(av =>
|
||||
// (!av.dayOfMonth || av.isFromPreviousMonth) &&
|
||||
// av.startTime <= ass.startTime &&
|
||||
// av.endTime >= ass.endTime)) {
|
||||
// borderStyles += 'border-t-2 border-yellow-500 '; // Left border for specific availability conditions
|
||||
// }
|
||||
if (selectedPublisher && selectedPublisher.id === ass.publisher.id) {
|
||||
borderStyles += 'border-2 border-blue-300'; // Bottom border for selected publishers
|
||||
}
|
||||
if (publisherInfo.hasUpToDateAvailabilities) {
|
||||
//add green right border
|
||||
borderStyles += 'border-r-2 border-green-300';
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div key={index}
|
||||
className={`flow space-x-2 rounded-md px-2 py-1 my-1 ${ass.isConfirmed ? 'bg-yellow-100' : 'bg-gray-100'} ${borderStyles}`}
|
||||
>
|
||||
<div className="flex justify-between items-center" onClick={() => handlePublisherClick(ass.publisher)}>
|
||||
<span className="text-gray-700">{publisherInfo.firstName} {publisherInfo.lastName}</span>
|
||||
<button onClick={() => removeAssignment(ass.id)}
|
||||
className="text-white bg-red-500 hover:bg-red-600 px-3 py-1 ml-2 rounded-md"
|
||||
>
|
||||
махни
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
|
||||
{/* This is a placeholder for the dropdown to add a publisher. You'll need to implement or integrate a dropdown component */}
|
||||
|
||||
<div className="flex space-x-2 items-center">
|
||||
{/* Add Button */}
|
||||
<button onClick={() => setIsModalOpen(true)} className="bg-blue-500 text-white p-2 py-1 rounded-md">
|
||||
добави {/* Placeholder for Add icon */}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal for Publisher Search
|
||||
forDate={new Date(shift.startTime)}
|
||||
*/}
|
||||
<Modal isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
forDate={new Date(shift.startTime)}
|
||||
useFilterDate={useFilterDate}
|
||||
onUseFilterDateChange={(value) => setUseFilterDate(value)}>
|
||||
|
||||
<PublisherSearchBox
|
||||
selectedId={null}
|
||||
isFocused={isModalOpen}
|
||||
filterDate={useFilterDate ? new Date(shift.startTime) : null}
|
||||
onChange={(publisher) => {
|
||||
// Add publisher as assignment logic
|
||||
setIsModalOpen(false);
|
||||
addAssignment(publisher, shift.id);
|
||||
}}
|
||||
showAllAuto={true}
|
||||
showSearch={true}
|
||||
showList={false}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ShiftComponent;
|
478
components/calendar/avcalendar.tsx
Normal file
478
components/calendar/avcalendar.tsx
Normal file
@ -0,0 +1,478 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Calendar, momentLocalizer, dateFnsLocalizer } from 'react-big-calendar';
|
||||
import 'react-big-calendar/lib/css/react-big-calendar.css';
|
||||
import AvailabilityForm from '../availability/AvailabilityForm';
|
||||
import common from '../../src/helpers/common';
|
||||
|
||||
import { toast } from 'react-toastify';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import moment from 'moment';
|
||||
import 'moment/locale/bg'; // Import Bulgarian locale
|
||||
import { ArrowLeftCircleIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
import { FaArrowLeft, FaArrowRight, FaRegCalendarAlt, FaRegListAlt, FaRegCalendarCheck } from 'react-icons/fa';
|
||||
import { MdToday } from 'react-icons/md';
|
||||
|
||||
import { useSwipeable } from 'react-swipeable';
|
||||
import axiosInstance from '../../src/axiosSecure';
|
||||
|
||||
// Set moment to use the Bulgarian locale
|
||||
moment.locale('bg');
|
||||
const localizer = momentLocalizer(moment);
|
||||
// Bulgarian translations for Calendar labels
|
||||
const messages = {
|
||||
allDay: 'Цял ден',
|
||||
previous: 'Предишен',
|
||||
next: 'Следващ',
|
||||
today: 'Днес',
|
||||
month: 'Месец',
|
||||
week: 'Седмица',
|
||||
day: 'Ден',
|
||||
agenda: 'Дневен ред',
|
||||
date: 'Дата',
|
||||
time: 'Час',
|
||||
event: 'Събитие', // or 'Събитие' depending on context
|
||||
// Any other labels you want to translate...
|
||||
};
|
||||
|
||||
const AvCalendar = ({ publisherId, events, selectedDate }) => {
|
||||
|
||||
const [date, setDate] = useState(new Date());
|
||||
const [currentView, setCurrentView] = useState('month');
|
||||
const [evts, setEvents] = useState(events); // Existing events
|
||||
const [displayedEvents, setDisplayedEvents] = useState(evts); // Events to display in the calendar
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedEvent, setSelectedEvent] = useState(null);
|
||||
const [visibleRange, setVisibleRange] = useState(() => {
|
||||
const start = new Date();
|
||||
start.setDate(1); // Set to the first day of the current month
|
||||
const end = new Date(start.getFullYear(), start.getMonth() + 1, 0); // Last day of the current month
|
||||
return { start, end };
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Update internal state when `events` prop changes
|
||||
useEffect(() => {
|
||||
setEvents(events);
|
||||
// Call any function here to process and set displayedEvents
|
||||
// based on the new events, if necessary
|
||||
}, [events]);
|
||||
|
||||
const onRangeChange = (range) => {
|
||||
if (Array.isArray(range)) {
|
||||
// For week and day views, range is an array of dates
|
||||
setVisibleRange({ start: range[0], end: range[range.length - 1] });
|
||||
} else {
|
||||
// For month view, range is an object with start and end
|
||||
setVisibleRange(range);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (currentView === 'agenda') {
|
||||
const filtered = evts?.filter(event => event.type === "assignment");
|
||||
setDisplayedEvents(filtered);
|
||||
} else {
|
||||
// Function to generate weekly occurrences of an event
|
||||
const recurringEvents = evts?.filter(event => event.type !== "assignment" && (event.dayOfMonth === null || event.dayOfMonth === undefined)) || [];
|
||||
const occurrences = recurringEvents?.flatMap(event => generateOccurrences(event, visibleRange.start, visibleRange.end)) || [];
|
||||
const nonRecurringEvents = evts?.filter(event => event.dayOfMonth !== null) || [];
|
||||
|
||||
setDisplayedEvents([...nonRecurringEvents, ...recurringEvents, ...occurrences]);
|
||||
}
|
||||
//setDisplayedEvents(evts);
|
||||
}, [visibleRange, evts, currentView]);
|
||||
|
||||
const handlers = useSwipeable({
|
||||
onSwipedLeft: () => navigate('NEXT'),
|
||||
onSwipedRight: () => navigate('PREV'),
|
||||
preventDefaultTouchmoveEvent: true,
|
||||
trackMouse: true,
|
||||
});
|
||||
const navigate = (action) => {
|
||||
console.log('navigate', action);
|
||||
setDate((currentDate) => {
|
||||
const newDate = new Date(currentDate);
|
||||
if (action === 'NEXT') {
|
||||
newDate.setMonth(newDate.getMonth() + 1);
|
||||
} else if (action === 'PREV') {
|
||||
newDate.setMonth(newDate.getMonth() - 1);
|
||||
}
|
||||
return newDate;
|
||||
});
|
||||
};
|
||||
|
||||
const generateOccurrences = (event, start, end) => {
|
||||
const occurrences = [];
|
||||
const eventStart = new Date(event.startTime);
|
||||
let current = new Date(event.startTime); // Assuming startTime has the start date
|
||||
|
||||
// Determine the end date for the event series
|
||||
const seriesEndDate = event.endDate ? new Date(event.endDate) : end;
|
||||
seriesEndDate.setHours(23, 59, 59); // Set to the end of the day
|
||||
|
||||
while (current <= seriesEndDate && current <= end) {
|
||||
// Check if the event should be repeated weekly or on a specific day of the month
|
||||
if (event.repeatWeekly && current.getDay() === eventStart.getDay()) {
|
||||
// For weekly recurring events
|
||||
addOccurrence(event, current, occurrences);
|
||||
} else if (event.dayOfMonth && current.getDate() === event.dayOfMonth) {
|
||||
// For specific day of month events
|
||||
addOccurrence(event, current, occurrences);
|
||||
}
|
||||
|
||||
// Move to the next day
|
||||
current = new Date(current.setDate(current.getDate() + 1));
|
||||
}
|
||||
|
||||
return occurrences;
|
||||
};
|
||||
|
||||
// Helper function to add an occurrence
|
||||
const addOccurrence = (event, current, occurrences) => {
|
||||
// Skip the original event date
|
||||
|
||||
const eventStart = new Date(event.startTime);
|
||||
const eventEnd = new Date(event.endTime);
|
||||
if (current.toDateString() !== eventStart.toDateString()) {
|
||||
const occurrence = {
|
||||
...event,
|
||||
startTime: new Date(current.setHours(eventStart.getHours(), eventStart.getMinutes())),
|
||||
endTime: new Date(current.setHours(eventEnd.getHours(), eventEnd.getMinutes())),
|
||||
date: current,
|
||||
type: 'recurring'
|
||||
};
|
||||
occurrences.push(occurrence);
|
||||
}
|
||||
};
|
||||
|
||||
// Define min and max times
|
||||
const minHour = 8; // 8:00 AM
|
||||
const maxHour = 20; // 8:00 PM
|
||||
const minTime = new Date();
|
||||
minTime.setHours(minHour, 0, 0);
|
||||
const maxTime = new Date();
|
||||
maxTime.setHours(maxHour, 0, 0);
|
||||
const totalHours = maxHour - minHour;
|
||||
|
||||
const handleSelect = ({ start, end }) => {
|
||||
if (!start || !end) return;
|
||||
if (start < new Date() || end < new Date() || start > end) return;
|
||||
|
||||
// Check if start and end are on the same day
|
||||
if (start.toDateString() !== end.toDateString()) {
|
||||
end = common.setTimeHHmm(start, "23:59");
|
||||
}
|
||||
|
||||
const startMinutes = common.getTimeInMinutes(start);
|
||||
const endMinutes = common.getTimeInMinutes(end);
|
||||
|
||||
// Adjust start and end times to be within min and max hours
|
||||
if (startMinutes < common.getTimeInMinutes(common.setTimeHHmm(start, minHour))) {
|
||||
start = common.setTimeHHmm(start, minHour);
|
||||
}
|
||||
if (endMinutes > common.getTimeInMinutes(common.setTimeHHmm(end, maxHour))) {
|
||||
end = common.setTimeHHmm(end, maxHour);
|
||||
}
|
||||
|
||||
setDate(start);
|
||||
|
||||
// get exising events for the selected date
|
||||
const existingEvents = evts?.filter(event => event.publisherId === publisherId && event.startTime === start.toDateString());
|
||||
|
||||
setSelectedEvent({
|
||||
date: start,
|
||||
startTime: start,
|
||||
endTime: end,
|
||||
dayOfMonth: start.getDate(),
|
||||
isactive: true,
|
||||
publisherId: publisherId,
|
||||
// Add any other initial values needed
|
||||
//set dayOfMonth to null, so that we repeat the availability every week
|
||||
dayOfMonth: null,
|
||||
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEventClick = (event) => {
|
||||
if (event.type === "assignment") return;
|
||||
// Handle event click
|
||||
const eventForEditing = {
|
||||
...event,
|
||||
startTime: new Date(event.startTime),
|
||||
endTime: new Date(event.endTime),
|
||||
publisherId: event.publisherId || event.publisher?.connect?.id,
|
||||
repeatWeekly: event.repeatWeekly || false,
|
||||
};
|
||||
//strip title, start, end and allDay properties
|
||||
delete eventForEditing.title;
|
||||
delete eventForEditing.start;
|
||||
delete eventForEditing.end;
|
||||
delete eventForEditing.type;
|
||||
delete eventForEditing.publisher
|
||||
console.log("handleEventClick: " + eventForEditing);
|
||||
setSelectedEvent(eventForEditing);
|
||||
setIsModalOpen(true);
|
||||
|
||||
};
|
||||
|
||||
const handleDialogClose = async (dialogEvent) => {
|
||||
setIsModalOpen(false);
|
||||
if (dialogEvent === null || dialogEvent === undefined) {
|
||||
} else {
|
||||
|
||||
// if (dialogEvent.deleted) {
|
||||
// // Remove the old event from the calendar
|
||||
// setEvents(currentEvents => currentEvents.filter(e => e.id !== selectedEvent.id));
|
||||
// }
|
||||
// else {
|
||||
// // Update the event data
|
||||
// dialogEvent.start = dialogEvent.startTime;
|
||||
// dialogEvent.end = dialogEvent.endTime;
|
||||
// // Update the events array by first removing the old event and then adding the updated one
|
||||
// setEvents(currentEvents => {
|
||||
// const filteredEvents = currentEvents?.filter(e => e.id !== selectedEvent.id) || [];
|
||||
// return [...filteredEvents, dialogEvent];
|
||||
// });
|
||||
// }
|
||||
//refresh the events from the server
|
||||
let events = await axiosInstance.get(`/api/?action=getCalendarEvents&publisherId=${publisherId}`);
|
||||
var newEvents = events.data;
|
||||
setEvents(newEvents);
|
||||
|
||||
}
|
||||
|
||||
console.log("handleSave: ", dialogEvent);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
const EventWrapper = ({ event, style }) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
let eventStyle = {
|
||||
...style
|
||||
};
|
||||
const handleMouseEnter = () => setIsHovered(true);
|
||||
const handleMouseLeave = () => setIsHovered(false);
|
||||
if (currentView !== 'agenda') {
|
||||
//if event.type is availability show in blue. if it is schedule - green if confirmed, yellow if not confirmed
|
||||
//if event is not active - show in gray
|
||||
let bgColorClass = 'bg-gray-500'; // Default color for inactive events
|
||||
var bgColor = event.isactive ? "" : "bg-gray-500";
|
||||
if (event.type === "assignment") {
|
||||
bgColor = event.isConfirmed ? "bg-green-500" : "bg-yellow-500";
|
||||
//event.title = event.publisher.name; //ToDo: add other publishers names
|
||||
//event.title = common.getTimeFomatted(event.startTime) + " - " + common.getTimeFomatted(event.endTime);
|
||||
} else {
|
||||
if (event.start !== undefined && event.end !== undefined && event.startTime !== null && event.endTime !== null) {
|
||||
try {
|
||||
if (event.type === "recurring") {
|
||||
//bgColor = "bg-blue-300";
|
||||
event.title = common.getTimeFomatted(event.startTime) + " - " + common.getTimeFomatted(event.endTime);
|
||||
}
|
||||
else {
|
||||
event.title = common.getTimeFomatted(event.startTime) + " - " + common.getTimeFomatted(event.endTime);
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
event.title = event.startTime + " - " + event.endTime;
|
||||
console.log("Error in EventWrapper: " + err);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
eventStyle = {
|
||||
...style,
|
||||
// backgroundColor: bgColorClass,
|
||||
//height: "50px",
|
||||
//color: 'white',
|
||||
whiteSpace: 'normal', // Allow the text to wrap to the next line
|
||||
overflow: 'hidden', // Hide overflowed content
|
||||
textOverflow: 'ellipsis' // Add ellipsis to text that's too long to fit
|
||||
};
|
||||
|
||||
|
||||
}
|
||||
const onDelete = (event) => {
|
||||
// Remove the event from the calendar
|
||||
setEvents(currentEvents => currentEvents.filter(e => e.id !== event.id));
|
||||
};
|
||||
|
||||
const onConfirm = (event) => {
|
||||
console.log("onConfirm: " + event.id);
|
||||
toast.info("Вие потвърдихте!", { autoClose: 2000 });
|
||||
// Update the event data
|
||||
event.isConfirmed = true;
|
||||
event.isactive = false;
|
||||
// Update the events array by first removing the old event and then adding the updated one
|
||||
setEvents(currentEvents => {
|
||||
const filteredEvents = currentEvents.filter(e => e.id !== event.id);
|
||||
return [...filteredEvents, event];
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div style={eventStyle} className={bgColor + " relative"}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave} >
|
||||
{event.title}
|
||||
{isHovered && event.type == "assignment" && (event.status == "pending" || event.status == undefined)
|
||||
&& (
|
||||
<div className="absolute top-1 left-0 right-0 flex justify-between px-1">
|
||||
{/* Delete Icon */}
|
||||
{/* <span
|
||||
className="disabled cursor-pointer rounded-full bg-red-500 text-white flex items-center justify-center"
|
||||
style={{ width: '24px', height: '24px' }} // Adjust the size as needed
|
||||
onClick={() => onDelete(event)}
|
||||
>
|
||||
✕
|
||||
</span> */}
|
||||
|
||||
{/* Confirm Icon */}
|
||||
{!event.isConfirmed && (
|
||||
<span
|
||||
className=" cursor-pointer rounded-full bg-green-500 text-white flex items-center justify-center"
|
||||
style={{ width: '24px', height: '24px' }} // Adjust the size as needed
|
||||
onClick={() => onConfirm(event)}
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const eventStyleGetter = (event, start, end, isSelected) => {
|
||||
|
||||
//console.log("eventStyleGetter: " + event);
|
||||
let backgroundColor = '#3174ad'; // default color for calendar events - #3174ad
|
||||
if (currentView === 'agenda') {
|
||||
return { style: {} }
|
||||
}
|
||||
if (event.type === "assignment") {
|
||||
//event.title = event.publisher.name; //ToDo: add other publishers names
|
||||
}
|
||||
if (event.type === "availability") {
|
||||
|
||||
}
|
||||
if (event.isFromPreviousAssignment) { //ToDo: does it work?
|
||||
// orange-500 from Tailwind CSS
|
||||
backgroundColor = '#f56565';
|
||||
}
|
||||
if (event.isactive) {
|
||||
switch (event.type) {
|
||||
case 'assignment':
|
||||
backgroundColor = event.isConfirmed ? '#48bb78' : '#f6e05e'; // green-500 and yellow-300 from Tailwind CSS
|
||||
break;
|
||||
case 'recurring':
|
||||
backgroundColor = '#63b3ed'; // blue-300 from Tailwind CSS
|
||||
break;
|
||||
default: // availability
|
||||
//backgroundColor = '#a0aec0'; // gray-400 from Tailwind CSS
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
backgroundColor = '#a0aec0'; // Default color for inactive events
|
||||
}
|
||||
|
||||
return {
|
||||
style: {
|
||||
backgroundColor,
|
||||
opacity: 0.8,
|
||||
color: 'white',
|
||||
border: '0px',
|
||||
display: 'block',
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Custom Toolbar Component
|
||||
|
||||
const CustomToolbar = ({ onNavigate, label, onView, view }) => {
|
||||
return (
|
||||
<div className="rbc-toolbar">
|
||||
<span className="rbc-btn-group">
|
||||
<button type="button" onClick={() => onNavigate('PREV')}>
|
||||
<FaArrowLeft className="icon-large" />
|
||||
</button>
|
||||
<button type="button" onClick={() => onNavigate('TODAY')}>
|
||||
<MdToday className="icon-large" />
|
||||
</button>
|
||||
<button type="button" onClick={() => onNavigate('NEXT')}>
|
||||
<FaArrowRight className="icon-large" />
|
||||
</button>
|
||||
</span>
|
||||
<span className="rbc-toolbar-label">{label}</span>
|
||||
<span className="rbc-btn-group">
|
||||
<button type="button" onClick={() => onView('month')} className={view === 'month' ? 'rbc-active' : ''}>
|
||||
<FaRegCalendarAlt className="icon-large" />
|
||||
</button>
|
||||
<button type="button" onClick={() => onView('week')} className={view === 'week' ? 'rbc-active' : ''}>
|
||||
<FaRegListAlt className="icon-large" />
|
||||
</button>
|
||||
<button type="button" onClick={() => onView('agenda')} className={view === 'agenda' ? 'rbc-active' : ''}>
|
||||
<FaRegCalendarCheck className="icon-large" />
|
||||
</button>
|
||||
{/* Add more view buttons as needed */}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<> <div {...handlers} className="flex flex-col"
|
||||
>
|
||||
{/* достъпности на {publisherId} */}
|
||||
<ToastContainer position="top-center" style={{ zIndex: 9999 }} />
|
||||
</div>
|
||||
<Calendar
|
||||
localizer={localizer}
|
||||
events={displayedEvents}
|
||||
startAccessor="startTime"
|
||||
endAccessor="endTime"
|
||||
selectable={true}
|
||||
onSelectSlot={handleSelect}
|
||||
onSelectEvent={handleEventClick}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
min={minTime} // Set minimum time
|
||||
max={maxTime} // Set maximum time
|
||||
messages={messages}
|
||||
view={currentView}
|
||||
views={['month', 'week', 'agenda']}
|
||||
onView={view => setCurrentView(view)}
|
||||
onRangeChange={onRangeChange}
|
||||
components={{
|
||||
event: EventWrapper,
|
||||
toolbar: CustomToolbar,
|
||||
// ... other custom components
|
||||
}}
|
||||
eventPropGetter={(eventStyleGetter)}
|
||||
date={date}
|
||||
onNavigate={setDate}
|
||||
className="rounded-lg shadow-lg"
|
||||
/>
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-50">
|
||||
<div className="modal-content">
|
||||
<AvailabilityForm
|
||||
publisherId={publisherId}
|
||||
existingItem={selectedEvent}
|
||||
onDone={handleDialogClose}
|
||||
inline={true}
|
||||
// Pass other props as needed
|
||||
/>
|
||||
</div>
|
||||
<div className="fixed inset-0 bg-black opacity-50" onClick={handleCancel}></div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AvCalendar;
|
196
components/cartevent/CartEventForm.tsx
Normal file
196
components/cartevent/CartEventForm.tsx
Normal file
@ -0,0 +1,196 @@
|
||||
import { CartEvent } from '@prisma/client';
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import axiosInstance from '../../src/axiosSecure';
|
||||
import DayOfWeek from "../DayOfWeek";
|
||||
import common from 'src/helpers/common';
|
||||
|
||||
/*
|
||||
model CartEvent {
|
||||
id Int @id @default(autoincrement())
|
||||
startTime DateTime
|
||||
endTime DateTime
|
||||
shiftDuration Int
|
||||
shifts Shift[]
|
||||
dayofweek DayOfWeek
|
||||
isactive Boolean @default(true)
|
||||
}*/
|
||||
interface Location {
|
||||
id: number;
|
||||
name: string;
|
||||
|
||||
}
|
||||
interface IProps {
|
||||
item?: CartEvent;
|
||||
locations: Location[];
|
||||
inline?: false;
|
||||
}
|
||||
|
||||
export default function CartEventForm(props: IProps) {
|
||||
const router = useRouter();
|
||||
console.log("init CartEventForm: " + JSON.stringify(props));
|
||||
const urls = {
|
||||
apiUrl: "/api/data/cartevents/",
|
||||
indexUrl: "/cart/cartevents"
|
||||
}
|
||||
|
||||
const [evt, setEvt] = useState(props.item || {
|
||||
id: router.query.id,
|
||||
startTime: "09:00",//8:00
|
||||
endTime: "19:30",//20:00
|
||||
shiftDuration: 90,//120
|
||||
dayofweek: "Monday",
|
||||
});
|
||||
|
||||
//const locations = props?.locations || [];
|
||||
//get locations from api
|
||||
const [locations, setLocations] = useState(props?.locations || []);
|
||||
useEffect(() => {
|
||||
const fetchLocations = async () => {
|
||||
try {
|
||||
console.log("fetching locations");
|
||||
const { data } = await axiosInstance.get("/api/data/locations");
|
||||
setLocations(data);
|
||||
console.log(data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
if (!locations.length) {
|
||||
fetchLocations();
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const fetch = async (id) => {
|
||||
try {
|
||||
console.log("fetching cart event from component " + router.query.id);
|
||||
const { data } = await axiosInstance.get(urls.apiUrl + id);
|
||||
data.startTime = common.formatTimeHHmm(data.startTime)
|
||||
data.endTime = common.formatTimeHHmm(data.endTime)
|
||||
setEvt(data);
|
||||
|
||||
console.log("id:" + evt.id);
|
||||
//console.log(data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
if (!props?.item) {
|
||||
setEvt(prevEvt => ({ ...prevEvt, id: router.query.id as string }));
|
||||
}
|
||||
|
||||
if (evt.id) {
|
||||
fetch(parseInt(evt.id));
|
||||
}
|
||||
|
||||
}, [router.query.id]);
|
||||
|
||||
|
||||
|
||||
const handleChange = ({ target }) => {
|
||||
console.log("CartEventForm.handleChange() " + target.name + " = " + target.value);
|
||||
if (target.type === "checkbox") {
|
||||
setEvt({ ...evt, [target.name]: target.checked });
|
||||
} else if (target.type === "number") {
|
||||
console.log("setting " + target.name + " to " + parseInt(target.value));
|
||||
setEvt({ ...evt, [target.name]: parseInt(target.value) });
|
||||
} else {
|
||||
setEvt({ ...evt, [target.name]: target.value });
|
||||
}
|
||||
|
||||
console.log("CartEventForm.handleChange() " + JSON.stringify(evt));
|
||||
}
|
||||
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const eventId = evt.id;
|
||||
delete evt.id;
|
||||
evt.startTime = common.parseTimeHHmm(evt.startTime.toString());
|
||||
evt.endTime = common.parseTimeHHmm(evt.endTime.toString());
|
||||
|
||||
console.log("calling api @ " + urls.apiUrl + evt.id);
|
||||
console.log(evt);
|
||||
if (eventId) { //update
|
||||
// evt.location = {
|
||||
// id: evt.locationId
|
||||
// };
|
||||
delete evt.locationId;
|
||||
await axiosInstance.put(urls.apiUrl + router.query.id, {
|
||||
...evt,
|
||||
});
|
||||
toast.success("Task Updated", {
|
||||
position: "bottom-center",
|
||||
});
|
||||
} else {
|
||||
|
||||
evt.locationId = parseInt(evt.locationId?.toString());
|
||||
await axiosInstance.post(urls.apiUrl, evt);
|
||||
toast.success("Task Saved", {
|
||||
position: "bottom-center",
|
||||
});
|
||||
}
|
||||
|
||||
// if (props.inline) {
|
||||
// } else {
|
||||
router.push(urls.indexUrl);
|
||||
// }
|
||||
} catch (error) {
|
||||
// toast.error(error.response.data.message);
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-xs mt-5 mx-auto shadow-md shadow-gray-500">
|
||||
<h3 className='p-5 pb-0 text-center text-lg '>{evt.id ? "Edit '" + evt.dayofweek + "'" : "Create"} Cart Event</h3>
|
||||
<form className="form mb-0" onSubmit={handleSubmit}>
|
||||
<label className='label' htmlFor="location">Location</label>
|
||||
{locations && (
|
||||
<select name="locationId" id="locationId" onChange={handleChange} value={evt.locationId}>
|
||||
{locations.map((loc: Location) => (
|
||||
<option key={loc.id} value={loc.id} type="number">{loc.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<label className='label' htmlFor="dayofweek">Day of Week</label>
|
||||
<DayOfWeek onChange={handleChange} selected={evt.dayofweek} />
|
||||
<label className='label' htmlFor="startTime">Start Time</label>
|
||||
<input className="shadow border form-input" type="time" name="startTime" id="startTime" onChange={handleChange} value={evt.startTime} />
|
||||
<label className='label' htmlFor="endTime">End Time</label>
|
||||
<input className="shadow border form-input" type="time" name="endTime" id="endTime" onChange={handleChange} value={evt.endTime} />
|
||||
|
||||
<label className='label' htmlFor="shiftDuration">Shift Duration</label>
|
||||
<input className="shadow border form-input" type="number" name="shiftDuration" id="shiftDuration" onChange={handleChange} value={evt.shiftDuration} />
|
||||
|
||||
<label className='label' htmlFor="numberOfPublishers">Max Shifts</label>
|
||||
<input className="shadow border form-input" type="number" name="numberOfPublishers" id="numberOfPublishers" onChange={handleChange} value={evt.numberOfPublishers} />
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="form-check">
|
||||
<input className="checkbox" type="checkbox" name="isactive" id="isactive" checked={evt.isactive} onChange={handleChange} />
|
||||
<label className='label align-text-bottom' htmlFor="isactive">Active</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="panel-actions">
|
||||
{!props?.inline && <Link href={urls.indexUrl} className="action-button"> Cancel </Link>}
|
||||
{evt.id &&
|
||||
<button className="button bg-red-500 hover:bg-red-700 focus:outline-none focus:shadow-outline" type="button" onClick={async () => {
|
||||
await axiosInstance.delete(urls.apiUrl + router.query.id);
|
||||
router.push(urls.indexUrl);
|
||||
}}>
|
||||
Delete
|
||||
</button>}
|
||||
<button className="button bg-blue-500 hover:bg-blue-700 focus:outline-none focus:shadow-outline" type="submit"> {evt.id ? "Update" : "Create"}</button>
|
||||
</div>
|
||||
|
||||
</form >
|
||||
</div >
|
||||
)
|
||||
}
|
29
components/footer.tsx
Normal file
29
components/footer.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import Link from "next/link"
|
||||
import styles from "../styles/footer.module.css"
|
||||
import packageJSON from "../package.json"
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="pt-10 bg-gray-100 text-gray-600">
|
||||
<hr />
|
||||
|
||||
<ul className={styles.navItems}>
|
||||
<li className={styles.navItem}>
|
||||
<a href="https://next-auth.js.org">Documentation</a>
|
||||
</li>
|
||||
<li className={styles.navItem}>
|
||||
<a href="https://www.npmjs.com/package/next-auth">NPM</a>
|
||||
</li>
|
||||
<li className={styles.navItem}>
|
||||
<a href="https://github.com/nextauthjs/next-auth-example">GitHub</a>
|
||||
</li>
|
||||
<li className={styles.navItem}>
|
||||
<Link href="/policy">Policy</Link>
|
||||
</li>
|
||||
<li className={styles.navItem}>
|
||||
<em>next-auth@{packageJSON.dependencies["next-auth"]}</em>
|
||||
</li>
|
||||
</ul>
|
||||
</footer>
|
||||
)
|
||||
}
|
124
components/header.tsx
Normal file
124
components/header.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
import Link from "next/link"
|
||||
import { signIn, signOut, useSession } from "next-auth/react"
|
||||
import styles from "../styles/header.module.css"
|
||||
|
||||
// The approach used in this component shows how to build a sign in and sign out
|
||||
// component that works on pages which support both client and server side
|
||||
// rendering, and avoids any flash incorrect content on initial page load.
|
||||
export default function Header() {
|
||||
const { data: session, status } = useSession()
|
||||
const loading = status === "loading"
|
||||
|
||||
//generate top header with sign in/out button and dropdown menu and user name/surname using tailwindcss
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<header className="">
|
||||
<noscript>
|
||||
<style>{`.nojs-show { opacity: 1; top: 0; }`}</style>
|
||||
</noscript>
|
||||
{/* <script src="https://cdn.jsdelivr.net/npm/tw-elements/dist/js/index.min.js"></script> */}
|
||||
<div className={styles.signedInStatus}>
|
||||
<p
|
||||
className={`nojs-show ${
|
||||
!session && loading ? styles.loading : styles.loaded
|
||||
}`}
|
||||
>
|
||||
{!session && (
|
||||
<>
|
||||
<span className={styles.notSignedInText}>
|
||||
You are not signed in
|
||||
</span>
|
||||
<a
|
||||
href={`/api/auth/signin`}
|
||||
className={styles.buttonPrimary}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
signIn()
|
||||
}}
|
||||
>
|
||||
Sign in
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{session?.user && (
|
||||
<>
|
||||
{session.user.image && (
|
||||
<span
|
||||
style={{ backgroundImage: `url('${session.user.image}')` }}
|
||||
className={styles.avatar}
|
||||
/>
|
||||
)}
|
||||
<span className={styles.signedInText}>
|
||||
<small>Signed in as</small>
|
||||
<br />
|
||||
<strong>{session.user.email ?? session.user.name}</strong>
|
||||
</span>
|
||||
<a
|
||||
href={`/api/auth/signout`}
|
||||
className={styles.button}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
signOut()
|
||||
}}
|
||||
>
|
||||
Sign out
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<nav className="max-w-7xl mx-auto ">
|
||||
<ul className={styles.navItems}>
|
||||
<li className={styles.navItem}>
|
||||
<Link href="/">Home</Link>
|
||||
</li>
|
||||
<li className={styles.navItem}>
|
||||
<Link href="/client">Client</Link>
|
||||
</li>
|
||||
<li className={styles.navItem}>
|
||||
<Link href="/server">Server</Link>
|
||||
</li>
|
||||
<li className={styles.navItem}>
|
||||
<Link href="/protected">Protected</Link>
|
||||
</li>
|
||||
<li className={styles.navItem}>
|
||||
<Link href="/api-example">API</Link>
|
||||
</li>
|
||||
<li className={styles.navItem}>
|
||||
<Link href="/admin">Admin</Link>
|
||||
</li>
|
||||
<li className={styles.navItem}>
|
||||
<Link href="/me">Me</Link>
|
||||
</li>
|
||||
<li className={styles.navItem}>
|
||||
CART
|
||||
{/* cart submenus */}
|
||||
<ul className={styles.submenu}>
|
||||
<li className={styles.submenuItem}>
|
||||
<Link href="/cart/locations">Locations</Link>
|
||||
</li>
|
||||
<li className={styles.submenuItem}>
|
||||
<Link href="/cart/publishers">Publishers</Link>
|
||||
</li>
|
||||
<li className={styles.submenuItem}>
|
||||
<Link href="/cart/availabilities">Availabilities</Link>
|
||||
</li>
|
||||
<li className={styles.submenuItem}>
|
||||
<Link href="/cart/cartevents">Cart Event</Link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
</li>
|
||||
{/* end cart submenus */}
|
||||
|
||||
<li className={styles.navItem}>
|
||||
<Link href="/cart/calendar">Calendar</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
}
|
64
components/layout.tsx
Normal file
64
components/layout.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import Header from "./header"
|
||||
import Link from 'next/link'
|
||||
import Footer from "./footer"
|
||||
import Sidebar from "./sidebar"
|
||||
import type { ReactNode } from "react"
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useState } from "react";
|
||||
import Body from 'next/document'
|
||||
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
const router = useRouter()
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
||||
|
||||
// auto resize for tablets: disabled.
|
||||
// useEffect(() => {
|
||||
// // Function to check and set the state based on window width
|
||||
// const handleResize = () => {
|
||||
// if (window.innerWidth < 768) { // Assuming 768px as the breakpoint for mobile devices
|
||||
// setIsSidebarOpen(false);
|
||||
// } else {
|
||||
// setIsSidebarOpen(true);
|
||||
// }
|
||||
// };
|
||||
// // Set initial state
|
||||
// handleResize();
|
||||
// // Add event listener
|
||||
// window.addEventListener('resize', handleResize);
|
||||
// // Cleanup
|
||||
// return () => window.removeEventListener('resize', handleResize);
|
||||
// }, []);
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setIsSidebarOpen(!isSidebarOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
// <div className="h-screen w-screen" >
|
||||
// <div className="flex flex-col">
|
||||
// <div className="flex flex-row h-screen">
|
||||
// <ToastContainer />
|
||||
// <Sidebar isSidebarOpen={isSidebarOpen} toggleSidebar={toggleSidebar} />
|
||||
// <main className={`pr-10 transition-all duration-300 ${isSidebarOpen ? 'ml-64 w-[calc(100%-16rem)] ' : 'ml-0 w-[calc(100%)] '}`}>
|
||||
|
||||
|
||||
<div className="">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-row h-[90vh] w-screen ">
|
||||
<ToastContainer position="top-center" style={{ zIndex: 9999 }} />
|
||||
<Sidebar isSidebarOpen={isSidebarOpen} toggleSidebar={toggleSidebar} />
|
||||
<main className={`pr-10 transition-all h-[90vh] duration-300 ${isSidebarOpen ? 'ml-64 w-[calc(100%-16rem)] ' : 'ml-0 w-[calc(100%)] '}`}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
{/* <div className="justify-end items-center text-center ">
|
||||
<Footer />
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
58
components/location/LocationCard.js
Normal file
58
components/location/LocationCard.js
Normal file
@ -0,0 +1,58 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import axiosInstance from '../../src/axiosSecure';
|
||||
|
||||
import { TrashIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
|
||||
|
||||
export default function LocationCard({ location }) {
|
||||
|
||||
const router = useRouter();
|
||||
const [isCardVisible, setIsCardVisible] = useState(true);
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
console.log("card: deleting location = ", id, "url: ", `/locations/${id}`);
|
||||
const response = await axiosInstance.delete(`/api/data/locations/${id}`);
|
||||
if (response.status === 200) {
|
||||
document.getElementById(`location-card-${id}`).classList.add('cardFadeOut');
|
||||
setTimeout(() => setIsCardVisible(false), 300);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(JSON.stringify(error));
|
||||
}
|
||||
};
|
||||
|
||||
return isCardVisible ? (
|
||||
<>
|
||||
<div
|
||||
id={`location-card-${location.id}`}
|
||||
className={`relative block p-6 max-w-sm rounded-lg border border-gray-200 shadow-md hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 dark:hover:bg-gray-700 mb-3 cursor-pointer ${location.isactive ? 'text-gray-900 dark:text-white font-bold' : 'text-gray-400 dark:text-gray-600'}`}
|
||||
onClick={() => router.push(`/cart/locations/edit/${location.id}`)}
|
||||
>
|
||||
<h5 className={`mb-2 text-2xl tracking-tight`}>
|
||||
{location.name} ({location.isactive ? "active" : "inactive"})
|
||||
</h5>
|
||||
<p className="font-normal text-gray-700 dark:text-gray-200">
|
||||
{location.address}
|
||||
</p>
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // This should now work as expected
|
||||
handleDelete(location.id);
|
||||
}}
|
||||
className="absolute bottom-2 right-2 z-20"
|
||||
>
|
||||
<button
|
||||
aria-label="Delete location"
|
||||
className="text-red-600 bg-transparent hover:bg-red-100 p-1 hover:border-red-700 rounded"
|
||||
>
|
||||
<TrashIcon className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null;
|
||||
|
||||
}
|
272
components/location/LocationForm.js
Normal file
272
components/location/LocationForm.js
Normal file
@ -0,0 +1,272 @@
|
||||
import axiosInstance from '../../src/axiosSecure';
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import toast from "react-hot-toast";
|
||||
import Link from "next/link";
|
||||
import DayOfWeek from "../DayOfWeek";
|
||||
import TextEditor from "../TextEditor";
|
||||
import FileUploadWithPreview from 'components/FileUploadWithPreview ';
|
||||
|
||||
import ProtectedRoute, { serverSideAuth } from "../..//components/protectedRoute";
|
||||
import { UserRole } from "@prisma/client";
|
||||
|
||||
const common = require('src/helpers/common');
|
||||
|
||||
// ------------------ LocationForm ------------------
|
||||
// This component is used to create and edit locations
|
||||
// location model:
|
||||
// model Location {
|
||||
// id Int @id @default(autoincrement())
|
||||
// name String
|
||||
// address String
|
||||
// isactive Boolean @default(true)
|
||||
// content String? @db.Text
|
||||
// cartEvents CartEvent[]
|
||||
// reports Report[]
|
||||
|
||||
// backupLocationId Int?
|
||||
// backupLocation Location? @relation("BackupLocation", fields: [backupLocationId], references: [id])
|
||||
// BackupForLocations Location[] @relation("BackupLocation")
|
||||
// }
|
||||
|
||||
export default function LocationForm() {
|
||||
const [uploadedImages, setUploadedImages] = useState([]);
|
||||
|
||||
const [isPreviewMode, setIsPreviewMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUploadedImages = async () => {
|
||||
try {
|
||||
const response = await axiosInstance.get('/uploaded-images');
|
||||
setUploadedImages(response.data.imageUrls);
|
||||
} catch (error) {
|
||||
console.error('Error fetching uploaded images:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUploadedImages();
|
||||
}, []);
|
||||
|
||||
const quillRef = useRef(null);
|
||||
const handleImageSelect = (e) => {
|
||||
const imageUrl = e.target.value;
|
||||
if (imageUrl && quillRef.current) {
|
||||
const editor = quillRef.getQuill();
|
||||
const range = editor.getSelection(true);
|
||||
if (range) {
|
||||
editor.insertEmbed(range.index, 'image', imageUrl);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const [content, setContent] = useState("");
|
||||
|
||||
const [location, set] = useState({
|
||||
name: "",
|
||||
address: "",
|
||||
isactive: true,
|
||||
});
|
||||
|
||||
// const [isEdit, setIsEdit] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLocation = async (id) => {
|
||||
try {
|
||||
console.log("fetching location " + router.query.id);
|
||||
const { data } = await axiosInstance.get("/api/data/locations/" + id);
|
||||
set(data);
|
||||
setContent(data.content);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
if (router.query?.id) {
|
||||
fetchLocation(parseInt(router.query.id.toString()));
|
||||
}
|
||||
console.log("called");
|
||||
}, [router.query.id]);
|
||||
|
||||
|
||||
const [locations, setLocations] = useState([]);
|
||||
useEffect(() => {
|
||||
const fetchLocations = async () => {
|
||||
try {
|
||||
console.log("fetching locations");
|
||||
const { data } = await axiosInstance.get("/api/data/locations");
|
||||
setLocations(data);
|
||||
console.log(data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
if (!locations.length) {
|
||||
fetchLocations();
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
const handleChange = ({ target }) => {
|
||||
if (target.type === "checkbox") {
|
||||
set({ ...location, [target.name]: target.checked });
|
||||
} else if (target.type === "number") {
|
||||
set({ ...location, [target.name]: parseInt(target.value) });
|
||||
} else {
|
||||
set({ ...location, [target.name]: target.value });
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const dataToSend = {
|
||||
...location,
|
||||
name: location.name.trim(),
|
||||
content: content,
|
||||
};
|
||||
|
||||
if (router.query?.id) { // UPDATE
|
||||
//connect backup location
|
||||
delete dataToSend.id;
|
||||
dataToSend.backupLocationId = parseInt(dataToSend.backupLocationId);
|
||||
// dataToSend.backupLocation = { connect: { id: location.backupLocationId } };
|
||||
// delete dataToSend.backupLocationId;
|
||||
await axiosInstance.put("/api/data/locations/" + router.query.id, {
|
||||
...dataToSend,
|
||||
});
|
||||
toast.success("Task Updated", {
|
||||
position: "bottom-center",
|
||||
});
|
||||
} else { // CREATE
|
||||
await axiosInstance.post("/api/data/locations", dataToSend);
|
||||
toast.success("Task Saved", {
|
||||
position: "bottom-center",
|
||||
});
|
||||
}
|
||||
|
||||
router.push("/cart/locations");
|
||||
} catch (error) {
|
||||
//toast.error(error.response.data.message);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProtectedRoute allowedRoles={[UserRole.ADMIN]} deniedMessage="">
|
||||
<div className="w-full max-lg">
|
||||
<form className="bg-white dark:bg-gray-800 shadow-md rounded px-8 pt-6 pb-8 mb-4" onSubmit={handleSubmit} >
|
||||
<div className="mb-4">
|
||||
<label className="label" htmlFor="name">Location Name</label>
|
||||
<input className="textbox" placeholder="name" id="name" name="name" onChange={handleChange} value={location.name} autoComplete="off" />
|
||||
</div>
|
||||
{/* Location.address */}
|
||||
<div className="mb-4">
|
||||
<label className="label" htmlFor="address"> Address</label>
|
||||
<input className="textbox"
|
||||
placeholder="address" id="address" name="address" onChange={handleChange} value={location.address} autoComplete="off" />
|
||||
</div>
|
||||
{/* UI for Location.isactive */}
|
||||
<div className="mb-4">
|
||||
<div className="form-check">
|
||||
<input className="checkbox form-input" type="checkbox" id="isactive" name="isactive" onChange={handleChange} checked={location.isactive} autoComplete="off" />
|
||||
<label className="label" htmlFor="isactive">Активна</label>
|
||||
</div>
|
||||
</div>
|
||||
{/* backupLocation */}
|
||||
<div className="mb-4">
|
||||
<label className="label" htmlFor="backupLocation">При дъжд и лошо време</label>
|
||||
{locations && (
|
||||
<select name="backupLocationId" id="backupLocationId" onChange={handleChange} value={location.backupLocationId} placeholder="Избери локация...">
|
||||
|
||||
<option>Избери локация...</option>
|
||||
{locations.map((loc) => (
|
||||
<option key={loc.id} value={loc.id} type="number">{loc.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Location.content */}
|
||||
<div className="mb-4">
|
||||
{/* <select onChange={handleImageSelect}>
|
||||
<option>Select an image</option>
|
||||
{uploadedImages.map((imageUrl, index) => (
|
||||
<option key={index} value={imageUrl}>{imageUrl}</option>
|
||||
))}
|
||||
</select> */}
|
||||
{router.query.id && (
|
||||
<>
|
||||
<div className="flex space-x-4">
|
||||
<FileUploadWithPreview
|
||||
name="picture1"
|
||||
label="Снимка 1"
|
||||
value={location.picture1}
|
||||
prefix={`location-${router.query.id}-picture1`}
|
||||
onUpload={(name, imageUrl) => {
|
||||
console.log('Uploaded image URL:', imageUrl);
|
||||
set(location => ({ ...location, [name]: imageUrl }));
|
||||
}}
|
||||
/>
|
||||
|
||||
<FileUploadWithPreview
|
||||
name="picture2"
|
||||
label="Снимка 2"
|
||||
value={location.picture2}
|
||||
prefix={`location-${router.query.id}-picture2`}
|
||||
onUpload={(name, imageUrl) => {
|
||||
console.log('Uploaded image URL:', imageUrl);
|
||||
set(location => ({ ...location, [name]: imageUrl }));
|
||||
}}
|
||||
/>
|
||||
<FileUploadWithPreview
|
||||
name="picture3"
|
||||
label="Снимка 3"
|
||||
value={location.picture3}
|
||||
prefix={`location-${router.query.id}-picture3`}
|
||||
onUpload={(name, imageUrl) => {
|
||||
console.log('Uploaded image URL:', imageUrl);
|
||||
set(location => ({ ...location, [name]: imageUrl }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="label" htmlFor="content">Content</label>
|
||||
|
||||
<TextEditor
|
||||
ref={quillRef}
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
placeholder="Описание на локацията. Снимки"
|
||||
prefix={`location-${router.query.id}`} />
|
||||
</>)}
|
||||
</div>
|
||||
<div className="panel-actions pt-12">
|
||||
<Link href="/cart/locations" className="action-button"> обратно </Link>
|
||||
<button className="button bg-blue-500 hover:bg-blue-700 focus:outline-none focus:shadow-outline" type="submit">
|
||||
{router.query?.id ? "Update" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
<button className='button' onClick={() => setIsPreviewMode(!isPreviewMode)}>
|
||||
{isPreviewMode ? 'Скрий потребителския изглед' : 'Виж какво ще се покаже на потребителите'}
|
||||
</button>
|
||||
</ProtectedRoute>
|
||||
<ProtectedRoute allowedRoles={[UserRole.USER]} deniedMessage=" " bypass={isPreviewMode}>
|
||||
<label className="label" htmlFor="backupLocation">При дъжд и лошо време</label>
|
||||
{location.name}
|
||||
{location.address}
|
||||
{location.backupLocationName}
|
||||
<div className="border-2 border-blue-500 p-5 my-5 rounded-lg" dangerouslySetInnerHTML={{ __html: content }}></div>
|
||||
</ProtectedRoute>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
55
components/privacy-policy/PrivacyPolicyBG.jsx
Normal file
55
components/privacy-policy/PrivacyPolicyBG.jsx
Normal file
@ -0,0 +1,55 @@
|
||||
export default function PrivacyPolicyBG() {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-4">
|
||||
<h1 className="text-2xl font-bold mb-4">Политика за Поверителност</h1>
|
||||
|
||||
<p className="mb-4">
|
||||
Тази политика за поверителност очертава как ние събираме, използваме и защитаваме вашите лични данни в съответствие с Общия регламент за защита на данните (GDPR).
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mb-2">Информация, която Събираме</h2>
|
||||
|
||||
<p className="mb-4">
|
||||
Ние събираме само лични данни, които вие доброволно ни предоставяте, като вашето име, електронна поща, телефонен номер и др.. Събраната лична информация се използва за предоставяне, поддържане и подобряване на нашите услуги, управление на потребителски акаунти и комуникация с вас относно услуги или продукти.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mb-2">Как Използваме Информацията</h2>
|
||||
|
||||
<p className="mb-4">
|
||||
Ние използваме вашите лични данни за предоставяне на услуги и подобряване на вашето удобство при използване на нашия сайт. Ние не продаваме или споделяме вашите лични данни с трети страни, освен ако не е необходимо по закон. Ние се ангажираме да никога не предоставяме личните данни, които държим, на трети страни.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mb-2">Защита на Данните и Сигурност</h2>
|
||||
|
||||
<p className="mb-4">
|
||||
Ние прилагаме различни мерки за сигурност за поддържане на безопасността на вашата лична информация, включително HTTPS и криптиране на данни. Достъпът до вашите лични данни е ограничен само за упълномощени лица.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mb-2">Вашите Права и Решения</h2>
|
||||
|
||||
<p className="mb-4">
|
||||
Съгласно GDPR, имате право да достъпите, актуализирате или изтриете информацията, която имаме за вас. Имате също така права за коригиране, възражение, ограничаване и пренасяне на данни. Можете по всяко време да се откажете от комуникацията с нас и да имате право да оттеглите съгласието си.
|
||||
</p>
|
||||
|
||||
<p className="mb-4">
|
||||
За да упражните тези права, моля свържете се с нас на [EMAIL].
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mb-2">Промени в тази Политика</h2>
|
||||
|
||||
<p className="mb-4">
|
||||
Ние можем от време на време да актуализираме нашата Политика за Поверителност. Ще ви уведомим за всякакви промени, като публикуваме новата Политика за Поверителност на тази страница и актуализираме датата на "Последно актуализирана".
|
||||
</p>
|
||||
|
||||
<p className="mb-4">
|
||||
Последно актуализирана: 03.02.2024 г.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mb-2">Свържете се с Нас</h2>
|
||||
|
||||
<p className="mb-4">
|
||||
Ако имате въпроси относно нашите практики за поверителност или тази Политика за Поверителност, моля свържете се с нас на [EMAIL].
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
27
components/privacy-policy/PrivacyPolicyContainer.jsx
Normal file
27
components/privacy-policy/PrivacyPolicyContainer.jsx
Normal file
@ -0,0 +1,27 @@
|
||||
import React, { useState } from 'react';
|
||||
import PrivacyPolicyEN from './PrivacyPolicyEN';
|
||||
import PrivacyPolicyBG from './PrivacyPolicyBG';
|
||||
|
||||
export default function PrivacyPolicyContainer() {
|
||||
const [language, setLanguage] = useState('bg'); // default language is Bulgarian
|
||||
|
||||
const toggleLanguage = () => {
|
||||
setLanguage(language === 'en' ? 'bg' : 'en');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow-lg rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
<button
|
||||
onClick={toggleLanguage}
|
||||
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
||||
>
|
||||
{language === 'en' ? 'На български' : 'In English'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="border-t border-gray-200">
|
||||
{language === 'en' ? <PrivacyPolicyEN /> : <PrivacyPolicyBG />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
55
components/privacy-policy/PrivacyPolicyEN.jsx
Normal file
55
components/privacy-policy/PrivacyPolicyEN.jsx
Normal file
@ -0,0 +1,55 @@
|
||||
export default function PrivacyPolicyEN() {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-4">
|
||||
<h1 className="text-2xl font-bold mb-4">Privacy Policy</h1>
|
||||
|
||||
<p className="mb-4">
|
||||
This privacy policy outlines how we collect, use, and protect your personal data in accordance with the General Data Protection Regulation (GDPR).
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mb-2">Information We Collect</h2>
|
||||
|
||||
<p className="mb-4">
|
||||
We only collect personal data that you voluntarily provide to us, such as your name, email address, phone number, etc.. The personal information we collect is used to provide, maintain, and improve our services, manage user accounts, and communicate with you about services or products.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mb-2">How We Use Information</h2>
|
||||
|
||||
<p className="mb-4">
|
||||
We use your personal data to provide services and improve your experience on our site. We do not sell or share your personal data with third parties, except when required by law.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mb-2">Data Protection and Security</h2>
|
||||
|
||||
<p className="mb-4">
|
||||
We implement a variety of security measures to maintain the safety of your personal information, including HTTPS and data encryption. Access to your personal data is limited to authorized staff only.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mb-2">Your Rights and Choices</h2>
|
||||
|
||||
<p className="mb-4">
|
||||
Under GDPR, you have the right to access, update, or delete the information we have on you. You also have the rights of rectification, objection, restriction, and data portability. You can opt-out of communications from us at any time and have the right to withdraw consent.
|
||||
</p>
|
||||
|
||||
<p className="mb-4">
|
||||
To exercise these rights, please contact us at [EMAIL].
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mb-2">Changes to this Policy</h2>
|
||||
|
||||
<p className="mb-4">
|
||||
We may update our Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page and updating the "Last updated" date.
|
||||
</p>
|
||||
|
||||
<p className="mb-4">
|
||||
Last updated: 03.02.2024
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mb-2">Contact Us</h2>
|
||||
|
||||
<p className="mb-4">
|
||||
If you have any questions about our privacy practices or this Privacy Policy, please contact us at [EMAIL].
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
70
components/protectedRoute.tsx
Normal file
70
components/protectedRoute.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
// components/ProtectedRoute.tsx
|
||||
import { useSession, signIn } from "next-auth/react";
|
||||
import { useEffect, ReactNode } from "react";
|
||||
import { useRouter } from 'next/router';
|
||||
import { UserRole } from '../Enums/UserRole';
|
||||
import { getSession } from "next-auth/react";
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: ReactNode;
|
||||
allowedRoles: UserRole[];
|
||||
deniedMessage?: string;
|
||||
bypass?: boolean;
|
||||
}
|
||||
|
||||
const ProtectedRoute = ({ children, allowedRoles, deniedMessage, bypass = false }: ProtectedRouteProps) => {
|
||||
const { data: session, status } = useSession()
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
console.log("session.role:" + session?.user?.role);
|
||||
if (!status || status === "unauthenticated") {
|
||||
// Redirect to the sign-in page
|
||||
if (!bypass) {
|
||||
signIn();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
else {
|
||||
console.log("session.role:" + session?.user?.role);
|
||||
}
|
||||
}, [session, status, router]);
|
||||
if (status === "authenticated") {
|
||||
const userRole = session.user.role as UserRole; // Assuming role is part of the session object
|
||||
|
||||
// Grant access if allowedRoles is not defined, or if the user's role is among the allowed roles
|
||||
if (bypass || !allowedRoles || (allowedRoles && allowedRoles.includes(userRole))) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Handle denied access
|
||||
if (deniedMessage !== undefined) {
|
||||
return <div>{deniedMessage}</div>;
|
||||
}
|
||||
return <div>Нямате достъп до тази страница. Ако мислите, че това е грешка, моля, свържете се с администраторите</div>;
|
||||
}
|
||||
|
||||
if (status === "loading") {
|
||||
return <div>Зареждане...</div>;
|
||||
}
|
||||
if (!session) return <a href="/api/auth/signin">Защитено съдържание. Впишете се.. </a>
|
||||
return children;
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
export async function serverSideAuth({ req, allowedRoles }) {
|
||||
const session = await getSession({ req });
|
||||
|
||||
if (!session || (allowedRoles && !allowedRoles.includes(session.user.role))) {
|
||||
// User is not authenticated or doesn't have the required role
|
||||
return {
|
||||
redirect: {
|
||||
destination: '/api/auth/signin', // Redirect to the sign-in page
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Return the session if the user is authenticated and has the required role
|
||||
return { session };
|
||||
}
|
101
components/publisher/PublisherCard.js
Normal file
101
components/publisher/PublisherCard.js
Normal file
@ -0,0 +1,101 @@
|
||||
import Link from "next/link";
|
||||
import { Publisher } from "@prisma/client"
|
||||
// import {IsDateXMonthsAgo} from "../../helpers/const"
|
||||
import { useEffect, useState } from 'react'
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import axiosInstance from '../../src/axiosSecure';
|
||||
|
||||
//add months to date. works with negative numbers and numbers > 12
|
||||
export function addMonths(numOfMonths, date) {
|
||||
var date = new Date(date);
|
||||
var m, d = (date = new Date(+date)).getDate();
|
||||
date.setMonth(date.getMonth() + numOfMonths, 1);
|
||||
m = date.getMonth();
|
||||
date.setDate(d);
|
||||
if (date.getMonth() !== m) date.setDate(0);
|
||||
return date;
|
||||
|
||||
}
|
||||
|
||||
//is date in range of months from and to
|
||||
//usage:
|
||||
//is date in last month: IsDateInXMonths(date, -1, 0)
|
||||
//is date in current month: IsDateInXMonths(date, 0, 0)
|
||||
//is date in next month: IsDateInXMonths(date, 0, 1)
|
||||
|
||||
export function IsDateInXMonths(date, monthsFrom, monthsTo) {
|
||||
var date = new Date(date);
|
||||
var dateYearMonth = new Date(date.getFullYear(), date.getMonth(), 1);
|
||||
if (monthsFrom === undefined) monthsFrom = -100;
|
||||
if (monthsTo === undefined) monthsTo = 100;
|
||||
// var from = new Date(date.setMonth(dateYearMonth.getMonth()+monthsFrom));
|
||||
// var to = new Date(date.setMonth(dateYearMonth.getMonth()+monthsTo));
|
||||
var from = addMonths(monthsFrom, dateYearMonth);
|
||||
var to = addMonths(monthsTo, dateYearMonth);
|
||||
//is date between from and to
|
||||
return date >= from && date <= to;
|
||||
};
|
||||
|
||||
export default function PublisherCard({ publisher }) {
|
||||
|
||||
const [isCardVisible, setIsCardVisible] = useState(true);
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
console.log("card: deleting publisher = ", id, "url: ", `/api/data/publishers/${id}`);
|
||||
const response = await axiosInstance.delete(`/api/data/publishers/${id}`);
|
||||
if (response.status === 200) {
|
||||
document.getElementById(`publisher-card-${id}`).classList.add('cardFadeOut');
|
||||
setTimeout(() => setIsCardVisible(false), 300);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(JSON.stringify(error));
|
||||
}
|
||||
};
|
||||
|
||||
return isCardVisible ? (
|
||||
// className="block p-6 max-w-sm bg-white rounded-lg border border-gray-200 shadow-md hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 dark:hover:bg-gray-700 mb-3"
|
||||
<div id={`publisher-card-${publisher.id}`} className={`relative block p-6 max-w-sm rounded-lg border border-gray-200 shadow-md hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 dark:hover:bg-gray-700 mb-3
|
||||
${publisher.isImported ? "bg-orange-50" : (publisher.isTrained ? "bg-white" : "bg-red-50")}`}>
|
||||
<a
|
||||
href={`/cart/publishers/edit/${publisher.id}`}
|
||||
className=""
|
||||
key={publisher.id}
|
||||
>
|
||||
|
||||
<h5 className="mb-2 text-2xl font-bold tracking-tight text-gray-900 dark:text-white">
|
||||
{publisher.firstName} {publisher.lastName} ({publisher.isactive ? "active" : "inactive"})
|
||||
</h5>
|
||||
<div className="font-normal text-gray-700 dark:text-gray-200">
|
||||
<p> {publisher.assignments.length} смени общо</p>
|
||||
<p> достъпност: {publisher.availabilities?.length}</p>
|
||||
{/* <p> {publisher.shifts.filter(s => IsDateInXMonths(s.startTime, -1, 0)).length} last month</p>
|
||||
<p> {publisher.shifts.filter(s => IsDateInXMonths(s.startTime, 0, 0)).length} this month</p>
|
||||
<p> {publisher.shifts.filter(s => IsDateInXMonths(s.startTime, 0, 1)).length} next month</p> */}
|
||||
</div>
|
||||
</a>
|
||||
<div className="absolute bottom-2 right-2">
|
||||
<button onClick={() => handleDelete(publisher.id)} aria-label="Delete Publisher">
|
||||
<svg className="w-5 h-6 text-red-500 hover:text-red-700" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path d="M10 11V17" stroke="#000000" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M14 11V17" stroke="#000000" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M4 7H20" stroke="#000000" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M6 7H12H18V18C18 19.6569 16.6569 21 15 21H9C7.34315 21 6 19.6569 6 18V7Z" stroke="#000000" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M9 5C9 3.89543 9.89543 3 11 3H13C14.1046 3 15 3.89543 15 5V7H9V5Z" stroke="#000000" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
|
||||
{/* <path d="M8 9a1 1 0 000 2h4a1 1 0 100-2H8z" />
|
||||
<path fillRule="evenodd" d="M4.293 4.293A1 1 0 015.707 3.707L10 8l4.293-4.293a1 1 0 111.414 1.414L11.414 9l4.293 4.293a1 1 0 01-1.414 1.414L10 10.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 9 4.293 4.707a1 1 0 010-1.414z" clipRule="evenodd" /> */}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<style jsx>{`
|
||||
.cardFadeOut {
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
317
components/publisher/PublisherForm.js
Normal file
317
components/publisher/PublisherForm.js
Normal file
@ -0,0 +1,317 @@
|
||||
// import axios from "axios";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useRouter } from "next/router";
|
||||
import Link from "next/link";
|
||||
import axiosInstance from '../../src/axiosSecure';
|
||||
//import { getDate } from "date-fns";
|
||||
|
||||
import { monthNamesBG, GetTimeFormat, GetDateFormat } from "../../src/helpers/const"
|
||||
import PublisherSearchBox from './PublisherSearchBox';
|
||||
import AvailabilityList from "../availability/AvailabilityList";
|
||||
import ShiftsList from "../publisher/ShiftsList.tsx";
|
||||
import common from "../../src/helpers/common";
|
||||
|
||||
import ProtectedRoute from '../../components/protectedRoute';
|
||||
import { UserRole } from "@prisma/client";
|
||||
|
||||
// import { Tabs, List } from 'tw-elements'
|
||||
|
||||
// model Publisher {
|
||||
// id String @id @default(cuid())
|
||||
// firstName String
|
||||
// lastName String
|
||||
// email String @unique
|
||||
// phone String?
|
||||
// isactive Boolean @default(true)
|
||||
// isImported Boolean @default(false)
|
||||
// age Int?
|
||||
// availabilities Availability[]
|
||||
// assignments Assignment[]
|
||||
|
||||
// emailVerified DateTime?
|
||||
// accounts Account[]
|
||||
// sessions Session[]
|
||||
// role UserRole @default(USER)
|
||||
// desiredShiftsPerMonth Int @default(4)
|
||||
// isMale Boolean @default(true)
|
||||
// isNameForeign Boolean @default(false)
|
||||
|
||||
// familyHeadId String? // Optional familyHeadId for each family member
|
||||
// familyHead Publisher? @relation("FamilyMember", fields: [familyHeadId], references: [id])
|
||||
// familyMembers Publisher[] @relation("FamilyMember")
|
||||
// type PublisherType @default(Publisher)
|
||||
// Town String?
|
||||
// Comments String?
|
||||
// }
|
||||
|
||||
Array.prototype.groupBy = function (prop) {
|
||||
return this.reduce(function (groups, item) {
|
||||
const val = item[prop]
|
||||
groups[val] = groups[val] || []
|
||||
groups[val].push(item)
|
||||
return groups
|
||||
}, {})
|
||||
}
|
||||
|
||||
export default function PublisherForm({ item, me }) {
|
||||
|
||||
|
||||
const router = useRouter();
|
||||
console.log("init PublisherForm: ");
|
||||
const urls = {
|
||||
apiUrl: "/api/data/publishers/",
|
||||
indexUrl: "/cart/publishers"
|
||||
}
|
||||
|
||||
const [helpers, setHelper] = useState(null);
|
||||
const fetchModules = async () => {
|
||||
const h = (await import("../../src/helpers/const.js")).default;
|
||||
//console.log("fetchModules: " + JSON.stringify(h));
|
||||
setHelper(h);
|
||||
}
|
||||
useEffect(() => {
|
||||
fetchModules();
|
||||
}, []);
|
||||
|
||||
const [publisher, set] = useState(item || {
|
||||
isactive: true,
|
||||
});
|
||||
|
||||
const handleChange = ({ target }) => {
|
||||
if (target.type === "checkbox") {
|
||||
set({ ...publisher, [target.name]: target.checked });
|
||||
} else if (target.type === "number") {
|
||||
set({ ...publisher, [target.name]: parseInt(target.value) });
|
||||
} else {
|
||||
set({ ...publisher, [target.name]: target.value });
|
||||
}
|
||||
if (item?.firstName) {
|
||||
publisher.isMale = item.firstName && item.firstName.endsWith('а') ? false : true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const handleParentSelection = (head) => {
|
||||
//setSelectedParent(parent);
|
||||
// Update the publisher state with the selected publisher's ID
|
||||
console.log("handleParentSelection: " + JSON.stringify(head));
|
||||
set({ ...publisher, familyHeadId: head.id });
|
||||
// Create a new object excluding the familyHeadId property
|
||||
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
router.query.id = router.query.id || "";
|
||||
console.log("handleSubmit: " + JSON.stringify(publisher));
|
||||
console.log("urls.apiUrl + router.query.id: " + urls.apiUrl + router.query.id)
|
||||
e.preventDefault();
|
||||
//remove availabilities, assignments from publisher
|
||||
publisher.availabilities = undefined;
|
||||
publisher.assignments = undefined;
|
||||
|
||||
let { familyHeadId, userId, ...rest } = publisher;
|
||||
// Set the familyHead relation based on the selected head
|
||||
const familyHeadRelation = familyHeadId ? { connect: { id: familyHeadId } } : { disconnect: true };
|
||||
const userRel = userId ? { connect: { id: userId } } : { disconnect: true };
|
||||
// Return the new state without familyHeadId and with the correct familyHead relation
|
||||
rest = {
|
||||
...rest,
|
||||
familyHead: familyHeadRelation,
|
||||
user: userRel
|
||||
};
|
||||
|
||||
try {
|
||||
if (router.query?.id) {
|
||||
await axiosInstance.put(urls.apiUrl + router.query.id, {
|
||||
...rest,
|
||||
});
|
||||
toast.success("Task Updated", {
|
||||
position: "bottom-center",
|
||||
});
|
||||
} else {
|
||||
await axiosInstance.post(urls.apiUrl, publisher);
|
||||
toast.success("Task Saved", {
|
||||
position: "bottom-center",
|
||||
});
|
||||
}
|
||||
router.push(urls.indexUrl);
|
||||
} catch (error) {
|
||||
console.log(JSON.stringify(error));
|
||||
//toast.error(error.response.data.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
//console.log("deleting publisher id = ", router.query.id, "; url=" + urls.apiUrl + router.query.id);
|
||||
await axiosInstance.delete(urls.apiUrl + router.query.id);
|
||||
toast.success("Записът изтрит", {
|
||||
position: "bottom-center",
|
||||
});
|
||||
router.push(urls.indexUrl);
|
||||
} catch (error) {
|
||||
console.log(JSON.stringify(error));
|
||||
toast.error(error.response.data.message);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
let formTitle;
|
||||
me = common.parseBool(me);
|
||||
if (me) {
|
||||
formTitle = "Моят профил / Настройки";
|
||||
} else if (router.query?.id) {
|
||||
formTitle = "Редактирай вестител"; // "Edit Publisher"
|
||||
} else {
|
||||
formTitle = "Създай вестител"; // "Create Publisher"
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col">
|
||||
<h3 className="text-2xl font-semibold mt-6 mb-4">{formTitle}</h3>
|
||||
<div className="h-4"></div>
|
||||
<div className="flex flex-row">
|
||||
|
||||
<form className="form"
|
||||
onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label className="label" htmlFor="firstName">Име</label>
|
||||
<input type="text" name="firstName" value={publisher.firstName} onChange={handleChange} className="textbox" placeholder="First Name" autoFocus />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="label" htmlFor="lastName">Фамилия</label>
|
||||
<input type="text" name="lastName" value={publisher.lastName} onChange={handleChange} className="textbox" placeholder="Last Name" autoFocus />
|
||||
<ProtectedRoute allowedRoles={[UserRole.ADMIN]} deniedMessage=" ">
|
||||
<div className="form-check">
|
||||
<input className="checkbox" type="checkbox" value={publisher.isNameForeign} id="isNameForeign" name="isNameForeign" onChange={handleChange} checked={publisher.isNameForeign} autoComplete="off" />
|
||||
<label className="label" htmlFor="isNameForeign">
|
||||
Чуждестранна фамилия</label>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
</div>
|
||||
{/* //desiredShiftsPerMonth */}
|
||||
<div className="mb-4">
|
||||
<label className="label" htmlFor="desiredShiftsPerMonth">Желани смeни на месец</label>
|
||||
<input type="number" name="desiredShiftsPerMonth" value={publisher.desiredShiftsPerMonth} onChange={handleChange} className="textbox" placeholder="desiredShiftsPerMonth" autoFocus />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="label" htmlFor="email">Имейл</label>
|
||||
<input type="text" name="email" value={publisher.email} onChange={handleChange} className="textbox" placeholder="Email" autoFocus />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="label" htmlFor="phone">Телефон</label>
|
||||
<input type="text" name="phone" value={publisher.phone} onChange={handleChange} className="textbox" placeholder="Phone" autoFocus />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="label" htmlFor="parentPublisher">
|
||||
Семейство (избери главата на семейството)
|
||||
</label>
|
||||
<PublisherSearchBox selectedId={publisher.familyHeadId} onChange={handleParentSelection} />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="form-check">
|
||||
<input className="radio" type="radio" id="male" name="isMale"
|
||||
onChange={() => handleChange({ target: { name: "isMale", value: true } })}
|
||||
checked={publisher.isMale}
|
||||
/>
|
||||
<label className="label" htmlFor="male">
|
||||
Мъж
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-check">
|
||||
<input className="radio" type="radio" id="female" name="isMale"
|
||||
onChange={() => handleChange({ target: { name: "isMale", value: false } })}
|
||||
checked={!publisher.isMale}
|
||||
/>
|
||||
<label className="label" htmlFor="female">
|
||||
Жена
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="label" htmlFor="type">Тип</label>
|
||||
<select name="type" value={publisher.type} onChange={handleChange} className="textbox" placeholder="Type" autoFocus >
|
||||
<option value="Publisher">Вестител</option>
|
||||
<option value="Bethelite">Бетелит</option>
|
||||
<option value="RegularPioneer">Редовен Пионер</option>
|
||||
<option value="SpecialPioneer">Специален Пионер/Мисионер</option>
|
||||
{/* <option value="Missionary">Мисионер</option>
|
||||
<option value="CircuitOverseer">Пътуваща служба</option> */}
|
||||
</select>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="label" htmlFor="town">Град</label>
|
||||
<input type="text" name="town" value={publisher.town} onChange={handleChange} className="textbox" placeholder="Град" autoFocus />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="label" htmlFor="comments">Коментари</label>
|
||||
<input type="text" name="comments" value={publisher.comments} onChange={handleChange} className="textbox" placeholder="Коментари" autoFocus />
|
||||
</div>
|
||||
</div>
|
||||
<ProtectedRoute allowedRoles={[UserRole.ADMIN]} deniedMessage=" ">
|
||||
<div className="mb-4">
|
||||
<label className="label" htmlFor="age">Възраст</label>
|
||||
<input type="number" name="age" value={publisher.age} onChange={handleChange} className="textbox" placeholder="Age" autoFocus />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<div className="form-check">
|
||||
<input className="checkbox" type="checkbox" id="isactive" name="isactive" onChange={handleChange} checked={publisher.isactive} autoComplete="off" />
|
||||
<label className="label" htmlFor="isactive">Активен</label>
|
||||
<input className="checkbox" type="checkbox" id="isTrained" name="isTrained" onChange={handleChange} checked={publisher.isTrained} autoComplete="off" />
|
||||
<label className="label" htmlFor="isTrained">Получил обучение</label>
|
||||
<input className="checkbox disabled" type="checkbox" id="isImported" name="isImported" onChange={handleChange} checked={publisher.isImported} autoComplete="off" />
|
||||
<label className="label " htmlFor="isImported">Импортиран от график</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="label" htmlFor="role">Роля Потребител</label>
|
||||
<select name="role" id="role" className="select" value={publisher.role} onChange={handleChange} >
|
||||
{/* <option value='${UserRole.USER}'>Потребител</option> */}
|
||||
<option value={`${UserRole.USER}`}>Потребител</option>
|
||||
<option value={`${UserRole.EXTERNAL}`}>Външен</option>
|
||||
<option value={`${UserRole.POWERUSER}`}>Организатор</option>
|
||||
<option value={`${UserRole.ADMIN}`}>Администратор</option>
|
||||
{/* Add other roles as needed */}
|
||||
|
||||
|
||||
|
||||
|
||||
</select>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
{/* ---------------------------- Actions --------------------------------- */}
|
||||
<div className="panel-actions">
|
||||
<Link href={urls.indexUrl} className="action-button"> обратно </Link>
|
||||
{/* delete */}
|
||||
<button className="button bg-red-500 hover:bg-red-700 focus:outline-none focus:shadow-outline" type="button" onClick={handleDelete}>
|
||||
Delete
|
||||
</button>
|
||||
<button className="button bg-blue-500 hover:bg-blue-700 focus:outline-none focus:shadow-outline" type="submit">
|
||||
{router.query?.id ? "Update" : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-row">
|
||||
<div className="flex-col" id="shiftlist" >
|
||||
<div className="">
|
||||
<ShiftsList assignments={publisher.assignments} selectedtab={common.getCurrentYearMonth()} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 p-5">
|
||||
<AvailabilityList publisher={publisher} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div >
|
||||
</>
|
||||
)
|
||||
};
|
||||
|
||||
|
73
components/publisher/PublisherInlineForm.js
Normal file
73
components/publisher/PublisherInlineForm.js
Normal file
@ -0,0 +1,73 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import axiosInstance from '../../src/axiosSecure';
|
||||
import toast from "react-hot-toast";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
const PublisherInlineForm = ({ publisherId, initialShiftsPerMonth }) => {
|
||||
const [desiredShiftsPerMonth, setDesiredShiftsPerMonth] = useState(initialShiftsPerMonth);
|
||||
const router = useRouter();
|
||||
const storedValue = useRef(initialShiftsPerMonth);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPublisherData = async () => {
|
||||
if (publisherId != null) {
|
||||
try {
|
||||
const response = await axiosInstance.get(`/api/data/publishers/${publisherId}`);
|
||||
const publisher = response.data;
|
||||
setDesiredShiftsPerMonth(publisher.desiredShiftsPerMonth);
|
||||
storedValue.current = publisher.desiredShiftsPerMonth;
|
||||
} catch (error) {
|
||||
console.error("Error fetching publisher data:", error);
|
||||
toast.error("Не може да се зареди информация.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
//if (storedValue.current == null) {
|
||||
fetchPublisherData();
|
||||
//}
|
||||
}, [publisherId]);
|
||||
|
||||
useEffect(() => {
|
||||
const saveShiftsPerMonth = async () => {
|
||||
|
||||
if (publisherId && desiredShiftsPerMonth != null
|
||||
&& initialShiftsPerMonth != desiredShiftsPerMonth
|
||||
&& storedValue.current != desiredShiftsPerMonth) {
|
||||
try {
|
||||
await axiosInstance.put(`/api/data/publishers/${publisherId}`, {
|
||||
desiredShiftsPerMonth,
|
||||
});
|
||||
toast.success("Смени на месец запазени", {
|
||||
position: "bottom-center",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error saving desired shifts per month:", error);
|
||||
toast.error("Грешка при запазване на смени на месец");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
saveShiftsPerMonth();
|
||||
}, [desiredShiftsPerMonth]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row items-center space-y-2 sm:space-y-0 sm:space-x-2">
|
||||
<label htmlFor="desiredShiftsPerMonth" className="block text-sm font-medium text-gray-700">
|
||||
Желани смени на месец:
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="desiredShiftsPerMonth"
|
||||
name="desiredShiftsPerMonth"
|
||||
value={desiredShiftsPerMonth}
|
||||
onChange={(e) => setDesiredShiftsPerMonth(parseInt(e.target.value))}
|
||||
className="textbox mt-1 sm:mt-0 w-full sm:w-auto flex-grow"
|
||||
placeholder="Желани смени на месец"
|
||||
min="0" max="10"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PublisherInlineForm;
|
140
components/publisher/PublisherSearchBox.js
Normal file
140
components/publisher/PublisherSearchBox.js
Normal file
@ -0,0 +1,140 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axiosInstance from '../../src/axiosSecure';
|
||||
import common from '../../src/helpers/common';
|
||||
//import { is } from 'date-fns/locale';
|
||||
|
||||
function PublisherSearchBox({ selectedId, onChange, isFocused, filterDate, showSearch = true, showList = false, showAllAuto = false, infoText = " Семеен глава" }) {
|
||||
const [selectedItem, setSelectedItem] = useState(null);
|
||||
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [publishers, setPublishers] = useState([]);
|
||||
const [searchResults, setSearchResults] = useState([]);
|
||||
const [selectedDate, setSelectedDate] = useState(filterDate);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPublishers();
|
||||
}, []); // Empty dependency array ensures this useEffect runs only once
|
||||
|
||||
const fetchPublishers = async () => {
|
||||
console.log("fetchPublishers called");
|
||||
try {
|
||||
let url = `/api/?action=filterPublishers&select=id,firstName,lastName,email,isactive&searchText=${searchText}&availabilities=false`;
|
||||
|
||||
if (filterDate) {
|
||||
url += `&filterDate=${common.getISODateOnly(filterDate)}`;
|
||||
}
|
||||
if (showList) {
|
||||
url += `&assignments=true`;
|
||||
}
|
||||
|
||||
const { data: publishersData } = await axiosInstance.get(url);
|
||||
//setPublishers(publishersData);
|
||||
const activePublishers = publishersData.filter(publisher => publisher.isactive === true);
|
||||
setPublishers(activePublishers);
|
||||
|
||||
} catch (error) {
|
||||
// Handle errors
|
||||
console.error("Error fetching publishers:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleHeadSelection = (pub) => {
|
||||
setSearchText('');
|
||||
setSearchResults([]);
|
||||
setSelectedItem(pub);
|
||||
onChange(pub); // Pass the selected parent to the parent component
|
||||
};
|
||||
|
||||
//allows us to trigger a focus on the input field when we trigger to show the search box from outside
|
||||
const inputRef = React.useRef(null);
|
||||
useEffect(() => {
|
||||
console.log("isFocused changed = ", isFocused);
|
||||
if (isFocused && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isFocused]);
|
||||
|
||||
// Update selectedDate filter from outside
|
||||
// useEffect(() => {
|
||||
// setSelectedDate(filterDate);
|
||||
// console.log("filterDate changed = ", filterDate);
|
||||
// }, [filterDate]);
|
||||
|
||||
// Update publishers when filterDate or showList changes
|
||||
useEffect(() => {
|
||||
fetchPublishers();
|
||||
}, [filterDate, showList]);
|
||||
|
||||
// Update selectedItem when selectedId changes and also at the initial load
|
||||
useEffect(() => {
|
||||
if (publishers) {
|
||||
const head = publishers.find((publisher) => publisher.id === selectedId);
|
||||
if (head) {
|
||||
//setSearchText(`${head.firstName} ${head.lastName}`);
|
||||
setSelectedItem(head);
|
||||
}
|
||||
}
|
||||
}, [selectedId, publishers]);
|
||||
|
||||
// Update searchResults when searchText or publishers change
|
||||
useEffect(() => {
|
||||
if (searchText || showAllAuto) {
|
||||
const filteredResults = publishers.filter((publisher) => {
|
||||
const fullName = `${publisher.firstName} ${publisher.lastName} `.toLowerCase();
|
||||
return fullName.includes(searchText.trim().toLowerCase())
|
||||
|| publisher.email.toLowerCase().includes(searchText.trim().toLowerCase());
|
||||
});
|
||||
setSearchResults(filteredResults);
|
||||
} else {
|
||||
setSearchResults([]);
|
||||
}
|
||||
}, [searchText, publishers]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{showSearch ? (
|
||||
<>
|
||||
<input ref={inputRef}
|
||||
type="text"
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
onFocus={() => { isFocused = true; }}
|
||||
className="textbox"
|
||||
placeholder={`${selectedItem?.firstName || ""} ${selectedItem?.lastName || ""}`}
|
||||
/>
|
||||
{(showSearch) && (searchResults.length > 0 || showAllAuto) && (
|
||||
<ul className="absolute bg-white border border-gray-300 w-full z-10">
|
||||
{/* showAllAuto ? publishers : searchResults */}
|
||||
{(searchResults).map((publisher) => (
|
||||
<li key={publisher.id}
|
||||
className={`p-2 cursor-pointer hover:bg-gray-200 ${publisher.assignmentsCurrentWeek > 0 ? 'text-orange-500' : ''}`}
|
||||
onClick={() => { handleHeadSelection(publisher); }} >
|
||||
{publisher.firstName} {publisher.lastName}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{selectedItem && infoText && (
|
||||
<p className="font-semibold pl-1">
|
||||
{infoText}: {selectedItem.firstName} {selectedItem.lastName}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
{showList ? (
|
||||
// Display only clickable list of all publishers
|
||||
<ul className="absolute bg-white border border-gray-300 w-full z-10">
|
||||
{publishers.map((publisher) => (
|
||||
<li key={publisher.id}
|
||||
className="p-2 cursor-pointer hover:bg-gray-200"
|
||||
onClick={() => { handleHeadSelection(publisher); }} >
|
||||
{publisher.firstName} {publisher.lastName}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublisherSearchBox;
|
149
components/publisher/ShiftsList.tsx
Normal file
149
components/publisher/ShiftsList.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
import React, { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useRouter } from "next/router";
|
||||
import Link from "next/link";
|
||||
import axiosInstance from '../../src/axiosSecure';
|
||||
|
||||
import { monthNamesBG, GetTimeFormat, GetDateFormat } from "../../src/helpers/const"
|
||||
import common from "../../src/helpers/common";
|
||||
|
||||
type Assignment = {
|
||||
items: {
|
||||
[key: string]: any[];
|
||||
};
|
||||
keys: string[];
|
||||
months: {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ShiftsListProps = {
|
||||
assignments: Assignment;
|
||||
selectedtab: string;
|
||||
};
|
||||
|
||||
|
||||
const ShiftsList = ({ assignments, selectedtab }: ShiftsListProps) => {
|
||||
|
||||
const { keys: assignmentKeys = [], months = [], items = [] } = assignments || {};
|
||||
const [currentTab, setCurrentTab] = useState<string>(selectedtab || assignmentKeys[-1]);
|
||||
|
||||
console.log("assignments = ", assignments);
|
||||
console.log("currentTab = ", currentTab);
|
||||
|
||||
const searchReplacement = async (id) => {
|
||||
try {
|
||||
var assignment = (await axiosInstance.get("/api/data/ssignments/" + id)).data;
|
||||
assignment.isConfirmed = true;
|
||||
// assignment.isDeleted = true;
|
||||
await axiosInstance.put("/api/data/assignments/" + id, assignment);
|
||||
toast.success("Shift Tentative", {
|
||||
position: "bottom-center",
|
||||
});
|
||||
// router.push(urls.indexUrl);
|
||||
} catch (error) {
|
||||
console.log(JSON.stringify(error));
|
||||
toast.error(error.response.data.message);
|
||||
}
|
||||
}
|
||||
const AddToGoogleCalendar = async (id) => {
|
||||
try {
|
||||
const { url, event } = await axiosInstance.get(`/api/shiftgenerate?action=createcalendarevent&id=${id}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
window.open(url, '_blank')
|
||||
.addEventListener('load', function () {
|
||||
console.log('loaded');
|
||||
});
|
||||
|
||||
// fetchShifts();
|
||||
// console.log(shifts);
|
||||
res.writeHead(301, { "Location": url });
|
||||
res.end();
|
||||
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
|
||||
console.log(JSON.stringify(error));
|
||||
if (error.response?.data?.message) {
|
||||
toast.error(error.response.data.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex flex-col m-5 w-full">
|
||||
<ul className="nav nav-tabs flex flex-col md:flex-row flex-wrap list-none border-b-0 pl-0 mb-1" role="tablist">
|
||||
{assignmentKeys?.slice(-4).map(m => (
|
||||
<li key={m.toString()} className="nav-item">
|
||||
<a
|
||||
href="#"
|
||||
onClick={() => setCurrentTab(m)}
|
||||
className={`text-blue-500 border-l border-t border-r inline-block py-2 px-4 ${currentTab === m ? 'active border-gray-300 font-bold' : ' border-transparent'}`}
|
||||
// className={`nav-link block font-medium text-xs leading-tight uppercase border-x-0 border-t-0 border-b-2 ${currentTab === m ? "active border-blue-500 font-bold" : "border-transparent"} px-6 py-3 my-2 hover:border-transparent hover:bg-gray-100 focus:border-transparent`}
|
||||
role="tab"
|
||||
aria-controls={"tabs-" + m}
|
||||
aria-selected={currentTab === m}
|
||||
>
|
||||
{months[m]}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="tab-content flex flow w-full p-2 border-2 border-gray-300 rounded-md">
|
||||
{assignmentKeys?.map(month => (
|
||||
// <div className={`tab-pane fade ${month === currentTab ? "active show" : ""}`} key={month.toString()} role="tabpanel" aria-labelledby={"tabs-tab" + month}>
|
||||
//if tab is selected
|
||||
//common.getCurrentYearMonth(month)
|
||||
currentTab === month ?
|
||||
<div key={month} className={`tab-pane fade ${month === currentTab ? "active show" : ""}`} role="tabpanel" aria-labelledby={"tabs-tab" + month}>
|
||||
<div className="flex items-center py-3 px-4">
|
||||
<span className="text-xl font-medium">
|
||||
{items[month]?.filter(Boolean).reduce((total, item) => (
|
||||
Array.isArray(item) || typeof item === 'object' ? total + Object.keys(item).length : total
|
||||
), 0)} смени за {months[month]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{items[month]?.map((shiftDay, i) => (
|
||||
shiftDay && shiftDay.length > 0 ? (
|
||||
<div className="flex items-center space-x-2 py-1" key={i}>
|
||||
<div className="font-bold flex-shrink-0 w-6 text-right">{i + ":"}</div> {/*This is the column for the date. I've given it a fixed width (w-8) which you can adjust*/}
|
||||
<div className="flex-grow flex">
|
||||
{shiftDay.map(assignment => (
|
||||
<div className="flow space-x-2 bg-gray-200 rounded-lg shadow-md py-2 px-3" key={assignment.id}>
|
||||
<span>{GetTimeFormat(assignment.start)} - {GetTimeFormat(assignment.end)}</span>
|
||||
<button
|
||||
className={`text-sm text-white font-semibold px-2 rounded-lg shadow ${assignment.isConfirmed ? "bg-yellow-400 hover:bg-yellow-500" : "bg-red-400 hover:bg-red-500"}`}
|
||||
onClick={() => searchReplacement(assignment.id)}
|
||||
>
|
||||
Търси заместник
|
||||
</button>
|
||||
<button
|
||||
className="text-sm bg-green-400 hover:bg-green-500 text-white font-semibold px-2 rounded-lg shadow"
|
||||
onClick={() => AddToGoogleCalendar(assignment.id)}
|
||||
>
|
||||
Добави в календар
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>) : null
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
: null
|
||||
))}
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export default ShiftsList;
|
179
components/reports/ExperienceForm.js
Normal file
179
components/reports/ExperienceForm.js
Normal file
@ -0,0 +1,179 @@
|
||||
import axiosInstance from '../../src/axiosSecure';
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from 'react-toastify';
|
||||
import { useRouter } from "next/router";
|
||||
import Link from "next/link";
|
||||
import DayOfWeek from "../DayOfWeek";
|
||||
import { Location, UserRole } from "@prisma/client";
|
||||
const common = require('src/helpers/common');
|
||||
|
||||
import { useSession } from "next-auth/react"
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
const ReactQuill = dynamic(() => import('react-quill'), {
|
||||
ssr: false,
|
||||
loading: () => <p>Loading...</p>,
|
||||
});
|
||||
import 'react-quill/dist/quill.snow.css'; // import styles
|
||||
|
||||
// ------------------ ExperienceForm ------------------
|
||||
// This component is used to create and edit
|
||||
// model:
|
||||
// model Report {
|
||||
// id Int @id @default(autoincrement())
|
||||
// date DateTime
|
||||
// publisher Publisher @relation(fields: [publisherId], references: [id], onDelete: Cascade)
|
||||
// publisherId String
|
||||
// assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
|
||||
// assignmentId Int
|
||||
|
||||
// placementCount Int?
|
||||
// videoCount Int?
|
||||
// returnVisitInfoCount Int?
|
||||
// conversationCount Int?
|
||||
|
||||
// experienceInfo String?
|
||||
// }
|
||||
|
||||
export default function ExperienceForm({ publisherId, assgnmentId, existingItem, onDone }) {
|
||||
const { data: session, status } = useSession()
|
||||
const [pubId, setPublisher] = useState(publisherId);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
//get the user from session if publisherId is null
|
||||
useEffect(() => {
|
||||
if (!publisherId) {
|
||||
if (session) {
|
||||
setPublisher(session.user.id);
|
||||
}
|
||||
}
|
||||
}, [publisherId, session]);
|
||||
|
||||
const [item, setItem] = useState(existingItem || {
|
||||
experienceInfo: "",
|
||||
assignmentId: assgnmentId,
|
||||
publisherId: publisherId,
|
||||
date: new Date(),
|
||||
placementCount: 0,
|
||||
videoCount: 0,
|
||||
returnVisitInfoCount: 0,
|
||||
conversationCount: 0
|
||||
});
|
||||
|
||||
const [locations, setLocations] = useState([]);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLocations = async () => {
|
||||
try {
|
||||
console.log("fetching locations");
|
||||
const { data } = await axiosInstance.get("/api/data/locations");
|
||||
setLocations(data);
|
||||
item.locationId = data[0].id;
|
||||
console.log(data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
if (!locations.length) {
|
||||
fetchLocations();
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
const handleLocationChange = ({ target }) => {
|
||||
setItem({ ...item, [target.name]: target.value });
|
||||
};
|
||||
|
||||
const handleChange = (content, delta, source, editor) => {
|
||||
item.experienceInfo = content;
|
||||
setItem(item);
|
||||
console.log(editor.getHTML()); // rich text
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
item.publisher = { connect: { id: pubId } };
|
||||
item.location = { connect: { id: parseInt(item.locationId) } };
|
||||
delete item.locationId;
|
||||
|
||||
try {
|
||||
const response = await axiosInstance.post('/api/data/reports', item);
|
||||
console.log(response);
|
||||
toast.success("Случката е записана. Благодарим Ви!");
|
||||
setTimeout(() => {
|
||||
if (onDone) {
|
||||
onDone();
|
||||
} else {
|
||||
router.push(`/dash`);
|
||||
}
|
||||
}, 3000); // Delay for 3 seconds
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
toast.error("Error saving report");
|
||||
}
|
||||
}
|
||||
|
||||
const modules = {
|
||||
toolbar: {
|
||||
container: [
|
||||
['bold', 'italic', 'underline'], // Basic text formats
|
||||
[{ 'list': 'ordered' }, { 'list': 'bullet' }], // Lists
|
||||
['link', 'image'] // Media
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md mx-auto">
|
||||
<form className="bg-white dark:bg-gray-800 shadow rounded-lg px-8 pt-6 pb-8 mb-4" onSubmit={handleSubmit} >
|
||||
|
||||
<div className="mb-4">
|
||||
<label className='block text-gray-700 dark:text-gray-300 text-sm font-bold mb-2' htmlFor="location">Място</label>
|
||||
{locations && (
|
||||
<select
|
||||
name="locationId"
|
||||
id="locationId"
|
||||
value={item.locationId}
|
||||
onChange={handleLocationChange}
|
||||
className="block appearance-none w-full bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded py-2 px-3 text-gray-700 dark:text-gray-300 leading-tight focus:outline-none focus:bg-white focus:border-blue-500"
|
||||
>
|
||||
{locations.map((loc) => (
|
||||
<option key={loc.id} value={loc.id}>{loc.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-8"> {/* Increased bottom margin */}
|
||||
<label className="block text-gray-700 dark:text-gray-300 text-sm font-bold mb-2" htmlFor="experienceInfo">
|
||||
Насърчителна случка
|
||||
</label>
|
||||
<ReactQuill
|
||||
theme="snow"
|
||||
value={item.experienceInfo}
|
||||
onChange={handleChange}
|
||||
modules={modules}
|
||||
className="w-full h-60 pb-6 bg-white dark:bg-gray-700"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row items-center justify-between mt-"> {/* Adjusted layout and added top margin */}
|
||||
<Link href={`/dash`} className="inline-block align-baseline font-bold text-sm text-blue-500 hover:text-blue-800 mt-4 md:mt-0">
|
||||
Отказ
|
||||
</Link>
|
||||
<button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline" type="submit">
|
||||
Запази
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
180
components/reports/ReportForm.js
Normal file
180
components/reports/ReportForm.js
Normal file
@ -0,0 +1,180 @@
|
||||
import axiosInstance from '../../src/axiosSecure';
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useRouter } from "next/router";
|
||||
import Link from "next/link";
|
||||
import { useSession } from "next-auth/react"
|
||||
|
||||
const common = require('src/helpers/common');
|
||||
|
||||
|
||||
// ------------------ ------------------
|
||||
// This component is used to create and edit
|
||||
/* location model:
|
||||
|
||||
model Report {
|
||||
id Int @id @default(autoincrement())
|
||||
date DateTime
|
||||
publisherId String
|
||||
publisher Publisher @relation(fields: [publisherId], references: [id], onDelete: Cascade)
|
||||
locationId Int?
|
||||
location Location? @relation(fields: [locationId], references: [id])
|
||||
shift Shift?
|
||||
|
||||
placementCount Int?
|
||||
videoCount Int?
|
||||
returnVisitInfoCount Int?
|
||||
conversationCount Int?
|
||||
|
||||
experienceInfo String? @db.LongText
|
||||
}
|
||||
*/
|
||||
|
||||
export default function ReportForm({ shiftId, existingItem, onDone }) {
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const getFormattedDate = (date) => {
|
||||
let year = date.getFullYear();
|
||||
let month = (1 + date.getMonth()).toString().padStart(2, '0');
|
||||
let day = date.getDate().toString().padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const initialDate = getFormattedDate(new Date());
|
||||
const { data: session, status } = useSession()
|
||||
const [publisherId, setPublisher] = useState(null);
|
||||
useEffect(() => {
|
||||
if (session) {
|
||||
setPublisher(session.user.id);
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
|
||||
const [item, setItem] = useState(existingItem || {
|
||||
experienceInfo: "",
|
||||
date: existingItem?.date || initialDate,
|
||||
shiftId: shiftId,
|
||||
publisherId: publisherId,
|
||||
placementCount: 0,
|
||||
videoCount: 0,
|
||||
returnVisitInfoCount: 0,
|
||||
conversationCount: 0
|
||||
});
|
||||
const [shifts, setShifts] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const dateStr = common.getISODateOnly(new Date(item.date));
|
||||
const { data: shiftsForDate } = await axiosInstance.get(`/api/?action=getShiftsForDay&date=${dateStr}`);
|
||||
setShifts(shiftsForDate);
|
||||
if (!existingItem && shiftsForDate.length > 0) {
|
||||
setItem((prevItem) => ({ ...prevItem, shiftId: shiftsForDate[0].id }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [item.date, existingItem]);
|
||||
const handleChange = ({ target }) => {
|
||||
setItem({ ...item, [target.name]: target.value });
|
||||
};
|
||||
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
item.publisher = { connect: { id: publisherId } };
|
||||
item.shift = { connect: { id: parseInt(item.shiftId) } };
|
||||
item.date = new Date(item.date);
|
||||
delete item.publisherId;
|
||||
delete item.shiftId;
|
||||
item.placementCount = parseInt(item.placementCount);
|
||||
item.videoCount = parseInt(item.videoCount);
|
||||
item.returnVisitInfoCount = parseInt(item.returnVisitInfoCount);
|
||||
item.conversationCount = parseInt(item.conversationCount);
|
||||
// item.location = { connect: { id: parseInt(item.locationId) } };s
|
||||
console.log("handleSubmit");
|
||||
console.log(item);
|
||||
try {
|
||||
const response = await axiosInstance.post('/api/data/reports', item);
|
||||
console.log(response);
|
||||
toast.success("Гоово. Благодарим Ви за отчета!");
|
||||
setTimeout(() => {
|
||||
if (onDone) {
|
||||
onDone();
|
||||
} else {
|
||||
router.push(`/dash`);
|
||||
}
|
||||
}, 300); // Delay for 3 seconds
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
toast.error("За съжаление възникна грешка!");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md mx-auto">
|
||||
{/* <iframe src="https://docs.google.com/forms/d/e/1FAIpQLSdjbqgQEGY5-fA4A0B4cXjKRQVRWk5_-uoHVIAwdMcZ5bB7Zg/viewform?embedded=true" width="640" height="717" frameborder="0" marginheight="0" marginwidth="0">Loading…</iframe> */}
|
||||
|
||||
<form className="bg-white dark:bg-gray-800 shadow-md rounded px-8 pt-6 pb-8 mb-4" onSubmit={handleSubmit} >
|
||||
|
||||
<h1 className="text-2xl font-bold mb-8">Отчет от смяна</h1>
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="date">
|
||||
Дата
|
||||
</label>
|
||||
<input className="textbox form-input px-4 py-2 rounded" id="date" name="date" type="date" onChange={handleChange} value={item.date} autoComplete="off" />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="shiftId">
|
||||
Смяна
|
||||
</label>
|
||||
<select className="textbox form-select px-4 py-2 rounded"
|
||||
id="shiftId" name="shiftId" onChange={handleChange} value={item.shiftId} autoComplete="off" >
|
||||
{shifts.map((shift) => (
|
||||
<option key={shift.id} value={shift.id}>
|
||||
{shift.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="placementCount">
|
||||
Издания
|
||||
</label>
|
||||
<input className="textbox form-input px-4 py-2 rounded" id="placementCount" name="placementCount" type="number" onChange={handleChange} value={item.placementCount} autoComplete="off" />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="conversationCount">
|
||||
Разговори
|
||||
</label>
|
||||
<input className="textbox form-input px-4 py-2 rounded" id="conversationCount" name="conversationCount" type="number" onChange={handleChange} value={item.conversationCount} autoComplete="off" />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="videoCount">
|
||||
Клипове
|
||||
</label>
|
||||
<input className="textbox form-input px-4 py-2 rounded" id="videoCount" name="videoCount" type="number" onChange={handleChange} value={item.videoCount} autoComplete="off" />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="returnVisitInfoCount">
|
||||
Адреси / Телефони
|
||||
</label>
|
||||
<input className="textbox form-input px-4 py-2 rounded" id="returnVisitInfoCount" name="returnVisitInfoCount" type="number" onChange={handleChange} value={item.returnVisitInfoCount} autoComplete="off" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" type="submit">
|
||||
Запази
|
||||
</button>
|
||||
<Link href={`/dash`}>
|
||||
Отказ
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
209
components/sidebar.tsx
Normal file
209
components/sidebar.tsx
Normal file
@ -0,0 +1,209 @@
|
||||
import { signIn, signOut, useSession } from "next-auth/react";
|
||||
import styles from "../styles/header.module.css";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { useRouter } from 'next/router';
|
||||
import sidemenu, { footerMenu } from './sidemenuData.js'; // Move sidemenu data to a separate file
|
||||
import axiosInstance from "src/axiosSecure";
|
||||
import common from "src/helpers/common";
|
||||
//get package version from package.json
|
||||
const packageVersion = require('../package.json').version;
|
||||
|
||||
function SidebarMenuItem({ item, session, isSubmenu }) {
|
||||
const router = useRouter();
|
||||
const isActive = router.pathname.includes(item.url);
|
||||
|
||||
const collapsable = item.collapsable === undefined ? true : item.collapsable;
|
||||
// is open is always true if not collapsable; isOpen is true if not collapsable
|
||||
//const [isOpen, setIsOpen] = useState(false && collapsable);
|
||||
// Initialize isOpen to true for non-collapsible items, ensuring they are always "open" // xOR
|
||||
|
||||
const baseClass = `sidemenu-item flex items-center ${isSubmenu ? "px-4 py-1" : ""} mt-1 transition-colors duration-3000 transform rounded-md`;
|
||||
const activeClass = isActive ? "sidemenu-item-active text-blue-600 bg-gray-100 dark:text-blue-400 dark:bg-blue-900" : "text-gray-700 dark:text-gray-300";
|
||||
const hoverClasses = "hover:bg-gray-100 dark:hover:bg-gray-800 dark:hover:text-gray-200 hover:text-gray-700";
|
||||
|
||||
const initialState = common.getLocalStorage(`sidebar-openState-${item.id}`, isActive);
|
||||
const [isOpen, setIsOpen] = useState(() => common.getLocalStorage(`sidebar-openState-${item.id}`, isActive));
|
||||
|
||||
useEffect(() => {
|
||||
// Only run this effect on the client-side and if it's a submenu item
|
||||
if (typeof window !== 'undefined' && isSubmenu) {
|
||||
common.setLocalStorage(`sidebar-openState-${item.id}`, isOpen);
|
||||
}
|
||||
}, [isOpen, item.id, isSubmenu]);
|
||||
|
||||
useEffect(() => {
|
||||
// This effect should also check for window to ensure it's client-side
|
||||
if (typeof window !== 'undefined' && isSubmenu) {
|
||||
const isAnyChildActive = item.children?.some(child => router.pathname.includes(child.url));
|
||||
if (isActive || isAnyChildActive) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}
|
||||
}, [router.pathname, isActive, item.children, isSubmenu]);
|
||||
|
||||
|
||||
if (!session || (item.roles && !item.roles.includes(session?.user?.role))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
//console.log("clicked", item);
|
||||
if (item.children && collapsable) { // Toggle isOpen only if item is collapsable and has children
|
||||
setIsOpen(!isOpen);
|
||||
} else if (item.url) {
|
||||
router.push(item.url);
|
||||
}
|
||||
};
|
||||
|
||||
const clickableClass = item.url || item.children ? "cursor-pointer" : "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`${baseClass} ${activeClass} ${hoverClasses} ${clickableClass}`}
|
||||
onClick={handleClick}>
|
||||
{item.svgData && <svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d={item.svgData} stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>}
|
||||
<span className="mx-4 font-medium">{item.text}</span>
|
||||
{item.children && <DropDownIcon isOpen={isOpen} />}
|
||||
</div>
|
||||
{isOpen && item.children && (
|
||||
// <ul className="relative accordion-collapse show">
|
||||
<ul className="pl-2 mt-1">
|
||||
{item.children.map((child, index) => (
|
||||
<SidebarMenuItem key={index} item={child} session={session} isSubmenu={true} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DropDownIcon({ isOpen }) {
|
||||
return (
|
||||
<svg aria-hidden="false" focusable="false" className="w-3 h-3 ml-auto" viewBox="0 0 448 512">
|
||||
{/* svg content */}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Sidebar({ isSidebarOpen, toggleSidebar }) {
|
||||
const { data: session, status } = useSession();
|
||||
const sidebarWidth = 256; // Simplify by using a constant
|
||||
const sidebarRef = useRef(null);
|
||||
//const [locations, setLocations] = useState([]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLocations = async () => {
|
||||
try {
|
||||
const response = await axiosInstance.get('/api/data/locations'); // Adjust the API endpoint as needed
|
||||
const locationsData = response.data
|
||||
.filter(location => location.isactive === true)
|
||||
.map(location => ({
|
||||
text: location.name,
|
||||
url: `/cart/locations/${location.id}`,
|
||||
}));
|
||||
// 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;
|
||||
}
|
||||
//setLocations(locationsData); // Optional, if you need to use locations elsewhere
|
||||
} catch (error) {
|
||||
console.error("Error fetching locations:", error);
|
||||
}
|
||||
};
|
||||
fetchLocations();
|
||||
}, []);
|
||||
|
||||
if (status === "loading") {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={toggleSidebar}
|
||||
className="fixed top-0 left-0 z-40 m-4 text-xl bg-white border border-gray-200 p-2 rounded-full shadow-lg focus:outline-none"
|
||||
style={{ transform: isSidebarOpen ? `translateX(${sidebarWidth - 64}px)` : 'translateX(-20px)' }}>☰</button>
|
||||
<aside id="sidenav" ref={sidebarRef}
|
||||
className="px-2 fixed top-0 left-0 z-30 h-screen overflow-y-auto bg-white border-r dark:bg-gray-900 dark:border-gray-700 transition-all duration-300 w-64"
|
||||
style={{ width: `${sidebarWidth}px`, transform: isSidebarOpen ? 'translateX(0)' : `translateX(-${sidebarWidth - 16}px)` }}>
|
||||
<h2 className="text-2xl font-semibold text-gray-800 dark:text-white pt-2 pl-4 pb-4"
|
||||
title={`v.${packageVersion} ${process.env.GIT_COMMIT_ID}`} >Специално Свидетелстване София</h2>
|
||||
<div className="flex flex-col justify-between pb-4">
|
||||
<nav>
|
||||
{sidemenu.map((item, index) => (
|
||||
<SidebarMenuItem key={index} item={item} session={session} />
|
||||
))}
|
||||
<hr className="my-6 border-gray-200 dark:border-gray-600" />
|
||||
{/* User section */}
|
||||
<UserSection session={session} />
|
||||
{/* Footer section: smaller lighter text */}
|
||||
<div className="mt-auto">
|
||||
<hr className="border-gray-200 dark:border-gray-600 text-align-bottom" />
|
||||
<FooterSection />
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UserSection({ session }) {
|
||||
return (
|
||||
<div className="sidemenu-item flex items-center">
|
||||
{!session ? <SignInButton /> : <UserDetails session={session} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SignInButton() {
|
||||
return (
|
||||
<div className="items-center py-6" onClick={() => signIn()}>
|
||||
<a href="/api/auth/signin">Впишете се</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserDetails({ session }) {
|
||||
return (
|
||||
<>
|
||||
<hr className="m-0" />
|
||||
<div className="flex items-center py-4 -mx-2">
|
||||
{session.user.image && (
|
||||
<img className="object-cover mx-2 rounded-full h-9 w-9" src={session.user.image} alt="avatar" />
|
||||
)}
|
||||
<div className="ml-3 overflow-hidden">
|
||||
<p className="mx-2 mt-2 text-sm font-medium text-gray-800 dark:text-gray-200">{session.user.name}</p>
|
||||
<p className="mx-2 mt-1 text-sm font-medium text-gray-600 dark:text-gray-400">{session.user.role}</p>
|
||||
<a href="/api/auth/signout" className={styles.button} onClick={(e) => { e.preventDefault(); signOut(); }}>Излезте</a>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterSection() {
|
||||
const router = useRouter();
|
||||
|
||||
const navigateTo = (url) => {
|
||||
router.push(url);
|
||||
};
|
||||
|
||||
return (
|
||||
footerMenu.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="px-4 py-2 mt-2 cursor-pointer hover:underline hover:text-blue-600 dark:hover:text-blue-400 "
|
||||
onClick={() => navigateTo(item.url)}
|
||||
>
|
||||
<span className="text-gray-700 dark:text-gray-300 font-medium">{item.text}</span>
|
||||
</div>
|
||||
))
|
||||
);
|
||||
}
|
||||
|
118
components/sidemenuData.js
Normal file
118
components/sidemenuData.js
Normal file
@ -0,0 +1,118 @@
|
||||
|
||||
import { UserRole } from "@prisma/client";
|
||||
|
||||
const sidemenu = [
|
||||
{
|
||||
id: "dashboard",
|
||||
text: "Предпочитания",
|
||||
url: "/dash",
|
||||
roles: [UserRole.ADMIN, UserRole.USER, UserRole.POWERUSER],
|
||||
svgData:
|
||||
"M19 11H5M19 11C20.1046 11 21 11.8954 21 13V19C21 20.1046 20.1046 21 19 21H5C3.89543 21 3 20.1046 3 19V13C3 11.8954 3.89543 11 5 11M19 11V9C19 7.89543 18.1046 7 17 7M5 11V9C5 7.89543 5.89543 7 7 7M7 7V5C7 3.89543 7.89543 3 9 3H15C16.1046 3 17 3.89543 17 5V7M7 7H17",
|
||||
},
|
||||
{
|
||||
id: "shedule",
|
||||
text: "График",
|
||||
url: "/cart/calendar/schedule",
|
||||
},
|
||||
{
|
||||
id: "locations",
|
||||
text: "Местоположения",
|
||||
svgData: "M12 2C8.13401 2 5 5.13401 5 9C5 14.25 12 22 12 22C12 22 19 14.25 19 9C19 5.13401 15.866 2 12 2ZM12 11.5C10.6193 11.5 9.5 10.3807 9.5 9C9.5 7.61929 10.6193 6.5 12 6.5C13.3807 6.5 14.5 7.61929 14.5 9C14.5 10.3807 13.3807 11.5 12 11.5Z", // Example SVG path for location icon
|
||||
url: "#",
|
||||
children: [], // Placeholder to be dynamically populated
|
||||
collapsable: true,
|
||||
url: "/cart/locations",
|
||||
},
|
||||
{
|
||||
id: "cart-report",
|
||||
text: "Отчет",
|
||||
url: "/cart/reports/report",
|
||||
},
|
||||
{
|
||||
id: "cart-experience",
|
||||
text: "Случка",
|
||||
url: "/cart/reports/experience",
|
||||
},
|
||||
{
|
||||
id: "guidelines",
|
||||
text: "Напътствия",
|
||||
url: "/guidelines",
|
||||
},
|
||||
{
|
||||
id: "contactAll",
|
||||
text: "Контакти",
|
||||
url: "/cart/publishers/contacts",
|
||||
},
|
||||
{
|
||||
id: "contactUs",
|
||||
text: "За връзка",
|
||||
url: "/contactUs",
|
||||
},
|
||||
{
|
||||
id: "admin",
|
||||
text: "Админ",
|
||||
url: "/admin",
|
||||
roles: [UserRole.ADMIN, UserRole.POWERUSER],
|
||||
svgData:
|
||||
"M19 11H5M19 11C20.1046 11 21 11.8954 21 13V19C21 20.1046 20.1046 21 19 21H5C3.89543 21 3 20.1046 3 19V13C3 11.8954 3.89543 11 5 11M19 11V9C19 7.89543 18.1046 7 17 7M5 11V9C5 7.89543 5.89543 7 7 7M7 7V5C7 3.89543 7.89543 3 9 3H15C16.1046 3 17 3.89543 17 5V7M7 7H17",
|
||||
children: [
|
||||
{
|
||||
id: "cart-places",
|
||||
text: "Места",
|
||||
url: "/cart/locations",
|
||||
roles: [UserRole.ADMIN, UserRole.POWERUSER],
|
||||
},
|
||||
{
|
||||
id: "cart-publishers",
|
||||
text: "Вестители",
|
||||
url: "/cart/publishers",
|
||||
roles: [UserRole.ADMIN, UserRole.POWERUSER],
|
||||
},
|
||||
// {
|
||||
// id: "cart-availability",
|
||||
// text: "Достъпности",
|
||||
// url: "/cart/availabilities",
|
||||
// roles: [UserRole.ADMIN, UserRole.POWERUSER],
|
||||
// },
|
||||
{
|
||||
id: "cart-events",
|
||||
text: "План",
|
||||
url: "/cart/cartevents",
|
||||
roles: [UserRole.ADMIN, UserRole.POWERUSER],
|
||||
},
|
||||
{
|
||||
id: "cart-calendar",
|
||||
text: "Календар",
|
||||
url: "/cart/calendar",
|
||||
roles: [UserRole.ADMIN, UserRole.POWERUSER],
|
||||
}, {
|
||||
id: "cart-reports",
|
||||
text: "Отчети",
|
||||
url: "/cart/reports/list",
|
||||
roles: [UserRole.ADMIN, UserRole.POWERUSER],
|
||||
},
|
||||
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
const footerMenu = [
|
||||
{
|
||||
id: "profile",
|
||||
text: "Настройки",
|
||||
url: `/cart/publishers/edit/me`,
|
||||
roles: [UserRole.ADMIN, UserRole.POWERUSER, UserRole.USER],
|
||||
svgData:
|
||||
"M16 7C16 9.20914 14.2091 11 12 11C9.79086 11 8 9.20914 8 7C8 4.79086 9.79086 3 12 3C14.2091 3 16 4.79086 16 7Z M12 14C8.13401 14 5 17.134 5 21H19C19 17.134 15.866 14 12 14Z"
|
||||
},
|
||||
{
|
||||
id: "privacy-policy",
|
||||
text: "Поверителност",
|
||||
url: "/privacy",
|
||||
},
|
||||
|
||||
]
|
||||
|
||||
export { footerMenu };
|
||||
export default sidemenu;
|
81
components/x-date-pickers/locales/bgBG.ts
Normal file
81
components/x-date-pickers/locales/bgBG.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { PickersLocaleText } from './utils/pickersLocaleTextApi';
|
||||
import { getPickersLocalization } from './utils/getPickersLocalization';
|
||||
|
||||
// Този обект не е Partial<PickersLocaleText>, защото това са стойностите по подразбиране
|
||||
|
||||
const bgBGPickers: PickersLocaleText<any> = {
|
||||
// Навигация в календара
|
||||
previousMonth: 'Предишен месец',
|
||||
nextMonth: 'Следващ месец',
|
||||
|
||||
// Навигация на изгледа
|
||||
openPreviousView: 'отвори предишен изглед',
|
||||
openNextView: 'отвори следващ изглед',
|
||||
calendarViewSwitchingButtonAriaLabel: (view) =>
|
||||
view === 'year'
|
||||
? 'отворен е годишен изглед, превключи към календарен изглед'
|
||||
: 'отворен е календарен изглед, превключи към годишен изглед',
|
||||
|
||||
// Мяста за дата
|
||||
start: 'Начало',
|
||||
end: 'Край',
|
||||
|
||||
// Акционен бар
|
||||
cancelButtonLabel: 'Отказ',
|
||||
clearButtonLabel: 'Изчисти',
|
||||
okButtonLabel: 'ОК',
|
||||
todayButtonLabel: 'Днес',
|
||||
|
||||
// Заглавия на инструментална лента
|
||||
datePickerToolbarTitle: 'Изберете дата',
|
||||
dateTimePickerToolbarTitle: 'Изберете дата и час',
|
||||
timePickerToolbarTitle: 'Изберете час',
|
||||
dateRangePickerToolbarTitle: 'Изберете период на дата',
|
||||
|
||||
// Етикети на часовника
|
||||
clockLabelText: (view, time, adapter) =>
|
||||
`Изберете ${view}. ${time === null ? 'Не е избрано време' : `Избраният час е ${adapter.format(time, 'fullTime')}`
|
||||
}`,
|
||||
hoursClockNumberText: (hours) => `${hours} часа`,
|
||||
minutesClockNumberText: (minutes) => `${minutes} минути`,
|
||||
secondsClockNumberText: (seconds) => `${seconds} секунди`,
|
||||
|
||||
// Етикети на цифров часовник
|
||||
selectViewText: (view) => `Изберете ${view}`,
|
||||
|
||||
// Етикети на календара
|
||||
calendarWeekNumberHeaderLabel: 'Номер на седмица',
|
||||
calendarWeekNumberHeaderText: '#',
|
||||
calendarWeekNumberAriaLabelText: (weekNumber) => `Седмица ${weekNumber}`,
|
||||
calendarWeekNumberText: (weekNumber) => `${weekNumber}`,
|
||||
|
||||
// Етикети за отваряне на избора
|
||||
openDatePickerDialogue: (value, utils) =>
|
||||
value !== null && utils.isValid(value)
|
||||
? `Изберете дата, избраната дата е ${utils.format(value, 'fullDate')}`
|
||||
: 'Изберете дата',
|
||||
openTimePickerDialogue: (value, utils) =>
|
||||
value !== null && utils.isValid(value)
|
||||
? `Изберете час, избраният час е ${utils.format(value, 'fullTime')}`
|
||||
: 'Изберете час',
|
||||
|
||||
fieldClearLabel: 'Изчисти стойност',
|
||||
|
||||
// Етикети на таблицата
|
||||
timeTableLabel: 'изберете време',
|
||||
dateTableLabel: 'изберете дата',
|
||||
|
||||
// Заместващи текстове в секции на полета
|
||||
fieldYearPlaceholder: (params) => 'Y'.repeat(params.digitAmount),
|
||||
fieldMonthPlaceholder: (params) => (params.contentType === 'letter' ? 'MMMM' : 'MM'),
|
||||
fieldDayPlaceholder: () => 'DD',
|
||||
fieldWeekDayPlaceholder: (params) => (params.contentType === 'letter' ? 'EEEE' : 'EE'),
|
||||
fieldHoursPlaceholder: () => 'hh',
|
||||
fieldMinutesPlaceholder: () => 'mm',
|
||||
fieldSecondsPlaceholder: () => 'ss',
|
||||
fieldMeridiemPlaceholder: () => 'aa',
|
||||
};
|
||||
|
||||
export const DEFAULT_LOCALE = bgBGPickers;
|
||||
|
||||
export const bgBG = getPickersLocalization(bgBGPickers);
|
@ -0,0 +1,13 @@
|
||||
import { PickersLocaleText } from './pickersLocaleTextApi';
|
||||
|
||||
export const getPickersLocalization = (pickersTranslations: Partial<PickersLocaleText<any>>) => {
|
||||
return {
|
||||
components: {
|
||||
MuiLocalizationProvider: {
|
||||
defaultProps: {
|
||||
localeText: { ...pickersTranslations },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
115
components/x-date-pickers/locales/utils/pickersLocaleTextApi.ts
Normal file
115
components/x-date-pickers/locales/utils/pickersLocaleTextApi.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import { TimeViewWithMeridiem } from '../../internals/models';
|
||||
import { DateView, TimeView, MuiPickersAdapter, FieldSectionContentType } from '../../models';
|
||||
|
||||
export interface PickersComponentSpecificLocaleText {
|
||||
/**
|
||||
* Title displayed in the toolbar of the `DatePicker` and its variants.
|
||||
* Will be overridden by the `toolbarTitle` translation key passed directly on the picker.
|
||||
*/
|
||||
datePickerToolbarTitle: string;
|
||||
/**
|
||||
* Title displayed in the toolbar of the `TimePicker` and its variants.
|
||||
* Will be overridden by the `toolbarTitle` translation key passed directly on the picker.
|
||||
*/
|
||||
timePickerToolbarTitle: string;
|
||||
/**
|
||||
* Title displayed in the toolbar of the `DateTimePicker` and its variants.
|
||||
* Will be overridden by the `toolbarTitle` translation key passed directly on the picker.
|
||||
*/
|
||||
dateTimePickerToolbarTitle: string;
|
||||
/**
|
||||
* Title displayed in the toolbar of the `DateRangePicker` and its variants.
|
||||
* Will be overridden by the `toolbarTitle` translation key passed directly on the picker.
|
||||
*/
|
||||
dateRangePickerToolbarTitle: string;
|
||||
}
|
||||
|
||||
export interface PickersComponentAgnosticLocaleText<TDate> {
|
||||
// Calendar navigation
|
||||
previousMonth: string;
|
||||
nextMonth: string;
|
||||
|
||||
// Calendar week number
|
||||
calendarWeekNumberHeaderLabel: string;
|
||||
calendarWeekNumberHeaderText: string;
|
||||
calendarWeekNumberAriaLabelText: (weekNumber: number) => string;
|
||||
calendarWeekNumberText: (weekNumber: number) => string;
|
||||
|
||||
// View navigation
|
||||
openPreviousView: string;
|
||||
openNextView: string;
|
||||
calendarViewSwitchingButtonAriaLabel: (currentView: DateView) => string;
|
||||
|
||||
// DateRange placeholders
|
||||
start: string;
|
||||
end: string;
|
||||
|
||||
// Action bar
|
||||
cancelButtonLabel: string;
|
||||
clearButtonLabel: string;
|
||||
okButtonLabel: string;
|
||||
todayButtonLabel: string;
|
||||
|
||||
// Clock labels
|
||||
clockLabelText: (view: TimeView, time: TDate | null, adapter: MuiPickersAdapter<TDate>) => string;
|
||||
hoursClockNumberText: (hours: string) => string;
|
||||
minutesClockNumberText: (minutes: string) => string;
|
||||
secondsClockNumberText: (seconds: string) => string;
|
||||
|
||||
// Digital clock labels
|
||||
selectViewText: (view: TimeViewWithMeridiem) => string;
|
||||
|
||||
// Open picker labels
|
||||
openDatePickerDialogue: (date: TDate | null, utils: MuiPickersAdapter<TDate>) => string;
|
||||
openTimePickerDialogue: (date: TDate | null, utils: MuiPickersAdapter<TDate>) => string;
|
||||
|
||||
// Clear button label
|
||||
fieldClearLabel: string;
|
||||
|
||||
// Table labels
|
||||
timeTableLabel: string;
|
||||
dateTableLabel: string;
|
||||
|
||||
// Field section placeholders
|
||||
fieldYearPlaceholder: (params: { digitAmount: number; format: string }) => string;
|
||||
fieldMonthPlaceholder: (params: {
|
||||
contentType: FieldSectionContentType;
|
||||
format: string;
|
||||
}) => string;
|
||||
fieldDayPlaceholder: (params: { format: string }) => string;
|
||||
fieldWeekDayPlaceholder: (params: {
|
||||
contentType: FieldSectionContentType;
|
||||
format: string;
|
||||
}) => string;
|
||||
fieldHoursPlaceholder: (params: { format: string }) => string;
|
||||
fieldMinutesPlaceholder: (params: { format: string }) => string;
|
||||
fieldSecondsPlaceholder: (params: { format: string }) => string;
|
||||
fieldMeridiemPlaceholder: (params: { format: string }) => string;
|
||||
}
|
||||
|
||||
export interface PickersLocaleText<TDate>
|
||||
extends PickersComponentAgnosticLocaleText<TDate>,
|
||||
PickersComponentSpecificLocaleText { }
|
||||
|
||||
export type PickersInputLocaleText<TDate> = Partial<PickersLocaleText<TDate>>;
|
||||
|
||||
/**
|
||||
* Translations that can be provided directly to the picker components.
|
||||
* It contains some generic translations like `toolbarTitle`
|
||||
* which will be dispatched to various translations keys in `PickersLocaleText`, depending on the pickers received them.
|
||||
*/
|
||||
export interface PickersInputComponentLocaleText<TDate>
|
||||
extends Partial<PickersComponentAgnosticLocaleText<TDate>> {
|
||||
/**
|
||||
* Title displayed in the toolbar of this picker.
|
||||
* Will override the global translation keys like `datePickerToolbarTitle` passed to the `LocalizationProvider`.
|
||||
*/
|
||||
toolbarTitle?: string;
|
||||
}
|
||||
|
||||
export type PickersTranslationKeys = keyof PickersLocaleText<any>;
|
||||
|
||||
export type LocalizedComponent<
|
||||
TDate,
|
||||
Props extends { localeText?: PickersInputComponentLocaleText<TDate> },
|
||||
> = Omit<Props, 'localeText'> & { localeText?: PickersInputLocaleText<TDate> };
|
11
config.json
Normal file
11
config.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"checkboxUI": {
|
||||
"enabled": true,
|
||||
"timeIncrements": 1.5,
|
||||
"options": {
|
||||
"allDay": true,
|
||||
"morningTransport": true,
|
||||
"eveningTransport": true
|
||||
}
|
||||
}
|
||||
}
|
1416
content/April.html
Normal file
1416
content/April.html
Normal file
File diff suppressed because it is too large
Load Diff
22
content/cerrt/Azure-MySqlDigiCertGlobalRootCA.crt.pem
Normal file
22
content/cerrt/Azure-MySqlDigiCertGlobalRootCA.crt.pem
Normal file
@ -0,0 +1,22 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDrzCCApegAwIBAgIQCDvgVpBCRrGhdWrJWZHHSjANBgkqhkiG9w0BAQUFADBh
|
||||
MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3
|
||||
d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD
|
||||
QTAeFw0wNjExMTAwMDAwMDBaFw0zMTExMTAwMDAwMDBaMGExCzAJBgNVBAYTAlVT
|
||||
MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j
|
||||
b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IENBMIIBIjANBgkqhkiG
|
||||
9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4jvhEXLeqKTTo1eqUKKPC3eQyaKl7hLOllsB
|
||||
CSDMAZOnTjC3U/dDxGkAV53ijSLdhwZAAIEJzs4bg7/fzTtxRuLWZscFs3YnFo97
|
||||
nh6Vfe63SKMI2tavegw5BmV/Sl0fvBf4q77uKNd0f3p4mVmFaG5cIzJLv07A6Fpt
|
||||
43C/dxC//AH2hdmoRBBYMql1GNXRor5H4idq9Joz+EkIYIvUX7Q6hL+hqkpMfT7P
|
||||
T19sdl6gSzeRntwi5m3OFBqOasv+zbMUZBfHWymeMr/y7vrTC0LUq7dBMtoM1O/4
|
||||
gdW7jVg/tRvoSSiicNoxBN33shbyTApOB6jtSj1etX+jkMOvJwIDAQABo2MwYTAO
|
||||
BgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA95QNVbR
|
||||
TLtm8KPiGxvDl7I90VUwHwYDVR0jBBgwFoAUA95QNVbRTLtm8KPiGxvDl7I90VUw
|
||||
DQYJKoZIhvcNAQEFBQADggEBAMucN6pIExIK+t1EnE9SsPTfrgT1eXkIoyQY/Esr
|
||||
hMAtudXH/vTBH1jLuG2cenTnmCmrEbXjcKChzUyImZOMkXDiqw8cvpOp/2PV5Adg
|
||||
06O/nVsJ8dWO41P0jmP6P6fbtGbfYmbW0W5BjfIttep3Sp+dWOIrWcBAI+0tKIJF
|
||||
PnlUkiaY4IBIqDfv8NZ5YBberOgOzW6sRBc4L0na4UU+Krk2U886UAb3LujEV0ls
|
||||
YSEY1QSteDwsOoBrp+uvFRTp2InBuThs4pFsiv9kuXclVzDAGySj4dzp30d8tbQk
|
||||
CAUw7C29C79Fv1C5qfPrmAESrciIxpg0X40KPMbp1ZWVbd4=
|
||||
-----END CERTIFICATE-----
|
1
content/missingPublishers.json
Normal file
1
content/missingPublishers.json
Normal file
@ -0,0 +1 @@
|
||||
[]
|
73516
content/publisherShiftStats.json
Normal file
73516
content/publisherShiftStats.json
Normal file
File diff suppressed because it is too large
Load Diff
1
content/publishersWithChangedPref.json
Normal file
1
content/publishersWithChangedPref.json
Normal file
@ -0,0 +1 @@
|
||||
[]
|
1
content/temp/График 2023-09.json
Normal file
1
content/temp/График 2023-09.json
Normal file
File diff suppressed because one or more lines are too long
1
content/temp/График 2023-10.json
Normal file
1
content/temp/График 2023-10.json
Normal file
File diff suppressed because one or more lines are too long
1
content/temp/График 2023-11.json
Normal file
1
content/temp/График 2023-11.json
Normal file
File diff suppressed because one or more lines are too long
1
content/temp/График 2023-12.json
Normal file
1
content/temp/График 2023-12.json
Normal file
File diff suppressed because one or more lines are too long
1
content/temp/График 2024-01.json
Normal file
1
content/temp/График 2024-01.json
Normal file
File diff suppressed because one or more lines are too long
1
content/temp/График 2024-02.json
Normal file
1
content/temp/График 2024-02.json
Normal file
File diff suppressed because one or more lines are too long
1
content/temp/График 2024-03.json
Normal file
1
content/temp/График 2024-03.json
Normal file
File diff suppressed because one or more lines are too long
1
content/temp/График source 2023-10.json
Normal file
1
content/temp/График source 2023-10.json
Normal file
File diff suppressed because one or more lines are too long
1
fuzzySearchResult.txt
Normal file
1
fuzzySearchResult.txt
Normal file
File diff suppressed because one or more lines are too long
12
me.tsx
Normal file
12
me.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { useSession } from "next-auth/react"
|
||||
import Layout from "./components/layout"
|
||||
|
||||
export default function MePage() {
|
||||
const { data } = useSession()
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<pre>{JSON.stringify(data, null, 2)}</pre>
|
||||
</Layout>
|
||||
)
|
||||
}
|
41
middleware.ts
Normal file
41
middleware.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { withAuth } from "next-auth/middleware";
|
||||
|
||||
import { UserRole } from "@prisma/client";
|
||||
|
||||
// More on how NextAuth.js middleware works: https://next-auth.js.org/configuration/nextjs#middleware
|
||||
export default withAuth({
|
||||
callbacks: {
|
||||
authorized({ req, token }) {
|
||||
console.log("req", req);
|
||||
// `/admin` requires admin role
|
||||
if (req.nextUrl.pathname === "/examples/admin") {
|
||||
return token?.userRole === "adminer"
|
||||
}
|
||||
|
||||
if (req.nextUrl.pathname === "/cart" && token?.role === UserRole.ADMIN) {
|
||||
// return NextResponse.redirect(new URL("/", req.url));
|
||||
return true;
|
||||
}
|
||||
// if(req.nextUrl.pathname === "/cart"){
|
||||
// return token?.role !== Role.ADMIN;
|
||||
// }
|
||||
|
||||
|
||||
// `/me` only requires the user to be logged in
|
||||
return !!token
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const config = {
|
||||
// matcher: ["/admin", "/me"]
|
||||
matcher: ["/admin/:path*", "/me", "/cart, /"],
|
||||
// callbackUrl: {
|
||||
// name: `__Secure-next-auth.callback-url`,
|
||||
// options: {
|
||||
// sameSite: 'lax',
|
||||
// path: '/',
|
||||
// secure: true
|
||||
// }
|
||||
// },
|
||||
}
|
31
next-auth.d.ts
vendored
Normal file
31
next-auth.d.ts
vendored
Normal file
@ -0,0 +1,31 @@
|
||||
// import { Role } from "@prisma/client";
|
||||
// import { DefaultSession } from "next-auth";
|
||||
// import { JWT } from "next-auth/jwt";
|
||||
|
||||
// Read more at: https://next-auth.js.org/getting-started/typescript#module-augmentation
|
||||
|
||||
declare module "next-auth/jwt" {
|
||||
interface JWT {
|
||||
/** The user's role. */
|
||||
role?: Role;
|
||||
userRole?: "admin"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// declare module "next-auth" {
|
||||
// /**
|
||||
// * Returned by `useSession`, `getSession` and received as a prop on the
|
||||
// * `SessionProvider` React Context and trpc context
|
||||
// */
|
||||
// interface Session {
|
||||
// user?: {
|
||||
// role?: Role;
|
||||
// } & DefaultSession["user"];
|
||||
// }
|
||||
|
||||
// /** Passed as a parameter to the `jwt` callback */
|
||||
// interface User {
|
||||
// role?: Role;
|
||||
// }
|
||||
// }
|
45
next.config.js
Normal file
45
next.config.js
Normal file
@ -0,0 +1,45 @@
|
||||
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
|
||||
|
||||
module.exports = {
|
||||
typescript: {
|
||||
// !! WARN !!
|
||||
// Dangerously allow production builds to successfully complete even if
|
||||
// your project has type errors.
|
||||
// !! WARN !!
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
compress: false,
|
||||
pageExtensions: ['ts', 'tsx', 'md', 'mdx'], // Replace `jsx?` with `tsx?`
|
||||
env: {
|
||||
env: process.env.NODE_ENV,
|
||||
server: 'http://' + process.env.NEXT_PUBLIC_HOST + ':' + process.env.NEXT_PUBLIC_PORT + '',
|
||||
},
|
||||
webpack(config, { isServer }) {
|
||||
|
||||
config.optimization.minimize = false;
|
||||
productionBrowserSourceMaps: true,
|
||||
|
||||
config.resolve.fallback = {
|
||||
|
||||
// if you miss it, all the other options in fallback, specified
|
||||
// by next.js will be dropped.
|
||||
...config.resolve.fallback,
|
||||
|
||||
fs: false, // the solution
|
||||
};
|
||||
|
||||
|
||||
// Only run the bundle analyzer for production builds and when the ANALYZE environment variable is set
|
||||
if (process.env.ANALYZE && !isServer) {
|
||||
config.plugins.push(
|
||||
new BundleAnalyzerPlugin({
|
||||
analyzerMode: 'static',
|
||||
openAnalyzer: true,
|
||||
generateStatsFile: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
}
|
7
nodemon.json
Normal file
7
nodemon.json
Normal file
@ -0,0 +1,7 @@
|
||||
|
||||
{
|
||||
"verbose": true,
|
||||
"ignore": ["node_modules", ".next"],
|
||||
"watch": ["server/**/*", "server.js","src/helpers.js","src/**/*", "next.config.js"],
|
||||
"ext": "js json"
|
||||
}
|
BIN
output.docx
Normal file
BIN
output.docx
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user