mirror of
https://github.com/MAKS-IT-COM/maksit-certs-ui.git
synced 2025-12-31 04:00:03 +01:00
(feature): migrate to cutom react from next init
This commit is contained in:
parent
81d602e381
commit
edacd27aef
@ -1,38 +1,16 @@
|
|||||||
# Use the specific Node.js version 20.14.0 image as the base image for building the app
|
FROM node:20.14.0-alpine
|
||||||
FROM node:20.14.0-alpine AS build
|
|
||||||
|
|
||||||
# Set the working directory inside the container
|
# Ambiente di sviluppo
|
||||||
|
ENV NODE_ENV=development
|
||||||
|
ENV LETSENCRYPT_SERVER=http://localhost:8080
|
||||||
|
ENV CHOKIDAR_USEPOLLING=true
|
||||||
|
ENV WATCHPACK_POLLING=true
|
||||||
|
|
||||||
|
# Lavora come utente normale
|
||||||
|
USER node
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy the package.json and package-lock.json files to the working directory
|
|
||||||
COPY ClientApp/package*.json ./
|
|
||||||
|
|
||||||
# Install the project dependencies
|
|
||||||
RUN npm install
|
|
||||||
|
|
||||||
# Copy the rest of the application code to the working directory
|
|
||||||
COPY ClientApp .
|
|
||||||
|
|
||||||
# Build the Next.js application for production
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# Use the same Node.js image for running the app
|
|
||||||
FROM node:20.14.0-alpine AS production
|
|
||||||
|
|
||||||
# Set the working directory inside the container
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy the package.json and package-lock.json files to the working directory
|
|
||||||
COPY ClientApp/package*.json ./
|
|
||||||
|
|
||||||
# Install only production dependencies
|
|
||||||
RUN npm install --only=production
|
|
||||||
|
|
||||||
# Copy the built Next.js application from the build stage to the current directory
|
|
||||||
COPY --from=build /app ./
|
|
||||||
|
|
||||||
# Expose port 3000 to access the application
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Start the Next.js server
|
# Comando di avvio
|
||||||
CMD ["npm", "start"]
|
CMD ["sh", "-c", "npm install && npm run dev"]
|
||||||
|
|||||||
38
src/ClientApp/Dockerfile.prod
Normal file
38
src/ClientApp/Dockerfile.prod
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
# Use the specific Node.js version 20.14.0 image as the base image for building the app
|
||||||
|
FROM node:20.14.0-alpine AS build
|
||||||
|
|
||||||
|
# Set the working directory inside the container
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy the package.json and package-lock.json files to the working directory
|
||||||
|
COPY ClientApp/package*.json ./
|
||||||
|
|
||||||
|
# Install the project dependencies
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copy the rest of the application code to the working directory
|
||||||
|
COPY ClientApp .
|
||||||
|
|
||||||
|
# Build the Next.js application for production
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Use the same Node.js image for running the app
|
||||||
|
FROM node:20.14.0-alpine AS production
|
||||||
|
|
||||||
|
# Set the working directory inside the container
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy the package.json and package-lock.json files to the working directory
|
||||||
|
COPY ClientApp/package*.json ./
|
||||||
|
|
||||||
|
# Install only production dependencies
|
||||||
|
RUN npm install --only=production
|
||||||
|
|
||||||
|
# Copy the built Next.js application from the build stage to the current directory
|
||||||
|
COPY --from=build /app ./
|
||||||
|
|
||||||
|
# Expose port 3000 to access the application
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Start the Next.js server
|
||||||
|
CMD ["npm", "start"]
|
||||||
@ -38,13 +38,13 @@ export default function Page() {
|
|||||||
|
|
||||||
const fetchAccounts = async () => {
|
const fetchAccounts = async () => {
|
||||||
const newAccounts: CacheAccount[] = []
|
const newAccounts: CacheAccount[] = []
|
||||||
const gatAccountsResult = await httpService.get<GetAccountResponse[]>(
|
const getAccountsResult = await httpService.get<GetAccountResponse[]>(
|
||||||
GetApiRoute(ApiRoutes.ACCOUNTS)
|
GetApiRoute(ApiRoutes.ACCOUNTS)
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!gatAccountsResult.isSuccess) return
|
if (!getAccountsResult.isSuccess) return
|
||||||
|
|
||||||
gatAccountsResult.data?.forEach((account) => {
|
getAccountsResult.data?.forEach((account) => {
|
||||||
newAccounts.push(toCacheAccount(account))
|
newAccounts.push(toCacheAccount(account))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -1,39 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { ApiRoutes, GetApiRoute } from '@/ApiRoutes'
|
|
||||||
import { PageContainer } from '@/components/pageContainer'
|
|
||||||
import { CustomButton } from '@/controls'
|
|
||||||
import { showToast } from '@/redux/slices/toastSlice'
|
|
||||||
import { useAppDispatch } from '@/redux/store'
|
|
||||||
import { httpService } from '@/services/HttpService'
|
|
||||||
|
|
||||||
const TestPage = () => {
|
|
||||||
const dispatch = useAppDispatch()
|
|
||||||
|
|
||||||
const handleTestAgent = async () => {
|
|
||||||
httpService
|
|
||||||
.get<string>(GetApiRoute(ApiRoutes.AGENT_TEST))
|
|
||||||
.then((response) => {
|
|
||||||
dispatch(
|
|
||||||
showToast({
|
|
||||||
message: JSON.stringify(response),
|
|
||||||
type: 'info'
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContainer title="CertsUI Tests">
|
|
||||||
<CustomButton
|
|
||||||
type="button"
|
|
||||||
onClick={handleTestAgent}
|
|
||||||
className="bg-green-500 text-white p-2 rounded ml-2"
|
|
||||||
>
|
|
||||||
Test Agent
|
|
||||||
</CustomButton>
|
|
||||||
</PageContainer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default TestPage
|
|
||||||
78
src/ClientApp/app/utils/page.tsx
Normal file
78
src/ClientApp/app/utils/page.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { ApiRoutes, GetApiRoute } from '@/ApiRoutes'
|
||||||
|
import { PageContainer } from '@/components/pageContainer'
|
||||||
|
import { CustomButton, CustomFileUploader } from '@/controls'
|
||||||
|
import { showToast } from '@/redux/slices/toastSlice'
|
||||||
|
import { useAppDispatch } from '@/redux/store'
|
||||||
|
import { httpService } from '@/services/HttpService'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
const TestPage = () => {
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
|
const [files, setFiles] = useState<File[] | null>(null)
|
||||||
|
|
||||||
|
const handleTestAgent = async () => {
|
||||||
|
httpService
|
||||||
|
.get<string>(GetApiRoute(ApiRoutes.AGENT_TEST))
|
||||||
|
.then((response) => {
|
||||||
|
dispatch(
|
||||||
|
showToast({
|
||||||
|
message: JSON.stringify(response),
|
||||||
|
type: 'info'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDownloadCache = async () => {}
|
||||||
|
|
||||||
|
const handleUploadCache = async () => {}
|
||||||
|
|
||||||
|
const handleRestoreFromCache = async () => {}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
handleUploadCache()
|
||||||
|
}
|
||||||
|
}, [files])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer title="CertsUI Utils">
|
||||||
|
<CustomButton
|
||||||
|
type="button"
|
||||||
|
onClick={handleTestAgent}
|
||||||
|
className="bg-green-500 text-white p-2 rounded ml-2"
|
||||||
|
>
|
||||||
|
Test Agent
|
||||||
|
</CustomButton>
|
||||||
|
|
||||||
|
<CustomButton
|
||||||
|
type="button"
|
||||||
|
onClick={handleRestoreFromCache}
|
||||||
|
className="bg-yellow-500 text-white p-2 rounded ml-2"
|
||||||
|
>
|
||||||
|
Restore from cache
|
||||||
|
</CustomButton>
|
||||||
|
|
||||||
|
<CustomButton
|
||||||
|
type="button"
|
||||||
|
onClick={handleDownloadCache}
|
||||||
|
className="bg-blue-500 text-white p-2 rounded ml-2"
|
||||||
|
>
|
||||||
|
Download cache
|
||||||
|
</CustomButton>
|
||||||
|
|
||||||
|
<CustomFileUploader
|
||||||
|
value={files}
|
||||||
|
onChange={setFiles}
|
||||||
|
accept=".zip"
|
||||||
|
buttonClassName="bg-blue-500 text-white p-2 rounded ml-2"
|
||||||
|
title="Upload cache"
|
||||||
|
/>
|
||||||
|
</PageContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TestPage
|
||||||
@ -16,7 +16,7 @@ interface SideMenuProps {
|
|||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ icon: <FaSyncAlt />, label: 'Auto Renew', path: '/' },
|
{ icon: <FaSyncAlt />, label: 'Auto Renew', path: '/' },
|
||||||
{ icon: <FaUserPlus />, label: 'Register', path: '/register' },
|
{ icon: <FaUserPlus />, label: 'Register', path: '/register' },
|
||||||
{ icon: <FaThermometerHalf />, label: 'Test', path: '/test' }
|
{ icon: <FaThermometerHalf />, label: 'Utils', path: '/utils' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const SideMenu: FC<SideMenuProps> = ({ isCollapsed, toggleSidebar }) => {
|
const SideMenu: FC<SideMenuProps> = ({ isCollapsed, toggleSidebar }) => {
|
||||||
|
|||||||
98
src/ClientApp/controls/customFileUploader.tsx
Normal file
98
src/ClientApp/controls/customFileUploader.tsx
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
'use client'
|
||||||
|
import React, { FC, useRef } from 'react'
|
||||||
|
import { CustomButton } from './customButton'
|
||||||
|
|
||||||
|
interface CustomFileUploaderProps {
|
||||||
|
value?: File[] | null
|
||||||
|
onChange?: (files: File[] | null) => void
|
||||||
|
label?: string
|
||||||
|
labelClassName?: string
|
||||||
|
error?: string
|
||||||
|
errorClassName?: string
|
||||||
|
className?: string
|
||||||
|
buttonClassName?: string
|
||||||
|
inputClassName?: string
|
||||||
|
readOnly?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
accept?: string
|
||||||
|
multiple?: boolean
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomFileUploader: FC<CustomFileUploaderProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
labelClassName = '',
|
||||||
|
error,
|
||||||
|
errorClassName = '',
|
||||||
|
className = '',
|
||||||
|
buttonClassName = '',
|
||||||
|
inputClassName = '',
|
||||||
|
readOnly = false,
|
||||||
|
disabled = false,
|
||||||
|
accept,
|
||||||
|
multiple = false,
|
||||||
|
title
|
||||||
|
}) => {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (!readOnly && !disabled) {
|
||||||
|
const files = e.target.files ? Array.from(e.target.files) : null
|
||||||
|
onChange?.(files && files.length > 0 ? files : null)
|
||||||
|
// Reset input value so the same file can be selected again
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center ${className}`}>
|
||||||
|
{label && <label className={`mb-1 ${labelClassName}`}>{label}</label>}
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={disabled}
|
||||||
|
readOnly={readOnly}
|
||||||
|
accept={accept}
|
||||||
|
multiple={multiple}
|
||||||
|
/>
|
||||||
|
{value && value.length > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="flex flex-row gap-2 mr-2 text-sm text-gray-600">
|
||||||
|
{value.map((file, idx) => (
|
||||||
|
<span key={file.name + idx}>{file.name}</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<CustomButton
|
||||||
|
onClick={() => {
|
||||||
|
onChange?.(null)
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||||
|
}}
|
||||||
|
className={buttonClassName + ' ml-1'}
|
||||||
|
disabled={disabled || readOnly}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</CustomButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<CustomButton
|
||||||
|
onClick={() => {
|
||||||
|
if (!disabled && !readOnly) fileInputRef.current?.click()
|
||||||
|
}}
|
||||||
|
className={buttonClassName}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</CustomButton>
|
||||||
|
{error && (
|
||||||
|
<span className={`text-red-500 mt-1 ${errorClassName}`}>{error}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CustomFileUploader }
|
||||||
@ -4,6 +4,7 @@ import { CustomCheckbox } from './customCheckbox'
|
|||||||
import { CustomSelect } from './customSelect'
|
import { CustomSelect } from './customSelect'
|
||||||
import { CustomEnumSelect } from './customEnumSelect'
|
import { CustomEnumSelect } from './customEnumSelect'
|
||||||
import { CustomRadioGroup } from './customRadioGroup'
|
import { CustomRadioGroup } from './customRadioGroup'
|
||||||
|
import { CustomFileUploader } from './customFileUploader'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
CustomButton,
|
CustomButton,
|
||||||
@ -11,5 +12,6 @@ export {
|
|||||||
CustomCheckbox,
|
CustomCheckbox,
|
||||||
CustomSelect,
|
CustomSelect,
|
||||||
CustomEnumSelect,
|
CustomEnumSelect,
|
||||||
CustomRadioGroup
|
CustomRadioGroup,
|
||||||
|
CustomFileUploader
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {};
|
|
||||||
|
|
||||||
export default nextConfig;
|
const nextConfig = {}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export default nextConfig
|
||||||
|
|||||||
5773
src/ClientApp/package-lock.json
generated
5773
src/ClientApp/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -9,28 +9,30 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@heroicons/react": "^2.1.3",
|
"@heroicons/react": "^2.2.0",
|
||||||
"@reduxjs/toolkit": "^2.2.5",
|
"@reduxjs/toolkit": "^2.9.2",
|
||||||
"next": "14.2.3",
|
"autoprefixer": "^10.4.21",
|
||||||
"react": "^18",
|
"next": "16.0.1",
|
||||||
"react-dom": "^18",
|
"react": "^19",
|
||||||
"react-icons": "^5.2.1",
|
"react-dom": "^19",
|
||||||
"react-redux": "^9.1.2",
|
"react-icons": "^5.5.0",
|
||||||
"react-toastify": "^10.0.5",
|
"react-redux": "^9.2.0",
|
||||||
"uuid": "^10.0.0"
|
"react-toastify": "^11.0.5",
|
||||||
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@tailwindcss/postcss": "^4.1.16",
|
||||||
"@types/react": "^18",
|
"@types/node": "^24",
|
||||||
"@types/react-dom": "^18",
|
"@types/react": "^19",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^8",
|
"@types/uuid": "^11.0.0",
|
||||||
"eslint-config-next": "14.2.3",
|
"eslint": "^9",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-next": "16.0.1",
|
||||||
"eslint-plugin-prettier": "^5.1.3",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-prettier": "^5.5.4",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"prettier": "^3.3.2",
|
"prettier": "^3.6.2",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^4.1.16",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
/** @type {import('postcss-load-config').Config} */
|
/** @type {import('postcss-load-config').Config} */
|
||||||
const config = {
|
const config = {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
'@tailwindcss/postcss': {},
|
||||||
|
autoprefixer: {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
@ -10,7 +14,7 @@
|
|||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "react-jsx",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
@ -18,9 +22,20 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./*"]
|
"@/*": [
|
||||||
}
|
"./*"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"target": "ES2017"
|
||||||
"exclude": ["node_modules"]
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="MaksIT.Core" Version="1.5.1" />
|
<PackageReference Include="MaksIT.Core" Version="1.5.1" />
|
||||||
<PackageReference Include="MaksIT.Results" Version="1.0.9" />
|
<PackageReference Include="MaksIT.Results" Version="1.1.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.10" />
|
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.10" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.10" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.10" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" />
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="MaksIT.Results" Version="1.0.9" />
|
<PackageReference Include="MaksIT.Results" Version="1.1.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.10" />
|
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.10" />
|
||||||
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" />
|
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
|
||||||
|
|||||||
3
src/MaksIT.WebUI/.env
Normal file
3
src/MaksIT.WebUI/.env
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
VITE_APP_TITLE=MaksIT.CertsUI
|
||||||
|
VITE_COMPANY=MaksIT
|
||||||
|
VITE_API_URL=http://localhost:8080/api
|
||||||
15
src/MaksIT.WebUI/.vscode/launch.json
vendored
Normal file
15
src/MaksIT.WebUI/.vscode/launch.json
vendored
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"type": "chrome",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "Launch Chrome against localhost",
|
||||||
|
"url": "http://localhost:5173",
|
||||||
|
"webRoot": "${workspaceFolder}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
12
src/MaksIT.WebUI/.vscode/settings.json
vendored
Normal file
12
src/MaksIT.WebUI/.vscode/settings.json
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"eslint.format.enable": true,
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll.eslint": "explicit"
|
||||||
|
},
|
||||||
|
"eslint.validate": [
|
||||||
|
"javascript",
|
||||||
|
"javascriptreact",
|
||||||
|
"typescript",
|
||||||
|
"typescriptreact"
|
||||||
|
]
|
||||||
|
}
|
||||||
13
src/MaksIT.WebUI/Dockerfile
Normal file
13
src/MaksIT.WebUI/Dockerfile
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
FROM node:24
|
||||||
|
|
||||||
|
# Ambiente di sviluppo
|
||||||
|
ENV NODE_ENV=development
|
||||||
|
|
||||||
|
# Lavora come utente normale
|
||||||
|
USER node
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
EXPOSE 5173
|
||||||
|
|
||||||
|
# Comando di avvio
|
||||||
|
CMD ["sh", "-c", "npm install && npm run dev"]
|
||||||
1
src/MaksIT.WebUI/Dockerfile.prod
Normal file
1
src/MaksIT.WebUI/Dockerfile.prod
Normal file
@ -0,0 +1 @@
|
|||||||
|
FROM node:24
|
||||||
25
src/MaksIT.WebUI/New Text Document.txt
Normal file
25
src/MaksIT.WebUI/New Text Document.txt
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
npm create vite@latest my-react-app
|
||||||
|
|
||||||
|
npm install -D tailwindcss postcss autoprefixer
|
||||||
|
npx tailwindcss init -p
|
||||||
|
|
||||||
|
tailwind.config.js
|
||||||
|
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
index.css
|
||||||
|
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
50
src/MaksIT.WebUI/README.md
Normal file
50
src/MaksIT.WebUI/README.md
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||||
|
|
||||||
|
- Configure the top-level `parserOptions` property like this:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default tseslint.config({
|
||||||
|
languageOptions: {
|
||||||
|
// other options...
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
|
||||||
|
- Optionally add `...tseslint.configs.stylisticTypeChecked`
|
||||||
|
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import react from 'eslint-plugin-react'
|
||||||
|
|
||||||
|
export default tseslint.config({
|
||||||
|
// Set the react version
|
||||||
|
settings: { react: { version: '18.3' } },
|
||||||
|
plugins: {
|
||||||
|
// Add the react plugin
|
||||||
|
react,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// other rules...
|
||||||
|
// Enable its recommended rules
|
||||||
|
...react.configs.recommended.rules,
|
||||||
|
...react.configs['jsx-runtime'].rules,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
50
src/MaksIT.WebUI/eslint.config.js
Normal file
50
src/MaksIT.WebUI/eslint.config.js
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import react from 'eslint-plugin-react'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['dist'] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
parser: tseslint.parser,
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
react, // Import and use the React plugin
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
'@typescript-eslint': tseslint.plugin,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// react plugin rules
|
||||||
|
'react/jsx-curly-brace-presence': [
|
||||||
|
'error',
|
||||||
|
{ props: 'always', children: 'never' }
|
||||||
|
],
|
||||||
|
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
|
||||||
|
// react-refresh plugin rules
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
|
||||||
|
// ESLint core rules
|
||||||
|
'quotes': ['error', 'single'], // Enforce single quotes
|
||||||
|
'semi': ['error', 'never'], // Disallow semicolons
|
||||||
|
'indent': ['error', 2], // Enforce 2-space indentation
|
||||||
|
'jsx-quotes': ['error', 'prefer-single'], // Enforce single quotes in JSX attributes
|
||||||
|
|
||||||
|
// @typescript-eslint plugin rules
|
||||||
|
'@typescript-eslint/no-unused-vars': ['warn', { 'argsIgnorePattern': '^_', 'ignoreRestSiblings': true }],
|
||||||
|
'@typescript-eslint/no-empty-object-type': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
13
src/MaksIT.WebUI/index.html
Normal file
13
src/MaksIT.WebUI/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>MaksIT.CertsUI</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
6214
src/MaksIT.WebUI/package-lock.json
generated
Normal file
6214
src/MaksIT.WebUI/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
53
src/MaksIT.WebUI/package.json
Normal file
53
src/MaksIT.WebUI/package.json
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"name": "maksit-vault",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "^2.8.2",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"axios": "^1.11.0",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"jwt-decode": "^4.0.0",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"lucide-react": "^0.536.0",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
|
"react": "^19.1.1",
|
||||||
|
"react-dom": "^19.1.1",
|
||||||
|
"react-redux": "^9.2.0",
|
||||||
|
"react-router-dom": "^7.7.1",
|
||||||
|
"react-virtualized": "^9.22.6",
|
||||||
|
"uuid": "^11.1.0",
|
||||||
|
"zod": "^4.0.14"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.32.0",
|
||||||
|
"@tailwindcss/aspect-ratio": "^0.4.2",
|
||||||
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
|
"@tailwindcss/line-clamp": "^0.4.4",
|
||||||
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
|
"@types/lodash": "^4.17.20",
|
||||||
|
"@types/react": "^19.1.9",
|
||||||
|
"@types/react-dom": "^19.1.7",
|
||||||
|
"@types/react-resizable": "^3.0.8",
|
||||||
|
"@types/react-virtualized": "^9.22.2",
|
||||||
|
"@vitejs/plugin-react-swc": "^3.11.0",
|
||||||
|
"eslint": "^9.32.0",
|
||||||
|
"eslint-plugin-react": "^7.37.5",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
|
"globals": "^16.3.0",
|
||||||
|
"tailwindcss": "^4.1.11",
|
||||||
|
"typescript": "^5.8.0",
|
||||||
|
"typescript-eslint": "^8.38.0",
|
||||||
|
"vite": "^7.0.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
src/MaksIT.WebUI/public/logo.png
Normal file
BIN
src/MaksIT.WebUI/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
1
src/MaksIT.WebUI/public/vite.svg
Normal file
1
src/MaksIT.WebUI/public/vite.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
18
src/MaksIT.WebUI/src/App.tsx
Normal file
18
src/MaksIT.WebUI/src/App.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
|
||||||
|
import { Routes } from 'react-router-dom'
|
||||||
|
import { GetRoutes } from './AppMap'
|
||||||
|
import { Loader } from './components/Loader'
|
||||||
|
|
||||||
|
const App = () => {
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Routes>
|
||||||
|
{GetRoutes()}
|
||||||
|
</Routes>
|
||||||
|
<Loader />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
App
|
||||||
|
}
|
||||||
232
src/MaksIT.WebUI/src/AppMap.tsx
Normal file
232
src/MaksIT.WebUI/src/AppMap.tsx
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
import { Link, Route } from 'react-router-dom'
|
||||||
|
import { HomePage } from './pages/HomePage'
|
||||||
|
import { ComponentType, FC, ReactNode } from 'react'
|
||||||
|
import { Layout } from './components/Layout'
|
||||||
|
import { LoginScreen } from './components/LoginScreen'
|
||||||
|
import { Authorization } from './components/Authorization'
|
||||||
|
import { UserOffcanvas } from './components/UserOffcanvas'
|
||||||
|
import { UserButton } from './components/UserButton'
|
||||||
|
import { Toast } from './components/Toast'
|
||||||
|
import { UtilitiesPage } from './pages/UtilitiesPage'
|
||||||
|
import { RegisterPage } from './pages/RegisterPage'
|
||||||
|
|
||||||
|
|
||||||
|
interface LayoutWrapperProps {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const LayoutWrapper: FC<LayoutWrapperProps> = (props) => {
|
||||||
|
const { children } = props
|
||||||
|
|
||||||
|
return <Layout
|
||||||
|
sideMenu={
|
||||||
|
{
|
||||||
|
headerChildren: <p>{import.meta.env.VITE_APP_TITLE}</p> ,
|
||||||
|
children: <ul>
|
||||||
|
{GetMenuItems(LinkArea.SideMenu)}
|
||||||
|
</ul>,
|
||||||
|
footerChildren: <></>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
header={
|
||||||
|
{
|
||||||
|
children: <>
|
||||||
|
<ul className={'flex space-x-4'}>
|
||||||
|
{/* <li>Item 1</li> */}
|
||||||
|
</ul>
|
||||||
|
<ul className={'flex space-x-4'}>
|
||||||
|
<li>
|
||||||
|
<UserButton />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
{
|
||||||
|
children: <p>© {new Date().getFullYear()} {import.meta.env.VITE_COMPANY}</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>{children}</Layout>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppMapType {
|
||||||
|
title: string,
|
||||||
|
routes: string[],
|
||||||
|
page: ComponentType,
|
||||||
|
useAuth?: boolean,
|
||||||
|
useLayout?: boolean
|
||||||
|
linkArea?: LinkArea []
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LinkArea {
|
||||||
|
SideMenu,
|
||||||
|
TopMenuLeft,
|
||||||
|
TopMenuRigt
|
||||||
|
}
|
||||||
|
|
||||||
|
const AppMap: AppMapType[] = [
|
||||||
|
{
|
||||||
|
title: 'Home',
|
||||||
|
routes: ['/', '/home'],
|
||||||
|
page: HomePage,
|
||||||
|
linkArea: [LinkArea.SideMenu]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Register',
|
||||||
|
routes: ['/register'],
|
||||||
|
page: RegisterPage,
|
||||||
|
linkArea: [LinkArea.SideMenu]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Utilities',
|
||||||
|
routes: ['/utilities'],
|
||||||
|
page: UtilitiesPage,
|
||||||
|
linkArea: [LinkArea.SideMenu]
|
||||||
|
}
|
||||||
|
|
||||||
|
// {
|
||||||
|
// title: 'Login',
|
||||||
|
// routes: ['/login'],
|
||||||
|
// page: LoginScreen,
|
||||||
|
// useAuth: false,
|
||||||
|
// useLayout: false
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// title: 'About',
|
||||||
|
// routes: ['/about'],
|
||||||
|
// page: Home
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// title: 'User',
|
||||||
|
// routes: ['/user/:userId'],
|
||||||
|
// page: Users
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// title: 'Organizations',
|
||||||
|
// routes: ['/organizations', '/organization/:organizationId'],
|
||||||
|
// page: Organizations,
|
||||||
|
// linkArea: [LinkArea.SideMenu]
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// title: 'Applications',
|
||||||
|
// routes: ['/applications'],
|
||||||
|
// page: Applications,
|
||||||
|
// linkArea: [LinkArea.SideMenu]
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// title: 'Secrets',
|
||||||
|
// routes: ['/secrets'],
|
||||||
|
// page: Secrets,
|
||||||
|
// linkArea: [LinkArea.SideMenu]
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// title: 'Users',
|
||||||
|
// routes: ['/users'],
|
||||||
|
// page: Users,
|
||||||
|
// linkArea: [LinkArea.SideMenu]
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// title: 'API Keys',
|
||||||
|
// routes: ['/apikeys'],
|
||||||
|
// page: ApiKeys,
|
||||||
|
// linkArea: [LinkArea.SideMenu]
|
||||||
|
// }
|
||||||
|
]
|
||||||
|
|
||||||
|
// AGENT_TEST = 'api/agent/test',
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
enum ApiRoutes {
|
||||||
|
ACCOUNTS = 'GET|/accounts',
|
||||||
|
|
||||||
|
ACCOUNT_POST = 'POST|/account',
|
||||||
|
ACCOUNT_GET = 'GET|/account/{accountId}',
|
||||||
|
ACCOUNT_PATCH = 'PATCH|/account/{accountId}',
|
||||||
|
ACCOUNT_DELETE = 'DELETE|/account/{accountId}',
|
||||||
|
|
||||||
|
// ACCOUNT_ID_CONTACTS = 'GET|/account/{accountId}/contacts',
|
||||||
|
// ACCOUNT_ID_CONTACT_ID = 'GET|/account/{accountId}/contact/{index}',
|
||||||
|
|
||||||
|
// ACCOUNT_ID_HOSTNAMES = 'GET|/account/{accountId}/hostnames',
|
||||||
|
// ACCOUNT_ID_HOSTNAME_ID = 'GET|/account/{accountId}/hostname/{index}',
|
||||||
|
|
||||||
|
// Secrets
|
||||||
|
generateSecret = 'GET|/secret/generatesecret',
|
||||||
|
|
||||||
|
// Identity
|
||||||
|
identityLogin = 'POST|/identity/login',
|
||||||
|
identityRefresh = 'POST|/identity/refresh',
|
||||||
|
identityLogout = 'POST|/identity/logout',
|
||||||
|
}
|
||||||
|
|
||||||
|
const GetMenuItems = (linkArea: LinkArea) => {
|
||||||
|
return AppMap.filter(item => item.linkArea?.includes(linkArea)).map((item, index) => {
|
||||||
|
return <li key={index}><Link to={item.routes[0]}>{item.title}</Link></li>
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const GetRoutes = () => {
|
||||||
|
return AppMap.flatMap((item) =>
|
||||||
|
item.routes.map((route) => {
|
||||||
|
const {
|
||||||
|
useAuth = true,
|
||||||
|
useLayout = true,
|
||||||
|
page: Page
|
||||||
|
} = item
|
||||||
|
|
||||||
|
const PageComponent = (
|
||||||
|
<>
|
||||||
|
{useLayout ? (
|
||||||
|
<LayoutWrapper>
|
||||||
|
<Page />
|
||||||
|
</LayoutWrapper>
|
||||||
|
) : (
|
||||||
|
<Page />
|
||||||
|
)}
|
||||||
|
<Toast />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Route
|
||||||
|
key={route}
|
||||||
|
path={route}
|
||||||
|
// element={useAuth
|
||||||
|
// ? <Authorization>
|
||||||
|
// {PageComponent}
|
||||||
|
// <UserOffcanvas />
|
||||||
|
// </Authorization>
|
||||||
|
// : PageComponent}
|
||||||
|
|
||||||
|
element={PageComponent}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiRoute {
|
||||||
|
method: string,
|
||||||
|
route: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const GetApiRoute = (apiRoute: ApiRoutes): ApiRoute => {
|
||||||
|
const apiUrl = import.meta.env.VITE_API_URL
|
||||||
|
|
||||||
|
const [method, route] = apiRoute.split('|')
|
||||||
|
|
||||||
|
return {
|
||||||
|
method,
|
||||||
|
route: `${apiUrl}${route}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
GetMenuItems,
|
||||||
|
GetRoutes,
|
||||||
|
ApiRoutes,
|
||||||
|
GetApiRoute
|
||||||
|
}
|
||||||
1
src/MaksIT.WebUI/src/assets/react.svg
Normal file
1
src/MaksIT.WebUI/src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
160
src/MaksIT.WebUI/src/axiosConfig.ts
Normal file
160
src/MaksIT.WebUI/src/axiosConfig.ts
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import { readIdentity } from './localStorage/identity'
|
||||||
|
import { ApiRoutes, GetApiRoute } from './AppMap'
|
||||||
|
import { store } from './redux/store'
|
||||||
|
import { refreshJwt } from './redux/slices/identitySlice'
|
||||||
|
import { hideLoader, showLoader } from './redux/slices/loaderSlice'
|
||||||
|
|
||||||
|
// Create an Axios instance
|
||||||
|
const axiosInstance = axios.create({
|
||||||
|
timeout: 10000, // Set a timeout if needed
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
let isRefreshing = false
|
||||||
|
let refreshPromise: Promise<unknown> | null = null
|
||||||
|
|
||||||
|
// Add a request interceptor
|
||||||
|
axiosInstance.interceptors.request.use(
|
||||||
|
async config => {
|
||||||
|
// Dispatch request
|
||||||
|
store.dispatch(showLoader())
|
||||||
|
|
||||||
|
// List of URLs to exclude from adding Bearer token
|
||||||
|
const excludeUrls = [
|
||||||
|
GetApiRoute(ApiRoutes.identityLogin).route,
|
||||||
|
GetApiRoute(ApiRoutes.identityRefresh).route
|
||||||
|
]
|
||||||
|
|
||||||
|
// Check if the URL is in the exclude list
|
||||||
|
if (config.url && excludeUrls.includes(config.url)) {
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
const identity = readIdentity()
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
if (identity) {
|
||||||
|
if (new Date(identity.expiresAt) < now) {
|
||||||
|
// Token expired, refresh if possible
|
||||||
|
if (new Date(identity.refreshTokenExpiresAt) > now) {
|
||||||
|
if (!isRefreshing) {
|
||||||
|
isRefreshing = true
|
||||||
|
refreshPromise = store.dispatch(refreshJwt())
|
||||||
|
.finally(() => { isRefreshing = false })
|
||||||
|
}
|
||||||
|
await refreshPromise
|
||||||
|
const newIdentity = readIdentity()
|
||||||
|
if (newIdentity) {
|
||||||
|
config.headers.Authorization = `${newIdentity.tokenType} ${newIdentity.token}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
config.headers.Authorization = `${identity.tokenType} ${identity.token}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
error => {
|
||||||
|
// Handle request error
|
||||||
|
store.dispatch(hideLoader())
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add a response interceptor
|
||||||
|
axiosInstance.interceptors.response.use(
|
||||||
|
response => {
|
||||||
|
// Dispatch request end
|
||||||
|
store.dispatch(hideLoader())
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
error => {
|
||||||
|
// Handle response error
|
||||||
|
store.dispatch(hideLoader())
|
||||||
|
if (error.response && error.response.status === 401) {
|
||||||
|
// Handle unauthorized error (e.g., redirect to login)
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const getData = async <TResponse>(url: string): Promise<TResponse | undefined> => {
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.get<TResponse>(url, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
} catch {
|
||||||
|
// Error is already handled by interceptors, so just return undefined
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const postData = async <TRequest, TResponse>(url: string, data: TRequest): Promise<TResponse | undefined> => {
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.post<TResponse>(url, data, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
} catch {
|
||||||
|
// Error is already handled by interceptors, so just return undefined
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const patchData = async <TRequest, TResponse>(url: string, data: TRequest): Promise<TResponse | undefined> => {
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.patch<TResponse>(url, data, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
} catch {
|
||||||
|
// Error is already handled by interceptors, so just return undefined
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const putData = async <TRequest, TResponse>(url: string, data: TRequest): Promise<TResponse | undefined> => {
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.put<TResponse>(url, data, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
} catch {
|
||||||
|
// Error is already handled by interceptors, so just return undefined
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteData = async <TResponse>(url: string): Promise<TResponse | undefined> => {
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.delete<TResponse>(url, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
} catch {
|
||||||
|
// Error is already handled by interceptors, so just return undefined
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
axiosInstance,
|
||||||
|
getData,
|
||||||
|
postData,
|
||||||
|
patchData,
|
||||||
|
putData,
|
||||||
|
deleteData
|
||||||
|
}
|
||||||
50
src/MaksIT.WebUI/src/components/Authorization.tsx
Normal file
50
src/MaksIT.WebUI/src/components/Authorization.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useAppDispatch, useAppSelector } from '../redux/hooks'
|
||||||
|
import { setIdentityFromLocalStorage } from '../redux/slices/identitySlice'
|
||||||
|
|
||||||
|
interface AuthorizationProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Authorization = (props: AuthorizationProps) => {
|
||||||
|
const { children } = props
|
||||||
|
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
const { identity } = useAppSelector((state) => state.identity)
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(setIdentityFromLocalStorage())
|
||||||
|
setLoading(false)
|
||||||
|
}, [dispatch])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading) {
|
||||||
|
if (!identity || new Date(identity.refreshTokenExpiresAt) < new Date()) {
|
||||||
|
navigate('/login', { replace: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [identity, navigate, loading])
|
||||||
|
|
||||||
|
// Render a simple loading spinner while loading (Tailwind v4 compatible)
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className={'flex items-center justify-center h-screen w-screen bg-white'}>
|
||||||
|
<div className={'animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-blue-500'}></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!identity || new Date(identity.refreshTokenExpiresAt) < new Date()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Authorization
|
||||||
|
}
|
||||||
433
src/MaksIT.WebUI/src/components/DataTable/DataTable.tsx
Normal file
433
src/MaksIT.WebUI/src/components/DataTable/DataTable.tsx
Normal file
@ -0,0 +1,433 @@
|
|||||||
|
import React, { useState, useMemo, useRef, useEffect } from 'react'
|
||||||
|
import { AutoSizer, MultiGrid, GridCellProps } from 'react-virtualized'
|
||||||
|
|
||||||
|
import { PagedResponse } from '../../models/PagedResponse'
|
||||||
|
import { Plus, Trash2, Edit } from 'lucide-react'
|
||||||
|
import { debounce } from 'lodash'
|
||||||
|
|
||||||
|
|
||||||
|
interface FilterProps {
|
||||||
|
columnId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CellProps<T, K extends keyof T = keyof T> {
|
||||||
|
columnId: string
|
||||||
|
data: T
|
||||||
|
value: T[K]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataTableColumn<T, K extends keyof T = keyof T> {
|
||||||
|
id: string
|
||||||
|
accessorKey: K
|
||||||
|
header: string
|
||||||
|
filter: (
|
||||||
|
props: FilterProps,
|
||||||
|
onFilterChange: (filterId: string, columnId: string, filters: string) => void
|
||||||
|
) => React.ReactNode
|
||||||
|
cell: (props: CellProps<T, K>) => React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataTableProps<T> {
|
||||||
|
rawd?: PagedResponse<T>
|
||||||
|
columns: DataTableColumn<T>[]
|
||||||
|
maxRecordsPerPage?: number
|
||||||
|
|
||||||
|
idFields?: string[]
|
||||||
|
|
||||||
|
allowAddRow?: () => boolean
|
||||||
|
onAddRow?: () => void
|
||||||
|
allowEditRow?: (ids: Record<string, string>) =>boolean
|
||||||
|
onEditRow?: (ids: Record<string, string>) => void
|
||||||
|
allowDeleteRow?: (ids: Record<string, string>) =>boolean
|
||||||
|
onDeleteRow?: (ids: Record<string, string>) => void
|
||||||
|
|
||||||
|
onFilterChange?: (filters: Record<string, string>) => void
|
||||||
|
onPreviousPage?: (pageNumber: number) => void
|
||||||
|
onNextPage?: (pageNumber: number) => void
|
||||||
|
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
|
||||||
|
|
||||||
|
storageKey?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_ACTION_WIDTH = 80
|
||||||
|
const DEFAULT_COL_WIDTH = 150
|
||||||
|
const HEADER_ROWS = 2
|
||||||
|
const ROW_HEIGHT = 40
|
||||||
|
|
||||||
|
const DataTable = <T extends Record<string, unknown>,>(props: DataTableProps<T>) => {
|
||||||
|
const {
|
||||||
|
rawd,
|
||||||
|
columns,
|
||||||
|
idFields = ['id'],
|
||||||
|
|
||||||
|
allowAddRow = () => false,
|
||||||
|
onAddRow,
|
||||||
|
allowEditRow = (_) => false,
|
||||||
|
onEditRow,
|
||||||
|
allowDeleteRow = (_) => false,
|
||||||
|
onDeleteRow,
|
||||||
|
|
||||||
|
onFilterChange,
|
||||||
|
onPreviousPage,
|
||||||
|
onNextPage,
|
||||||
|
colspan = 12,
|
||||||
|
storageKey,
|
||||||
|
} = props
|
||||||
|
|
||||||
|
const {
|
||||||
|
items = [],
|
||||||
|
pageNumber = 0,
|
||||||
|
pageSize = 0,
|
||||||
|
totalCount = 0,
|
||||||
|
totalPages = 0,
|
||||||
|
hasPreviousPage = false,
|
||||||
|
hasNextPage = false,
|
||||||
|
} = rawd || {}
|
||||||
|
|
||||||
|
const gridRef = useRef<MultiGrid>(null)
|
||||||
|
|
||||||
|
const [selectedRowIndex, setSelectedRowIndex] = useState<number | null>(null)
|
||||||
|
const [filterValues, setFilterValues] = useState<Record<string, Record<string, string>>>({})
|
||||||
|
|
||||||
|
const [colWidths, setColWidths] = useState<number[]>(() => {
|
||||||
|
const defaultWidths = [DEFAULT_ACTION_WIDTH, ...columns.map(() => DEFAULT_COL_WIDTH)]
|
||||||
|
|
||||||
|
if (storageKey) {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(storageKey)
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored)
|
||||||
|
// Check if parsed is an array and matches the expected length
|
||||||
|
if (Array.isArray(parsed) && parsed.length === columns.length + 1) { // +1 for action column
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Remove invalid storage and fall back to default
|
||||||
|
localStorage.removeItem(storageKey)
|
||||||
|
return defaultWidths
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// Ignore and fall back to default
|
||||||
|
return defaultWidths
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no storage key or no valid stored widths, return default
|
||||||
|
return defaultWidths
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (storageKey) {
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(colWidths))
|
||||||
|
}
|
||||||
|
if (gridRef.current) {
|
||||||
|
gridRef.current.recomputeGridSize()
|
||||||
|
gridRef.current.forceUpdateGrids()
|
||||||
|
}
|
||||||
|
}, [colWidths, storageKey])
|
||||||
|
|
||||||
|
const debouncedOnFilterChange = useMemo(
|
||||||
|
() => (onFilterChange ? debounce(onFilterChange, 500) : undefined),
|
||||||
|
[onFilterChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleFilterChange = (
|
||||||
|
filterId: string,
|
||||||
|
columnId: string,
|
||||||
|
filters: string
|
||||||
|
) => {
|
||||||
|
setFilterValues((prev) => {
|
||||||
|
const newValues = {
|
||||||
|
...prev,
|
||||||
|
[filterId]: {
|
||||||
|
...prev[filterId],
|
||||||
|
[columnId]: filters,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const linqQueries = Object.fromEntries(
|
||||||
|
Object.entries(newValues).map(([fid, cols]) => {
|
||||||
|
const q = Object.values(cols)
|
||||||
|
.filter((v) => v)
|
||||||
|
.map((v) => `(${v})`)
|
||||||
|
.join(' && ')
|
||||||
|
return [fid, q]
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
debouncedOnFilterChange?.(linqQueries)
|
||||||
|
return newValues
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePreviousPage = () => onPreviousPage?.(pageNumber - 1)
|
||||||
|
const handleNextPage = () => onNextPage?.(pageNumber + 1)
|
||||||
|
|
||||||
|
|
||||||
|
const getRealIdsFromRow = (rowIndex: number) => {
|
||||||
|
const row = items[rowIndex]
|
||||||
|
const ids = Object.fromEntries(
|
||||||
|
idFields.map((key) => [key, `${row[key]}`])
|
||||||
|
)
|
||||||
|
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddRow = () => onAddRow?.()
|
||||||
|
const handleEditRow = (rowIndex: number) => {
|
||||||
|
const ids = getRealIdsFromRow(rowIndex)
|
||||||
|
|
||||||
|
onEditRow?.(ids)
|
||||||
|
}
|
||||||
|
const handleDeleteRow = (rowIndex: number) => {
|
||||||
|
const ids = getRealIdsFromRow(rowIndex)
|
||||||
|
|
||||||
|
onDeleteRow?.(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAllowAddRow = () => {
|
||||||
|
return allowAddRow?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAllowEditRow = (rowIndex: number) => {
|
||||||
|
const ids = getRealIdsFromRow(rowIndex)
|
||||||
|
return allowEditRow?.(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAllowDeleteRow = (rowIndex: number) => {
|
||||||
|
const ids = getRealIdsFromRow(rowIndex)
|
||||||
|
return allowDeleteRow?.(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRowClick = (idx: number) =>
|
||||||
|
setSelectedRowIndex((prev) => (prev === idx ? null : idx))
|
||||||
|
|
||||||
|
const handleHeaderResize = (colIdx: number, startX: number, startWidth: number) => {
|
||||||
|
const onMouseMove = (e: MouseEvent) => {
|
||||||
|
const delta = e.clientX - startX
|
||||||
|
setColWidths(prev => {
|
||||||
|
const next = [...prev]
|
||||||
|
next[colIdx] = Math.max(40, startWidth + delta)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const onMouseUp = () => {
|
||||||
|
window.removeEventListener('mousemove', onMouseMove)
|
||||||
|
window.removeEventListener('mouseup', onMouseUp)
|
||||||
|
}
|
||||||
|
window.addEventListener('mousemove', onMouseMove)
|
||||||
|
window.addEventListener('mouseup', onMouseUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cellRenderer = ({ columnIndex, key, rowIndex, style }: GridCellProps) => {
|
||||||
|
const isActionCol = columnIndex === 0
|
||||||
|
const col = columns[columnIndex - 1]
|
||||||
|
|
||||||
|
// classi statiche; solo width/height via style
|
||||||
|
const commonClasses = [
|
||||||
|
'box-border',
|
||||||
|
'flex',
|
||||||
|
'items-center',
|
||||||
|
'px-2',
|
||||||
|
'py-1',
|
||||||
|
'border-b',
|
||||||
|
'border-r',
|
||||||
|
'border-gray-200',
|
||||||
|
'overflow-hidden',
|
||||||
|
'whitespace-nowrap',
|
||||||
|
'truncate',
|
||||||
|
rowIndex >= HEADER_ROWS ? 'cursor-pointer' : '',
|
||||||
|
rowIndex >= HEADER_ROWS && selectedRowIndex === rowIndex - HEADER_ROWS ? 'bg-sky-100' : '',
|
||||||
|
].filter(Boolean).join(' ')
|
||||||
|
|
||||||
|
// Header
|
||||||
|
if (rowIndex === 0) {
|
||||||
|
const allowAddRowResult = handleAllowAddRow()
|
||||||
|
|
||||||
|
if (isActionCol) {
|
||||||
|
return (
|
||||||
|
<div key={key} style={style} className={commonClasses}>
|
||||||
|
<button
|
||||||
|
onClick={handleAddRow}
|
||||||
|
disabled={!allowAddRowResult}
|
||||||
|
className={`p-1 ${allowAddRowResult
|
||||||
|
? 'cursor-pointer'
|
||||||
|
: 'cursor-not-allowed opacity-50'
|
||||||
|
}`}>
|
||||||
|
<Plus />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// il div esterno mantiene style/className, Resizable solo sul contenuto
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
style={{ ...style, userSelect: 'none' }}
|
||||||
|
className={commonClasses}
|
||||||
|
>
|
||||||
|
<span>{col.header}</span>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
height: '100%',
|
||||||
|
width: 8,
|
||||||
|
cursor: 'col-resize',
|
||||||
|
zIndex: 10,
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
onMouseDown={e => {
|
||||||
|
e.preventDefault()
|
||||||
|
handleHeaderResize(columnIndex, e.clientX, colWidths[columnIndex])
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter row
|
||||||
|
if (rowIndex === 1) {
|
||||||
|
return isActionCol ? (
|
||||||
|
<div key={key} style={style} className={commonClasses} />
|
||||||
|
) : (
|
||||||
|
<div key={key} style={style} className={commonClasses}>
|
||||||
|
{col.filter({ columnId: col.id }, handleFilterChange)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data rows
|
||||||
|
const dataIdx = rowIndex - HEADER_ROWS
|
||||||
|
if (isActionCol) {
|
||||||
|
const allowEditRowResult = handleAllowEditRow(dataIdx)
|
||||||
|
const allowDeleteRowResult = handleAllowDeleteRow(dataIdx)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
style={style}
|
||||||
|
className={commonClasses} onClick={() => handleRowClick(dataIdx)}>
|
||||||
|
<button
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleEditRow(dataIdx)
|
||||||
|
}}
|
||||||
|
disabled={!allowEditRowResult}
|
||||||
|
className={`p-1 ${allowEditRowResult
|
||||||
|
? 'cursor-pointer'
|
||||||
|
: 'cursor-not-allowed opacity-50'
|
||||||
|
}`}>
|
||||||
|
<Edit />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleDeleteRow(dataIdx)
|
||||||
|
}}
|
||||||
|
disabled={!allowDeleteRowResult}
|
||||||
|
className={`p-1 ${allowDeleteRowResult
|
||||||
|
? 'cursor-pointer'
|
||||||
|
: 'cursor-not-allowed opacity-50'
|
||||||
|
}`}>
|
||||||
|
<Trash2 />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = items[dataIdx]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
style={style}
|
||||||
|
className={commonClasses}
|
||||||
|
onClick={() => setSelectedRowIndex(dataIdx)}
|
||||||
|
>
|
||||||
|
{col.cell({
|
||||||
|
columnId: col.id,
|
||||||
|
data: row,
|
||||||
|
value: row[col.accessorKey]
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler for MultiGrid scroll
|
||||||
|
const handleGridScroll = ({
|
||||||
|
scrollTop,
|
||||||
|
clientHeight,
|
||||||
|
scrollHeight,
|
||||||
|
}: {
|
||||||
|
scrollTop: number
|
||||||
|
clientHeight: number
|
||||||
|
scrollHeight: number
|
||||||
|
}) => {
|
||||||
|
// At bottom
|
||||||
|
if (scrollTop + clientHeight >= scrollHeight - 2 && hasNextPage) {
|
||||||
|
handleNextPage()
|
||||||
|
}
|
||||||
|
// At top
|
||||||
|
if (scrollTop <= 2 && hasPreviousPage) {
|
||||||
|
handlePreviousPage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`col-span-${colspan} flex flex-col h-full w-full`}>
|
||||||
|
<div className={'flex-1'}>
|
||||||
|
<AutoSizer>
|
||||||
|
{({ height, width }) => (
|
||||||
|
<MultiGrid
|
||||||
|
ref={gridRef}
|
||||||
|
cellRenderer={cellRenderer}
|
||||||
|
columnCount={columns.length + 1}
|
||||||
|
columnWidth={({ index }) => colWidths[index]}
|
||||||
|
fixedColumnCount={1}
|
||||||
|
fixedRowCount={HEADER_ROWS}
|
||||||
|
height={height}
|
||||||
|
rowCount={items.length + HEADER_ROWS}
|
||||||
|
rowHeight={({ index }) => index === 1 ? ROW_HEIGHT * 2 : ROW_HEIGHT}
|
||||||
|
width={width}
|
||||||
|
onScroll={({ scrollTop, clientHeight, scrollHeight }) =>
|
||||||
|
handleGridScroll({ scrollTop, clientHeight, scrollHeight })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AutoSizer>
|
||||||
|
</div>
|
||||||
|
<div className={'mt-4 text-sm'}>
|
||||||
|
<div className={'flex justify-end gap-4'}>
|
||||||
|
<span>Page Size: {pageSize}</span>
|
||||||
|
<span>Total Pages: {totalPages}</span>
|
||||||
|
<span>Total Count: {totalCount}</span>
|
||||||
|
</div>
|
||||||
|
<div className={'flex items-center justify-between mt-2'}>
|
||||||
|
<button
|
||||||
|
onClick={handlePreviousPage}
|
||||||
|
disabled={!hasPreviousPage}
|
||||||
|
className={'px-4 py-2 bg-gray-200 rounded-md hover:bg-gray-300 aria-disabled:opacity-50'}
|
||||||
|
aria-disabled={!hasPreviousPage}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span>Page {pageNumber} of {totalPages}</span>
|
||||||
|
<button
|
||||||
|
onClick={handleNextPage}
|
||||||
|
disabled={!hasNextPage}
|
||||||
|
className={'px-4 py-2 bg-gray-200 rounded-md hover:bg-gray-300 aria-disabled:opacity-50'}
|
||||||
|
aria-disabled={!hasNextPage}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DataTable }
|
||||||
161
src/MaksIT.WebUI/src/components/DataTable/DataTableFilter.tsx
Normal file
161
src/MaksIT.WebUI/src/components/DataTable/DataTableFilter.tsx
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { debounce } from 'lodash'
|
||||||
|
import { useAppDispatch } from '../../redux/hooks'
|
||||||
|
import { postData } from '../../axiosConfig'
|
||||||
|
import { PagedRequest } from '../../models/PagedRequest'
|
||||||
|
import { PagedResponse } from '../../models/PagedResponse'
|
||||||
|
|
||||||
|
interface FilterPropsBase {
|
||||||
|
filterId?: string
|
||||||
|
columnId: string
|
||||||
|
accessorKey: string
|
||||||
|
value?: FilterState
|
||||||
|
disabled?: boolean
|
||||||
|
onFilterChange?: (filterId: string, columnId: string, filters: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NormalFilterProps extends FilterPropsBase {
|
||||||
|
type: 'normal',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RemoteFilterProps extends FilterPropsBase {
|
||||||
|
type: 'remote',
|
||||||
|
route: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type FilterProps = NormalFilterProps | RemoteFilterProps
|
||||||
|
|
||||||
|
interface FilterState {
|
||||||
|
value: string,
|
||||||
|
operator: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DataTableFilter = <T extends { [key: string]: string }>(props: FilterProps) => {
|
||||||
|
const {
|
||||||
|
type,
|
||||||
|
filterId = 'filters',
|
||||||
|
columnId,
|
||||||
|
accessorKey,
|
||||||
|
value = {
|
||||||
|
value: '',
|
||||||
|
operator: 'contains'
|
||||||
|
},
|
||||||
|
disabled = false,
|
||||||
|
onFilterChange
|
||||||
|
} = props
|
||||||
|
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
|
const [filterState, setFilterState] = useState<FilterState>(value)
|
||||||
|
|
||||||
|
const debounceOnFilterChange = useMemo(() => {
|
||||||
|
if (!onFilterChange) return
|
||||||
|
|
||||||
|
return debounce((route: string, filters: string) => {
|
||||||
|
|
||||||
|
postData<PagedRequest, PagedResponse<T>>(route, {
|
||||||
|
pageSize: 100,
|
||||||
|
filters
|
||||||
|
}).then((response) => {
|
||||||
|
if (!response) return
|
||||||
|
|
||||||
|
const linqQuery = response.items.map(item => `${columnId} == "${item['id']}"`).join(' || ')
|
||||||
|
onFilterChange?.(filterId, columnId, linqQuery)
|
||||||
|
|
||||||
|
}).finally(() => {
|
||||||
|
|
||||||
|
})
|
||||||
|
}, 500)
|
||||||
|
|
||||||
|
}, [filterId, columnId, dispatch, onFilterChange])
|
||||||
|
|
||||||
|
const handleFilterChange = (value: string, operator: string) => {
|
||||||
|
setFilterState({
|
||||||
|
value,
|
||||||
|
operator
|
||||||
|
})
|
||||||
|
|
||||||
|
if (value === '') {
|
||||||
|
onFilterChange?.(filterId, columnId, '')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let linqQuery = ''
|
||||||
|
|
||||||
|
switch (operator) {
|
||||||
|
case 'contains':
|
||||||
|
linqQuery = `${accessorKey}.Contains("${value}")`
|
||||||
|
break
|
||||||
|
case 'startsWith':
|
||||||
|
linqQuery = `${accessorKey}.StartsWith("${value}")`
|
||||||
|
break
|
||||||
|
case 'endsWith':
|
||||||
|
linqQuery = `${accessorKey}.EndsWith("${value}")`
|
||||||
|
break
|
||||||
|
case '=':
|
||||||
|
linqQuery = `${accessorKey} == "${value}"`
|
||||||
|
break
|
||||||
|
case '!=':
|
||||||
|
linqQuery = `${accessorKey} != "${value}"`
|
||||||
|
break
|
||||||
|
case '>':
|
||||||
|
linqQuery = `${accessorKey} > "${value}"`
|
||||||
|
break
|
||||||
|
case '<':
|
||||||
|
linqQuery = `${accessorKey} < "${value}"`
|
||||||
|
break
|
||||||
|
case '>=':
|
||||||
|
linqQuery = `${accessorKey} >= "${value}"`
|
||||||
|
break
|
||||||
|
case '<=':
|
||||||
|
linqQuery = `${accessorKey} <= "${value}"`
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
linqQuery = `${accessorKey}.Contains("${value}")` // Default case handles using 'contains' as the safe fallback
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'normal') {
|
||||||
|
onFilterChange?.(filterId, columnId, linqQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'remote' && debounceOnFilterChange) {
|
||||||
|
const { route } = props as RemoteFilterProps
|
||||||
|
|
||||||
|
debounceOnFilterChange(route, linqQuery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={'flex flex-col gap-2'}>
|
||||||
|
<input
|
||||||
|
type={'text'}
|
||||||
|
placeholder={'Filter...'}
|
||||||
|
className={'w-full border border-outline/20 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:bg-gray-100 disabled:opacity-60'}
|
||||||
|
value={filterState.value}
|
||||||
|
onChange={e => handleFilterChange(e.target.value, filterState.operator)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={filterState.operator}
|
||||||
|
onChange={e => handleFilterChange(filterState.value, e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
className={'w-full border border-outline/20 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:bg-gray-100 disabled:opacity-60'}
|
||||||
|
>
|
||||||
|
<option value={'contains'}>Contains</option>
|
||||||
|
<option value={'startsWith'}>Starts With</option>
|
||||||
|
<option value={'endsWith'}>Ends With</option>
|
||||||
|
<option value={'='}>=</option>
|
||||||
|
<option value={'!='}>!=</option>
|
||||||
|
<option value={'>'}>{'>'}</option>
|
||||||
|
<option value={'<'}>{'<'}</option>
|
||||||
|
<option value={'>='}>{'>='}</option>
|
||||||
|
<option value={'<='}>{'<='}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DataTableFilter
|
||||||
|
}
|
||||||
63
src/MaksIT.WebUI/src/components/DataTable/DataTableLabel.tsx
Normal file
63
src/MaksIT.WebUI/src/components/DataTable/DataTableLabel.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { getData } from '../../axiosConfig'
|
||||||
|
import { useAppDispatch } from '../../redux/hooks'
|
||||||
|
import { formatISODateString } from '../../functions'
|
||||||
|
|
||||||
|
interface NormalLabelProps {
|
||||||
|
type: 'normal',
|
||||||
|
value?: string,
|
||||||
|
dataType?: 'string' | 'date'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RemoteLabelProps {
|
||||||
|
type: 'remote',
|
||||||
|
route: string,
|
||||||
|
columnId: string,
|
||||||
|
accessorKey: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
type LabelProps = NormalLabelProps | RemoteLabelProps
|
||||||
|
|
||||||
|
const DataTableLabel = <T extends { [key: string]: never }>(props: LabelProps) => {
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
|
const [label, setLabel] = useState<string>('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const { type } = props
|
||||||
|
|
||||||
|
if (type === 'normal') {
|
||||||
|
const { value = '', dataType = 'string' } = props as NormalLabelProps
|
||||||
|
|
||||||
|
switch (dataType) {
|
||||||
|
case 'date':
|
||||||
|
setLabel(formatISODateString(value))
|
||||||
|
break
|
||||||
|
case 'string':
|
||||||
|
default:
|
||||||
|
setLabel(value)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'remote') {
|
||||||
|
const { route, accessorKey } = props as RemoteLabelProps
|
||||||
|
|
||||||
|
getData<T>(route)
|
||||||
|
.then(response => {
|
||||||
|
if (!response) return
|
||||||
|
|
||||||
|
setLabel(response[accessorKey])
|
||||||
|
}).finally(() => {
|
||||||
|
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
}, [props, dispatch])
|
||||||
|
|
||||||
|
return <p>{label}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DataTableLabel
|
||||||
|
}
|
||||||
18
src/MaksIT.WebUI/src/components/DataTable/helpers.ts
Normal file
18
src/MaksIT.WebUI/src/components/DataTable/helpers.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { DataTableColumn } from './DataTable'
|
||||||
|
|
||||||
|
const createColumn = <T, K extends keyof T>(col: DataTableColumn<T, K>): DataTableColumn<T, K> => {
|
||||||
|
return col
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TypeScript cannot express "an array of DataTableColumn<T, K> for various K" without using any or a cast in the helper.
|
||||||
|
* This is a known limitation and your use of a helper like createColumns is the best practical solution.
|
||||||
|
* @param cols
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const createColumns = <T>(cols: DataTableColumn<T, any>[]) => {
|
||||||
|
return cols as unknown as DataTableColumn<T, keyof T>[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export { createColumn, createColumns }
|
||||||
29
src/MaksIT.WebUI/src/components/DataTable/index.ts
Normal file
29
src/MaksIT.WebUI/src/components/DataTable/index.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import {
|
||||||
|
DataTable,
|
||||||
|
DataTableColumn
|
||||||
|
} from './DataTable'
|
||||||
|
|
||||||
|
import {
|
||||||
|
createColumn,
|
||||||
|
createColumns
|
||||||
|
} from './helpers'
|
||||||
|
|
||||||
|
import {
|
||||||
|
DataTableLabel
|
||||||
|
} from './DataTableLabel'
|
||||||
|
|
||||||
|
import {
|
||||||
|
DataTableFilter
|
||||||
|
} from './DataTableFilter'
|
||||||
|
|
||||||
|
export type {
|
||||||
|
DataTableColumn
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DataTable,
|
||||||
|
DataTableLabel,
|
||||||
|
DataTableFilter,
|
||||||
|
createColumn,
|
||||||
|
createColumns
|
||||||
|
}
|
||||||
19
src/MaksIT.WebUI/src/components/FormLayout/FormContainer.tsx
Normal file
19
src/MaksIT.WebUI/src/components/FormLayout/FormContainer.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { FC, ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface FormContainerProps {
|
||||||
|
children?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormContainer: FC<FormContainerProps> = (props) => {
|
||||||
|
const {
|
||||||
|
children
|
||||||
|
} = props
|
||||||
|
|
||||||
|
return <div className={'grid grid-rows-[auto_1fr_auto] h-full gap-0'}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
FormContainer
|
||||||
|
}
|
||||||
19
src/MaksIT.WebUI/src/components/FormLayout/FormContent.tsx
Normal file
19
src/MaksIT.WebUI/src/components/FormLayout/FormContent.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { FC, ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface FormContentProps {
|
||||||
|
children?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormContent: FC<FormContentProps> = (props) => {
|
||||||
|
const {
|
||||||
|
children
|
||||||
|
} = props
|
||||||
|
|
||||||
|
return <div className={'bg-gray-100 w-full h-full p-4 overflow-y-auto'}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
FormContent
|
||||||
|
}
|
||||||
31
src/MaksIT.WebUI/src/components/FormLayout/FormFooter.tsx
Normal file
31
src/MaksIT.WebUI/src/components/FormLayout/FormFooter.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { FC, ReactNode } from 'react'
|
||||||
|
|
||||||
|
|
||||||
|
interface FormFooterProps {
|
||||||
|
children?: ReactNode,
|
||||||
|
leftChildren?: ReactNode,
|
||||||
|
rightChildren?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormFooter: FC<FormFooterProps> = (props) => {
|
||||||
|
|
||||||
|
const {
|
||||||
|
children,
|
||||||
|
leftChildren,
|
||||||
|
rightChildren
|
||||||
|
} = props
|
||||||
|
|
||||||
|
return <div className={'bg-gray-200 p-4 h-14 flex justify-between items-center'}>
|
||||||
|
{children ?? <>
|
||||||
|
<div className={'flex space-x-4'}>{leftChildren}</div>
|
||||||
|
<div className={'flex space-x-4'}>{rightChildren}</div>
|
||||||
|
</>}
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
FormFooter
|
||||||
|
}
|
||||||
19
src/MaksIT.WebUI/src/components/FormLayout/FormHeader.tsx
Normal file
19
src/MaksIT.WebUI/src/components/FormLayout/FormHeader.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { FC, ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface FormHeaderProps {
|
||||||
|
children?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormHeader: FC<FormHeaderProps> = (props) => {
|
||||||
|
const {
|
||||||
|
children
|
||||||
|
} = props
|
||||||
|
|
||||||
|
return <h1 className={'bg-gray-200 p-4 h-14 text-2xl font-bold'}>
|
||||||
|
{children}
|
||||||
|
</h1>
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
FormHeader
|
||||||
|
}
|
||||||
11
src/MaksIT.WebUI/src/components/FormLayout/index.ts
Normal file
11
src/MaksIT.WebUI/src/components/FormLayout/index.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { FormContainer } from './FormContainer'
|
||||||
|
import { FormHeader } from './FormHeader'
|
||||||
|
import { FormContent } from './FormContent'
|
||||||
|
import { FormFooter } from './FormFooter'
|
||||||
|
|
||||||
|
export {
|
||||||
|
FormContainer,
|
||||||
|
FormHeader,
|
||||||
|
FormContent,
|
||||||
|
FormFooter
|
||||||
|
}
|
||||||
17
src/MaksIT.WebUI/src/components/Layout/Container.tsx
Normal file
17
src/MaksIT.WebUI/src/components/Layout/Container.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { FC, ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface ContentProps {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const Container: FC<ContentProps> = (props) => {
|
||||||
|
const { children } = props
|
||||||
|
|
||||||
|
return <div className={'grid grid-rows-[auto_1fr_auto] h-screen'}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Container
|
||||||
|
}
|
||||||
17
src/MaksIT.WebUI/src/components/Layout/Content.tsx
Normal file
17
src/MaksIT.WebUI/src/components/Layout/Content.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { FC, ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface ContentProps {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const Content: FC<ContentProps> = (props) => {
|
||||||
|
const { children } = props
|
||||||
|
|
||||||
|
return <main className={'bg-gray-100 h-full w-full overflow-y-auto'}>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Content
|
||||||
|
}
|
||||||
16
src/MaksIT.WebUI/src/components/Layout/Footer.tsx
Normal file
16
src/MaksIT.WebUI/src/components/Layout/Footer.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { FC, ReactNode } from 'react'
|
||||||
|
|
||||||
|
export interface FooterProps {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const Footer: FC<FooterProps> = (props) => {
|
||||||
|
const { children } = props
|
||||||
|
return <footer className={'bg-blue-500 text-white p-4 h-14 flex items-center justify-center'}>
|
||||||
|
{children}
|
||||||
|
</footer>
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Footer
|
||||||
|
}
|
||||||
19
src/MaksIT.WebUI/src/components/Layout/Header.tsx
Normal file
19
src/MaksIT.WebUI/src/components/Layout/Header.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { FC, ReactNode } from 'react'
|
||||||
|
|
||||||
|
export interface HeaderProps {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const Header: FC<HeaderProps> = (props) => {
|
||||||
|
const { children } = props
|
||||||
|
|
||||||
|
return <header>
|
||||||
|
<div className={'bg-blue-500 text-white p-4 h-14 flex justify-between items-center'}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Header
|
||||||
|
}
|
||||||
17
src/MaksIT.WebUI/src/components/Layout/MainContainer.tsx
Normal file
17
src/MaksIT.WebUI/src/components/Layout/MainContainer.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { FC, ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface ContainerProps {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const MainContainer: FC<ContainerProps> = (props) => {
|
||||||
|
const { children } = props
|
||||||
|
|
||||||
|
return <div className={'grid grid-cols-[250px_1fr] h-screen w-screen'}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
MainContainer
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
import { FC, ReactNode } from 'react'
|
||||||
|
|
||||||
|
export interface ContainerProps {
|
||||||
|
headerChildren?: ReactNode
|
||||||
|
children: ReactNode
|
||||||
|
footerChildren?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const Container: FC<ContainerProps> = (props) => {
|
||||||
|
const { children } = props
|
||||||
|
|
||||||
|
return <aside className={'grid grid-rows-[auto_1fr_auto] h-screen'}>
|
||||||
|
{children}
|
||||||
|
</aside>
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Container
|
||||||
|
}
|
||||||
17
src/MaksIT.WebUI/src/components/Layout/SideMenu/Content.tsx
Normal file
17
src/MaksIT.WebUI/src/components/Layout/SideMenu/Content.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { FC, ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface ContentProps {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const Content: FC<ContentProps> = (props) => {
|
||||||
|
const { children } = props
|
||||||
|
|
||||||
|
return <main className={'bg-gray-200 h-full p-4 overflow-y-auto border-r border-blue-500'}>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Content
|
||||||
|
}
|
||||||
16
src/MaksIT.WebUI/src/components/Layout/SideMenu/Footer.tsx
Normal file
16
src/MaksIT.WebUI/src/components/Layout/SideMenu/Footer.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { FC, ReactNode } from 'react'
|
||||||
|
|
||||||
|
export interface FooterProps {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const Footer: FC<FooterProps> = (props) => {
|
||||||
|
const { children } = props
|
||||||
|
return <footer className={'bg-blue-500 text-white p-4 h-14'}>
|
||||||
|
{children}
|
||||||
|
</footer>
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Footer
|
||||||
|
}
|
||||||
19
src/MaksIT.WebUI/src/components/Layout/SideMenu/Header.tsx
Normal file
19
src/MaksIT.WebUI/src/components/Layout/SideMenu/Header.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { FC, ReactNode } from 'react'
|
||||||
|
|
||||||
|
export interface HeaderProps {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const Header: FC<HeaderProps> = (props) => {
|
||||||
|
const { children } = props
|
||||||
|
|
||||||
|
return <header>
|
||||||
|
<div className={'bg-blue-500 text-white p-4 h-14'}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Header
|
||||||
|
}
|
||||||
32
src/MaksIT.WebUI/src/components/Layout/SideMenu/index.tsx
Normal file
32
src/MaksIT.WebUI/src/components/Layout/SideMenu/index.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { FC, ReactNode } from 'react'
|
||||||
|
import { Container } from '../Container'
|
||||||
|
import { Header } from './Header'
|
||||||
|
import { Content } from './Content'
|
||||||
|
import { Footer } from './Footer'
|
||||||
|
|
||||||
|
|
||||||
|
export interface SideMenuProps {
|
||||||
|
headerChildren?: ReactNode
|
||||||
|
children: ReactNode
|
||||||
|
footerChildren?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const SideMenu: FC<SideMenuProps> = (props) => {
|
||||||
|
const { headerChildren, children, footerChildren } = props
|
||||||
|
|
||||||
|
return <Container>
|
||||||
|
<Header>
|
||||||
|
{headerChildren}
|
||||||
|
</Header>
|
||||||
|
<Content>
|
||||||
|
{children}
|
||||||
|
</Content>
|
||||||
|
<Footer>
|
||||||
|
{footerChildren}
|
||||||
|
</Footer>
|
||||||
|
</Container>
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
SideMenu
|
||||||
|
}
|
||||||
40
src/MaksIT.WebUI/src/components/Layout/index.tsx
Normal file
40
src/MaksIT.WebUI/src/components/Layout/index.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { FC } from 'react'
|
||||||
|
import { MainContainer } from './MainContainer'
|
||||||
|
import { SideMenu, SideMenuProps } from './SideMenu'
|
||||||
|
import { Container } from './Container'
|
||||||
|
import { Header, HeaderProps } from './Header'
|
||||||
|
import { Footer, FooterProps } from './Footer'
|
||||||
|
import { Content } from './Content'
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
sideMenu: SideMenuProps
|
||||||
|
header: HeaderProps
|
||||||
|
children: React.ReactNode
|
||||||
|
footer: FooterProps
|
||||||
|
}
|
||||||
|
|
||||||
|
const Layout: FC<LayoutProps> = (props) => {
|
||||||
|
const { sideMenu, header, children, footer } = props
|
||||||
|
|
||||||
|
return <MainContainer>
|
||||||
|
<SideMenu
|
||||||
|
headerChildren={sideMenu.headerChildren}
|
||||||
|
footerChildren={sideMenu.footerChildren}
|
||||||
|
>
|
||||||
|
{sideMenu.children}
|
||||||
|
</SideMenu>
|
||||||
|
<Container>
|
||||||
|
<Header>
|
||||||
|
{header.children}
|
||||||
|
</Header>
|
||||||
|
<Content>
|
||||||
|
{children}
|
||||||
|
</Content>
|
||||||
|
<Footer>{footer.children}</Footer>
|
||||||
|
</Container>
|
||||||
|
</MainContainer>
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Layout
|
||||||
|
}
|
||||||
84
src/MaksIT.WebUI/src/components/LazyLoadTable.tsx
Normal file
84
src/MaksIT.WebUI/src/components/LazyLoadTable.tsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { FC, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
interface LazyLoadTableColumnProps {
|
||||||
|
key: string
|
||||||
|
title: string
|
||||||
|
dataIndex: string
|
||||||
|
renderColumn?: (value: any) => React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LazyLoadTableProps {
|
||||||
|
data: any[]
|
||||||
|
columns: LazyLoadTableColumnProps[]
|
||||||
|
loadMore: () => void
|
||||||
|
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
|
||||||
|
}
|
||||||
|
|
||||||
|
const LazyLoadTable: FC<LazyLoadTableProps> = (props) => {
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
loadMore,
|
||||||
|
colspan = 6
|
||||||
|
} = props
|
||||||
|
|
||||||
|
const [selectedRowIndex, setSelectedRowIndex] = useState<number | null>(null)
|
||||||
|
const observerRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0].isIntersecting) {
|
||||||
|
loadMore()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 1.0 }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (observerRef.current) {
|
||||||
|
observer.observe(observerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (observerRef.current) {
|
||||||
|
observer.unobserve(observerRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [loadMore])
|
||||||
|
|
||||||
|
const handleRowClick = (index: number) => {
|
||||||
|
setSelectedRowIndex(selectedRowIndex === index ? null : index)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`col-span-${colspan}`}>
|
||||||
|
<table className={'w-full border-collapse'}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{columns.map((column, index) => (
|
||||||
|
<th key={index} className={'bg-gray-50 text-left py-2 px-4 border-b'}>{column.title}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.map((row, index) => (
|
||||||
|
<tr
|
||||||
|
key={index}
|
||||||
|
className={`hover:bg-gray-100 ${selectedRowIndex === index ? 'bg-blue-100' : ''} transition-colors`}
|
||||||
|
onClick={() => handleRowClick(index)}
|
||||||
|
>
|
||||||
|
{columns.map((column, colIndex) => (
|
||||||
|
<td className={'py-2 px-4 border-b'} key={colIndex}>
|
||||||
|
{column.renderColumn ? column.renderColumn(row[column.dataIndex]) : row[column.dataIndex]}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div ref={observerRef} className={'text-center py-4'}>Loading more...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { LazyLoadTable }
|
||||||
17
src/MaksIT.WebUI/src/components/Loader.tsx
Normal file
17
src/MaksIT.WebUI/src/components/Loader.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { useAppSelector } from '../redux/hooks'
|
||||||
|
import { RootState } from '../redux/store'
|
||||||
|
|
||||||
|
|
||||||
|
const Loader = () => {
|
||||||
|
const loading = useAppSelector((state: RootState) => state.loader.loading)
|
||||||
|
|
||||||
|
if (!loading) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={'fixed inset-0 flex items-center justify-center bg-gray-800/75 z-50'}>
|
||||||
|
<div className={'ease-linear rounded-full border-8 border-t-8 border-gray-200 h-32 w-32 animate-spin'} style={{ borderTopColor: '#3498db' }}></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Loader }
|
||||||
139
src/MaksIT.WebUI/src/components/LoginScreen.tsx
Normal file
139
src/MaksIT.WebUI/src/components/LoginScreen.tsx
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { LoginRequest, LoginRequestSchema } from '../models/identity/login/LoginRequest'
|
||||||
|
import { useAppDispatch, useAppSelector } from '../redux/hooks'
|
||||||
|
import { login } from '../redux/slices/identitySlice'
|
||||||
|
import { useFormState } from '../hooks/useFormState'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { ButtonComponent, CheckBoxComponent, TextBoxComponent } from './editors'
|
||||||
|
|
||||||
|
const LoginScreen: React.FC = () => {
|
||||||
|
const [use2FA, setUse2FA] = useState(false)
|
||||||
|
const [use2FARecovery, setUse2FARecovery] = useState(false)
|
||||||
|
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
const { identity } = useAppSelector((state) => state.identity)
|
||||||
|
|
||||||
|
const {
|
||||||
|
formState,
|
||||||
|
errors,
|
||||||
|
formIsValid,
|
||||||
|
handleInputChange
|
||||||
|
} = useFormState<LoginRequest>({
|
||||||
|
initialState: { username: '', password: '' },
|
||||||
|
validationSchema: LoginRequestSchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!identity || new Date(identity.refreshTokenExpiresAt) < new Date()) {
|
||||||
|
navigate('/login', { replace: true })
|
||||||
|
} else {
|
||||||
|
navigate('/', { replace: true })
|
||||||
|
}
|
||||||
|
}, [identity, navigate])
|
||||||
|
|
||||||
|
const handleUse2FA = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setUse2FA(e.target.checked)
|
||||||
|
if (!e.target.checked) {
|
||||||
|
setUse2FARecovery(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogin = () => {
|
||||||
|
if (!formIsValid) return
|
||||||
|
|
||||||
|
if (formState.twoFactorCode === '') delete formState.twoFactorCode
|
||||||
|
if (formState.twoFactorRecoveryCode === '') delete formState.twoFactorRecoveryCode
|
||||||
|
|
||||||
|
dispatch(login(formState))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility classes
|
||||||
|
const inputClasses =
|
||||||
|
'block w-full rounded-md border border-gray-300 p-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-200'
|
||||||
|
const checkboxClasses =
|
||||||
|
'h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-2 focus:ring-blue-200'
|
||||||
|
const buttonPrimaryClasses =
|
||||||
|
'w-full py-2 px-4 rounded-md bg-blue-600 hover:bg-blue-700 text-white font-semibold'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={'flex items-center justify-center min-h-screen bg-gray-100'}>
|
||||||
|
<div className={'w-full max-w-md bg-white rounded-lg shadow-md p-8 space-y-6'}>
|
||||||
|
{/* Logo */}
|
||||||
|
<div className={'flex justify-center'}>
|
||||||
|
<img src={'/logo.png'} alt={'Logo'} className={'h-12 w-auto'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<div className={'space-y-4'}>
|
||||||
|
<div className={'space-y-4'}>
|
||||||
|
<TextBoxComponent
|
||||||
|
label={'Email'}
|
||||||
|
placeholder={'Email...'}
|
||||||
|
value={formState.username}
|
||||||
|
onChange={(e) => handleInputChange('username', e.target.value)}
|
||||||
|
errorText={errors.username}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextBoxComponent
|
||||||
|
label={'Password'}
|
||||||
|
placeholder={'Password...'}
|
||||||
|
type={'password'}
|
||||||
|
value={formState.password}
|
||||||
|
onChange={(e) => handleInputChange('password', e.target.value)}
|
||||||
|
errorText={errors.password}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2FA Options */}
|
||||||
|
<div className={'flex items-center gap-4'}>
|
||||||
|
<CheckBoxComponent
|
||||||
|
label={'Use 2FA'}
|
||||||
|
value={use2FA}
|
||||||
|
onChange={handleUse2FA}
|
||||||
|
/>
|
||||||
|
{use2FA && (
|
||||||
|
<CheckBoxComponent
|
||||||
|
label={'Use 2FA Recovery'}
|
||||||
|
value={use2FARecovery}
|
||||||
|
onChange={(e) => setUse2FARecovery(e.target.checked)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2FA Inputs */}
|
||||||
|
{use2FA && (
|
||||||
|
<div className={'space-y-4'}>
|
||||||
|
{use2FARecovery ? (
|
||||||
|
<TextBoxComponent
|
||||||
|
label={'2FA Recovery Code'}
|
||||||
|
placeholder={'Recovery code...'}
|
||||||
|
value={formState.twoFactorRecoveryCode}
|
||||||
|
onChange={(e) => handleInputChange('twoFactorRecoveryCode', e.target.value)}
|
||||||
|
errorText={errors.twoFactorRecoveryCode}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TextBoxComponent
|
||||||
|
label={'2FA Code'}
|
||||||
|
placeholder={'Authentication code...'}
|
||||||
|
value={formState.twoFactorCode}
|
||||||
|
onChange={(e) => handleInputChange('twoFactorCode', e.target.value)}
|
||||||
|
errorText={errors.twoFactorCode}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<ButtonComponent
|
||||||
|
label={'Sign in'}
|
||||||
|
buttonHierarchy={'primary'}
|
||||||
|
onClick={handleLogin}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { LoginScreen }
|
||||||
56
src/MaksIT.WebUI/src/components/Offcanvas.tsx
Normal file
56
src/MaksIT.WebUI/src/components/Offcanvas.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { FC, ReactNode, useCallback, useEffect } from 'react'
|
||||||
|
|
||||||
|
export interface OffcanvasProps {
|
||||||
|
children: ReactNode
|
||||||
|
isOpen?: boolean
|
||||||
|
onOpen?: () => void
|
||||||
|
onClose?: () => void
|
||||||
|
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
|
||||||
|
}
|
||||||
|
|
||||||
|
const Offcanvas: FC<OffcanvasProps> = (props) => {
|
||||||
|
const {
|
||||||
|
children,
|
||||||
|
isOpen = false,
|
||||||
|
onOpen,
|
||||||
|
onClose,
|
||||||
|
colspan = 6
|
||||||
|
} = props
|
||||||
|
|
||||||
|
const handleOnOpen = useCallback(() => {
|
||||||
|
onOpen?.()
|
||||||
|
}, [onOpen])
|
||||||
|
|
||||||
|
const handleOnClose = useCallback(() => {
|
||||||
|
onClose?.()
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) handleOnOpen()
|
||||||
|
else handleOnClose()
|
||||||
|
}, [isOpen, handleOnOpen, handleOnClose])
|
||||||
|
|
||||||
|
const leftSpan = 12 - colspan
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'fixed inset-0 h-screen w-screen',
|
||||||
|
'bg-black/20 backdrop-blur-md',
|
||||||
|
'z-40 transition-opacity duration-300 ease-in-out',
|
||||||
|
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<div className={'grid grid-cols-12 h-full w-full'}>
|
||||||
|
{/* colonna di offset */}
|
||||||
|
<div className={`col-span-${leftSpan}`} />
|
||||||
|
{/* area principale */}
|
||||||
|
<div className={`col-span-${colspan} min-h-0`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Offcanvas }
|
||||||
13
src/MaksIT.WebUI/src/components/Toast/addToast.ts
Normal file
13
src/MaksIT.WebUI/src/components/Toast/addToast.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
// Define the types for the toast
|
||||||
|
interface AddToastProps {
|
||||||
|
message: string;
|
||||||
|
type: 'info' | 'success' | 'warning' | 'error';
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const addToast = (message: string, type: 'info' | 'success' | 'warning' | 'error', duration?: number): void => {
|
||||||
|
const event = new CustomEvent<AddToastProps>('add-toast', {
|
||||||
|
detail: { message, type, duration },
|
||||||
|
})
|
||||||
|
window.dispatchEvent(event)
|
||||||
|
}
|
||||||
70
src/MaksIT.WebUI/src/components/Toast/index.tsx
Normal file
70
src/MaksIT.WebUI/src/components/Toast/index.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { useState, useEffect, FC } from 'react'
|
||||||
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
|
||||||
|
// Define types for a toast
|
||||||
|
interface Toast {
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
type: 'info' | 'success' | 'warning' | 'error';
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Toast: FC = () => {
|
||||||
|
const [toasts, setToasts] = useState<Toast[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleAddToast = (event: CustomEvent<Toast>) => {
|
||||||
|
const { message, type, duration } = event.detail
|
||||||
|
|
||||||
|
// Add the new toast
|
||||||
|
const id = uuidv4()
|
||||||
|
setToasts((prev) => [...prev, { id, message, type, duration }])
|
||||||
|
|
||||||
|
// Auto-remove if a duration is specified
|
||||||
|
if (duration) {
|
||||||
|
setTimeout(() => {
|
||||||
|
setToasts((prev) => prev.filter((toast) => toast.id !== id))
|
||||||
|
}, duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for the custom event
|
||||||
|
window.addEventListener('add-toast', handleAddToast as EventListener)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Cleanup event listener on component unmount
|
||||||
|
window.removeEventListener('add-toast', handleAddToast as EventListener)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Remove toast manually
|
||||||
|
const handleClose = (id: string) => {
|
||||||
|
setToasts((prev) => prev.filter((toast) => toast.id !== id))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={'fixed bottom-16 right-4 flex flex-col gap-2 z-50'}>
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<div
|
||||||
|
key={toast.id}
|
||||||
|
className={`relative flex items-center justify-between gap-3 px-4 py-3 pr-10 rounded-md shadow-md text-white
|
||||||
|
${toast.type === 'success' ? 'bg-green-500' : ''}
|
||||||
|
${toast.type === 'error' ? 'bg-red-500' : ''}
|
||||||
|
${toast.type === 'warning' ? 'bg-yellow-500' : ''}
|
||||||
|
${toast.type === 'info' ? 'bg-blue-500' : ''}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<span>{toast.message}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleClose(toast.id)}
|
||||||
|
className={'absolute top-2 right-2 text-xl font-bold text-white hover:opacity-75'}
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Toast
|
||||||
|
}
|
||||||
26
src/MaksIT.WebUI/src/components/UserButton.tsx
Normal file
26
src/MaksIT.WebUI/src/components/UserButton.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { useAppDispatch, useAppSelector } from '../redux/hooks'
|
||||||
|
import { setShowUserOffcanvas } from '../redux/slices/identitySlice'
|
||||||
|
|
||||||
|
const UserButton = () => {
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
//const { identity } = useAppSelector(state => state.identity)
|
||||||
|
|
||||||
|
const identity = {
|
||||||
|
username: 'JohnDoe',
|
||||||
|
isGlobalAdmin: true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!identity) return <></>
|
||||||
|
|
||||||
|
return <button
|
||||||
|
className={'bg-white text-blue-500 px-2 py-1 rounded'}
|
||||||
|
onClick={() => {
|
||||||
|
dispatch(setShowUserOffcanvas())
|
||||||
|
}}>
|
||||||
|
{`${identity.username} ${identity.isGlobalAdmin ? '(Global Admin)' : ''}`.trim()}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
UserButton
|
||||||
|
}
|
||||||
48
src/MaksIT.WebUI/src/components/UserOffcanvas.tsx
Normal file
48
src/MaksIT.WebUI/src/components/UserOffcanvas.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { useAppDispatch, useAppSelector } from '../redux/hooks'
|
||||||
|
import { logout, setHideUserOffcanvas } from '../redux/slices/identitySlice'
|
||||||
|
import { ButtonComponent } from './editors'
|
||||||
|
import { FormContainer, FormContent, FormHeader } from './FormLayout'
|
||||||
|
import { Offcanvas } from './Offcanvas'
|
||||||
|
|
||||||
|
const UserOffcanvas = () => {
|
||||||
|
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
const { identity, showUserOffcanvas } = useAppSelector(state => state.identity)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Offcanvas isOpen={showUserOffcanvas} colspan={4}>
|
||||||
|
<FormContainer>
|
||||||
|
<FormHeader>{identity?.username}</FormHeader>
|
||||||
|
<FormContent>
|
||||||
|
<div className={'flex flex-col justify-between h-full'}>
|
||||||
|
<div className={'flex flex-col items-center gap-3'}>
|
||||||
|
<ButtonComponent
|
||||||
|
label={'Edit User'}
|
||||||
|
route={`/user/${identity?.userId}`}
|
||||||
|
onClick={() => {
|
||||||
|
dispatch(setHideUserOffcanvas())
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={'flex flex-col items-center gap-3'}>
|
||||||
|
<ButtonComponent
|
||||||
|
label={'Back'}
|
||||||
|
buttonHierarchy={'secondary'}
|
||||||
|
onClick={() => dispatch(setHideUserOffcanvas())}
|
||||||
|
/>
|
||||||
|
<ButtonComponent
|
||||||
|
label={'Logout'}
|
||||||
|
buttonHierarchy={'error'}
|
||||||
|
onClick={() => dispatch(logout(false))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FormContent>
|
||||||
|
</FormContainer>
|
||||||
|
</Offcanvas>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
UserOffcanvas
|
||||||
|
}
|
||||||
79
src/MaksIT.WebUI/src/components/editors/ButtonComponent.tsx
Normal file
79
src/MaksIT.WebUI/src/components/editors/ButtonComponent.tsx
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
interface ConditionalButtonProps {
|
||||||
|
label: string;
|
||||||
|
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
|
||||||
|
route?: string;
|
||||||
|
buttonHierarchy?: 'primary' | 'secondary' | 'success' | 'error' | 'warning';
|
||||||
|
onClick?: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ButtonComponent: React.FC<ConditionalButtonProps> = (props) => {
|
||||||
|
|
||||||
|
const {
|
||||||
|
label,
|
||||||
|
colspan,
|
||||||
|
route,
|
||||||
|
buttonHierarchy,
|
||||||
|
onClick,
|
||||||
|
disabled = false
|
||||||
|
} = props
|
||||||
|
|
||||||
|
const handleClick = (e?: React.MouseEvent) => {
|
||||||
|
if (disabled) {
|
||||||
|
e?.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onClick?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
let buttonClass = ''
|
||||||
|
switch (buttonHierarchy) {
|
||||||
|
case 'primary':
|
||||||
|
buttonClass = 'bg-blue-500 text-white'
|
||||||
|
break
|
||||||
|
case 'secondary':
|
||||||
|
buttonClass = 'bg-gray-500 text-white'
|
||||||
|
break
|
||||||
|
case 'success':
|
||||||
|
buttonClass = 'bg-green-500 text-white'
|
||||||
|
break
|
||||||
|
case 'warning':
|
||||||
|
buttonClass = 'bg-yellow-500 text-white'
|
||||||
|
break
|
||||||
|
case 'error':
|
||||||
|
buttonClass = 'bg-red-500 text-white'
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
buttonClass = 'bg-blue-500 text-white'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const disabledClass = disabled ? 'opacity-50 cursor-default' : 'cursor-pointer'
|
||||||
|
|
||||||
|
return route
|
||||||
|
? (
|
||||||
|
<Link
|
||||||
|
to={route}
|
||||||
|
className={`${buttonClass} px-4 py-2 rounded ${colspan ? `col-span-${colspan}` : 'w-full'} text-center ${disabledClass}`}
|
||||||
|
onClick={handleClick}
|
||||||
|
tabIndex={disabled ? -1 : undefined}
|
||||||
|
aria-disabled={disabled}
|
||||||
|
style={disabled ? { pointerEvents: 'none' } : undefined}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className={`${buttonClass} px-4 py-2 rounded ${colspan ? `col-span-${colspan}` : 'w-full'} ${disabledClass}`}
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ButtonComponent }
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
interface CheckBoxComponentProps {
|
||||||
|
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
|
||||||
|
label: string;
|
||||||
|
value: boolean;
|
||||||
|
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
errorText?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CheckBoxComponent: React.FC<CheckBoxComponentProps> = (props) => {
|
||||||
|
|
||||||
|
const {
|
||||||
|
colspan = 6,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
errorText,
|
||||||
|
disabled = false
|
||||||
|
} = props
|
||||||
|
|
||||||
|
const prevValue = useRef<boolean>(value)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
prevValue.current = value
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
const handleOnChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (prevValue.current === e.target.checked)
|
||||||
|
return
|
||||||
|
|
||||||
|
prevValue.current = e.target.checked
|
||||||
|
|
||||||
|
onChange?.(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`mb-4 col-span-${colspan}`}>
|
||||||
|
<label className={'block text-gray-700 text-sm font-bold mb-2'}>
|
||||||
|
<input
|
||||||
|
type={'checkbox'}
|
||||||
|
checked={value}
|
||||||
|
onChange={handleOnChange}
|
||||||
|
className={`mr-2 leading-tight ${errorText ? 'border-red-500' : ''}`}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{errorText && (
|
||||||
|
<p className={'text-red-500 text-xs italic mt-2'}>
|
||||||
|
{errorText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
CheckBoxComponent
|
||||||
|
}
|
||||||
@ -0,0 +1,192 @@
|
|||||||
|
import { ChangeEvent, FC, useState, useEffect, useRef } from 'react'
|
||||||
|
import { parseISO, formatISO, format, getDaysInMonth, addMonths, subMonths } from 'date-fns'
|
||||||
|
import { ButtonComponent } from './ButtonComponent'
|
||||||
|
import { TextBoxComponent } from './TextBoxComponent'
|
||||||
|
import { CircleX } from 'lucide-react'
|
||||||
|
|
||||||
|
const DISPLAY_FORMAT = 'yyyy-MM-dd HH:mm'
|
||||||
|
|
||||||
|
interface DateTimePickerComponentProps {
|
||||||
|
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
|
||||||
|
label: string
|
||||||
|
value?: string
|
||||||
|
onChange?: (isoString?: string) => void
|
||||||
|
errorText?: string
|
||||||
|
placeholder?: string
|
||||||
|
readOnly?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const DateTimePickerComponent: FC<DateTimePickerComponentProps> = ({
|
||||||
|
colspan = 6,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
errorText,
|
||||||
|
placeholder,
|
||||||
|
readOnly = false,
|
||||||
|
disabled = false,
|
||||||
|
}) => {
|
||||||
|
const prevValueRef = useRef<string | undefined>(value)
|
||||||
|
const parsedValue = value ? parseISO(value) : undefined
|
||||||
|
|
||||||
|
const [showDropdown, setShowDropdown] = useState(false)
|
||||||
|
const [currentViewDate, setCurrentViewDate] = useState<Date>(parsedValue || new Date())
|
||||||
|
const [tempDate, setTempDate] = useState<Date>(parsedValue || new Date())
|
||||||
|
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (value !== prevValueRef.current) {
|
||||||
|
const newDate = parsedValue || new Date()
|
||||||
|
setCurrentViewDate(newDate)
|
||||||
|
setTempDate(newDate)
|
||||||
|
prevValueRef.current = value
|
||||||
|
}
|
||||||
|
}, [value, parsedValue])
|
||||||
|
|
||||||
|
const formatForDisplay = (date: Date) => format(date, DISPLAY_FORMAT)
|
||||||
|
|
||||||
|
const daysCount = getDaysInMonth(currentViewDate)
|
||||||
|
const daysArray = Array.from({ length: daysCount }, (_, i) => i + 1)
|
||||||
|
|
||||||
|
const handlePrevMonth = () => setCurrentViewDate((prev) => subMonths(prev, 1))
|
||||||
|
const handleNextMonth = () => setCurrentViewDate((prev) => addMonths(prev, 1))
|
||||||
|
|
||||||
|
const handleDayClick = (day: number) => {
|
||||||
|
const newDate = new Date(tempDate)
|
||||||
|
newDate.setFullYear(currentViewDate.getFullYear(), currentViewDate.getMonth(), day)
|
||||||
|
setTempDate(newDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTimeChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
const [hours, minutes] = e.target.value.split(':').map(Number)
|
||||||
|
const newDate = new Date(tempDate)
|
||||||
|
newDate.setHours(hours, minutes, 0, 0)
|
||||||
|
setTempDate(newDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
if (prevValueRef.current !== undefined) {
|
||||||
|
onChange?.(undefined)
|
||||||
|
prevValueRef.current = undefined
|
||||||
|
}
|
||||||
|
setShowDropdown(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
const isoString = formatISO(tempDate, { representation: 'complete' })
|
||||||
|
if (isoString !== prevValueRef.current) {
|
||||||
|
onChange?.(isoString)
|
||||||
|
prevValueRef.current = isoString
|
||||||
|
}
|
||||||
|
setShowDropdown(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionButtons = () => {
|
||||||
|
const className = 'p-1 text-gray-600 hover:text-gray-800 bg-white'
|
||||||
|
return [
|
||||||
|
!!value && !readOnly && (
|
||||||
|
<button
|
||||||
|
key={'clear'}
|
||||||
|
type={'button'}
|
||||||
|
onClick={handleClear}
|
||||||
|
className={className}
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label={'Clear'}
|
||||||
|
>
|
||||||
|
<CircleX />
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
].filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setShowDropdown(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDropdown) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
|
}, [showDropdown])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative mb-4 ${colspan ? `col-span-${colspan}` : 'w-full'}`} ref={dropdownRef}>
|
||||||
|
<label className={'block text-gray-700 text-sm font-bold mb-2'}>{label}</label>
|
||||||
|
<div className={'relative'}>
|
||||||
|
<input
|
||||||
|
type={'text'}
|
||||||
|
value={value ? formatForDisplay(parsedValue!) : ''}
|
||||||
|
onFocus={() => !readOnly && !disabled && setShowDropdown(true)}
|
||||||
|
readOnly
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={`shadow appearance-none border rounded w-full px-3 py-2 text-gray-700 leading-tight focus:outline-none focus:shadow-outline ${
|
||||||
|
errorText ? 'border-red-500' : ''
|
||||||
|
} ${disabled ? 'bg-gray-100 text-gray-500 cursor-default' : 'bg-white'}${readOnly && !disabled ? ' text-gray-500 cursor-default' : ''}`}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Fixed Action Buttons */}
|
||||||
|
<div className={'absolute top-0 bottom-0 right-2 flex items-center gap-1 pointer-events-auto'}>
|
||||||
|
{actionButtons()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showDropdown && !readOnly && !disabled && (
|
||||||
|
<div className={'absolute left-0 right-0 bg-white border border-gray-300 rounded mt-1 w-full shadow-lg z-10'}>
|
||||||
|
<div className={'flex justify-between items-center px-3 py-2'}>
|
||||||
|
<button onClick={handlePrevMonth} type={'button'}>
|
||||||
|
<
|
||||||
|
</button>
|
||||||
|
<span>{format(currentViewDate, 'MMMM yyyy')}</span>
|
||||||
|
<button onClick={handleNextMonth} type={'button'}>
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={'grid grid-cols-7 gap-1 px-3 py-2'}>
|
||||||
|
{daysArray.map((day) => (
|
||||||
|
<div
|
||||||
|
key={day}
|
||||||
|
onClick={() => handleDayClick(day)}
|
||||||
|
className={`p-2 cursor-pointer text-center ${
|
||||||
|
tempDate.getDate() === day &&
|
||||||
|
tempDate.getMonth() === currentViewDate.getMonth() &&
|
||||||
|
tempDate.getFullYear() === currentViewDate.getFullYear()
|
||||||
|
? 'bg-blue-500 text-white rounded'
|
||||||
|
: 'hover:bg-gray-200 rounded'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={'px-3 py-2'}>
|
||||||
|
<TextBoxComponent
|
||||||
|
label={'Time'}
|
||||||
|
type={'time'}
|
||||||
|
value={format(tempDate, 'HH:mm')}
|
||||||
|
onChange={handleTimeChange}
|
||||||
|
placeholder={'HH:MM'}
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={'px-3 py-2 gap-2 flex justify-between'}>
|
||||||
|
<ButtonComponent label={'Clear'} buttonHierarchy={'secondary'} onClick={handleClear} />
|
||||||
|
<ButtonComponent label={'Confirm'} buttonHierarchy={'primary'} onClick={handleConfirm} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{errorText && <p className={'text-red-500 text-xs italic mt-2'}>{errorText}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DateTimePickerComponent }
|
||||||
106
src/MaksIT.WebUI/src/components/editors/DualListboxComponent.tsx
Normal file
106
src/MaksIT.WebUI/src/components/editors/DualListboxComponent.tsx
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
|
interface DualListboxComponentProps {
|
||||||
|
label?: string;
|
||||||
|
availableItemsLabel?: string;
|
||||||
|
selectedItemsLabel?: string;
|
||||||
|
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
|
||||||
|
|
||||||
|
idFieldName?: string;
|
||||||
|
availableItems: string[];
|
||||||
|
selectedItems: string[];
|
||||||
|
onChange: (selectedItems: string[]) => void;
|
||||||
|
errorText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DualListboxComponent: React.FC<DualListboxComponentProps> = (props) => {
|
||||||
|
|
||||||
|
const {
|
||||||
|
label,
|
||||||
|
availableItemsLabel = 'Available Items',
|
||||||
|
selectedItemsLabel = 'Selected Items',
|
||||||
|
colspan = 6,
|
||||||
|
availableItems,
|
||||||
|
selectedItems,
|
||||||
|
onChange,
|
||||||
|
errorText
|
||||||
|
} = props
|
||||||
|
|
||||||
|
const [available, setAvailable] = useState<string[]>(availableItems)
|
||||||
|
const [selected, setSelected] = useState<string[]>(selectedItems)
|
||||||
|
|
||||||
|
const moveToSelected = () => {
|
||||||
|
const movedItems = available.filter(item => selected.includes(item))
|
||||||
|
setAvailable(available.filter(item => !movedItems.includes(item)))
|
||||||
|
setSelected([...selected, ...movedItems])
|
||||||
|
onChange([...selected, ...movedItems])
|
||||||
|
}
|
||||||
|
|
||||||
|
const moveToAvailable = () => {
|
||||||
|
const movedItems = selected.filter(item => !available.includes(item))
|
||||||
|
setSelected(selected.filter(item => !movedItems.includes(item)))
|
||||||
|
setAvailable([...available, ...movedItems])
|
||||||
|
onChange(selected.filter(item => !movedItems.includes(item)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`col-span-${colspan}`}>
|
||||||
|
<label className={'block text-gray-700 text-sm font-bold mb-2'}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<div className={'flex justify-center items-center gap-4 w-full h-full'}>
|
||||||
|
<div className={'flex flex-col'}>
|
||||||
|
<h3>{availableItemsLabel}</h3>
|
||||||
|
<ul className={'border p-2 w-40 h-64 overflow-auto'}>
|
||||||
|
{available.map(item => (
|
||||||
|
<li
|
||||||
|
key={item}
|
||||||
|
className={'cursor-pointer hover:bg-gray-200'}
|
||||||
|
onClick={() => setAvailable(available.filter(i => i !== item))}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className={'flex flex-col gap-2'}>
|
||||||
|
<button
|
||||||
|
onClick={moveToSelected}
|
||||||
|
className={'border px-4 py-2 bg-blue-500 text-white hover:bg-blue-600'}
|
||||||
|
>
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={moveToAvailable}
|
||||||
|
className={'border px-4 py-2 bg-red-500 text-white hover:bg-red-600'}
|
||||||
|
>
|
||||||
|
<
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={'flex flex-col'}>
|
||||||
|
<h3>{selectedItemsLabel}</h3>
|
||||||
|
<ul className={'border p-2 w-40 h-64 overflow-auto'}>
|
||||||
|
{selected.map(item => (
|
||||||
|
<li
|
||||||
|
key={item}
|
||||||
|
className={'cursor-pointer hover:bg-gray-200'}
|
||||||
|
onClick={() => setSelected(selected.filter(i => i !== item))}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{errorText && (
|
||||||
|
<p className={'text-red-500 text-xs italic mt-2'}>
|
||||||
|
{errorText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DualListboxComponent
|
||||||
|
}
|
||||||
147
src/MaksIT.WebUI/src/components/editors/FileUploadComponent.tsx
Normal file
147
src/MaksIT.WebUI/src/components/editors/FileUploadComponent.tsx
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
import React, { useRef, useState } from 'react'
|
||||||
|
import { ButtonComponent } from './ButtonComponent'
|
||||||
|
|
||||||
|
interface FileUploadComponentProps {
|
||||||
|
label?: string
|
||||||
|
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
|
||||||
|
multiple?: boolean
|
||||||
|
onChange?: (files: File[]) => void
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
|
label = 'Select files',
|
||||||
|
colspan = 6,
|
||||||
|
multiple = true,
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
}) => {
|
||||||
|
const [selectedFiles, setSelectedFiles] = useState<File[]>([])
|
||||||
|
const [showPopup, setShowPopup] = useState(false)
|
||||||
|
const [popupPos, setPopupPos] = useState<{x: number, y: number}>({x: 0, y: 0})
|
||||||
|
const popupRef = useRef<HTMLDivElement>(null)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// Focus popup when it opens
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (showPopup && popupRef.current) {
|
||||||
|
popupRef.current.focus()
|
||||||
|
}
|
||||||
|
}, [showPopup])
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files ? Array.from(e.target.files) : []
|
||||||
|
setSelectedFiles(files)
|
||||||
|
onChange?.(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
setSelectedFiles([])
|
||||||
|
if (inputRef.current) inputRef.current.value = ''
|
||||||
|
onChange?.([])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectFiles = () => {
|
||||||
|
if (!disabled) inputRef.current?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`grid grid-cols-4 gap-2 ${colspan ? `col-span-${colspan}` : 'w-full'}`}>
|
||||||
|
{/* File input (hidden) */}
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type={'file'}
|
||||||
|
multiple={multiple}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Files counter with hover popup */}
|
||||||
|
<div
|
||||||
|
className={'col-span-1 flex items-center justify-center relative'}
|
||||||
|
onMouseEnter={e => {
|
||||||
|
setShowPopup(true)
|
||||||
|
if (!showPopup) {
|
||||||
|
setPopupPos({ x: e.clientX, y: e.clientY })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={e => {
|
||||||
|
// Only close if not moving into popup
|
||||||
|
if (!popupRef.current || !popupRef.current.contains(e.relatedTarget as Node)) {
|
||||||
|
setShowPopup(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={'bg-gray-200 px-4 py-2 rounded w-full text-center select-none block'}
|
||||||
|
style={{ minHeight: '40px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||||
|
>
|
||||||
|
{selectedFiles.length} file{selectedFiles.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
{showPopup && selectedFiles.length > 0 && (
|
||||||
|
<div
|
||||||
|
ref={popupRef}
|
||||||
|
className={'fixed z-50 bg-white border border-gray-300 rounded shadow-lg p-2 text-sm'}
|
||||||
|
tabIndex={0}
|
||||||
|
style={{
|
||||||
|
left: popupPos.x + 2,
|
||||||
|
top: popupPos.y + 2,
|
||||||
|
maxWidth: '400px',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
minWidth: '120px',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
outline: 'none',
|
||||||
|
}}
|
||||||
|
onBlur={e => {
|
||||||
|
// Only close if focus moves outside popup and counter
|
||||||
|
if (!e.relatedTarget || (!popupRef.current?.contains(e.relatedTarget as Node) && !(e.relatedTarget as HTMLElement)?.closest('.col-span-1'))) {
|
||||||
|
setShowPopup(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={e => {
|
||||||
|
// Only close if not moving back to counter
|
||||||
|
const parent = (e.relatedTarget as HTMLElement)?.closest('.col-span-1')
|
||||||
|
if (!parent) setShowPopup(false)
|
||||||
|
}}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Escape') setShowPopup(false)
|
||||||
|
}}
|
||||||
|
onFocus={() => {}}
|
||||||
|
>
|
||||||
|
<ul className={'max-h-40 overflow-auto'} tabIndex={0} style={{outline: 'none'}}>
|
||||||
|
{selectedFiles.map((file, idx) => (
|
||||||
|
<li key={file.name + idx} className={'truncate'} title={file.name}>
|
||||||
|
{file.name}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Clear selection button */}
|
||||||
|
<ButtonComponent
|
||||||
|
label={'Clear selection'}
|
||||||
|
buttonHierarchy={'secondary'}
|
||||||
|
onClick={handleClear}
|
||||||
|
disabled={disabled || selectedFiles.length === 0}
|
||||||
|
colspan={1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Select files button */}
|
||||||
|
<ButtonComponent
|
||||||
|
colspan={2}
|
||||||
|
label={label}
|
||||||
|
buttonHierarchy={'primary'}
|
||||||
|
onClick={handleSelectFiles}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { FileUploadComponent }
|
||||||
67
src/MaksIT.WebUI/src/components/editors/ListBoxComponent.tsx
Normal file
67
src/MaksIT.WebUI/src/components/editors/ListBoxComponent.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
|
interface ListboxComponentProps {
|
||||||
|
label?: string;
|
||||||
|
itemsLabel?: string;
|
||||||
|
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
|
||||||
|
items: string[];
|
||||||
|
onChange: (items: string[]) => void;
|
||||||
|
errorText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ListboxComponent: React.FC<ListboxComponentProps> = (props) => {
|
||||||
|
|
||||||
|
const {
|
||||||
|
label,
|
||||||
|
itemsLabel = 'Items',
|
||||||
|
colspan = 6,
|
||||||
|
items,
|
||||||
|
onChange,
|
||||||
|
errorText
|
||||||
|
} = props
|
||||||
|
|
||||||
|
const [selectedItems, setSelectedItems] = useState<string[]>([])
|
||||||
|
|
||||||
|
const toggleItemSelection = (item: string) => {
|
||||||
|
if (selectedItems.includes(item)) {
|
||||||
|
const updatedSelection = selectedItems.filter(i => i !== item)
|
||||||
|
setSelectedItems(updatedSelection)
|
||||||
|
onChange(updatedSelection)
|
||||||
|
} else {
|
||||||
|
const updatedSelection = [...selectedItems, item]
|
||||||
|
setSelectedItems(updatedSelection)
|
||||||
|
onChange(updatedSelection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`col-span-${colspan}`}>
|
||||||
|
<label className={'block text-gray-700 text-sm font-bold mb-2'}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<div className={'flex flex-col'}>
|
||||||
|
<h3>{itemsLabel}</h3>
|
||||||
|
<ul className={'border p-2 w-40 h-64 overflow-auto'}>
|
||||||
|
{items.map(item => (
|
||||||
|
<li
|
||||||
|
key={item}
|
||||||
|
className={`cursor-pointer hover:bg-gray-200 ${selectedItems.includes(item) ? 'bg-gray-300' : ''}`}
|
||||||
|
onClick={() => toggleItemSelection(item)}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{errorText && (
|
||||||
|
<p className={'text-red-500 text-xs italic mt-2'}>
|
||||||
|
{errorText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ListboxComponent
|
||||||
|
}
|
||||||
@ -0,0 +1,80 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
interface RadioOption {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RadioGroupComponentProps {
|
||||||
|
options: RadioOption[]
|
||||||
|
label?: string
|
||||||
|
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
|
||||||
|
value?: string
|
||||||
|
onChange?: (value: string) => void
|
||||||
|
errorText?: string
|
||||||
|
readOnly?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const RadioGroupComponent: React.FC<RadioGroupComponentProps> = (props) => {
|
||||||
|
const {
|
||||||
|
options,
|
||||||
|
label,
|
||||||
|
colspan = 6,
|
||||||
|
value = '',
|
||||||
|
onChange,
|
||||||
|
errorText,
|
||||||
|
readOnly = false,
|
||||||
|
disabled = false
|
||||||
|
} = props
|
||||||
|
|
||||||
|
const prevValue = useRef<string>(value)
|
||||||
|
const [selectedValue, setSelectedValue] = useState<string>(value)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
prevValue.current = value
|
||||||
|
setSelectedValue(value)
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
const handleOptionChange = (val: string) => {
|
||||||
|
if (readOnly || disabled) return
|
||||||
|
if (prevValue.current === val) return
|
||||||
|
prevValue.current = val
|
||||||
|
setSelectedValue(val)
|
||||||
|
onChange?.(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`mb-4 ${colspan ? `col-span-${colspan}` : 'w-full'}`}>
|
||||||
|
{label && <label className={'block text-gray-700 text-sm font-bold mb-2'}>{label}</label>}
|
||||||
|
<div className={'flex flex-col'}>
|
||||||
|
{options.map(option => {
|
||||||
|
// Use default cursor (arrow) if disabled or readOnly, else pointer
|
||||||
|
const isInactive = disabled || readOnly
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={option.value}
|
||||||
|
className={`flex items-center mb-2 ${disabled ? 'opacity-50' : ''} ${isInactive ? 'cursor-default' : 'cursor-pointer'}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type={'radio'}
|
||||||
|
value={option.value}
|
||||||
|
checked={selectedValue === option.value}
|
||||||
|
onChange={() => handleOptionChange(option.value)}
|
||||||
|
className={'mr-2'}
|
||||||
|
disabled={disabled}
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
{option.label}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{errorText && (
|
||||||
|
<p className={'text-red-500 text-xs italic mt-2'}>{errorText}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RadioGroupComponent }
|
||||||
@ -0,0 +1,116 @@
|
|||||||
|
import { useState, useCallback, ChangeEvent, useEffect, useRef } from 'react'
|
||||||
|
import { SelectBoxComponent } from '.'
|
||||||
|
import { PagedRequest } from '../../models/PagedRequest'
|
||||||
|
import { ApiRoutes, GetApiRoute } from '../../AppMap'
|
||||||
|
import { useAppDispatch } from '../../redux/hooks'
|
||||||
|
import { postData } from '../../axiosConfig'
|
||||||
|
import { disableLoader, enableLoader } from '../../redux/slices/loaderSlice'
|
||||||
|
import { PagedResponse } from '../../models/PagedResponse'
|
||||||
|
import { SearchResponseBase } from '../../models/SearchResponseBase'
|
||||||
|
import { deepEqual } from '../../functions'
|
||||||
|
|
||||||
|
interface RemoteSelectBoxProps<TRequest extends PagedRequest> {
|
||||||
|
apiRoute: ApiRoutes
|
||||||
|
additionalFilters?: TRequest
|
||||||
|
|
||||||
|
label: string
|
||||||
|
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
|
||||||
|
errorText?: string
|
||||||
|
|
||||||
|
// Field used to compare with the value
|
||||||
|
idField?: string
|
||||||
|
// Fields to search against when filtering options
|
||||||
|
filterFields?: string[]
|
||||||
|
|
||||||
|
value?: string | number
|
||||||
|
onChange: (e: ChangeEvent<HTMLInputElement>) => void
|
||||||
|
placeholder?: string
|
||||||
|
readOnly?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const RemoteSelectBoxComponent = <TRequest extends PagedRequest>(props: RemoteSelectBoxProps<TRequest>) => {
|
||||||
|
|
||||||
|
const {
|
||||||
|
apiRoute,
|
||||||
|
additionalFilters,
|
||||||
|
|
||||||
|
label,
|
||||||
|
colspan = 12,
|
||||||
|
errorText,
|
||||||
|
|
||||||
|
idField = 'id',
|
||||||
|
filterFields = ['name'],
|
||||||
|
|
||||||
|
value = '',
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
readOnly = false,
|
||||||
|
} = props
|
||||||
|
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
|
const [options, setOptions] = useState<SearchResponseBase []>([])
|
||||||
|
|
||||||
|
const prevPagedRequest = useRef<TRequest | null>(null)
|
||||||
|
|
||||||
|
const handleFilterChange = useCallback((filters?: string, showLoader: boolean = false) => {
|
||||||
|
const pagedRequest = {
|
||||||
|
pageSize: 10,
|
||||||
|
filters,
|
||||||
|
...additionalFilters
|
||||||
|
} as TRequest
|
||||||
|
|
||||||
|
if (deepEqual(pagedRequest, prevPagedRequest.current))
|
||||||
|
return
|
||||||
|
|
||||||
|
prevPagedRequest.current = pagedRequest
|
||||||
|
|
||||||
|
if (!showLoader)
|
||||||
|
dispatch(disableLoader())
|
||||||
|
|
||||||
|
postData<TRequest, PagedResponse<SearchResponseBase>>(GetApiRoute(apiRoute).route, pagedRequest)
|
||||||
|
.then((response) => {
|
||||||
|
if (!response) return
|
||||||
|
setOptions(response.items)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('RemoteSelectBox fetch error:', error)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
dispatch(enableLoader())
|
||||||
|
})
|
||||||
|
}, [apiRoute, additionalFilters, dispatch])
|
||||||
|
|
||||||
|
// Initialize options on mount
|
||||||
|
useEffect(() => {
|
||||||
|
handleFilterChange(undefined, true)
|
||||||
|
}, [handleFilterChange])
|
||||||
|
|
||||||
|
|
||||||
|
const handleOnChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onChange?.(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SelectBoxComponent
|
||||||
|
colspan={colspan}
|
||||||
|
label={label}
|
||||||
|
placeholder={placeholder}
|
||||||
|
options={options?.map(item => {
|
||||||
|
return {
|
||||||
|
value: item.id,
|
||||||
|
label: item.name
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
idField={idField}
|
||||||
|
filterFields={filterFields}
|
||||||
|
onFilterChange={(text) => handleFilterChange(text, false)}
|
||||||
|
value={value}
|
||||||
|
onChange={handleOnChange}
|
||||||
|
errorText={errorText}
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
RemoteSelectBoxComponent
|
||||||
|
}
|
||||||
147
src/MaksIT.WebUI/src/components/editors/SecretComponent.tsx
Normal file
147
src/MaksIT.WebUI/src/components/editors/SecretComponent.tsx
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
import { Copy, Dices, Eye, EyeOff } from 'lucide-react'
|
||||||
|
import { ChangeEvent, FC, useRef, useState } from 'react'
|
||||||
|
import { TrngResponse } from '../../models/TrngResponse'
|
||||||
|
import { getData } from '../../axiosConfig'
|
||||||
|
import { ApiRoutes, GetApiRoute } from '../../AppMap'
|
||||||
|
|
||||||
|
|
||||||
|
interface PasswordGeneratorProps {
|
||||||
|
label: string
|
||||||
|
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
|
||||||
|
errorText?: string
|
||||||
|
value?: string
|
||||||
|
onChange?: (e: ChangeEvent<HTMLInputElement>) => void
|
||||||
|
placeholder?: string
|
||||||
|
readOnly?: boolean
|
||||||
|
enableCopy?: boolean
|
||||||
|
enableGenerate?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const SecretComponent: FC<PasswordGeneratorProps> = (props) => {
|
||||||
|
|
||||||
|
const {
|
||||||
|
label,
|
||||||
|
colspan = 12,
|
||||||
|
errorText,
|
||||||
|
value = '',
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
readOnly = false,
|
||||||
|
enableCopy = false,
|
||||||
|
enableGenerate = false
|
||||||
|
} = props
|
||||||
|
|
||||||
|
const prevValue = useRef<string>(value)
|
||||||
|
|
||||||
|
// Stato locale per alternare la visibilità della password
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
|
||||||
|
const handleOnChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (prevValue.current === e.target.value)
|
||||||
|
return
|
||||||
|
|
||||||
|
prevValue.current = e.target.value
|
||||||
|
|
||||||
|
onChange?.(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGenerateSecret = () => {
|
||||||
|
getData<TrngResponse>(`${GetApiRoute(ApiRoutes.generateSecret).route}`)
|
||||||
|
.then(response => {
|
||||||
|
if (!response) return
|
||||||
|
|
||||||
|
const fakeEvent = {
|
||||||
|
target: { value: response.secret }
|
||||||
|
} as ChangeEvent<HTMLInputElement>
|
||||||
|
|
||||||
|
handleOnChange(fakeEvent)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
if (!value) return
|
||||||
|
|
||||||
|
await navigator.clipboard.writeText(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Controlla se c'è contenuto per mostrare il pulsante di toggle
|
||||||
|
const hasContent = String(value).length > 0
|
||||||
|
|
||||||
|
const actionButtons = () => {
|
||||||
|
|
||||||
|
const className = 'p-1 text-gray-600 hover:text-gray-800 bg-white'
|
||||||
|
|
||||||
|
return [
|
||||||
|
hasContent && (
|
||||||
|
<button
|
||||||
|
key={'eye'}
|
||||||
|
type={'button'}
|
||||||
|
onClick={() => setShowPassword(prev => !prev)}
|
||||||
|
className={className}
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff /> : <Eye />}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
|
||||||
|
enableGenerate && !readOnly && (
|
||||||
|
<button
|
||||||
|
key={'generate'}
|
||||||
|
type={'button'}
|
||||||
|
onClick={handleGenerateSecret}
|
||||||
|
className={className}
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label={'Generate secret'}
|
||||||
|
>
|
||||||
|
<Dices />
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
enableCopy && hasContent && (
|
||||||
|
<button
|
||||||
|
key={'copy'}
|
||||||
|
type={'button'}
|
||||||
|
onClick={handleCopy}
|
||||||
|
className={className}
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label={'Copy secret'}
|
||||||
|
>
|
||||||
|
<Copy />
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
].filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`mb-4 ${colspan ? `col-span-${colspan}` : 'w-full'}`}>
|
||||||
|
<label className={'block text-gray-700 text-sm font-bold mb-2'}>{label}</label>
|
||||||
|
|
||||||
|
<div className={'relative'}>
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={value}
|
||||||
|
onChange={handleOnChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={`
|
||||||
|
shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline
|
||||||
|
${errorText ? 'border-red-500' : ''}
|
||||||
|
${readOnly ? 'bg-gray-100 text-gray-500' : ''}
|
||||||
|
`}
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Action buttons container */}
|
||||||
|
<div
|
||||||
|
className={'absolute top-0 bottom-0 right-2 flex items-center gap-1 pointer-events-auto'}
|
||||||
|
>
|
||||||
|
{actionButtons()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errorText && <p className={'text-red-500 text-xs italic mt-2'}>{errorText}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { SecretComponent }
|
||||||
220
src/MaksIT.WebUI/src/components/editors/SelectBoxComponent.tsx
Normal file
220
src/MaksIT.WebUI/src/components/editors/SelectBoxComponent.tsx
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
import { debounce } from 'lodash'
|
||||||
|
import { CircleX } from 'lucide-react'
|
||||||
|
import { ChangeEvent, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
export interface SelectBoxComponentOption {
|
||||||
|
value: string | number
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectBoxComponentProps {
|
||||||
|
label: string
|
||||||
|
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
|
||||||
|
errorText?: string
|
||||||
|
options?: SelectBoxComponentOption[]
|
||||||
|
|
||||||
|
// Field used to compare with the value
|
||||||
|
idField?: string
|
||||||
|
// Fields to search against when filtering options
|
||||||
|
filterFields?: string[]
|
||||||
|
// Callback function called with a filter string, debounced
|
||||||
|
onFilterChange?: (filters: string) => void
|
||||||
|
|
||||||
|
value?: string | number
|
||||||
|
onChange?: (e: ChangeEvent<HTMLInputElement>) => void
|
||||||
|
placeholder?: string
|
||||||
|
readOnly?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const SelectBoxComponent: FC<SelectBoxComponentProps> = (props) => {
|
||||||
|
|
||||||
|
const {
|
||||||
|
label,
|
||||||
|
colspan = 12,
|
||||||
|
errorText,
|
||||||
|
options = [],
|
||||||
|
|
||||||
|
idField = 'id',
|
||||||
|
filterFields,
|
||||||
|
onFilterChange,
|
||||||
|
|
||||||
|
value = '',
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
readOnly = false,
|
||||||
|
disabled = false,
|
||||||
|
} = props
|
||||||
|
|
||||||
|
// Local state to control dropdown visibility and current filter text
|
||||||
|
const [showDropdown, setShowDropdown] = useState(false)
|
||||||
|
const [filterValue, setFilterValue] = useState<string>('')
|
||||||
|
|
||||||
|
// Memoized debounced callback for filter changes.
|
||||||
|
const debounceOnFilterChange = useMemo(() => {
|
||||||
|
return onFilterChange ? debounce(onFilterChange, 500) : undefined
|
||||||
|
}, [onFilterChange])
|
||||||
|
|
||||||
|
// Refs to store previous values to detect changes
|
||||||
|
const initRef = useRef(false)
|
||||||
|
const prevValue = useRef(value)
|
||||||
|
const prevFilterValue = useRef(filterValue)
|
||||||
|
|
||||||
|
// Update the selected value and notify parent via onValueChange callback.
|
||||||
|
const handleValueChange = useCallback(
|
||||||
|
(newValue: string | number) => {
|
||||||
|
prevValue.current = newValue
|
||||||
|
// Simulate a ChangeEvent with the new value
|
||||||
|
onChange?.({ target: { value: newValue } } as ChangeEvent<HTMLInputElement>)
|
||||||
|
},
|
||||||
|
[onChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handle input changes for filtering options.
|
||||||
|
const handleFilterChange = useCallback(
|
||||||
|
(e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (disabled) return
|
||||||
|
const newFilter = e.target.value
|
||||||
|
setFilterValue(newFilter)
|
||||||
|
|
||||||
|
// If filter value hasn't changed, exit early.
|
||||||
|
if (prevFilterValue.current === newFilter) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a filter query string based on the filterFields.
|
||||||
|
const query = filterFields
|
||||||
|
?.map((field) => `${field}.Contains("${newFilter}")`)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' || ') ?? ''
|
||||||
|
|
||||||
|
// If debounced filter callback is provided, invoke it.
|
||||||
|
if (debounceOnFilterChange) {
|
||||||
|
debounceOnFilterChange(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the selected value when user types in filter.
|
||||||
|
if (showDropdown) {
|
||||||
|
handleValueChange('')
|
||||||
|
}
|
||||||
|
|
||||||
|
prevFilterValue.current = newFilter
|
||||||
|
},
|
||||||
|
[filterFields, debounceOnFilterChange, showDropdown, handleValueChange, disabled]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Effect to sync external value with filter text and trigger filtering.
|
||||||
|
useEffect(() => {
|
||||||
|
// When value is cleared, also clear the filter.
|
||||||
|
if (value === '') {
|
||||||
|
if (prevValue.current !== value) {
|
||||||
|
// Simulate clearing the filter input.
|
||||||
|
handleFilterChange({ target: { value: '' } } as ChangeEvent<HTMLInputElement>)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the option that matches the current value.
|
||||||
|
const selectedOption = options.find((option) => option.value === value)
|
||||||
|
if (selectedOption) {
|
||||||
|
if (filterValue !== selectedOption.label) {
|
||||||
|
setFilterValue(selectedOption.label) // Only update if the filterValue is different.
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the value does not correspond to an existing option,
|
||||||
|
// trigger filtering using the idField.
|
||||||
|
if (debounceOnFilterChange && !initRef.current) {
|
||||||
|
debounceOnFilterChange(`${idField} == "${value}"`)
|
||||||
|
initRef.current = true
|
||||||
|
}
|
||||||
|
}, [value, filterValue, options, idField, debounceOnFilterChange, handleFilterChange])
|
||||||
|
|
||||||
|
// Handle click on an option from the dropdown.
|
||||||
|
const handleOptionClick = (optionValue: string | number) => {
|
||||||
|
if (disabled) return
|
||||||
|
// Update the selected value.
|
||||||
|
handleValueChange(optionValue)
|
||||||
|
// Update the input to display the selected option's label.
|
||||||
|
const selectedOption = options.find((option) => option.value === optionValue)
|
||||||
|
setFilterValue(selectedOption?.label ?? '')
|
||||||
|
// Close the dropdown.
|
||||||
|
setShowDropdown(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionButtons = () => {
|
||||||
|
const className = 'p-1 text-gray-600 hover:text-gray-800 bg-white'
|
||||||
|
if (disabled) return null
|
||||||
|
return [
|
||||||
|
!!filterValue && !readOnly && (
|
||||||
|
<button
|
||||||
|
key={'clear'}
|
||||||
|
type={'button'}
|
||||||
|
onClick={() => {
|
||||||
|
setFilterValue('')
|
||||||
|
handleValueChange('')
|
||||||
|
if (debounceOnFilterChange) debounceOnFilterChange('')
|
||||||
|
}}
|
||||||
|
className={className}
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label={'Clear'}
|
||||||
|
>
|
||||||
|
<CircleX />
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
].filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative mb-4 ${colspan ? `col-span-${colspan}` : 'w-full'}`}>
|
||||||
|
{/* Label for the select input */}
|
||||||
|
<label className={'block text-gray-700 text-sm font-bold mb-2'}>{label}</label>
|
||||||
|
<div className={'relative'}>
|
||||||
|
<input
|
||||||
|
type={'text'}
|
||||||
|
value={filterValue}
|
||||||
|
onChange={handleFilterChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={`shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline
|
||||||
|
${errorText ? 'border-red-500' : ''}
|
||||||
|
${disabled ? 'bg-gray-100 text-gray-500 cursor-default' : 'bg-white'}
|
||||||
|
${readOnly && !disabled ? 'text-gray-500 cursor-default' : ''}`}
|
||||||
|
disabled={readOnly || disabled}
|
||||||
|
// Open dropdown when input is focused.
|
||||||
|
onFocus={() => { if (!disabled) setShowDropdown(true) }}
|
||||||
|
// Delay closing dropdown to allow click events on options.
|
||||||
|
onBlur={() => setTimeout(() => setShowDropdown(false), 200)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div
|
||||||
|
className={'absolute top-0 bottom-0 right-2 flex items-center gap-1 pointer-events-auto'}
|
||||||
|
>
|
||||||
|
{actionButtons()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showDropdown && !disabled && (
|
||||||
|
<div className={'absolute left-0 right-0 bg-white border border-gray-300 rounded mt-1 w-full shadow-lg z-10'}>
|
||||||
|
{options.length > 0 ? (
|
||||||
|
options.map((option) => (
|
||||||
|
<div
|
||||||
|
key={option.value}
|
||||||
|
className={'px-4 py-2 cursor-pointer hover:bg-gray-200'}
|
||||||
|
onMouseDown={() => handleOptionClick(option.value)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className={'px-4 py-2 text-gray-500'}>No options found</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{errorText && <p className={'text-red-500 text-xs italic mt-2'}>{errorText}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { SelectBoxComponent }
|
||||||
120
src/MaksIT.WebUI/src/components/editors/TextBoxComponent.tsx
Normal file
120
src/MaksIT.WebUI/src/components/editors/TextBoxComponent.tsx
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import { Eye, EyeOff } from 'lucide-react'
|
||||||
|
import { ChangeEvent, FC, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
interface TextBoxComponentProps {
|
||||||
|
label: string
|
||||||
|
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
|
||||||
|
errorText?: string
|
||||||
|
value?: string | number
|
||||||
|
onChange?: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void
|
||||||
|
type?: 'text' | 'password' | 'textarea' | 'number' | 'email' | 'time'
|
||||||
|
placeholder?: string
|
||||||
|
readOnly?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const TextBoxComponent: FC<TextBoxComponentProps> = (props) => {
|
||||||
|
|
||||||
|
const {
|
||||||
|
label,
|
||||||
|
colspan = 12,
|
||||||
|
errorText,
|
||||||
|
value = '',
|
||||||
|
onChange,
|
||||||
|
type = 'text',
|
||||||
|
placeholder,
|
||||||
|
readOnly = false,
|
||||||
|
disabled = false,
|
||||||
|
} = props
|
||||||
|
|
||||||
|
const prevValue = useRef<string | number>(value)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
prevValue.current = value
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
// Stato locale per gestire la visibilità della password
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
|
||||||
|
const handleOnChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
if (prevValue.current === e.target.value)
|
||||||
|
return
|
||||||
|
|
||||||
|
prevValue.current = e.target.value
|
||||||
|
|
||||||
|
onChange?.(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se il type è "textarea", comportamento invariato
|
||||||
|
if (type === 'textarea') {
|
||||||
|
return (
|
||||||
|
<div className={`mb-4 ${colspan ? `col-span-${colspan}` : 'w-full'}`}>
|
||||||
|
<label className={'block text-gray-700 text-sm font-bold mb-2'}>{label}</label>
|
||||||
|
<textarea
|
||||||
|
value={value}
|
||||||
|
onChange={handleOnChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={`shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline ${
|
||||||
|
errorText ? 'border-red-500' : ''
|
||||||
|
} ${disabled ? 'bg-gray-100 text-gray-500 cursor-default' : 'bg-white'}${readOnly && !disabled ? ' text-gray-500 cursor-default' : ''}`}
|
||||||
|
readOnly={readOnly}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
{errorText && <p className={'text-red-500 text-xs italic mt-2'}>{errorText}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifica se il valore non è vuoto (per tipo "password" useremo questa condizione)
|
||||||
|
const hasContent = String(value).length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`mb-4 ${colspan ? `col-span-${colspan}` : 'w-full'}`}>
|
||||||
|
<label className={'block text-gray-700 text-sm font-bold mb-2'}>{label}</label>
|
||||||
|
|
||||||
|
{type === 'password' ? (
|
||||||
|
// Wrapper che contiene input e bottone show/hide, ma bottone solo se c'è contenuto
|
||||||
|
<div className={'relative'}>
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={value}
|
||||||
|
onChange={handleOnChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={`shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline ${
|
||||||
|
errorText ? 'border-red-500' : ''
|
||||||
|
} ${disabled ? 'bg-gray-100 text-gray-500 cursor-default' : 'bg-white'}${readOnly && !disabled ? ' text-gray-500 cursor-default' : ''}`}
|
||||||
|
readOnly={readOnly}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
{hasContent && (
|
||||||
|
<button
|
||||||
|
type={'button'}
|
||||||
|
onClick={() => setShowPassword(prev => !prev)}
|
||||||
|
className={'absolute inset-y-0 right-0 pr-3 flex items-center text-gray-600'}
|
||||||
|
tabIndex={-1} // Non interferisce con l'ordine di tabulazione
|
||||||
|
>
|
||||||
|
{showPassword ? <Eye /> : <EyeOff />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Input normale per tutti gli altri tipi (text, number, email, time)
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
value={value}
|
||||||
|
onChange={handleOnChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={`shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline ${
|
||||||
|
errorText ? 'border-red-500' : ''
|
||||||
|
} ${disabled ? 'bg-gray-100 text-gray-500 cursor-default' : 'bg-white'}${readOnly && !disabled ? ' text-gray-500 cursor-default' : ''}`}
|
||||||
|
readOnly={readOnly}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{errorText && <p className={'text-red-500 text-xs italic mt-2'}>{errorText}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { TextBoxComponent }
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
import React, { useState, ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface TreeNode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
content?: ReactNode | ((ids: string[]) => ReactNode); // Custom content at each node
|
||||||
|
children?: TreeNode[]; // Nested nodes for infinite depth
|
||||||
|
defaultCollapsed?: boolean; // Default collapse/expand state
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TreeViewProps {
|
||||||
|
data: TreeNode[];
|
||||||
|
label?: string;
|
||||||
|
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TreeViewComponent: React.FC<TreeViewProps> = (props) => {
|
||||||
|
|
||||||
|
const { data, label, colspan = 6 } = props
|
||||||
|
|
||||||
|
const [collapsedItems, setCollapsedItems] = useState<Record<string, boolean>>(
|
||||||
|
() =>
|
||||||
|
data.reduce((acc, node) => {
|
||||||
|
const initCollapse = (node: TreeNode, collapsed: Record<string, boolean>): Record<string, boolean> => {
|
||||||
|
collapsed[node.id] = node.defaultCollapsed ?? true
|
||||||
|
if (node.children) {
|
||||||
|
node.children.forEach((child) => initCollapse(child, collapsed))
|
||||||
|
}
|
||||||
|
return collapsed
|
||||||
|
}
|
||||||
|
return initCollapse(node, acc)
|
||||||
|
}, {})
|
||||||
|
)
|
||||||
|
|
||||||
|
const toggleCollapse = (id: string) => {
|
||||||
|
setCollapsedItems((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[id]: !prev[id],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderTree = (nodes: TreeNode[], parentIds: string[] = []) => {
|
||||||
|
return nodes.map((node) => {
|
||||||
|
const nodeIds = [...parentIds, node.id]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={node.id} className={'mb-2 ml-4'}>
|
||||||
|
{/* Node Header */}
|
||||||
|
<div className={'flex items-center justify-between'}>
|
||||||
|
<div className={'flex items-center'}>
|
||||||
|
<span className={'font-bold'}>{node.name}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleCollapse(node.id)}
|
||||||
|
className={'text-sm text-blue-500 focus:outline-none'}
|
||||||
|
>
|
||||||
|
{collapsedItems[node.id] ? 'Expand' : 'Collapse'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Node Content */}
|
||||||
|
{!collapsedItems[node.id] && (
|
||||||
|
<>
|
||||||
|
{node.content && (
|
||||||
|
<div className={'ml-4 mt-2'}>
|
||||||
|
{typeof node.content === 'function' ? node.content(nodeIds) : node.content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{node.children && <div className={'ml-4'}>{renderTree(node.children, nodeIds)}</div>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`col-span-${colspan}`}>
|
||||||
|
{label && <label className={'block text-gray-700 text-sm font-bold mb-2'}>{label}</label>}
|
||||||
|
<div className={'border p-4 rounded-md'}>{renderTree(data)}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { TreeViewComponent }
|
||||||
28
src/MaksIT.WebUI/src/components/editors/index.ts
Normal file
28
src/MaksIT.WebUI/src/components/editors/index.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { ButtonComponent } from './ButtonComponent'
|
||||||
|
import { CheckBoxComponent } from './CheckBoxComponent'
|
||||||
|
import { TextBoxComponent } from './TextBoxComponent'
|
||||||
|
import { DateTimePickerComponent } from './DateTimePickerComponent'
|
||||||
|
import { DualListboxComponent } from './DualListboxComponent'
|
||||||
|
import { TreeViewComponent } from './TreeViewComponent'
|
||||||
|
import { ListboxComponent } from './ListBoxComponent'
|
||||||
|
import { SecretComponent } from './SecretComponent'
|
||||||
|
import { SelectBoxComponent } from './SelectBoxComponent'
|
||||||
|
import { RemoteSelectBoxComponent } from './RemoteSelectBoxComponent'
|
||||||
|
import { RadioGroupComponent } from './RadioGroupComponent'
|
||||||
|
import { FileUploadComponent } from './FileUploadComponent'
|
||||||
|
|
||||||
|
|
||||||
|
export {
|
||||||
|
ButtonComponent,
|
||||||
|
CheckBoxComponent,
|
||||||
|
DateTimePickerComponent,
|
||||||
|
TextBoxComponent,
|
||||||
|
DualListboxComponent,
|
||||||
|
TreeViewComponent,
|
||||||
|
ListboxComponent,
|
||||||
|
RemoteSelectBoxComponent,
|
||||||
|
SecretComponent,
|
||||||
|
SelectBoxComponent,
|
||||||
|
RadioGroupComponent,
|
||||||
|
FileUploadComponent,
|
||||||
|
}
|
||||||
41
src/MaksIT.WebUI/src/entities/CacheAccount.ts
Normal file
41
src/MaksIT.WebUI/src/entities/CacheAccount.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
|
||||||
|
import { PatchAccountRequest } from '../models/letsEncryptServer/account/requests/PatchAccountRequest'
|
||||||
|
import { PatchOperation } from '../models/PatchOperation'
|
||||||
|
import { CacheAccountHostname } from './CacheAccountHostname'
|
||||||
|
|
||||||
|
export interface CacheAccount {
|
||||||
|
accountId: string
|
||||||
|
isDisabled: boolean
|
||||||
|
description: string
|
||||||
|
contacts: string[]
|
||||||
|
challengeType?: string
|
||||||
|
hostnames?: CacheAccountHostname[]
|
||||||
|
isEditMode: boolean
|
||||||
|
isStaging: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const toPatchAccountRequest = (account: CacheAccount): PatchAccountRequest => {
|
||||||
|
return {
|
||||||
|
description: { op: PatchOperation.None, value: account.description },
|
||||||
|
isDisabled: { op: PatchOperation.None, value: account.isDisabled },
|
||||||
|
contacts: account.contacts.map((contact, index) => ({
|
||||||
|
index: index,
|
||||||
|
op: PatchOperation.None,
|
||||||
|
value: contact
|
||||||
|
})),
|
||||||
|
hostnames: account.hostnames?.map((hostname, index) => ({
|
||||||
|
hostname: {
|
||||||
|
index: index,
|
||||||
|
op: PatchOperation.None,
|
||||||
|
value: hostname.hostname
|
||||||
|
},
|
||||||
|
isDisabled: {
|
||||||
|
index: index,
|
||||||
|
op: PatchOperation.None,
|
||||||
|
value: hostname.isDisabled
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { toPatchAccountRequest }
|
||||||
6
src/MaksIT.WebUI/src/entities/CacheAccountHostname.ts
Normal file
6
src/MaksIT.WebUI/src/entities/CacheAccountHostname.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export interface CacheAccountHostname {
|
||||||
|
hostname: string
|
||||||
|
expires: Date
|
||||||
|
isUpcomingExpire: boolean
|
||||||
|
isDisabled: boolean
|
||||||
|
}
|
||||||
4
src/MaksIT.WebUI/src/entities/ChallengeType.ts
Normal file
4
src/MaksIT.WebUI/src/entities/ChallengeType.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export enum ChallengeType {
|
||||||
|
http01 = 'http-01',
|
||||||
|
dns01 = 'dns-01'
|
||||||
|
}
|
||||||
154
src/MaksIT.WebUI/src/forms/Home.tsx
Normal file
154
src/MaksIT.WebUI/src/forms/Home.tsx
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
import { FC, useEffect, useState } from 'react'
|
||||||
|
import { FormContainer, FormContent, FormFooter, FormHeader } from '../components/FormLayout'
|
||||||
|
import { ButtonComponent, CheckBoxComponent, RadioGroupComponent, SelectBoxComponent } from '../components/editors'
|
||||||
|
import { CacheAccount } from '../entities/CacheAccount'
|
||||||
|
import { GetAccountResponse } from '../models/letsEncryptServer/account/responses/GetAccountResponse'
|
||||||
|
import { deleteData, getData } from '../axiosConfig'
|
||||||
|
import { ApiRoutes, GetApiRoute } from '../AppMap'
|
||||||
|
import { enumToArr, formatISODateString } from '../functions'
|
||||||
|
import { ChallengeType } from '../entities/ChallengeType'
|
||||||
|
import { Radio } from 'lucide-react'
|
||||||
|
|
||||||
|
|
||||||
|
const Home: FC = () => {
|
||||||
|
const [rawd, setRawd] = useState<GetAccountResponse[]>([])
|
||||||
|
const [editingAccount, setEditingAccount] = useState<GetAccountResponse | null>(
|
||||||
|
null
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log(GetApiRoute(ApiRoutes.ACCOUNTS).route)
|
||||||
|
|
||||||
|
getData<GetAccountResponse []>(GetApiRoute(ApiRoutes.ACCOUNTS).route).then((response) => {
|
||||||
|
if (!response) return
|
||||||
|
|
||||||
|
setRawd(response)
|
||||||
|
})
|
||||||
|
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleAccountUpdate = (updatedAccount: CacheAccount) => {
|
||||||
|
// setAccounts(
|
||||||
|
// accounts.map((account) =>
|
||||||
|
// account.accountId === updatedAccount.accountId
|
||||||
|
// ? updatedAccount
|
||||||
|
// : account
|
||||||
|
// )
|
||||||
|
// )
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteAccount = (accountId: string) => {
|
||||||
|
deleteData<void>(
|
||||||
|
GetApiRoute(ApiRoutes.ACCOUNT_DELETE)
|
||||||
|
.route.replace('{accountId}', accountId)
|
||||||
|
).then((result) => {
|
||||||
|
if (!result) return
|
||||||
|
|
||||||
|
setRawd(rawd.filter((account) => account.accountId !== accountId))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return <FormContainer>
|
||||||
|
<FormHeader>Home</FormHeader>
|
||||||
|
<FormContent>
|
||||||
|
<div className={'grid grid-cols-12 gap-4 w-full'}>
|
||||||
|
{rawd.length === 0 ?
|
||||||
|
<div className={'text-center text-gray-600 col-span-12'}>
|
||||||
|
No accounts registered.
|
||||||
|
</div> :
|
||||||
|
rawd.map((acc) => (
|
||||||
|
<div key={acc.accountId} className={'bg-white shadow-lg rounded-lg p-6 mb-6 col-span-12'}>
|
||||||
|
<div className={'grid grid-cols-12 gap-4 w-full'}>
|
||||||
|
<h2 className={'col-span-8'}>
|
||||||
|
Account: {acc.accountId}
|
||||||
|
</h2>
|
||||||
|
<ButtonComponent
|
||||||
|
colspan={2}
|
||||||
|
onClick={() => deleteAccount(acc.accountId)}
|
||||||
|
label={'Delete'}
|
||||||
|
buttonHierarchy={'error'}
|
||||||
|
/>
|
||||||
|
<ButtonComponent
|
||||||
|
colspan={2}
|
||||||
|
onClick={() => setEditingAccount(acc)}
|
||||||
|
label={'Edit'}
|
||||||
|
/>
|
||||||
|
<h3 className={'col-span-12'}>
|
||||||
|
Description: {acc.description}
|
||||||
|
</h3>
|
||||||
|
<CheckBoxComponent
|
||||||
|
colspan={12}
|
||||||
|
value={acc.isDisabled}
|
||||||
|
label={'Disabled'}
|
||||||
|
disabled={true}
|
||||||
|
/>
|
||||||
|
<h3 className={'col-span-12'}>Contacts:</h3>
|
||||||
|
<ul className={'col-span-12'}>
|
||||||
|
{acc.contacts.map((contact) => (
|
||||||
|
<li key={contact} className={'pb-2'}>
|
||||||
|
{contact}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<RadioGroupComponent
|
||||||
|
colspan={12}
|
||||||
|
label={'LetsEncrypt Environment'}
|
||||||
|
options={[
|
||||||
|
{ value: 'staging', label: 'Staging' },
|
||||||
|
{ value: 'production', label: 'Production' }
|
||||||
|
]}
|
||||||
|
|
||||||
|
value={acc.challengeType ? 'staging' : 'production'}
|
||||||
|
disabled={true}
|
||||||
|
/>
|
||||||
|
<h3 className={'col-span-12'}>Hostnames:</h3>
|
||||||
|
<ul className={'col-span-12'}>
|
||||||
|
{acc.hostnames?.map((hostname) => (
|
||||||
|
<li key={hostname.hostname} className={'grid grid-cols-12 gap-4 w-full pb-2'}>
|
||||||
|
<span className={'col-span-3'}>{hostname.hostname}</span>
|
||||||
|
<span className={'col-span-3'}>{formatISODateString(hostname.expires)}</span>
|
||||||
|
<span className={'col-span-3'}>
|
||||||
|
<span className={`${hostname.isUpcomingExpire
|
||||||
|
? 'bg-yellow-200 text-yellow-800'
|
||||||
|
: 'bg-green-200 text-green-800'}`}>{hostname.isUpcomingExpire
|
||||||
|
? 'Upcoming'
|
||||||
|
: 'Not Upcoming'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<CheckBoxComponent
|
||||||
|
colspan={3}
|
||||||
|
value={hostname.isDisabled}
|
||||||
|
label={'Disabled'}
|
||||||
|
disabled={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<SelectBoxComponent
|
||||||
|
label={'Environment'}
|
||||||
|
options={[
|
||||||
|
{ value: 'production', label: 'Production' },
|
||||||
|
{ value: 'staging', label: 'Staging' }
|
||||||
|
]}
|
||||||
|
value={acc.isStaging ? 'staging' : 'production'}
|
||||||
|
disabled={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</FormContent>
|
||||||
|
<FormFooter />
|
||||||
|
</FormContainer>
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Home }
|
||||||
227
src/MaksIT.WebUI/src/forms/Register.tsx
Normal file
227
src/MaksIT.WebUI/src/forms/Register.tsx
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
import { FC } from 'react'
|
||||||
|
import { FormContainer, FormContent, FormFooter, FormHeader } from '../components/FormLayout'
|
||||||
|
import { postData } from '../axiosConfig'
|
||||||
|
import { GetAccountResponse } from '../models/letsEncryptServer/account/responses/GetAccountResponse'
|
||||||
|
import { ApiRoutes, GetApiRoute } from '../AppMap'
|
||||||
|
import { ButtonComponent, RadioGroupComponent, SelectBoxComponent, TextBoxComponent } from '../components/editors'
|
||||||
|
import { ChallengeType } from '../entities/ChallengeType'
|
||||||
|
import z, { array, boolean, object, Schema, string } from 'zod'
|
||||||
|
import { useFormState } from '../hooks/useFormState'
|
||||||
|
import { enumToArr } from '../functions'
|
||||||
|
import { PostAccountRequest, PostAccountRequestSchema } from '../models/letsEncryptServer/account/requests/PostAccountRequest'
|
||||||
|
import { addToast } from '../components/Toast/addToast'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
|
||||||
|
interface RegisterFormProps {
|
||||||
|
description: string
|
||||||
|
|
||||||
|
contact: string
|
||||||
|
contacts: string[]
|
||||||
|
|
||||||
|
hostname: string
|
||||||
|
hostnames: string[]
|
||||||
|
|
||||||
|
challengeType: ChallengeType
|
||||||
|
isStaging: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const RegisterFormProto = (): RegisterFormProps => ({
|
||||||
|
description: '',
|
||||||
|
|
||||||
|
contact: '',
|
||||||
|
contacts: [],
|
||||||
|
|
||||||
|
hostname: '',
|
||||||
|
hostnames: [],
|
||||||
|
|
||||||
|
challengeType: ChallengeType.http01,
|
||||||
|
isStaging: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const RegisterFormSchema: Schema<RegisterFormProps> = object({
|
||||||
|
description: string(),
|
||||||
|
|
||||||
|
contact: string(),
|
||||||
|
contacts: array(string()),
|
||||||
|
|
||||||
|
hostname: string(),
|
||||||
|
hostnames: array(string()),
|
||||||
|
|
||||||
|
challengeType: z.enum(ChallengeType),
|
||||||
|
isStaging: boolean()
|
||||||
|
})
|
||||||
|
|
||||||
|
interface RegisterProps {
|
||||||
|
}
|
||||||
|
|
||||||
|
const Register: FC<RegisterProps> = () => {
|
||||||
|
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const {
|
||||||
|
formState,
|
||||||
|
errors,
|
||||||
|
formIsValid,
|
||||||
|
handleInputChange
|
||||||
|
} = useFormState<RegisterFormProps>({
|
||||||
|
initialState: RegisterFormProto(),
|
||||||
|
validationSchema: RegisterFormSchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!formIsValid) return
|
||||||
|
|
||||||
|
const requestData: PostAccountRequest = {
|
||||||
|
description: formState.description,
|
||||||
|
contacts: formState.contacts,
|
||||||
|
hostnames: formState.hostnames,
|
||||||
|
challengeType: formState.challengeType,
|
||||||
|
isStaging: formState.isStaging,
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = PostAccountRequestSchema.safeParse(requestData)
|
||||||
|
|
||||||
|
if (!request.success) {
|
||||||
|
request.error.issues.forEach(error => {
|
||||||
|
addToast(error.message, 'error')
|
||||||
|
})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
postData<PostAccountRequest, GetAccountResponse>(GetApiRoute(ApiRoutes.ACCOUNT_POST).route, request.data)
|
||||||
|
.then(response => {
|
||||||
|
if (!response) return
|
||||||
|
|
||||||
|
navigate('/')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return <FormContainer>
|
||||||
|
<FormHeader>Register</FormHeader>
|
||||||
|
<FormContent>
|
||||||
|
<div className={'grid grid-cols-12 gap-4 w-full'}>
|
||||||
|
<TextBoxComponent
|
||||||
|
colspan={12}
|
||||||
|
label={'Account Description'}
|
||||||
|
value={formState.description}
|
||||||
|
onChange={(e) => handleInputChange('description', e.target.value)}
|
||||||
|
placeholder={'Account Description'}
|
||||||
|
errorText={errors.description}
|
||||||
|
/>
|
||||||
|
<h3 className={'col-span-12'}>Contacts:</h3>
|
||||||
|
<ul className={'col-span-12'}>
|
||||||
|
{formState.contacts.map((contact) => (
|
||||||
|
<li key={contact} className={'grid grid-cols-12 gap-4 w-full pb-2'}>
|
||||||
|
<span className={'col-span-10'}>{contact}</span>
|
||||||
|
<ButtonComponent
|
||||||
|
colspan={2}
|
||||||
|
label={'TRASH'}
|
||||||
|
onClick={() => {
|
||||||
|
const updatedContacts = formState.contacts.filter(c => c !== contact)
|
||||||
|
handleInputChange('contacts', updatedContacts)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<TextBoxComponent
|
||||||
|
colspan={10}
|
||||||
|
label={'New Contact'}
|
||||||
|
value={formState.contact}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (formState.contacts.includes(e.target.value))
|
||||||
|
return
|
||||||
|
|
||||||
|
handleInputChange('contact', e.target.value)
|
||||||
|
}}
|
||||||
|
placeholder={'Add contact'}
|
||||||
|
type={'text'}
|
||||||
|
errorText={errors.contact}
|
||||||
|
/>
|
||||||
|
<ButtonComponent
|
||||||
|
colspan={2}
|
||||||
|
label={'PLUS'}
|
||||||
|
onClick={() => {
|
||||||
|
handleInputChange('contacts', [...formState.contacts, formState.contact])
|
||||||
|
handleInputChange('contact', '')
|
||||||
|
}}
|
||||||
|
disabled={formState.contact.trim() === ''}
|
||||||
|
/>
|
||||||
|
<div className={'col-span-12'}>
|
||||||
|
<SelectBoxComponent
|
||||||
|
label={'Challenge Type'}
|
||||||
|
options={enumToArr(ChallengeType).map(ct => ({ value: ct.value, label: ct.displayValue }))}
|
||||||
|
value={formState.challengeType}
|
||||||
|
placeholder={'Select Challenge Type'}
|
||||||
|
onChange={(e) => handleInputChange('challengeType', e.target.value)}
|
||||||
|
errorText={errors.challengeType}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h3 className={'col-span-12'}>Hostnames:</h3>
|
||||||
|
<ul className={'col-span-12'}>
|
||||||
|
{formState.hostnames.map((hostname) => (
|
||||||
|
<li key={hostname} className={'grid grid-cols-12 gap-4 w-full'}>
|
||||||
|
<span className={'col-span-10'}>{hostname}</span>
|
||||||
|
<ButtonComponent
|
||||||
|
colspan={2}
|
||||||
|
label={'TRASH'}
|
||||||
|
onClick={() => {
|
||||||
|
const updatedHostnames = formState.hostnames.filter(h => h !== hostname)
|
||||||
|
handleInputChange('hostnames', updatedHostnames)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<TextBoxComponent
|
||||||
|
colspan={10}
|
||||||
|
label={'New Hostname'}
|
||||||
|
value={formState.hostname}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (formState.hostnames.includes(e.target.value))
|
||||||
|
return
|
||||||
|
|
||||||
|
handleInputChange('hostname', e.target.value)
|
||||||
|
}}
|
||||||
|
placeholder={'Add hostname'}
|
||||||
|
type={'text'}
|
||||||
|
errorText={errors.hostname}
|
||||||
|
/>
|
||||||
|
<ButtonComponent
|
||||||
|
colspan={2}
|
||||||
|
label={'PLUS'}
|
||||||
|
onClick={() => {
|
||||||
|
handleInputChange('hostnames', [...formState.hostnames, formState.hostname])
|
||||||
|
handleInputChange('hostname', '')
|
||||||
|
}}
|
||||||
|
disabled={formState.hostname.trim() === ''}
|
||||||
|
/>
|
||||||
|
<RadioGroupComponent
|
||||||
|
colspan={12}
|
||||||
|
label={'LetsEncrypt Environment'}
|
||||||
|
options={[
|
||||||
|
{ value: 'staging', label: 'Staging' },
|
||||||
|
{ value: 'production', label: 'Production' }
|
||||||
|
]}
|
||||||
|
|
||||||
|
value={formState.isStaging ? 'staging' : 'production'}
|
||||||
|
onChange={(value) => {
|
||||||
|
handleInputChange('isStaging', value === 'staging')
|
||||||
|
}}
|
||||||
|
errorText={errors.isStaging}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormContent>
|
||||||
|
<FormFooter rightChildren={
|
||||||
|
<ButtonComponent
|
||||||
|
label={'Create Account'}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
/>
|
||||||
|
} />
|
||||||
|
</FormContainer>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export { Register }
|
||||||
42
src/MaksIT.WebUI/src/forms/Utilities.tsx
Normal file
42
src/MaksIT.WebUI/src/forms/Utilities.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { FC, useState } from 'react'
|
||||||
|
import { FormContainer, FormContent, FormFooter, FormHeader } from '../components/FormLayout'
|
||||||
|
import { ButtonComponent, FileUploadComponent } from '../components/editors'
|
||||||
|
|
||||||
|
const Utilities: FC = () => {
|
||||||
|
|
||||||
|
const [files, setFiles] = useState<File[]>([])
|
||||||
|
|
||||||
|
return <FormContainer>
|
||||||
|
<FormHeader>Utilities</FormHeader>
|
||||||
|
<FormContent>
|
||||||
|
<div className={'grid grid-cols-12 gap-4 w-full'}>
|
||||||
|
<ButtonComponent
|
||||||
|
colspan={3}
|
||||||
|
label={'Test agent'}
|
||||||
|
buttonHierarchy={'primary'}
|
||||||
|
onClick={() => {}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ButtonComponent
|
||||||
|
colspan={3}
|
||||||
|
label={'Download cache files'}
|
||||||
|
buttonHierarchy={'secondary'}
|
||||||
|
onClick={() => {}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FileUploadComponent
|
||||||
|
colspan={6}
|
||||||
|
label={'Upload cache files'}
|
||||||
|
multiple={true}
|
||||||
|
onChange={setFiles}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span className={'col-span-12'}></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</FormContent>
|
||||||
|
<FormFooter />
|
||||||
|
</FormContainer>
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Utilities }
|
||||||
9
src/MaksIT.WebUI/src/functions/acl/index.ts
Normal file
9
src/MaksIT.WebUI/src/functions/acl/index.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import {
|
||||||
|
parseAclEntry,
|
||||||
|
parseAclEntries
|
||||||
|
} from './parseAclEntry'
|
||||||
|
|
||||||
|
export {
|
||||||
|
parseAclEntry,
|
||||||
|
parseAclEntries,
|
||||||
|
}
|
||||||
49
src/MaksIT.WebUI/src/functions/acl/parseAclEntry.ts
Normal file
49
src/MaksIT.WebUI/src/functions/acl/parseAclEntry.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { ScopeEntityType } from '../../models/identity/ScopeEntityType'
|
||||||
|
import { ScopePermission } from '../../models/identity/ScopePermissions'
|
||||||
|
|
||||||
|
export interface AclEntry {
|
||||||
|
entityType: ScopeEntityType,
|
||||||
|
entityId: string,
|
||||||
|
scope: ScopePermission
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseAclEntry = (aclEntry: string): AclEntry | null => {
|
||||||
|
if (typeof aclEntry !== 'string')
|
||||||
|
return null
|
||||||
|
|
||||||
|
const parts = aclEntry.split(':')
|
||||||
|
if (parts.length !== 3)
|
||||||
|
|
||||||
|
return null
|
||||||
|
|
||||||
|
const entityTypeMap: Record<string, ScopeEntityType> = {
|
||||||
|
O: ScopeEntityType.Organization,
|
||||||
|
A: ScopeEntityType.Application,
|
||||||
|
S: ScopeEntityType.Secret,
|
||||||
|
I: ScopeEntityType.Identity,
|
||||||
|
K: ScopeEntityType.ApiKey
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityType = entityTypeMap[parts[0]] ?? 'Unknown'
|
||||||
|
const entityId = parts[1]
|
||||||
|
const scopePermission = parseInt(parts[2], 16) as ScopePermission
|
||||||
|
|
||||||
|
|
||||||
|
const aclEntryResult: AclEntry = {
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
scope: scopePermission
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Parsed ACL Entry:', aclEntryResult)
|
||||||
|
|
||||||
|
return aclEntryResult
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseAclEntries = (aclEntries: string[]): AclEntry [] => {
|
||||||
|
return aclEntries
|
||||||
|
.map(parseAclEntry)
|
||||||
|
.filter((entry): entry is AclEntry => entry !== null)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { parseAclEntry, parseAclEntries }
|
||||||
19
src/MaksIT.WebUI/src/functions/date/formatISODateString.ts
Normal file
19
src/MaksIT.WebUI/src/functions/date/formatISODateString.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { parseISO, isValid, format } from 'date-fns'
|
||||||
|
|
||||||
|
const DISPLAY_FORMAT = 'yyyy-MM-dd HH:mm'
|
||||||
|
|
||||||
|
const formatISODateString = (isoString: string): string => {
|
||||||
|
if (!isoString)
|
||||||
|
return ''
|
||||||
|
|
||||||
|
const parsed = parseISO(isoString)
|
||||||
|
|
||||||
|
if (!isValid(parsed))
|
||||||
|
return 'ISO Date String is invalid'
|
||||||
|
|
||||||
|
return format(parsed, DISPLAY_FORMAT)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
formatISODateString
|
||||||
|
}
|
||||||
7
src/MaksIT.WebUI/src/functions/date/index.ts
Normal file
7
src/MaksIT.WebUI/src/functions/date/index.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { isValidISODateString } from './isValidDateString'
|
||||||
|
import { formatISODateString } from './formatISODateString'
|
||||||
|
|
||||||
|
export {
|
||||||
|
isValidISODateString,
|
||||||
|
formatISODateString
|
||||||
|
}
|
||||||
15
src/MaksIT.WebUI/src/functions/date/isValidDateString.ts
Normal file
15
src/MaksIT.WebUI/src/functions/date/isValidDateString.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { parseISO, isValid } from 'date-fns'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const isValidISODateString = (dateString: string): boolean => {
|
||||||
|
if (!dateString) return false
|
||||||
|
const parsed = parseISO(dateString)
|
||||||
|
return isValid(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export {
|
||||||
|
isValidISODateString
|
||||||
|
}
|
||||||
35
src/MaksIT.WebUI/src/functions/deep/deepCopy.ts
Normal file
35
src/MaksIT.WebUI/src/functions/deep/deepCopy.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
const deepCopy = <T>(obj: T, seen = new WeakMap<object, unknown>()): T =>{
|
||||||
|
if (
|
||||||
|
obj === null ||
|
||||||
|
typeof obj !== 'object' ||
|
||||||
|
obj instanceof Date ||
|
||||||
|
obj instanceof RegExp ||
|
||||||
|
obj instanceof Function
|
||||||
|
) {
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seen.has(obj as object)) {
|
||||||
|
return seen.get(obj as object) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
const arrCopy: unknown[] = []
|
||||||
|
seen.set(obj, arrCopy)
|
||||||
|
for (const item of obj) {
|
||||||
|
arrCopy.push(deepCopy(item, seen))
|
||||||
|
}
|
||||||
|
return arrCopy as T
|
||||||
|
}
|
||||||
|
|
||||||
|
const objCopy = {} as { [K in keyof T]: T[K] }
|
||||||
|
seen.set(obj, objCopy)
|
||||||
|
|
||||||
|
for (const key of Object.keys(obj) as Array<keyof T>) {
|
||||||
|
objCopy[key] = deepCopy(obj[key], seen)
|
||||||
|
}
|
||||||
|
|
||||||
|
return objCopy
|
||||||
|
}
|
||||||
|
|
||||||
|
export { deepCopy }
|
||||||
316
src/MaksIT.WebUI/src/functions/deep/deepDelta.ts
Normal file
316
src/MaksIT.WebUI/src/functions/deep/deepDelta.ts
Normal file
@ -0,0 +1,316 @@
|
|||||||
|
import { PatchOperation } from '../../models/PatchOperation'
|
||||||
|
import { deepCopy } from './deepCopy.js'
|
||||||
|
import { deepEqual } from './deepEqual.js'
|
||||||
|
|
||||||
|
type IdLike = string | number | null | undefined
|
||||||
|
|
||||||
|
export type Identifiable<I extends string | number = string | number> = {
|
||||||
|
id?: I | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type OperationBag<K extends string = string> = {
|
||||||
|
operations?: Partial<Record<K | 'collectionItemOperation', PatchOperation>>
|
||||||
|
}
|
||||||
|
|
||||||
|
type EnsureId<T extends Identifiable> = { id?: T['id'] }
|
||||||
|
|
||||||
|
type PlainObject = Record<string, unknown>
|
||||||
|
|
||||||
|
type DeltaArrayItem<T extends Identifiable> = Partial<T> & EnsureId<T> & OperationBag
|
||||||
|
|
||||||
|
/** Policy non-generica: chiavi sempre stringhe */
|
||||||
|
export type ArrayPolicy = {
|
||||||
|
/** Nome del campo “radice” che implica re-parenting (es. 'organizationId') */
|
||||||
|
rootKey?: string
|
||||||
|
/** Nomi degli array figli da trattare in caso di re-parenting (es. ['applicationRoles']) */
|
||||||
|
childArrayKeys?: string[]
|
||||||
|
/** Se true, in re-parenting i figli vengono azzerati (default TRUE) */
|
||||||
|
dropChildrenOnRootChange?: boolean
|
||||||
|
/** Nome del campo ruolo (default 'role') */
|
||||||
|
roleFieldKey?: string
|
||||||
|
/** Se true, quando role diventa null si rimuove l’intero item (default TRUE) */
|
||||||
|
deleteItemWhenRoleRemoved?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DeepDeltaOptions<T> = {
|
||||||
|
/** Policy per i campi array del payload (mappati per nome chiave) */
|
||||||
|
arrays?: Partial<Record<Extract<keyof T, string>, ArrayPolicy>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Delta<T> =
|
||||||
|
Partial<{
|
||||||
|
[K in keyof T]:
|
||||||
|
T[K] extends (infer U)[]
|
||||||
|
? DeltaArrayItem<(U & Identifiable)>[]
|
||||||
|
: T[K] extends object
|
||||||
|
? Delta<T[K] & OperationBag<Extract<keyof T, string>>>
|
||||||
|
: T[K]
|
||||||
|
}> & OperationBag<Extract<keyof T, string>>
|
||||||
|
|
||||||
|
/** Safe index per evitare TS2536 quando si indicizza su chiavi dinamiche */
|
||||||
|
const getArrayPolicy = <T>(options: DeepDeltaOptions<T> | undefined, key: string): ArrayPolicy | undefined =>{
|
||||||
|
const arrays = options?.arrays as Partial<Record<string, ArrayPolicy>> | undefined
|
||||||
|
return arrays?.[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPlainObject = (value: unknown): value is PlainObject =>
|
||||||
|
typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
|
||||||
|
export const deepDelta = <T extends Record<string, unknown>>(
|
||||||
|
formState: T,
|
||||||
|
backupState: T,
|
||||||
|
options?: DeepDeltaOptions<T>
|
||||||
|
): Delta<T> => {
|
||||||
|
const delta = {} as Delta<T>
|
||||||
|
|
||||||
|
const setOp = (bag: OperationBag, key: string, op: PatchOperation) => {
|
||||||
|
const ops = (bag.operations ??= {} as Record<string, PatchOperation>)
|
||||||
|
ops[key] = op
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateDelta = (
|
||||||
|
form: PlainObject,
|
||||||
|
backup: PlainObject,
|
||||||
|
parentDelta: PlainObject & OperationBag
|
||||||
|
) => {
|
||||||
|
const keys = Array.from(new Set([...Object.keys(form), ...Object.keys(backup)]))
|
||||||
|
|
||||||
|
for (const rawKey of keys) {
|
||||||
|
const key = rawKey as keyof T & string
|
||||||
|
const formValue = form[key]
|
||||||
|
const backupValue = backup[key]
|
||||||
|
|
||||||
|
// --- ARRAY ---
|
||||||
|
if (Array.isArray(formValue) && Array.isArray(backupValue)) {
|
||||||
|
const policy = getArrayPolicy(options, key)
|
||||||
|
const arrayDelta = calculateArrayDelta(
|
||||||
|
formValue as Identifiable[],
|
||||||
|
backupValue as Identifiable[],
|
||||||
|
policy
|
||||||
|
)
|
||||||
|
if (arrayDelta.length > 0) {
|
||||||
|
;(parentDelta as Delta<T>)[key] = arrayDelta as unknown as Delta<T>[typeof key]
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- OBJECT ---
|
||||||
|
if (isPlainObject(formValue) && isPlainObject(backupValue)) {
|
||||||
|
if (!deepEqual(formValue, backupValue)) {
|
||||||
|
const nestedDelta: PlainObject & OperationBag = {}
|
||||||
|
calculateDelta(
|
||||||
|
formValue as PlainObject,
|
||||||
|
(backupValue as PlainObject) ?? {},
|
||||||
|
nestedDelta
|
||||||
|
)
|
||||||
|
if (Object.keys(nestedDelta).length > 0) {
|
||||||
|
;(parentDelta as Delta<T>)[key] = nestedDelta as unknown as Delta<T>[typeof key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- PRIMITIVE / TYPE CHANGED ---
|
||||||
|
if (!deepEqual(formValue, backupValue)) {
|
||||||
|
;(parentDelta as Delta<T>)[key] = formValue as Delta<T>[typeof key]
|
||||||
|
setOp(parentDelta, key, formValue === null ? PatchOperation.RemoveField : PatchOperation.SetField)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateArrayDelta = <U extends Identifiable>(
|
||||||
|
formArray: U[],
|
||||||
|
backupArray: U[],
|
||||||
|
policy?: ArrayPolicy
|
||||||
|
): DeltaArrayItem<U>[] => {
|
||||||
|
const arrayDelta: DeltaArrayItem<U>[] = []
|
||||||
|
|
||||||
|
const getId = (item?: U): IdLike => (item ? item.id ?? null : null)
|
||||||
|
const childrenKeys = policy?.childArrayKeys ?? []
|
||||||
|
const dropChildren = policy?.dropChildrenOnRootChange ?? true
|
||||||
|
const roleKey = (policy?.roleFieldKey ?? 'role') as keyof U & string
|
||||||
|
const rootKey = policy?.rootKey
|
||||||
|
|
||||||
|
const sameRoot = (f: U, b: U): boolean => {
|
||||||
|
if (!rootKey) return true
|
||||||
|
return (f as PlainObject)[rootKey] === (b as PlainObject)[rootKey]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mappe id → item per lookup veloce
|
||||||
|
const formMap = new Map<string | number, U>()
|
||||||
|
const backupMap = new Map<string | number, U>()
|
||||||
|
for (const item of formArray) {
|
||||||
|
const id = getId(item)
|
||||||
|
if (id !== null && id !== undefined) formMap.set(id as string | number, item)
|
||||||
|
}
|
||||||
|
for (const item of backupArray) {
|
||||||
|
const id = getId(item)
|
||||||
|
if (id !== null && id !== undefined) backupMap.set(id as string | number, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Gestione elementi presenti nel form
|
||||||
|
for (const formItem of formArray) {
|
||||||
|
const fid = getId(formItem)
|
||||||
|
|
||||||
|
// 1.a) Nuovo item (senza id)
|
||||||
|
if (fid === null || fid === undefined) {
|
||||||
|
const addItem = {} as DeltaArrayItem<U>
|
||||||
|
Object.assign(addItem, formItem as Partial<U>)
|
||||||
|
addItem.operations = { collectionItemOperation: PatchOperation.AddToCollection }
|
||||||
|
|
||||||
|
// ⬇️ NON droppiamo i figli su "add": li normalizziamo come AddToCollection
|
||||||
|
for (const ck of childrenKeys) {
|
||||||
|
const v = (addItem as PlainObject)[ck]
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
const normalized = (v as Identifiable[]).map(child => {
|
||||||
|
const c = {} as DeltaArrayItem<Identifiable>
|
||||||
|
Object.assign(c, child as Partial<Identifiable>)
|
||||||
|
c.operations = { collectionItemOperation: PatchOperation.AddToCollection }
|
||||||
|
return c
|
||||||
|
})
|
||||||
|
;(addItem as PlainObject)[ck] = normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
arrayDelta.push(addItem)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1.b) Ha id ma non esiste nel backup ⇒ AddToCollection
|
||||||
|
const backupItem = backupMap.get(fid as string | number)
|
||||||
|
if (!backupItem) {
|
||||||
|
const addItem = {} as DeltaArrayItem<U>
|
||||||
|
Object.assign(addItem, formItem as Partial<U>)
|
||||||
|
addItem.id = fid as U['id']
|
||||||
|
addItem.operations = { collectionItemOperation: PatchOperation.AddToCollection }
|
||||||
|
|
||||||
|
// ⬇️ Anche qui: manteniamo i figli, marcandoli come AddToCollection
|
||||||
|
for (const ck of childrenKeys) {
|
||||||
|
const v = (addItem as PlainObject)[ck]
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
const normalized = (v as Identifiable[]).map(child => {
|
||||||
|
const c = {} as DeltaArrayItem<Identifiable>
|
||||||
|
Object.assign(c, child as Partial<Identifiable>)
|
||||||
|
c.operations = { collectionItemOperation: PatchOperation.AddToCollection }
|
||||||
|
return c
|
||||||
|
})
|
||||||
|
;(addItem as PlainObject)[ck] = normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
arrayDelta.push(addItem)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1.c) Re-parenting: root cambiata
|
||||||
|
if (!sameRoot(formItem, backupItem)) {
|
||||||
|
// REMOVE vecchio
|
||||||
|
const removeItem = {} as DeltaArrayItem<U>
|
||||||
|
removeItem.id = fid as U['id']
|
||||||
|
removeItem.operations = { collectionItemOperation: PatchOperation.RemoveFromCollection }
|
||||||
|
arrayDelta.push(removeItem)
|
||||||
|
|
||||||
|
// ADD nuovo
|
||||||
|
const addItem = {} as DeltaArrayItem<U>
|
||||||
|
Object.assign(addItem, formItem as Partial<U>)
|
||||||
|
addItem.operations = { collectionItemOperation: PatchOperation.AddToCollection }
|
||||||
|
|
||||||
|
if (dropChildren) {
|
||||||
|
// ⬇️ SOLO qui, in caso di re-parenting e se richiesto, azzera i figli
|
||||||
|
for (const ck of childrenKeys) {
|
||||||
|
if (ck in (addItem as PlainObject)) {
|
||||||
|
;(addItem as PlainObject)[ck] = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Mantieni i figli marcandoli come AddToCollection
|
||||||
|
for (const ck of childrenKeys) {
|
||||||
|
const v = (addItem as PlainObject)[ck]
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
const normalized = (v as Identifiable[]).map(child => {
|
||||||
|
const c = {} as DeltaArrayItem<Identifiable>
|
||||||
|
Object.assign(c, child as Partial<Identifiable>)
|
||||||
|
c.operations = { collectionItemOperation: PatchOperation.AddToCollection }
|
||||||
|
return c
|
||||||
|
}); (addItem as PlainObject)[ck] = normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
arrayDelta.push(addItem)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 1.d) Ruolo → null ⇒ rimozione item (se abilitato)
|
||||||
|
const deleteOnRoleNull = policy?.deleteItemWhenRoleRemoved ?? true
|
||||||
|
if (deleteOnRoleNull) {
|
||||||
|
const formRole = (formItem as PlainObject)[roleKey]
|
||||||
|
const backupRole = (backupItem as PlainObject)[roleKey]
|
||||||
|
const roleBecameNull = backupRole !== null && formRole === null
|
||||||
|
if (roleBecameNull) {
|
||||||
|
const removeItem = {} as DeltaArrayItem<U>
|
||||||
|
removeItem.id = fid as U['id']
|
||||||
|
removeItem.operations = { collectionItemOperation: PatchOperation.RemoveFromCollection }
|
||||||
|
arrayDelta.push(removeItem)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1.e) Diff puntuale su campi
|
||||||
|
const itemDeltaBase = {} as (PlainObject & OperationBag & { id?: U['id'] })
|
||||||
|
itemDeltaBase.id = fid as U['id']
|
||||||
|
|
||||||
|
calculateDelta(
|
||||||
|
formItem as PlainObject,
|
||||||
|
backupItem as PlainObject,
|
||||||
|
itemDeltaBase
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasMeaningfulChanges = Object.keys(itemDeltaBase).some(k => k !== 'id')
|
||||||
|
if (hasMeaningfulChanges) {
|
||||||
|
arrayDelta.push(itemDeltaBase as DeltaArrayItem<U>)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Elementi rimossi
|
||||||
|
for (const backupItem of backupArray) {
|
||||||
|
const bid = getId(backupItem)
|
||||||
|
if (bid === null || bid === undefined) continue
|
||||||
|
if (!formMap.has(bid as string | number)) {
|
||||||
|
const removeItem = {} as DeltaArrayItem<U>
|
||||||
|
removeItem.id = bid as U['id']
|
||||||
|
removeItem.operations = { collectionItemOperation: PatchOperation.RemoveFromCollection }
|
||||||
|
arrayDelta.push(removeItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return arrayDelta
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateDelta(
|
||||||
|
deepCopy(formState) as PlainObject,
|
||||||
|
deepCopy(backupState) as PlainObject,
|
||||||
|
delta as PlainObject & OperationBag
|
||||||
|
)
|
||||||
|
|
||||||
|
return delta
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deltaHasOperations = <T extends Record<string, unknown>>(delta: Delta<T>): boolean => {
|
||||||
|
if (!isPlainObject(delta)) return false
|
||||||
|
if ('operations' in delta && isPlainObject(delta.operations)) return true
|
||||||
|
|
||||||
|
for (const key in delta) {
|
||||||
|
const v = (delta as PlainObject)[key]
|
||||||
|
|
||||||
|
if (isPlainObject(v) && deltaHasOperations(v as Delta<{}>)) return true
|
||||||
|
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
for (const item of v) {
|
||||||
|
if (isPlainObject(item) && deltaHasOperations(item as Delta<{}>)) return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
70
src/MaksIT.WebUI/src/functions/deep/deepEqual.ts
Normal file
70
src/MaksIT.WebUI/src/functions/deep/deepEqual.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { deepCopy } from './deepCopy.js'
|
||||||
|
|
||||||
|
const deepEqual = (objA: unknown, objB: unknown): boolean => {
|
||||||
|
const copyA = deepCopy(objA)
|
||||||
|
const copyB = deepCopy(objB)
|
||||||
|
|
||||||
|
if (copyA === copyB) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(copyA) && Array.isArray(copyB)) {
|
||||||
|
return deepEqualArrays(copyA, copyB)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof copyA !== 'object' ||
|
||||||
|
typeof copyB !== 'object' ||
|
||||||
|
copyA === null ||
|
||||||
|
copyB === null
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const keysA = Object.keys(copyA)
|
||||||
|
const keysB = Object.keys(copyB)
|
||||||
|
|
||||||
|
if (keysA.length !== keysB.length) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of keysA) {
|
||||||
|
if (!keysB.includes(key)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const valA = (copyA as Record<string, unknown>)[key]
|
||||||
|
const valB = (copyB as Record<string, unknown>)[key]
|
||||||
|
|
||||||
|
if (!deepEqual(valA, valB)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const deepEqualArrays = (arrA: unknown[], arrB: unknown[]): boolean => {
|
||||||
|
const copyA = deepCopy(arrA)
|
||||||
|
const copyB = deepCopy(arrB)
|
||||||
|
|
||||||
|
if (copyA.length !== copyB.length) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (copyA.length === 0 && copyB.length === 0) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const itemA of copyA) {
|
||||||
|
const matchIndex = copyB.findIndex((itemB) => deepEqual(itemA, itemB))
|
||||||
|
if (matchIndex === -1) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
copyB.splice(matchIndex, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export { deepEqual, deepEqualArrays }
|
||||||
35
src/MaksIT.WebUI/src/functions/deep/deepMerge.ts
Normal file
35
src/MaksIT.WebUI/src/functions/deep/deepMerge.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
const deepMerge = <T>(target: T, source: T, seen = new WeakMap<object, unknown>()): T => {
|
||||||
|
if (target === null || typeof target !== 'object') return source
|
||||||
|
if (source === null || typeof source !== 'object') return target
|
||||||
|
|
||||||
|
if (seen.has(target as object)) {
|
||||||
|
return seen.get(target as object) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(target) && Array.isArray(source)) {
|
||||||
|
const mergedArray: unknown[] = []
|
||||||
|
seen.set(target, mergedArray)
|
||||||
|
|
||||||
|
const maxLength = Math.max(target.length, source.length)
|
||||||
|
for (let i = 0; i < maxLength; i++) {
|
||||||
|
mergedArray[i] = deepMerge(target[i], source[i], seen)
|
||||||
|
}
|
||||||
|
|
||||||
|
return mergedArray as T
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedObject = { ...target } as Record<string | number | symbol, unknown>
|
||||||
|
seen.set(target as object, mergedObject)
|
||||||
|
|
||||||
|
for (const key of Object.keys(source) as Array<keyof T>) {
|
||||||
|
const sourceValue = source[key]
|
||||||
|
if (sourceValue !== undefined) {
|
||||||
|
const targetValue = target[key]
|
||||||
|
mergedObject[key] = deepMerge(targetValue, sourceValue, seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mergedObject as T
|
||||||
|
}
|
||||||
|
|
||||||
|
export { deepMerge }
|
||||||
18
src/MaksIT.WebUI/src/functions/deep/index.ts
Normal file
18
src/MaksIT.WebUI/src/functions/deep/index.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { deepCopy } from './deepCopy'
|
||||||
|
import {
|
||||||
|
deepDelta,
|
||||||
|
deltaHasOperations
|
||||||
|
} from './deepDelta'
|
||||||
|
import { deepEqualArrays, deepEqual } from './deepEqual'
|
||||||
|
import { deepMerge } from './deepMerge'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export {
|
||||||
|
deepCopy,
|
||||||
|
deepDelta,
|
||||||
|
deltaHasOperations,
|
||||||
|
deepEqualArrays,
|
||||||
|
deepEqual,
|
||||||
|
deepMerge
|
||||||
|
}
|
||||||
39
src/MaksIT.WebUI/src/functions/enum/enumToArr.ts
Normal file
39
src/MaksIT.WebUI/src/functions/enum/enumToArr.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
export interface EnumArrayProps {
|
||||||
|
value: number | string;
|
||||||
|
displayValue: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const enumToArr = (enumType: unknown): EnumArrayProps[] => {
|
||||||
|
if (!enumType) return []
|
||||||
|
|
||||||
|
const enumEntries = Object.entries(enumType)
|
||||||
|
const addedValues = new Set()
|
||||||
|
const result: EnumArrayProps[] = []
|
||||||
|
|
||||||
|
enumEntries.forEach(([key, value]) => {
|
||||||
|
// Skip numeric keys to avoid reverse mapping duplicates in numeric enums
|
||||||
|
if (!isNaN(Number(key))) return
|
||||||
|
|
||||||
|
// Skip already added values for string enums with reverse mapping
|
||||||
|
if (addedValues.has(value)) return
|
||||||
|
addedValues.add(value)
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
value: value,
|
||||||
|
displayValue: key,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sort the result array by displayValue (key)
|
||||||
|
result.sort((a, b) => {
|
||||||
|
if (typeof a.displayValue === 'string' && typeof b.displayValue === 'string') {
|
||||||
|
return a.displayValue.localeCompare(b.displayValue)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export { enumToArr }
|
||||||
17
src/MaksIT.WebUI/src/functions/enum/enumToObj.ts
Normal file
17
src/MaksIT.WebUI/src/functions/enum/enumToObj.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
|
||||||
|
const enumToObj = (enumType: unknown) => {
|
||||||
|
if (!enumType) return {}
|
||||||
|
|
||||||
|
const enumEntries = Object.entries(enumType)
|
||||||
|
const result: { [key: string]: number | string } = {}
|
||||||
|
|
||||||
|
enumEntries.forEach(([key, value]) => {
|
||||||
|
// Skip numeric keys to avoid reverse mapping duplicates in numeric enums
|
||||||
|
if (!isNaN(Number(key))) return
|
||||||
|
result[key] = value
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export { enumToObj }
|
||||||
23
src/MaksIT.WebUI/src/functions/enum/enumToString.ts
Normal file
23
src/MaksIT.WebUI/src/functions/enum/enumToString.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { EnumArrayProps, enumToArr } from './enumToArr'
|
||||||
|
|
||||||
|
const getEnumValue = <T>(enumType: T, enumValue: number) : EnumArrayProps | undefined => {
|
||||||
|
return enumToArr(enumType).find((item) => item.value == enumValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
const enumToString = <T>(enumType: T, enumValue?: number | null): string => {
|
||||||
|
|
||||||
|
if (enumValue === undefined || enumValue === null) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const enumVal = getEnumValue(enumType, enumValue)
|
||||||
|
|
||||||
|
if (!enumVal)
|
||||||
|
return ''
|
||||||
|
|
||||||
|
return `${enumVal.value} - ${enumVal.displayValue}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
enumToString
|
||||||
|
}
|
||||||
10
src/MaksIT.WebUI/src/functions/enum/flagsToString.ts
Normal file
10
src/MaksIT.WebUI/src/functions/enum/flagsToString.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { enumToArr } from './enumToArr'
|
||||||
|
|
||||||
|
const flagsToString = <T>(enumType: T, flags: number): string => {
|
||||||
|
return enumToArr(enumType)
|
||||||
|
.filter(opt => (flags & opt.value as number) === opt.value && opt.value !== 0)
|
||||||
|
.map(opt => opt.displayValue)
|
||||||
|
.join(', ') || 'None'
|
||||||
|
}
|
||||||
|
|
||||||
|
export { flagsToString }
|
||||||
5
src/MaksIT.WebUI/src/functions/enum/hasAnyFlag.ts
Normal file
5
src/MaksIT.WebUI/src/functions/enum/hasAnyFlag.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
const hasAnyFlag = <T extends number>(current: T = 0 as T, flags: T): boolean => {
|
||||||
|
return (current & flags) !== 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export { hasAnyFlag }
|
||||||
5
src/MaksIT.WebUI/src/functions/enum/hasFlag.ts
Normal file
5
src/MaksIT.WebUI/src/functions/enum/hasFlag.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
const hasFlag = <T extends number>(current: T = 0 as T, flag: T): boolean => {
|
||||||
|
return (current & flag) === flag
|
||||||
|
}
|
||||||
|
|
||||||
|
export { hasFlag }
|
||||||
37
src/MaksIT.WebUI/src/functions/enum/index.ts
Normal file
37
src/MaksIT.WebUI/src/functions/enum/index.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import {
|
||||||
|
enumToArr
|
||||||
|
} from './enumToArr'
|
||||||
|
|
||||||
|
import {
|
||||||
|
enumToObj
|
||||||
|
} from './enumToObj'
|
||||||
|
|
||||||
|
import {
|
||||||
|
enumToString
|
||||||
|
} from './enumToString'
|
||||||
|
|
||||||
|
import {
|
||||||
|
flagsToString
|
||||||
|
} from './flagsToString'
|
||||||
|
|
||||||
|
import {
|
||||||
|
toggleFlag
|
||||||
|
} from './toggleFlag'
|
||||||
|
|
||||||
|
import {
|
||||||
|
hasFlag
|
||||||
|
} from './hasFlag'
|
||||||
|
|
||||||
|
import {
|
||||||
|
hasAnyFlag
|
||||||
|
} from './hasAnyFlag'
|
||||||
|
|
||||||
|
export {
|
||||||
|
enumToArr,
|
||||||
|
enumToObj,
|
||||||
|
enumToString,
|
||||||
|
flagsToString,
|
||||||
|
toggleFlag,
|
||||||
|
hasFlag,
|
||||||
|
hasAnyFlag
|
||||||
|
}
|
||||||
5
src/MaksIT.WebUI/src/functions/enum/toggleFlag.ts
Normal file
5
src/MaksIT.WebUI/src/functions/enum/toggleFlag.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
const toggleFlag = <T extends number>(current: T = 0 as T, flag: T): T => {
|
||||||
|
return ((current & flag) === flag ? (current & ~flag) : (current | flag)) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
export { toggleFlag }
|
||||||
55
src/MaksIT.WebUI/src/functions/index.ts
Normal file
55
src/MaksIT.WebUI/src/functions/index.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
isValidISODateString,
|
||||||
|
formatISODateString
|
||||||
|
} from './date'
|
||||||
|
|
||||||
|
import {
|
||||||
|
deepCopy,
|
||||||
|
deepDelta,
|
||||||
|
deltaHasOperations,
|
||||||
|
deepEqual,
|
||||||
|
deepMerge,
|
||||||
|
} from './deep'
|
||||||
|
|
||||||
|
import {
|
||||||
|
enumToArr,
|
||||||
|
enumToObj,
|
||||||
|
enumToString,
|
||||||
|
flagsToString,
|
||||||
|
toggleFlag,
|
||||||
|
hasFlag,
|
||||||
|
hasAnyFlag
|
||||||
|
} from './enum'
|
||||||
|
|
||||||
|
import {
|
||||||
|
isGuid
|
||||||
|
} from './isGuid'
|
||||||
|
|
||||||
|
import {
|
||||||
|
parseAclEntry,
|
||||||
|
parseAclEntries
|
||||||
|
} from './acl'
|
||||||
|
|
||||||
|
export {
|
||||||
|
isValidISODateString,
|
||||||
|
formatISODateString,
|
||||||
|
|
||||||
|
deepCopy,
|
||||||
|
deepDelta,
|
||||||
|
deltaHasOperations,
|
||||||
|
deepEqual,
|
||||||
|
deepMerge,
|
||||||
|
|
||||||
|
enumToArr,
|
||||||
|
enumToObj,
|
||||||
|
enumToString,
|
||||||
|
flagsToString,
|
||||||
|
toggleFlag,
|
||||||
|
hasFlag,
|
||||||
|
hasAnyFlag,
|
||||||
|
|
||||||
|
isGuid,
|
||||||
|
|
||||||
|
parseAclEntry,
|
||||||
|
parseAclEntries
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user