🎉 (initial commit of sori client) initial commit of sori client

This commit is contained in:
Robert Chan 2025-10-07 10:07:22 +08:00
commit 1e91c6a97b
336 changed files with 30352 additions and 0 deletions

5
.editorconfig Normal file
View File

@ -0,0 +1,5 @@
[*]
tab_width = 2
indent_style = space
indent_size = 2
trim_trailing_whitespace = true

89
.eslintrc.cjs Normal file
View File

@ -0,0 +1,89 @@
import pluginJs from '@eslint/js';
import pluginUnocss from '@unocss/eslint-config/flat';
import pluginPrettier from 'eslint-config-prettier';
import pluginImport from 'eslint-plugin-import';
import pluginReact from 'eslint-plugin-react';
import pluginReactCompiler from 'eslint-plugin-react-compiler';
import pluginReactHooks from 'eslint-plugin-react-hooks';
import pluginReactRefresh from 'eslint-plugin-react-refresh';
import globals from 'globals';
import { configs as tseslintConfigs } from 'typescript-eslint';
/** @type {import('eslint').Linter.Config[]} */
export default [
{ ignores: ['dist', 'node-modules', '.yarn', '.pnp.*'] },
{ files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'] },
{ languageOptions: { globals: globals.browser } },
{
...pluginReact.configs.flat.recommended,
settings: {
react: {
version: 'detect',
},
},
rules: {
'react/prop-types': ['off'],
},
},
pluginReact.configs.flat['jsx-runtime'],
pluginJs.configs.recommended,
...tseslintConfigs.recommended,
{
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': pluginReactHooks,
'react-refresh': pluginReactRefresh,
},
rules: {
...pluginReactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
pluginImport.flatConfigs.recommended,
pluginImport.flatConfigs.typescript,
pluginImport.flatConfigs.react,
{
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
settings: {
'import/external-module-folders': ['node_modules', '.yarn'],
'import/resolver': {
// You will also need to install and configure the TypeScript resolver
// See also https://github.com/import-js/eslint-import-resolver-typescript#configuration
typescript: true,
node: true,
},
},
rules: {
'import/no-cycle': 'warn',
'import/order': [
'error',
{
named: true,
alphabetize: {
order: 'asc',
caseInsensitive: true,
},
},
],
},
},
{
plugins: {
'react-compiler': pluginReactCompiler,
},
rules: {
'react-compiler/react-compiler': 'error',
},
},
pluginPrettier,
pluginUnocss,
];

View File

@ -0,0 +1,34 @@
name: Release Docker Image
on:
push:
tags:
- v*
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Login to Gitea
uses: docker/login-action@v3
with:
registry: ${{ vars._CI_REGISTRY }}
username: ${{ vars._CI_REGISTRY_USER }}
password: ${{ secrets._CI_REGISTRY_ACCESS_TOKEN }}
# Remarks: Setting up QEMU and Buildx are useful for cross-platform build but it will slow the CI pipeline.
# - name: Set up QEMU
# uses: docker/setup-qemu-action@v3
# - name: Set up Docker Buildx
# uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
secrets: JIG_SOFTWARE_GITEA_DEPLOYMENT_TOKEN=${{ secrets._CI_REGISTRY_ACCESS_TOKEN }}
push: true
tags: ${{ vars._CI_REGISTRY }}/${{ gitea.repository }}:${{ gitea.ref_name }},${{ vars._CI_REGISTRY }}/${{ gitea.repository }}:latest

42
.gitignore vendored Normal file
View File

@ -0,0 +1,42 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.yarn/install-state.gz
# Environment
.env
# Yarn 2
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Generated certs
certs
arcgisData

4
.prettierignore Normal file
View File

@ -0,0 +1,4 @@
dist/
node_modules/
.pnp.*
.yarn/*

16
.prettierrc Normal file
View File

@ -0,0 +1,16 @@
{
"semi": true,
"tabWidth": 2,
"useTabs": false,
"arrowParens": "always",
"singleQuote": true,
"trailingComma": "all",
"overrides": [
{
"files": ".yarnrc.yml",
"options": {
"singleQuote": false
}
}
]
}

7
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,7 @@
{
"recommendations": [
"arcanis.vscode-zipfs",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
]
}

32
.yarn/sdks/eslint/bin/eslint.js vendored Executable file
View File

@ -0,0 +1,32 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, register} = require(`module`);
const {resolve} = require(`path`);
const {pathToFileURL} = require(`url`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`);
const absRequire = createRequire(absPnpApiPath);
const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require eslint/bin/eslint.js
require(absPnpApiPath).setup();
if (isPnpLoaderEnabled && register) {
register(pathToFileURL(absPnpLoaderPath));
}
}
}
const wrapWithUserWrapper = existsSync(absUserWrapperPath)
? exports => absRequire(absUserWrapperPath)(exports)
: exports => exports;
// Defer to the real eslint/bin/eslint.js your application uses
module.exports = wrapWithUserWrapper(absRequire(`eslint/bin/eslint.js`));

32
.yarn/sdks/eslint/lib/api.js vendored Normal file
View File

@ -0,0 +1,32 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, register} = require(`module`);
const {resolve} = require(`path`);
const {pathToFileURL} = require(`url`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`);
const absRequire = createRequire(absPnpApiPath);
const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require eslint
require(absPnpApiPath).setup();
if (isPnpLoaderEnabled && register) {
register(pathToFileURL(absPnpLoaderPath));
}
}
}
const wrapWithUserWrapper = existsSync(absUserWrapperPath)
? exports => absRequire(absUserWrapperPath)(exports)
: exports => exports;
// Defer to the real eslint your application uses
module.exports = wrapWithUserWrapper(absRequire(`eslint`));

32
.yarn/sdks/eslint/lib/types/index.d.ts vendored Normal file
View File

@ -0,0 +1,32 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, register} = require(`module`);
const {resolve} = require(`path`);
const {pathToFileURL} = require(`url`);
const relPnpApiPath = "../../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`);
const absRequire = createRequire(absPnpApiPath);
const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require eslint
require(absPnpApiPath).setup();
if (isPnpLoaderEnabled && register) {
register(pathToFileURL(absPnpLoaderPath));
}
}
}
const wrapWithUserWrapper = existsSync(absUserWrapperPath)
? exports => absRequire(absUserWrapperPath)(exports)
: exports => exports;
// Defer to the real eslint your application uses
module.exports = wrapWithUserWrapper(absRequire(`eslint`));

View File

@ -0,0 +1,32 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, register} = require(`module`);
const {resolve} = require(`path`);
const {pathToFileURL} = require(`url`);
const relPnpApiPath = "../../../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`);
const absRequire = createRequire(absPnpApiPath);
const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require eslint/rules
require(absPnpApiPath).setup();
if (isPnpLoaderEnabled && register) {
register(pathToFileURL(absPnpLoaderPath));
}
}
}
const wrapWithUserWrapper = existsSync(absUserWrapperPath)
? exports => absRequire(absUserWrapperPath)(exports)
: exports => exports;
// Defer to the real eslint/rules your application uses
module.exports = wrapWithUserWrapper(absRequire(`eslint/rules`));

View File

@ -0,0 +1,32 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, register} = require(`module`);
const {resolve} = require(`path`);
const {pathToFileURL} = require(`url`);
const relPnpApiPath = "../../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`);
const absRequire = createRequire(absPnpApiPath);
const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require eslint/universal
require(absPnpApiPath).setup();
if (isPnpLoaderEnabled && register) {
register(pathToFileURL(absPnpLoaderPath));
}
}
}
const wrapWithUserWrapper = existsSync(absUserWrapperPath)
? exports => absRequire(absUserWrapperPath)(exports)
: exports => exports;
// Defer to the real eslint/universal your application uses
module.exports = wrapWithUserWrapper(absRequire(`eslint/universal`));

View File

@ -0,0 +1,32 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, register} = require(`module`);
const {resolve} = require(`path`);
const {pathToFileURL} = require(`url`);
const relPnpApiPath = "../../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`);
const absRequire = createRequire(absPnpApiPath);
const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require eslint/use-at-your-own-risk
require(absPnpApiPath).setup();
if (isPnpLoaderEnabled && register) {
register(pathToFileURL(absPnpLoaderPath));
}
}
}
const wrapWithUserWrapper = existsSync(absUserWrapperPath)
? exports => absRequire(absUserWrapperPath)(exports)
: exports => exports;
// Defer to the real eslint/use-at-your-own-risk your application uses
module.exports = wrapWithUserWrapper(absRequire(`eslint/use-at-your-own-risk`));

32
.yarn/sdks/eslint/lib/universal.js vendored Normal file
View File

@ -0,0 +1,32 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, register} = require(`module`);
const {resolve} = require(`path`);
const {pathToFileURL} = require(`url`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`);
const absRequire = createRequire(absPnpApiPath);
const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require eslint/universal
require(absPnpApiPath).setup();
if (isPnpLoaderEnabled && register) {
register(pathToFileURL(absPnpLoaderPath));
}
}
}
const wrapWithUserWrapper = existsSync(absUserWrapperPath)
? exports => absRequire(absUserWrapperPath)(exports)
: exports => exports;
// Defer to the real eslint/universal your application uses
module.exports = wrapWithUserWrapper(absRequire(`eslint/universal`));

View File

@ -0,0 +1,32 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, register} = require(`module`);
const {resolve} = require(`path`);
const {pathToFileURL} = require(`url`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`);
const absRequire = createRequire(absPnpApiPath);
const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require eslint/use-at-your-own-risk
require(absPnpApiPath).setup();
if (isPnpLoaderEnabled && register) {
register(pathToFileURL(absPnpLoaderPath));
}
}
}
const wrapWithUserWrapper = existsSync(absUserWrapperPath)
? exports => absRequire(absUserWrapperPath)(exports)
: exports => exports;
// Defer to the real eslint/use-at-your-own-risk your application uses
module.exports = wrapWithUserWrapper(absRequire(`eslint/use-at-your-own-risk`));

27
.yarn/sdks/eslint/package.json vendored Normal file
View File

@ -0,0 +1,27 @@
{
"name": "eslint",
"version": "9.14.0-sdk",
"main": "./lib/api.js",
"type": "commonjs",
"bin": {
"eslint": "./bin/eslint.js"
},
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/api.js"
},
"./package.json": "./package.json",
"./use-at-your-own-risk": {
"types": "./lib/types/use-at-your-own-risk.d.ts",
"default": "./lib/unsupported-api.js"
},
"./rules": {
"types": "./lib/types/rules/index.d.ts"
},
"./universal": {
"types": "./lib/types/universal.d.ts",
"default": "./lib/universal.js"
}
}
}

5
.yarn/sdks/integrations.yml vendored Normal file
View File

@ -0,0 +1,5 @@
# This file is automatically generated by @yarnpkg/sdks.
# Manual changes might be lost!
integrations:
- vscode

32
.yarn/sdks/prettier/bin/prettier.cjs vendored Executable file
View File

@ -0,0 +1,32 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, register} = require(`module`);
const {resolve} = require(`path`);
const {pathToFileURL} = require(`url`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`);
const absRequire = createRequire(absPnpApiPath);
const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require prettier/bin/prettier.cjs
require(absPnpApiPath).setup();
if (isPnpLoaderEnabled && register) {
register(pathToFileURL(absPnpLoaderPath));
}
}
}
const wrapWithUserWrapper = existsSync(absUserWrapperPath)
? exports => absRequire(absUserWrapperPath)(exports)
: exports => exports;
// Defer to the real prettier/bin/prettier.cjs your application uses
module.exports = wrapWithUserWrapper(absRequire(`prettier/bin/prettier.cjs`));

32
.yarn/sdks/prettier/index.cjs vendored Normal file
View File

@ -0,0 +1,32 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, register} = require(`module`);
const {resolve} = require(`path`);
const {pathToFileURL} = require(`url`);
const relPnpApiPath = "../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`);
const absRequire = createRequire(absPnpApiPath);
const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require prettier
require(absPnpApiPath).setup();
if (isPnpLoaderEnabled && register) {
register(pathToFileURL(absPnpLoaderPath));
}
}
}
const wrapWithUserWrapper = existsSync(absUserWrapperPath)
? exports => absRequire(absUserWrapperPath)(exports)
: exports => exports;
// Defer to the real prettier your application uses
module.exports = wrapWithUserWrapper(absRequire(`prettier`));

7
.yarn/sdks/prettier/package.json vendored Normal file
View File

@ -0,0 +1,7 @@
{
"name": "prettier",
"version": "3.3.3-sdk",
"main": "./index.cjs",
"type": "commonjs",
"bin": "./bin/prettier.cjs"
}

32
.yarn/sdks/typescript/bin/tsc vendored Executable file
View File

@ -0,0 +1,32 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, register} = require(`module`);
const {resolve} = require(`path`);
const {pathToFileURL} = require(`url`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`);
const absRequire = createRequire(absPnpApiPath);
const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/bin/tsc
require(absPnpApiPath).setup();
if (isPnpLoaderEnabled && register) {
register(pathToFileURL(absPnpLoaderPath));
}
}
}
const wrapWithUserWrapper = existsSync(absUserWrapperPath)
? exports => absRequire(absUserWrapperPath)(exports)
: exports => exports;
// Defer to the real typescript/bin/tsc your application uses
module.exports = wrapWithUserWrapper(absRequire(`typescript/bin/tsc`));

32
.yarn/sdks/typescript/bin/tsserver vendored Executable file
View File

@ -0,0 +1,32 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, register} = require(`module`);
const {resolve} = require(`path`);
const {pathToFileURL} = require(`url`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`);
const absRequire = createRequire(absPnpApiPath);
const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/bin/tsserver
require(absPnpApiPath).setup();
if (isPnpLoaderEnabled && register) {
register(pathToFileURL(absPnpLoaderPath));
}
}
}
const wrapWithUserWrapper = existsSync(absUserWrapperPath)
? exports => absRequire(absUserWrapperPath)(exports)
: exports => exports;
// Defer to the real typescript/bin/tsserver your application uses
module.exports = wrapWithUserWrapper(absRequire(`typescript/bin/tsserver`));

32
.yarn/sdks/typescript/lib/tsc.js vendored Normal file
View File

@ -0,0 +1,32 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, register} = require(`module`);
const {resolve} = require(`path`);
const {pathToFileURL} = require(`url`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`);
const absRequire = createRequire(absPnpApiPath);
const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/lib/tsc.js
require(absPnpApiPath).setup();
if (isPnpLoaderEnabled && register) {
register(pathToFileURL(absPnpLoaderPath));
}
}
}
const wrapWithUserWrapper = existsSync(absUserWrapperPath)
? exports => absRequire(absUserWrapperPath)(exports)
: exports => exports;
// Defer to the real typescript/lib/tsc.js your application uses
module.exports = wrapWithUserWrapper(absRequire(`typescript/lib/tsc.js`));

248
.yarn/sdks/typescript/lib/tsserver.js vendored Normal file
View File

@ -0,0 +1,248 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, register} = require(`module`);
const {resolve} = require(`path`);
const {pathToFileURL} = require(`url`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`);
const absRequire = createRequire(absPnpApiPath);
const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/lib/tsserver.js
require(absPnpApiPath).setup();
if (isPnpLoaderEnabled && register) {
register(pathToFileURL(absPnpLoaderPath));
}
}
}
const wrapWithUserWrapper = existsSync(absUserWrapperPath)
? exports => absRequire(absUserWrapperPath)(exports)
: exports => exports;
const moduleWrapper = exports => {
return wrapWithUserWrapper(moduleWrapperFn(exports));
};
const moduleWrapperFn = tsserver => {
if (!process.versions.pnp) {
return tsserver;
}
const {isAbsolute} = require(`path`);
const pnpApi = require(`pnpapi`);
const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//);
const isPortal = str => str.startsWith("portal:/");
const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`);
const dependencyTreeRoots = new Set(pnpApi.getDependencyTreeRoots().map(locator => {
return `${locator.name}@${locator.reference}`;
}));
// VSCode sends the zip paths to TS using the "zip://" prefix, that TS
// doesn't understand. This layer makes sure to remove the protocol
// before forwarding it to TS, and to add it back on all returned paths.
function toEditorPath(str) {
// We add the `zip:` prefix to both `.zip/` paths and virtual paths
if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) {
// We also take the opportunity to turn virtual paths into physical ones;
// this makes it much easier to work with workspaces that list peer
// dependencies, since otherwise Ctrl+Click would bring us to the virtual
// file instances instead of the real ones.
//
// We only do this to modules owned by the the dependency tree roots.
// This avoids breaking the resolution when jumping inside a vendor
// with peer dep (otherwise jumping into react-dom would show resolution
// errors on react).
//
const resolved = isVirtual(str) ? pnpApi.resolveVirtual(str) : str;
if (resolved) {
const locator = pnpApi.findPackageLocator(resolved);
if (locator && (dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))) {
str = resolved;
}
}
str = normalize(str);
if (str.match(/\.zip\//)) {
switch (hostInfo) {
// Absolute VSCode `Uri.fsPath`s need to start with a slash.
// VSCode only adds it automatically for supported schemes,
// so we have to do it manually for the `zip` scheme.
// The path needs to start with a caret otherwise VSCode doesn't handle the protocol
//
// Ref: https://github.com/microsoft/vscode/issues/105014#issuecomment-686760910
//
// 2021-10-08: VSCode changed the format in 1.61.
// Before | ^zip:/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
// 2022-04-06: VSCode changed the format in 1.66.
// Before | ^/zip//c:/foo/bar.zip/package.json
// After | ^/zip/c:/foo/bar.zip/package.json
//
// 2022-05-06: VSCode changed the format in 1.68
// Before | ^/zip/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
case `vscode <1.61`: {
str = `^zip:${str}`;
} break;
case `vscode <1.66`: {
str = `^/zip/${str}`;
} break;
case `vscode <1.68`: {
str = `^/zip${str}`;
} break;
case `vscode`: {
str = `^/zip/${str}`;
} break;
// To make "go to definition" work,
// We have to resolve the actual file system path from virtual path
// and convert scheme to supported by [vim-rzip](https://github.com/lbrayner/vim-rzip)
case `coc-nvim`: {
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = resolve(`zipfile:${str}`);
} break;
// Support neovim native LSP and [typescript-language-server](https://github.com/theia-ide/typescript-language-server)
// We have to resolve the actual file system path from virtual path,
// everything else is up to neovim
case `neovim`: {
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = `zipfile://${str}`;
} break;
default: {
str = `zip:${str}`;
} break;
}
} else {
str = str.replace(/^\/?/, process.platform === `win32` ? `` : `/`);
}
}
return str;
}
function fromEditorPath(str) {
switch (hostInfo) {
case `coc-nvim`: {
str = str.replace(/\.zip::/, `.zip/`);
// The path for coc-nvim is in format of /<pwd>/zipfile:/<pwd>/.yarn/...
// So in order to convert it back, we use .* to match all the thing
// before `zipfile:`
return process.platform === `win32`
? str.replace(/^.*zipfile:\//, ``)
: str.replace(/^.*zipfile:/, ``);
} break;
case `neovim`: {
str = str.replace(/\.zip::/, `.zip/`);
// The path for neovim is in format of zipfile:///<pwd>/.yarn/...
return str.replace(/^zipfile:\/\//, ``);
} break;
case `vscode`:
default: {
return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`)
} break;
}
}
// Force enable 'allowLocalPluginLoads'
// TypeScript tries to resolve plugins using a path relative to itself
// which doesn't work when using the global cache
// https://github.com/microsoft/TypeScript/blob/1b57a0395e0bff191581c9606aab92832001de62/src/server/project.ts#L2238
// VSCode doesn't want to enable 'allowLocalPluginLoads' due to security concerns but
// TypeScript already does local loads and if this code is running the user trusts the workspace
// https://github.com/microsoft/vscode/issues/45856
const ConfiguredProject = tsserver.server.ConfiguredProject;
const {enablePluginsWithOptions: originalEnablePluginsWithOptions} = ConfiguredProject.prototype;
ConfiguredProject.prototype.enablePluginsWithOptions = function() {
this.projectService.allowLocalPluginLoads = true;
return originalEnablePluginsWithOptions.apply(this, arguments);
};
// And here is the point where we hijack the VSCode <-> TS communications
// by adding ourselves in the middle. We locate everything that looks
// like an absolute path of ours and normalize it.
const Session = tsserver.server.Session;
const {onMessage: originalOnMessage, send: originalSend} = Session.prototype;
let hostInfo = `unknown`;
Object.assign(Session.prototype, {
onMessage(/** @type {string | object} */ message) {
const isStringMessage = typeof message === 'string';
const parsedMessage = isStringMessage ? JSON.parse(message) : message;
if (
parsedMessage != null &&
typeof parsedMessage === `object` &&
parsedMessage.arguments &&
typeof parsedMessage.arguments.hostInfo === `string`
) {
hostInfo = parsedMessage.arguments.hostInfo;
if (hostInfo === `vscode` && process.env.VSCODE_IPC_HOOK) {
const [, major, minor] = (process.env.VSCODE_IPC_HOOK.match(
// The RegExp from https://semver.org/ but without the caret at the start
/(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/
) ?? []).map(Number)
if (major === 1) {
if (minor < 61) {
hostInfo += ` <1.61`;
} else if (minor < 66) {
hostInfo += ` <1.66`;
} else if (minor < 68) {
hostInfo += ` <1.68`;
}
}
}
}
const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => {
return typeof value === 'string' ? fromEditorPath(value) : value;
});
return originalOnMessage.call(
this,
isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON)
);
},
send(/** @type {any} */ msg) {
return originalSend.call(this, JSON.parse(JSON.stringify(msg, (key, value) => {
return typeof value === `string` ? toEditorPath(value) : value;
})));
}
});
return tsserver;
};
const [major, minor] = absRequire(`typescript/package.json`).version.split(`.`, 2).map(value => parseInt(value, 10));
// In TypeScript@>=5.5 the tsserver uses the public TypeScript API so that needs to be patched as well.
// Ref https://github.com/microsoft/TypeScript/pull/55326
if (major > 5 || (major === 5 && minor >= 5)) {
moduleWrapper(absRequire(`typescript`));
}
// Defer to the real typescript/lib/tsserver.js your application uses
module.exports = moduleWrapper(absRequire(`typescript/lib/tsserver.js`));

View File

@ -0,0 +1,248 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, register} = require(`module`);
const {resolve} = require(`path`);
const {pathToFileURL} = require(`url`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`);
const absRequire = createRequire(absPnpApiPath);
const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/lib/tsserverlibrary.js
require(absPnpApiPath).setup();
if (isPnpLoaderEnabled && register) {
register(pathToFileURL(absPnpLoaderPath));
}
}
}
const wrapWithUserWrapper = existsSync(absUserWrapperPath)
? exports => absRequire(absUserWrapperPath)(exports)
: exports => exports;
const moduleWrapper = exports => {
return wrapWithUserWrapper(moduleWrapperFn(exports));
};
const moduleWrapperFn = tsserver => {
if (!process.versions.pnp) {
return tsserver;
}
const {isAbsolute} = require(`path`);
const pnpApi = require(`pnpapi`);
const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//);
const isPortal = str => str.startsWith("portal:/");
const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`);
const dependencyTreeRoots = new Set(pnpApi.getDependencyTreeRoots().map(locator => {
return `${locator.name}@${locator.reference}`;
}));
// VSCode sends the zip paths to TS using the "zip://" prefix, that TS
// doesn't understand. This layer makes sure to remove the protocol
// before forwarding it to TS, and to add it back on all returned paths.
function toEditorPath(str) {
// We add the `zip:` prefix to both `.zip/` paths and virtual paths
if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) {
// We also take the opportunity to turn virtual paths into physical ones;
// this makes it much easier to work with workspaces that list peer
// dependencies, since otherwise Ctrl+Click would bring us to the virtual
// file instances instead of the real ones.
//
// We only do this to modules owned by the the dependency tree roots.
// This avoids breaking the resolution when jumping inside a vendor
// with peer dep (otherwise jumping into react-dom would show resolution
// errors on react).
//
const resolved = isVirtual(str) ? pnpApi.resolveVirtual(str) : str;
if (resolved) {
const locator = pnpApi.findPackageLocator(resolved);
if (locator && (dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))) {
str = resolved;
}
}
str = normalize(str);
if (str.match(/\.zip\//)) {
switch (hostInfo) {
// Absolute VSCode `Uri.fsPath`s need to start with a slash.
// VSCode only adds it automatically for supported schemes,
// so we have to do it manually for the `zip` scheme.
// The path needs to start with a caret otherwise VSCode doesn't handle the protocol
//
// Ref: https://github.com/microsoft/vscode/issues/105014#issuecomment-686760910
//
// 2021-10-08: VSCode changed the format in 1.61.
// Before | ^zip:/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
// 2022-04-06: VSCode changed the format in 1.66.
// Before | ^/zip//c:/foo/bar.zip/package.json
// After | ^/zip/c:/foo/bar.zip/package.json
//
// 2022-05-06: VSCode changed the format in 1.68
// Before | ^/zip/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
case `vscode <1.61`: {
str = `^zip:${str}`;
} break;
case `vscode <1.66`: {
str = `^/zip/${str}`;
} break;
case `vscode <1.68`: {
str = `^/zip${str}`;
} break;
case `vscode`: {
str = `^/zip/${str}`;
} break;
// To make "go to definition" work,
// We have to resolve the actual file system path from virtual path
// and convert scheme to supported by [vim-rzip](https://github.com/lbrayner/vim-rzip)
case `coc-nvim`: {
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = resolve(`zipfile:${str}`);
} break;
// Support neovim native LSP and [typescript-language-server](https://github.com/theia-ide/typescript-language-server)
// We have to resolve the actual file system path from virtual path,
// everything else is up to neovim
case `neovim`: {
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = `zipfile://${str}`;
} break;
default: {
str = `zip:${str}`;
} break;
}
} else {
str = str.replace(/^\/?/, process.platform === `win32` ? `` : `/`);
}
}
return str;
}
function fromEditorPath(str) {
switch (hostInfo) {
case `coc-nvim`: {
str = str.replace(/\.zip::/, `.zip/`);
// The path for coc-nvim is in format of /<pwd>/zipfile:/<pwd>/.yarn/...
// So in order to convert it back, we use .* to match all the thing
// before `zipfile:`
return process.platform === `win32`
? str.replace(/^.*zipfile:\//, ``)
: str.replace(/^.*zipfile:/, ``);
} break;
case `neovim`: {
str = str.replace(/\.zip::/, `.zip/`);
// The path for neovim is in format of zipfile:///<pwd>/.yarn/...
return str.replace(/^zipfile:\/\//, ``);
} break;
case `vscode`:
default: {
return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`)
} break;
}
}
// Force enable 'allowLocalPluginLoads'
// TypeScript tries to resolve plugins using a path relative to itself
// which doesn't work when using the global cache
// https://github.com/microsoft/TypeScript/blob/1b57a0395e0bff191581c9606aab92832001de62/src/server/project.ts#L2238
// VSCode doesn't want to enable 'allowLocalPluginLoads' due to security concerns but
// TypeScript already does local loads and if this code is running the user trusts the workspace
// https://github.com/microsoft/vscode/issues/45856
const ConfiguredProject = tsserver.server.ConfiguredProject;
const {enablePluginsWithOptions: originalEnablePluginsWithOptions} = ConfiguredProject.prototype;
ConfiguredProject.prototype.enablePluginsWithOptions = function() {
this.projectService.allowLocalPluginLoads = true;
return originalEnablePluginsWithOptions.apply(this, arguments);
};
// And here is the point where we hijack the VSCode <-> TS communications
// by adding ourselves in the middle. We locate everything that looks
// like an absolute path of ours and normalize it.
const Session = tsserver.server.Session;
const {onMessage: originalOnMessage, send: originalSend} = Session.prototype;
let hostInfo = `unknown`;
Object.assign(Session.prototype, {
onMessage(/** @type {string | object} */ message) {
const isStringMessage = typeof message === 'string';
const parsedMessage = isStringMessage ? JSON.parse(message) : message;
if (
parsedMessage != null &&
typeof parsedMessage === `object` &&
parsedMessage.arguments &&
typeof parsedMessage.arguments.hostInfo === `string`
) {
hostInfo = parsedMessage.arguments.hostInfo;
if (hostInfo === `vscode` && process.env.VSCODE_IPC_HOOK) {
const [, major, minor] = (process.env.VSCODE_IPC_HOOK.match(
// The RegExp from https://semver.org/ but without the caret at the start
/(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/
) ?? []).map(Number)
if (major === 1) {
if (minor < 61) {
hostInfo += ` <1.61`;
} else if (minor < 66) {
hostInfo += ` <1.66`;
} else if (minor < 68) {
hostInfo += ` <1.68`;
}
}
}
}
const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => {
return typeof value === 'string' ? fromEditorPath(value) : value;
});
return originalOnMessage.call(
this,
isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON)
);
},
send(/** @type {any} */ msg) {
return originalSend.call(this, JSON.parse(JSON.stringify(msg, (key, value) => {
return typeof value === `string` ? toEditorPath(value) : value;
})));
}
});
return tsserver;
};
const [major, minor] = absRequire(`typescript/package.json`).version.split(`.`, 2).map(value => parseInt(value, 10));
// In TypeScript@>=5.5 the tsserver uses the public TypeScript API so that needs to be patched as well.
// Ref https://github.com/microsoft/TypeScript/pull/55326
if (major > 5 || (major === 5 && minor >= 5)) {
moduleWrapper(absRequire(`typescript`));
}
// Defer to the real typescript/lib/tsserverlibrary.js your application uses
module.exports = moduleWrapper(absRequire(`typescript/lib/tsserverlibrary.js`));

32
.yarn/sdks/typescript/lib/typescript.js vendored Normal file
View File

@ -0,0 +1,32 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, register} = require(`module`);
const {resolve} = require(`path`);
const {pathToFileURL} = require(`url`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`);
const absRequire = createRequire(absPnpApiPath);
const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript
require(absPnpApiPath).setup();
if (isPnpLoaderEnabled && register) {
register(pathToFileURL(absPnpLoaderPath));
}
}
}
const wrapWithUserWrapper = existsSync(absUserWrapperPath)
? exports => absRequire(absUserWrapperPath)(exports)
: exports => exports;
// Defer to the real typescript your application uses
module.exports = wrapWithUserWrapper(absRequire(`typescript`));

10
.yarn/sdks/typescript/package.json vendored Normal file
View File

@ -0,0 +1,10 @@
{
"name": "typescript",
"version": "5.6.3-sdk",
"main": "./lib/typescript.js",
"type": "commonjs",
"bin": {
"tsc": "./bin/tsc",
"tsserver": "./bin/tsserver"
}
}

10
.yarnrc.yml Normal file
View File

@ -0,0 +1,10 @@
enableGlobalCache: false
nodeLinker: node-modules
npmScopes:
jig-software:
npmAlwaysAuth: false
npmAuthToken: "${JIG_SOFTWARE_GITEA_DEPLOYMENT_TOKEN:-NONE}"
npmPublishRegistry: "https://gitea.jig.com.hk/api/packages/jig-software/npm/"
npmRegistryServer: "https://gitea.jig.com.hk/api/packages/jig-software/npm/"

19
Dockerfile Normal file
View File

@ -0,0 +1,19 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY . .
RUN corepack enable
RUN --mount=type=secret,id=JIG_SOFTWARE_GITEA_DEPLOYMENT_TOKEN,env=JIG_SOFTWARE_GITEA_DEPLOYMENT_TOKEN yarn install
RUN yarn build
FROM node:22-alpine
WORKDIR /opt/app
RUN yarn global add serve
COPY --from=build /app/dist/ .
EXPOSE 80
CMD ["serve", "-s", ".", "-l", "tcp://0.0.0.0:3000"]

93
README.md Normal file
View File

@ -0,0 +1,93 @@
# SPVTMS Client
## Getting started
To make it easy for you to get started with GitLab, here's a list of recommended next steps.
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
## Add your files
- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files
- [ ] [Add files using the command line](https://docs.gitlab.com/ee/gitlab-basics/add-file.html#add-a-file-using-the-command-line) or push an existing Git repository with the following command:
```
cd existing_repo
git remote add origin https://gitlab.com/jig-software-team/spvtms/spvtms-client.git
git branch -M main
git push -uf origin main
```
## Integrate with your tools
- [ ] [Set up project integrations](https://gitlab.com/jig-software-team/spvtms/spvtms-client/-/settings/integrations)
## Collaborate with your team
- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/)
- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
- [ ] [Set auto-merge](https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html)
## Test and Deploy
Use the built-in continuous integration in GitLab.
- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/index.html)
- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/)
- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/)
- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
***
# Editing this README
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template.
## Suggestions for a good README
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
## Name
Choose a self-explaining name for your project.
## Description
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
## Badges
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
## Visuals
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
## Installation
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
## Usage
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
## Support
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
## Roadmap
If you have ideas for releases in the future, it is a good idea to list them in the README.
## Contributing
State if you are open to contributions and what your requirements are for accepting them.
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
## Authors and acknowledgment
Show your appreciation to those who have contributed to the project.
## License
For open source projects, say how it is licensed.
## Project status
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.

20
components.json Normal file
View File

@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"tailwind": {
"config": "tailwind.config.js",
"css": "src/global.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"rsc": false,
"tsx": true,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"hooks": "@/hooks",
"lib": "@/lib"
}
}

89
eslint.config.js Normal file
View File

