initial commit - code moved to separate repo
This commit is contained in:
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> };
|
Reference in New Issue
Block a user