This commit is contained in:
xds
2025-10-27 15:12:00 +03:00
commit a198c703ef
47 changed files with 2141 additions and 0 deletions

3
.env Normal file
View File

@@ -0,0 +1,3 @@
# Базовые настройки для всех режимов
VITE_APP_NAME=Space
VITE_API_TIMEOUT=5000

2
.env.development Normal file
View File

@@ -0,0 +1,2 @@
VITE_API_URL=http://localhost:8086/api
VITE_ENABLE_DEVTOOLS=true

2
.env.production Normal file
View File

@@ -0,0 +1,2 @@
VITE_API_URL=https://vector.luminic.space/api
VITE_ENABLE_DEVTOOLS=false

36
.gitignore vendored Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

10
manifest.json Normal file
View 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
View 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
View File

@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

84
src/App.vue Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

87
src/assets/base.css Normal file
View 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
View 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
View 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
View 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;
}

View 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>

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
</script>
<template>
<div class="card">
Not implemented.
</div>
</template>
<style scoped>
</style>

View 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>

View 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>

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
</script>
<template>
<div class="card">
Not implemented
</div>
</template>
<style scoped>
</style>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
</script>
<template>
<div class="card">
Not implemented
</div>
</template>
<style scoped>
</style>

View 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>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
</script>
<template>
</template>
<style scoped>
</style>

14
src/env.d.ts vendored Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,10 @@
export enum CategoryType {
EXPENSE = "EXPENSE",
INCOME = "INCOME",
}
export const CategoryTypeName: Record<CategoryType, string> = {
[CategoryType.EXPENSE]: 'Расходы',
[CategoryType.INCOME]: 'Поступления',
}

View 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
View 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
View 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
View 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
View 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

View 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
}

View 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
}

View 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
View 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
View 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};
})

View 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
View 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
View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [
tailwindcss(),
],
})

45
tsconfig.json Normal file
View 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
View 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)),
},
},
}
})