@ -0,0 +1,89 @@
import pluginJs from '@eslint/js';
import pluginUnocss from '@unocss/eslint-config/flat';
import pluginPrettier from 'eslint-config-prettier';
import pluginImport from 'eslint-plugin-import';
import pluginReact from 'eslint-plugin-react';
import pluginReactCompiler from 'eslint-plugin-react-compiler';
import pluginReactHooks from 'eslint-plugin-react-hooks';
import pluginReactRefresh from 'eslint-plugin-react-refresh';
import globals from 'globals';
import { configs as tseslintConfigs } from 'typescript-eslint';
/** @type {import('eslint').Linter.Config[]} */
export default [
{ ignores: ['dist', 'node-modules', '.yarn', '.pnp.*'] },
{ files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'] },
{ languageOptions: { globals: globals.browser } },
{
...pluginReact.configs.flat.recommended,
settings: {
react: {
version: 'detect',
},
},
rules: {
'react/prop-types': ['off'],
},
},
pluginReact.configs.flat['jsx-runtime'],
pluginJs.configs.recommended,
...tseslintConfigs.recommended,
{
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': pluginReactHooks,
'react-refresh': pluginReactRefresh,
},
rules: {
...pluginReactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
pluginImport.flatConfigs.recommended,
pluginImport.flatConfigs.typescript,
pluginImport.flatConfigs.react,
{
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
settings: {
'import/external-module-folders': ['node_modules', '.yarn'],
'import/resolver': {
// You will also need to install and configure the TypeScript resolver
// See also https://github.com/import-js/eslint-import-resolver-typescript#configuration
typescript: true,
node: true,
},
},
rules: {
'import/no-cycle': 'warn',
'import/order': [
'error',
{
named: true,
alphabetize: {
order: 'asc',
caseInsensitive: true,
},
},
],
},
},
{
plugins: {
'react-compiler': pluginReactCompiler,
},
rules: {
'react-compiler/react-compiler': 'error',
},
},
pluginPrettier,
pluginUnocss,
];

13
index.html Normal file
View 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>SORI</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

130
package.json Normal file
View File

@ -0,0 +1,130 @@
{
"name": "spvtms-client",
"private": true,
"version": "0.0.8",
"productVersion": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"commit": "cz"
},
"dependencies": {
"@ark-ui/react": "^4.9.2",
"@fullcalendar/core": "^6.1.19",
"@fullcalendar/daygrid": "^6.1.19",
"@fullcalendar/interaction": "^6.1.19",
"@fullcalendar/react": "^6.1.19",
"@hookform/resolvers": "^5.0.1",
"@iconify-json/ant-design": "^1.2.5",
"@iconify-json/bi": "^1.2.4",
"@iconify-json/fluent": "^1.2.8",
"@iconify-json/material-symbols": "^1.2.12",
"@iconify-json/mdi": "^1.2.2",
"@iconify-json/ri": "^1.2.5",
"@iconify-json/tabler": "^1.2.18",
"@jig-software/react-ol": "^10.0.0-beta.5",
"@jig-software/trest-client": "^2.13.1",
"@jig-software/trest-core": "^2.13.1",
"@jig-software/zod-bson": "^1.1.2",
"@mantine/core": "^7.13.4",
"@mantine/dates": "^8.1.2",
"@mantine/hooks": "^8.1.2",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-context-menu": "^2.2.15",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-hover-card": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-menubar": "^1.1.15",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.7",
"bson": "^6.10.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.13",
"html5-qrcode": "^2.3.8",
"immer": "^10.1.1",
"jotai": "^2.12.5",
"jotai-immer": "^0.4.1",
"konva": "^9.3.18",
"next-themes": "^0.4.6",
"ol": "^10.3.1",
"ol-ext": "^4.0.17",
"ol-mapbox-style": "^12.4.0",
"react": "^19.1.0",
"react-big-calendar": "^1.16.3",
"react-day-picker": "^9.7.0",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^19.1.0",
"react-hook-form": "^7.53.1",
"react-hotkeys-hook": "^4.6.1",
"react-konva": "^19.0.2",
"react-konva-utils": "^1.0.7",
"react-router": "^7.6.0",
"sonner": "^1.7.0",
"tailwind-merge": "^3.3.0",
"use-image": "^1.1.1",
"uuid": "^11.0.5",
"vaul": "^1.1.2",
"zod": "^3.25.53",
"zustand": "^5.0.1"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@nabla/vite-plugin-eslint": "^2.0.5",
"@types/ol-ext": "npm:@siedlerchr/types-ol-ext@3.3.0",
"@types/react": "^19.1.5",
"@types/react-big-calendar": "^1.16.1",
"@types/react-dom": "^19.1.5",
"@typescript-eslint/parser": "^8.19.1",
"@unocss/eslint-config": "^66.1.2",
"@unocss/preset-icons": "^65.4.2",
"@vitejs/plugin-react": "^4.3.3",
"babel-plugin-react-compiler": "^19.1.0-rc.2",
"commitizen": "^4.3.1",
"cz-emoji": "^1.3.2-canary.2",
"eslint": "^9.13.0",
"eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-typescript": "^3.7.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-compiler": "19.0.0-beta-201e55d-20241215",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^16.1.0",
"prettier": "^3.5.3",
"typescript": "^5.8.3",
"typescript-eslint": "^8.32.1",
"unocss": "^66.1.2",
"unocss-preset-animations": "^1.2.1",
"unocss-preset-shadcn": "^0.5.0",
"vite": "^6.0.7",
"vite-plugin-mkcert": "^1.17.8",
"vite-plugin-pwa": "^1.0.0",
"vite-plugin-top-level-await": "^1.5.0",
"vite-plugin-tsconfig-paths": "^1.4.1"
},
"config": {
"commitizen": {
"path": "cz-emoji"
}
},
"packageManager": "yarn@4.9.1"
}

1
public/vite.svg Normal file
View 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

37
src/App.tsx Normal file
View File

@ -0,0 +1,37 @@
// TODO: Remove this file
import { FC } from 'react';
import { Link, Outlet } from 'react-router';
import 'ol/ol.css';
export const App: FC = () => {
return (
<div className="h-screen w-screen flex items-stretch">
<div className="flex flex-col items-stretch gap-1 bg-white p-2 dark:bg-slate-800">
<NavLink path="basic" title="Basic" />
<NavLink path="draw" title="Draw" />
<NavLink path="image/arcgis" title="Image Source (ArcGIS Rest)" />
</div>
<div className="flex-1">
<Outlet />
</div>
</div>
);
};
interface NavLinkProps {
path: string;
title: string;
}
const NavLink: FC<NavLinkProps> = ({ path, title }) => {
return (
<Link
className="rounded-lg bg-white p-2 font-medium dark:bg-slate-700 hover:brightness-90 dark:hover:brightness-110"
to={path}
>
{title}
</Link>
);
};
export default App;

View File

@ -0,0 +1,152 @@
import { FC, useEffect, useMemo, useState } from 'react';
import {
Attendance,
Holiday,
Leave,
soriAPIClient,
Staff,
} from '../../lib/sori';
export const AttendanceScene: FC = () => {
const [staffs, setStaffs] = useState<Staff[]>([]);
const [attendances, setAttendances] = useState<Attendance[]>([]);
const [holidays, setHolidays] = useState<Holiday[]>([]);
const [leaves, setLeaves] = useState<Leave[]>([]);
const loadData = async () => {
const staffsResult = await soriAPIClient.staffs.actions.getStaffs();
const attendancesResult =
await soriAPIClient.attendances.actions.getAttendance();
const holidaysResult = await soriAPIClient.holidays.actions.getHolidays();
const leavesResult = await soriAPIClient.leaves.actions.getLeaves();
setStaffs(staffsResult.unwrap());
setAttendances(attendancesResult.unwrap());
setHolidays(holidaysResult.unwrap());
setLeaves(leavesResult.unwrap());
};
useEffect(() => {
loadData();
}, []);
const dateArray = useMemo(() => {
const dayInMs = 1000 * 60 * 60 * 24;
const fromDate = new Date().getTime();
const toDate = new Date('Jan 1 2025').getTime();
const dates: Date[] = [];
for (let d = fromDate; d >= toDate; d -= dayInMs) {
dates.push(new Date(d));
}
return dates;
}, []);
return (
<>
<h1 className="m-2 text-xl">Attendance</h1>
<div className="m-3 w-[900px] overflow-x-auto border">
<table className="min-w-max border-collapse border">
<thead>
<tr className="">
<th className="sticky left-0 z-20 border bg-neutral-4 p-2">
Staff
</th>
{dateArray.map((d) => (
<th className="w-[100px] p-2">
{d.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
})}
</th>
))}
</tr>
</thead>
<tbody>
{staffs.map((staff) => (
<AttendanceTr
staff={staff}
attendances={attendances}
dateArray={dateArray}
holidays={holidays}
leaves={leaves}
/>
))}
</tbody>
</table>
</div>
</>
);
};
const AttendanceTr: FC<{
staff: Staff;
attendances: Attendance[];
dateArray: Date[];
holidays: Holiday[];
leaves: Leave[];
}> = ({ staff, attendances, dateArray, holidays, leaves }) => {
const staffAttendance = useMemo(() => {
const map = new Set(
attendances
.filter((a) => a.staff.toString() === staff._id.toString())
.map((a) => new Date(a.workDay.date).toDateString()),
);
return map;
}, [attendances, staff._id]);
const holidayList = useMemo(() => {
const map = new Set(
holidays.map((h) => new Date(h.startdate).toDateString()),
);
return map;
}, [holidays]);
const leaveList = useMemo(() => {
const map = new Map<string, { type: string; workDayType: string }>();
leaves
.filter(
(l) =>
l.staff.toString() === staff._id.toString() &&
l.status === 'approved',
)
.map((l) => {
const date = new Date(l.workDay.date).toDateString();
map.set(date, { type: l.type, workDayType: l.workDay.type });
});
return map;
}, [leaves, staff._id]);
const renderCell = (d: Date) => {
const leave = leaveList.get(d.toDateString());
if (leave)
return {
text: `${leave.type} (${leave.workDayType})`,
className: 'bg-orange-3',
};
if (holidayList.has(d.toDateString())) {
return { text: '🎉', className: 'bg-red-3' };
}
if (staffAttendance.has(d.toDateString())) {
return { text: '✅', className: 'bg-green-3' };
}
if (d.getDay() === 0 || d.getDay() === 6)
return { text: '', className: 'bg-green-1' };
return { text: '❌', className: 'bg-red-1' };
};
return (
<tr>
<td className="sticky left-0 z-10 border bg-neutral-4 p-2 text-center">
{staff.nameEN}
</td>
{dateArray.map((d) => {
const { text, className } = renderCell(d);
return (
<td className={`z-5 p-2 text-center border ${className}`}>{text}</td>
);
})}
</tr>
);
};

637
src/app/Editor/Editor.tsx Normal file
View File

@ -0,0 +1,637 @@
import {
DrawInteraction,
Map as OlMap,
OSMSource,
SelectInteraction,
TileLayer,
TranslateInteraction,
VectorLayer,
VectorSource,
View,
} from '@jig-software/react-ol';
import { ScrollArea } from '@mantine/core';
import { Feature, Map } from 'ol';
import { Coordinate } from 'ol/coordinate';
import { altKeyOnly } from 'ol/events/condition';
import { Point, Polygon } from 'ol/geom';
import { Draw as OlDraw, Select } from 'ol/interaction';
import { Vector } from 'ol/source';
import { Fill, Icon, Stroke, Style } from 'ol/style';
import { FC, useEffect, useRef, useState } from 'react';
import plan1 from '../../assets/plan-1.png';
import plan2 from '../../assets/plan-2.png';
import plan3 from '../../assets/plan-3.png';
import plan4 from '../../assets/plan-4.png';
import { Group, Stack } from '../../components';
import { Confirm } from '../../components/Confirm';
import { Dialog } from '../../components/Dialog';
import { Job } from '../../lib/sori';
import { twx } from '../../utils';
import { calculateLongestEdge } from '../../utils/calculateLongestEdge';
import { degreesToRadians } from '../../utils/degreesToRadians';
import { radiansToDegrees } from '../../utils/radiansToDegrees';
import { JobForm } from '../JobRecord/JobForm';
import { SignLibrary } from './SignLibrary';
const style2 = new Style({
stroke: new Stroke({
color: 'red', // Color for the progress segment
width: 1,
}),
fill: new Fill({
color: '#55555555',
}),
});
enum Action {
Rectangle = 'rectangle',
Square = 'square',
Polygon = 'polygon',
Move = 'move',
Submit = 'submit',
Select = 'select',
}
enum Mode {
GenMarkingMode = 'genMarkingMode',
EditMarkingMode = 'editMarkingMode',
SignMode = 'signMode',
}
type EditorProps = {
job: Job;
};
export const Editor: FC<EditorProps> = () => {
const [source, setSource] = useState<Vector | null>(null);
const drawRef = useRef<OlDraw>(null);
const mapRef = useRef<Map>(null);
const selectRef = useRef<Select>(null);
const [preview, setPreview] = useState(false);
const [action, setAction] = useState<Action | null>(null);
const [mode, setMode] = useState<Mode | null>(null);
const [streetView, setStreetView] = useState(false);
const [currentRotation, setCurrentRotation] = useState(0);
const [projectRotation, setProjectRotation] = useState(0);
const [confirmOpen, setConfirmOpen] = useState(false);
const [selectedSign, setSelectedSign] = useState('');
const [saveSuccess] = useState(false);
const centerCoordinates = [12702704.266171953, 2552576.3410693165]; // 22.352743, 114.107071
const [open, setOpen] = useState(false);
useEffect(() => {
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault();
return '';
};
console.log('saveSuccess', saveSuccess);
if (saveSuccess) {
window.removeEventListener('beforeunload', handleBeforeUnload);
} else {
window.addEventListener('beforeunload', handleBeforeUnload);
}
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [saveSuccess]);
useEffect(() => {
mapRef.current?.getView()?.on('change:rotation', () => {
const rotationInRadians = mapRef.current?.getView()?.getRotation() ?? 0;
const rotationInDegrees = radiansToDegrees(rotationInRadians);
setCurrentRotation(Math.round((rotationInDegrees % 360) + 360) % 360);
});
}, [currentRotation, action]);
useEffect(() => {
const test = async () => {
const geojson = await fetch(
'/arcgisData/DTAD_YL_BOX_LINE_FeaturesToJ.geojson',
);
const data = await geojson.json();
console.log('data', data);
};
test();
}, [mode]);
if (source?.getFeatures().length) {
console.log('source', source?.getFeatures()[0].getGeometry());
}
return (
<>
{/* Job Form */}
<Dialog open={open} onRequestClose={() => setOpen(false)}>
<JobForm onSubmit={() => {}} />
</Dialog>
{/* Confirm */}
<Confirm
open={confirmOpen}
title="Are you sure to generate?"
message="This action will generate the marking & lock rotation"
onConfirm={() => {
setConfirmOpen(false);
setProjectRotation(currentRotation);
setMode(null);
}}
onCancel={() => {
setConfirmOpen(false);
}}
/>
<div className="h-full w-full flex flex-col bg-zinc-100 text-black">
<Group className="h-full w-full">
<Stack className="h-full w-full">
{/* ToolBar */}
<Stack className="relative h-full w-full">
<div className="h-36 w-full overflow-hidden bg-white">
<ToolBar
action={action}
setAction={(action) => {
setAction((act) => (act === action ? null : action));
}}
onClickPreview={() => setPreview((prev) => !prev)}
preview={preview}
mode={mode}
setMode={setMode}
onClear={() => {
source?.removeFeatures(source.getFeatures());
}}
onDelete={() => {
selectRef.current?.getFeatures().forEach((feature) => {
source?.removeFeature(feature);
});
}}
onGenerate={() => {
setConfirmOpen(true);
}}
streetView={streetView}
onStreetView={() => setStreetView((prev) => !prev)}
currentRotation={currentRotation}
setCurrentRotation={setCurrentRotation}
projectRotation={projectRotation}
mapRef={mapRef}
/>
</div>
<Stack className="relative h-full w-full">
<div
className={twx(
'absolute top-0 left-0 rounded-md shadow-md z-10 w-100 h-full p-0.5',
{
block: preview,
hidden: !preview,
},
)}
>
<div className="relative h-full w-full flex flex-col gap-2 bg-white p-2 pt-8">
<button
className="absolute right-1 top-1 rounded-full bg-white text-black/60"
onClick={() => {
setPreview((prev) => !prev);
}}
>
<div className="i-ant-design-close-circle-outlined h-6 w-6"></div>
</button>
<ScrollArea className="h-full text-center">
<Stack className="gap-4">
<div className="relative h-full w-full">
<img src={plan1} className="h-full w-full" />
<p className="">Plan 1</p>
</div>
<div className="relative h-full w-full">
<img src={plan2} className="h-full w-full" />
<p className="">Plan 2</p>
</div>
<div className="relative h-full w-full">
<img src={plan3} className="h-full w-full" />
<p className="">Plan 3</p>
</div>
<div className="relative h-full w-full">
<img src={plan4} className="h-full w-full" />
<p className="">Plan 4</p>
</div>
</Stack>
</ScrollArea>
</div>
</div>
<div
className={twx(
'absolute top-0 left-0 rounded-md shadow-md z-10 h-full p-0.5',
{
block: mode === Mode.SignMode,
hidden: mode !== Mode.SignMode,
},
)}
>
<div className="relative h-full w-full flex flex-col gap-2 bg-white p-2">
<button
className="absolute right-1 top-1 rounded-full bg-white text-black/60"
onClick={() => {
setMode(null);
}}
>
<div className="i-ant-design-close-circle-outlined h-6 w-6" />
</button>
<SignLibrary
onSelect={(sign) => {
setSelectedSign(sign.id);
}}
selectedSign={selectedSign}
/>
</div>
</div>
<div
className={twx(
'absolute right-0 top-0 rounded-md shadow-md z-10 h-full p-0.5 w-200',
{
block: streetView,
hidden: !streetView,
},
)}
>
<div className="relative h-full w-full flex flex-col gap-2 bg-white p-2">
<button
className="absolute right-1 top-1 rounded-full bg-white text-black/60"
onClick={() => {
setStreetView((prev) => !prev);
}}
>
<div className="i-ant-design-close-circle-outlined h-6 w-6" />
</button>
Street View
</div>
</div>
<OlMap
ref={mapRef}
defaultControls={{}}
defaultInteractions={{ altShiftDragRotate: true }}
className="h-full w-full"
>
<View
initial={{
zoom: 17,
center: centerCoordinates,
}}
/>
<TileLayer>
<OSMSource />
</TileLayer>
<VectorLayer style={style2}>
<VectorSource ref={setSource} />
</VectorLayer>
{action === Action.Polygon && (
<DrawInteraction
ref={drawRef}
freehandCondition={altKeyOnly}
type={'Polygon'}
style={{
'circle-radius': 5,
'circle-fill-color': 'blue',
'stroke-color': 'blue',
'stroke-width': 2,
}}
onDrawEnd={(event) => {
const geometry = event?.feature?.getGeometry();
if (!geometry || !(geometry instanceof Polygon)) return;
const coordinates: Coordinate[][] =
geometry.getCoordinates() as unknown as Coordinate[][];
const { longestEdgeAngle } =
calculateLongestEdge(coordinates);
const progressSegment = new Polygon(coordinates);
const progressFeature = new Feature(progressSegment);
progressFeature.setStyle(
new Style({
stroke: new Stroke({
color: 'red',
width: 4,
}),
fill: new Fill({
color: '#55555555',
}),
}),
);
source?.removeFeature(source.getFeatures()[0]);
source!.addFeature(progressFeature);
mapRef.current
?.getView()
?.setRotation(Math.abs(longestEdgeAngle));
}}
/>
)}
{action === Action.Rectangle && (
<DrawInteraction
ref={drawRef}
freehandCondition={altKeyOnly}
type={'Point'}
style={{
'circle-radius': 5,
'circle-fill-color': 'blue',
'stroke-color': 'blue',
'stroke-width': 2,
}}
onDrawEnd={(event) => {
const geometry = event?.feature?.getGeometry();
const [x, y] = geometry?.getExtent() as [
number,
number,
];
const coordinates: Coordinate[][] = [
[
[x, y],
[x + 200, y],
[x + 200, y - 100],
[x, y - 100],
[x, y],
],
] as Coordinate[][];
// 计算旋转后的坐标
const rotatedCoordinates = coordinates[0].map(
([coordX, coordY]) => {
const angle = degreesToRadians(currentRotation); // 使用当前旋转角度
const cos = Math.cos(angle);
const sin = Math.sin(angle);
return [
cos * (coordX - x) - sin * (coordY - y) + x,
sin * (coordX - x) + cos * (coordY - y) + y,
];
},
);
const progressSegment = new Polygon([
rotatedCoordinates,
]);
const progressFeature = new Feature(progressSegment);
progressFeature.setStyle(
new Style({
stroke: new Stroke({
color: '#3366ff',
width: 2,
}),
fill: new Fill({
color: '#3366ff33',
}),
}),
);
source!.addFeature(progressFeature);
}}
/>
)}
{mode === Mode.SignMode && (
<DrawInteraction
ref={drawRef}
freehandCondition={altKeyOnly}
type={'Point'}
style={{
'circle-radius': 5,
'circle-fill-color': 'blue',
'stroke-color': 'blue',
'stroke-width': 2,
}}
onDrawEnd={(event) => {
const geometry = event?.feature?.getGeometry();
const [x, y] = geometry?.getExtent() as [
number,
number,
];
const progressSegment = new Point([x, y]);
const progressFeature = new Feature(progressSegment);
progressFeature.setStyle(
new Style({
image: new Icon({
src: 'assets/shadow-vehicle.png',
color: 'red',
scale: 0.1,
}),
}),
);
source!.addFeature(progressFeature);
}}
/>
)}
{action === Action.Select && (
<SelectInteraction
ref={selectRef}
removeCondition={altKeyOnly}
/>
)}
{action === Action.Move && <TranslateInteraction />}
</OlMap>
</Stack>
</Stack>
</Stack>
</Group>
</div>
</>
);
};
const ToolBar: FC<{
action: Action | null;
setAction: (action: Action | null) => void;
onClickPreview: () => void;
preview: boolean;
mode: Mode | null;
setMode: (mode: Mode | null) => void;
onClear: () => void;
onGenerate: () => void;
onDelete: () => void;
streetView: boolean;
onStreetView: () => void;
currentRotation: number;
setCurrentRotation: (rotation: number) => void;
projectRotation: number;
mapRef: React.RefObject<Map | null>;
}> = ({
action,
setAction,
onClickPreview,
preview,
mode,
setMode,
onClear,
onGenerate,
onDelete,
streetView,
onStreetView,
}) => {
return (
<div className="h-full flex flex-row gap-2 px-4 py-2.5">
<ActionButton
label="Move"
onClick={() => setAction(Action.Move)}
active={action === Action.Move}
icon="i-custom-draw "
/>
<ActionButton
label="Select"
onClick={() => setAction(Action.Select)}
active={action === Action.Select}
icon="i-custom-select "
/>
<ActionButton
label="Preview"
onClick={onClickPreview}
active={preview}
icon="i-custom-preview "
/>
<ActionButton
label="Street View"
onClick={onStreetView}
active={streetView}
icon="i-custom-street-view "
/>
<div className="m-6 mx-4 border-x-1 border-gray-200" />
<ActionButton
label="Generate Marking"
onClick={() => {
setMode(mode === Mode.GenMarkingMode ? null : Mode.GenMarkingMode);
if (mode === Mode.GenMarkingMode) setAction(null);
}}
active={mode === Mode.GenMarkingMode}
icon="i-custom-generate "
/>
<ActionButton
label="Edit Marking"
onClick={() => {
setMode(mode === Mode.EditMarkingMode ? null : Mode.EditMarkingMode);
if (mode === Mode.EditMarkingMode) setAction(null);
}}
active={mode === Mode.EditMarkingMode}
icon="i-custom-edit "
/>
<ActionButton
label="Sign"
onClick={() => {
setMode(mode === Mode.SignMode ? null : Mode.SignMode);
if (mode === Mode.SignMode) setAction(null);
}}
active={mode === Mode.SignMode}
icon="i-custom-sign-library "
/>
<div className="m-6 mx-4 border-x-1 border-gray-200" />
<Group
className="items-center gap-2.5"
hidden={mode !== Mode.GenMarkingMode}
>
<ActionButton
label="Area"
onClick={() => setAction(Action.Polygon)}
active={action === Action.Polygon}
icon="i-custom-area "
/>
<ActionButton
label="Rectangle"
onClick={() => setAction(Action.Rectangle)}
active={action === Action.Rectangle}
icon="i-custom-rectangle "
/>
</Group>
<div
className="m-6 mx-4 border-x-1 border-gray-200"
hidden={mode !== Mode.GenMarkingMode && mode !== Mode.SignMode}
/>
<Group
className="items-center gap-2.5"
hidden={mode !== Mode.GenMarkingMode}
>
<ActionButton
label="Generate"
onClick={onGenerate}
icon="i-ri-ai-generate "
/>
<ActionButton
label="Delete"
onClick={onDelete}
icon="i-ant-design-delete-filled "
/>
<ActionButton
label="Clear"
onClick={onClear}
icon="i-ant-design-clear-outlined "
/>
</Group>
<Group className="items-center gap-2.5" hidden={mode !== Mode.SignMode}>
<ActionButton
label="Delete"
onClick={onDelete}
icon="i-ant-design-delete-filled "
/>
<ActionButton
label="Clear"
onClick={onClear}
icon="i-ant-design-clear-outlined "
/>
</Group>
{/* <div className="flex flex-col border border-black-500 p-2 ml-auto">
<Stack>
<Group>
Current Rotation:
<input
type="number"
className="w-12 bg-white text-black"
value={currentRotation}
max={360}
min={0}
onChange={(e) => {
setCurrentRotation(Number(e.target.value));
mapRef.current
?.getView()
?.setRotation(degreesToRadians(Number(e.target.value)));
}}
/>
</Group>
<Group>
Project Rotation:
{projectRotation}
</Group>
<Button
className="bg-white text-black"
onClick={() => {
mapRef.current
?.getView()
?.setRotation(degreesToRadians(projectRotation));
}}
>
Back to Project Rotation
</Button>
</Stack>
</div> */}
</div>
);
};
const ActionButton: FC<{
onClick: () => void;
active?: boolean;
className?: string;
label?: string;
icon?: string;
iconClass?: string;
src?: string;
}> = ({ onClick, className, active, label, icon, iconClass, src }) => {
return (
<button
className={twx(
' text-white p-2 text-sm gap-1 flex flex-col items-center px-4 rounded-md hover:bg-gray-100 max-w-20',
active ? 'bg-gray-200 text-black/90' : 'bg-white text-black/75',
className,
)}
onClick={onClick}
>
{icon && <div className={twx([icon, 'w-12 h-12 shrink-0', iconClass])} />}
{src && <img src={src} className="h-12 w-12" />}
<div className="flex basis-10 items-center justify-center text-xs">
{label}
</div>
</button>
);
};

View File

@ -0,0 +1,90 @@
// import { Tabs } from '@ark-ui/react';
// import { MantineProvider } from '@mantine/core';
// import { FC, useState } from 'react';
// import { useParams } from 'react-router';
// import { Button, Group, Stack } from '../../components';
// import { Dialog } from '../../components/Dialog';
// import { JobInfo } from '../../components/JobInfo';
// import TabList from '../../components/TabList';
// import { jobs } from '../../demoData/job';
// import { Job } from '../../lib/spvtms';
// import { JobForm } from '../JobRecord/JobForm';
// import { Editor } from './Editor';
// export const EditorScene: FC = () => {
// const { jobId } = useParams();
// const [job] = useState<Job | null>(
// jobs.find((job) => job.jobId === jobId) ?? null,
// );
// const [saving, setSaving] = useState(false);
// const [saveSuccess, setSaveSuccess] = useState(false);
// console.debug('saveSuccess', saveSuccess);
// const [open, setOpen] = useState(false);
// return (
// <MantineProvider>
// <>
// {/* Job Form */}
// <Dialog open={open} onRequestClose={() => setOpen(false)}>
// <JobForm onSubmit={() => {}} />
// </Dialog>
// <div className="h-screen w-screen flex flex-col bg-zinc-100 p-2">
// <Stack className="w-full">
// <Group className="h-14 w-full items-center gap-2 bg-white p-2">
// <p className="text-xl">Job ID:{job?.jobId ?? 'TBC'}</p>
// <Button
// className="ml-auto w-30 bg-green-6 bg-white p-2 py-1.5 text-black text-white"
// icon="i-ant-design-save-outlined"
// iconClass="text-6"
// onClick={() => {
// setSaveSuccess(true);
// setSaving(true);
// setTimeout(() => {
// setSaving(false);
// }, 1000);
// }}
// >
// {saving ? 'Saving...' : 'Save'}
// </Button>
// <Button
// className="w-30 bg-blue-5 bg-white p-2 py-1.5 text-black text-white"
// icon="i-ant-design-send-outlined"
// iconClass="text-6"
// onClick={() => {
// window.confirm('Are you sure to submit?');
// setSaveSuccess(true);
// window.close();
// }}
// >
// Submit
// </Button>
// </Group>
// </Stack>
// <Tabs.Root
// className="mt-2 h-full w-full flex flex-col"
// defaultValue="info"
// >
// <TabList
// tabs={[
// { label: 'Info', value: 'info' },
// { label: 'Editor', value: 'editor' },
// ]}
// />
// <Tabs.Content
// className="h-full w-full flex-1 bg-white"
// value="info"
// >
// {job && <JobInfo job={job} />}
// </Tabs.Content>
// <Tabs.Content
// className="h-full w-full flex-1 bg-white"
// value="editor"
// >
// {job && <Editor job={job} />}
// </Tabs.Content>
// </Tabs.Root>
// </div>
// </>
// </MantineProvider>
// );
// };

View File

@ -0,0 +1,52 @@
import { Tabs } from '@ark-ui/react';
import { FC } from 'react';
import { Group, Stack } from '../../components';
import { SearchInput } from '../../components/SearchInput';
import { singSet } from '../../dataSet/sing';
import { Sign } from '../../types/sign';
import { twx } from '../../utils';
type SignProps = {
onSelect: (sign: Sign) => void;
selectedSign: string;
};
export const SignLibrary: FC<SignProps> = ({ onSelect, selectedSign }) => {
return (
<Stack className="h-full w-100 bg-white p-2">
<p>Sign Library</p>
<Tabs.Root className="w-full" defaultValue={'spv'}>
<Tabs.List className="flex gap-6 border-b bg-white">
<Tabs.Trigger
className="aria-[selected=true]:border-b-blue aria-[selected=true]:text-blue border-b border-b-2 border-b-transparent bg-transparent p-1 pb-0.5"
value="spv"
>
Sign
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content className="relative overflow-hidden pt-2" value="spv">
<Stack className="gap-2">
<SearchInput placeholder="" onSearch={() => {}} />
<Stack>
{singSet.map((sign) => {
return (
<Group
className={twx(
'items-center cursor-pointer border',
selectedSign === sign.id && 'bg-gray-200',
)}
onClick={() => onSelect(sign)}
>
<img className={'w-16'} src={sign.signImage}></img>
<p>{sign.name}</p>
<div className="i-ant-design-right-outlined m-2 ml-auto" />
</Group>
);
})}
</Stack>
</Stack>
</Tabs.Content>
</Tabs.Root>
</Stack>
);
};

View File

@ -0,0 +1,62 @@
import { FC } from 'react';
import { useForm } from 'react-hook-form';
import { Approval, ApprovalStatus } from '../../types/approval';
type ApprovalFormProps = {
approval?: Approval;
handleSubmit: (data: FormInputs) => void;
};
type FormInputs = {
title: string;
department: string;
description: string;
status: ApprovalStatus;
};
export const ApprovalForm: FC<ApprovalFormProps> = ({
approval,
handleSubmit,
}) => {
const dialogForm = useForm<FormInputs>(
approval
? {
defaultValues: approval,
}
: {},
);
return (
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-4">
<form
className="flex flex-col"
onSubmit={dialogForm.handleSubmit(handleSubmit)}
>
<input
className="mb-2 w-full border rounded px-3 py-2"
placeholder="Title"
{...dialogForm.register('title', { required: true })}
/>
<input
placeholder="Department"
className="mb-2 w-full border rounded px-3 py-2"
{...dialogForm.register('department', { required: true })}
/>
<textarea
placeholder="Description"
className="mb-2 w-full border rounded px-3 py-2"
rows={3}
{...dialogForm.register('description', { required: true })}
/>
<button
type="submit"
className="hover:bg-blue-600 self-end rounded bg-blue-5 px-4 py-2 text-white"
>
Save
</button>
</form>
</div>
</div>
);
};

View File

