init
This commit is contained in:
3
.env
Normal file
3
.env
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Базовые настройки для всех режимов
|
||||||
|
VITE_APP_NAME=Space
|
||||||
|
VITE_API_TIMEOUT=5000
|
||||||
2
.env.development
Normal file
2
.env.development
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_API_URL=http://localhost:8086/api
|
||||||
|
VITE_ENABLE_DEVTOOLS=true
|
||||||
2
.env.production
Normal file
2
.env.production
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_API_URL=https://vector.luminic.space/api
|
||||||
|
VITE_ENABLE_DEVTOOLS=false
|
||||||
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
coverage
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Cypress
|
||||||
|
/cypress/videos/
|
||||||
|
/cypress/screenshots/
|
||||||
|
|
||||||
|
# Vitest
|
||||||
|
__screenshots__/
|
||||||
38
README.md
Normal file
38
README.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# space
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 in Vite.
|
||||||
|
|
||||||
|
## Recommended IDE Setup
|
||||||
|
|
||||||
|
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||||
|
|
||||||
|
## Recommended Browser Setup
|
||||||
|
|
||||||
|
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
||||||
|
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||||
|
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
||||||
|
- Firefox:
|
||||||
|
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||||
|
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
||||||
|
|
||||||
|
## Customize configuration
|
||||||
|
|
||||||
|
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||||
|
|
||||||
|
## Project Setup
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compile and Hot-Reload for Development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compile and Minify for Production
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
23
index.html
Normal file
23
index.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link rel="icon" href="/favicon.ico">
|
||||||
|
<link rel="manifest" href="/manifest.json">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<link rel="apple-touch-icon" href="/src/assets/1024.png">
|
||||||
|
<link rel="stylesheet" href="/src/assets/main.css"/>
|
||||||
|
<script src="https://telegram.org/js/telegram-web-app.js?59"></script>
|
||||||
|
<title>Luminic Space</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script type="text/javascript">
|
||||||
|
let tgWebApp = window.Telegram.WebApp
|
||||||
|
|
||||||
|
</script>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
8
jsconfig.json
Normal file
8
jsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
10
manifest.json
Normal file
10
manifest.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "Luminic Space App",
|
||||||
|
"short_name": "Luminic Space",
|
||||||
|
"start_url": "/",
|
||||||
|
"icons": [
|
||||||
|
],
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#ffffff"
|
||||||
|
}
|
||||||
39
package.json
Normal file
39
package.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "space",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@amirafa/vue3-pull-to-refresh": "^1.2.13",
|
||||||
|
"@primevue/themes": "^4.4.1",
|
||||||
|
"@tailwindcss/postcss": "^4.1.16",
|
||||||
|
"@tailwindcss/vite": "^4.1.16",
|
||||||
|
"axios": "^1.12.2",
|
||||||
|
"dayjs": "^1.11.18",
|
||||||
|
"emoji-regex": "^10.6.0",
|
||||||
|
"pinia": "^3.0.3",
|
||||||
|
"primeicons": "^7.0.0",
|
||||||
|
"primevue": "^4.4.1",
|
||||||
|
"tailwind": "^2.3.1",
|
||||||
|
"tailwindcss-primeui": "^0.6.1",
|
||||||
|
"tslib": "^2.8.1",
|
||||||
|
"vue": "^3.5.22",
|
||||||
|
"vue-router": "^4.6.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^4.1.16",
|
||||||
|
"vite": "^7.1.11",
|
||||||
|
"vite-plugin-vue-devtools": "^8.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
postcss.config.js
Normal file
5
postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
84
src/App.vue
Normal file
84
src/App.vue
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<script setup>
|
||||||
|
import SpaceList from "@/components/space-list/SpaceList.vue";
|
||||||
|
import {useSpaceStore} from "@/stores/spaceStore";
|
||||||
|
import {computed, onMounted, ref} from "vue";
|
||||||
|
import Toolbar from "@/components/Toolbar.vue";
|
||||||
|
import {useToolbarStore} from "@/stores/toolbar-store";
|
||||||
|
import router from "@/router/index.js";
|
||||||
|
|
||||||
|
const spaceStore = useSpaceStore();
|
||||||
|
const toolbarStore = useToolbarStore()
|
||||||
|
|
||||||
|
const isSpaceSelectorVisible = ref(false);
|
||||||
|
const isSpaceSelected = computed(() => {
|
||||||
|
return spaceStore.selectedSpaceId === undefined || isSpaceSelectorVisible.value;
|
||||||
|
})
|
||||||
|
|
||||||
|
const menu = [
|
||||||
|
{
|
||||||
|
name: "Dashboard",
|
||||||
|
icon: "pi pi-chart-bar",
|
||||||
|
link: "/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Transactions",
|
||||||
|
icon: "pi pi-cog",
|
||||||
|
link: "/transactions",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Settings",
|
||||||
|
icon: "pi pi-list",
|
||||||
|
link: "/settings",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const spaceSelected = () => {
|
||||||
|
router.push('/')
|
||||||
|
isSpaceSelectorVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
toolbarStore.registerHandler('openSpacePicker', () => {
|
||||||
|
isSpaceSelectorVisible.value = true
|
||||||
|
});
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<body class="flex flex-col">
|
||||||
|
<SpaceList v-if="isSpaceSelected" @space-selected="spaceSelected()"
|
||||||
|
/>
|
||||||
|
<div v-else class="flex flex-col w-full gap-4">
|
||||||
|
<div class="w-full flex flex-row justify-items-end items-end justify-end pt-4 pe-4">
|
||||||
|
<Toolbar class=""/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col w-full h-full justify-items-end justify-end items-end px-4 gap-4 sticky">
|
||||||
|
<!-- <div class="w-fit flex flex-row items-center gap-2 p-2 bg-white rounded-full !text-xl sticky"-->
|
||||||
|
<!-- >-->
|
||||||
|
<!-- <span class=" px-1 " @click="isSpaceSelectorVisible=true">{{ spaceStore.selectedSpaceName }}</span>-->
|
||||||
|
<!-- <Divider layout="vertical" class="!h-full !m-0 !text-black !bg-black"/>-->
|
||||||
|
<!-- <i class="pi pi-plus !text-lg px-2"/>-->
|
||||||
|
<!-- </div>-->
|
||||||
|
|
||||||
|
<router-view class="w-full"/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="w-full flex flex-row items-center justify-items-center justify-between fixed bottom-0 h-16 px-10 bg-white z-50 ">
|
||||||
|
<router-link v-for="itemKey in menu.keys()" :key="itemKey" :to="menu[itemKey].link" class="items-center">
|
||||||
|
<div class="w-fit flex flex-col justify-center items-center justify-items-center gap-0">
|
||||||
|
<i class="!text-lg" :class="menu[itemKey].icon"/>
|
||||||
|
<span class="text-lg font-medium text-gray-900">{{ menu[itemKey].name }}</span>
|
||||||
|
</div>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
BIN
src/assets/1024.png
Normal file
BIN
src/assets/1024.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
87
src/assets/base.css
Normal file
87
src/assets/base.css
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
/* color palette from <https://github.com/vuejs/theme> */
|
||||||
|
:root {
|
||||||
|
--vt-c-white: #ffffff;
|
||||||
|
--vt-c-white-soft: #f8f8f8;
|
||||||
|
--vt-c-white-mute: #f2f2f2;
|
||||||
|
|
||||||
|
--vt-c-black: #181818;
|
||||||
|
--vt-c-black-soft: #222222;
|
||||||
|
--vt-c-black-mute: #282828;
|
||||||
|
|
||||||
|
--vt-c-indigo: #2c3e50;
|
||||||
|
|
||||||
|
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
|
||||||
|
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
|
||||||
|
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
|
||||||
|
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
|
||||||
|
|
||||||
|
--vt-c-text-light-1: var(--vt-c-indigo);
|
||||||
|
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
|
||||||
|
--vt-c-text-dark-1: var(--vt-c-white);
|
||||||
|
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* semantic color variables for this project */
|
||||||
|
:root {
|
||||||
|
--color-background: var(--vt-c-white);
|
||||||
|
--color-background-soft: var(--vt-c-white-soft);
|
||||||
|
--color-background-mute: var(--vt-c-white-mute);
|
||||||
|
|
||||||
|
--color-border: var(--vt-c-divider-light-2);
|
||||||
|
--color-border-hover: var(--vt-c-divider-light-1);
|
||||||
|
|
||||||
|
--color-heading: var(--vt-c-text-light-1);
|
||||||
|
--color-text: var(--vt-c-text-light-1);
|
||||||
|
|
||||||
|
--section-gap: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--color-background: var(--vt-c-black);
|
||||||
|
--color-background-soft: var(--vt-c-black-soft);
|
||||||
|
--color-background-mute: var(--vt-c-black-mute);
|
||||||
|
|
||||||
|
--color-border: var(--vt-c-divider-dark-2);
|
||||||
|
--color-border-hover: var(--vt-c-divider-dark-1);
|
||||||
|
|
||||||
|
--color-heading: var(--vt-c-text-dark-1);
|
||||||
|
--color-text: var(--vt-c-text-dark-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-background);
|
||||||
|
transition:
|
||||||
|
color 0.5s,
|
||||||
|
background-color 0.5s;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-family:
|
||||||
|
Inter,
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
'Segoe UI',
|
||||||
|
Roboto,
|
||||||
|
Oxygen,
|
||||||
|
Ubuntu,
|
||||||
|
Cantarell,
|
||||||
|
'Fira Sans',
|
||||||
|
'Droid Sans',
|
||||||
|
'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
1
src/assets/logo.svg
Normal file
1
src/assets/logo.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
|
||||||
|
After Width: | Height: | Size: 276 B |
57
src/assets/main.css
Normal file
57
src/assets/main.css
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "./theme.css";
|
||||||
|
@import './base.css';
|
||||||
|
|
||||||
|
@plugin "tailwindcss-primeui";
|
||||||
|
|
||||||
|
|
||||||
|
#app {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
font-weight: normal;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
a,
|
||||||
|
.green {
|
||||||
|
text-decoration: none;
|
||||||
|
color: hsla(160, 100%, 37%, 1);
|
||||||
|
transition: 0.4s;
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
a:hover {
|
||||||
|
background-color: hsla(160, 100%, 37%, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
justify-content: center;
|
||||||
|
justify-items: center;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: var(--radius-4xl);
|
||||||
|
background-color: var(--color-white);
|
||||||
|
padding: calc(var(--spacing) * 2);
|
||||||
|
}
|
||||||
208
src/assets/theme.css
Normal file
208
src/assets/theme.css
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
/* custom-theme.css — кастомная тема PrimeVue v4 с теплой премиальной палитрой */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Основные цвета */
|
||||||
|
--primary-color: #EFEFF3; /* mainGold */
|
||||||
|
--primary-color-text: #ffffff;
|
||||||
|
|
||||||
|
--primary-hover-color: #AA8F59; /* secondGold */
|
||||||
|
--primary-active-color: #8B744B; /* ещё темнее вариант для активного */
|
||||||
|
|
||||||
|
--secondary-color: #AA8F59;
|
||||||
|
--secondary-color-text: #ffffff;
|
||||||
|
|
||||||
|
/* Поверхности */
|
||||||
|
--surface-ground: #FBF9F6; /* system02 */
|
||||||
|
--surface-card: #ffffff; /* Белый, на фоне system02 */
|
||||||
|
--surface-overlay: #C7BCA8; /* system01 как подложка/оверлей */
|
||||||
|
|
||||||
|
/* Текст */
|
||||||
|
--text-color: #393838; /* darkBrown */
|
||||||
|
--text-color-secondary: #AA8F59; /* secondGold */
|
||||||
|
--text-color-muted: #C7BCA8; /* system01 */
|
||||||
|
|
||||||
|
/* Статусы */
|
||||||
|
--success-color: #36AC54;
|
||||||
|
--danger-color: #CC2A21;
|
||||||
|
--warning-color: #D99721;
|
||||||
|
|
||||||
|
/* Бордюры и тени */
|
||||||
|
--border-color: #C7BCA8;
|
||||||
|
--shadow-color: rgba(200, 173, 125, 0.3); /* мягкая тень mainGold */
|
||||||
|
|
||||||
|
/* PrimeVue-specific */
|
||||||
|
--button-text-color: var(--primary-color-text);
|
||||||
|
--button-bg-color: var(--primary-color);
|
||||||
|
--button-hover-bg-color: var(--primary-hover-color);
|
||||||
|
--button-active-bg-color: var(--primary-active-color);
|
||||||
|
|
||||||
|
--input-bg-color: var(--surface-ground);
|
||||||
|
--input-border-color: var(--secondary-color);
|
||||||
|
--input-focus-border-color: var(--primary-color);
|
||||||
|
|
||||||
|
--menu-bg-color: var(--surface-card);
|
||||||
|
--menu-item-hover-bg-color: var(--surface-overlay);
|
||||||
|
--menu-item-hover-color: var(--primary-color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Кнопки */
|
||||||
|
.p-button {
|
||||||
|
background-color: var(--button-bg-color) !important;
|
||||||
|
color: var(--button-text-color) !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: 0 2px 6px var(--shadow-color);
|
||||||
|
transition: background-color 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
padding: 8px 12px !important;
|
||||||
|
min-width: fit-content;
|
||||||
|
/*font-weight: 500;*/
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-button:hover {
|
||||||
|
background-color: var(--button-hover-bg-color) !important;
|
||||||
|
box-shadow: 0 4px 10px var(--shadow-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-button:active {
|
||||||
|
background-color: var(--button-active-bg-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-button-secondary {
|
||||||
|
background-color: var(--surface-ground) !important;
|
||||||
|
color: var(--button-active-bg-color) !important;
|
||||||
|
border: 1px solid var(--button-bg-color) !important;
|
||||||
|
/*font-size: 1rem !important;*/
|
||||||
|
padding: 8px 12px !important;
|
||||||
|
min-width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-button-secondary:hover {
|
||||||
|
background-color: var(--button-bg-color) !important;
|
||||||
|
box-shadow: 0 4px 10px var(--shadow-color);
|
||||||
|
color: var(--primary-color-text) !important;
|
||||||
|
}
|
||||||
|
.p-togglebutton {
|
||||||
|
background-color: var(--primary-color) !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-togglebutton-content {
|
||||||
|
|
||||||
|
color: var(--primary-color) !important;
|
||||||
|
padding: 0.5rem 2rem !important;
|
||||||
|
/*background-color: var(--input-bg-color) !important;*/
|
||||||
|
/*border: 1px solid var(--button-bg-color) !important;*/
|
||||||
|
box-shadow: inset 0.3px rgba(0, 0, 0, 0.5);;
|
||||||
|
}
|
||||||
|
.p-togglebutton-checked {
|
||||||
|
box-shadow: 1px rgba(0, 0, 0, 0.5);;
|
||||||
|
}
|
||||||
|
.p-selectbutton {
|
||||||
|
border: 1px solid var(--button-bg-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* InputText */
|
||||||
|
.p-inputtext, textarea {
|
||||||
|
width: 100% !important;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
/*justify-items: center;*/
|
||||||
|
font-weight: bold;
|
||||||
|
background-color: var(--primary-color) !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
/*color: var(--text-color) !important;*/
|
||||||
|
/*padding: 0.5rem 0.75rem;*/
|
||||||
|
font-size: inherit !important;
|
||||||
|
transition: border-color 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
.p-inputnumber-input {
|
||||||
|
|
||||||
|
}
|
||||||
|
.p-inputgroupaddon {
|
||||||
|
background-color: var(--input-bg-color) !important;
|
||||||
|
border: 1px solid var(--button-bg-color) !important;
|
||||||
|
border-right: 0 !important ;
|
||||||
|
}
|
||||||
|
.p-inputtext:focus, textarea:focus {
|
||||||
|
border-color: var(--input-focus-border-color) !important;
|
||||||
|
outline: none !important;
|
||||||
|
box-shadow: 0 0 8px var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Select */
|
||||||
|
.p-select {
|
||||||
|
background-color: var(--input-bg-color) !important;
|
||||||
|
border: 1px solid var(--button-bg-color) !important;
|
||||||
|
color: var(--text-color) !important;
|
||||||
|
/*padding: 0.5rem 0.75rem;*/
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
transition: border-color 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-select:focus {
|
||||||
|
border-color: var(--input-focus-border-color) !important;
|
||||||
|
outline: none !important;
|
||||||
|
box-shadow: 0 0 8px var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-select-option-selected {
|
||||||
|
background-color: var(--input-bg-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-select-option-label {
|
||||||
|
color: #000 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-panel-header {
|
||||||
|
border-radius: 1rem 1rem 1rem 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Общий фон */
|
||||||
|
body {
|
||||||
|
width: 100% !important;
|
||||||
|
background-color: var(--surface-ground);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox */
|
||||||
|
.p-checkbox-checked .p-checkbox-box {
|
||||||
|
background: var(--button-bg-color) !important;
|
||||||
|
/*color: var(--button-text-color) !important;*/
|
||||||
|
border-color: var(--button-bg-color) !important;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Прочее */
|
||||||
|
.p-card {
|
||||||
|
background-color: var(--surface-card) !important;
|
||||||
|
box-shadow: 0 2px 8px var(--shadow-color);
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-menu .p-menuitem:hover {
|
||||||
|
background-color: var(--menu-item-hover-bg-color) !important;
|
||||||
|
color: var(--menu-item-hover-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Индикаторы статуса */
|
||||||
|
.status-success {
|
||||||
|
color: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-danger {
|
||||||
|
color: var(--danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-warning {
|
||||||
|
color: var(--warning-color);
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
span, a, i {
|
||||||
|
color: var(--text-color) !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
28
src/components/Toolbar.vue
Normal file
28
src/components/Toolbar.vue
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {useToolbarStore} from '@/stores/toolbar-store'
|
||||||
|
import {RouterLink} from 'vue-router'
|
||||||
|
import {Divider} from "primevue";
|
||||||
|
|
||||||
|
const toolbar = useToolbarStore()
|
||||||
|
const keyOf = (b: { id?: string; text?: string }) => b.id ?? b.text ?? crypto.randomUUID()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<nav class="h-12 w-fit flex flex-row items-center gap-2 p-2 bg-white rounded-full !text-xl sticky top-10 justify-items-end justify-end">
|
||||||
|
<component
|
||||||
|
v-for="btnKey in toolbar.current.keys()"
|
||||||
|
:is="toolbar.current[btnKey].to ? RouterLink : 'button'"
|
||||||
|
:key="btnKey"
|
||||||
|
class="flex flex-row gap-2 items-center "
|
||||||
|
:title="toolbar.current[btnKey].title || toolbar.current[btnKey].text"
|
||||||
|
v-bind="toolbar.current[btnKey].to ? { to: toolbar.current[btnKey].to } : { type: 'button', disabled: toolbar.current[btnKey].disabled }"
|
||||||
|
@click="!toolbar.current[btnKey].to && toolbar.invoke(toolbar.current[btnKey].onClickId)"
|
||||||
|
>
|
||||||
|
<i v-if="toolbar.current[btnKey].icon" :class="toolbar.current[btnKey].icon" style="font-size: 1.1rem" class="!p-2"/>
|
||||||
|
<span v-if="toolbar.current[btnKey].text">{{ toolbar.current[btnKey].text }}</span>
|
||||||
|
<Divider v-if="btnKey+1 != toolbar.current.length" class="!m-0" layout="vertical"/>
|
||||||
|
</component>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
|
||||||
|
</template>
|
||||||
13
src/components/dashboard/DashboardView.vue
Normal file
13
src/components/dashboard/DashboardView.vue
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="card">
|
||||||
|
Not implemented.
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
71
src/components/settings/CategoriesList.vue
Normal file
71
src/components/settings/CategoriesList.vue
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
import {useSpaceStore} from "@/stores/spaceStore";
|
||||||
|
import {useToast} from "primevue/usetoast";
|
||||||
|
import {Divider} from "primevue";
|
||||||
|
import {onMounted, ref} from "vue";
|
||||||
|
import {Category} from "@/models/category";
|
||||||
|
import {categoriesService} from "@/services/categories-service";
|
||||||
|
import {useToolbarStore} from "@/stores/toolbar-store";
|
||||||
|
import {useRouter} from "vue-router";
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const spaceStore = useSpaceStore()
|
||||||
|
const toolbar = useToolbarStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const categories = ref<Category[]>([])
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
if (spaceStore.selectedSpaceId !== null) {
|
||||||
|
let spaceId = spaceStore.selectedSpaceId!!
|
||||||
|
categories.value = await categoriesService.fetchCategories(spaceId)
|
||||||
|
}
|
||||||
|
} catch (error: Error) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Failed to fetch categories.',
|
||||||
|
detail: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toCreation = () => {
|
||||||
|
router.push(`/categories/create`)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchData()
|
||||||
|
toolbar.registerHandler('createCategory', () => {
|
||||||
|
console.log("create cateogiry")
|
||||||
|
toCreation()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div v-for="key in categories.keys()" :key="categories[key].id" @click="router.push(`/categories/${categories[key].id}/edit`)"
|
||||||
|
class="flex flex-col w-full gap-0 pl-5 items-start justify-items-center font-bold text-xl">
|
||||||
|
<div class="flex-row w-full items-center justify-between">
|
||||||
|
<div class="flex-col items-start">
|
||||||
|
<div class="flex-row"> {{ categories[key].icon }} {{ categories[key].name }}</div>
|
||||||
|
<div class="flex flex-row text-sm">{{ categories[key].description }}</div>
|
||||||
|
</div>
|
||||||
|
<i class="pi pi-angle-right !text-xl !font-extralight"/>
|
||||||
|
</div>
|
||||||
|
<Divider v-if="key+1 !== categories.length" class="!m-0 !py-3"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
150
src/components/settings/CategoryCreateUpdate.vue
Normal file
150
src/components/settings/CategoryCreateUpdate.vue
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {useRoute} from "vue-router";
|
||||||
|
import {computed, onMounted, ref} from "vue";
|
||||||
|
import {useToolbarStore} from "@/stores/toolbar-store";
|
||||||
|
import {useToast} from "primevue/usetoast";
|
||||||
|
import {SelectButton} from "primevue";
|
||||||
|
import {categoriesService} from "@/services/categories-service";
|
||||||
|
import {useSpaceStore} from "@/stores/spaceStore";
|
||||||
|
import {CategoryType, CategoryTypeName} from "@/models/enums";
|
||||||
|
import emojiRegex from 'emoji-regex'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const toolbar = useToolbarStore();
|
||||||
|
const toast = useToast();
|
||||||
|
const spaceStore = useSpaceStore();
|
||||||
|
const categoryId = ref<string | undefined>(route.params.id)
|
||||||
|
const mode = computed(() => {
|
||||||
|
return categoryId.value ? "edit" : "create"
|
||||||
|
})
|
||||||
|
const categoryType = ref<CategoryType>(CategoryType.EXPENSE)
|
||||||
|
const categoryName = ref<string>()
|
||||||
|
const categoryIcon = ref<string>("🛍️")
|
||||||
|
const categoryDescription = ref<string>()
|
||||||
|
|
||||||
|
|
||||||
|
// Генерим опции: [{ label: "Расходы", value: "EXPENSE" }, ...]
|
||||||
|
const options = Object.values(CategoryType).map(type => ({
|
||||||
|
label: CategoryTypeName[type],
|
||||||
|
value: type
|
||||||
|
}))
|
||||||
|
|
||||||
|
const fetchCategory = async () => {
|
||||||
|
try {
|
||||||
|
console.log('here')
|
||||||
|
if (spaceStore.selectedSpaceId && categoryId.value) {
|
||||||
|
console.log('here2')
|
||||||
|
let category = await categoriesService.fetchCategory(spaceStore.selectedSpaceId, Number(categoryId.value))
|
||||||
|
categoryType.value = category.type
|
||||||
|
categoryName.value = category.name
|
||||||
|
categoryDescription.value = category.description
|
||||||
|
categoryIcon.value = category.icon
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err)
|
||||||
|
toast.add({
|
||||||
|
severity: "error",
|
||||||
|
summary: "Error while fetching category",
|
||||||
|
detail: err.detail.message,
|
||||||
|
life: 3000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const re = emojiRegex()
|
||||||
|
|
||||||
|
function toOneEmoji(raw: string): string {
|
||||||
|
const matches = raw.match(re) ?? [] // ← вместо [...raw.matchAll(re)]
|
||||||
|
return matches.length ? matches[matches.length - 1] : '🛍️'
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput(e: Event) {
|
||||||
|
const el = e.target as HTMLInputElement
|
||||||
|
const next = toOneEmoji(el.value)
|
||||||
|
if (el.value !== next) {
|
||||||
|
el.value = next // визуально заменить
|
||||||
|
}
|
||||||
|
categoryIcon.value = next // обновить v-model
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePaste(e: ClipboardEvent) {
|
||||||
|
const text = e.clipboardData?.getData('text') ?? ''
|
||||||
|
const next = toOneEmoji(text)
|
||||||
|
e.preventDefault()
|
||||||
|
const target = e.target as HTMLInputElement
|
||||||
|
target.value = next
|
||||||
|
categoryIcon.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
// для мобильных IME: окончание композиции
|
||||||
|
function handleCompositionEnd(e: CompositionEvent) {
|
||||||
|
handleInput(e as unknown as Event)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (mode.value === "edit") {
|
||||||
|
await fetchCategory()
|
||||||
|
toolbar.registerHandler('deleteCategory', () => {
|
||||||
|
console.log("delete category")
|
||||||
|
})
|
||||||
|
toolbar.registerHandler('updateCategory', () => {
|
||||||
|
console.log("update category")
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
toolbar.registerHandler('createCategory', () => {
|
||||||
|
console.log("create category")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
<div class="flex flex-col w-full justify-items-start gap-7">
|
||||||
|
|
||||||
|
<div class="flex flex-col w-full ">
|
||||||
|
<div class=" flex-col " v-tooltip.bottom="'Only emoji supported'">
|
||||||
|
<input class=" !justify-items-center !justify-center font-extralight text-9xl w-full focus:outline-0"
|
||||||
|
placeholder="Icon" v-model="categoryIcon" @input="handleInput" @paste="handlePaste"
|
||||||
|
@compositionend="handleCompositionEnd" inputmode="text"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"/>
|
||||||
|
<label class="!justify-items-center !justify-center !font-extralight text-gray-600 text-center">Category
|
||||||
|
icon</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full !justify-items-center !items-center !justify-center">
|
||||||
|
<SelectButton
|
||||||
|
v-model="categoryType"
|
||||||
|
:options="options"
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
class="!w-full !justify-items-center !items-center !justify-center "
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col w-full justify-items-start">
|
||||||
|
<label class="!font-semibold text-2xl text-gray-600 pl-2">Category name</label>
|
||||||
|
<div class="card !justify-start !items-start !p-4 !pl-5 ">
|
||||||
|
<input class="font-extralight text-xl w-full focus:outline-0" placeholder="Name" v-model="categoryName"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col w-full justify-items-start">
|
||||||
|
<label class="!font-semibold text-2xl text-gray-600 !pl-2">Category description</label>
|
||||||
|
<div class="card !justify-start !items-start !pl-2">
|
||||||
|
<textarea
|
||||||
|
class="font-extralight text-xl w-full focus:outline-0 !focus:border-0 !@focus:shadow-none !bg-white !border-0 min-h-36"
|
||||||
|
style="box-shadow: none !important;"
|
||||||
|
placeholder="Description" v-model="categoryDescription"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.p-togglebutton-label, .p-togglebutton {
|
||||||
|
padding: 10rem !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
13
src/components/settings/NotificationSettings.vue
Normal file
13
src/components/settings/NotificationSettings.vue
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="card">
|
||||||
|
Not implemented
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
80
src/components/settings/RecurrentsList.vue
Normal file
80
src/components/settings/RecurrentsList.vue
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
|
||||||
|
import {RecurrentOperation} from "@/models/recurrent-operation";
|
||||||
|
import {onMounted, ref} from "vue";
|
||||||
|
import {recurrentsService} from "@/services/recurrents-service";
|
||||||
|
import {useSpaceStore} from "@/stores/spaceStore";
|
||||||
|
import {useToast} from "primevue/usetoast";
|
||||||
|
import {Divider} from "primevue";
|
||||||
|
import {Category} from "@/models/category";
|
||||||
|
import {useRouter} from "vue-router";
|
||||||
|
import {useToolbarStore} from "@/stores/toolbar-store";
|
||||||
|
|
||||||
|
const toolbar = useToolbarStore()
|
||||||
|
const spaceStore = useSpaceStore();
|
||||||
|
const toast = useToast();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
|
||||||
|
const categories = ref<Category[]>([])
|
||||||
|
const recurrents = ref<RecurrentOperation[]>([])
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
if (spaceStore.selectedSpaceId) {
|
||||||
|
console.log('hereeee')
|
||||||
|
let recurrentsResponse = await recurrentsService.fetchRecurrents(spaceStore.selectedSpaceId)
|
||||||
|
recurrents.value = recurrentsResponse
|
||||||
|
console.log(recurrentsResponse)
|
||||||
|
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Failed to fetch recurrents.',
|
||||||
|
detail: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchData()
|
||||||
|
toolbar.registerHandler('openRecurrentCreation', () => {
|
||||||
|
router.push('/recurrents/create')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="card">
|
||||||
|
<div v-for="key in recurrents.keys()" :key="recurrents[key].id"
|
||||||
|
@click="router.push(`/recurrents/${recurrents[key].id}/edit`)"
|
||||||
|
class="flex flex-col w-full gap-0 pl-5 items-start justify-items-center font-bold text-xl">
|
||||||
|
<div class="flex-row w-full items-center justify-between">
|
||||||
|
<div class="flex-row items-center gap-2">
|
||||||
|
<span class="text-4xl">{{ recurrents[key].category.icon }}</span>
|
||||||
|
<div class="flex-col items-start">
|
||||||
|
<div class="flex-row"> {{ recurrents[key].name }}</div>
|
||||||
|
<div class="flex flex-row text-sm">{{ recurrents[key].category.name }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="items-center flex-col">
|
||||||
|
<span class="text-lg !font-semibold">{{recurrents[key].amount}}₽ </span>
|
||||||
|
<span class="text-sm">каждое {{ recurrents[key].date }} число </span>
|
||||||
|
</div>
|
||||||
|
<i class="pi pi-angle-right !text-xl !font-extralight"/>
|
||||||
|
</div>
|
||||||
|
<Divider v-if="key+1 !== recurrents.length" class="!m-0 !py-3"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
196
src/components/settings/RecurrentyCreateUpdate.vue
Normal file
196
src/components/settings/RecurrentyCreateUpdate.vue
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {useRoute} from "vue-router";
|
||||||
|
import {computed, onMounted, ref} from "vue";
|
||||||
|
import {useToolbarStore} from "@/stores/toolbar-store";
|
||||||
|
import {useToast} from "primevue/usetoast";
|
||||||
|
import {Divider, InputNumber} from "primevue";
|
||||||
|
import {categoriesService} from "@/services/categories-service";
|
||||||
|
import {useSpaceStore} from "@/stores/spaceStore";
|
||||||
|
import {recurrentsService} from "@/services/recurrents-service";
|
||||||
|
import {Category} from "@/models/category";
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const toolbar = useToolbarStore();
|
||||||
|
const toast = useToast();
|
||||||
|
const spaceStore = useSpaceStore();
|
||||||
|
|
||||||
|
const isCategorySelectorOpened = ref(false);
|
||||||
|
|
||||||
|
const categories = ref<Category[]>([]);
|
||||||
|
const recurrentId = ref<string | undefined>(route.params.id)
|
||||||
|
const mode = computed(() => {
|
||||||
|
return recurrentId.value ? "edit" : "create"
|
||||||
|
})
|
||||||
|
const recurrentCategory = ref<Category>({})
|
||||||
|
const recurrentName = ref<string>()
|
||||||
|
const recurrentAmount = ref<number>(0)
|
||||||
|
const recurrentDate = ref<number>(Math.floor(Math.random() * 31))
|
||||||
|
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
console.log('here')
|
||||||
|
if (spaceStore.selectedSpaceId) {
|
||||||
|
categories.value = await categoriesService.fetchCategories(spaceStore.selectedSpaceId)
|
||||||
|
if (categories.value.length > 0) {
|
||||||
|
if (mode.value === "edit") {
|
||||||
|
console.log('here2')
|
||||||
|
let recurrent = await recurrentsService.fetchRecurrent(spaceStore.selectedSpaceId, Number(recurrentId.value))
|
||||||
|
recurrentCategory.value = recurrent.category
|
||||||
|
recurrentName.value = recurrent.name
|
||||||
|
recurrentAmount.value = recurrent.amount
|
||||||
|
recurrentDate.value = recurrent.date
|
||||||
|
} else {
|
||||||
|
recurrentCategory.value = categories.value[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err)
|
||||||
|
toast.add({
|
||||||
|
severity: "error",
|
||||||
|
summary: "Error while fetching category",
|
||||||
|
detail: err.detail.message,
|
||||||
|
life: 3000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput(e: Event) {
|
||||||
|
const el = e.target as HTMLInputElement
|
||||||
|
const val = el.value.trim()
|
||||||
|
|
||||||
|
// если пусто — сбрасываем
|
||||||
|
if (!val) {
|
||||||
|
recurrentAmount.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// пробуем преобразовать в число
|
||||||
|
const num = Number(val)
|
||||||
|
recurrentAmount.value = isNaN(num) ? 0 : num
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePaste(e: ClipboardEvent) {
|
||||||
|
e.preventDefault() // предотвратить стандартную вставку
|
||||||
|
const text = e.clipboardData?.getData('text')?.trim() ?? ''
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
recurrentAmount.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const num = Number(text)
|
||||||
|
recurrentAmount.value = isNaN(num) ? 0 : num
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchData()
|
||||||
|
if (mode.value === "edit") {
|
||||||
|
|
||||||
|
toolbar.registerHandler('deleteRecurrent', () => {
|
||||||
|
console.log("delete recurrent")
|
||||||
|
})
|
||||||
|
toolbar.registerHandler('updateRecurrent', () => {
|
||||||
|
console.log("update Recurrent")
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
toolbar.registerHandler('createRecurrent', () => {
|
||||||
|
console.log("create Recurrent")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
<div v-if="categories.length===0" class="card !gap-4 !p-10">
|
||||||
|
<span class="text-2xl">No categories available.</span>
|
||||||
|
<span class="text-center">Maybe you want to <router-link to="/categories" class="!text-blue-700">create a new category</router-link> first?</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex flex-col w-full justify-items-start gap-7">
|
||||||
|
<div v-if="isCategorySelectorOpened" class="!absolute !top-0 !left-0 !h-full !w-full !z-50 !px-4 !overflow-y-auto"
|
||||||
|
style="background: var(--primary-color)">
|
||||||
|
<div class="card">
|
||||||
|
<div v-for="key in categories.keys()" :key="categories[key].id"
|
||||||
|
@click="recurrentCategory = categories[key]; isCategorySelectorOpened = false"
|
||||||
|
class="flex flex-col w-full gap-0 pl-5 items-start justify-items-center font-bold text-xl">
|
||||||
|
<div class="flex-row w-full items-center justify-between">
|
||||||
|
<div class="flex-row items-center gap-2">
|
||||||
|
<span class="text-3xl">{{ categories[key].icon }} </span>
|
||||||
|
<div class="flex-col justify-between">
|
||||||
|
<div class="flex-row"> {{ categories[key].name }}</div>
|
||||||
|
<div class="flex flex-row text-sm">{{ categories[key].description }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<i class="pi pi-angle-right !text-xl !font-extralight"/>
|
||||||
|
</div>
|
||||||
|
<Divider v-if="key+1 !== categories.length" class="!m-0 !py-3"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col w-full ">
|
||||||
|
<div class="flex-col w-full">
|
||||||
|
|
||||||
|
<InputNumber
|
||||||
|
v-model="recurrentAmount"
|
||||||
|
@input="handleInput"
|
||||||
|
@paste="handlePaste"
|
||||||
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
placeholder="Amount"
|
||||||
|
suffix="₽"
|
||||||
|
class="text-7xl font-bold w-full text-center focus:outline-none !p-0 !m-0"
|
||||||
|
|
||||||
|
/>
|
||||||
|
<!-- <span class="absolute right-2 top-1/2 -translate-y-1/2 text-7xl font-bold">₽</span>-->
|
||||||
|
|
||||||
|
<label class="!justify-items-center !justify-center !font-extralight text-gray-600 text-center">Amount</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col w-full justify-items-start">
|
||||||
|
<label class="!font-semibold text-2xl text-gray-600 pl-2">Recurrent category</label>
|
||||||
|
<div class="card !justify-start !items-start !p-4 !pl-5 " @click="isCategorySelectorOpened = true">
|
||||||
|
<div class="flex-row w-full gap-2 items-center justify-between">
|
||||||
|
<div class="flex-row gap-2 items-center">
|
||||||
|
<span class="!text-3xl ">{{ recurrentCategory.icon }}</span>
|
||||||
|
<div class="flex-col ">
|
||||||
|
<span class=" !text-2xl">{{ recurrentCategory.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<i class="pi pi-angle-right !text-xl !font-extralight"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col w-full justify-items-start">
|
||||||
|
<label class="!font-semibold text-2xl text-gray-600 pl-2">Recurrent name</label>
|
||||||
|
<div class="card !justify-start !items-start !p-4 !pl-5 ">
|
||||||
|
<input class="font-extralight text-xl w-full focus:outline-0" placeholder="Name" v-model="recurrentName"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col w-full justify-items-start">
|
||||||
|
<label class="!font-semibold text-2xl text-gray-600 !pl-2">Recurrent date</label>
|
||||||
|
<div class="card !justify-start !items-start !pl-2">
|
||||||
|
<div class="!grid !grid-cols-7 gap-2">
|
||||||
|
<div v-for="i in 31" class="!w-12 !h-12 !items-center !justify-items-center !justify-center rounded-full "
|
||||||
|
:class="recurrentDate == i ? 'bg-green-200' : 'bg-gray-100'"
|
||||||
|
@click="recurrentDate=i">
|
||||||
|
{{ i }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="!font-extralight text-gray-600 !pl-2">recurrent every N day of month</label>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
44
src/components/settings/SettingsList.vue
Normal file
44
src/components/settings/SettingsList.vue
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {Divider} from "primevue";
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{name: "Space settings", link: '/space-settings'},
|
||||||
|
{name: "Notification settings", link: '/notification-settings'},
|
||||||
|
{name: "Categories", link: '/categories'},
|
||||||
|
{name: "Recurrent Operations", link: '/recurrents'},
|
||||||
|
{name: "Recurrent Operations", link: '/recurrents'},
|
||||||
|
{name: "Recurrent Operations", link: '/recurrents'},
|
||||||
|
{name: "Recurrent Operations", link: '/recurrents'},
|
||||||
|
{name: "Recurrent Operations", link: '/recurrents'},
|
||||||
|
{name: "Recurrent Operations", link: '/recurrents'},
|
||||||
|
{name: "Recurrent Operations", link: '/recurrents'},
|
||||||
|
{name: "Recurrent Operations", link: '/recurrents'},
|
||||||
|
{name: "Recurrent Operations", link: '/recurrents'},
|
||||||
|
{name: "Recurrent Operations", link: '/recurrents'},
|
||||||
|
{name: "Recurrent Operations", link: '/recurrents'}
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="overflow-y-auto">
|
||||||
|
<div class="card">
|
||||||
|
<router-link :to="items[item].link" v-for="item in items.keys()"
|
||||||
|
class="flex flex-col w-full gap-0 pl-5 items-start justify-items-center">
|
||||||
|
|
||||||
|
<div class="flex flex-row justify-between items-center w-full pe-2 p-2">
|
||||||
|
|
||||||
|
<span class="font-bold text-xl">{{ items[item].name }}</span>
|
||||||
|
|
||||||
|
<i class="pi pi-angle-right !text-xl !font-extralight"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider v-if="item+1 != items.length" class="!p-2 !m-0"/>
|
||||||
|
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
13
src/components/settings/SpaceSettings.vue
Normal file
13
src/components/settings/SpaceSettings.vue
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="card">
|
||||||
|
Not implemented
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
80
src/components/space-list/SpaceList.vue
Normal file
80
src/components/space-list/SpaceList.vue
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {computed, onMounted, ref} from "vue";
|
||||||
|
import type {Space} from "@/models/space";
|
||||||
|
import {Vue3PullToRefresh} from "@amirafa/vue3-pull-to-refresh";
|
||||||
|
// import {spaceService} from "@/services/space-service";
|
||||||
|
|
||||||
|
// PrimeVue Toast
|
||||||
|
import Toast from "primevue/toast"; // <— правильный импорт компонента
|
||||||
|
import {useToast} from "primevue/usetoast";
|
||||||
|
import {useSpaceStore} from "@/stores/spaceStore"; // нужен ToastService в main.ts
|
||||||
|
const toast = useToast();
|
||||||
|
const spaceStore = useSpaceStore();
|
||||||
|
const emits = defineEmits(["space-selected"])
|
||||||
|
|
||||||
|
const spaces = ref<Space[]>([]);
|
||||||
|
const spaceId = computed(() => {
|
||||||
|
return spaceStore.selectedSpaceId
|
||||||
|
})
|
||||||
|
const spaceName = computed(() => {
|
||||||
|
return spaceStore.selectedSpaceName
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
spaces.value = await spaceStore.getSpaces();
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err);
|
||||||
|
toast.add({
|
||||||
|
severity: "error",
|
||||||
|
summary: "Ошибка загрузки данных",
|
||||||
|
detail: err?.message ?? "Неизвестная ошибка",
|
||||||
|
life: 3000,
|
||||||
|
closable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectSpace = (space: Space) => {
|
||||||
|
spaceStore.setSpace(space.id, space.name);
|
||||||
|
emits('space-selected', space);
|
||||||
|
}
|
||||||
|
|
||||||
|
// если нужно дергать из родителя:
|
||||||
|
// defineExpose({ fetchData });
|
||||||
|
|
||||||
|
onMounted(fetchData);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Toast/>
|
||||||
|
<Vue3PullToRefresh
|
||||||
|
:distance="50"
|
||||||
|
:duration="2000"
|
||||||
|
:size="32"
|
||||||
|
noreload
|
||||||
|
:options="{ color: '#111', bgColor: '#fff' }"
|
||||||
|
@onrefresh="
|
||||||
|
() => {
|
||||||
|
console.log('refreshed');
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<div class="space-list flex w-full flex-col justify-center gap-2 p-2">
|
||||||
|
<!-- твой контент -->
|
||||||
|
<span class="font-bold">Selected space: {{spaceName}}</span>
|
||||||
|
<div v-for="space in spaces" :key="space.id" class="w-full h-full " @click="selectSpace(space)" >
|
||||||
|
<div class="flex w-full flex-col justify-start rounded-2xl p-2 bg-gray-50 gap-2" :class="spaceId === space.id ? '!bg-green-50' : 'bg-gray-50'">
|
||||||
|
<span class="text-2xl font-medium ">{{ space.name }}</span>
|
||||||
|
<div class="w-10 h-10 rounded-full bg-green-200 flex items-center justify-center ">
|
||||||
|
<span class="font-bold ">{{
|
||||||
|
space.owner.firstName.substring(0, 1).toUpperCase()
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
||||||
11
src/components/transactions/TransactionList.vue
Normal file
11
src/components/transactions/TransactionList.vue
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
14
src/env.d.ts
vendored
Normal file
14
src/env.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_URL: string
|
||||||
|
readonly VITE_ENABLE_DEVTOOLS: boolean
|
||||||
|
readonly VITE_APP_NAME: string
|
||||||
|
readonly VITE_API_TIMEOUT: number
|
||||||
|
// добавь сюда свои переменные, если есть
|
||||||
|
// readonly VITE_APP_TITLE: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
45
src/main.ts
Normal file
45
src/main.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import './assets/main.css'
|
||||||
|
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import PrimeVue from 'primevue/config'
|
||||||
|
import router from './router';
|
||||||
|
import Aura from '@primevue/themes/aura';
|
||||||
|
import 'primeicons/primeicons.css'
|
||||||
|
import Ripple from "primevue/ripple";
|
||||||
|
import ToastService from 'primevue/toastservice'
|
||||||
|
import Tooltip from 'primevue/tooltip';
|
||||||
|
import { createPinia } from 'pinia';
|
||||||
|
import ConfirmationService from 'primevue/confirmationservice';
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(router);
|
||||||
|
app.use(ToastService);
|
||||||
|
app.use(ConfirmationService);
|
||||||
|
app.use(createPinia())
|
||||||
|
app.directive('ripple', Ripple);
|
||||||
|
app.directive('tooltip', Tooltip);
|
||||||
|
|
||||||
|
app.use(PrimeVue, {
|
||||||
|
theme: {
|
||||||
|
preset: Aura,
|
||||||
|
options: {
|
||||||
|
darkModeSelector: 'light-mode',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.config.globalProperties.$primevue.config.locale = {
|
||||||
|
firstDayOfWeek: 1, // Устанавливаем понедельник как первый день недели
|
||||||
|
dayNames: ["Воскресенье", "Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота"],
|
||||||
|
dayNamesShort: ["Вс", "Пн", "Вт", "Ср", "Чт", "Пт", "Сб"],
|
||||||
|
dayNamesMin: ["Вс", "Пн", "Вт", "Ср", "Чт", "Пт", "Сб"],
|
||||||
|
monthNames: ["Январь", "Февраль", "Март", "Апрель", "Май", "Июнь", "Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь"],
|
||||||
|
monthNamesShort: ["Янв", "Фев", "Мар", "Апр", "Май", "Июн", "Июл", "Авг", "Сен", "Окт", "Ноя", "Дек"],
|
||||||
|
today: "Сегодня",
|
||||||
|
clear: "Очистить",
|
||||||
|
dateFormat: "dd.mm.yy",
|
||||||
|
weekHeader: "Нед",
|
||||||
|
fileSizeTypes: []
|
||||||
|
};
|
||||||
|
app.mount('#app')
|
||||||
30
src/models/category.ts
Normal file
30
src/models/category.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import {User} from "@/models/user";
|
||||||
|
import {CategoryType} from "@/models/enums";
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
id: number;
|
||||||
|
type: CategoryType;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
createdBy: User;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedBy: User;
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCategoryDTO {
|
||||||
|
type: CategoryType;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCategoryDTO {
|
||||||
|
type: CategoryType;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
10
src/models/enums.ts
Normal file
10
src/models/enums.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export enum CategoryType {
|
||||||
|
EXPENSE = "EXPENSE",
|
||||||
|
INCOME = "INCOME",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CategoryTypeName: Record<CategoryType, string> = {
|
||||||
|
[CategoryType.EXPENSE]: 'Расходы',
|
||||||
|
[CategoryType.INCOME]: 'Поступления',
|
||||||
|
}
|
||||||
|
|
||||||
26
src/models/recurrent-operation.ts
Normal file
26
src/models/recurrent-operation.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import {Category} from "@/models/category";
|
||||||
|
import {User} from "@/models/user";
|
||||||
|
|
||||||
|
export interface RecurrentOperation {
|
||||||
|
id: number;
|
||||||
|
category: Category;
|
||||||
|
name: string;
|
||||||
|
amount: number;
|
||||||
|
date: number
|
||||||
|
createdBy: User;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateRecurrentOperationDTO {
|
||||||
|
categoryId: number;
|
||||||
|
name: string;
|
||||||
|
amount: number;
|
||||||
|
date: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateRecurrentOperationDTO {
|
||||||
|
categoryId: number;
|
||||||
|
name: string;
|
||||||
|
amount: number;
|
||||||
|
date: number;
|
||||||
|
}
|
||||||
19
src/models/space.ts
Normal file
19
src/models/space.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import {User} from "@/models/user";
|
||||||
|
|
||||||
|
export interface Space {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
owner: User;
|
||||||
|
participants: User[];
|
||||||
|
createdBy: User;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSpaceDTO {
|
||||||
|
name: string;
|
||||||
|
createBasicCategories: boolean;
|
||||||
|
}
|
||||||
|
export interface UpdateSpaceDTO {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
8
src/models/user.ts
Normal file
8
src/models/user.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
firstName: string;
|
||||||
|
tgId: number
|
||||||
|
tdUserName: string;
|
||||||
|
roles: string[];
|
||||||
|
}
|
||||||
58
src/network/axiosSetup.ts
Normal file
58
src/network/axiosSetup.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
// src/services/axiosSetup.ts
|
||||||
|
import axios from 'axios';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
// Создаем экземпляр axios
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_URL,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Устанавливаем токен из localStorage при каждом запуске
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (token) {
|
||||||
|
api.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 🔧 Глобальное преобразование даты (без Z) =====
|
||||||
|
|
||||||
|
const isDate = (value: any): value is Date =>
|
||||||
|
Object.prototype.toString.call(value) === '[object Date]';
|
||||||
|
|
||||||
|
const recursivelyFormatDates = (obj: any): any => {
|
||||||
|
if (obj === null || obj === undefined) return obj;
|
||||||
|
|
||||||
|
if (isDate(obj)) return dayjs(obj).format('YYYY-MM-DDTHH:mm:ss');
|
||||||
|
|
||||||
|
if (Array.isArray(obj)) return obj.map(recursivelyFormatDates);
|
||||||
|
|
||||||
|
if (typeof obj === 'object') {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(obj).map(([key, value]) => [key, recursivelyFormatDates(value)])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj;
|
||||||
|
};
|
||||||
|
|
||||||
|
// api.interceptors.request.use((config) => {
|
||||||
|
// if (config.data && typeof config.data === 'object') {
|
||||||
|
// config.data = recursivelyFormatDates(config.data);
|
||||||
|
// }
|
||||||
|
// return config;
|
||||||
|
// }, (error) => Promise.reject(error));
|
||||||
|
|
||||||
|
// ===== 🔐 Перехватчик ответа для проверки 401 =====
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
if (error.response && error.response.status === 401) {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
const router = useRouter();
|
||||||
|
// await router.push('/login');
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default api;
|
||||||
197
src/router/index.ts
Normal file
197
src/router/index.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
// index.ts
|
||||||
|
import {createRouter, createWebHistory, RouteRecordRaw} from 'vue-router'
|
||||||
|
import {useToolbarStore} from '@/stores/toolbar-store'
|
||||||
|
import {useSpaceStore} from '@/stores/spaceStore'
|
||||||
|
import CategoryCreateUpdate from "@/components/settings/CategoryCreateUpdate.vue";
|
||||||
|
import DashboardView from "@/components/dashboard/DashboardView.vue";
|
||||||
|
import RecurrentyCreateUpdate from "@/components/settings/RecurrentyCreateUpdate.vue";
|
||||||
|
import TransactionList from "@/components/transactions/TransactionList.vue";
|
||||||
|
|
||||||
|
// 📝 Расширяем тип меты роутов (типобезопасный toolbar, requiresAuth, guestOnly)
|
||||||
|
declare module 'vue-router' {
|
||||||
|
interface RouteMeta {
|
||||||
|
requiresAuth?: boolean
|
||||||
|
guestOnly?: boolean
|
||||||
|
toolbar?: import('@/stores/toolbar-store').ToolbarConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⚙️ Ленивая загрузка компонентов (code-splitting)
|
||||||
|
const SettingsList = () => import('@/components/settings/SettingsList.vue')
|
||||||
|
const CategoriesList = () => import('@/components/settings/CategoriesList.vue')
|
||||||
|
const RecurrentsList = () => import('@/components/settings/RecurrentsList.vue')
|
||||||
|
const SpaceSettings = () => import('@/components/settings/SpaceSettings.vue')
|
||||||
|
const NotificationSettings = () => import('@/components/settings/NotificationSettings.vue')
|
||||||
|
|
||||||
|
// Имена роутов для автокомплита и навигации
|
||||||
|
export const enum RouteName {
|
||||||
|
Dashboard = 'dashboard',
|
||||||
|
TransactionList = 'transaction-list',
|
||||||
|
SettingsList = 'settings-list',
|
||||||
|
CategoriesList = 'categories-list',
|
||||||
|
CategoryCreate = 'category-create',
|
||||||
|
CategoryUpdate = 'category-update',
|
||||||
|
RecurrentsList = 'recurrents-list',
|
||||||
|
RecurrentCreate = 'recurrent-create',
|
||||||
|
RecurrentUpdate = 'recurrent-update',
|
||||||
|
SpaceSettings = 'space-settings',
|
||||||
|
NotificationSettings = 'notification-settings',
|
||||||
|
}
|
||||||
|
|
||||||
|
const routes: RouteRecordRaw[] = [
|
||||||
|
{path: '/', name: RouteName.Dashboard, component: DashboardView, meta: {requiresAuth: true}},
|
||||||
|
{path: '/transactions', name: RouteName.TransactionList, component: TransactionList, meta: {requiresAuth: true}},
|
||||||
|
{path: '/settings', name: RouteName.SettingsList, component: SettingsList, meta: {requiresAuth: true}},
|
||||||
|
{
|
||||||
|
path: '/categories', name: RouteName.CategoriesList, component: CategoriesList, meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
toolbar: ({spaceStore}: { spaceStore: ReturnType<typeof useSpaceStore> }) => [
|
||||||
|
{
|
||||||
|
id: 'space',
|
||||||
|
text: spaceStore.selectedSpaceName ?? 'Select Space',
|
||||||
|
icon: '',
|
||||||
|
onClickId: 'openSpacePicker',
|
||||||
|
},
|
||||||
|
{id: 'openCategoryCreation', text: '', icon: 'pi pi-plus', onClickId: 'openCategoryCreation'},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/categories/create', name: RouteName.CategoryCreate, component: CategoryCreateUpdate, meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
toolbar: ({spaceStore}: { spaceStore: ReturnType<typeof useSpaceStore> }) => [
|
||||||
|
{
|
||||||
|
id: 'space',
|
||||||
|
text: spaceStore.selectedSpaceName ?? 'Select Space',
|
||||||
|
icon: '',
|
||||||
|
onClickId: 'openSpacePicker',
|
||||||
|
},
|
||||||
|
{id: 'createCategory', text: '', icon: 'pi pi-save', onClickId: 'createCategory'},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/categories/:id/edit', name: RouteName.CategoryUpdate, component: CategoryCreateUpdate, meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
toolbar: ({spaceStore}: { spaceStore: ReturnType<typeof useSpaceStore> }) => [
|
||||||
|
{
|
||||||
|
id: 'space',
|
||||||
|
text: spaceStore.selectedSpaceName ?? 'Select Space',
|
||||||
|
icon: '',
|
||||||
|
onClickId: 'openSpacePicker',
|
||||||
|
},
|
||||||
|
{id: 'deleteCategory', text: '', icon: 'pi pi-trash', onClickId: 'deleteCategory'},
|
||||||
|
{id: 'updateCategory', text: '', icon: 'pi pi-save', onClickId: 'updateCategory'},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
path: '/recurrents', name: RouteName.RecurrentsList, component: RecurrentsList, meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
toolbar: ({spaceStore}: { spaceStore: ReturnType<typeof useSpaceStore> }) => [
|
||||||
|
{
|
||||||
|
id: 'space',
|
||||||
|
text: spaceStore.selectedSpaceName ?? 'Select Space',
|
||||||
|
icon: 'pi pi-home',
|
||||||
|
onClickId: 'openSpacePicker',
|
||||||
|
},
|
||||||
|
{id: 'openRecurrentCreation', text: '', icon: 'pi pi-plus', onClickId: 'openRecurrentCreation'},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/recurrents/create', name: RouteName.RecurrentCreate, component: RecurrentyCreateUpdate, meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
toolbar: ({spaceStore}: { spaceStore: ReturnType<typeof useSpaceStore> }) => [
|
||||||
|
{
|
||||||
|
id: 'space',
|
||||||
|
text: spaceStore.selectedSpaceName ?? 'Select Space',
|
||||||
|
icon: '',
|
||||||
|
onClickId: 'openSpacePicker',
|
||||||
|
},
|
||||||
|
{id: 'createRecurrent', text: '', icon: 'pi pi-save', onClickId: 'createCategory'},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/recurrents/:id/edit', name: RouteName.RecurrentUpdate, component: RecurrentyCreateUpdate, meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
toolbar: ({spaceStore}: { spaceStore: ReturnType<typeof useSpaceStore> }) => [
|
||||||
|
{
|
||||||
|
id: 'space',
|
||||||
|
text: spaceStore.selectedSpaceName ?? 'Select Space',
|
||||||
|
icon: '',
|
||||||
|
onClickId: 'openSpacePicker',
|
||||||
|
},
|
||||||
|
{id: 'deleteRecurrent', text: '', icon: 'pi pi-trash', onClickId: 'deleteRecurrent'},
|
||||||
|
{id: 'updateRecurrent', text: '', icon: 'pi pi-save', onClickId: 'updateRecurrent'},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/space-settings',
|
||||||
|
name: RouteName.SpaceSettings,
|
||||||
|
component: SpaceSettings,
|
||||||
|
meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
toolbar: ({spaceStore}: { spaceStore: ReturnType<typeof useSpaceStore> }) => [
|
||||||
|
{
|
||||||
|
id: 'space',
|
||||||
|
text: spaceStore.selectedSpaceName ?? 'Select Space',
|
||||||
|
icon: 'pi pi-home',
|
||||||
|
onClickId: 'openSpacePicker',
|
||||||
|
},
|
||||||
|
{id: 'save', text: 'Save', icon: 'pi pi-check', onClickId: 'saveSettings'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/notification-settings',
|
||||||
|
name: RouteName.NotificationSettings,
|
||||||
|
component: NotificationSettings,
|
||||||
|
meta: {requiresAuth: true}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
routes,
|
||||||
|
scrollBehavior() {
|
||||||
|
return {top: 0, left: 0, behavior: 'auto'}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function isAuthed() {
|
||||||
|
return !!localStorage.getItem('token')
|
||||||
|
}
|
||||||
|
|
||||||
|
router.beforeEach((to, _from, next) => {
|
||||||
|
const authed = isAuthed()
|
||||||
|
|
||||||
|
if (to.meta.requiresAuth && !authed) {
|
||||||
|
const back = encodeURIComponent(to.fullPath)
|
||||||
|
return next(`/login?back=${back}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.meta.guestOnly && authed) {
|
||||||
|
return next({name: RouteName.SettingsList})
|
||||||
|
}
|
||||||
|
|
||||||
|
return next()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 🔁 Авто-обновление тулбара при каждом переходе
|
||||||
|
router.afterEach((to) => {
|
||||||
|
const toolbar = useToolbarStore()
|
||||||
|
const cfg = to.meta.toolbar
|
||||||
|
|
||||||
|
if (typeof cfg === 'function') {
|
||||||
|
// даём конфигу доступ к сторам (расширяй при необходимости)
|
||||||
|
toolbar.setByConfig(({...ctx}) => cfg({spaceStore: useSpaceStore(), ...ctx}))
|
||||||
|
} else {
|
||||||
|
toolbar.setByConfig(cfg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
43
src/services/categories-service.ts
Normal file
43
src/services/categories-service.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import {Category} from "@/models/category";
|
||||||
|
import {CategoryType} from "@/models/enums";
|
||||||
|
import {User} from "@/models/user";
|
||||||
|
|
||||||
|
async function fetchCategories(spaceId: number) {
|
||||||
|
let categories : Category[] = []
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
categories.push({
|
||||||
|
id: i,
|
||||||
|
type: Math.floor(Math.random() * 2) === 0 ? CategoryType.INCOME : CategoryType.EXPENSE,
|
||||||
|
name: `Category ${i}`,
|
||||||
|
description: `Description of Category ${i}`,
|
||||||
|
icon: "😇",
|
||||||
|
createdBy: {
|
||||||
|
id: i,
|
||||||
|
username: `username_${i}`,
|
||||||
|
firstName: `firstName ${i}`,
|
||||||
|
} as User,
|
||||||
|
createdAt: new Date(),
|
||||||
|
} as Category)
|
||||||
|
}
|
||||||
|
return categories;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCategory(spaceId: number, categoryId: number): Promise<Category> {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
type: Math.floor(Math.random() * 2) === 0 ? CategoryType.INCOME : CategoryType.EXPENSE,
|
||||||
|
name: `Category ${1}`,
|
||||||
|
description: `Description of Category ${1}`,
|
||||||
|
icon: "😇",
|
||||||
|
createdBy: {
|
||||||
|
id: 1,
|
||||||
|
username: `username_${1}`,
|
||||||
|
firstName: `firstName ${1}`,
|
||||||
|
} as User,
|
||||||
|
createdAt: new Date(),
|
||||||
|
} as Category
|
||||||
|
}
|
||||||
|
|
||||||
|
export const categoriesService = {
|
||||||
|
fetchCategories, fetchCategory
|
||||||
|
}
|
||||||
44
src/services/recurrents-service.ts
Normal file
44
src/services/recurrents-service.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
|
||||||
|
import {User} from "@/models/user";
|
||||||
|
import {RecurrentOperation} from "@/models/recurrent-operation";
|
||||||
|
import {categoriesService} from "@/services/categories-service";
|
||||||
|
|
||||||
|
async function fetchRecurrents(spaceId: number): Promise<RecurrentOperation[]> {
|
||||||
|
let recurrents : RecurrentOperation[] = []
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
recurrents.push({
|
||||||
|
id: i,
|
||||||
|
category: await categoriesService.fetchCategory(1,1),
|
||||||
|
name: `Recurrent ${i}`,
|
||||||
|
amount: Math.floor(Math.random() * 1000000),
|
||||||
|
date: Math.floor(Math.random() * 31),
|
||||||
|
createdBy: {
|
||||||
|
id: 1,
|
||||||
|
username: `username_${i}`,
|
||||||
|
firstName: `firstName ${i}`,
|
||||||
|
} as User,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
} as RecurrentOperation)
|
||||||
|
}
|
||||||
|
return recurrents;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRecurrent(spaceId: number, categoryId: number): Promise<RecurrentOperation> {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
category: await categoriesService.fetchCategory(1,1),
|
||||||
|
name: `Recurrent ${1}`,
|
||||||
|
amount: Math.floor(Math.random() * 1000000),
|
||||||
|
date: Math.floor(Math.random() * 31),
|
||||||
|
createdBy: {
|
||||||
|
id: 1,
|
||||||
|
username: `username_${1}`,
|
||||||
|
firstName: `firstName ${1}`,
|
||||||
|
} as User,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
} as RecurrentOperation
|
||||||
|
}
|
||||||
|
|
||||||
|
export const recurrentsService = {
|
||||||
|
fetchRecurrents, fetchRecurrent
|
||||||
|
}
|
||||||
66
src/services/space-service.ts
Normal file
66
src/services/space-service.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type {Space} from "@/models/space";
|
||||||
|
import api from "@/network/axiosSetup"
|
||||||
|
import {User} from "@/models/user";
|
||||||
|
|
||||||
|
// async function getSpaces(): Promise<Space[]> {
|
||||||
|
// const response = await api.get("/api/spaces");
|
||||||
|
// if (!response.status.toString().startsWith('2')) throw new Error("Failed to fetch spaces");
|
||||||
|
// return response.data;
|
||||||
|
// }
|
||||||
|
|
||||||
|
|
||||||
|
async function fetchSpaces(): Promise<Space[]> {
|
||||||
|
let spaces: Space[] = [];
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
spaces.push(
|
||||||
|
{
|
||||||
|
id: i,
|
||||||
|
name: `Space ${i}`,
|
||||||
|
owner: {
|
||||||
|
id: i,
|
||||||
|
username: `user_name_${i}`,
|
||||||
|
firstName: `user_name_${i}`,
|
||||||
|
} as User,
|
||||||
|
participants: [{
|
||||||
|
id: i,
|
||||||
|
username: `user_name_${i}`,
|
||||||
|
firstName: `user_name_${i}`,
|
||||||
|
} as User],
|
||||||
|
createdBy: {
|
||||||
|
id: i,
|
||||||
|
username: `user_name_${i}`,
|
||||||
|
firstName: `user_name_${i}`,
|
||||||
|
} as User,
|
||||||
|
createdAt: new Date(),
|
||||||
|
} as Space
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return spaces;
|
||||||
|
|
||||||
|
}
|
||||||
|
async function fetchSpace(spaceId: number): Promise<Space> {
|
||||||
|
return {
|
||||||
|
id: spaceId,
|
||||||
|
name: `Space ${spaceId}`,
|
||||||
|
owner: {
|
||||||
|
id: spaceId,
|
||||||
|
username: `user_name_${spaceId}`,
|
||||||
|
firstName: `user_name_${spaceId}`,
|
||||||
|
} as User,
|
||||||
|
participants: [{
|
||||||
|
id: spaceId,
|
||||||
|
username: `user_name_${spaceId}`,
|
||||||
|
firstName: `user_name_${spaceId}`,
|
||||||
|
} as User],
|
||||||
|
createdBy: {
|
||||||
|
id: spaceId,
|
||||||
|
username: `user_name_${spaceId}`,
|
||||||
|
firstName: `user_name_${spaceId}`,
|
||||||
|
} as User,
|
||||||
|
createdAt: new Date(),
|
||||||
|
} as Space
|
||||||
|
}
|
||||||
|
|
||||||
|
export const spaceService = {
|
||||||
|
fetchSpaces, fetchSpace
|
||||||
|
};
|
||||||
6
src/shims-vue.d.ts
vendored
Normal file
6
src/shims-vue.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<{}, {}, any>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
46
src/stores/spaceStore.ts
Normal file
46
src/stores/spaceStore.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import {defineStore} from "pinia";
|
||||||
|
import {ref, watch} from "vue";
|
||||||
|
|
||||||
|
import {spaceService} from "@/services/space-service";
|
||||||
|
import {Space} from "@/models/space";
|
||||||
|
|
||||||
|
export const useSpaceStore = defineStore('space', () => {
|
||||||
|
const spaces = ref<Space[]>([]);
|
||||||
|
const getSpaces = async () => {
|
||||||
|
await spaceService.fetchSpaces().then((res) => {
|
||||||
|
spaces.value = res;
|
||||||
|
const spaceId = localStorage.getItem("spaceId");
|
||||||
|
const selectedSpace = spaces.value.find((s: Space) => s.id.toString() === spaceId) || null;
|
||||||
|
selectedSpaceId.value = selectedSpace?.id;
|
||||||
|
selectedSpaceName.value = selectedSpace?.name;
|
||||||
|
})
|
||||||
|
return spaces.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedSpaceId = ref<number | undefined>(undefined);
|
||||||
|
const selectedSpaceName = ref<string | undefined>(undefined);
|
||||||
|
|
||||||
|
|
||||||
|
const getSpace = async () => {
|
||||||
|
const spaceId = selectedSpaceId.value;
|
||||||
|
if (!spaceId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return spaceService.fetchSpace(spaceId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSpace = (newSpaceId: number, newSpaceName: string) => {
|
||||||
|
if (selectedSpaceId.value != newSpaceId) {
|
||||||
|
selectedSpaceId.value = newSpaceId;
|
||||||
|
selectedSpaceName.value = newSpaceName;
|
||||||
|
localStorage.setItem("spaceId", newSpaceId.toString());
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return {spaces, getSpaces, getSpace, selectedSpaceId, selectedSpaceName, setSpace};
|
||||||
|
|
||||||
|
})
|
||||||
70
src/stores/toolbar-store.ts
Normal file
70
src/stores/toolbar-store.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { computed, reactive, ref } from "vue";
|
||||||
|
import { useSpaceStore } from "@/stores/spaceStore";
|
||||||
|
|
||||||
|
export interface ToolbarButton {
|
||||||
|
id?: string;
|
||||||
|
text?: string;
|
||||||
|
icon?: string;
|
||||||
|
to?: string;
|
||||||
|
onClickId?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
class?: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ToolbarConfig =
|
||||||
|
| ToolbarButton[]
|
||||||
|
| ((ctx: { spaceStore: ReturnType<typeof useSpaceStore> }) => ToolbarButton[]);
|
||||||
|
|
||||||
|
export const useToolbarStore = defineStore("toolbar", () => {
|
||||||
|
const spaceStore = useSpaceStore();
|
||||||
|
|
||||||
|
// обработчики
|
||||||
|
const handlers: Record<string, () => void> = reactive({});
|
||||||
|
const registerHandler = (key: string, fn: () => void) => (handlers[key] = fn);
|
||||||
|
const unregisterHandler = (key: string) => delete handlers[key];
|
||||||
|
const invoke = (key?: string) => {
|
||||||
|
if (!key) return;
|
||||||
|
const fn = handlers[key];
|
||||||
|
if (typeof fn === "function") fn();
|
||||||
|
};
|
||||||
|
|
||||||
|
// текущие кнопки (сделал ref — теперь реактивно)
|
||||||
|
const _current = ref<ToolbarButton[]>([]);
|
||||||
|
const set = (items: ToolbarButton[]) => void (_current.value = items);
|
||||||
|
const clear = () => void (_current.value = []);
|
||||||
|
|
||||||
|
const setByConfig = (config?: ToolbarConfig) => {
|
||||||
|
if (!config) return clear();
|
||||||
|
_current.value =
|
||||||
|
typeof config === "function" ? config({ spaceStore }) : config;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaults = computed<ToolbarButton[]>(() => [
|
||||||
|
{
|
||||||
|
id: 'space',
|
||||||
|
text: spaceStore.selectedSpaceName ?? 'Select Space',
|
||||||
|
icon: '',
|
||||||
|
onClickId: 'openSpacePicker',
|
||||||
|
},
|
||||||
|
|
||||||
|
]);
|
||||||
|
|
||||||
|
const current = computed<ToolbarButton[]>(() => {
|
||||||
|
const map = new Map<string, ToolbarButton>();
|
||||||
|
for (const b of defaults.value) map.set(b.id ?? crypto.randomUUID(), b);
|
||||||
|
for (const b of _current.value) map.set(b.id ?? crypto.randomUUID(), b);
|
||||||
|
return Array.from(map.values());
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
current,
|
||||||
|
registerHandler,
|
||||||
|
unregisterHandler,
|
||||||
|
invoke,
|
||||||
|
set,
|
||||||
|
clear,
|
||||||
|
setByConfig,
|
||||||
|
};
|
||||||
|
});
|
||||||
81
src/stores/userStore.ts
Normal file
81
src/stores/userStore.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import {defineStore} from 'pinia';
|
||||||
|
import {ref} from 'vue';
|
||||||
|
import apiClient from "@/network/axiosSetup";
|
||||||
|
import {useRoute, useRouter} from "vue-router";
|
||||||
|
import {useToast} from "primevue/usetoast";
|
||||||
|
import {User} from "@/models/user";
|
||||||
|
|
||||||
|
export const useUserStore = defineStore('user', () => {
|
||||||
|
const toast = useToast();
|
||||||
|
const user = ref<User | null>(null);
|
||||||
|
const loadingUser = ref(true);
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
async function fetchUserProfile() {
|
||||||
|
// Убираем проверку на `loadingUser`, чтобы не блокировать запрос
|
||||||
|
if (!user.value) {
|
||||||
|
loadingUser.value = true;
|
||||||
|
try {
|
||||||
|
await apiClient.get('/auth/me')
|
||||||
|
.then((res: any) => user.value = res.data)
|
||||||
|
.catch((err: Error) => {
|
||||||
|
console.log(err)
|
||||||
|
throw err
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при загрузке данных пользователя:', error);
|
||||||
|
user.value = null;
|
||||||
|
} finally {
|
||||||
|
loadingUser.value = false; // Сбрасываем флаг `loadingUser` в `false` после завершения
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Основная функция для логина
|
||||||
|
async function login(username: string, password: string) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response;
|
||||||
|
|
||||||
|
response = await apiClient.post('/auth/login', {
|
||||||
|
username: username,
|
||||||
|
password: password,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const token = response.data.token;
|
||||||
|
console.log(token);
|
||||||
|
localStorage.setItem('token', token);
|
||||||
|
apiClient.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||||
|
toast.add({severity: 'success', summary: 'Вход выполнен', detail: 'Добро пожаловать!', life: 3000})
|
||||||
|
await fetchUserProfile();
|
||||||
|
await router.push(route.query['back'] ? route.query['back'].toString() : '/');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(error);
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Ошибка авторизации',
|
||||||
|
detail: error.response.data.message,
|
||||||
|
life: 3000
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function register(username: string, password: string, firstName: string) {
|
||||||
|
try {
|
||||||
|
let response = await apiClient.post('/auth/register', {
|
||||||
|
username: username,
|
||||||
|
password: password,
|
||||||
|
firstName: firstName
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
} catch (error) {
|
||||||
|
// console.error(error);
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return {user, loadingUser, fetchUserProfile, login, register};
|
||||||
|
});
|
||||||
8
tailwind.config.js
Normal file
8
tailwind.config.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
tailwindcss(),
|
||||||
|
],
|
||||||
|
})
|
||||||
45
tsconfig.json
Normal file
45
tsconfig.json
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"module": "esnext",
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"importHelpers": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"types": [
|
||||||
|
"webpack-env"
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
},
|
||||||
|
"lib": [
|
||||||
|
"esnext",
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"scripthost"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts",
|
||||||
|
"src/**/*.tsx",
|
||||||
|
"src/**/*.vue",
|
||||||
|
"tests/**/*.ts",
|
||||||
|
"tests/**/*.tsx"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
],
|
||||||
|
"esModuleInterop": true,
|
||||||
|
|
||||||
|
"typeRoots": [
|
||||||
|
"./node_modules/@types"
|
||||||
|
]
|
||||||
|
}
|
||||||
23
vite.config.js
Normal file
23
vite.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import {defineConfig, loadEnv} from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
|
export default defineConfig(async ({mode}) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), '')
|
||||||
|
console.log('🚀 Running Vite in mode:', mode)
|
||||||
|
|
||||||
|
return {
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
// пример: включаем devtools только в development/staging
|
||||||
|
env.VITE_ENABLE_DEVTOOLS === 'true'
|
||||||
|
? (await import('vite-plugin-vue-devtools')).default()
|
||||||
|
: undefined
|
||||||
|
].filter(Boolean),
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user