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