@ -0,0 +1,80 @@
import { FC } from 'react';
import { Group } from '../../components';
import { ActionButton } from '../../components/ActionButton';
import { Approval } from '../../types/approval';
import { twx } from '../../utils/twx';
type ApprovalTableProps = {
approvals: Approval[];
onSelectApproval: (
approval: Approval,
action: 'edit' | 'delete' | 'files',
) => void;
allowDelete: boolean;
};
export const ApprovalTable: FC<ApprovalTableProps> = ({
approvals,
onSelectApproval,
allowDelete = true,
}) => {
return (
<>
<div className="overflow-x-auto">
<table className="min-w-full w-full border border-gray-300 bg-white">
<thead>
<tr className="bg-gray-200 text-gray-700">
<th className="border-b p-4 text-left">Title</th>
<th className="border-b p-4 text-left">Department</th>
<th className="border-b p-4 text-left">Description</th>
<th className="border-b p-4 text-left">Created At</th>
<th className="border-b p-4 text-left">Status</th>
<th className="border-b p-4 text-left">Action</th>
</tr>
</thead>
<tbody>
{approvals.map((approval) => (
<tr key={approval.approvalId}>
<td className="border-b p-4">{approval.title}</td>
<td className="border-b p-4">{approval.department}</td>
<td className="border-b p-4">{approval.description}</td>
<td className="border-b p-4">
{new Date(approval.createdAt).toLocaleString()}
</td>
<td className="border-b p-4">{approval.status}</td>
<td className="border-b p-4">
<Group className="gap-2">
<ActionButton
className={twx('text-blue-6')}
label="Info"
onClick={() => {
onSelectApproval(approval, 'edit');
}}
isVisible
/>
<ActionButton
className={twx('text-blue-6')}
label="Files"
onClick={() => {
onSelectApproval(approval, 'files');
}}
isVisible
/>
<ActionButton
className={twx('text-blue-6')}
label="Delete"
onClick={() => {
onSelectApproval(approval, 'delete');
}}
isVisible={allowDelete}
/>
</Group>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
);
};

View File

@ -0,0 +1,303 @@
import { FC, useState } from 'react';
import { useNavigate, useParams } from 'react-router';
import { Button, Group } from '../../components';
import Breadcrumb from '../../components/Breadcrumb';
import { Dialog } from '../../components/Dialog';
import { approvals as demoApprovals } from '../../demoData/approval';
import { Approval, ApprovalStatus } from '../../types/approval';
import { twx } from '../../utils';
import { ApprovalForm } from './ApprovalForm';
import { ApprovalTable } from './ApprovalTable';
import { UploadFiles } from './UploadFiles';
type FormInputs = {
title: string;
department: string;
description: string;
status: ApprovalStatus;
};
export const ExternalAuditScene: FC = () => {
const projectId = useParams().id!;
const jobId = useParams().jobId!;
const navigate = useNavigate();
const [isCollapsedFinalApproval, setIsCollapsedFinalApproval] =
useState(true);
const [approvals, setApprovals] = useState<Approval[]>(
demoApprovals.filter((approval) => approval.jobId === jobId),
);
const [openDialog, setOpenDialog] = useState<'info' | 'uploadFiles' | null>(
null,
);
const [selectedApproval, setSelectedApproval] = useState<Approval | null>(
null,
);
const canSubmitFinalApproval = approvals.every(
(approval) => approval.status === 'Approved',
);
const handleCreate = (data: FormInputs) => {
const approval: Approval = {
approvalId: Date.now().toString(),
jobId: jobId,
title: data.title,
description: data.description,
department: data.department,
status: 'Pending',
createdAt: new Date().toISOString(),
files: [],
};
setApprovals([...approvals, approval]);
setOpenDialog(null);
};
const handleEdit = (data: FormInputs) => {
if (selectedApproval) {
const updatedApprovals = approvals.map((approval) => {
if (approval.approvalId === selectedApproval.approvalId) {
return {
...approval,
title: data.title,
department: data.department,
description: data.description,
status: data.status,
};
}
return approval;
});
setApprovals(updatedApprovals);
const updatedApproval = updatedApprovals.find(
(approval) => approval.approvalId === selectedApproval.approvalId,
);
if (updatedApproval) {
setSelectedApproval(updatedApproval);
}
setOpenDialog(null);
}
};
const handleFileUpload = (files: FileList) => {
if (selectedApproval) {
const updatedApprovals = approvals.map((approval) => {
if (approval.approvalId === selectedApproval.approvalId) {
return {
...approval,
files: [...approval.files, ...Array.from(files)],
};
}
return approval;
});
setApprovals(updatedApprovals);
const updatedApproval = updatedApprovals.find(
(approval) => approval.approvalId === selectedApproval.approvalId,
);
if (updatedApproval) {
setSelectedApproval(updatedApproval);
}
}
};
const handleDeleteFile = (index: number) => {
if (selectedApproval) {
const updatedApprovals = approvals.map((approval) => {
if (approval.approvalId === selectedApproval.approvalId) {
return {
...approval,
files: approval.files.filter((_, i) => i !== index),
};
}
return approval;
});
setApprovals(updatedApprovals);
}
};
return (
<>
<Dialog
key={selectedApproval?.approvalId}
open={openDialog !== null}
onRequestClose={() => setOpenDialog(null)}
className="w-[600px] flex flex-col gap-4 rounded-lg bg-white p-6 dark:bg-gray-800"
>
<div className="text-2xl font-bold">Approval</div>
<div className="flex flex-col gap-2">
{openDialog === 'info' && (
<ApprovalForm
key={selectedApproval?.approvalId}
approval={selectedApproval ?? undefined}
handleSubmit={selectedApproval ? handleEdit : handleCreate}
/>
)}
<div className="flex flex-col gap-2 border-t pt-4">
<p className="font-bold">Status</p>
<select
className="mb-2 w-full border rounded px-3 py-2"
defaultValue={selectedApproval?.status}
onChange={(e) => {
if (selectedApproval) {
handleEdit({
...selectedApproval,
status: e.target.value as ApprovalStatus,
});
}
}}
>
<option value="Pending">Pending</option>
<option value="Approved">Approved</option>
<option value="Rejected">Rejected</option>
</select>
</div>
</div>
{openDialog === 'uploadFiles' && (
<UploadFiles
key={selectedApproval?.approvalId}
approval={selectedApproval ?? undefined}
handleFileUpload={handleFileUpload}
handleDeleteFile={handleDeleteFile}
/>
)}
</Dialog>
<Group className="w-full gap-2">
<Breadcrumb
crumbs={[
{
label: 'Project Record',
onClick: () => navigate('/project-record'),
},
{
label: projectId || '',
onClick: () => navigate(`/project-record/${projectId}`),
},
{ label: `${jobId} - External Audit` },
]}
/>
</Group>
<div className="mt-2 flex flex-col gap-2">
<div className="w-full flex flex-col gap-2 rounded-lg bg-white p-4 dark:bg-gray-800">
<div className="text-lg font-bold">Approval</div>
<div className="onClick={() => setIsFormVisible(!isFormVisible)} flex items-center justify-between">
<button
onClick={() => {
setSelectedApproval(null);
setOpenDialog('info');
}}
className="hover:bg-blue-600 rounded bg-blue-5 px-4 py-2 text-white"
>
Create Approval
</button>
</div>
{/* Approval List */}
<ApprovalTable
approvals={approvals}
onSelectApproval={(approval, action) => {
if (action === 'edit') {
setSelectedApproval(approval);
setOpenDialog('info');
} else if (action === 'files') {
setSelectedApproval(approval);
setOpenDialog('uploadFiles');
} else if (action === 'delete') {
setApprovals(
approvals.filter((a) => a.approvalId !== approval.approvalId),
);
}
}}
allowDelete={true}
/>
</div>
<div
className={twx(
'flex flex-col w-full gap-2 bg-white dark:bg-gray-800 p-4 rounded-lg overflow-hidden transition-all duration-300 h-112',
{ 'opacity-50': !canSubmitFinalApproval },
{ 'h-20': isCollapsedFinalApproval },
)}
>
<div
className="flex cursor-pointer"
onClick={() =>
setIsCollapsedFinalApproval(!isCollapsedFinalApproval)
}
>
<div className="flex flex-col gap-2">
<div className="text-lg font-bold">Final Approval</div>
<div className="text-red-500 text-sm text-black/60">
All approvals must be approved before submitting the final
approval
</div>
</div>
<div className="ml-auto flex items-center gap-2">
<div className="i-mdi-chevron-down text-2xl" />
</div>
</div>
<div className="flex flex-col gap-4 transition-opacity duration-300">
<FinalApprovalForm />
<Button className="hover:bg-blue-600 w-full rounded bg-blue-5 text-white">
Approve Complete
</Button>
</div>
</div>
</div>
</>
);
};
const FinalApprovalForm: FC = () => {
const [finalApproval] = useState<Approval | null>({
approvalId: 'finalApproval',
jobId: 'finalApproval',
title: 'Final Approval',
description: 'Final Approval',
department: 'Final Approval',
status: 'Pending',
createdAt: new Date().toISOString(),
files: [],
});
const [finalApprovalFile, setFinalApprovalFile] = useState<File | null>(null);
return (
<div className="flex flex-col gap-4">
<div className="flex gap-4">
<button
className="hover:bg-blue-600 rounded bg-blue-5 px-4 py-2 text-white"
onClick={() => {
console.log('下载模板');
}}
>
Download final approval form
</button>
<input
type="file"
onChange={(e) => {
if (e.target.files?.[0]) {
setFinalApprovalFile(e.target.files[0]);
}
}}
className="hover:file:bg-blue-600 file:mr-4 file:border-0 file:rounded file:bg-blue-5 file:px-4 file:py-2 file:text-white"
/>
</div>
{finalApprovalFile && (
<div className="flex items-center gap-2 rounded bg-gray-100 p-2 dark:bg-gray-700">
<span>{finalApprovalFile.name}</span>
<button
onClick={() => setFinalApprovalFile(null)}
className="text-red-500 hover:text-red-600"
>
Delete
</button>
</div>
)}
<div>
<ApprovalForm
key={finalApproval?.approvalId}
approval={finalApproval ?? undefined}
handleSubmit={() => {}}
/>
</div>
</div>
);
};

View File

@ -0,0 +1,95 @@
import { FC } from 'react';
import { Group } from '../../components/Group';
import { Approval } from '../../types/approval';
type UploadFilesProps = {
approval?: Approval;
handleFileUpload: (files: FileList) => void;
handleDeleteFile: (index: number) => void;
};
export const UploadFiles: FC<UploadFilesProps> = ({
approval,
handleFileUpload,
handleDeleteFile,
}) => {
return (
<div className="flex flex-col">
<div className="flex flex-col gap-4">
<p className="mb-4 text-2xl font-bold">Approval</p>
</div>
<div className="flex flex-col gap-4">
<div>
<Group className="flex items-center gap-2">
<p className="font-bold">Upload Files:</p>
<button
className="hover:bg-blue-600 rounded bg-blue-5 p-1 text-white"
onClick={() => {
const fileInput = document.getElementById(
'fileInput',
) as HTMLInputElement;
fileInput.click();
}}
>
<div className="i-ant-design-upload-outlined text-2xl" />
</button>
<input
id="fileInput"
type="file"
multiple
className="hidden"
onChange={(e) =>
e.target.files && handleFileUpload(e.target.files)
}
/>
</Group>
<table className="mt-2 w-full">
<tbody>
{approval?.files.map((file, index) => (
<tr key={index} className="border-b last:border-b-0">
<td className="py-2">
<div className="text-gray-600 dark:text-gray-400">
{file.name}
</div>
</td>
<td className="w-10 py-2 text-right">
<button
onClick={(e) => {
e.stopPropagation();
const url = window.URL.createObjectURL(file);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', file.name);
document.body.appendChild(link);
link.click();
link.parentNode?.removeChild(link);
}}
className="text-red-500 hover:text-red-700 bg-transparent"
>
<div className="i-ant-design-download-outlined text-3xl" />
</button>
</td>
<td className="w-10 py-2 text-right">
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteFile(index);
}}
className="text-red-500 hover:text-red-700 bg-transparent"
>
<div className="i-ant-design-delete-outlined text-3xl" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
};

104
src/app/Header.tsx Normal file
View File

@ -0,0 +1,104 @@
import { FC, useEffect, useState } from 'react';
import { useNavigate } from 'react-router';
import { Group } from '../components';
import { notifications } from '../demoData/notification';
import { usePreferenceStore } from '../store';
import { twx } from '../utils';
interface HeaderButtonProps {
className?: string;
icon: string;
onClick: () => void;
}
const HeaderButton: FC<HeaderButtonProps> = ({ className, icon, onClick }) => {
return (
<div
className={twx(
'h-9 w-9 cursor-pointer rounded p-2 transition-all hover:backdrop-brightness-90 dark:hover:backdrop-brightness-110 text-black/90 dark:text-white/90',
className,
)}
onClick={onClick}
>
<div className={`w-full h-full ${icon}`} />
</div>
);
};
export const Header: FC = () => {
const navCollapsed = usePreferenceStore.use.navCollapsed();
const navigate = useNavigate();
const updatePreference = usePreferenceStore.use.updatePreference();
const notifCenterCollapsed = usePreferenceStore.use.notifCenterCollapsed();
return (
<Group
className={twx(
`h-14 w-full gap-4 border-b bg-white border-black/10 text-black/90 px-4 text-xl shadow-lg dark:text-white/90 dark:bg-zinc-800 transition-all`,
navCollapsed ? 'pl-20' : 'pl-62',
)}
>
<Group className="flex-1 items-center gap-4">
<p>SORI</p>
</Group>
<Group className="items-center gap-2">
<IconButton
iconClass="i-material-symbols-notifications-outline"
count={notifications.length}
onClick={() => {
updatePreference({ notifCenterCollapsed: !notifCenterCollapsed });
}}
countClassName=" bg-red-5"
/>
<HeaderButton
icon={'i-mdi-exit-to-app'}
onClick={() => {
localStorage.clear();
navigate('/login');
}}
/>
</Group>
</Group>
);
};
interface IconButtonProps {
iconClass: string;
count: number;
onClick: () => void;
countClassName: string;
}
const IconButton: FC<IconButtonProps> = ({
iconClass,
count,
onClick,
countClassName,
}) => {
const [animateClose, setAnimateClose] = useState(false);
useEffect(() => {
setAnimateClose(true);
const timeoutId = setTimeout(() => {
setAnimateClose(false);
}, 500);
return () => clearTimeout(timeoutId);
}, [count]);
return (
<div
className={twx(
'relative h-9 w-9 cursor-pointer rounded p-2 transition-all hover:backdrop-brightness-90 dark:hover:backdrop-brightness-110 dark:text-white/90',
animateClose && 'animate-shock',
)}
onClick={onClick}
>
<div className={`w-full h-full ${iconClass}`} />
<div
className={twx(
`absolute right-0 top-0 h-4 min-w-4 rounded-full ${countClassName} px-1 text-center text-xs text-white/90`,
)}
>
{count}
</div>
</div>
);
};

7
src/app/History.ts Normal file
View File

@ -0,0 +1,7 @@
import { NavigateFunction } from 'react-router';
const History: { navigate: NavigateFunction | null } = {
navigate: null,
};
export default History;

View File

@ -0,0 +1,27 @@
import { FC, useEffect } from 'react';
import { Group } from '../../components';
import { SearchInput } from '../../components/SearchInput';
import { useActiveSelectionStore } from '../../store';
export const HomeScene: FC = () => {
const reset = useActiveSelectionStore((state) => state.reset);
useEffect(() => {
reset();
}, [reset]);
return (
<Group className="relative h-full w-full">
<div className="absolute left-2 top-2 z-19 w-70 flex flex flex-col items-center justify-center gap-2 rounded bg-white shadow-md">
<Group className="w-full">
<SearchInput
placeholder="input search text"
onSearch={(keyword) => {
console.log(keyword);
}}
/>
</Group>
</div>
</Group>
);
};

7
src/app/Home/test.css Normal file
View File

@ -0,0 +1,7 @@
.rbc-calendar {
font-size: 0.8rem;
}
.rbc-event {
padding: 2px 5px;
}

View File

@ -0,0 +1,68 @@
import { FC } from 'react';
import { Calendar } from 'react-big-calendar';
import { Stack } from '../../components';
import { Job } from '../../types/job';
import { formatDate } from '../../utils';
import { localizer } from '../../utils/calendar';
type BasicInfoProps = {
work: Job;
};
export const BasicInfo: FC<BasicInfoProps> = ({ work }) => {
return (
<Stack className="h-full w-full justify-center gap-2 bg-white p-4">
{/* <G></G> */}
<Stack className="gap-2 p-4">
<LabelWithText label="Job Name" value={work.jobId} />
<LabelWithText label="Contact Person" value={work.contactPerson.name} />
<LabelWithText
label="Contact Person Tel"
value={work.contactPerson.tel}
/>
<LabelWithText label="Created At" value={formatDate(work.createdAt)} />
<LabelWithText
label="Expected Period"
value={
work.expectedStartAt
? `${formatDate(work.expectedStartAt)} - ${formatDate(work.expectedEndAt)}`
: 'N/A'
}
/>
<LabelWithText
label="Site Period"
value={
work.siteStartAt
? `${formatDate(work.siteStartAt)} - ${formatDate(work.siteEndAt)}`
: 'N/A'
}
/>
<LabelWithText label="District" value={work.district || 'N/A'} />
<div className="flex gap-2">
<span className="font-medium">Description:</span>
<p className="whitespace-pre-wrap">{work.description || 'N/A'}</p>
</div>
</Stack>
<div className="h-full w-full">
<Calendar
localizer={localizer}
startAccessor="start"
endAccessor="end"
views={['month']}
/>
</div>
</Stack>
);
};
const LabelWithText: FC<{ label: string; value: string }> = ({
label,
value,
}) => {
return (
<div className="flex gap-2">
<span className="font-medium">{label}:</span>
<span>{value}</span>
</div>
);
};

View File

@ -0,0 +1,53 @@
import { ScrollArea } from '@mantine/core';
import { FC } from 'react';
import { Group, Stack } from '../../components';
import { IconButton } from '../../components/IconButton';
import { Comment } from '../../types/job';
import { formatDate } from '../../utils';
type CommentProps = {
comments: Comment[];
onDeleteComment: (comment: Comment) => void;
};
export const CommentHistory: FC<CommentProps> = ({
comments,
onDeleteComment,
}) => {
return (
<div className="h-2/3 w-full bg-white">
<Stack className="h-full gap-2">
<p className="border-b border-gray-2 p-4 pb-2 font-medium">
Comment History
</p>
<ScrollArea className="flex-1 p-4">
<Stack className="gap-2">
{comments.map((comment, index) => (
<Group key={index} className="gap-2">
<div className="h-6 w-6 flex items-center justify-center rounded bg-volcano-2 text-xs text-volcano-6">
{comment.user}
</div>
<Stack className="flex-1 gap-1">
<div className="text-sm">
<div className="whitespace-pre-wrap">{comment.content}</div>
</div>
<Group className="gap-4 text-xs text-gray-5">
<span>{formatDate(comment.time, 2)}</span>
<span>{`Page ${comment.page}`}</span>
</Group>
</Stack>
<IconButton
className="h-6 w-6 border border-red-3 bg-white text-xs text-red-5"
icon="i-ant-design-delete-outlined"
onClick={() => {
onDeleteComment(comment);
}}
/>
</Group>
))}
</Stack>
</ScrollArea>
</Stack>
</div>
);
};

View File

@ -0,0 +1,214 @@
import { ScrollArea } from '@mantine/core';
import { FC, useState } from 'react';
import { useNavigate, useParams } from 'react-router';
import plan1 from '../../assets/plan-1.png';
import plan2 from '../../assets/plan-2.png';
import plan3 from '../../assets/plan-3.png';
import plan4 from '../../assets/plan-4.png';
import { Button, Group, Stack } from '../../components';
import Breadcrumb from '../../components/Breadcrumb';
import { Confirm } from '../../components/Confirm';
import { IconButton } from '../../components/IconButton';
import { jobs } from '../../demoData/job';
import { Comment, Job, Remark } from '../../types/job';
import { twx } from '../../utils';
import { BasicInfo } from './BasicInfo';
import { CommentHistory } from './CommentHistory';
import { Plan } from './Plan';
import { RemarkHistory } from './RemarkHistory';
const plans = [plan1, plan2, plan3, plan4];
export const InternalAuditScene: FC = () => {
const navigate = useNavigate();
const projectId = useParams().id!;
const [currentPage, setCurrentPage] = useState(1);
const [work] = useState<Job>(jobs[1]);
const [mode, setMode] = useState<'sitePlan' | 'basicInfo'>('sitePlan');
const [isAddingCar, setIsAddingCar] = useState(false);
const [comments, setComments] = useState<Comment[]>([]);
const [remarks, setRemarks] = useState<Remark[]>([]);
const [confirmOpen, setConfirmOpen] = useState(false);
return (
<Stack className="h-full w-full gap-2 overflow-hidden">
<Breadcrumb
crumbs={[
{
label: 'Project Record',
onClick: () => navigate('/project-record'),
},
{
label: projectId,
onClick: () => {
navigate(`/project-record/${projectId}`);
},
},
{ label: `${work.jobId} - Internal Audit` },
]}
/>
<Confirm
open={confirmOpen}
title="Are you sure to reject?"
message="This action will reject the work"
onConfirm={() => {
setConfirmOpen(false);
}}
onCancel={() => {
setConfirmOpen(false);
}}
/>
<Group className="gap-2">
<Group className="w-full flex-wrap items-center justify-between gap-x-10 bg-white p-4">
<p className="text-2xl font-medium">
Internal Auditing: {work.jobId}
</p>
<Group className="gap-2">
<Button className="w-40 bg-white text-gray-9">Save and Exit</Button>
<Button
className="w-40 bg-red-5 text-white"
onClick={() => {
setConfirmOpen(true);
}}
>
Rejected
</Button>
<Button
className="w-40 bg-blue-5 text-white"
onClick={() => {
navigate('/');
}}
>
Approved
</Button>
</Group>
</Group>
</Group>
<Group className="relative h-full flex-1 gap-2 overflow-hidden">
<Stack className="h-full w-full">
<Group className="gap-2">
<button
className={twx(
'bg-neutral-2 text-black p-2',
mode === 'sitePlan' && 'text-blue-5 bg-white',
)}
onClick={() => setMode('sitePlan')}
>
Site Plan
</button>
<button
className={twx(
'bg-neutral-2 text-black p-2',
mode === 'basicInfo' && 'text-blue-5 bg-white',
)}
onClick={() => setMode('basicInfo')}
>
Basic Information
</button>
</Group>
{/* Site Plan */}
{mode === 'sitePlan' && (
<Group className="h-full w-full gap-2 bg-white">
<ScrollArea className="h-full w-50 flex flex-shrink-0 flex-col overflow-hidden bg-white p-4">
<Stack className="h-full w-full gap-2">
{plans.map((plan, index) => (
<Preview
src={plan}
title={`${index + 1}`}
isActive={currentPage === index + 1}
onClick={() => setCurrentPage(index + 1)}
/>
))}
</Stack>
</ScrollArea>
<Stack className="h-full w-full gap-2 p-4">
<Group className="h-10 flex-nowrap gap-2">
<p className="text-sm text-black">{`Page ${currentPage} of ${3}`}</p>
{/* <IconButton
className="bg-white text-gray-9 h-10 w-10 ml-auto"
icon="i-ant-design-edit-filled"
iconClass="text-6"
onClick={() => {
}}
/> */}
<IconButton
className={twx(
'bg-white text-gray-9 h-10 w-10 ml-auto',
isAddingCar && 'bg-blue-2',
)}
icon="i-ant-design-comment-outlined"
iconClass="text-6"
onClick={() => setIsAddingCar((o) => !o)}
/>
</Group>
<div className="flex flex-1 justify-center overflow-hidden">
<Plan
isAddingComment={isAddingCar}
comments={comments}
onAddComment={(comment) => {
setComments([...comments, comment]);
setIsAddingCar(false);
}}
onEditComment={(comment) => {
setComments(
comments.map((c) =>
c.id === comment.id ? comment : c,
),
);
}}
currentPage={currentPage}
plan={plans[currentPage - 1]}
/>
</div>
</Stack>
</Group>
)}
{/* Basic Information */}
{mode === 'basicInfo' && <BasicInfo work={work} />}
</Stack>
<Stack className="relative h-full w-[392px] gap-2">
<RemarkHistory
remarks={remarks}
onAddRemark={(remark) => {
setRemarks([...remarks, remark]);
}}
onDeleteRemark={(id) => {
setRemarks(remarks.filter((r) => r.id !== id));
}}
onEditRemark={(remark) => {
setRemarks(remarks.map((r) => (r.id === remark.id ? remark : r)));
}}
/>
<CommentHistory
comments={comments}
onDeleteComment={(comment) => {
setComments(comments.filter((c) => c.id !== comment.id));
}}
/>
</Stack>
</Group>
</Stack>
);
};
const Preview: FC<{
src: string;
title: string;
isActive?: boolean;
onClick?: () => void;
}> = ({ src, title, isActive, onClick }) => {
return (
<Stack
className={twx(
'w-full h-full cursor-pointer border-2',
isActive && 'border-2 border-blue-5',
)}
onClick={onClick}
>
<img src={src} className="border-red-500 w-full border" />
<p className="text-center text-sm">{title}</p>
</Stack>
);
};

View File

@ -0,0 +1,135 @@
import { KonvaEventObject } from 'konva/lib/Node';
import React, { FC, useEffect, useRef, useState } from 'react';
import { Circle, Group, Image, Layer, Stage } from 'react-konva';
import { Html } from 'react-konva-utils';
import useImage from 'use-image';
import commentIcon from '../../assets/comment.svg';
import { Comment } from '../../types/job';
type PlanProps = {
isAddingComment: boolean;
comments: Comment[];
onAddComment: (comment: Comment) => void;
onEditComment: (comment: Comment) => void;
currentPage: number;
plan: string;
};
export const Plan: FC<PlanProps> = ({
isAddingComment,
comments,
onAddComment,
onEditComment,
currentPage,
plan,
}) => {
const ref = useRef<HTMLDivElement>(null);
const [carImage] = useImage(commentIcon);
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
useEffect(() => {
const updateDimensions = () => {
if (ref.current) {
setDimensions({
width: ref.current.clientWidth,
height: ref.current.clientHeight,
});
}
};
updateDimensions();
window.addEventListener('resize', updateDimensions);
return () => window.removeEventListener('resize', updateDimensions);
}, []);
const handleStageClick = (e: KonvaEventObject<MouseEvent>) => {
if (!isAddingComment) return;
const stage = e.target.getStage();
const pos = stage?.getPointerPosition();
const newComment: Comment = {
id: Date.now().toString(),
content: '',
position: { x: pos?.x ?? 0, y: pos?.y ?? 0 },
user: 'U',
time: new Date(),
page: currentPage,
};
onAddComment(newComment);
};
const handleTextChange = (
e: React.ChangeEvent<HTMLTextAreaElement>,
comment: Comment,
) => {
const newComments = comments.find((c) => c.id === comment.id);
if (newComments) {
newComments.content = e.target.value;
onEditComment(newComments);
}
};
const handleDragEnd = (e: KonvaEventObject<DragEvent>, comment: Comment) => {
const newComments = comments.find((c) => c.id === comment.id);
if (newComments) {
const position = e.target.getPosition();
if (position) {
newComments.position = {
x: position.x,
y: position.y,
};
onEditComment(newComments);
}
}
};
return (
<div className="h-full w-full flex flex-1 justify-center overflow-hidden">
<div ref={ref} className="relative h-full w-full">
<img src={plan} className="absolute h-full" />
<Stage
width={dimensions.width}
height={dimensions.height}
style={{ position: 'absolute' }}
onClick={handleStageClick}
>
<Layer>
{carImage &&
comments
.filter((c) => c.page === currentPage)
.map((comment, i) => (
<Group
key={i}
draggable
onDragEnd={(e) => handleDragEnd(e, comment)}
x={comment.position.x}
y={comment.position.y}
>
<Circle radius={20} fill="white" stroke="#ddd" />
<Image
image={carImage}
x={-15}
y={-15}
width={30}
height={30}
/>
<Group x={+20} y={-20}>
<Html>
<textarea
className="border border-gray-300 bg-white p-0.5"
value={comment.content}
onChange={(e) => handleTextChange(e, comment)}
placeholder="input"
rows={2}
/>
</Html>
</Group>
</Group>
))}
</Layer>
</Stage>
</div>
</div>
);
};

View File

@ -0,0 +1,129 @@
import { ScrollArea } from '@mantine/core';
import { FC, useState } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { Button, Group, Stack } from '../../components';
import { IconButton } from '../../components/IconButton';
import { Remark } from '../../types/job';
import { formatDate } from '../../utils';
type RemarkHistoryProps = {
remarks: Remark[];
onAddRemark: (remark: Remark) => void;
onDeleteRemark: (id: string) => void;
onEditRemark: (remark: Remark) => void;
};
export const RemarkHistory: FC<RemarkHistoryProps> = ({
remarks,
onAddRemark,
onDeleteRemark,
onEditRemark,
}) => {
const [isInputRemark, setIsInputRemark] = useState(false);
const [selectedRemark, setSelectedRemark] = useState<Remark | null>(null);
const [remark, setRemark] = useState('');
return (
<div className="h-1/3 w-full bg-white">
<Stack className="h-full gap-2">
<Group className="w-full items-center justify-between border-b border-gray-2 p-4 pb-2">
<p className="font-medium">Remark</p>
{!isInputRemark && (
<IconButton
className="h-6 w-6 bg-white text-gray-9"
icon="i-ant-design-edit-filled"
iconClass="text-sm"
onClick={() => {
setIsInputRemark(true);
setSelectedRemark(null);
setRemark('');
}}
/>
)}
</Group>
{!isInputRemark ? (
<ScrollArea className="flex-1 p-4">
<Stack className="gap-2">
{remarks.map((remark, index) => (
<Group key={index} className="gap-2">
<div className="h-6 w-6 flex items-center justify-center rounded bg-blue-2 text-xs text-blue-6">
{remark.user}
</div>
<Stack className="flex-1 gap-1">
<div className="whitespace-pre-wrap text-sm">
{remark.content}
</div>
<Group className="gap-1 text-xs text-gray-5">
<span>{formatDate(remark.time, 2)}</span>
</Group>
</Stack>
<IconButton
className="h-6 w-6 border border-blue-3 bg-white text-xs text-blue-5"
icon="i-ant-design-edit-outlined"
onClick={() => {
setRemark(remark.content);
setSelectedRemark(remark);
setIsInputRemark(true);
}}
/>
<IconButton
className="h-6 w-6 border border-red-3 bg-white text-xs text-red-5"
icon="i-ant-design-delete-outlined"
onClick={() => {
onDeleteRemark(remark.id);
}}
/>
</Group>
))}
</Stack>
</ScrollArea>
) : (
<div className="h-full w-full bg-white p-2">
<Stack className="h-full w-full">
<textarea
placeholder="Remark"
className="h-full w-full border border-gray-2 rounded-sm p-2"
value={remark}
onChange={(e) => setRemark(e.target.value)}
/>
<Group className="w-full justify-end gap-2">
<Button
className="self-end bg-white text-sm text-gray-9"
onClick={() => {
setRemark('');
setIsInputRemark(false);
}}
>
Cancel
</Button>
<Button
className="self-end bg-blue-5 text-sm text-white"
onClick={() => {
if (selectedRemark) {
onEditRemark({
id: selectedRemark.id,
user: 'U',
content: remark,
time: new Date(),
});
} else {
onAddRemark({
id: uuidv4(),
user: 'U',
content: remark,
time: new Date(),
});
}
setRemark('');
setIsInputRemark(false);
}}
>
Save
</Button>
</Group>
</Stack>
</div>
)}
</Stack>
</div>
);
};

View File

@ -0,0 +1,61 @@
import { FC } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Button, Stack } from '../../components';
import { LeaveApplication, soriAPIClient } from '../../lib/sori';
type CancelLeaveApplicationProps = {
onClose: () => void;
leaveApplication: LeaveApplication;
};
export const CancelLeaveApplicationForm: FC<CancelLeaveApplicationProps> = ({
onClose,
leaveApplication,
}) => {
const { handleSubmit } = useForm();
const onFormSubmit = async () => {
const payload = {};
const _id = leaveApplication._id;
const [error] =
await soriAPIClient.leaveApplications.actions.cancelLeaveApplication(
_id,
payload,
);
if (error) {
console.error(error);
toast.error('Failed to cancel leave application');
onClose();
} else {
toast.success('Leave application cancellled');
onClose();
}
};
return (
<div>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Stack className="w-20vw flex flex-col gap-4 border-gray-200 rounded-lg bg-white p-6 text-black sm:max-w-130 dark:bg-zinc-800 dark:text-white/90">
<div className="flex flex-col gap-1">
<label>Confirm to cancel leave application?</label>
</div>
<div className="mx-auto flex items-center gap-4">
<Button className="mt-auto bg-blue-5 text-white" type="submit">
Confirm
</Button>
<Button
className="mt-auto bg-blue-5 text-white"
type="button"
onClick={() => {
onClose();
}}
>
Cancel
</Button>
</div>
</Stack>
</form>
</div>
);
};

View File

@ -0,0 +1,231 @@
import { ObjectId } from 'bson';
import { FC, useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Button, Stack } from '../../components';
import {
LeaveInit,
LeaveType,
soriAPIClient,
WorkDayType,
} from '../../lib/sori';
import { DateRangePicker } from '@/components/DateRangePicker';
import { CSelect } from '@/components/ui/c-select';
type CreateLeaveApplicationProps = {
onClose: () => void;
open: boolean;
};
type FormValues = {
type: LeaveType;
dateRange: { start: Date; end: Date };
workDayType: WorkDayType;
remark: string;
};
export const CreateLeaveApplicationForm: FC<CreateLeaveApplicationProps> = ({
onClose,
open,
}) => {
const { register, handleSubmit, reset, setValue, watch } =
useForm<FormValues>({
defaultValues: {
type: 'annual',
dateRange: { start: new Date(), end: new Date() },
workDayType: 'full',
remark: '',
},
});
useEffect(() => {
if (!open) {
reset({
type: 'annual',
workDayType: 'full',
dateRange: { start: new Date(), end: new Date() },
remark: '',
});
}
}, [open]);
const leaveTypes: LeaveType[] = Object.values(LeaveType.Enum);
const workDayTypes = Object.values(WorkDayType.Enum);
const currentWorkDayType = watch('workDayType');
const currentDateRange = watch('dateRange');
const [halfDayWarning, setHalfDayWarning] = useState<string | null>(null);
const [weekendWarning, setWeekendWarning] = useState<string | null>(null);
const [buttonDisabled, setbuttonDisabled] = useState<boolean>(false);
useEffect(() => {
if (!currentDateRange.start || !currentDateRange.end) {
setHalfDayWarning(null);
setWeekendWarning(null);
setbuttonDisabled(false);
} else if (
currentWorkDayType !== 'full' &&
currentDateRange.start.getTime() !== currentDateRange.end.getTime()
) {
console.log('currentWorkDayType', currentWorkDayType);
console.log('currentDateRange', currentDateRange);
setHalfDayWarning('*Half day can only be applied for a single day.');
setbuttonDisabled(true);
} else if (
currentDateRange.start.getDay() === 0 ||
currentDateRange.end.getDay() === 6
) {
setbuttonDisabled(true);
setWeekendWarning('No leave required for weekends');
} else {
setHalfDayWarning(null);
setWeekendWarning(null);
setbuttonDisabled(false);
}
}, [currentWorkDayType, currentDateRange]);
const onFormSubmit = async (data: FormValues) => {
const fromDate = data.dateRange.start;
const toDate = data.dateRange.end;
const dayInMs = 1000 * 60 * 60 * 24;
let fromDateMs = fromDate.getTime();
const toDateMs = toDate.getTime();
const dateDiff = toDateMs - fromDateMs;
let leaves: LeaveInit[] = [];
if (dateDiff === 0) {
leaves = [
{
type: data.type,
workDay: {
date: `${fromDate.toDateString()}`,
type: data.workDayType,
},
},
];
} else if (dateDiff === dayInMs) {
leaves = [
{
type: data.type,
workDay: {
date: `${fromDate.toDateString()}`,
type: data.workDayType,
},
},
{
type: data.type,
workDay: {
date: `${toDate.toDateString()}`,
type: data.workDayType,
},
},
];
} else {
while (fromDateMs <= toDateMs) {
if (
new Date(fromDateMs).getDay() === 0 ||
new Date(fromDateMs).getDay() === 6
) {
fromDateMs = fromDateMs + dayInMs;
} else {
leaves.push({
type: data.type,
workDay: {
date: `${new Date(fromDateMs).toDateString()}`,
type: data.workDayType,
},
});
fromDateMs = fromDateMs + dayInMs;
}
}
}
const payload = {
status: 'pending',
leaves: leaves,
applyRemarks: data.remark,
appliedBy: new ObjectId('68d50e6f73dd78e6f9d2e098'),
};
const [error] =
await soriAPIClient.leaveApplications.actions.createLeaveApplication(
payload,
);
if (error) {
console.error(error);
onClose();
toast.error('Failed to create leave application');
} else {
toast.success('Leave application created successfully');
onClose();
}
};
return (
<div>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Stack className="w-100vw flex flex-col gap-4 border-gray-200 rounded-lg bg-white p-6 text-black sm:max-w-130 dark:bg-zinc-800 dark:text-white/90">
<h1 className="text-xl">Create Leave</h1>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<label>Leave Type</label>
<CSelect
options={leaveTypes.map((leaveType) => ({
label: leaveType,
value: leaveType,
}))}
onSelect={(selected) => {
const value = selected?.map((opt) => opt.value) ?? [];
setValue('type', value[0] as LeaveType);
}}
/>
</div>
<div className="flex flex-col gap-1">
<label>Full/Half Day</label>
<CSelect
options={workDayTypes.map((workDayType) => ({
label: workDayType,
value: workDayType,
}))}
onSelect={(selected) => {
const value = selected?.map((opt) => opt.value) ?? [];
setValue('workDayType', value[0] as WorkDayType);
}}
/>
</div>
<div className="flex flex-col gap-1">
<label>Dates</label>
<label className="text-xs text-red-6">
{halfDayWarning}
{weekendWarning}
</label>
<DateRangePicker
onChange={(range) => {
setValue('dateRange', {
start: range[0],
end: range[1],
});
}}
/>
</div>
<div className="flex flex-col gap-1">
<label>Remark</label>
<textarea
{...register('remark')}
rows={3}
className="border border-zinc-300 rounded px-2 py-1"
/>
</div>
</div>
<Button
className="mt-auto bg-blue-5 text-white"
type="submit"
disabled={buttonDisabled}
>
Save
</Button>
</Stack>
</form>
</div>
);
};

View File

@ -0,0 +1,201 @@
import { ScrollArea } from '@mantine/core';
import { FC, useEffect, useState } from 'react';
import { useNavigate } from 'react-router';
import { Button, Stack } from '../../components';
import { Dialog } from '../../components/Dialog';
import { Pagination } from '../../components/Pagination';
import { LeaveApplication, soriAPIClient } from '../../lib/sori';
import { CancelLeaveApplicationForm } from './CancelLeaveApplicationForm';
import { CreateLeaveApplicationForm } from './CreateLeaveApplicationForm';
export const LeaveApplicationScene: FC = () => {
const [createOpen, setCreateOpen] = useState(false);
const [approveOpen, setApproveOpen] = useState(false);
const [leaveApplications, setLeaveApplications] = useState<
LeaveApplication[]
>([]);
const [selectedLeaveApplication, setSelectedLeaveApplication] =
useState<LeaveApplication | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 20;
const paginatedstaffs = leaveApplications.slice(
(currentPage - 1) * pageSize,
currentPage * pageSize,
);
const loadLeaveApplications = async () => {
const leaveApplicationsResult =
await soriAPIClient.leaveApplications.actions.getLeaveApplications();
const leaveApplications = leaveApplicationsResult.unwrap();
setLeaveApplications(leaveApplications);
};
useEffect(() => {
loadLeaveApplications();
}, []);
// const onSubmit = (project: Partial<Project>) => {
// setProjects([...projects, project as Project]);
// setOpen(false);
// };
const navigate = useNavigate();
return (
<>
<Dialog open={createOpen} onRequestClose={() => setCreateOpen(false)}>
<CreateLeaveApplicationForm
onClose={() => {
setCreateOpen(false);
loadLeaveApplications();
}}
open={createOpen}
/>
</Dialog>
<Dialog open={approveOpen} onRequestClose={() => setApproveOpen(false)}>
{selectedLeaveApplication && (
<CancelLeaveApplicationForm
leaveApplication={selectedLeaveApplication}
onClose={() => {
setApproveOpen(false);
setSelectedLeaveApplication(null);
loadLeaveApplications(); // refresh list after delete
}}
/>
)}
</Dialog>
<Stack className="border-black-2 relative mx-auto mt-20px h-[90%] w-[90%] overflow-hidden border rounded-lg shadow-[0_2px_5px_2px_rgba(0,0,0,0.1)] xl:px-20">
<div className="h-20 min-h-12 flex flex-col justify-center gap-1 px-8 text-lg text-xl text-black/90 font-bold font-medium dark:text-white/90">
<div className="flex items-center justify-between">
<span>Leave Application Management (User)</span>
<Button
className="bg-blue-6 text-white"
onClick={() => {
setCreateOpen(true);
}}
>
+ Create Leave Application
</Button>
<Button
className="bg-blue-6 text-white"
onClick={() => navigate('/leave-application-record-admin')}
>
Admin Page
</Button>
</div>
</div>
<Stack className="relative h-full w-full overflow-hidden">
<LeaveAppTable
leaveApplications={paginatedstaffs}
onCancelClick={(leaveApplication) => {
setSelectedLeaveApplication(leaveApplication);
setApproveOpen(true);
}}
/>
<Pagination
count={leaveApplications.length}
pageSize={pageSize}
siblingCount={1}
currentPage={currentPage}
onPageChange={setCurrentPage}
/>
</Stack>
</Stack>
</>
);
};
const LeaveAppTable: FC<{
leaveApplications: LeaveApplication[];
onCancelClick: (leaveApplication: LeaveApplication) => void;
}> = ({ leaveApplications, onCancelClick }) => {
return (
<ScrollArea className="mb-1 h-full w-full overflow-hidden border border-gray-300 rounded-lg shadow-[2px_2px_5px_2px_rgba(0,0,0,0.1)]">
<table className="min-w-full">
<thead>
<tr className="text-gray-700">
<th className="border-b px-4 py-2">Leave Type</th>
<th className="border-b px-4 py-2">From Date</th>
<th className="border-b px-4 py-2">To Date</th>
<th className="border-b px-4 py-2">Duration (Days)</th>
<th className="border-b px-4 py-2">Full/Half</th>
<th className="border-b px-4 py-2">Last Updated</th>
<th className="border-b px-4 py-2">Status</th>
<th className="border-b px-4 py-2"></th>
</tr>
</thead>
<tbody>
{leaveApplications.map((leaveApplication) => (
<LeaveAppTr
key={leaveApplication._id.toString()}
LeaveApplication={leaveApplication}
onCancelClick={onCancelClick}
/>
))}
</tbody>
</table>
</ScrollArea>
);
};
const LeaveAppTr: FC<{
LeaveApplication: LeaveApplication;
onCancelClick: (leaveApplication: LeaveApplication) => void;
}> = ({ LeaveApplication, onCancelClick }) => {
const getLastUpdatedTime = (updatedAt: Date) => {
const lastUpdated = new Date(updatedAt);
const today = new Date();
const diffMs = lastUpdated.getTime() - today.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (-diffDays >= 30) {
return `> ${Math.trunc(-diffDays / 30)} 個月`;
}
return `${-diffDays} day(s)`;
};
const workDayType = LeaveApplication.leaves[0].workDay.type;
let leaveDuration = 0;
if (workDayType === 'full') {
leaveDuration = LeaveApplication.leaves.length;
} else {
leaveDuration = 0.5;
}
return (
<tr
key={LeaveApplication._id.toString()}
className="text-center text-gray-600"
>
<td className="border-b px-4 py-2">
{LeaveApplication.leaves[0].type[0].toUpperCase() +
LeaveApplication.leaves[0].type.slice(1)}
</td>
<td className="border-b px-4 py-2">
{LeaveApplication.leaves[0].workDay.date}
</td>
<td className="border-b px-4 py-2">
{
LeaveApplication.leaves[LeaveApplication.leaves.length - 1].workDay
.date
}
</td>
<td className="border-b px-4 py-2">{leaveDuration}</td>
<td className="border-b px-4 py-2">
{LeaveApplication.leaves[0].workDay.type}
</td>
<td className="border-b px-4 py-2">{`${getLastUpdatedTime(LeaveApplication.updatedAt)}`}</td>
<td className="border-b px-4 py-2">{LeaveApplication.status}</td>
<td className="border-b px-4 py-2">
<Button
className="mx-auto border-gray-300 rounded-lg bg-transparent"
onClick={() => onCancelClick(LeaveApplication)}
>
Cancel
</Button>
</td>
</tr>
);
};

View File

@ -0,0 +1,214 @@
import { ScrollArea } from '@mantine/core';
import { FC, useEffect, useState } from 'react';
import { Button, Stack } from '../../components';
import { Dialog } from '../../components/Dialog';
import { Pagination } from '../../components/Pagination';
import { LeaveApplication, soriAPIClient } from '../../lib/sori';
import { ApproveLeaveForm } from './ApproveLeaveForm';
import { CreateLeaveApplicationForm } from './CreateLeaveApplicationForm';
import { RejectLeaveForm } from './RejectLeaveForm';
export const AdminLeaveApplicationScene: FC = () => {
const [createOpen, setCreateOpen] = useState(false);
const [approveOpen, setApproveOpen] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const [leaveApplications, setLeaveApplications] = useState<
LeaveApplication[]
>([]);
const [selectedLeaveApplication, setSelectedLeaveApplication] =
useState<LeaveApplication | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 20;
const paginatedstaffs = leaveApplications.slice(
(currentPage - 1) * pageSize,
currentPage * pageSize,
);
const loadLeaveApplications = async () => {
const leaveApplicationsResult =
await soriAPIClient.leaveApplications.actions.getLeaveApplications();
const leaveApplications = leaveApplicationsResult.unwrap();
setLeaveApplications(leaveApplications);
};
useEffect(() => {
loadLeaveApplications();
}, []);
// const onSubmit = (project: Partial<Project>) => {
// setProjects([...projects, project as Project]);
// setOpen(false);
// };
return (
<>
<Dialog open={createOpen} onRequestClose={() => setCreateOpen(false)}>
<CreateLeaveApplicationForm
onClose={() => {
setCreateOpen(false);
loadLeaveApplications();
}}
open={createOpen}
/>
</Dialog>
<Dialog open={approveOpen} onRequestClose={() => setApproveOpen(false)}>
{selectedLeaveApplication && (
<ApproveLeaveForm
leaveApplication={selectedLeaveApplication}
onClose={() => {
setApproveOpen(false);
setSelectedLeaveApplication(null);
loadLeaveApplications(); // refresh list after delete
}}
/>
)}
</Dialog>
<Dialog open={rejectOpen} onRequestClose={() => setRejectOpen(false)}>
{selectedLeaveApplication && (
<RejectLeaveForm
leaveApplication={selectedLeaveApplication}
onClose={() => {
setRejectOpen(false);
setSelectedLeaveApplication(null);
loadLeaveApplications(); // refresh list after delete
}}
/>
)}
</Dialog>
<Stack className="border-black-2 relative mx-auto mt-20px h-[90%] w-[90%] overflow-hidden border rounded-lg shadow-[0_2px_5px_2px_rgba(0,0,0,0.1)] xl:px-20">
<div className="h-20 min-h-12 flex flex-col justify-center gap-1 px-8 text-lg text-xl text-black/90 font-bold font-medium dark:text-white/90">
<div className="flex items-center justify-between">
<span>Leave Application Management (Admin)</span>
</div>
</div>
<Stack className="relative h-full w-full overflow-hidden">
<LeaveAppTable
leaveApplications={paginatedstaffs}
onApproveClick={(leaveApplication) => {
setSelectedLeaveApplication(leaveApplication);
setApproveOpen(true);
}}
onRejectClick={(leaveApplication) => {
setSelectedLeaveApplication(leaveApplication);
setRejectOpen(true);
}}
/>
<Pagination
count={leaveApplications.length}
pageSize={pageSize}
siblingCount={1}
currentPage={currentPage}
onPageChange={setCurrentPage}
/>
</Stack>
</Stack>
</>
);
};
const LeaveAppTable: FC<{
leaveApplications: LeaveApplication[];
onApproveClick: (leaveApplication: LeaveApplication) => void;
onRejectClick: (leaveApplication: LeaveApplication) => void;
}> = ({ leaveApplications, onApproveClick, onRejectClick }) => {
return (
<ScrollArea className="mb-1 h-full w-full overflow-hidden border border-gray-300 rounded-lg shadow-[2px_2px_5px_2px_rgba(0,0,0,0.1)]">
<table className="min-w-full">
<thead>
<tr className="text-gray-700">
<th className="border-b px-4 py-2">Leave Type</th>
<th className="border-b px-4 py-2">From Date</th>
<th className="border-b px-4 py-2">To Date</th>
<th className="border-b px-4 py-2">Duration (Days)</th>
<th className="border-b px-4 py-2">Full/Half</th>
<th className="border-b px-4 py-2">Last Updated</th>
<th className="border-b px-4 py-2">Status</th>
<th className="border-b px-4 py-2"></th>
<th className="border-b px-4 py-2"></th>
</tr>
</thead>
<tbody>
{leaveApplications.map((leaveApplication) => (
<LeaveAppTr
key={leaveApplication._id.toString()}
LeaveApplication={leaveApplication}
onApproveClick={onApproveClick}
onRejectClick={onRejectClick}
/>
))}
</tbody>
</table>
</ScrollArea>
);
};
const LeaveAppTr: FC<{
LeaveApplication: LeaveApplication;
onApproveClick: (leaveApplication: LeaveApplication) => void;
onRejectClick: (leaveApplication: LeaveApplication) => void;
}> = ({ LeaveApplication, onApproveClick, onRejectClick }) => {
const getLastUpdatedTime = (updatedAt: Date) => {
const lastUpdated = new Date(updatedAt);
const today = new Date();
const diffMs = lastUpdated.getTime() - today.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (-diffDays >= 30) {
return `> ${Math.trunc(-diffDays / 30)} 個月`;
}
return `${-diffDays} day(s)`;
};
const workDayType = LeaveApplication.leaves[0].workDay.type;
let leaveDuration = 0;
if (workDayType === 'full') {
leaveDuration = LeaveApplication.leaves.length;
} else {
leaveDuration = 0.5;
}
return (
<tr
key={LeaveApplication._id.toString()}
className="text-center text-gray-600"
>
<td className="border-b px-4 py-2">
{LeaveApplication.leaves[0].type[0].toUpperCase() +
LeaveApplication.leaves[0].type.slice(1)}
</td>
<td className="border-b px-4 py-2">
{LeaveApplication.leaves[0].workDay.date}
</td>
<td className="border-b px-4 py-2">
{
LeaveApplication.leaves[LeaveApplication.leaves.length - 1].workDay
.date
}
</td>
<td className="border-b px-4 py-2">{leaveDuration}</td>
<td className="border-b px-4 py-2">
{LeaveApplication.leaves[0].workDay.type}
</td>
<td className="border-b px-4 py-2">{`${getLastUpdatedTime(LeaveApplication.updatedAt)}`}</td>
<td className="border-b px-4 py-2">{LeaveApplication.status}</td>
<td className="border-b px-4 py-2">
<Button
className="mx-auto border-gray-300 rounded-lg bg-transparent"
onClick={() => onApproveClick(LeaveApplication)}
>
Approve
</Button>
</td>
<td className="border-b px-4 py-2">
<Button
className="mx-auto border-gray-300 rounded-lg bg-transparent"
onClick={() => onRejectClick(LeaveApplication)}
>
Reject
</Button>
</td>
</tr>
);
};

View File

@ -0,0 +1,60 @@
import { FC } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Button, Stack } from '../../components';
import { LeaveApplication, soriAPIClient } from '../../lib/sori';
type ApproveLeaveProps = {
onClose: () => void;
leaveApplication: LeaveApplication;
};
export const ApproveLeaveForm: FC<ApproveLeaveProps> = ({
onClose,
leaveApplication,
}) => {
const { handleSubmit } = useForm();
const onFormSubmit = async () => {
const payload = {};
const _id = leaveApplication._id;
const [error] =
await soriAPIClient.leaveApplications.actions.approveLeaveApplication(
_id,
payload,
);
if (error) {
console.error(error);
toast.error('Failed to approve leave');
} else {
toast.success('Leave application approved');
onClose();
}
};
return (
<div>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Stack className="w-20vw flex flex-col gap-4 border-gray-200 rounded-lg bg-white p-6 text-black sm:max-w-130 dark:bg-zinc-800 dark:text-white/90">
<div className="flex flex-col gap-1">
<label>Confirm to approve leave?</label>
</div>
<div className="mx-auto flex items-center gap-4">
<Button className="mt-auto bg-blue-5 text-white" type="submit">
Confirm
</Button>
<Button
className="mt-auto bg-blue-5 text-white"
type="button"
onClick={() => {
onClose();
}}
>
Cancel
</Button>
</div>
</Stack>
</form>
</div>
);
};

View File

@ -0,0 +1,220 @@
import { ObjectId } from 'bson';
import { FC, useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Button, Stack } from '../../components';
import {
LeaveInit,
LeaveType,
soriAPIClient,
WorkDayType,
} from '../../lib/sori';
import { DateRangePicker } from '@/components/DateRangePicker';
import { CSelect } from '@/components/ui/c-select';
type CreateLeaveApplicationProps = {
onClose: () => void;
open: boolean;
};
type FormValues = {
type: LeaveType;
dateRange: { start: Date; end: Date };
workDayType: WorkDayType;
remark: string;
};
export const CreateLeaveApplicationForm: FC<CreateLeaveApplicationProps> = ({
onClose,
open,
}) => {
const { register, handleSubmit, reset, setValue, watch } =
useForm<FormValues>({
defaultValues: {
type: 'annual',
dateRange: { start: new Date(), end: new Date() },
workDayType: 'full',
remark: '',
},
});
useEffect(() => {
if (!open) {
reset({
type: 'annual',
workDayType: 'full',
dateRange: { start: new Date(), end: new Date() },
remark: '',
});
}
}, [open]);
const leaveTypes: LeaveType[] = Object.values(LeaveType.Enum);
const workDayTypes = Object.values(WorkDayType.Enum);
const currentWorkDayType = watch('workDayType');
const currentDateRange = watch('dateRange');
const [halfDayWarning, setHalfDayWarning] = useState<string | null>(null);
const [halfDayWarningDisable, setHalfDayWarningDisable] =
useState<boolean>(false);
useEffect(() => {
if (!currentDateRange.start || !currentDateRange.end) {
setHalfDayWarning(null);
setHalfDayWarningDisable(false);
} else if (
currentWorkDayType !== 'full' &&
currentDateRange.start.getTime() !== currentDateRange.end.getTime()
) {
console.log('currentWorkDayType', currentWorkDayType);
console.log('currentDateRange', currentDateRange);
setHalfDayWarning('*Half day can only be applied for a single day.');
setHalfDayWarningDisable(true);
} else {
setHalfDayWarning(null);
setHalfDayWarningDisable(false);
}
}, [currentWorkDayType, currentDateRange]);
const onFormSubmit = async (data: FormValues) => {
const fromDate = data.dateRange.start;
const toDate = data.dateRange.end;
const dayInMs = 1000 * 60 * 60 * 24;
let fromDateMs = fromDate.getTime();
const toDateMs = toDate.getTime();
const dateDiff = toDateMs - fromDateMs;
let leaves: LeaveInit[] = [];
if (dateDiff === 0) {
leaves = [
{
type: data.type,
workDay: {
date: ` ${fromDate.getDate()}/${fromDate.getMonth() + 1}/${fromDate.getFullYear()}`,
type: data.workDayType,
},
},
];
} else if (dateDiff === dayInMs) {
leaves = [
{
type: data.type,
workDay: {
date: ` ${fromDate.getDate()}/${fromDate.getMonth() + 1}/${fromDate.getFullYear()}`,
type: data.workDayType,
},
},
{
type: data.type,
workDay: {
date: ` ${toDate.getDate()}/${toDate.getMonth() + 1}/${toDate.getFullYear()}`,
type: data.workDayType,
},
},
];
} else {
while (fromDateMs <= toDateMs) {
if (
new Date(fromDateMs).getDay() === 0 ||
new Date(fromDateMs).getDay() === 6
) {
fromDateMs = fromDateMs + dayInMs;
} else {
leaves.push({
type: data.type,
workDay: {
date: ` ${new Date(fromDateMs).getDate()}/${new Date(fromDateMs).getMonth() + 1}/${new Date(fromDateMs).getFullYear()}`,
type: data.workDayType,
},
});
fromDateMs = fromDateMs + dayInMs;
}
}
}
const payload = {
status: 'pending',
leaves: leaves,
applyRemarks: data.remark,
appliedBy: new ObjectId('68d50e6f73dd78e6f9d2e098'),
};
const [error] =
await soriAPIClient.leaveApplications.actions.createLeaveApplication(
payload,
);
if (error) {
console.error(error);
onClose();
toast.error('Failed to create leave application');
} else {
toast.success('Leave application created successfully');
onClose();
}
};
return (
<div>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Stack className="w-100vw flex flex-col gap-4 border-gray-200 rounded-lg bg-white p-6 text-black sm:max-w-130 dark:bg-zinc-800 dark:text-white/90">
<h1>Create Leave</h1>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<label>Leave Type</label>
<CSelect
options={leaveTypes.map((leaveType) => ({
label: leaveType,
value: leaveType,
}))}
onSelect={(selected) => {
const value = selected?.map((opt) => opt.value) ?? [];
setValue('type', value[0] as LeaveType);
}}
/>
</div>
<div className="flex flex-col gap-1">
<label>Full/Half Day</label>
<CSelect
options={workDayTypes.map((workDayType) => ({
label: workDayType,
value: workDayType,
}))}
onSelect={(selected) => {
const value = selected?.map((opt) => opt.value) ?? [];
setValue('workDayType', value[0] as WorkDayType);
}}
/>
</div>
<div className="flex flex-col gap-1">
<label>Dates</label>
<label className="text-xs text-red-6">{halfDayWarning}</label>
<DateRangePicker
onChange={(range) => {
setValue('dateRange', {
start: range[0],
end: range[1],
});
}}
/>
</div>
<div className="flex flex-col gap-1">
<label>Remark</label>
<textarea
{...register('remark')}
rows={3}
className="border border-zinc-300 rounded px-2 py-1"
/>
</div>
</div>
<Button
className="mt-auto bg-blue-5 text-white"
type="submit"
disabled={halfDayWarningDisable}
>
Save
</Button>
</Stack>
</form>
</div>
);
};

View File

@ -0,0 +1,61 @@
import { FC } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Button, Stack } from '../../components';
import { LeaveApplication, soriAPIClient } from '../../lib/sori';
type RejectLeaveProps = {
onClose: () => void;
leaveApplication: LeaveApplication;
};
export const RejectLeaveForm: FC<RejectLeaveProps> = ({
onClose,
leaveApplication,
}) => {
const { handleSubmit } = useForm();
const onFormSubmit = async () => {
const payload = {};
const _id = leaveApplication._id;
const [error] =
await soriAPIClient.leaveApplications.actions.rejectLeaveApplication(
_id,
payload,
);
if (error) {
console.error(error);
toast.error('Failed to reject leave');
onClose();
} else {
toast.success('Leave application rejected');
onClose();
}
};
return (
<div>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Stack className="w-20vw flex flex-col gap-4 border-gray-200 rounded-lg bg-white p-6 text-black sm:max-w-130 dark:bg-zinc-800 dark:text-white/90">
<div className="flex flex-col gap-1">
<label>Confirm to reject leave?</label>
</div>
<div className="mx-auto flex items-center gap-4">
<Button className="mt-auto bg-blue-5 text-white" type="submit">
Confirm
</Button>
<Button
className="mt-auto bg-blue-5 text-white"
type="button"
onClick={() => {
onClose();
}}
>
Cancel
</Button>
</div>
</Stack>
</form>
</div>
);
};

View File

@ -0,0 +1,60 @@
import { FC } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Button, Stack } from '../../components';
import { LeaveCancellation, soriAPIClient } from '../../lib/sori';
type ApproveLeaveCancellationProps = {
onClose: () => void;
leaveCancellation: LeaveCancellation;
};
export const ApproveLeaveCancellationForm: FC<
ApproveLeaveCancellationProps
> = ({ onClose, leaveCancellation }) => {
const { handleSubmit } = useForm();
const onFormSubmit = async () => {
const payload = {};
const _id = leaveCancellation._id;
const [error] =
await soriAPIClient.leaveCancellations.actions.approveLeaveCancellation(
_id,
payload,
);
if (error) {
console.error(error);
toast.error('Failed to approve leave cancellation');
onClose();
} else {
toast.success('Leave cancellation approved');
onClose();
}
};
return (
<div>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Stack className="w-20vw flex flex-col gap-4 border-gray-200 rounded-lg bg-white p-6 text-black sm:max-w-130 dark:bg-zinc-800 dark:text-white/90">
<div className="flex flex-col gap-1">
<label>Confirm to approve leave cancellation?</label>
</div>
<div className="mx-auto flex items-center gap-4">
<Button className="mt-auto bg-blue-5 text-white" type="submit">
Confirm
</Button>
<Button
className="mt-auto bg-blue-5 text-white"
type="button"
onClick={() => {
onClose();
}}
>
Cancel
</Button>
</div>
</Stack>
</form>
</div>
);
};

View File

@ -0,0 +1,193 @@
import { ObjectId } from 'bson';
import { FC, useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Button, Stack } from '../../components';
import { LeaveType, soriAPIClient, WorkDay, WorkDayType } from '../../lib/sori';
import { DateRangePicker } from '@/components/DateRangePicker';
import { CSelect } from '@/components/ui/c-select';
type CreateLeaveCancellationProps = {
onClose: () => void;
open: boolean;
};
type FormValues = {
type: LeaveType;
dateRange: { start: Date; end: Date };
workDayType: WorkDayType;
remark: string;
};
export const CreateLeaveCancellationForm: FC<CreateLeaveCancellationProps> = ({
onClose,
open,
}) => {
const { register, handleSubmit, reset, setValue, watch } =
useForm<FormValues>({
defaultValues: {
type: 'annual',
dateRange: { start: new Date(), end: new Date() },
workDayType: 'full',
remark: '',
},
});
useEffect(() => {
if (!open) {
reset({
type: 'annual',
workDayType: 'full',
dateRange: { start: new Date(), end: new Date() },
remark: '',
});
}
}, [open]);
const leaveTypes: LeaveType[] = Object.values(LeaveType.Enum);
const workDayTypes = Object.values(WorkDayType.Enum);
const currentWorkDayType = watch('workDayType');
const currentDateRange = watch('dateRange');
const [halfDayWarning, setHalfDayWarning] = useState<string | null>(null);
useEffect(() => {
if (!currentDateRange.start || !currentDateRange.end) {
setHalfDayWarning(null);
} else if (
currentWorkDayType !== 'full' &&
currentDateRange.start.getTime() !== currentDateRange.end.getTime()
) {
console.log('currentWorkDayType', currentWorkDayType);
console.log('currentDateRange', currentDateRange);
setHalfDayWarning('*Half day can only be applied for a single day.');
} else {
setHalfDayWarning(null);
}
}, [currentWorkDayType, currentDateRange]);
const onFormSubmit = async (data: FormValues) => {
const fromDate = data.dateRange.start;
const toDate = data.dateRange.end;
const dayInMs = 1000 * 60 * 60 * 24;
let fromDateMs = fromDate.getTime();
const toDateMs = toDate.getTime();
const dateDiff = toDateMs - fromDateMs;
let workDays: WorkDay[] = [];
if (dateDiff === 0) {
workDays = [
{
date: `${fromDate.toDateString()}`,
type: data.workDayType,
},
];
} else if (dateDiff === dayInMs) {
workDays = [
{
date: `${fromDate.toDateString()}`,
type: data.workDayType,
},
{
date: `${toDate.toDateString()}`,
type: data.workDayType,
},
];
} else {
while (fromDateMs <= toDateMs) {
if (
new Date(fromDateMs).getDay() === 0 ||
new Date(fromDateMs).getDay() === 6
) {
fromDateMs = fromDateMs + dayInMs;
} else {
workDays.push({
date: `${new Date(fromDateMs).toDateString()}`,
type: data.workDayType,
});
fromDateMs = fromDateMs + dayInMs;
}
}
}
const payload = {
workDays: workDays,
applyRemarks: data.remark,
appliedBy: new ObjectId('68d50e6f73dd78e6f9d2e098'),
};
const [error] =
await soriAPIClient.leaveCancellations.actions.createLeaveCancellation(
payload,
);
if (error) {
console.error(error);
onClose();
toast.error('Failed to create leave cancellation');
} else {
toast.success('Leave cancellation created successfully');
onClose();
}
};
return (
<div>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Stack className="w-100vw flex flex-col gap-4 border-gray-200 rounded-lg bg-white p-6 text-black sm:max-w-130 dark:bg-zinc-800 dark:text-white/90">
<h1 className="text-xl">Create Leave Cancellation</h1>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<label>Leave Type</label>
<CSelect
options={leaveTypes.map((leaveType) => ({
label: leaveType,
value: leaveType,
}))}
onSelect={(selected) => {
const value = selected?.map((opt) => opt.value) ?? [];
setValue('type', value[0] as LeaveType);
}}
/>
</div>
<div className="flex flex-col gap-1">
<label>Full Day/ Half Day</label>
<CSelect
options={workDayTypes.map((workDayType) => ({
label: workDayType,
value: workDayType,
}))}
onSelect={(selected) => {
const value = selected?.map((opt) => opt.value) ?? [];
setValue('workDayType', value[0] as WorkDayType);
}}
/>
</div>
<div className="flex flex-col gap-1">
<label>Dates</label>
<label className="text-xs text-red-6">{halfDayWarning}</label>
<DateRangePicker
onChange={(range) => {
setValue('dateRange', {
start: range[0],
end: range[1],
});
}}
/>
</div>
<div className="flex flex-col gap-1">
<label>Remark</label>
<textarea
{...register('remark')}
rows={3}
className="border border-zinc-300 rounded px-2 py-1"
/>
</div>
</div>
<Button className="mt-auto bg-blue-5 text-white" type="submit">
Save
</Button>
</Stack>
</form>
</div>
);
};

View File

@ -0,0 +1,201 @@
import { ScrollArea } from '@mantine/core';
import { FC, useEffect, useState } from 'react';
import { Button, Stack } from '../../components';
import { Dialog } from '../../components/Dialog';
import { Pagination } from '../../components/Pagination';
import { LeaveCancellation, soriAPIClient } from '../../lib/sori';
import { ApproveLeaveCancellationForm } from './ApproveLeaveCancellationForm';
import { CreateLeaveCancellationForm } from './CreateLeaveCancellationForm';
export const LeaveCancellationScene: FC = () => {
const [createOpen, setCreateOpen] = useState(false);
const [approveOpen, setApproveOpen] = useState(false);
const [leaveCancellations, setLeaveCancellations] = useState<
LeaveCancellation[]
>([]);
const [selectedLeaveCancellation, setSelectedLeaveCancellation] =
useState<LeaveCancellation | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 20;
const paginatedstaffs = leaveCancellations.slice(
(currentPage - 1) * pageSize,
currentPage * pageSize,
);
const loadLeaveCancellations = async () => {
const leaveCancellationsResult =
await soriAPIClient.leaveCancellations.actions.getLeaveCancellations();
const leaveCancellations = leaveCancellationsResult.unwrap();
setLeaveCancellations(leaveCancellations);
};
useEffect(() => {
loadLeaveCancellations();
}, []);
// const onSubmit = (project: Partial<Project>) => {
// setProjects([...projects, project as Project]);
// setOpen(false);
// };
return (
<>
<Dialog open={createOpen} onRequestClose={() => setCreateOpen(false)}>
<CreateLeaveCancellationForm
onClose={() => {
setCreateOpen(false);
loadLeaveCancellations();
}}
open={createOpen}
/>
</Dialog>
<Dialog open={approveOpen} onRequestClose={() => setApproveOpen(false)}>
{selectedLeaveCancellation && (
<ApproveLeaveCancellationForm
leaveCancellation={selectedLeaveCancellation}
onClose={() => {
setApproveOpen(false);
setSelectedLeaveCancellation(null);
loadLeaveCancellations(); // refresh list after delete
}}
/>
)}
</Dialog>
<Stack className="border-black-2 relative mx-auto mt-20px h-[90%] w-[90%] overflow-hidden border rounded-lg shadow-[0_2px_5px_2px_rgba(0,0,0,0.1)] xl:px-20">
<div className="h-20 min-h-12 flex flex-col justify-center gap-1 px-8 text-lg text-xl text-black/90 font-bold font-medium dark:text-white/90">
<div className="flex items-center justify-between">
<span>Leave Cancellation Management</span>
<Button
className="bg-blue-6 text-white"
onClick={() => {
setCreateOpen(true);
}}
>
+ Create Leave Cancellation
</Button>
</div>
</div>
<Stack className="relative h-full w-full overflow-hidden">
<LeaveAppTable
leaveCancellations={paginatedstaffs}
onApproveClick={(leaveCancellation) => {
setSelectedLeaveCancellation(leaveCancellation);
setApproveOpen(true);
}}
/>
<Pagination
count={leaveCancellations.length}
pageSize={pageSize}
siblingCount={1}
currentPage={currentPage}
onPageChange={setCurrentPage}
/>
</Stack>
</Stack>
</>
);
};
const LeaveAppTable: FC<{
leaveCancellations: LeaveCancellation[];
onApproveClick: (leaveCancellation: LeaveCancellation) => void;
}> = ({ leaveCancellations, onApproveClick }) => {
return (
<ScrollArea className="mb-1 h-full w-full overflow-hidden border border-gray-300 rounded-lg shadow-[2px_2px_5px_2px_rgba(0,0,0,0.1)]">
<table className="min-w-full">
<thead>
<tr className="text-gray-700">
<th className="border-b px-4 py-2">Leave Type</th>
<th className="border-b px-4 py-2">From Date</th>
<th className="border-b px-4 py-2">To Date</th>
<th className="border-b px-4 py-2">Duration (Days)</th>
<th className="border-b px-4 py-2">Full/Half</th>
<th className="border-b px-4 py-2">Last Updated</th>
<th className="border-b px-4 py-2">Status</th>
<th className="border-b px-4 py-2"></th>
<th className="border-b px-4 py-2"></th>
</tr>
</thead>
<tbody>
{leaveCancellations.map((leaveCancellation) => (
<LeaveAppTr
key={leaveCancellation._id.toString()}
LeaveCancellation={leaveCancellation}
onApproveClick={onApproveClick}
/>
))}
</tbody>
</table>
</ScrollArea>
);
};
const LeaveAppTr: FC<{
LeaveCancellation: LeaveCancellation;
onApproveClick: (leaveCancellation: LeaveCancellation) => void;
}> = ({ LeaveCancellation, onApproveClick }) => {
const getLastUpdatedTime = (updatedAt: Date) => {
const lastUpdated = new Date(updatedAt);
const today = new Date();
const diffMs = lastUpdated.getTime() - today.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (-diffDays >= 30) {
return `> ${Math.trunc(-diffDays / 30)} 個月`;
}
return `${-diffDays} day(s)`;
};
const workDayType = LeaveCancellation.leaves[0].workDay.type;
let leaveDuration = 0;
if (workDayType === 'full') {
leaveDuration = LeaveCancellation.leaves.length;
} else {
leaveDuration = 0.5;
}
return (
<tr
key={LeaveCancellation._id.toString()}
className="text-center text-gray-600"
>
<td className="border-b px-4 py-2">
{LeaveCancellation.leaves[0].type[0].toUpperCase() +
LeaveCancellation.leaves[0].type.slice(1)}
</td>
<td className="border-b px-4 py-2">
{LeaveCancellation.leaves[0].workDay.date}
</td>
<td className="border-b px-4 py-2">
{
LeaveCancellation.leaves[LeaveCancellation.leaves.length - 1].workDay
.date
}
</td>
<td className="border-b px-4 py-2">{leaveDuration}</td>
<td className="border-b px-4 py-2">
{LeaveCancellation.leaves[0].workDay.type}
</td>
<td className="border-b px-4 py-2">{`${getLastUpdatedTime(LeaveCancellation.updatedAt)}`}</td>
<td className="border-b px-4 py-2">{LeaveCancellation.status}</td>
<td className="border-b px-4 py-2">
<Button
className="mx-auto border-gray-300 rounded-lg bg-transparent"
//onClick={() => onDeleteClick(LeaveCancellation)}
>
Edit
</Button>
</td>
<td className="border-b px-4 py-2">
<Button
className="mx-auto border-gray-300 rounded-lg bg-transparent"
onClick={() => onApproveClick(LeaveCancellation)}
>
Approve
</Button>
</td>
</tr>
);
};

View File

@ -0,0 +1,61 @@
import { FC } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Button, Stack } from '../../components';
import { LeaveApplication, soriAPIClient } from '../../lib/sori';
type CancelLeaveApplicationProps = {
onClose: () => void;
leaveApplication: LeaveApplication;
};
export const CancelLeaveApplicationForm: FC<CancelLeaveApplicationProps> = ({
onClose,
leaveApplication,
}) => {
const { handleSubmit } = useForm();
const onFormSubmit = async () => {
const payload = {};
const _id = leaveApplication._id;
const [error] =
await soriAPIClient.leaveApplications.actions.cancelLeaveApplication(
_id,
payload,
);
if (error) {
console.error(error);
toast.error('Failed to cancel leave application');
onClose();
} else {
toast.success('Leave application cancellled');
onClose();
}
};
return (
<div>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Stack className="w-20vw flex flex-col gap-4 border-gray-200 rounded-lg bg-white p-6 text-black sm:max-w-130 dark:bg-zinc-800 dark:text-white/90">
<div className="flex flex-col gap-1">
<label>Confirm to cancel leave application?</label>
</div>
<div className="mx-auto flex items-center gap-4">
<Button className="mt-auto bg-blue-5 text-white" type="submit">
Confirm
</Button>
<Button
className="mt-auto bg-blue-5 text-white"
type="button"
onClick={() => {
onClose();
}}
>
Cancel
</Button>
</div>
</Stack>
</form>
</div>
);
};

View File

@ -0,0 +1,144 @@
import { ObjectId } from 'bson';
import { FC, useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Button, Stack } from '../../components';
import { LeaveType, soriAPIClient } from '../../lib/sori';
import { CSelect } from '@/components/ui/c-select';
type CreateLeaveCreditApplicationProps = {
onClose: () => void;
open: boolean;
};
type FormValues = {
leaveType: LeaveType;
amount: number;
remark: string;
};
export const CreateLeaveCreditApplicationForm: FC<
CreateLeaveCreditApplicationProps
> = ({ onClose, open }) => {
const { register, handleSubmit, reset, setValue } = useForm<FormValues>({
defaultValues: {
leaveType: 'annual',
amount: 0,
remark: '',
},
});
useEffect(() => {
if (!open) {
reset({
leaveType: 'annual',
amount: 0,
remark: '',
});
}
}, [open]);
const leaveTypes: LeaveType[] = Object.values(LeaveType.Enum);
// const currentWorkDayType = watch('workDayType');
// const currentDateRange = watch('dateRange');
// const [halfDayWarning, setHalfDayWarning] = useState<string | null>(null);
// const [weekendWarning, setWeekendWarning] = useState<string | null>(null);
const [buttonDisabled, setbuttonDisabled] = useState<boolean>(false);
// useEffect(() => {
// if (!currentDateRange.start || !currentDateRange.end) {
// setHalfDayWarning(null);
// setWeekendWarning(null);
// setbuttonDisabled(false);
// } else if (
// currentWorkDayType !== 'full' &&
// currentDateRange.start.getTime() !== currentDateRange.end.getTime()
// ) {
// console.log('currentWorkDayType', currentWorkDayType);
// console.log('currentDateRange', currentDateRange);
// setHalfDayWarning('*Half day can only be applied for a single day.');
// setbuttonDisabled(true);
// } else if (
// currentDateRange.start.getDay() === 0 ||
// currentDateRange.end.getDay() === 6
// ) {
// setbuttonDisabled(true);
// setWeekendWarning('No leave required for weekends');
// } else {
// setHalfDayWarning(null);
// setWeekendWarning(null);
// setbuttonDisabled(false);
// }
// }, [currentWorkDayType, currentDateRange]);
const onFormSubmit = async (data: FormValues) => {
const payload = {
leaveType: data.leaveType,
amount: Number(data.amount),
appliedBy: new ObjectId('68d50e6f73dd78e6f9d2e098'),
applyRemarks: data.remark,
};
const [error] =
await soriAPIClient.leaveCreditApplications.actions.createLeaveCreditApplication(
payload,
);
if (error) {
console.error(error);
onClose();
toast.error('Failed to create leave credit application');
} else {
toast.success('Leave credit application created successfully');
onClose();
}
};
return (
<div>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Stack className="w-100vw flex flex-col gap-4 border-gray-200 rounded-lg bg-white p-6 text-black sm:max-w-130 dark:bg-zinc-800 dark:text-white/90">
<h1 className="text-xl">Create Leave Credit</h1>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<label>Leave Type</label>
<CSelect
options={leaveTypes.map((leaveType) => ({
label: leaveType,
value: leaveType,
}))}
onSelect={(selected) => {
const value = selected?.map((opt) => opt.value) ?? [];
setValue('leaveType', value[0] as LeaveType);
}}
/>
</div>
<div className="flex flex-col gap-1">
<label>Amount</label>
<input
type="number"
{...register('amount')}
className="border border-zinc-300 rounded bg-white px-2 py-1"
/>
</div>
<div className="flex flex-col gap-1">
<label>Remark</label>
<textarea
{...register('remark')}
rows={3}
className="border border-zinc-300 rounded px-2 py-1"
/>
</div>
</div>
<Button
className="mt-auto bg-blue-5 text-white"
type="submit"
disabled={buttonDisabled}
>
Save
</Button>
</Stack>
</form>
</div>
);
};

View File

@ -0,0 +1,173 @@
import { ScrollArea } from '@mantine/core';
import { FC, useEffect, useState } from 'react';
import { useNavigate } from 'react-router';
import { Button, Stack } from '../../components';
import { Dialog } from '../../components/Dialog';
import { Pagination } from '../../components/Pagination';
import { LeaveCreditApplication, soriAPIClient } from '../../lib/sori';
//import { CancelLeaveApplicationForm } from './CancelLeaveApplicationForm';
import { CreateLeaveCreditApplicationForm } from './CreateLeaveCreditApplicationForm';
export const LeaveCreditApplicationScene: FC = () => {
const [createOpen, setCreateOpen] = useState(false);
//const [approveOpen, setApproveOpen] = useState(false);
const [leaveApplications, setLeaveApplications] = useState<
LeaveCreditApplication[]
>([]);
const [selectedLeaveApplication, setSelectedLeaveApplication] =
useState<LeaveCreditApplication | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 20;
const paginatedstaffs = leaveApplications.slice(
(currentPage - 1) * pageSize,
currentPage * pageSize,
);
const loadLeaveApplications = async () => {
const leaveCreditApplicationsResult =
await soriAPIClient.leaveCreditApplications.actions.getLeaveCreditApplications();
const leaveCreditApplications = leaveCreditApplicationsResult.unwrap();
setLeaveApplications(leaveCreditApplications);
};
useEffect(() => {
loadLeaveApplications();
}, []);
// const onSubmit = (project: Partial<Project>) => {
// setProjects([...projects, project as Project]);
// setOpen(false);
// };
const navigate = useNavigate();
return (
<>
<Dialog open={createOpen} onRequestClose={() => setCreateOpen(false)}>
<CreateLeaveCreditApplicationForm
onClose={() => {
setCreateOpen(false);
loadLeaveApplications();
}}
open={createOpen}
/>
</Dialog>
{/*<Dialog open={approveOpen} onRequestClose={() => setApproveOpen(false)}>
{selectedLeaveApplication && (
<CancelLeaveApplicationForm
leaveApplication={selectedLeaveApplication}
onClose={() => {
setApproveOpen(false);
setSelectedLeaveApplication(null);
loadLeaveApplications(); // refresh list after delete
}}
/>
)}
</Dialog> */}
<Stack className="border-black-2 relative mx-auto mt-20px h-[90%] w-[90%] overflow-hidden border rounded-lg shadow-[0_2px_5px_2px_rgba(0,0,0,0.1)] xl:px-20">
<div className="h-20 min-h-12 flex flex-col justify-center gap-1 px-8 text-lg text-xl text-black/90 font-bold font-medium dark:text-white/90">
<div className="flex items-center justify-between">
<span>Leave Credit Application Management</span>
<Button
className="bg-blue-6 text-white"
onClick={() => {
setCreateOpen(true);
}}
>
+ Create Leave Credit Application
</Button>
<Button
className="bg-blue-6 text-white"
onClick={() => navigate('/leave-application-record-admin')}
>
Admin Page
</Button>
</div>
</div>
<Stack className="relative h-full w-full overflow-hidden">
<LeaveAppTable
leaveCreditApplications={paginatedstaffs}
onCancelClick={(leaveCreditApplication) => {
setSelectedLeaveApplication(leaveCreditApplication);
}}
/>
<Pagination
count={leaveApplications.length}
pageSize={pageSize}
siblingCount={1}
currentPage={currentPage}
onPageChange={setCurrentPage}
/>
</Stack>
</Stack>
</>
);
};
const LeaveAppTable: FC<{
leaveCreditApplications: LeaveCreditApplication[];
onCancelClick: (leaveCreditApplication: LeaveCreditApplication) => void;
}> = ({ leaveCreditApplications, onCancelClick }) => {
return (
<ScrollArea className="mb-1 h-full w-full overflow-hidden border border-gray-300 rounded-lg shadow-[2px_2px_5px_2px_rgba(0,0,0,0.1)]">
<table className="min-w-full">
<thead>
<tr className="text-gray-700">
<th className="border-b px-4 py-2">Leave Type</th>
<th className="border-b px-4 py-2">Amount</th>
<th className="border-b px-4 py-2">Last Updated</th>
<th className="border-b px-4 py-2">Status</th>
<th className="border-b px-4 py-2"></th>
</tr>
</thead>
<tbody>
{leaveCreditApplications.map((leaveCreditApplication) => (
<LeaveAppTr
key={leaveCreditApplication._id.toString()}
LeaveCreditApplication={leaveCreditApplication}
onCancelClick={onCancelClick}
/>
))}
</tbody>
</table>
</ScrollArea>
);
};
const LeaveAppTr: FC<{
LeaveCreditApplication: LeaveCreditApplication;
onCancelClick: (leaveApplication: LeaveCreditApplication) => void;
}> = ({ LeaveCreditApplication, onCancelClick }) => {
const getLastUpdatedTime = (updatedAt: Date) => {
const lastUpdated = new Date(updatedAt);
const today = new Date();
const diffMs = lastUpdated.getTime() - today.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (-diffDays >= 30) {
return `> ${Math.trunc(-diffDays / 30)} 個月`;
}
return `${-diffDays} day(s)`;
};
return (
<tr
key={LeaveCreditApplication._id.toString()}
className="text-center text-gray-600"
>
<td className="border-b px-4 py-2">{LeaveCreditApplication.leaveType}</td>
<td className="border-b px-4 py-2">{LeaveCreditApplication.amount}</td>
<td className="border-b px-4 py-2">{`${getLastUpdatedTime(LeaveCreditApplication.updatedAt)}`}</td>
<td className="border-b px-4 py-2">{LeaveCreditApplication.status}</td>
<td className="border-b px-4 py-2">
<Button
className="mx-auto border-gray-300 rounded-lg bg-transparent"
onClick={() => onCancelClick(LeaveCreditApplication)}
>
Cancel
</Button>
</td>
</tr>
);
};

View File

@ -0,0 +1,123 @@
import { ScrollArea } from '@mantine/core';
import { ObjectId } from 'bson';
import { FC, useEffect, useState } from 'react';
import { Stack } from '../../components';
import { Pagination } from '../../components/Pagination';
import { LeaveTransaction, soriAPIClient } from '../../lib/sori';
export const LeaveTransactionScene: FC = () => {
const [leaveTransactions, setLeaveTransactions] = useState<
LeaveTransaction[]
>([]);
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 20;
const paginatedstaffs = leaveTransactions.slice(
(currentPage - 1) * pageSize,
currentPage * pageSize,
);
const [staffMap, setStaffMap] = useState<Record<string, string>>({});
const loadLeaveTransactions = async () => {
const leaveApplicationsResult =
await soriAPIClient.leaveTransactions.actions.getLeaveTransactions();
const leaveTransactions = leaveApplicationsResult.unwrap();
const staffIds = [
...new Set(leaveTransactions.map((t) => t.grantedTo.toString())),
];
const staffEntries = await Promise.all(
staffIds.map(async (id) => {
const staffResult = await soriAPIClient.staffs.actions.getStaff(
new ObjectId(id),
);
const staff = staffResult.unwrap();
return [id, staff.nameEN] as const;
}),
);
setStaffMap(Object.fromEntries(staffEntries));
setLeaveTransactions(leaveTransactions);
};
useEffect(() => {
loadLeaveTransactions();
}, []);
return (
<>
<Stack className="border-black-2 relative mx-auto mt-20px h-[90%] w-[90%] overflow-hidden border rounded-lg shadow-[0_2px_5px_2px_rgba(0,0,0,0.1)] xl:px-20">
<div className="h-20 min-h-12 flex flex-col justify-center gap-1 px-8 text-lg text-xl text-black/90 font-bold font-medium dark:text-white/90">
<div className="flex items-center justify-between">
<span>Leave Transactions</span>
</div>
</div>
<Stack className="relative h-full w-full overflow-hidden">
<LeaveAppTable
leaveTransactions={paginatedstaffs}
staffMap={staffMap}
/>
<Pagination
count={leaveTransactions.length}
pageSize={pageSize}
siblingCount={1}
currentPage={currentPage}
onPageChange={setCurrentPage}
/>
</Stack>
</Stack>
</>
);
};
const LeaveAppTable: FC<{
leaveTransactions: LeaveTransaction[];
staffMap: Record<string, string>;
}> = ({ leaveTransactions, staffMap }) => {
return (
<ScrollArea className="mb-1 h-full w-full overflow-hidden border border-gray-300 rounded-lg shadow-[2px_2px_5px_2px_rgba(0,0,0,0.1)]">
<table className="min-w-full">
<thead>
<tr className="text-gray-700">
<th className="border-b px-4 py-2">Leave Type</th>
<th className="border-b px-4 py-2">Amount</th>
<th className="border-b px-4 py-2">Balance</th>
<th className="border-b px-4 py-2">User</th>
<th className="border-b px-4 py-2">Created At</th>
</tr>
</thead>
<tbody>
{leaveTransactions.map((leaveTransaction) => (
<LeaveAppTr
key={leaveTransaction._id.toString()}
LeaveTransaction={leaveTransaction}
staffMap={staffMap}
/>
))}
</tbody>
</table>
</ScrollArea>
);
};
const LeaveAppTr: FC<{
LeaveTransaction: LeaveTransaction;
staffMap: Record<string, string>;
}> = ({ LeaveTransaction, staffMap }) => {
return (
<tr
key={LeaveTransaction._id.toString()}
className="text-center text-gray-600"
>
<td className="border-b px-4 py-2">{LeaveTransaction.leaveType}</td>
<td className="border-b px-4 py-2">{LeaveTransaction.amount}</td>
<td className="border-b px-4 py-2">{LeaveTransaction.balance}</td>
<td className="border-b px-4 py-2">
{staffMap[LeaveTransaction.grantedTo.toString()] ?? 'Loading...'}
</td>
<td className="border-b px-4 py-2">
{LeaveTransaction.createdAt.toDateString()}
</td>
</tr>
);
};

268
src/app/NavBar.tsx Normal file
View File

@ -0,0 +1,268 @@
import { Menu } from '@ark-ui/react';
import { useViewportSize } from '@mantine/hooks';
import { FC, useState } from 'react';
import { NavLink, useLocation, useMatches, useNavigate } from 'react-router';
import { Group, Stack } from '../components';
import { usePreferenceStore } from '../store';
import { twx } from '../utils';
interface Scene {
title: string;
path: string;
icon?: string;
disable?: boolean;
children?: Scene[];
}
// TODO: update path
const scenes: Scene[] = [
{
title: 'Home',
path: '/',
icon: 'i-ant-design-home-filled',
},
{
title: 'Staff',
path: '/staff-record',
icon: 'i-bi:people-fill',
},
{
title: 'Leave Application',
path: '/leave-application-record',
icon: 'i-mdi:tick',
},
{
title: 'Leave Cancellation',
path: '/leave-cancellation-record',
icon: 'i-mdi:cancel-bold',
},
{
title: 'Leave Transaction',
path: '/leave-transaction-record',
icon: 'i-ant-design:transaction-outlined',
},
{
title: 'Leave Credit Application',
path: '/leave-credit-application-record',
icon: 'i-fluent:wallet-credit-card-24-filled',
},
{
title: 'Attendance',
path: '/attendance-record',
icon: 'i-fluent:wallet-credit-card-24-filled',
},
{
title: 'Report',
path: '/report-record',
icon: 'i-fluent:wallet-credit-card-24-filled',
},
];
export const NavBar: FC = () => {
const navCollapsed = usePreferenceStore.use.navCollapsed();
const togglePreference = usePreferenceStore.use.togglePreference();
return (
<Group className="bg-red overflow-hidden">
<div
className={twx([
'fixed z-50 h-full transition-all ',
navCollapsed ? 'w-16' : 'w-60',
])}
>
<Stack className="h-full w-full flex flex-col items-stretch border-r bg-zinc-100 shadow-lg backdrop-blur-xl">
<div className="ml-auto flex">
<button
className="h-13.5 w-16 flex items-center justify-center p-2 transition-colors duration-200"
onClick={() => togglePreference('navCollapsed')}
>
<div
className={twx(
'text-2xl text-black',
navCollapsed
? 'i-ant-design-menu-fold-outlined rotate-180'
: 'i-ant-design-menu-fold-outlined',
)}
/>
</button>
</div>
<Stack className="z-1 gap-2.5">
{scenes.map((scene) =>
scene.children ? (
<NavGroup key={scene.title} scene={scene} />
) : (
<NavItem key={scene.title} scene={scene} />
),
)}
</Stack>
<Stack
className={twx(
'mt-auto text-black/90 dark:text-white/90 transition-all fixed bottom-2 ',
navCollapsed ? 'left--60' : 'left-0',
)}
></Stack>
</Stack>
</div>
</Group>
);
};
export type NavGroupProps = {
scene: Scene;
};
const NavGroup: FC<NavGroupProps> = ({ scene }) => {
const { title, icon, path, children } = scene;
const matches = useMatches();
const isParent = matches.some(({ pathname }) => {
return pathname.includes(path);
});
const [opened, setOpened] = useState(isParent);
const navCollapsed = usePreferenceStore.use.navCollapsed();
return (
<Stack
className={twx(`transition-all overflow-hidden dark:bg-zinc-800`)}
style={{
height: (navCollapsed ? navCollapsed : !opened)
? '48px'
: `${((scene.children?.length ?? 1) + 1) * 50 + 1}px`,
}}
>
<Menu.Root positioning={{ placement: 'left-start' }} closeOnSelect>
<Menu.Trigger className="flex bg-transparent">
<div
className={twx([
'bg-white dark:bg-zinc-800 items-center transition-all ease duration-200 text-sm border-l-4 text-black/90 dark:text-white/90 hover:dark:bg-zinc-900 pl-2',
isParent
? navCollapsed
? 'border-l-transparent dark:text-[#ffce6f] text-orange-6'
: 'text-orange-6 border-l-amber-600 bg-gradient-to-r from-amber-500/30 dark:text-[#ffce6f]'
: ' border-transparent hover:bg-amber-100',
navCollapsed ? 'w-16' : 'w-full',
])}
onClick={(e) => {
setOpened((o) => !o);
if (!navCollapsed) {
e.preventDefault();
}
}}
>
<Group className="gap-4 p-2.5 py-3.5">
<div className={twx('h-5 w-5')}>
<p className={`${icon} w-full h-full`} />
</div>
<Group
className={twx(
'transition-all overflow-hidden justify-between',
navCollapsed ? 'w-0' : 'flex-1',
)}
>
<p className="text-nowrap">{title}</p>
{!navCollapsed &&
(!opened ? (
<div className="i-mdi-chevron-down ml-1 h-5 w-5" />
) : (
<div className="i-mdi-chevron-up ml-1 h-5 w-5" />
))}
</Group>
</Group>
</div>
</Menu.Trigger>
<Menu.Positioner>
<Menu.Content className="z-20 mt--1 border border-zinc-700 rounded bg-zinc-700 text-white">
{children?.map((scene) => {
return (
<Menu.Item value="" key={scene.path}>
<MenuNavItem scene={scene} />
</Menu.Item>
);
})}
</Menu.Content>
</Menu.Positioner>
</Menu.Root>
<Stack className="border-y-2 p-0 dark:border-zinc-700">
{children?.map((scene) => {
return <NavItem key={scene.path} scene={scene} isChildren />;
})}
</Stack>
</Stack>
);
};
export type NavItemProps = {
scene: Scene;
isChildren?: boolean;
};
const NavItem: FC<NavItemProps> = ({ scene, isChildren }) => {
const { width } = useViewportSize();
const { path, title, icon, disable } = scene;
const navCollapsed = usePreferenceStore.use.navCollapsed();
const updatePreference = usePreferenceStore.use.updatePreference();
return (
<NavLink
key={path}
to={path}
state={{ title }}
className={({ isActive }) =>
twx(
'transition-all ease duration-200 text-sm border-l-4 text-black/90 dark:text-white/90 hover:dark:bg-zinc-900 pl-2',
isActive
? isChildren
? 'border-l-transparent dark:text-orange-2 text-orange-6'
: navCollapsed
? 'border-l-transparent dark:text-orange-2 text-orange-6'
: 'text-orange-6 border-l-orange-6 bg-gradient-to-r from-orange-5/30 dark:bg-zinc-800 dark:text-orange-2'
: 'border-transparent hover:bg-orange-1',
disable && 'text-black/50 dark:text-white/50 pointer-events-none',
navCollapsed ? 'w-16' : 'w-full',
isActive && 'isActive',
)
}
onClick={(e) => {
if (width < 1024) updatePreference({ navCollapsed: true });
if (e.currentTarget.className.includes('isActive')) {
e.preventDefault();
}
}}
>
<Group className="w-full gap-4 p-2.5 py-3.5">
<div className={twx('h-5 w-5')}>
<p className={`${icon} w-full h-full`} />
</div>
<div
className={twx(
'transition-all overflow-hidden',
navCollapsed ? 'w-0' : 'flex-1',
)}
>
<p className="text-nowrap">{title}</p>
</div>
</Group>
</NavLink>
);
};
export type MenuNavItemProps = {
scene: Scene;
};
const MenuNavItem: FC<MenuNavItemProps> = ({ scene }) => {
const { path, title } = scene;
const navigate = useNavigate();
const location = useLocation();
const isActive = location.pathname.includes(path);
return (
<Group
className={twx(
'w-full cursor-pointer justify-center gap-4 p-2 hover:bg-[#745d35]',
isActive && 'bg-[#81683b]',
)}
onClick={() => navigate(path)}
>
{title}
</Group>
);
};

View File

@ -0,0 +1,88 @@
//@unocss-include
import { FC } from 'react';
import { useNavigate } from 'react-router';
import { Group, Stack } from '../components';
import { notifications } from '../demoData/notification';
import { usePreferenceStore } from '../store';
import { Notification } from '../types/notification';
import { twx } from '../utils';
type NotificationCardProps = Notification & {
onClose?: () => void;
};
export const NotificationCard: FC<NotificationCardProps> = ({
type,
title,
message,
actionText,
onClose,
}) => {
const navigate = useNavigate();
return (
<Stack
className={twx(
'p-4 mb-2 rounded-lg border relative',
type === 'Rejected'
? 'bg-red-1 dark:bg-red-950/30 border-red-2 dark:border-red-900'
: 'bg-green-1 dark:bg-green-950/30 border-green-2 dark:border-green-900',
)}
>
<Group className="items-start justify-between">
<Group className="gap-2">
{type === 'Rejected' ? (
<div className="i-mdi-close-circle text-xl text-red-5" />
) : (
<div className="i-mdi-check-circle text-xl text-green-5" />
)}
<p className="font-medium">{title}</p>
</Group>
{onClose && (
<button
className="i-mdi-close text-black/60 dark:text-white/60 hover:text-black dark:hover:text-white"
onClick={onClose}
/>
)}
</Group>
<p className="mt-1 text-sm text-black/70 dark:text-white/70">{message}</p>
<Group className="mt-2 justify-end">
<button
className="rounded px-3 py-1 text-sm hover:bg-black/5 dark:hover:bg-white/5"
onClick={() => {
if (type === 'Approved') {
navigate('/SPV-assign');
} else {
window.open('/editor', '_blank');
}
}}
>
{actionText}
</button>
</Group>
</Stack>
);
};
export const NotificationCenter: FC = () => {
const notifCenterCollapsed = usePreferenceStore.use.notifCenterCollapsed();
return (
<Stack
className={twx(
'absolute right-0 top-14 z-40 h-[calc(100vh-3.8rem)] transition-transform bg-white w-120 shadow-lg text-black/90 dark:text-white/90 dark:bg-zinc-800 border dark:border-zinc-700',
notifCenterCollapsed && 'translate-x-full',
)}
>
<Stack className="p-4">
<Group className="mb-4 items-center justify-between">
<p className="font-medium">Notification Center</p>
</Group>
<Stack className="gap-2">
{notifications.map((notification, index) => (
<NotificationCard key={index} {...notification} />
))}
</Stack>
</Stack>
</Stack>
);
};

View File

@ -0,0 +1,61 @@
import { FC } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Button, Stack } from '../../components';
import { LeaveApplication, soriAPIClient } from '../../lib/sori';
type CancelLeaveApplicationProps = {
onClose: () => void;
leaveApplication: LeaveApplication;
};
export const CancelLeaveApplicationForm: FC<CancelLeaveApplicationProps> = ({
onClose,
leaveApplication,
}) => {
const { handleSubmit } = useForm();
const onFormSubmit = async () => {
const payload = {};
const _id = leaveApplication._id;
const [error] =
await soriAPIClient.leaveApplications.actions.cancelLeaveApplication(
_id,
payload,
);
if (error) {
console.error(error);
toast.error('Failed to cancel leave application');
onClose();
} else {
toast.success('Leave application cancellled');
onClose();
}
};
return (
<div>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Stack className="w-20vw flex flex-col gap-4 border-gray-200 rounded-lg bg-white p-6 text-black sm:max-w-130 dark:bg-zinc-800 dark:text-white/90">
<div className="flex flex-col gap-1">
<label>Confirm to cancel leave application?</label>
</div>
<div className="mx-auto flex items-center gap-4">
<Button className="mt-auto bg-blue-5 text-white" type="submit">
Confirm
</Button>
<Button
className="mt-auto bg-blue-5 text-white"
type="button"
onClick={() => {
onClose();
}}
>
Cancel
</Button>
</div>
</Stack>
</form>
</div>
);
};

View File

@ -0,0 +1,231 @@
import { ObjectId } from 'bson';
import { FC, useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Button, Stack } from '../../components';
import {
LeaveInit,
LeaveType,
soriAPIClient,
WorkDayType,
} from '../../lib/sori';
import { DateRangePicker } from '@/components/DateRangePicker';
import { CSelect } from '@/components/ui/c-select';
type CreateLeaveApplicationProps = {
onClose: () => void;
open: boolean;
};
type FormValues = {
type: LeaveType;
dateRange: { start: Date; end: Date };
workDayType: WorkDayType;
remark: string;
};
export const CreateLeaveApplicationForm: FC<CreateLeaveApplicationProps> = ({
onClose,
open,
}) => {
const { register, handleSubmit, reset, setValue, watch } =
useForm<FormValues>({
defaultValues: {
type: 'annual',
dateRange: { start: new Date(), end: new Date() },
workDayType: 'full',
remark: '',
},
});
useEffect(() => {
if (!open) {
reset({
type: 'annual',
workDayType: 'full',
dateRange: { start: new Date(), end: new Date() },
remark: '',
});
}
}, [open]);
const leaveTypes: LeaveType[] = Object.values(LeaveType.Enum);
const workDayTypes = Object.values(WorkDayType.Enum);
const currentWorkDayType = watch('workDayType');
const currentDateRange = watch('dateRange');
const [halfDayWarning, setHalfDayWarning] = useState<string | null>(null);
const [weekendWarning, setWeekendWarning] = useState<string | null>(null);
const [buttonDisabled, setbuttonDisabled] = useState<boolean>(false);
useEffect(() => {
if (!currentDateRange.start || !currentDateRange.end) {
setHalfDayWarning(null);
setWeekendWarning(null);
setbuttonDisabled(false);
} else if (
currentWorkDayType !== 'full' &&
currentDateRange.start.getTime() !== currentDateRange.end.getTime()
) {
console.log('currentWorkDayType', currentWorkDayType);
console.log('currentDateRange', currentDateRange);
setHalfDayWarning('*Half day can only be applied for a single day.');
setbuttonDisabled(true);
} else if (
currentDateRange.start.getDay() === 0 ||
currentDateRange.end.getDay() === 6
) {
setbuttonDisabled(true);
setWeekendWarning('No leave required for weekends');
} else {
setHalfDayWarning(null);
setWeekendWarning(null);
setbuttonDisabled(false);
}
}, [currentWorkDayType, currentDateRange]);
const onFormSubmit = async (data: FormValues) => {
const fromDate = data.dateRange.start;
const toDate = data.dateRange.end;
const dayInMs = 1000 * 60 * 60 * 24;
let fromDateMs = fromDate.getTime();
const toDateMs = toDate.getTime();
const dateDiff = toDateMs - fromDateMs;
let leaves: LeaveInit[] = [];
if (dateDiff === 0) {
leaves = [
{
type: data.type,
workDay: {
date: `${fromDate.toDateString()}`,
type: data.workDayType,
},
},
];
} else if (dateDiff === dayInMs) {
leaves = [
{
type: data.type,
workDay: {
date: `${fromDate.toDateString()}`,
type: data.workDayType,
},
},
{
type: data.type,
workDay: {
date: `${toDate.toDateString()}`,
type: data.workDayType,
},
},
];
} else {
while (fromDateMs <= toDateMs) {
if (
new Date(fromDateMs).getDay() === 0 ||
new Date(fromDateMs).getDay() === 6
) {
fromDateMs = fromDateMs + dayInMs;
} else {
leaves.push({
type: data.type,
workDay: {
date: `${new Date(fromDateMs).toDateString()}`,
type: data.workDayType,
},
});
fromDateMs = fromDateMs + dayInMs;
}
}
}
const payload = {
status: 'pending',
leaves: leaves,
applyRemarks: data.remark,
appliedBy: new ObjectId('68d50e6f73dd78e6f9d2e098'),
};
const [error] =
await soriAPIClient.leaveApplications.actions.createLeaveApplication(
payload,
);
if (error) {
console.error(error);
onClose();
toast.error('Failed to create leave application');
} else {
toast.success('Leave application created successfully');
onClose();
}
};
return (
<div>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Stack className="w-100vw flex flex-col gap-4 border-gray-200 rounded-lg bg-white p-6 text-black sm:max-w-130 dark:bg-zinc-800 dark:text-white/90">
<h1 className="text-xl">Create Leave</h1>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<label>Leave Type</label>
<CSelect
options={leaveTypes.map((leaveType) => ({
label: leaveType,
value: leaveType,
}))}
onSelect={(selected) => {
const value = selected?.map((opt) => opt.value) ?? [];
setValue('type', value[0] as LeaveType);
}}
/>
</div>
<div className="flex flex-col gap-1">
<label>Full/Half Day</label>
<CSelect
options={workDayTypes.map((workDayType) => ({
label: workDayType,
value: workDayType,
}))}
onSelect={(selected) => {
const value = selected?.map((opt) => opt.value) ?? [];
setValue('workDayType', value[0] as WorkDayType);
}}
/>
</div>
<div className="flex flex-col gap-1">
<label>Dates</label>
<label className="text-xs text-red-6">
{halfDayWarning}
{weekendWarning}
</label>
<DateRangePicker
onChange={(range) => {
setValue('dateRange', {
start: range[0],
end: range[1],
});
}}
/>
</div>
<div className="flex flex-col gap-1">
<label>Remark</label>
<textarea
{...register('remark')}
rows={3}
className="border border-zinc-300 rounded px-2 py-1"
/>
</div>
</div>
<Button
className="mt-auto bg-blue-5 text-white"
type="submit"
disabled={buttonDisabled}
>
Save
</Button>
</Stack>
</form>
</div>
);
};

View File

@ -0,0 +1,201 @@
import { ScrollArea } from '@mantine/core';
import { FC, useEffect, useState } from 'react';
import { useNavigate } from 'react-router';
import { Button, Stack } from '../../components';
import { Dialog } from '../../components/Dialog';
import { Pagination } from '../../components/Pagination';
import { LeaveApplication, soriAPIClient } from '../../lib/sori';
import { CancelLeaveApplicationForm } from './CancelLeaveApplicationForm';
import { CreateLeaveApplicationForm } from './CreateLeaveApplicationForm';
export const ReportScene: FC = () => {
const [createOpen, setCreateOpen] = useState(false);
const [approveOpen, setApproveOpen] = useState(false);
const [leaveApplications, setLeaveApplications] = useState<
LeaveApplication[]
>([]);
const [selectedLeaveApplication, setSelectedLeaveApplication] =
useState<LeaveApplication | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 20;
const paginatedstaffs = leaveApplications.slice(
(currentPage - 1) * pageSize,
currentPage * pageSize,
);
const loadLeaveApplications = async () => {
const leaveApplicationsResult =
await soriAPIClient.leaveApplications.actions.getLeaveApplications();
const leaveApplications = leaveApplicationsResult.unwrap();
setLeaveApplications(leaveApplications);
};
useEffect(() => {
loadLeaveApplications();
}, []);
// const onSubmit = (project: Partial<Project>) => {
// setProjects([...projects, project as Project]);
// setOpen(false);
// };
const navigate = useNavigate();
return (
<>
<Dialog open={createOpen} onRequestClose={() => setCreateOpen(false)}>
<CreateLeaveApplicationForm
onClose={() => {
setCreateOpen(false);
loadLeaveApplications();
}}
open={createOpen}
/>
</Dialog>
<Dialog open={approveOpen} onRequestClose={() => setApproveOpen(false)}>
{selectedLeaveApplication && (
<CancelLeaveApplicationForm
leaveApplication={selectedLeaveApplication}
onClose={() => {
setApproveOpen(false);
setSelectedLeaveApplication(null);
loadLeaveApplications(); // refresh list after delete
}}
/>
)}
</Dialog>
<Stack className="border-black-2 relative mx-auto mt-20px h-[90%] w-[90%] overflow-hidden border rounded-lg shadow-[0_2px_5px_2px_rgba(0,0,0,0.1)] xl:px-20">
<div className="h-20 min-h-12 flex flex-col justify-center gap-1 px-8 text-lg text-xl text-black/90 font-bold font-medium dark:text-white/90">
<div className="flex items-center justify-between">
<span>Reports</span>
<Button
className="bg-blue-6 text-white"
onClick={() => {
setCreateOpen(true);
}}
>
+ Create Leave Application
</Button>
<Button
className="bg-blue-6 text-white"
onClick={() => navigate('/leave-application-record-admin')}
>
Admin Page
</Button>
</div>
</div>
<Stack className="relative h-full w-full overflow-hidden">
<LeaveAppTable
leaveApplications={paginatedstaffs}
onCancelClick={(leaveApplication) => {
setSelectedLeaveApplication(leaveApplication);
setApproveOpen(true);
}}
/>
<Pagination
count={leaveApplications.length}
pageSize={pageSize}
siblingCount={1}
currentPage={currentPage}
onPageChange={setCurrentPage}
/>
</Stack>
</Stack>
</>
);
};
const LeaveAppTable: FC<{
leaveApplications: LeaveApplication[];
onCancelClick: (leaveApplication: LeaveApplication) => void;
}> = ({ leaveApplications, onCancelClick }) => {
return (
<ScrollArea className="mb-1 h-full w-full overflow-hidden border border-gray-300 rounded-lg shadow-[2px_2px_5px_2px_rgba(0,0,0,0.1)]">
<table className="min-w-full">
<thead>
<tr className="text-gray-700">
<th className="border-b px-4 py-2">Leave Type</th>
<th className="border-b px-4 py-2">From Date</th>
<th className="border-b px-4 py-2">To Date</th>
<th className="border-b px-4 py-2">Duration (Days)</th>
<th className="border-b px-4 py-2">Full/Half</th>
<th className="border-b px-4 py-2">Last Updated</th>
<th className="border-b px-4 py-2">Status</th>
<th className="border-b px-4 py-2"></th>
</tr>
</thead>
<tbody>
{leaveApplications.map((leaveApplication) => (
<LeaveAppTr
key={leaveApplication._id.toString()}
LeaveApplication={leaveApplication}
onCancelClick={onCancelClick}
/>
))}
</tbody>
</table>
</ScrollArea>
);
};
const LeaveAppTr: FC<{
LeaveApplication: LeaveApplication;
onCancelClick: (leaveApplication: LeaveApplication) => void;
}> = ({ LeaveApplication, onCancelClick }) => {
const getLastUpdatedTime = (updatedAt: Date) => {
const lastUpdated = new Date(updatedAt);
const today = new Date();
const diffMs = lastUpdated.getTime() - today.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (-diffDays >= 30) {
return `> ${Math.trunc(-diffDays / 30)} 個月`;
}
return `${-diffDays} day(s)`;
};
const workDayType = LeaveApplication.leaves[0].workDay.type;
let leaveDuration = 0;
if (workDayType === 'full') {
leaveDuration = LeaveApplication.leaves.length;
} else {
leaveDuration = 0.5;
}
return (
<tr
key={LeaveApplication._id.toString()}
className="text-center text-gray-600"
>
<td className="border-b px-4 py-2">
{LeaveApplication.leaves[0].type[0].toUpperCase() +
LeaveApplication.leaves[0].type.slice(1)}
</td>
<td className="border-b px-4 py-2">
{LeaveApplication.leaves[0].workDay.date}
</td>
<td className="border-b px-4 py-2">
{
LeaveApplication.leaves[LeaveApplication.leaves.length - 1].workDay
.date
}
</td>
<td className="border-b px-4 py-2">{leaveDuration}</td>
<td className="border-b px-4 py-2">
{LeaveApplication.leaves[0].workDay.type}
</td>
<td className="border-b px-4 py-2">{`${getLastUpdatedTime(LeaveApplication.updatedAt)}`}</td>
<td className="border-b px-4 py-2">{LeaveApplication.status}</td>
<td className="border-b px-4 py-2">
<Button
className="mx-auto border-gray-300 rounded-lg bg-transparent"
onClick={() => onCancelClick(LeaveApplication)}
>
Cancel
</Button>
</td>
</tr>
);
};

66
src/app/Root.tsx Normal file
View File

@ -0,0 +1,66 @@
import { MantineProvider } from '@mantine/core';
import { useSetAtom } from 'jotai';
import { FC, useEffect } from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { Outlet, useNavigation } from 'react-router';
import { getToken, tokenAtom } from '../lib/auth/client';
import { Shell } from './Shell';
import 'ol/ol.css';
export const Root: FC = () => {
const { state } = useNavigation();
const setToken = useSetAtom(tokenAtom);
useEffect(() => {
// Initialize token from localStorage on app start
const token = getToken();
if (token) {
setToken(token);
}
}, [setToken]);
return (
<DndProvider backend={HTML5Backend}>
<MantineProvider>
<Shell>
{state === 'loading' && (
<div className="absolute z-100 h-full w-full flex items-center justify-center bg-white bg-opacity-60">
<div className="flex items-center">
<span className="mr-4 text-3xl">Loading</span>
<svg
className="h-8 w-8 animate-spin text-gray-800"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</div>
</div>
)}
<div
className={
'h-full w-full' +
'dark:from-neutral-900 dark:to-neutral-800 dark:bg-gradient-to-br'
}
>
<Outlet />
</div>
</Shell>
</MantineProvider>
</DndProvider>
);
};

87
src/app/Shell.tsx Normal file
View File

@ -0,0 +1,87 @@
import { FC, PropsWithChildren } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useNavigate } from 'react-router';
import { toast, Toaster } from 'sonner';
import { notifications } from '../demoData/notification';
import { usePreferenceStore } from '../store';
import { twx } from '../utils';
import { Header } from './Header';
import History from './History';
import { NavBar } from './NavBar';
import { NotificationCard, NotificationCenter } from './NotificationCenter';
export const Shell: FC<PropsWithChildren> = ({ children }) => {
const navigate = useNavigate();
// eslint-disable-next-line react-compiler/react-compiler
History.navigate = navigate;
const navCollapsed = usePreferenceStore.use.navCollapsed();
const updatePreference = usePreferenceStore.use.updatePreference();
// TODO: handle login
// useEffect(() => {
// function refreshToken() {
// const token = localStorage.getItem('token');
// if (!token) return navigate('/login');
// }
// refreshToken();
// const intervalId = setInterval(() => {
// refreshToken();
// }, 1800000);
// return () => clearInterval(intervalId);
// }, [navigate]);
useHotkeys('shift+z', () => {
toast.custom((t) => (
<NotificationCard
{...notifications[0]}
onClose={() => toast.dismiss(t)}
/>
));
});
useHotkeys('shift+x', () => {
toast.custom((t) => (
<NotificationCard
{...notifications[1]}
onClose={() => toast.dismiss(t)}
/>
));
});
return (
<div className="relative h-screen flex flex-col overflow-hidden bg-zinc-100 dark:bg-zinc-800">
<Toaster
style={{ zIndex: 50, top: 60, right: 10, width: '28rem' }}
toastOptions={{
style: {
width: '28rem',
},
}}
position="top-right"
expand={true}
richColors
/>
<NavBar />
<Header />
<NotificationCenter />
<main
className={twx([
navCollapsed ? '' : 'lg:ml-60',
'ml-16 relative box-border flex-1 overflow-auto transition-all bg-zinc-100 dark:bg-zinc-700',
])}
>
<div
className={twx(
'pointer-events-auto absolute top-0 z-10 h-full w-full bg-black/40 lg:hidden',
navCollapsed && 'contents',
)}
onClick={() => {
updatePreference({ navCollapsed: true });
}}
/>
{children}
</main>
</div>
);
};

View File

@ -0,0 +1,100 @@
import { FC, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Button, Stack } from '../../components';
import { Role, soriAPIClient } from '../../lib/sori';
type CreateFleetProps = {
onClose: () => void;
open: boolean;
};
type FormValues = {
nameEn: string;
nameZh: string;
role: Role;
hkId: string;
};
export const CreateStaffForm: FC<CreateFleetProps> = ({ onClose, open }) => {
const { register, handleSubmit, reset } = useForm<FormValues>({
defaultValues: {
nameEn: '',
nameZh: '',
role: 'manager',
hkId: '',
},
});
useEffect(() => {
if (!open) {
reset();
}
}, [open]);
const onFormSubmit = async (data: FormValues) => {
const payload = {
nameEN: data.nameEn,
nameZH: data.nameZh,
role: data.role,
hkId: data.hkId,
preferences: { attendanceReminderEnabled: true },
};
const [error] = await soriAPIClient.staffs.actions.createStaff(payload);
if (error) {
console.error(error);
onClose();
toast.error('Failed to create staff');
} else {
toast.success('Staff created successfully');
onClose();
}
};
return (
<div>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Stack className="w-100vw flex flex-col gap-4 border-gray-200 rounded-lg bg-white p-6 text-black sm:max-w-130 dark:bg-zinc-800 dark:text-white/90">
<div className="flex flex-col gap-4">
<h1>Create Staff</h1>
<div className="flex flex-col gap-1">
<label>Name (EN)</label>
<input
{...register('nameEn')}
className="border border-zinc-300 rounded bg-white px-2 py-1"
/>
</div>
<div className="flex flex-col gap-1">
<label>Name (ZH)</label>
<input
{...register('nameZh')}
className="border border-zinc-300 rounded bg-white px-2 py-1"
/>
</div>
<div className="flex flex-col gap-1">
<label>Role</label>
<input
{...register('role')}
className="border border-zinc-300 rounded bg-white px-2 py-1"
/>
</div>
<div className="flex flex-col gap-1">
<label>HK Id</label>
<input
{...register('hkId')}
className="border border-zinc-300 rounded bg-white px-2 py-1"
/>
</div>
</div>
<Button className="mt-auto bg-blue-5 text-white" type="submit">
Save
</Button>
</Stack>
</form>
</div>
);
};

View File

@ -0,0 +1,149 @@
import { ScrollArea } from '@mantine/core';
import { FC, useEffect, useState } from 'react';
import { Button, Stack } from '../../components';
import { Dialog } from '../../components/Dialog';
import { Pagination } from '../../components/Pagination';
import { soriAPIClient, Staff } from '../../lib/sori';
import { CreateStaffForm } from './CreateStaffForm';
export const StaffScene: FC = () => {
const [createOpen, setCreateOpen] = useState(false);
const [staffs, setStaffs] = useState<Staff[]>([]);
const [selectedStaff, setSelectedStaff] = useState<Staff | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 20;
const paginatedstaffs = staffs.slice(
(currentPage - 1) * pageSize,
currentPage * pageSize,
);
const loadStaffs = async () => {
const staffsResult = await soriAPIClient.staffs.actions.getStaffs();
const staffs = staffsResult.unwrap();
setStaffs(staffs);
};
useEffect(() => {
loadStaffs();
}, []);
// const onSubmit = (project: Partial<Project>) => {
// setProjects([...projects, project as Project]);
// setOpen(false);
// };
return (
<>
<Dialog open={createOpen} onRequestClose={() => setCreateOpen(false)}>
<CreateStaffForm
onClose={() => {
setCreateOpen(false);
loadStaffs();
}}
open={createOpen}
/>
</Dialog>
<Stack className="border-black-2 relative mx-auto mt-20px h-[90%] w-[90%] overflow-hidden border rounded-lg shadow-[0_2px_5px_2px_rgba(0,0,0,0.1)] xl:px-20">
<div className="h-20 min-h-12 flex flex-col justify-center gap-1 px-8 text-lg text-xl text-black/90 font-bold font-medium dark:text-white/90">
<div className="flex items-center justify-between">
<span>Staff Management</span>
<Button
className="bg-blue-6 text-white"
onClick={() => {
setCreateOpen(true);
}}
>
+ Create Staff
</Button>
</div>
</div>
<Stack className="relative h-full w-full overflow-hidden">
<StaffTable
staffs={paginatedstaffs}
onDeleteClick={(staff) => {
setSelectedStaff(staff);
}}
/>
<Pagination
count={staffs.length}
pageSize={pageSize}
siblingCount={1}
currentPage={currentPage}
onPageChange={setCurrentPage}
/>
</Stack>
</Stack>
</>
);
};
const StaffTable: FC<{
staffs: Staff[];
onDeleteClick: (staff: Staff) => void;
}> = ({ staffs, onDeleteClick }) => {
return (
<ScrollArea className="mb-1 h-full w-full overflow-hidden border border-gray-300 rounded-lg shadow-[2px_2px_5px_2px_rgba(0,0,0,0.1)]">
<table className="min-w-full">
<thead>
<tr className="text-gray-700">
<th className="border-b px-4 py-2">Staff ID</th>
<th className="border-b px-4 py-2">Name (EN)</th>
<th className="border-b px-4 py-2">Name (ZH)</th>
<th className="border-b px-4 py-2">Role</th>
<th className="border-b px-4 py-2">Last Updated</th>
<th className="border-b px-4 py-2">Action</th>
</tr>
</thead>
<tbody>
{staffs.map((staff) => (
<StaffTr
key={staff.hkId}
Staff={staff}
onDeleteClick={onDeleteClick}
/>
))}
</tbody>
</table>
</ScrollArea>
);
};
const StaffTr: FC<{
Staff: Staff;
onDeleteClick: (staff: Staff) => void;
}> = ({ Staff, onDeleteClick }) => {
const getLastUpdatedTime = (updatedAt: Date) => {
const lastUpdated = new Date(updatedAt);
const today = new Date();
const diffMs = lastUpdated.getTime() - today.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (-diffDays >= 30) {
return `> ${Math.trunc(-diffDays / 30)} 個月`;
}
return `${-diffDays} day(s)`;
};
return (
<tr key={Staff.hkId} className="text-center text-gray-600">
<td className="border-b px-4 py-2">{Staff.hkId}</td>
<td className="border-b px-4 py-2">{Staff.nameEN}</td>
<td className="border-b px-4 py-2">{Staff.nameZH}</td>
<td className="border-b px-4 py-2">{Staff.role.toUpperCase()}</td>
{/* <td className="border-b px-4 py-2">
<SSCBattery ssc={Staff} />
</td> */}
<td className="border-b px-4 py-2">{`${getLastUpdatedTime(Staff.updatedAt)}`}</td>
<td className="border-b px-4 py-2">
<Button
className="mx-auto border-gray-300 rounded-lg bg-transparent"
onClick={() => onDeleteClick(Staff)}
>
Edit
</Button>
</td>
</tr>
);
};

View File

@ -0,0 +1,35 @@
import { FC } from 'react';
import { useNavigate } from 'react-router';
export const ErrorScene: FC = () => {
const navigate = useNavigate();
const handleClearLocalStorage = () => {
localStorage.clear();
};
return (
<div className="h-screen w-screen flex items-center justify-center">
<div className="flex flex-col gap-4 gap-4 p-12 container">
<div className="flex items-center gap-4 text-black/70">
<div className="i-mdi-robot-dead h-12 w-12" />
<div className="text-5xl"></div>
</div>
<a
className="text-blue-600 hover:text-blue-400 cursor-pointer underline"
onClick={() => navigate(-1)}
>
</a>
<a
className="text-blue-600 hover:text-blue-400 cursor-pointer underline"
onClick={() => {
handleClearLocalStorage();
navigate(-1);
}}
>
</a>
</div>
</div>
);
};

2
src/app/error/index.tsx Normal file
View File

@ -0,0 +1,2 @@
// eslint-disable-next-line react-refresh/only-export-components
export * from './ErrorScene';

View File

@ -0,0 +1,118 @@
import { getYear } from 'date-fns';
import { FC, useState } from 'react';
import { useForm, SubmitHandler } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { Toaster } from 'sonner';
import { productVersion } from '../../../package.json';
import LoginBg from '../../assets/loginBg.png';
// import { authClient } from '../../auth/client';
import { Button, Group, Stack } from '../../components';
import { PasswordInput } from '../../components/PasswordInput';
import { RememberCheckbox } from './RememberCheckbox';
type Inputs = {
username: string;
password: string;
};
export const LoginScene: FC = () => {
const navigate = useNavigate();
const { register, handleSubmit } = useForm<Inputs>();
const [failLogin, setFailLogin] = useState(false);
const [loading, setLoading] = useState(false);
const onSubmit: SubmitHandler<Inputs> = ({ username, password }) => {
// TODO: handle login
// setLoading(true);
// authClient.actions
// .generateToken({
// username,
// password,
// allowRefresh: true,
// lifeTime: 1296000,
// audience: 'test-auth',
// })
// .then(([error, result]) => {
// setLoading(false);
// if (error) {
// toast.error((error.error as Error).message, {
// classNames: { icon: 'text-red' },
// });
// setFailLogin(true);
// setLoading(false);
// return;
// }
// const { token } = result;
// localStorage.setItem('token', token);
// navigate('/dashboard');
// });
setFailLogin(false);
setLoading(false);
console.log('username', username);
console.log('password', password);
navigate('/');
};
const [rememberMe, setRememberMe] = useState(true);
return (
<section className="h-100vh w-100vw flex flex-col items-center justify-center overflow-hidden bg-black text-white">
<Toaster style={{ zIndex: 50 }} position="top-right" expand={true} />
<form
className="min-h-105% min-w-110% flex flex-col items-center justify-center bg-cover bg-center"
style={{
backgroundImage: `url(${LoginBg})`, // TODO: change icon
}}
onSubmit={handleSubmit(onSubmit)}
>
<div className="w w-120 flex flex-col items-center rounded-xl p-10">
<div className="mb-10 mt-8 w-full flex flex-col gap-6">
<div className="relative">
<div className="pointer-events-none absolute start-0 inset-y-0 flex items-center ps-3">
<div className="i-ant-design-user-outlined h-5 w-5 text-orange-600"></div>
</div>
<input
type="text"
className="block w-full border border-gray-300 rounded-lg bg-white p-2.5 ps-10 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500"
required
placeholder="username"
{...register('username', { required: true })}
/>
</div>
<PasswordInput
placeholder="password"
{...register('password', { required: true })}
/>
<div className="flex justify-between">
<RememberCheckbox
label="Remember me"
checked={rememberMe}
onChange={() => {
setRememberMe(!rememberMe);
}}
/>
</div>
<Group className="items-center">
<Button
type="submit"
className="h-10 w-30 border-sky-800 bg-sky-600 px-1.5 py-2 text-white"
>
{loading && <div className="i-mdi-loading h-5 animate-spin" />}
Sign In
</Button>
{failLogin && (
<div className="pl-10 text-red">
Incorrect username or password
</div>
)}
</Group>
</div>
</div>
<Stack className="items-center gap-2">
<p>{`© ${getYear(new Date())} F.A.S.T.`}</p>
<p>{`Ver. ${productVersion}`}</p>
</Stack>
</form>
</section>
);
};

View File

@ -0,0 +1,71 @@
import { Checkbox as ArkCheckbox } from '@ark-ui/react';
import { FC } from 'react';
import { CheckboxSizes } from '../../components/constants';
import { Size } from '../../components/types';
import { CheckedState } from '../../types/checkbox';
import { twx } from '../../utils';
interface RememberCheckboxProps {
label?: string;
size?: Size;
checked?: CheckedState;
disabled?: boolean;
inverted?: boolean;
autoFocus?: boolean;
onChange?: (checked: CheckedState) => void;
}
export const RememberCheckbox: FC<RememberCheckboxProps> = ({
label,
size = 'md',
disabled = false,
inverted = false,
checked,
autoFocus,
onChange,
}) => {
const icon =
checked == 'indeterminate'
? 'i-mdi-minus-thick'
: checked == true
? 'i-mdi-check-bold'
: null;
const color =
checked == true
? 'text-white'
: disabled
? inverted
? 'dark:text-black/30 text-white/30'
: 'text-black/30 dark:text-white/30'
: inverted
? 'dark:text-black/80 text-white/80'
: 'text-black/70 dark:text-white/80';
return (
<ArkCheckbox.Root
className="flex items-center gap-2"
checked={checked}
onCheckedChange={
onChange ? ({ checked }) => onChange(checked) : undefined
}
disabled={disabled}
autoFocus={autoFocus}
>
<ArkCheckbox.Control
className={twx([
CheckboxSizes.get(size),
checked == true
? 'bg-sky-500 border-sky-500'
: 'border-black/20 bg-white dark:bg-zinc-600 dark:border-white/20',
'rounded border-1 transition-all',
disabled &&
'bg-gray-200 border-black/10 dark:bg-zinc-500 dark:border-white/20',
])}
>
<div className={twx([icon, color, 'w-full h-full'])} />
</ArkCheckbox.Control>
<ArkCheckbox.Label className="text-black">{label}</ArkCheckbox.Label>
<ArkCheckbox.HiddenInput />
</ArkCheckbox.Root>
);
};

8
src/ark.d.ts vendored Normal file
View File

@ -0,0 +1,8 @@
import { PortalProps } from '@ark-ui/react';
import { PropsWithChildren, ReactNode, ReactPortal } from 'react';
declare module '@ark-ui/react' {
export declare const Portal: (
props: PropsWithChildren<PortalProps>,
) => ReactPortal[] | ReactNode | null | undefined;
}

26
src/assets/Frame 4.svg Normal file
View File

@ -0,0 +1,26 @@
<svg width="50" height="96" viewBox="0 0 50 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Frame 4">
<g id="Group 1">
<path id="Vector" d="M25.3154 0.720746C24.816 0.721401 24.3169 0.716513 23.8176 0.711289C19.9274 0.695351 15.5611 1.21956 12.1654 3.27172C12.0086 3.44501 11.8517 3.6183 11.6901 3.79683C11.0301 4.39929 10.6465 4.47679 9.77274 4.60514C6.81566 5.18777 6.81566 5.18777 5.75045 6.17158C5.26606 7.37405 5.15275 8.57903 5.11025 9.86598C5.10241 10.4081 5.10241 10.4081 4.92271 11.1428C4.40873 11.5412 3.87413 11.7513 3.26723 11.9713C2.86735 12.3715 3.01736 13.1222 3.01053 13.6686C3.00784 13.8245 3.00515 13.9805 3.00239 14.1412C2.97614 15.7739 2.96442 17.4069 2.95375 19.0399C2.94821 19.8151 2.93813 20.5901 2.92404 21.3653C2.90709 22.3003 2.89825 23.235 2.89517 24.1702C2.89264 24.5267 2.88724 24.8831 2.87894 25.2395C2.81646 28.0622 2.81646 28.0622 3.44033 28.7596C3.8986 29.0275 3.8986 29.0275 4.35958 29.1905C4.47713 29.2499 4.59467 29.3092 4.71578 29.3704C4.71578 29.6438 4.71578 29.9172 4.71578 30.1989C4.60054 30.2569 4.4853 30.3148 4.36658 30.3745C2.65855 31.2727 1.13789 32.3093 0.370147 34.1344C0.309726 34.707 0.329608 35.2132 0.370147 35.7915C1.4326 36.1645 2.23049 35.7728 3.22843 35.3902C3.47311 35.2999 3.47311 35.2999 3.72273 35.2077C4.12358 35.0594 4.52327 34.9079 4.92271 34.7558C4.92065 35.1275 4.92065 35.1275 4.91854 35.5066C4.90596 37.8382 4.89665 40.1699 4.89055 42.5016C4.88731 43.7004 4.8829 44.8992 4.87588 46.098C4.86915 47.2544 4.86543 48.4107 4.86381 49.567C4.86266 50.0088 4.86042 50.4505 4.85708 50.8923C4.85259 51.5098 4.85195 52.1272 4.85224 52.7447C4.85 52.9284 4.84777 53.1121 4.84548 53.3013C4.84633 53.4695 4.84718 53.6377 4.84806 53.8109C4.84749 53.957 4.84692 54.1031 4.84633 54.2535C4.92272 54.6406 4.92271 54.6406 5.14106 54.9982C5.75647 55.4015 6.37938 55.3475 7.09093 55.3409C7.24989 55.3418 7.40885 55.3428 7.57262 55.3438C8.10691 55.3463 8.64109 55.3443 9.17539 55.3424C9.55775 55.3432 9.94012 55.3443 10.3225 55.3457C11.3623 55.3485 12.402 55.3474 13.4418 55.3454C14.5286 55.3438 15.6153 55.3453 16.7021 55.3463C18.5273 55.3474 20.3525 55.3459 22.1777 55.343C24.2897 55.3396 26.4016 55.3407 28.5136 55.3441C30.325 55.3469 32.1363 55.3473 33.9477 55.3457C35.0304 55.3447 36.1131 55.3446 37.1958 55.3466C38.2135 55.3484 39.2312 55.3472 40.249 55.3436C40.6231 55.3428 40.9973 55.343 41.3714 55.3444C41.8809 55.3461 42.3903 55.344 42.8998 55.3409C43.049 55.3422 43.1981 55.3436 43.3518 55.3451C43.9385 55.3381 44.3515 55.3247 44.8497 54.9982C45.1134 54.5663 45.1447 54.3157 45.1427 53.8109C45.144 53.5587 45.144 53.5587 45.1453 53.3013C45.1431 53.1176 45.1408 52.9339 45.1385 52.7447C45.1387 52.4536 45.1387 52.4536 45.1388 52.1567C45.1384 51.5144 45.1335 50.8723 45.1287 50.2301C45.1275 49.7852 45.1266 49.3402 45.126 48.8953C45.1236 47.7235 45.1175 46.5518 45.1107 45.3801C45.1043 44.1847 45.1015 42.9893 45.0984 41.7939C45.0917 39.4479 45.0811 37.1019 45.068 34.7558C45.1765 34.7946 45.285 34.8333 45.3968 34.8732C45.89 35.0478 46.3843 35.219 46.8787 35.3902C47.1348 35.4817 47.1348 35.4817 47.3961 35.5751C47.5614 35.6318 47.7268 35.6885 47.8972 35.747C48.0489 35.8002 48.2007 35.8533 48.357 35.9081C48.8495 36.0104 49.1479 35.9491 49.6206 35.7915C49.6156 34.407 49.5512 33.3934 48.5471 32.386C47.7147 31.6694 46.88 31.0138 45.9087 30.4967C45.4276 30.2266 45.4276 30.2266 45.3138 29.6423C45.2946 29.4052 45.2946 29.4052 45.275 29.1633C45.4713 29.1035 45.6676 29.0437 45.8699 28.982C46.5166 28.749 46.5166 28.749 46.9305 28.3347C46.978 27.8229 46.9999 27.3337 47.0037 26.821C47.0064 26.6651 47.0092 26.5091 47.012 26.3484C47.0201 25.8311 47.0249 25.3138 47.0291 24.7965C47.0308 24.6199 47.0324 24.4433 47.0342 24.2614C47.0428 23.3268 47.0487 22.3923 47.0526 21.4576C47.0572 20.4921 47.0714 19.527 47.0879 18.5616C47.0987 17.8193 47.1022 17.0772 47.1036 16.3349C47.1057 15.979 47.1105 15.6231 47.1181 15.2673C47.1745 12.4808 47.1745 12.4808 46.5645 11.7249C45.9979 11.3565 45.5407 11.1428 44.8611 11.1428C44.8719 10.9689 44.8719 10.9689 44.8829 10.7916C44.9359 9.21984 44.8247 7.64332 44.2403 6.17157C43.6883 5.60502 43.1223 5.37739 42.3779 5.13591C42.205 5.07129 42.0322 5.00668 41.8541 4.94011C40.7559 4.56352 39.8093 4.45022 38.6531 4.51451C38.6061 4.39489 38.5592 4.27528 38.5108 4.15203C37.5181 2.44861 35.0868 1.95291 33.3116 1.47226C30.6621 0.872514 28.0218 0.713362 25.3154 0.720746Z" fill="black"/>
<path id="Vector_2" d="M42.6953 56.1938C42.4609 56.1921 42.4609 56.1921 42.2217 56.1903C41.6975 56.1875 41.1736 56.1906 40.6495 56.1937C40.2739 56.1929 39.8982 56.1917 39.5226 56.1903C38.5021 56.1874 37.4815 56.1897 36.461 56.1931C35.3935 56.196 34.326 56.1946 33.2585 56.1939C31.4654 56.1934 29.6724 56.1962 27.8794 56.201C25.8061 56.2065 23.7329 56.2068 21.6596 56.2044C19.6659 56.2022 17.6722 56.2034 15.6785 56.2064C14.8298 56.2076 13.981 56.2075 13.1323 56.2066C12.1327 56.2056 11.1332 56.2077 10.1336 56.2122C9.76662 56.2133 9.39966 56.2134 9.0327 56.2124C8.53193 56.2111 8.03131 56.2137 7.53056 56.2173C7.31249 56.2154 7.31249 56.2154 7.09002 56.2135C6.20102 56.225 5.62508 56.3622 4.92366 56.919C4.79924 57.4498 4.79924 57.4498 4.8062 58.0924C4.80628 58.3328 4.80636 58.5733 4.80644 58.8211C4.81178 59.0806 4.81712 59.3402 4.82262 59.6077C4.82482 60.0063 4.82482 60.0063 4.82706 60.413C4.83101 61.119 4.84117 61.8248 4.85262 62.5307C4.86321 63.2513 4.86793 63.9719 4.87315 64.6925C4.88424 66.106 4.90189 67.5193 4.92367 68.9327C4.77002 68.954 4.61637 68.9754 4.45806 68.9974C3.8153 69.1582 3.50717 69.2681 3.06125 69.7612C2.97562 70.3029 2.94381 70.7592 2.94758 71.3017C2.94547 71.458 2.94335 71.6144 2.94117 71.7755C2.93541 72.2927 2.93582 72.8097 2.93677 73.3269C2.93516 73.6863 2.93337 74.0457 2.93139 74.405C2.92831 75.1585 2.92843 75.9118 2.93061 76.6653C2.933 77.631 2.926 78.5964 2.91639 79.5621C2.9103 80.3043 2.90993 81.0465 2.91122 81.7888C2.91101 82.1448 2.90884 82.5008 2.90468 82.8568C2.89957 83.3548 2.90237 83.8522 2.90716 84.3502C2.904 84.4973 2.90084 84.6445 2.89758 84.796C2.90637 85.2087 2.90637 85.2087 3.06126 85.9175C3.71121 86.4802 4.05765 86.7461 4.92367 86.7461C4.91825 86.9853 4.91283 87.2246 4.90725 87.4712C4.88849 88.3609 4.87663 89.2506 4.86683 90.1405C4.86164 90.5253 4.85458 90.91 4.84561 91.2947C4.83303 91.8485 4.82719 92.4021 4.82263 92.956C4.81729 93.1273 4.81195 93.2987 4.80645 93.4752C4.80637 93.6369 4.80629 93.7985 4.80621 93.9651C4.80276 94.1769 4.80276 94.1769 4.79924 94.393C5.00144 95.0937 5.40201 95.4026 5.95834 95.8599C6.52277 95.9622 6.95838 96.0004 7.52132 95.9914C7.75478 95.9938 7.75478 95.9938 7.99295 95.9963C8.51362 96.0004 9.03397 95.9971 9.55464 95.9939C9.92825 95.9953 10.3018 95.9971 10.6754 95.9994C11.6896 96.0041 12.7037 96.0023 13.7178 95.999C14.779 95.9964 15.8401 95.9988 16.9013 96.0004C18.6833 96.0023 20.4653 95.9998 22.2474 95.9949C24.3079 95.9893 26.3683 95.9911 28.4288 95.9968C30.1977 96.0015 31.9665 96.0021 33.7354 95.9994C34.7919 95.9978 35.8485 95.9976 36.905 96.001C37.8984 96.004 38.8916 96.0019 39.8849 95.996C40.2495 95.9946 40.6142 95.995 40.9788 95.9973C41.4765 96.0001 41.9737 95.9966 42.4714 95.9914C42.6158 95.9937 42.7602 95.996 42.909 95.9984C43.7045 95.9824 44.0179 95.8734 44.6416 95.3608C45.0718 94.8207 45.1974 94.636 45.1865 93.9651C45.1864 93.8034 45.1863 93.6418 45.1862 93.4752C45.1782 93.2182 45.1782 93.2182 45.17 92.956C45.1686 92.7781 45.1671 92.6003 45.1656 92.4171C45.1598 91.8513 45.1469 91.286 45.1337 90.7204C45.1285 90.3359 45.1238 89.9515 45.1195 89.567C45.1081 88.6265 45.0903 87.6864 45.069 86.746C45.2995 86.714 45.2995 86.714 45.5346 86.6813C46.182 86.5193 46.4667 86.3975 46.9314 85.9175C47.0171 85.3758 47.0489 84.9195 47.0451 84.377C47.0472 84.2207 47.0493 84.0643 47.0515 83.9033C47.0573 83.386 47.0569 82.869 47.0559 82.3518C47.0575 81.9924 47.0593 81.6331 47.0613 81.2737C47.0644 80.5202 47.0642 79.7669 47.0621 79.0134C47.0597 78.0477 47.0667 77.0823 47.0763 76.1166C47.0824 75.3744 47.0827 74.6322 47.0814 73.8899C47.0817 73.5339 47.0838 73.1779 47.088 72.8219C47.0931 72.3239 47.0903 71.8265 47.0855 71.3285C47.0887 71.1814 47.0918 71.0343 47.0951 70.8827C47.0863 70.47 47.0863 70.47 46.9314 69.7612C46.2815 69.1985 45.935 68.9327 45.069 68.9327C45.0708 68.7866 45.0727 68.6405 45.0746 68.49C45.0912 67.1125 45.1038 65.735 45.1119 64.3574C45.1162 63.6492 45.1221 62.9411 45.1314 62.2329C45.1422 61.4185 45.1462 60.6042 45.1498 59.7897C45.1541 59.5357 45.1584 59.2818 45.1628 59.0201C45.1628 58.7838 45.1629 58.5475 45.163 58.304C45.1657 57.9921 45.1657 57.9921 45.1685 57.674C44.918 56.2951 43.9151 56.1771 42.6953 56.1938Z" fill="white"/>
<path id="Vector_3" d="M42.6953 56.1938C42.4609 56.1921 42.4609 56.1921 42.2217 56.1903C41.6975 56.1875 41.1736 56.1906 40.6495 56.1937C40.2739 56.1929 39.8982 56.1917 39.5226 56.1903C38.5021 56.1874 37.4815 56.1897 36.461 56.1931C35.3935 56.196 34.326 56.1946 33.2585 56.1939C31.4654 56.1934 29.6724 56.1962 27.8794 56.201C25.8061 56.2065 23.7329 56.2068 21.6596 56.2044C19.6659 56.2022 17.6722 56.2034 15.6785 56.2064C14.8298 56.2076 13.981 56.2075 13.1323 56.2066C12.1327 56.2056 11.1332 56.2077 10.1336 56.2122C9.76662 56.2133 9.39966 56.2134 9.0327 56.2124C8.53193 56.2111 8.03131 56.2137 7.53056 56.2173C7.31249 56.2154 7.31249 56.2154 7.09002 56.2135C6.20102 56.225 5.62508 56.3622 4.92366 56.919C4.79924 57.4498 4.79924 57.4498 4.8062 58.0924C4.80628 58.3328 4.80636 58.5733 4.80644 58.8211C4.81178 59.0806 4.81712 59.3402 4.82262 59.6077C4.82482 60.0063 4.82482 60.0063 4.82706 60.413C4.83101 61.119 4.84117 61.8248 4.85262 62.5307C4.86321 63.2513 4.86793 63.9719 4.87315 64.6925C4.88424 66.106 4.90189 67.5193 4.92367 68.9327C4.77002 68.954 4.61637 68.9754 4.45806 68.9974C3.8153 69.1582 3.50717 69.2681 3.06125 69.7612C2.97562 70.3029 2.94381 70.7592 2.94758 71.3017C2.94547 71.458 2.94335 71.6144 2.94117 71.7755C2.93541 72.2927 2.93582 72.8097 2.93677 73.3269C2.93516 73.6863 2.93337 74.0457 2.93139 74.405C2.92831 75.1585 2.92843 75.9118 2.93061 76.6653C2.933 77.631 2.926 78.5964 2.91639 79.5621C2.9103 80.3043 2.90993 81.0465 2.91122 81.7888C2.91101 82.1448 2.90884 82.5008 2.90468 82.8568C2.89957 83.3548 2.90237 83.8522 2.90716 84.3502C2.904 84.4973 2.90084 84.6445 2.89758 84.796C2.90637 85.2087 2.90637 85.2087 3.06126 85.9175C3.71121 86.4802 4.05765 86.7461 4.92367 86.7461C4.91825 86.9853 4.91283 87.2246 4.90725 87.4712C4.88849 88.3609 4.87663 89.2506 4.86683 90.1405C4.86164 90.5253 4.85458 90.91 4.84561 91.2947C4.83303 91.8485 4.82719 92.4021 4.82263 92.956C4.81729 93.1273 4.81195 93.2987 4.80645 93.4752C4.80637 93.6369 4.80629 93.7985 4.80621 93.9651C4.80276 94.1769 4.80276 94.1769 4.79924 94.393C5.00144 95.0937 5.40201 95.4026 5.95834 95.8599C6.52277 95.9622 6.95838 96.0004 7.52132 95.9914C7.75478 95.9938 7.75478 95.9938 7.99295 95.9963C8.51362 96.0004 9.03397 95.9971 9.55464 95.9939C9.92825 95.9953 10.3018 95.9971 10.6754 95.9994C11.6896 96.0041 12.7037 96.0023 13.7178 95.999C14.779 95.9964 15.8401 95.9988 16.9013 96.0004C18.6833 96.0023 20.4653 95.9998 22.2474 95.9949C24.3079 95.9893 26.3683 95.9911 28.4288 95.9968C30.1977 96.0015 31.9665 96.0021 33.7354 95.9994C34.7919 95.9978 35.8485 95.9976 36.905 96.001C37.8984 96.004 38.8916 96.0019 39.8849 95.996C40.2495 95.9946 40.6142 95.995 40.9788 95.9973C41.4765 96.0001 41.9737 95.9966 42.4714 95.9914C42.6158 95.9937 42.7602 95.996 42.909 95.9984C43.7045 95.9824 44.0179 95.8734 44.6416 95.3608C45.0718 94.8207 45.1974 94.636 45.1865 93.9651C45.1864 93.8034 45.1863 93.6418 45.1862 93.4752C45.1782 93.2182 45.1782 93.2182 45.17 92.956C45.1686 92.7781 45.1671 92.6003 45.1656 92.4171C45.1598 91.8513 45.1469 91.286 45.1337 90.7204C45.1285 90.3359 45.1238 89.9515 45.1195 89.567C45.1081 88.6265 45.0903 87.6864 45.069 86.746C45.2995 86.714 45.2995 86.714 45.5346 86.6813C46.182 86.5193 46.4667 86.3975 46.9314 85.9175C47.0171 85.3758 47.0489 84.9195 47.0451 84.377C47.0472 84.2207 47.0493 84.0643 47.0515 83.9033C47.0573 83.386 47.0569 82.869 47.0559 82.3518C47.0575 81.9924 47.0593 81.6331 47.0613 81.2737C47.0644 80.5202 47.0642 79.7669 47.0621 79.0134C47.0597 78.0477 47.0667 77.0823 47.0763 76.1166C47.0824 75.3744 47.0827 74.6322 47.0814 73.8899C47.0817 73.5339 47.0838 73.1779 47.088 72.8219C47.0931 72.3239 47.0903 71.8265 47.0855 71.3285C47.0887 71.1814 47.0918 71.0343 47.0951 70.8827C47.0863 70.47 47.0863 70.47 46.9314 69.7612C46.2815 69.1985 45.935 68.9327 45.069 68.9327C45.0708 68.7866 45.0727 68.6405 45.0746 68.49C45.0912 67.1125 45.1038 65.735 45.1119 64.3574C45.1162 63.6492 45.1221 62.9411 45.1314 62.2329C45.1422 61.4185 45.1462 60.6042 45.1498 59.7897C45.1541 59.5357 45.1584 59.2818 45.1628 59.0201C45.1628 58.7838 45.1629 58.5475 45.163 58.304C45.1657 57.9921 45.1657 57.9921 45.1685 57.674C44.918 56.2951 43.9151 56.1771 42.6953 56.1938ZM43.4135 57.9546C43.4135 69.3014 43.4135 80.6481 43.4135 92.3386C31.2582 92.3386 19.1028 92.3386 6.57915 92.3386C6.57915 80.9919 6.57914 69.6452 6.57914 57.9547C18.7345 57.9546 30.8898 57.9546 43.4135 57.9546Z" fill="black"/>
<path id="Vector_4" d="M7.81954 29.992C7.93694 31.1686 8.2566 32.1475 8.70387 33.2381C8.80456 33.488 8.80456 33.488 8.9073 33.7429C9.04878 34.0928 9.19109 34.4423 9.33417 34.7915C9.55275 35.326 9.76786 35.8617 9.98266 36.3977C10.1209 36.7384 10.2594 37.0791 10.3981 37.4196C10.4622 37.5795 10.5262 37.7394 10.5922 37.9041C10.6844 38.1272 10.6844 38.1272 10.7784 38.3547C10.8315 38.4851 10.8846 38.6155 10.9394 38.7499C11.1688 39.1772 11.3714 39.4224 11.7513 39.7272C12.2396 39.8054 12.2396 39.8054 12.8274 39.8061C13.1616 39.8087 13.1616 39.8087 13.5026 39.8114C13.7471 39.8102 13.9915 39.8089 14.2434 39.8076C14.6296 39.8092 14.6296 39.8092 15.0235 39.8109C15.7299 39.8132 16.4361 39.8126 17.1424 39.8107C17.8812 39.8091 18.62 39.8106 19.3587 39.8115C20.5994 39.8127 21.8401 39.8112 23.0808 39.8082C24.5157 39.8048 25.9505 39.8059 27.3855 39.8093C28.6169 39.8122 29.8483 39.8126 31.0797 39.8109C31.8155 39.81 32.5512 39.8098 33.2869 39.8119C34.1069 39.814 34.9268 39.8111 35.7468 39.8076C35.9913 39.8089 36.2358 39.8101 36.4877 39.8114C36.7105 39.8097 36.9333 39.8079 37.1629 39.8061C37.4538 39.8058 37.4538 39.8058 37.7506 39.8054C38.336 39.7117 38.5065 39.5738 38.8597 39.1058C39.0763 38.6278 39.2709 38.159 39.4547 37.6688C39.5636 37.3869 39.6728 37.1051 39.7821 36.8233C39.8358 36.6835 39.8896 36.5437 39.945 36.3996C40.1638 35.8388 40.3972 35.2846 40.6316 34.7301C40.7999 34.3246 40.9681 33.9189 41.136 33.5132C41.2355 33.2769 41.335 33.0406 41.4375 32.7972C41.5344 32.5593 41.6312 32.3214 41.731 32.0762C41.8201 31.8608 41.9092 31.6453 42.001 31.4233C42.1921 30.7444 42.2618 30.4283 41.9638 29.7848C40.979 29.2423 39.9378 28.9845 38.8468 28.7362C38.7098 28.703 38.5729 28.6698 38.4317 28.6355C30.8113 26.8621 14.7051 25.2835 7.81954 29.992Z" fill="white"/>
</g>
<path id="Vector_5" d="M7.19859 32.8911C7.06201 32.8911 6.92543 32.8911 6.78472 32.8911C6.77993 35.473 6.77625 38.0549 6.774 40.6368C6.77291 41.8357 6.77145 43.0345 6.76911 44.2333C6.76686 45.3896 6.76562 46.546 6.76509 47.7023C6.7647 48.1441 6.76395 48.5858 6.76284 49.0276C6.76134 49.645 6.76113 50.2625 6.76123 50.8799C6.76048 51.0636 6.75974 51.2473 6.75897 51.4366C6.75926 51.6048 6.75954 51.7729 6.75983 51.9462C6.75964 52.0923 6.75945 52.2383 6.75926 52.3888C6.78472 52.7758 6.78472 52.7758 6.99165 53.3972C8.01598 53.3972 9.04031 53.3972 10.0957 53.3972C10.1145 51.7892 10.1296 50.1812 10.1386 48.573C10.1429 47.8261 10.1487 47.0792 10.1581 46.3323C10.1672 45.6098 10.1721 44.8873 10.1742 44.1647C10.1765 43.7586 10.1829 43.3527 10.1895 42.9467C10.1899 41.2819 9.92631 40.0068 9.26793 38.4837C9.10605 38.0198 8.94546 37.5555 8.78697 37.0904C8.70655 36.8557 8.62612 36.621 8.54326 36.3792C8.37697 35.8927 8.21101 35.4062 8.04532 34.9196C7.9657 34.6875 7.88607 34.4555 7.80403 34.2164C7.73236 34.0064 7.66069 33.7964 7.58684 33.58C7.40552 33.0982 7.40552 33.0982 7.19859 32.8911Z" fill="white"/>
<path id="Vector_6" d="M43.205 33.0986C43.0002 33.0986 42.7953 33.0986 42.5842 33.0986C42.5101 33.3234 42.4359 33.5482 42.3596 33.7799C42.0852 34.6112 41.8101 35.4422 41.5347 36.2731C41.4154 36.6333 41.2964 36.9936 41.1776 37.3539C41.0071 37.8708 40.8358 38.3874 40.6644 38.904C40.6112 39.066 40.5579 39.2281 40.5031 39.395C40.3021 40.1366 40.3022 40.1366 39.8941 40.7625C39.8746 41.1566 39.8698 41.5514 39.8706 41.9459C39.8706 42.1989 39.8706 42.4518 39.8706 42.7124C39.8717 42.9905 39.8728 43.2686 39.8739 43.5467C39.8743 43.8297 39.8745 44.1127 39.8748 44.3956C39.8755 45.1417 39.8776 45.8878 39.8799 46.6339C39.882 47.3947 39.8829 48.1555 39.884 48.9163C39.8862 50.4101 39.8897 51.9039 39.8941 53.3976C40.3682 53.4143 40.8425 53.4264 41.3168 53.4364C41.7129 53.4473 41.7129 53.4473 42.117 53.4583C42.3395 53.4383 42.562 53.4182 42.7912 53.3976C43.3409 52.5723 43.2599 51.857 43.252 50.8993C43.2521 50.7073 43.2521 50.5153 43.2522 50.3175C43.2519 49.682 43.2487 49.0465 43.2455 48.4111C43.2447 47.9708 43.2441 47.5305 43.2437 47.0902C43.2421 45.9307 43.238 44.7712 43.2335 43.6118C43.2292 42.4289 43.2273 41.246 43.2252 40.0631C43.2208 37.7416 43.2137 35.4201 43.205 33.0986Z" fill="white"/>
<path id="Vector_7" d="M38.2389 63.1323C37.5566 63.2006 37.2823 63.2613 36.7903 63.7537C36.7382 64.162 36.7382 64.162 36.7378 64.6722C36.736 64.9619 36.736 64.9619 36.7342 65.2574C36.7351 65.47 36.7359 65.6825 36.7368 65.9015C36.736 66.1248 36.7353 66.3481 36.7346 66.5782C36.7327 67.3194 36.734 68.0606 36.7354 68.8018C36.7351 69.3152 36.7347 69.8286 36.7342 70.342C36.7335 71.5575 36.7349 72.773 36.7371 73.9885C36.7387 74.9584 36.7381 75.9283 36.7364 76.8982C36.734 78.2336 36.7336 79.5691 36.7349 80.9045C36.7352 81.4145 36.7349 81.9244 36.7339 82.4344C36.7329 83.1466 36.7345 83.8587 36.7368 84.5709C36.7359 84.783 36.7351 84.9951 36.7342 85.2136C36.7354 85.4073 36.7366 85.601 36.7378 85.8006C36.7379 85.9691 36.7381 86.1375 36.7382 86.311C36.7903 86.7454 36.7903 86.7454 37.2042 87.3668C37.7215 87.4315 37.7215 87.4315 38.2389 87.3668C38.7485 86.8567 38.7047 86.7182 38.7053 86.0169C38.7065 85.82 38.7076 85.6231 38.7088 85.4202C38.708 85.2035 38.7072 84.9868 38.7063 84.7635C38.707 84.5358 38.7077 84.3081 38.7085 84.0735C38.7103 83.3179 38.7091 82.5623 38.7077 81.8066C38.708 81.2834 38.7084 80.7602 38.7089 80.2371C38.7095 79.1393 38.7087 78.0417 38.7067 76.9439C38.7044 75.6739 38.7052 74.4039 38.7074 73.1339C38.7096 71.9134 38.7093 70.6929 38.7081 69.4723C38.7078 68.9524 38.7082 68.4324 38.7091 67.9124C38.7102 67.1868 38.7086 66.4612 38.7063 65.7356C38.7071 65.5189 38.708 65.3022 38.7088 65.0789C38.7077 64.882 38.7065 64.6851 38.7053 64.4822C38.7051 64.3105 38.705 64.1388 38.7048 63.9619C38.6527 63.5466 38.6527 63.5466 38.2389 63.1323Z" fill="black"/>
<path id="Vector_8" d="M12.036 63.1845C11.5445 63.3399 11.5445 63.3399 11.3376 63.547C11.3169 63.8469 11.3106 64.1478 11.3099 64.4484C11.3091 64.643 11.3082 64.8376 11.3073 65.0381C11.3074 65.2533 11.3075 65.4685 11.3076 65.6902C11.3069 65.9155 11.3062 66.1408 11.3054 66.373C11.3035 67.122 11.3031 67.8711 11.3029 68.6201C11.3022 69.1384 11.3015 69.6568 11.3008 70.1751C11.2995 71.2634 11.2991 72.3518 11.2993 73.4401C11.2994 74.6987 11.2972 75.9573 11.2939 77.2159C11.2909 78.4254 11.2902 79.635 11.2903 80.8446C11.29 81.3598 11.2891 81.8749 11.2875 82.3901C11.2855 83.1097 11.2861 83.8293 11.2873 84.549C11.2862 84.7633 11.285 84.9777 11.2838 85.1986C11.2847 85.3943 11.2856 85.5901 11.2864 85.7918C11.2863 85.962 11.2862 86.1322 11.2861 86.3076C11.3376 86.7458 11.3376 86.7458 11.7515 87.3672C12.2688 87.4449 12.2688 87.4449 12.7862 87.3672C13.2234 86.7108 13.2521 86.5529 13.254 85.8024C13.2554 85.609 13.2569 85.4156 13.2584 85.2163C13.2579 85.0045 13.2574 84.7928 13.2569 84.5746C13.2579 84.3512 13.259 84.1279 13.2601 83.8978C13.2629 83.1577 13.2627 82.4176 13.2623 81.6775C13.2631 81.1644 13.264 80.6513 13.265 80.1382C13.2665 79.0621 13.2664 77.9861 13.2653 76.91C13.2642 75.8033 13.2657 74.6966 13.2696 73.59C13.2742 72.2556 13.2759 70.9213 13.275 69.587C13.2751 69.0776 13.2762 68.5682 13.2783 68.0588C13.2809 67.3476 13.2795 66.6365 13.2771 65.9254C13.2786 65.7136 13.2802 65.5018 13.2819 65.2836C13.2804 65.0902 13.279 64.8968 13.2775 64.6975C13.2775 64.5293 13.2775 64.3612 13.2776 64.1879C13.1399 63.418 12.8115 63.1106 12.036 63.1845Z" fill="black"/>
<path id="Vector_9" d="M24.866 63.1069C24.7038 63.1155 24.5416 63.124 24.3745 63.1328C24.1262 63.6299 24.1415 63.9369 24.1413 64.492C24.1407 64.6909 24.1401 64.8899 24.1395 65.0948C24.1399 65.3138 24.1404 65.5328 24.1408 65.7584C24.1404 65.9885 24.1401 66.2185 24.1397 66.4555C24.1388 67.2189 24.1394 67.9823 24.1401 68.7456C24.1399 69.274 24.1397 69.8023 24.1395 70.3306C24.1392 71.4389 24.1396 72.5472 24.1406 73.6556C24.1417 74.9385 24.1413 76.2215 24.1402 77.5044C24.1392 78.7369 24.1393 79.9694 24.1399 81.2019C24.14 81.7272 24.1398 82.2526 24.1394 82.7779C24.1388 83.5105 24.1396 84.243 24.1408 84.9756C24.1404 85.195 24.1399 85.4144 24.1395 85.6405C24.1401 85.8389 24.1407 86.0372 24.1413 86.2416C24.1414 86.415 24.1414 86.5884 24.1415 86.7671C24.1676 87.1602 24.1676 87.1602 24.3745 87.3673C24.9953 87.3932 24.9953 87.3932 25.6161 87.3673C25.9452 87.0379 25.8498 86.6985 25.8507 86.2416C25.8516 86.0432 25.8525 85.8449 25.8534 85.6405C25.8533 85.4211 25.8532 85.2017 25.8531 84.9756C25.8538 84.746 25.8545 84.5163 25.8552 84.2796C25.8572 83.5161 25.8576 82.7527 25.8578 81.9892C25.8585 81.461 25.8592 80.9329 25.8599 80.4048C25.8611 79.2961 25.8615 78.1874 25.8614 77.0787C25.8613 75.7959 25.8634 74.5131 25.8667 73.2302C25.8697 71.9979 25.8705 70.7655 25.8704 69.5331C25.8706 69.008 25.8715 68.4828 25.8731 67.9576C25.8751 67.2245 25.8746 66.4915 25.8733 65.7584C25.8745 65.5395 25.8756 65.3205 25.8768 65.0948C25.876 64.8959 25.8751 64.6969 25.8742 64.492C25.8743 64.3185 25.8744 64.145 25.8745 63.9663C25.7897 63.2759 25.5372 63.1389 24.866 63.1069Z" fill="black"/>
<path id="Vector_10" d="M17.9598 63.3396C17.7529 63.5467 17.7529 63.5467 17.7274 63.941C17.7277 64.2019 17.7277 64.2019 17.728 64.4681C17.7277 64.667 17.7274 64.866 17.7271 65.071C17.7279 65.2911 17.7286 65.5111 17.7294 65.7378C17.7293 65.9682 17.7293 66.1985 17.7293 66.4359C17.7294 67.2013 17.731 67.9668 17.7326 68.7323C17.733 69.2614 17.7333 69.7905 17.7335 70.3197C17.7342 71.5726 17.7359 72.8256 17.738 74.0785C17.7403 75.5046 17.7414 76.9307 17.7424 78.3568C17.7446 81.2912 17.7483 84.2255 17.7529 87.1598C18.2342 87.2387 18.7173 87.3074 19.2014 87.3669C19.4083 87.1598 19.4083 87.1598 19.4338 86.7623C19.4336 86.5869 19.4334 86.4114 19.4332 86.2306C19.4335 86.0299 19.4338 85.8292 19.4341 85.6224C19.4333 85.4004 19.4326 85.1784 19.4318 84.9497C19.4319 84.7173 19.4319 84.485 19.4319 84.2456C19.4318 83.4733 19.4302 82.7011 19.4285 81.9289C19.4282 81.3952 19.4279 80.8614 19.4277 80.3276C19.4269 79.0637 19.4253 77.7997 19.4232 76.5358C19.4209 75.0972 19.4198 73.6585 19.4187 72.2199C19.4166 69.2598 19.4129 66.2997 19.4083 63.3396C18.853 63.0617 18.549 63.1884 17.9598 63.3396Z" fill="black"/>
<path id="Vector_11" d="M31.8235 63.1323C30.8808 63.2474 30.8808 63.2474 30.5819 63.5466C30.5616 63.8527 30.5557 64.1598 30.5556 64.4665C30.555 64.6652 30.5544 64.8639 30.5538 65.0686C30.5542 65.2883 30.5547 65.508 30.5551 65.7344C30.5547 65.9644 30.5544 66.1944 30.554 66.4314C30.5531 67.1959 30.5537 67.9604 30.5544 68.7249C30.5542 69.2535 30.554 69.7821 30.5538 70.3108C30.5535 71.4203 30.5539 72.5299 30.5549 73.6394C30.556 74.9239 30.5557 76.2084 30.5545 77.4929C30.5535 78.7264 30.5536 79.9598 30.5542 81.1933C30.5543 81.7192 30.5541 82.2451 30.5537 82.7711C30.5531 83.5047 30.5539 84.2382 30.5551 84.9718C30.5547 85.1916 30.5543 85.4113 30.5538 85.6377C30.5544 85.8364 30.555 86.035 30.5556 86.2397C30.5557 86.4134 30.5557 86.5871 30.5558 86.766C30.5819 87.1597 30.5819 87.1597 30.7888 87.3668C31.4096 87.3927 31.4096 87.3927 32.0304 87.3668C32.3595 87.0374 32.2641 86.698 32.265 86.2411C32.2659 86.0427 32.2668 85.8444 32.2677 85.64C32.2676 85.4206 32.2675 85.2012 32.2674 84.9751C32.2681 84.7455 32.2688 84.5158 32.2695 84.2791C32.2715 83.5156 32.2719 82.7522 32.2721 81.9887C32.2728 81.4605 32.2735 80.9324 32.2742 80.4043C32.2754 79.2956 32.2758 78.1869 32.2757 77.0782C32.2756 75.7954 32.2778 74.5125 32.281 73.2297C32.284 71.9974 32.2848 70.765 32.2847 69.5326C32.2849 69.0075 32.2858 68.4823 32.2874 67.9571C32.2894 67.224 32.2889 66.491 32.2876 65.7579C32.2888 65.539 32.2899 65.32 32.2911 65.0943C32.2903 64.8954 32.2894 64.6964 32.2885 64.4915C32.2886 64.318 32.2887 64.1445 32.2888 63.9658C32.2373 63.5466 32.2373 63.5466 31.8235 63.1323Z" fill="black"/>
<path id="Vector_12" d="M35.9631 7.41406C35.8948 7.41406 35.8265 7.41406 35.7561 7.41406C35.5472 10.2892 36.6549 13.2371 37.6185 15.9065C37.6704 16.0507 37.7223 16.1949 37.7758 16.3434C37.9373 16.7896 38.1007 17.235 38.2652 17.6801C38.3152 17.8157 38.3652 17.9514 38.4167 18.0911C38.7246 18.9095 39.0775 19.688 39.481 20.4634C39.703 19.7965 39.6823 19.6526 39.4999 19.0064C39.4303 18.7551 39.4303 18.7551 39.3593 18.4986C39.3071 18.3174 39.255 18.1362 39.2013 17.9495C39.1483 17.7605 39.0954 17.5716 39.0408 17.3769C38.8701 16.7698 38.697 16.1634 38.5239 15.557C38.4119 15.1588 38.3001 14.7606 38.1884 14.3623C37.6838 12.5699 37.1678 10.7814 36.6178 9.00235C36.5753 8.86381 36.5328 8.72527 36.489 8.58254C36.1953 7.6465 36.1953 7.6465 35.9631 7.41406Z" fill="white"/>
<path id="Vector_13" d="M14.2334 7.41406C13.6118 7.93136 13.4572 8.40135 13.2504 9.17469C13.1808 9.43155 13.1112 9.68841 13.0395 9.95305C12.8395 10.7195 12.643 11.4869 12.4461 12.2542C12.3854 12.4907 12.3246 12.7273 12.262 12.971C12.1362 13.4613 12.0108 13.9516 11.8858 14.442C11.4674 16.0681 10.9994 17.6644 10.4568 19.253C10.3016 19.842 10.3016 19.842 10.5085 20.4634C14.2848 11.6907 14.2848 11.6907 14.2463 8.48856C14.2444 8.28644 14.2426 8.08431 14.2406 7.87606C14.2382 7.7236 14.2358 7.57114 14.2334 7.41406Z" fill="white"/>
<path id="Vector_14" d="M38.0313 13.8354C37.7581 13.9722 37.485 14.1089 37.2035 14.2497C37.472 15.3991 37.8042 16.4879 38.2253 17.5897C38.2774 17.7299 38.3296 17.8701 38.3833 18.0145C38.7021 18.8585 39.0621 19.6625 39.4798 20.4637C39.7475 19.6599 39.575 19.2793 39.3448 18.4676C39.3078 18.3352 39.2707 18.2027 39.2326 18.0662C39.114 17.6437 38.993 17.2219 38.8719 16.8C38.7911 16.5135 38.7104 16.227 38.6298 15.9404C38.4321 15.2382 38.2323 14.5367 38.0313 13.8354Z" fill="white"/>
<path id="Vector_15" d="M12.165 14.25C11.684 14.7315 11.6306 14.9955 11.4666 15.6481C11.1579 16.852 10.8042 18.039 10.4416 19.2277C10.3026 19.8426 10.3026 19.8426 10.5095 20.464C10.8177 19.7313 11.123 18.9974 11.4278 18.2632C11.5584 17.9528 11.5584 17.9528 11.6917 17.6361C11.7751 17.4345 11.8584 17.2329 11.9443 17.0253C12.0213 16.8408 12.0982 16.6564 12.1775 16.4665C12.3885 15.8595 12.5224 15.3049 12.5789 14.6643C12.4423 14.5276 12.3057 14.3909 12.165 14.25Z" fill="white"/>
<path id="Vector_16" d="M38.8588 16.9419C38.4449 17.5633 38.4449 17.5633 38.5257 17.9954C38.5844 18.1518 38.6431 18.3083 38.7036 18.4695C38.7936 18.715 38.7936 18.715 38.8854 18.9655C38.9747 19.1942 38.9747 19.1942 39.0657 19.4275C39.1468 19.6368 39.2279 19.8461 39.3114 20.0618C39.3947 20.2605 39.3947 20.2605 39.4796 20.4631C39.7467 19.6611 39.5258 19.1287 39.2856 18.353C39.225 18.1497 39.225 18.1497 39.1631 17.9424C39.0633 17.6084 38.9612 17.2751 38.8588 16.9419Z" fill="white"/>
<path id="Vector_17" d="M35.9617 7.41406C35.8934 7.41406 35.8251 7.41406 35.7548 7.41406C35.7548 8.02925 35.7548 8.64443 35.7548 9.27825C35.8913 9.14155 36.0279 9.00484 36.1686 8.86399C36.3052 9.1374 36.4418 9.41082 36.5825 9.69252C36.6508 9.55581 36.7191 9.4191 36.7894 9.27825C36.6286 8.83001 36.6286 8.83001 36.3885 8.32027C36.3098 8.15098 36.2311 7.9817 36.15 7.80729C36.0879 7.67753 36.0257 7.54776 35.9617 7.41406Z" fill="white"/>
<path id="Vector_18" d="M36.1686 8.65674C36.1003 8.65674 36.032 8.65674 35.9617 8.65674C35.9617 9.27192 35.9617 9.8871 35.9617 10.5209C36.03 10.3842 36.0983 10.2475 36.1686 10.1067C36.3735 10.175 36.5784 10.2434 36.7894 10.3138C36.6596 9.70715 36.4612 9.20337 36.1686 8.65674Z" fill="white"/>
<path id="Vector_19" d="M14.2327 7.41406C14.0278 7.61912 13.8229 7.82418 13.6119 8.03546C13.6119 8.24052 13.6119 8.44558 13.6119 8.65686C13.7484 8.65686 13.885 8.65686 14.0257 8.65686C14.0257 8.99863 14.0257 9.34039 14.0257 9.69252C14.094 9.69252 14.1623 9.69252 14.2327 9.69252C14.2327 8.94063 14.2327 8.18874 14.2327 7.41406Z" fill="white"/>
<path id="Vector_20" d="M13.2008 86.7456C13.0641 86.8824 12.9275 87.0192 12.7867 87.1601C12.2544 87.2943 12.2544 87.2943 11.7514 87.3674C12.0247 87.4358 12.298 87.5041 12.5796 87.5746C12.5796 87.7114 12.5796 87.8482 12.5796 87.9891C12.7846 87.9207 12.9896 87.8523 13.2008 87.7819C13.2691 87.5767 13.3374 87.3715 13.4078 87.1601C13.3395 87.0233 13.2712 86.8865 13.2008 86.7456Z" fill="white"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 28 KiB

BIN
src/assets/ai.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 815 KiB

BIN
src/assets/ai2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 623 KiB

View File

@ -0,0 +1,11 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_180582_31967)">
<path d="M40.4612 35.0871L36.7125 31.3384C37.6085 30.1531 38.1429 28.6746 38.1429 27.0714C38.1429 23.1661 34.9768 20 31.0714 20C27.1661 20 24 23.1661 24 27.0714C24 30.9768 27.1661 34.1429 31.0714 34.1429C32.5098 34.1429 33.8437 33.7129 34.9607 32.9777L38.7656 36.7826C38.8299 36.8469 38.9103 36.875 38.9906 36.875C39.071 36.875 39.1554 36.8429 39.2156 36.7826L40.4612 35.5371C40.4908 35.5076 40.5143 35.4725 40.5304 35.4339C40.5464 35.3953 40.5547 35.3539 40.5547 35.3121C40.5547 35.2702 40.5464 35.2288 40.5304 35.1902C40.5143 35.1516 40.4908 35.1165 40.4612 35.0871ZM31.0714 31.5714C28.5844 31.5714 26.5714 29.5585 26.5714 27.0714C26.5714 24.5844 28.5844 22.5714 31.0714 22.5714C33.5585 22.5714 35.5714 24.5844 35.5714 27.0714C35.5714 29.5585 33.5585 31.5714 31.0714 31.5714Z" fill="#fff" fill-opacity="0.85"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M1 20C1 10.0598 9.05982 2 19 2C27.5654 2 34.7347 7.9846 36.5539 16H33.4048C31.6552 9.68862 25.8673 5.05357 19 5.05357C10.7473 5.05357 4.05357 11.7473 4.05357 20C4.05357 28.2527 10.7473 34.9464 19 34.9464C19.6781 34.9464 20.3458 34.9012 21 34.8137V37.8901C20.3433 37.9627 19.676 38 19 38C9.05982 38 1 29.9402 1 20ZM17.6357 28.4351C17.274 28.0735 17.0708 27.5829 17.0708 27.0714C17.0708 26.5599 17.274 26.0694 17.6357 25.7077C17.9973 25.346 18.4879 25.1429 18.9994 25.1429C19.5109 25.1429 20.0014 25.346 20.3631 25.7077C20.7248 26.0694 20.9279 26.5599 20.9279 27.0714C20.9279 27.5829 20.7248 28.0735 20.3631 28.4351C20.0014 28.7968 19.5109 29 18.9994 29C18.4879 29 17.9973 28.7968 17.6357 28.4351ZM19.9637 22.5714H18.0351C17.8583 22.5714 17.7137 22.4268 17.7137 22.25V11.3214C17.7137 11.1446 17.8583 11 18.0351 11H19.9637C20.1404 11 20.2851 11.1446 20.2851 11.3214V22.25C20.2851 22.4268 20.1404 22.5714 19.9637 22.5714Z" fill="#fff" fill-opacity="0.85"/>
</g>
<defs>
<clipPath id="clip0_180582_31967">
<rect width="40" height="40" fill="#fff"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
src/assets/car-arrow.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

26
src/assets/car.svg Normal file
View File

@ -0,0 +1,26 @@
<svg width="50" height="96" viewBox="0 0 50 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Frame 4">
<g id="Group 1">
<path id="Vector" d="M25.3154 0.720746C24.816 0.721401 24.3169 0.716513 23.8176 0.711289C19.9274 0.695351 15.5611 1.21956 12.1654 3.27172C12.0086 3.44501 11.8517 3.6183 11.6901 3.79683C11.0301 4.39929 10.6465 4.47679 9.77274 4.60514C6.81566 5.18777 6.81566 5.18777 5.75045 6.17158C5.26606 7.37405 5.15275 8.57903 5.11025 9.86598C5.10241 10.4081 5.10241 10.4081 4.92271 11.1428C4.40873 11.5412 3.87413 11.7513 3.26723 11.9713C2.86735 12.3715 3.01736 13.1222 3.01053 13.6686C3.00784 13.8245 3.00515 13.9805 3.00239 14.1412C2.97614 15.7739 2.96442 17.4069 2.95375 19.0399C2.94821 19.8151 2.93813 20.5901 2.92404 21.3653C2.90709 22.3003 2.89825 23.235 2.89517 24.1702C2.89264 24.5267 2.88724 24.8831 2.87894 25.2395C2.81646 28.0622 2.81646 28.0622 3.44033 28.7596C3.8986 29.0275 3.8986 29.0275 4.35958 29.1905C4.47713 29.2499 4.59467 29.3092 4.71578 29.3704C4.71578 29.6438 4.71578 29.9172 4.71578 30.1989C4.60054 30.2569 4.4853 30.3148 4.36658 30.3745C2.65855 31.2727 1.13789 32.3093 0.370147 34.1344C0.309726 34.707 0.329608 35.2132 0.370147 35.7915C1.4326 36.1645 2.23049 35.7728 3.22843 35.3902C3.47311 35.2999 3.47311 35.2999 3.72273 35.2077C4.12358 35.0594 4.52327 34.9079 4.92271 34.7558C4.92065 35.1275 4.92065 35.1275 4.91854 35.5066C4.90596 37.8382 4.89665 40.1699 4.89055 42.5016C4.88731 43.7004 4.8829 44.8992 4.87588 46.098C4.86915 47.2544 4.86543 48.4107 4.86381 49.567C4.86266 50.0088 4.86042 50.4505 4.85708 50.8923C4.85259 51.5098 4.85195 52.1272 4.85224 52.7447C4.85 52.9284 4.84777 53.1121 4.84548 53.3013C4.84633 53.4695 4.84718 53.6377 4.84806 53.8109C4.84749 53.957 4.84692 54.1031 4.84633 54.2535C4.92272 54.6406 4.92271 54.6406 5.14106 54.9982C5.75647 55.4015 6.37938 55.3475 7.09093 55.3409C7.24989 55.3418 7.40885 55.3428 7.57262 55.3438C8.10691 55.3463 8.64109 55.3443 9.17539 55.3424C9.55775 55.3432 9.94012 55.3443 10.3225 55.3457C11.3623 55.3485 12.402 55.3474 13.4418 55.3454C14.5286 55.3438 15.6153 55.3453 16.7021 55.3463C18.5273 55.3474 20.3525 55.3459 22.1777 55.343C24.2897 55.3396 26.4016 55.3407 28.5136 55.3441C30.325 55.3469 32.1363 55.3473 33.9477 55.3457C35.0304 55.3447 36.1131 55.3446 37.1958 55.3466C38.2135 55.3484 39.2312 55.3472 40.249 55.3436C40.6231 55.3428 40.9973 55.343 41.3714 55.3444C41.8809 55.3461 42.3903 55.344 42.8998 55.3409C43.049 55.3422 43.1981 55.3436 43.3518 55.3451C43.9385 55.3381 44.3515 55.3247 44.8497 54.9982C45.1134 54.5663 45.1447 54.3157 45.1427 53.8109C45.144 53.5587 45.144 53.5587 45.1453 53.3013C45.1431 53.1176 45.1408 52.9339 45.1385 52.7447C45.1387 52.4536 45.1387 52.4536 45.1388 52.1567C45.1384 51.5144 45.1335 50.8723 45.1287 50.2301C45.1275 49.7852 45.1266 49.3402 45.126 48.8953C45.1236 47.7235 45.1175 46.5518 45.1107 45.3801C45.1043 44.1847 45.1015 42.9893 45.0984 41.7939C45.0917 39.4479 45.0811 37.1019 45.068 34.7558C45.1765 34.7946 45.285 34.8333 45.3968 34.8732C45.89 35.0478 46.3843 35.219 46.8787 35.3902C47.1348 35.4817 47.1348 35.4817 47.3961 35.5751C47.5614 35.6318 47.7268 35.6885 47.8972 35.747C48.0489 35.8002 48.2007 35.8533 48.357 35.9081C48.8495 36.0104 49.1479 35.9491 49.6206 35.7915C49.6156 34.407 49.5512 33.3934 48.5471 32.386C47.7147 31.6694 46.88 31.0138 45.9087 30.4967C45.4276 30.2266 45.4276 30.2266 45.3138 29.6423C45.2946 29.4052 45.2946 29.4052 45.275 29.1633C45.4713 29.1035 45.6676 29.0437 45.8699 28.982C46.5166 28.749 46.5166 28.749 46.9305 28.3347C46.978 27.8229 46.9999 27.3337 47.0037 26.821C47.0064 26.6651 47.0092 26.5091 47.012 26.3484C47.0201 25.8311 47.0249 25.3138 47.0291 24.7965C47.0308 24.6199 47.0324 24.4433 47.0342 24.2614C47.0428 23.3268 47.0487 22.3923 47.0526 21.4576C47.0572 20.4921 47.0714 19.527 47.0879 18.5616C47.0987 17.8193 47.1022 17.0772 47.1036 16.3349C47.1057 15.979 47.1105 15.6231 47.1181 15.2673C47.1745 12.4808 47.1745 12.4808 46.5645 11.7249C45.9979 11.3565 45.5407 11.1428 44.8611 11.1428C44.8719 10.9689 44.8719 10.9689 44.8829 10.7916C44.9359 9.21984 44.8247 7.64332 44.2403 6.17157C43.6883 5.60502 43.1223 5.37739 42.3779 5.13591C42.205 5.07129 42.0322 5.00668 41.8541 4.94011C40.7559 4.56352 39.8093 4.45022 38.6531 4.51451C38.6061 4.39489 38.5592 4.27528 38.5108 4.15203C37.5181 2.44861 35.0868 1.95291 33.3116 1.47226C30.6621 0.872514 28.0218 0.713362 25.3154 0.720746Z" fill="black"/>
<path id="Vector_2" d="M42.6953 56.1938C42.4609 56.1921 42.4609 56.1921 42.2217 56.1903C41.6975 56.1875 41.1736 56.1906 40.6495 56.1937C40.2739 56.1929 39.8982 56.1917 39.5226 56.1903C38.5021 56.1874 37.4815 56.1897 36.461 56.1931C35.3935 56.196 34.326 56.1946 33.2585 56.1939C31.4654 56.1934 29.6724 56.1962 27.8794 56.201C25.8061 56.2065 23.7329 56.2068 21.6596 56.2044C19.6659 56.2022 17.6722 56.2034 15.6785 56.2064C14.8298 56.2076 13.981 56.2075 13.1323 56.2066C12.1327 56.2056 11.1332 56.2077 10.1336 56.2122C9.76662 56.2133 9.39966 56.2134 9.0327 56.2124C8.53193 56.2111 8.03131 56.2137 7.53056 56.2173C7.31249 56.2154 7.31249 56.2154 7.09002 56.2135C6.20102 56.225 5.62508 56.3622 4.92366 56.919C4.79924 57.4498 4.79924 57.4498 4.8062 58.0924C4.80628 58.3328 4.80636 58.5733 4.80644 58.8211C4.81178 59.0806 4.81712 59.3402 4.82262 59.6077C4.82482 60.0063 4.82482 60.0063 4.82706 60.413C4.83101 61.119 4.84117 61.8248 4.85262 62.5307C4.86321 63.2513 4.86793 63.9719 4.87315 64.6925C4.88424 66.106 4.90189 67.5193 4.92367 68.9327C4.77002 68.954 4.61637 68.9754 4.45806 68.9974C3.8153 69.1582 3.50717 69.2681 3.06125 69.7612C2.97562 70.3029 2.94381 70.7592 2.94758 71.3017C2.94547 71.458 2.94335 71.6144 2.94117 71.7755C2.93541 72.2927 2.93582 72.8097 2.93677 73.3269C2.93516 73.6863 2.93337 74.0457 2.93139 74.405C2.92831 75.1585 2.92843 75.9118 2.93061 76.6653C2.933 77.631 2.926 78.5964 2.91639 79.5621C2.9103 80.3043 2.90993 81.0465 2.91122 81.7888C2.91101 82.1448 2.90884 82.5008 2.90468 82.8568C2.89957 83.3548 2.90237 83.8522 2.90716 84.3502C2.904 84.4973 2.90084 84.6445 2.89758 84.796C2.90637 85.2087 2.90637 85.2087 3.06126 85.9175C3.71121 86.4802 4.05765 86.7461 4.92367 86.7461C4.91825 86.9853 4.91283 87.2246 4.90725 87.4712C4.88849 88.3609 4.87663 89.2506 4.86683 90.1405C4.86164 90.5253 4.85458 90.91 4.84561 91.2947C4.83303 91.8485 4.82719 92.4021 4.82263 92.956C4.81729 93.1273 4.81195 93.2987 4.80645 93.4752C4.80637 93.6369 4.80629 93.7985 4.80621 93.9651C4.80276 94.1769 4.80276 94.1769 4.79924 94.393C5.00144 95.0937 5.40201 95.4026 5.95834 95.8599C6.52277 95.9622 6.95838 96.0004 7.52132 95.9914C7.75478 95.9938 7.75478 95.9938 7.99295 95.9963C8.51362 96.0004 9.03397 95.9971 9.55464 95.9939C9.92825 95.9953 10.3018 95.9971 10.6754 95.9994C11.6896 96.0041 12.7037 96.0023 13.7178 95.999C14.779 95.9964 15.8401 95.9988 16.9013 96.0004C18.6833 96.0023 20.4653 95.9998 22.2474 95.9949C24.3079 95.9893 26.3683 95.9911 28.4288 95.9968C30.1977 96.0015 31.9665 96.0021 33.7354 95.9994C34.7919 95.9978 35.8485 95.9976 36.905 96.001C37.8984 96.004 38.8916 96.0019 39.8849 95.996C40.2495 95.9946 40.6142 95.995 40.9788 95.9973C41.4765 96.0001 41.9737 95.9966 42.4714 95.9914C42.6158 95.9937 42.7602 95.996 42.909 95.9984C43.7045 95.9824 44.0179 95.8734 44.6416 95.3608C45.0718 94.8207 45.1974 94.636 45.1865 93.9651C45.1864 93.8034 45.1863 93.6418 45.1862 93.4752C45.1782 93.2182 45.1782 93.2182 45.17 92.956C45.1686 92.7781 45.1671 92.6003 45.1656 92.4171C45.1598 91.8513 45.1469 91.286 45.1337 90.7204C45.1285 90.3359 45.1238 89.9515 45.1195 89.567C45.1081 88.6265 45.0903 87.6864 45.069 86.746C45.2995 86.714 45.2995 86.714 45.5346 86.6813C46.182 86.5193 46.4667 86.3975 46.9314 85.9175C47.0171 85.3758 47.0489 84.9195 47.0451 84.377C47.0472 84.2207 47.0493 84.0643 47.0515 83.9033C47.0573 83.386 47.0569 82.869 47.0559 82.3518C47.0575 81.9924 47.0593 81.6331 47.0613 81.2737C47.0644 80.5202 47.0642 79.7669 47.0621 79.0134C47.0597 78.0477 47.0667 77.0823 47.0763 76.1166C47.0824 75.3744 47.0827 74.6322 47.0814 73.8899C47.0817 73.5339 47.0838 73.1779 47.088 72.8219C47.0931 72.3239 47.0903 71.8265 47.0855 71.3285C47.0887 71.1814 47.0918 71.0343 47.0951 70.8827C47.0863 70.47 47.0863 70.47 46.9314 69.7612C46.2815 69.1985 45.935 68.9327 45.069 68.9327C45.0708 68.7866 45.0727 68.6405 45.0746 68.49C45.0912 67.1125 45.1038 65.735 45.1119 64.3574C45.1162 63.6492 45.1221 62.9411 45.1314 62.2329C45.1422 61.4185 45.1462 60.6042 45.1498 59.7897C45.1541 59.5357 45.1584 59.2818 45.1628 59.0201C45.1628 58.7838 45.1629 58.5475 45.163 58.304C45.1657 57.9921 45.1657 57.9921 45.1685 57.674C44.918 56.2951 43.9151 56.1771 42.6953 56.1938Z" fill="white"/>
<path id="Vector_3" d="M42.6953 56.1938C42.4609 56.1921 42.4609 56.1921 42.2217 56.1903C41.6975 56.1875 41.1736 56.1906 40.6495 56.1937C40.2739 56.1929 39.8982 56.1917 39.5226 56.1903C38.5021 56.1874 37.4815 56.1897 36.461 56.1931C35.3935 56.196 34.326 56.1946 33.2585 56.1939C31.4654 56.1934 29.6724 56.1962 27.8794 56.201C25.8061 56.2065 23.7329 56.2068 21.6596 56.2044C19.6659 56.2022 17.6722 56.2034 15.6785 56.2064C14.8298 56.2076 13.981 56.2075 13.1323 56.2066C12.1327 56.2056 11.1332 56.2077 10.1336 56.2122C9.76662 56.2133 9.39966 56.2134 9.0327 56.2124C8.53193 56.2111 8.03131 56.2137 7.53056 56.2173C7.31249 56.2154 7.31249 56.2154 7.09002 56.2135C6.20102 56.225 5.62508 56.3622 4.92366 56.919C4.79924 57.4498 4.79924 57.4498 4.8062 58.0924C4.80628 58.3328 4.80636 58.5733 4.80644 58.8211C4.81178 59.0806 4.81712 59.3402 4.82262 59.6077C4.82482 60.0063 4.82482 60.0063 4.82706 60.413C4.83101 61.119 4.84117 61.8248 4.85262 62.5307C4.86321 63.2513 4.86793 63.9719 4.87315 64.6925C4.88424 66.106 4.90189 67.5193 4.92367 68.9327C4.77002 68.954 4.61637 68.9754 4.45806 68.9974C3.8153 69.1582 3.50717 69.2681 3.06125 69.7612C2.97562 70.3029 2.94381 70.7592 2.94758 71.3017C2.94547 71.458 2.94335 71.6144 2.94117 71.7755C2.93541 72.2927 2.93582 72.8097 2.93677 73.3269C2.93516 73.6863 2.93337 74.0457 2.93139 74.405C2.92831 75.1585 2.92843 75.9118 2.93061 76.6653C2.933 77.631 2.926 78.5964 2.91639 79.5621C2.9103 80.3043 2.90993 81.0465 2.91122 81.7888C2.91101 82.1448 2.90884 82.5008 2.90468 82.8568C2.89957 83.3548 2.90237 83.8522 2.90716 84.3502C2.904 84.4973 2.90084 84.6445 2.89758 84.796C2.90637 85.2087 2.90637 85.2087 3.06126 85.9175C3.71121 86.4802 4.05765 86.7461 4.92367 86.7461C4.91825 86.9853 4.91283 87.2246 4.90725 87.4712C4.88849 88.3609 4.87663 89.2506 4.86683 90.1405C4.86164 90.5253 4.85458 90.91 4.84561 91.2947C4.83303 91.8485 4.82719 92.4021 4.82263 92.956C4.81729 93.1273 4.81195 93.2987 4.80645 93.4752C4.80637 93.6369 4.80629 93.7985 4.80621 93.9651C4.80276 94.1769 4.80276 94.1769 4.79924 94.393C5.00144 95.0937 5.40201 95.4026 5.95834 95.8599C6.52277 95.9622 6.95838 96.0004 7.52132 95.9914C7.75478 95.9938 7.75478 95.9938 7.99295 95.9963C8.51362 96.0004 9.03397 95.9971 9.55464 95.9939C9.92825 95.9953 10.3018 95.9971 10.6754 95.9994C11.6896 96.0041 12.7037 96.0023 13.7178 95.999C14.779 95.9964 15.8401 95.9988 16.9013 96.0004C18.6833 96.0023 20.4653 95.9998 22.2474 95.9949C24.3079 95.9893 26.3683 95.9911 28.4288 95.9968C30.1977 96.0015 31.9665 96.0021 33.7354 95.9994C34.7919 95.9978 35.8485 95.9976 36.905 96.001C37.8984 96.004 38.8916 96.0019 39.8849 95.996C40.2495 95.9946 40.6142 95.995 40.9788 95.9973C41.4765 96.0001 41.9737 95.9966 42.4714 95.9914C42.6158 95.9937 42.7602 95.996 42.909 95.9984C43.7045 95.9824 44.0179 95.8734 44.6416 95.3608C45.0718 94.8207 45.1974 94.636 45.1865 93.9651C45.1864 93.8034 45.1863 93.6418 45.1862 93.4752C45.1782 93.2182 45.1782 93.2182 45.17 92.956C45.1686 92.7781 45.1671 92.6003 45.1656 92.4171C45.1598 91.8513 45.1469 91.286 45.1337 90.7204C45.1285 90.3359 45.1238 89.9515 45.1195 89.567C45.1081 88.6265 45.0903 87.6864 45.069 86.746C45.2995 86.714 45.2995 86.714 45.5346 86.6813C46.182 86.5193 46.4667 86.3975 46.9314 85.9175C47.0171 85.3758 47.0489 84.9195 47.0451 84.377C47.0472 84.2207 47.0493 84.0643 47.0515 83.9033C47.0573 83.386 47.0569 82.869 47.0559 82.3518C47.0575 81.9924 47.0593 81.6331 47.0613 81.2737C47.0644 80.5202 47.0642 79.7669 47.0621 79.0134C47.0597 78.0477 47.0667 77.0823 47.0763 76.1166C47.0824 75.3744 47.0827 74.6322 47.0814 73.8899C47.0817 73.5339 47.0838 73.1779 47.088 72.8219C47.0931 72.3239 47.0903 71.8265 47.0855 71.3285C47.0887 71.1814 47.0918 71.0343 47.0951 70.8827C47.0863 70.47 47.0863 70.47 46.9314 69.7612C46.2815 69.1985 45.935 68.9327 45.069 68.9327C45.0708 68.7866 45.0727 68.6405 45.0746 68.49C45.0912 67.1125 45.1038 65.735 45.1119 64.3574C45.1162 63.6492 45.1221 62.9411 45.1314 62.2329C45.1422 61.4185 45.1462 60.6042 45.1498 59.7897C45.1541 59.5357 45.1584 59.2818 45.1628 59.0201C45.1628 58.7838 45.1629 58.5475 45.163 58.304C45.1657 57.9921 45.1657 57.9921 45.1685 57.674C44.918 56.2951 43.9151 56.1771 42.6953 56.1938ZM43.4135 57.9546C43.4135 69.3014 43.4135 80.6481 43.4135 92.3386C31.2582 92.3386 19.1028 92.3386 6.57915 92.3386C6.57915 80.9919 6.57914 69.6452 6.57914 57.9547C18.7345 57.9546 30.8898 57.9546 43.4135 57.9546Z" fill="black"/>
<path id="Vector_4" d="M7.81954 29.992C7.93694 31.1686 8.2566 32.1475 8.70387 33.2381C8.80456 33.488 8.80456 33.488 8.9073 33.7429C9.04878 34.0928 9.19109 34.4423 9.33417 34.7915C9.55275 35.326 9.76786 35.8617 9.98266 36.3977C10.1209 36.7384 10.2594 37.0791 10.3981 37.4196C10.4622 37.5795 10.5262 37.7394 10.5922 37.9041C10.6844 38.1272 10.6844 38.1272 10.7784 38.3547C10.8315 38.4851 10.8846 38.6155 10.9394 38.7499C11.1688 39.1772 11.3714 39.4224 11.7513 39.7272C12.2396 39.8054 12.2396 39.8054 12.8274 39.8061C13.1616 39.8087 13.1616 39.8087 13.5026 39.8114C13.7471 39.8102 13.9915 39.8089 14.2434 39.8076C14.6296 39.8092 14.6296 39.8092 15.0235 39.8109C15.7299 39.8132 16.4361 39.8126 17.1424 39.8107C17.8812 39.8091 18.62 39.8106 19.3587 39.8115C20.5994 39.8127 21.8401 39.8112 23.0808 39.8082C24.5157 39.8048 25.9505 39.8059 27.3855 39.8093C28.6169 39.8122 29.8483 39.8126 31.0797 39.8109C31.8155 39.81 32.5512 39.8098 33.2869 39.8119C34.1069 39.814 34.9268 39.8111 35.7468 39.8076C35.9913 39.8089 36.2358 39.8101 36.4877 39.8114C36.7105 39.8097 36.9333 39.8079 37.1629 39.8061C37.4538 39.8058 37.4538 39.8058 37.7506 39.8054C38.336 39.7117 38.5065 39.5738 38.8597 39.1058C39.0763 38.6278 39.2709 38.159 39.4547 37.6688C39.5636 37.3869 39.6728 37.1051 39.7821 36.8233C39.8358 36.6835 39.8896 36.5437 39.945 36.3996C40.1638 35.8388 40.3972 35.2846 40.6316 34.7301C40.7999 34.3246 40.9681 33.9189 41.136 33.5132C41.2355 33.2769 41.335 33.0406 41.4375 32.7972C41.5344 32.5593 41.6312 32.3214 41.731 32.0762C41.8201 31.8608 41.9092 31.6453 42.001 31.4233C42.1921 30.7444 42.2618 30.4283 41.9638 29.7848C40.979 29.2423 39.9378 28.9845 38.8468 28.7362C38.7098 28.703 38.5729 28.6698 38.4317 28.6355C30.8113 26.8621 14.7051 25.2835 7.81954 29.992Z" fill="white"/>
</g>
<path id="Vector_5" d="M7.19859 32.8911C7.06201 32.8911 6.92543 32.8911 6.78472 32.8911C6.77993 35.473 6.77625 38.0549 6.774 40.6368C6.77291 41.8357 6.77145 43.0345 6.76911 44.2333C6.76686 45.3896 6.76562 46.546 6.76509 47.7023C6.7647 48.1441 6.76395 48.5858 6.76284 49.0276C6.76134 49.645 6.76113 50.2625 6.76123 50.8799C6.76048 51.0636 6.75974 51.2473 6.75897 51.4366C6.75926 51.6048 6.75954 51.7729 6.75983 51.9462C6.75964 52.0923 6.75945 52.2383 6.75926 52.3888C6.78472 52.7758 6.78472 52.7758 6.99165 53.3972C8.01598 53.3972 9.04031 53.3972 10.0957 53.3972C10.1145 51.7892 10.1296 50.1812 10.1386 48.573C10.1429 47.8261 10.1487 47.0792 10.1581 46.3323C10.1672 45.6098 10.1721 44.8873 10.1742 44.1647C10.1765 43.7586 10.1829 43.3527 10.1895 42.9467C10.1899 41.2819 9.92631 40.0068 9.26793 38.4837C9.10605 38.0198 8.94546 37.5555 8.78697 37.0904C8.70655 36.8557 8.62612 36.621 8.54326 36.3792C8.37697 35.8927 8.21101 35.4062 8.04532 34.9196C7.9657 34.6875 7.88607 34.4555 7.80403 34.2164C7.73236 34.0064 7.66069 33.7964 7.58684 33.58C7.40552 33.0982 7.40552 33.0982 7.19859 32.8911Z" fill="white"/>
<path id="Vector_6" d="M43.205 33.0986C43.0002 33.0986 42.7953 33.0986 42.5842 33.0986C42.5101 33.3234 42.4359 33.5482 42.3596 33.7799C42.0852 34.6112 41.8101 35.4422 41.5347 36.2731C41.4154 36.6333 41.2964 36.9936 41.1776 37.3539C41.0071 37.8708 40.8358 38.3874 40.6644 38.904C40.6112 39.066 40.5579 39.2281 40.5031 39.395C40.3021 40.1366 40.3022 40.1366 39.8941 40.7625C39.8746 41.1566 39.8698 41.5514 39.8706 41.9459C39.8706 42.1989 39.8706 42.4518 39.8706 42.7124C39.8717 42.9905 39.8728 43.2686 39.8739 43.5467C39.8743 43.8297 39.8745 44.1127 39.8748 44.3956C39.8755 45.1417 39.8776 45.8878 39.8799 46.6339C39.882 47.3947 39.8829 48.1555 39.884 48.9163C39.8862 50.4101 39.8897 51.9039 39.8941 53.3976C40.3682 53.4143 40.8425 53.4264 41.3168 53.4364C41.7129 53.4473 41.7129 53.4473 42.117 53.4583C42.3395 53.4383 42.562 53.4182 42.7912 53.3976C43.3409 52.5723 43.2599 51.857 43.252 50.8993C43.2521 50.7073 43.2521 50.5153 43.2522 50.3175C43.2519 49.682 43.2487 49.0465 43.2455 48.4111C43.2447 47.9708 43.2441 47.5305 43.2437 47.0902C43.2421 45.9307 43.238 44.7712 43.2335 43.6118C43.2292 42.4289 43.2273 41.246 43.2252 40.0631C43.2208 37.7416 43.2137 35.4201 43.205 33.0986Z" fill="white"/>
<path id="Vector_7" d="M38.2389 63.1323C37.5566 63.2006 37.2823 63.2613 36.7903 63.7537C36.7382 64.162 36.7382 64.162 36.7378 64.6722C36.736 64.9619 36.736 64.9619 36.7342 65.2574C36.7351 65.47 36.7359 65.6825 36.7368 65.9015C36.736 66.1248 36.7353 66.3481 36.7346 66.5782C36.7327 67.3194 36.734 68.0606 36.7354 68.8018C36.7351 69.3152 36.7347 69.8286 36.7342 70.342C36.7335 71.5575 36.7349 72.773 36.7371 73.9885C36.7387 74.9584 36.7381 75.9283 36.7364 76.8982C36.734 78.2336 36.7336 79.5691 36.7349 80.9045C36.7352 81.4145 36.7349 81.9244 36.7339 82.4344C36.7329 83.1466 36.7345 83.8587 36.7368 84.5709C36.7359 84.783 36.7351 84.9951 36.7342 85.2136C36.7354 85.4073 36.7366 85.601 36.7378 85.8006C36.7379 85.9691 36.7381 86.1375 36.7382 86.311C36.7903 86.7454 36.7903 86.7454 37.2042 87.3668C37.7215 87.4315 37.7215 87.4315 38.2389 87.3668C38.7485 86.8567 38.7047 86.7182 38.7053 86.0169C38.7065 85.82 38.7076 85.6231 38.7088 85.4202C38.708 85.2035 38.7072 84.9868 38.7063 84.7635C38.707 84.5358 38.7077 84.3081 38.7085 84.0735C38.7103 83.3179 38.7091 82.5623 38.7077 81.8066C38.708 81.2834 38.7084 80.7602 38.7089 80.2371C38.7095 79.1393 38.7087 78.0417 38.7067 76.9439C38.7044 75.6739 38.7052 74.4039 38.7074 73.1339C38.7096 71.9134 38.7093 70.6929 38.7081 69.4723C38.7078 68.9524 38.7082 68.4324 38.7091 67.9124C38.7102 67.1868 38.7086 66.4612 38.7063 65.7356C38.7071 65.5189 38.708 65.3022 38.7088 65.0789C38.7077 64.882 38.7065 64.6851 38.7053 64.4822C38.7051 64.3105 38.705 64.1388 38.7048 63.9619C38.6527 63.5466 38.6527 63.5466 38.2389 63.1323Z" fill="black"/>
<path id="Vector_8" d="M12.036 63.1845C11.5445 63.3399 11.5445 63.3399 11.3376 63.547C11.3169 63.8469 11.3106 64.1478 11.3099 64.4484C11.3091 64.643 11.3082 64.8376 11.3073 65.0381C11.3074 65.2533 11.3075 65.4685 11.3076 65.6902C11.3069 65.9155 11.3062 66.1408 11.3054 66.373C11.3035 67.122 11.3031 67.8711 11.3029 68.6201C11.3022 69.1384 11.3015 69.6568 11.3008 70.1751C11.2995 71.2634 11.2991 72.3518 11.2993 73.4401C11.2994 74.6987 11.2972 75.9573 11.2939 77.2159C11.2909 78.4254 11.2902 79.635 11.2903 80.8446C11.29 81.3598 11.2891 81.8749 11.2875 82.3901C11.2855 83.1097 11.2861 83.8293 11.2873 84.549C11.2862 84.7633 11.285 84.9777 11.2838 85.1986C11.2847 85.3943 11.2856 85.5901 11.2864 85.7918C11.2863 85.962 11.2862 86.1322 11.2861 86.3076C11.3376 86.7458 11.3376 86.7458 11.7515 87.3672C12.2688 87.4449 12.2688 87.4449 12.7862 87.3672C13.2234 86.7108 13.2521 86.5529 13.254 85.8024C13.2554 85.609 13.2569 85.4156 13.2584 85.2163C13.2579 85.0045 13.2574 84.7928 13.2569 84.5746C13.2579 84.3512 13.259 84.1279 13.2601 83.8978C13.2629 83.1577 13.2627 82.4176 13.2623 81.6775C13.2631 81.1644 13.264 80.6513 13.265 80.1382C13.2665 79.0621 13.2664 77.9861 13.2653 76.91C13.2642 75.8033 13.2657 74.6966 13.2696 73.59C13.2742 72.2556 13.2759 70.9213 13.275 69.587C13.2751 69.0776 13.2762 68.5682 13.2783 68.0588C13.2809 67.3476 13.2795 66.6365 13.2771 65.9254C13.2786 65.7136 13.2802 65.5018 13.2819 65.2836C13.2804 65.0902 13.279 64.8968 13.2775 64.6975C13.2775 64.5293 13.2775 64.3612 13.2776 64.1879C13.1399 63.418 12.8115 63.1106 12.036 63.1845Z" fill="black"/>
<path id="Vector_9" d="M24.866 63.1069C24.7038 63.1155 24.5416 63.124 24.3745 63.1328C24.1262 63.6299 24.1415 63.9369 24.1413 64.492C24.1407 64.6909 24.1401 64.8899 24.1395 65.0948C24.1399 65.3138 24.1404 65.5328 24.1408 65.7584C24.1404 65.9885 24.1401 66.2185 24.1397 66.4555C24.1388 67.2189 24.1394 67.9823 24.1401 68.7456C24.1399 69.274 24.1397 69.8023 24.1395 70.3306C24.1392 71.4389 24.1396 72.5472 24.1406 73.6556C24.1417 74.9385 24.1413 76.2215 24.1402 77.5044C24.1392 78.7369 24.1393 79.9694 24.1399 81.2019C24.14 81.7272 24.1398 82.2526 24.1394 82.7779C24.1388 83.5105 24.1396 84.243 24.1408 84.9756C24.1404 85.195 24.1399 85.4144 24.1395 85.6405C24.1401 85.8389 24.1407 86.0372 24.1413 86.2416C24.1414 86.415 24.1414 86.5884 24.1415 86.7671C24.1676 87.1602 24.1676 87.1602 24.3745 87.3673C24.9953 87.3932 24.9953 87.3932 25.6161 87.3673C25.9452 87.0379 25.8498 86.6985 25.8507 86.2416C25.8516 86.0432 25.8525 85.8449 25.8534 85.6405C25.8533 85.4211 25.8532 85.2017 25.8531 84.9756C25.8538 84.746 25.8545 84.5163 25.8552 84.2796C25.8572 83.5161 25.8576 82.7527 25.8578 81.9892C25.8585 81.461 25.8592 80.9329 25.8599 80.4048C25.8611 79.2961 25.8615 78.1874 25.8614 77.0787C25.8613 75.7959 25.8634 74.5131 25.8667 73.2302C25.8697 71.9979 25.8705 70.7655 25.8704 69.5331C25.8706 69.008 25.8715 68.4828 25.8731 67.9576C25.8751 67.2245 25.8746 66.4915 25.8733 65.7584C25.8745 65.5395 25.8756 65.3205 25.8768 65.0948C25.876 64.8959 25.8751 64.6969 25.8742 64.492C25.8743 64.3185 25.8744 64.145 25.8745 63.9663C25.7897 63.2759 25.5372 63.1389 24.866 63.1069Z" fill="black"/>
<path id="Vector_10" d="M17.9598 63.3396C17.7529 63.5467 17.7529 63.5467 17.7274 63.941C17.7277 64.2019 17.7277 64.2019 17.728 64.4681C17.7277 64.667 17.7274 64.866 17.7271 65.071C17.7279 65.2911 17.7286 65.5111 17.7294 65.7378C17.7293 65.9682 17.7293 66.1985 17.7293 66.4359C17.7294 67.2013 17.731 67.9668 17.7326 68.7323C17.733 69.2614 17.7333 69.7905 17.7335 70.3197C17.7342 71.5726 17.7359 72.8256 17.738 74.0785C17.7403 75.5046 17.7414 76.9307 17.7424 78.3568C17.7446 81.2912 17.7483 84.2255 17.7529 87.1598C18.2342 87.2387 18.7173 87.3074 19.2014 87.3669C19.4083 87.1598 19.4083 87.1598 19.4338 86.7623C19.4336 86.5869 19.4334 86.4114 19.4332 86.2306C19.4335 86.0299 19.4338 85.8292 19.4341 85.6224C19.4333 85.4004 19.4326 85.1784 19.4318 84.9497C19.4319 84.7173 19.4319 84.485 19.4319 84.2456C19.4318 83.4733 19.4302 82.7011 19.4285 81.9289C19.4282 81.3952 19.4279 80.8614 19.4277 80.3276C19.4269 79.0637 19.4253 77.7997 19.4232 76.5358C19.4209 75.0972 19.4198 73.6585 19.4187 72.2199C19.4166 69.2598 19.4129 66.2997 19.4083 63.3396C18.853 63.0617 18.549 63.1884 17.9598 63.3396Z" fill="black"/>
<path id="Vector_11" d="M31.8235 63.1323C30.8808 63.2474 30.8808 63.2474 30.5819 63.5466C30.5616 63.8527 30.5557 64.1598 30.5556 64.4665C30.555 64.6652 30.5544 64.8639 30.5538 65.0686C30.5542 65.2883 30.5547 65.508 30.5551 65.7344C30.5547 65.9644 30.5544 66.1944 30.554 66.4314C30.5531 67.1959 30.5537 67.9604 30.5544 68.7249C30.5542 69.2535 30.554 69.7821 30.5538 70.3108C30.5535 71.4203 30.5539 72.5299 30.5549 73.6394C30.556 74.9239 30.5557 76.2084 30.5545 77.4929C30.5535 78.7264 30.5536 79.9598 30.5542 81.1933C30.5543 81.7192 30.5541 82.2451 30.5537 82.7711C30.5531 83.5047 30.5539 84.2382 30.5551 84.9718C30.5547 85.1916 30.5543 85.4113 30.5538 85.6377C30.5544 85.8364 30.555 86.035 30.5556 86.2397C30.5557 86.4134 30.5557 86.5871 30.5558 86.766C30.5819 87.1597 30.5819 87.1597 30.7888 87.3668C31.4096 87.3927 31.4096 87.3927 32.0304 87.3668C32.3595 87.0374 32.2641 86.698 32.265 86.2411C32.2659 86.0427 32.2668 85.8444 32.2677 85.64C32.2676 85.4206 32.2675 85.2012 32.2674 84.9751C32.2681 84.7455 32.2688 84.5158 32.2695 84.2791C32.2715 83.5156 32.2719 82.7522 32.2721 81.9887C32.2728 81.4605 32.2735 80.9324 32.2742 80.4043C32.2754 79.2956 32.2758 78.1869 32.2757 77.0782C32.2756 75.7954 32.2778 74.5125 32.281 73.2297C32.284 71.9974 32.2848 70.765 32.2847 69.5326C32.2849 69.0075 32.2858 68.4823 32.2874 67.9571C32.2894 67.224 32.2889 66.491 32.2876 65.7579C32.2888 65.539 32.2899 65.32 32.2911 65.0943C32.2903 64.8954 32.2894 64.6964 32.2885 64.4915C32.2886 64.318 32.2887 64.1445 32.2888 63.9658C32.2373 63.5466 32.2373 63.5466 31.8235 63.1323Z" fill="black"/>
<path id="Vector_12" d="M35.9631 7.41406C35.8948 7.41406 35.8265 7.41406 35.7561 7.41406C35.5472 10.2892 36.6549 13.2371 37.6185 15.9065C37.6704 16.0507 37.7223 16.1949 37.7758 16.3434C37.9373 16.7896 38.1007 17.235 38.2652 17.6801C38.3152 17.8157 38.3652 17.9514 38.4167 18.0911C38.7246 18.9095 39.0775 19.688 39.481 20.4634C39.703 19.7965 39.6823 19.6526 39.4999 19.0064C39.4303 18.7551 39.4303 18.7551 39.3593 18.4986C39.3071 18.3174 39.255 18.1362 39.2013 17.9495C39.1483 17.7605 39.0954 17.5716 39.0408 17.3769C38.8701 16.7698 38.697 16.1634 38.5239 15.557C38.4119 15.1588 38.3001 14.7606 38.1884 14.3623C37.6838 12.5699 37.1678 10.7814 36.6178 9.00235C36.5753 8.86381 36.5328 8.72527 36.489 8.58254C36.1953 7.6465 36.1953 7.6465 35.9631 7.41406Z" fill="white"/>
<path id="Vector_13" d="M14.2334 7.41406C13.6118 7.93136 13.4572 8.40135 13.2504 9.17469C13.1808 9.43155 13.1112 9.68841 13.0395 9.95305C12.8395 10.7195 12.643 11.4869 12.4461 12.2542C12.3854 12.4907 12.3246 12.7273 12.262 12.971C12.1362 13.4613 12.0108 13.9516 11.8858 14.442C11.4674 16.0681 10.9994 17.6644 10.4568 19.253C10.3016 19.842 10.3016 19.842 10.5085 20.4634C14.2848 11.6907 14.2848 11.6907 14.2463 8.48856C14.2444 8.28644 14.2426 8.08431 14.2406 7.87606C14.2382 7.7236 14.2358 7.57114 14.2334 7.41406Z" fill="white"/>
<path id="Vector_14" d="M38.0313 13.8354C37.7581 13.9722 37.485 14.1089 37.2035 14.2497C37.472 15.3991 37.8042 16.4879 38.2253 17.5897C38.2774 17.7299 38.3296 17.8701 38.3833 18.0145C38.7021 18.8585 39.0621 19.6625 39.4798 20.4637C39.7475 19.6599 39.575 19.2793 39.3448 18.4676C39.3078 18.3352 39.2707 18.2027 39.2326 18.0662C39.114 17.6437 38.993 17.2219 38.8719 16.8C38.7911 16.5135 38.7104 16.227 38.6298 15.9404C38.4321 15.2382 38.2323 14.5367 38.0313 13.8354Z" fill="white"/>
<path id="Vector_15" d="M12.165 14.25C11.684 14.7315 11.6306 14.9955 11.4666 15.6481C11.1579 16.852 10.8042 18.039 10.4416 19.2277C10.3026 19.8426 10.3026 19.8426 10.5095 20.464C10.8177 19.7313 11.123 18.9974 11.4278 18.2632C11.5584 17.9528 11.5584 17.9528 11.6917 17.6361C11.7751 17.4345 11.8584 17.2329 11.9443 17.0253C12.0213 16.8408 12.0982 16.6564 12.1775 16.4665C12.3885 15.8595 12.5224 15.3049 12.5789 14.6643C12.4423 14.5276 12.3057 14.3909 12.165 14.25Z" fill="white"/>
<path id="Vector_16" d="M38.8588 16.9419C38.4449 17.5633 38.4449 17.5633 38.5257 17.9954C38.5844 18.1518 38.6431 18.3083 38.7036 18.4695C38.7936 18.715 38.7936 18.715 38.8854 18.9655C38.9747 19.1942 38.9747 19.1942 39.0657 19.4275C39.1468 19.6368 39.2279 19.8461 39.3114 20.0618C39.3947 20.2605 39.3947 20.2605 39.4796 20.4631C39.7467 19.6611 39.5258 19.1287 39.2856 18.353C39.225 18.1497 39.225 18.1497 39.1631 17.9424C39.0633 17.6084 38.9612 17.2751 38.8588 16.9419Z" fill="white"/>
<path id="Vector_17" d="M35.9617 7.41406C35.8934 7.41406 35.8251 7.41406 35.7548 7.41406C35.7548 8.02925 35.7548 8.64443 35.7548 9.27825C35.8913 9.14155 36.0279 9.00484 36.1686 8.86399C36.3052 9.1374 36.4418 9.41082 36.5825 9.69252C36.6508 9.55581 36.7191 9.4191 36.7894 9.27825C36.6286 8.83001 36.6286 8.83001 36.3885 8.32027C36.3098 8.15098 36.2311 7.9817 36.15 7.80729C36.0879 7.67753 36.0257 7.54776 35.9617 7.41406Z" fill="white"/>
<path id="Vector_18" d="M36.1686 8.65674C36.1003 8.65674 36.032 8.65674 35.9617 8.65674C35.9617 9.27192 35.9617 9.8871 35.9617 10.5209C36.03 10.3842 36.0983 10.2475 36.1686 10.1067C36.3735 10.175 36.5784 10.2434 36.7894 10.3138C36.6596 9.70715 36.4612 9.20337 36.1686 8.65674Z" fill="white"/>
<path id="Vector_19" d="M14.2327 7.41406C14.0278 7.61912 13.8229 7.82418 13.6119 8.03546C13.6119 8.24052 13.6119 8.44558 13.6119 8.65686C13.7484 8.65686 13.885 8.65686 14.0257 8.65686C14.0257 8.99863 14.0257 9.34039 14.0257 9.69252C14.094 9.69252 14.1623 9.69252 14.2327 9.69252C14.2327 8.94063 14.2327 8.18874 14.2327 7.41406Z" fill="white"/>
<path id="Vector_20" d="M13.2008 86.7456C13.0641 86.8824 12.9275 87.0192 12.7867 87.1601C12.2544 87.2943 12.2544 87.2943 11.7514 87.3674C12.0247 87.4358 12.298 87.5041 12.5796 87.5746C12.5796 87.7114 12.5796 87.8482 12.5796 87.9891C12.7846 87.9207 12.9896 87.8523 13.2008 87.7819C13.2691 87.5767 13.3374 87.3715 13.4078 87.1601C13.3395 87.0233 13.2712 86.8865 13.2008 86.7456Z" fill="white"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 28 KiB

1
src/assets/circle.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 0 24 24" width="48px" fill="#000000"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 2C6.47 2 2 6.47 2 12s4.47 10 10 10 10-4.47 10-10S17.53 2 12 2z"/></svg>

After

Width:  |  Height:  |  Size: 223 B

1
src/assets/comment.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1024 1024"><path fill="currentColor" d="M573 421c-23.1 0-41 17.9-41 40s17.9 40 41 40c21.1 0 39-17.9 39-40s-17.9-40-39-40m-280 0c-23.1 0-41 17.9-41 40s17.9 40 41 40c21.1 0 39-17.9 39-40s-17.9-40-39-40"/><path fill="currentColor" d="M894 345c-48.1-66-115.3-110.1-189-130v.1c-17.1-19-36.4-36.5-58-52.1c-163.7-119-393.5-82.7-513 81c-96.3 133-92.2 311.9 6 439l.8 132.6c0 3.2.5 6.4 1.5 9.4c5.3 16.9 23.3 26.2 40.1 20.9L309 806c33.5 11.9 68.1 18.7 102.5 20.6l-.5.4c89.1 64.9 205.9 84.4 313 49l127.1 41.4c3.2 1 6.5 1.6 9.9 1.6c17.7 0 32-14.3 32-32V753c88.1-119.6 90.4-284.9 1-408M323 735l-12-5l-99 31l-1-104l-8-9c-84.6-103.2-90.2-251.9-11-361c96.4-132.2 281.2-161.4 413-66c132.2 96.1 161.5 280.6 66 412c-80.1 109.9-223.5 150.5-348 102m505-17l-8 10l1 104l-98-33l-12 5c-56 20.8-115.7 22.5-171 7l-.2-.1C613.7 788.2 680.7 742.2 729 676c76.4-105.3 88.8-237.6 44.4-350.4l.6.4c23 16.5 44.1 37.1 62 62c72.6 99.6 68.5 235.2-8 330"/><path fill="currentColor" d="M433 421c-23.1 0-41 17.9-41 40s17.9 40 41 40c21.1 0 39-17.9 39-40s-17.9-40-39-40"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,10 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_42_3517)">
<path d="M45 8.99978C45.0028 7.43905 44.3961 5.9389 43.3092 4.81892C42.2222 3.69893 40.7408 3.04766 39.1807 3.00383C37.6206 2.96 36.105 3.52709 34.9569 4.58428C33.8088 5.64148 33.1188 7.1052 33.034 8.66363L14.3796 12.3938C13.9433 11.4874 13.2857 10.7055 12.4673 10.1204C11.6489 9.53538 10.6963 9.16604 9.69745 9.04656C8.69859 8.92708 7.68573 9.0613 6.75247 9.43682C5.8192 9.81235 4.99565 10.4171 4.35792 11.1951C3.72019 11.9731 3.28885 12.8993 3.10377 13.8881C2.91869 14.8769 2.98583 15.8964 3.299 16.8524C3.61217 17.8084 4.16126 18.67 4.89552 19.3576C5.62979 20.0453 6.52553 20.5368 7.49999 20.7866V33.2129C6.47665 33.4754 5.54098 34.0039 4.78795 34.7448C4.03492 35.4858 3.49135 36.4128 3.21244 37.4318C2.93352 38.4508 2.92918 39.5254 3.19986 40.5466C3.47054 41.5677 4.0066 42.4991 4.75362 43.2461C5.50065 43.9932 6.43203 44.5292 7.45321 44.7999C8.47439 45.0706 9.54901 45.0662 10.568 44.7873C11.5869 44.5084 12.514 43.9648 13.2549 43.2118C13.9959 42.4588 14.5244 41.5231 14.7868 40.4998H27.2131C27.4625 41.475 27.9538 42.3717 28.6415 43.1067C29.3292 43.8418 30.1912 44.3916 31.1477 44.7053C32.1042 45.019 33.1244 45.0864 34.1139 44.9014C35.1033 44.7164 36.0302 44.2848 36.8087 43.6466C37.5872 43.0085 38.1922 42.1843 38.5678 41.2504C38.9434 40.3164 39.0774 39.3029 38.9575 38.3034C38.8376 37.3039 38.4676 36.3509 37.8817 35.5323C37.2958 34.7137 36.513 34.0561 35.6056 33.6202L39.3361 14.966C40.8633 14.8823 42.3007 14.2178 43.3538 13.1085C44.4068 11.9992 44.9958 10.5293 45 8.99978ZM39 5.99978C39.5933 5.99978 40.1734 6.17573 40.6667 6.50537C41.16 6.83502 41.5446 7.30355 41.7716 7.85173C41.9987 8.39991 42.0581 9.00311 41.9423 9.58505C41.8266 10.167 41.5409 10.7015 41.1213 11.1211C40.7018 11.5407 40.1672 11.8264 39.5853 11.9421C39.0033 12.0579 38.4001 11.9985 37.8519 11.7714C37.3038 11.5444 36.8352 11.1598 36.5056 10.6665C36.1759 10.1731 36 9.59312 36 8.99978C36.0009 8.20441 36.3173 7.44188 36.8797 6.87947C37.4421 6.31706 38.2046 6.00069 39 5.99978ZM5.99999 14.9998C5.99999 14.4064 6.17593 13.8264 6.50558 13.3331C6.83522 12.8397 7.30376 12.4552 7.85194 12.2281C8.40012 12.0011 9.00331 11.9417 9.58526 12.0574C10.1672 12.1732 10.7018 12.4589 11.1213 12.8785C11.5409 13.298 11.8266 13.8326 11.9423 14.4145C12.0581 14.9965 11.9987 15.5997 11.7716 16.1478C11.5446 16.696 11.16 17.1645 10.6667 17.4942C10.1733 17.8238 9.59333 17.9998 8.99999 17.9998C8.20462 17.9989 7.44209 17.6825 6.87968 17.1201C6.31726 16.5577 6.0009 15.7951 5.99999 14.9998ZM8.99999 41.9998C8.40664 41.9998 7.82662 41.8238 7.33328 41.4942C6.83993 41.1645 6.45541 40.696 6.22835 40.1478C6.00129 39.5997 5.94188 38.9965 6.05763 38.4145C6.17339 37.8326 6.45911 37.298 6.87867 36.8785C7.29822 36.4589 7.83277 36.1732 8.41472 36.0574C8.99666 35.9417 9.59986 36.0011 10.148 36.2281C10.6962 36.4552 11.1648 36.8397 11.4944 37.3331C11.824 37.8264 12 38.4064 12 38.9998C11.9991 39.7952 11.6827 40.5577 11.1203 41.1201C10.5579 41.6825 9.79536 41.9989 8.99999 41.9998ZM27.2131 37.4998H14.7868C14.5181 36.4686 13.9792 35.5277 13.2256 34.7741C12.4721 34.0206 11.5312 33.4817 10.5 33.2129V20.7866C11.7256 20.4679 12.8184 19.7684 13.621 18.7889C14.4235 17.8093 14.8945 16.6003 14.9659 15.3359L33.6204 11.6057C34.2087 12.8146 35.1854 13.7912 36.3943 14.3794L32.6638 33.0335C31.3995 33.1051 30.1905 33.5761 29.2109 34.3787C28.2314 35.1813 27.5319 36.2742 27.2131 37.4998ZM33 41.9998C32.4066 41.9998 31.8266 41.8238 31.3333 41.4942C30.8399 41.1645 30.4554 40.696 30.2283 40.1478C30.0013 39.5997 29.9419 38.9965 30.0576 38.4145C30.1734 37.8326 30.4591 37.298 30.8787 36.8785C31.2982 36.4589 31.8328 36.1732 32.4147 36.0574C32.9967 35.9417 33.5999 36.0011 34.148 36.2281C34.6962 36.4552 35.1648 36.8397 35.4944 37.3331C35.824 37.8264 36 38.4064 36 38.9998C35.9991 39.7952 35.6827 40.5577 35.1203 41.1201C34.5579 41.6825 33.7954 41.9989 33 41.9998Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_42_3517">
<rect width="48" height="48" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,3 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M45.2829 23.6936L38.4472 18.299C38.39 18.254 38.3213 18.226 38.249 18.2183C38.1766 18.2105 38.1035 18.2233 38.0381 18.2552C37.9727 18.2871 37.9176 18.3367 37.8791 18.3985C37.8406 18.4602 37.8203 18.5316 37.8204 18.6043V22.0704H25.9276V10.1775H29.399C29.7204 10.1775 29.9026 9.80254 29.7044 9.55076L24.3044 2.71504C24.2686 2.66879 24.2228 2.63134 24.1703 2.60557C24.1178 2.5798 24.0601 2.56641 24.0017 2.56641C23.9432 2.56641 23.8855 2.5798 23.8331 2.60557C23.7806 2.63134 23.7347 2.66879 23.699 2.71504L18.299 9.55076C18.254 9.60795 18.226 9.67666 18.2183 9.74902C18.2106 9.82137 18.2234 9.89444 18.2552 9.95986C18.2871 10.0253 18.3368 10.0804 18.3985 10.1189C18.4602 10.1574 18.5316 10.1777 18.6044 10.1775H22.0704V22.0704H10.1776V18.599C10.1776 18.2775 9.80257 18.0954 9.55079 18.2936L2.71507 23.6936C2.66882 23.7294 2.63137 23.7752 2.6056 23.8277C2.57983 23.8802 2.56643 23.9378 2.56643 23.9963C2.56643 24.0548 2.57983 24.1124 2.6056 24.1649C2.63137 24.2174 2.66882 24.2632 2.71507 24.299L9.54543 29.699C9.79721 29.8972 10.1722 29.7204 10.1722 29.3936V25.9275H22.0651V37.8204H18.5936C18.2722 37.8204 18.0901 38.1954 18.2883 38.4472L23.6883 45.2775C23.8436 45.4758 24.1436 45.4758 24.2936 45.2775L29.6936 38.4472C29.8919 38.1954 29.7151 37.8204 29.3883 37.8204H25.9276V25.9275H37.8204V29.399C37.8204 29.7204 38.1954 29.9025 38.4472 29.7043L45.2776 24.3043C45.3237 24.2681 45.3611 24.222 45.3869 24.1693C45.4128 24.1167 45.4265 24.0589 45.427 24.0003C45.4275 23.9416 45.4148 23.8836 45.3899 23.8305C45.365 23.7774 45.3284 23.7307 45.2829 23.6936Z" fill="black" fill-opacity="0.85"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,4 @@
<svg width="46" height="46" viewBox="0 0 46 46" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M43.1258 22.9952H40.2508C40.0249 22.9952 39.8401 23.18 39.8401 23.4059V39.8345H6.16152V6.1559H22.5901C22.816 6.1559 23.0008 5.97108 23.0008 5.74519V2.87019C23.0008 2.64429 22.816 2.45947 22.5901 2.45947H4.10795C3.19924 2.45947 2.46509 3.19362 2.46509 4.10233V41.888C2.46509 42.7968 3.19924 43.5309 4.10795 43.5309H41.8937C42.8024 43.5309 43.5365 42.7968 43.5365 41.888V23.4059C43.5365 23.18 43.3517 22.9952 43.1258 22.9952Z" fill="black" fill-opacity="0.85"/>
<path d="M14.9864 24.1709L14.8889 30.2751C14.8837 30.732 15.2534 31.1068 15.7103 31.1068H15.7308L21.7889 30.9579C21.8915 30.9528 21.9942 30.9117 22.0661 30.8398L43.4181 9.53403C43.5773 9.37487 43.5773 9.11304 43.4181 8.95389L37.0366 2.57755C36.9545 2.49541 36.8518 2.45947 36.744 2.45947C36.6362 2.45947 36.5335 2.50054 36.4514 2.57755L15.1045 23.8834C15.0303 23.9609 14.9882 24.0636 14.9864 24.1709ZM18.2464 25.3825L36.744 6.92599L39.0645 9.24139L20.5567 27.7081L18.2105 27.7646L18.2464 25.3825Z" fill="black" fill-opacity="0.85"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,3 @@
<svg width="43" height="45" viewBox="0 0 43 45" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M35.1051 0.57174C35.0284 0.312513 34.8256 0.109758 34.5664 0.0330273C34.1438 -0.0920665 33.6998 0.149124 33.5747 0.57174L33.0723 2.26906C32.3517 4.7036 30.4472 6.60762 28.0125 7.32771L26.3351 7.82384C26.0768 7.90022 25.8745 8.10169 25.7971 8.35961C25.6703 8.78173 25.9098 9.22668 26.3319 9.35343L28.0422 9.867C30.4603 10.5931 32.3501 12.488 33.0697 14.908L33.575 16.6074C33.6518 16.8659 33.854 17.0681 34.1125 17.1449C34.5349 17.2706 34.9792 17.0299 35.1048 16.6074L35.6101 14.908C36.3297 12.488 38.2195 10.5931 40.6375 9.867L42.3479 9.35343C42.6058 9.27598 42.8073 9.07368 42.8837 8.81545C43.0087 8.3928 42.7674 7.94885 42.3447 7.82384L40.6673 7.32771C38.2326 6.60762 36.3281 4.7036 35.6075 2.26906L35.1051 0.57174ZM17.336 11.2676C17.9063 11.4364 18.3523 11.8825 18.5211 12.4528L19.7887 16.7352C20.9707 20.7282 24.0942 23.8511 28.0875 25.0322L32.3338 26.2881C33.2636 26.5631 33.7944 27.5398 33.5194 28.4696C33.3514 29.0377 32.9082 29.4828 32.3407 29.6531L28.0387 30.9449C24.0728 32.1358 20.9732 35.2437 19.793 39.2129L18.5205 43.4926C18.2442 44.422 17.2667 44.9515 16.3373 44.6751C15.7687 44.506 15.3239 44.0613 15.1548 43.4926L13.8823 39.2129C12.7021 35.2437 9.60255 32.1358 5.63661 30.9449L1.3346 29.6531C0.405935 29.3743 -0.120847 28.3954 0.158005 27.4667C0.328385 26.8993 0.773447 26.4561 1.34157 26.2881L5.58788 25.0322C9.58114 23.8511 12.7047 20.7282 13.8866 16.7352L15.1542 12.4528C15.4294 11.523 16.4062 10.9924 17.336 11.2676ZM16.8348 21.6635L16.5784 22.108C15.1769 24.3974 13.2502 26.3236 10.9606 27.7247L10.5196 27.9787L10.9997 28.2608C13.2734 29.6605 15.1866 31.5789 16.5802 33.8563L16.8378 34.2968L17.0949 33.8563C18.4886 31.5789 20.4018 29.6605 22.6754 28.2608L23.1529 27.9787L22.7146 27.7247C20.4249 26.3236 18.4983 24.3974 17.0967 22.108L16.8348 21.6635Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -0,0 +1,10 @@
<svg width="49" height="48" viewBox="0 0 49 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_42_3507)">
<path d="M48.1348 22.8522C47.7696 22.3305 39.3174 9.91309 24.5 9.91309C9.68261 9.91309 1.23044 22.3305 0.86522 22.8522C0.395655 23.5305 0.395655 24.4696 0.86522 25.2C1.23044 25.6696 9.68261 38.087 24.5 38.087C39.3174 38.087 47.7696 25.6696 48.1348 25.1479C48.6044 24.4696 48.6044 23.5305 48.1348 22.8522ZM24.5 33.9131C14.4304 33.9131 7.54348 26.8174 5.19566 24C7.54348 21.1305 14.3783 14.087 24.5 14.087C34.5696 14.087 41.4565 21.1827 43.8043 24C41.4044 26.8696 34.5696 33.9131 24.5 33.9131ZM25.7 15.4435C23.4043 15.1305 21.1609 15.7044 19.2826 17.1131C15.4739 19.9827 14.6913 25.4087 17.5609 29.2174C18.9696 31.0957 20.9522 32.2435 23.2478 32.6087C23.6652 32.6609 24.0826 32.7131 24.4478 32.7131C26.3261 32.7131 28.1 32.087 29.613 30.9913C33.4217 28.1218 34.2043 22.6957 31.3348 18.887C30.0304 16.9566 27.9957 15.7566 25.7 15.4435ZM27.787 28.4348C26.6391 29.3218 25.1783 29.687 23.7174 29.4783C22.2565 29.2696 20.9522 28.487 20.0652 27.3392C18.2913 24.887 18.7609 21.3913 21.213 19.5653C22.3609 18.6783 23.8217 18.3131 25.2826 18.5218C26.7435 18.7305 28.0478 19.5131 28.9348 20.6609C30.7087 23.1131 30.2391 26.6087 27.787 28.4348ZM27.5783 20.8696C27.9957 21.2348 28.2043 21.8087 28.2043 22.3305C28.2043 22.8522 27.9957 23.4261 27.5783 23.7913C27.213 24.1566 26.6391 24.4174 26.1174 24.4174C25.5435 24.4174 25.0217 24.2087 24.6565 23.7913C24.2391 23.374 24.0304 22.8522 24.0304 22.3305C24.0304 21.7566 24.2391 21.2348 24.6565 20.8696C25.0217 20.4522 25.5957 20.2435 26.1174 20.2435C26.6913 20.2957 27.213 20.5044 27.5783 20.8696Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_42_3507">
<rect width="48" height="48" fill="white" transform="translate(0.5)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -0,0 +1,10 @@
<svg width="41" height="40" viewBox="0 0 41 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_42_3524)">
<path d="M11.4062 3.63281H18.6797V0H11.4062V3.63281ZM33.2266 0V3.63281H36.8594V7.26562H40.5V0H33.2266ZM4.13281 3.63281H7.76562V0H0.5V7.27344H4.13281V3.63281ZM22.3203 40H29.5938V36.3672H22.3203V40ZM4.13281 10.9062H0.5V18.1797H4.13281V10.9062ZM36.8672 18.1797H40.5V10.9062H36.8672V18.1797ZM36.8672 29.0938H40.5V21.8203H36.8672V29.0938ZM36.8672 36.3672H33.2344V40H40.5V32.7266H36.8672V36.3672ZM22.3203 3.63281H29.5938V0H22.3203V3.63281ZM0.5 40H18.6797V21.8203H0.5V40Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_42_3524">
<rect width="40" height="40" fill="white" transform="translate(0.5)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 753 B

View File

@ -0,0 +1,3 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.00002 8.14982C1.79418 4.93428 4.9343 1.79418 8.14982 3.00002L42.431 15.8555C46.2434 17.2851 45.7314 22.8324 41.7218 23.54L26.2672 26.2672L23.54 41.7218C22.8324 45.7314 17.2851 46.2434 15.8555 42.431L3.00002 8.14982ZM6.74534 6.74534L19.6008 41.0266L22.328 25.572C22.6202 23.9164 23.9164 22.6202 25.572 22.328L41.0266 19.6008L6.74534 6.74534Z" fill="#0F0F0F"/>
</svg>

After

Width:  |  Height:  |  Size: 514 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

BIN
src/assets/loginBg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

BIN
src/assets/map.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 598 KiB

Some files were not shown because too many files have changed in this diff Show More