Commit
Initial commit: SplitApp Vue 3 frontend
commit
d4d8a12
96 changed files with +18443 and −0
Jump to a changed file
- .dockerignore +21 −0
- .editorconfig +8 −0
- .env.example +6 −0
- .gitattributes +1 −0
- .gitignore +44 −0
- .gitlab-ci.yml +11 −0
- .oxlintrc.json +10 −0
- .prettierrc.json +6 −0
- .vscode/extensions.json +10 −0
- Dockerfile +36 −0
- LICENSE +21 −0
- README.md +291 −0
- YLEVAADE.md +719 −0
- docker-compose.yml +15 −0
- docs/Project_proposal_Rasmus_Jürgenson.pdf +0 −0
- docs/grouptravel.png +0 −0
- e2e/auth.spec.ts +51 −0
- e2e/trip-crud.spec.ts +61 −0
- env.d.ts +1 −0
- eslint.config.ts +32 −0
- index.html +17 −0
- package-lock.json +7673 −0
- package.json +58 −0
- playwright.config.ts +41 −0
- public/favicon.ico +0 −0
- src/App.vue +97 −0
- src/__tests__/integration/token-refresh.spec.ts +157 −0
- src/__tests__/unit/SplitMethodSelector.spec.ts +204 −0
- src/__tests__/unit/auth-store.spec.ts +81 −0
- src/__tests__/unit/formatCurrency.spec.ts +34 −0
- src/__tests__/unit/parseJwt.spec.ts +59 −0
- src/__tests__/vitest.setup.ts +22 −0
- src/assets/splitapp-design.css +1566 −0
- src/components/LangSwitcher.vue +61 −0
- src/components/SplitMethodSelector.vue +386 −0
- src/components/ToastContainer.vue +33 −0
- src/composables/useToast.ts +39 −0
- src/directives/vAnimate.ts +23 −0
- src/i18n/index.ts +47 −0
- src/locales/en.json +390 −0
- src/locales/et.json +390 −0
- src/main.ts +20 −0
- src/router/index.ts +138 −0
- src/services/AccountService.ts +86 −0
- src/services/BudgetCategoryService.ts +58 −0
- src/services/CurrencyService.ts +31 −0
- src/services/ExpenseService.ts +67 −0
- src/services/InvitationService.ts +67 −0
- src/services/PollService.ts +76 −0
- src/services/SettlementService.ts +58 −0
- src/services/TripService.ts +94 −0
- src/services/WishlistService.ts +77 −0
- src/services/httpClient.ts +57 −0
- src/stores/auth.ts +33 −0
- src/stores/counter.ts +12 −0
- src/stores/lang.ts +42 −0
- src/types/IBudgetCategory.ts +17 −0
- src/types/ICurrency.ts +6 −0
- src/types/IExpense.ts +43 −0
- src/types/IInvitation.ts +13 −0
- src/types/IJwtResponse.ts +6 −0
- src/types/IPoll.ts +26 −0
- src/types/IResultObject.ts +4 −0
- src/types/ISettlement.ts +31 −0
- src/types/ITrip.ts +47 −0
- src/types/IWishlist.ts +28 −0
- src/utils/formatCurrency.ts +17 −0
- src/utils/parseJwt.ts +21 −0
- src/views/HomeView.vue +137 −0
- src/views/LoginView.vue +118 −0
- src/views/RegisterView.vue +152 −0
- src/views/budget-categories/CreateView.vue +118 −0
- src/views/budget-categories/EditView.vue +170 −0
- src/views/budget-categories/IndexView.vue +244 −0
- src/views/expenses/CreateView.vue +188 −0
- src/views/expenses/EditView.vue +197 −0
- src/views/expenses/IndexView.vue +168 −0
- src/views/invitations/AcceptView.vue +163 −0
- src/views/members/MembersView.vue +212 −0
- src/views/polls/CreateView.vue +171 −0
- src/views/polls/DetailView.vue +194 −0
- src/views/polls/IndexView.vue +117 −0
- src/views/settlements/SettlementView.vue +510 −0
- src/views/trips/CreateView.vue +214 −0
- src/views/trips/DetailView.vue +404 −0
- src/views/trips/EditView.vue +180 −0
- src/views/trips/IndexView.vue +154 −0
- src/views/wishlist/CreateView.vue +146 −0
- src/views/wishlist/EditView.vue +192 −0
- src/views/wishlist/IndexView.vue +286 −0
- tsconfig.app.json +18 −0
- tsconfig.json +14 −0
- tsconfig.node.json +27 −0
- tsconfig.vitest.json +19 −0
- vite.config.ts +18 −0
- vitest.config.ts +15 −0
added .dockerignore +21 −0
| @@ -0,0 +1,21 @@ | ||
| 1 | +node_modules | |
| 2 | +dist | |
| 3 | +.git | |
| 4 | +.gitignore | |
| 5 | +.gitattributes | |
| 6 | +.vscode | |
| 7 | +.claude | |
| 8 | +.env | |
| 9 | +.env.* | |
| 10 | +!.env.example | |
| 11 | +npm-debug.log* | |
| 12 | +*.log | |
| 13 | +coverage | |
| 14 | +.DS_Store | |
| 15 | +Dockerfile | |
| 16 | +docker-compose.yml | |
| 17 | +.dockerignore | |
| 18 | +README.md | |
| 19 | +docs | |
| 20 | +openspec | |
| 21 | +.planning |
added .editorconfig +8 −0
| @@ -0,0 +1,8 @@ | ||
| 1 | +[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}] | |
| 2 | +charset = utf-8 | |
| 3 | +indent_size = 2 | |
| 4 | +indent_style = space | |
| 5 | +insert_final_newline = true | |
| 6 | +trim_trailing_whitespace = true | |
| 7 | +end_of_line = lf | |
| 8 | +max_line_length = 100 |
added .env.example +6 −0
| @@ -0,0 +1,6 @@ | ||
| 1 | +# Copy to .env — the Vue app reads this at build/dev time. | |
| 2 | +# Point it at your running backend's versioned API base (note the trailing slash). | |
| 3 | +# Local backend (docker compose) examples: | |
| 4 | +# Clean/Onion backend -> http://localhost:84/api/v1/ | |
| 5 | +# Modular Monolith -> http://localhost:90/api/v1/ | |
| 6 | +VITE_API_BASE_URL=http://localhost:84/api/v1/ |
added .gitattributes +1 −0
| @@ -0,0 +1 @@ | ||
| 1 | +* text=auto eol=lf |
added .gitignore +44 −0
| @@ -0,0 +1,44 @@ | ||
| 1 | +# Logs | |
| 2 | +logs | |
| 3 | +*.log | |
| 4 | +npm-debug.log* | |
| 5 | +yarn-debug.log* | |
| 6 | +yarn-error.log* | |
| 7 | +pnpm-debug.log* | |
| 8 | +lerna-debug.log* | |
| 9 | + | |
| 10 | +node_modules | |
| 11 | +.DS_Store | |
| 12 | +dist | |
| 13 | +dist-ssr | |
| 14 | +coverage | |
| 15 | +*.local | |
| 16 | + | |
| 17 | +# Env files | |
| 18 | +.env | |
| 19 | +.env.* | |
| 20 | +!.env.example | |
| 21 | + | |
| 22 | +# Editor directories and files | |
| 23 | +.vscode/* | |
| 24 | +!.vscode/extensions.json | |
| 25 | +.idea | |
| 26 | +*.suo | |
| 27 | +*.ntvs* | |
| 28 | +*.njsproj | |
| 29 | +*.sln | |
| 30 | +*.sw? | |
| 31 | + | |
| 32 | +*.tsbuildinfo | |
| 33 | + | |
| 34 | +.eslintcache | |
| 35 | + | |
| 36 | +# Cypress | |
| 37 | +/cypress/videos/ | |
| 38 | +/cypress/screenshots/ | |
| 39 | + | |
| 40 | +# Vitest | |
| 41 | +__screenshots__/ | |
| 42 | + | |
| 43 | +# Vite | |
| 44 | +*.timestamp-*-*.mjs |
added .gitlab-ci.yml +11 −0
| @@ -0,0 +1,11 @@ | ||
| 1 | +stages: | |
| 2 | + - deploy | |
| 3 | + | |
| 4 | +deploy: | |
| 5 | + stage: deploy | |
| 6 | + only: | |
| 7 | + - main | |
| 8 | + tags: | |
| 9 | + - shared | |
| 10 | + script: | |
| 11 | + - docker compose -p rasmju-js-a7 up --build --remove-orphans --detach |
added .oxlintrc.json +10 −0
| @@ -0,0 +1,10 @@ | ||
| 1 | +{ | |
| 2 | + "$schema": "./node_modules/oxlint/configuration_schema.json", | |
| 3 | + "plugins": ["eslint", "typescript", "unicorn", "oxc", "vue", "vitest"], | |
| 4 | + "env": { | |
| 5 | + "browser": true | |
| 6 | + }, | |
| 7 | + "categories": { | |
| 8 | + "correctness": "error" | |
| 9 | + } | |
| 10 | +} |
added .prettierrc.json +6 −0
| @@ -0,0 +1,6 @@ | ||
| 1 | +{ | |
| 2 | + "$schema": "https://json.schemastore.org/prettierrc", | |
| 3 | + "semi": false, | |
| 4 | + "singleQuote": true, | |
| 5 | + "printWidth": 100 | |
| 6 | +} |
added .vscode/extensions.json +10 −0
| @@ -0,0 +1,10 @@ | ||
| 1 | +{ | |
| 2 | + "recommendations": [ | |
| 3 | + "Vue.volar", | |
| 4 | + "vitest.explorer", | |
| 5 | + "dbaeumer.vscode-eslint", | |
| 6 | + "EditorConfig.EditorConfig", | |
| 7 | + "oxc.oxc-vscode", | |
| 8 | + "esbenp.prettier-vscode" | |
| 9 | + ] | |
| 10 | +} |
added Dockerfile +36 −0
| @@ -0,0 +1,36 @@ | ||
| 1 | +# syntax=docker/dockerfile:1 | |
| 2 | + | |
| 3 | +# --- Build stage --- | |
| 4 | +FROM node:22-alpine AS build | |
| 5 | +WORKDIR /app | |
| 6 | + | |
| 7 | +# Vite bakes VITE_* env vars into the bundle at build time. | |
| 8 | +# Pass the backend URL as a build arg so it can be overridden per environment | |
| 9 | +# (local docker compose, CI, VPS deploy, ...) without rebaking the Dockerfile. | |
| 10 | +ARG VITE_API_BASE_URL=http://localhost:90/api/v1/ | |
| 11 | +ENV VITE_API_BASE_URL=${VITE_API_BASE_URL} | |
| 12 | + | |
| 13 | +COPY package*.json ./ | |
| 14 | +RUN npm ci | |
| 15 | + | |
| 16 | +COPY . . | |
| 17 | +RUN npm run build | |
| 18 | + | |
| 19 | +# --- Production stage --- | |
| 20 | +FROM nginx:alpine AS production | |
| 21 | +COPY --from=build /app/dist /usr/share/nginx/html | |
| 22 | +COPY <<'EOF' /etc/nginx/conf.d/default.conf | |
| 23 | +server { | |
| 24 | + listen 80; | |
| 25 | + server_name _; | |
| 26 | + root /usr/share/nginx/html; | |
| 27 | + index index.html; | |
| 28 | + | |
| 29 | + location / { | |
| 30 | + try_files $uri $uri/ /index.html; | |
| 31 | + } | |
| 32 | +} | |
| 33 | +EOF | |
| 34 | + | |
| 35 | +EXPOSE 80 | |
| 36 | +CMD ["nginx", "-g", "daemon off;"] |
added LICENSE +21 −0
| @@ -0,0 +1,21 @@ | ||
| 1 | +MIT License | |
| 2 | + | |
| 3 | +Copyright (c) 2026 Rasmus Jürgenson | |
| 4 | + | |
| 5 | +Permission is hereby granted, free of charge, to any person obtaining a copy | |
| 6 | +of this software and associated documentation files (the "Software"), to deal | |
| 7 | +in the Software without restriction, including without limitation the rights | |
| 8 | +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
| 9 | +copies of the Software, and to permit persons to whom the Software is | |
| 10 | +furnished to do so, subject to the following conditions: | |
| 11 | + | |
| 12 | +The above copyright notice and this permission notice shall be included in all | |
| 13 | +copies or substantial portions of the Software. | |
| 14 | + | |
| 15 | +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
| 16 | +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
| 17 | +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
| 18 | +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
| 19 | +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
| 20 | +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
| 21 | +SOFTWARE. |
added README.md +291 −0
| @@ -0,0 +1,291 @@ | ||
| 1 | +# SplitApp — Vue 3 frontend | |
| 2 | +https://travel.rasmusj.com/ | |
| 3 | + | |
| 4 | +Frontend (separate client app) for the **SplitApp** travel planning and shared expense management system. Built with **Vue 3 + TypeScript + Vite**, talks to the ASP.NET Core backend over REST with JWT + refresh token authentication. | |
| 5 | + | |
| 6 | +This repo satisfies the "Separate client app" requirements of the TalTech Personal Project assignment — see [Assignment compliance](#assignment-compliance) below. | |
| 7 | + | |
| 8 | +## Features | |
| 9 | + | |
| 10 | +- **Authentication** — register, login, logout, JWT + refresh token flow with auto-refresh on 401 | |
| 11 | +- **Trips** — create, view, edit, delete trips with destination, dates, currency (`views/trips`) | |
| 12 | +- **Members & invitations** — invite travel companions by token, accept/decline/revoke (`views/members`, `views/invitations`) | |
| 13 | +- **Expenses** — record shared costs with four split methods (see below), select any trip member as the payer (not just the logged-in user), and see totals summed in the trip's default currency even when individual expenses were entered in other currencies (`views/expenses`, `components/SplitMethodSelector.vue`) | |
| 14 | +- **Budget categories** — categorize expenses, track spent vs planned (`views/budget-categories`) | |
| 15 | +- **Wishlist** — trip activities and destinations with group voting and completion tracking (`views/wishlist`) | |
| 16 | +- **Polls** — group decision-making with single/multi vote and close-poll workflow (`views/polls`) | |
| 17 | +- **Settlements** — real-time balance tracking with suggested payments preview; organizer finalizes the trip (`Active → Finalizing`) to lock in the settlement plan, then participants complete the mark-paid / confirm-paid workflow — trip auto-advances to `Settled` once every recipient has confirmed. The **Mark Paid** button appears only for the payer of a row and **Confirm** only for the payee; other viewers see an "Awaiting Confirmation" / "Pending" badge. **Reopen** is available to the organizer while the plan is not yet `Completed` (`views/settlements`) | |
| 18 | +- **Multi-currency support** (`services/CurrencyService.ts`) | |
| 19 | + | |
| 20 | +### Expense split methods | |
| 21 | + | |
| 22 | +The `SplitMethodSelector` component supports four validated split strategies, matching the backend's `ESplitMethod` enum: | |
| 23 | + | |
| 24 | +| Method | Behavior | | |
| 25 | +| -------------- | ------------------------------------------------------------------ | | |
| 26 | +| `EqualAll` | Total divided equally among every trip member | | |
| 27 | +| `EqualSubset` | User picks a subset of members; total divided equally among picked | | |
| 28 | +| `ExactAmounts` | Per-member fixed amount input; must sum exactly to total | | |
| 29 | +| `Percentages` | Per-member percentage input; must sum to exactly 100% | | |
| 30 | + | |
| 31 | +Each method is validated live and blocks the save button when invalid. | |
| 32 | + | |
| 33 | +## Tech stack | |
| 34 | + | |
| 35 | +- **Vue 3.5** (Composition API) + **TypeScript 6** | |
| 36 | +- **Vite 8** (dev server + production build) | |
| 37 | +- **Vue Router 5** — nested routes with an auth navigation guard | |
| 38 | +- **Pinia 3** — auth store (JWT, refresh token, user name) with localStorage sync | |
| 39 | +- **Axios** — shared `httpClient.ts` with request/response interceptors | |
| 40 | +- **Bootstrap 5** + custom coral/teal CSS variable theme | |
| 41 | +- **Vitest 4** + `@vue/test-utils` + **MSW** — unit & integration tests | |
| 42 | +- **Playwright** — browser-based end-to-end tests | |
| 43 | +- **ESLint** (flat config) + **Oxlint** + **Prettier** | |
| 44 | + | |
| 45 | +## Project structure | |
| 46 | + | |
| 47 | +``` | |
| 48 | +src/ | |
| 49 | +├── components/ # SplitMethodSelector, ToastContainer | |
| 50 | +├── composables/ # useToast — global toast system | |
| 51 | +├── directives/ # v-animate — IntersectionObserver scroll animations | |
| 52 | +├── router/ # vue-router config + auth guard | |
| 53 | +├── services/ # 9 axios-based API clients, one per resource | |
| 54 | +│ ├── httpClient.ts # shared axios instance with JWT interceptors | |
| 55 | +│ ├── AccountService.ts | |
| 56 | +│ ├── TripService.ts | |
| 57 | +│ ├── ExpenseService.ts | |
| 58 | +│ ├── BudgetCategoryService.ts | |
| 59 | +│ ├── CurrencyService.ts | |
| 60 | +│ ├── PollService.ts | |
| 61 | +│ ├── WishlistService.ts | |
| 62 | +│ ├── SettlementService.ts | |
| 63 | +│ └── InvitationService.ts | |
| 64 | +├── stores/ | |
| 65 | +│ └── auth.ts # jwt, refreshToken, userName, isAuthenticated | |
| 66 | +├── types/ # TypeScript contracts mirroring backend DTOs | |
| 67 | +├── utils/ # formatCurrency, parseJwt | |
| 68 | +├── views/ # Pages (HomeView, LoginView, RegisterView, trips/, expenses/, ...) | |
| 69 | +├── App.vue # Root layout (navbar + router-view + toasts) | |
| 70 | +└── main.ts | |
| 71 | +e2e/ # Playwright tests | |
| 72 | +src/__tests__/ | |
| 73 | +├── unit/ # Vitest unit tests | |
| 74 | +├── integration/ # Vitest + MSW integration tests | |
| 75 | +└── vitest.setup.ts | |
| 76 | +``` | |
| 77 | + | |
| 78 | +## Requirements | |
| 79 | + | |
| 80 | +- **Node.js** `^20.19.0 || >=22.12.0` | |
| 81 | +- **npm** | |
| 82 | +- A running instance of the SplitApp backend (`splitapp-backend-clean-onion`) on the URL configured in `.env` | |
| 83 | + | |
| 84 | +## Setup | |
| 85 | + | |
| 86 | +1. Install dependencies: | |
| 87 | + | |
| 88 | + ```sh | |
| 89 | + npm install | |
| 90 | + ``` | |
| 91 | + | |
| 92 | +2. Create a `.env` file at the project root: | |
| 93 | + | |
| 94 | + ```env | |
| 95 | + VITE_API_BASE_URL=http://localhost:90/api/v1/ | |
| 96 | + ``` | |
| 97 | + | |
| 98 | + Use `http://localhost:5086/api/v1/` if you run the backend locally via `dotnet run` instead of Docker. | |
| 99 | + | |
| 100 | +3. Start the backend (separate repo): | |
| 101 | + | |
| 102 | + ```sh | |
| 103 | + cd ../splitapp-backend-clean-onion | |
| 104 | + docker compose up -d | |
| 105 | + ``` | |
| 106 | + | |
| 107 | +4. Run the frontend: | |
| 108 | + ```sh | |
| 109 | + npm run dev | |
| 110 | + ``` | |
| 111 | + → http://localhost:5173 | |
| 112 | + | |
| 113 | +## Scripts | |
| 114 | + | |
| 115 | +```sh | |
| 116 | +npm run dev # Vite dev server (http://localhost:5173) | |
| 117 | +npm run build # Type-check + production build | |
| 118 | +npm run preview # Preview the production bundle | |
| 119 | +npm run test:unit # Vitest unit + integration tests (watch mode) | |
| 120 | +npm run test:unit -- --run # One-off CI-style run | |
| 121 | +npm run test:e2e # Playwright end-to-end tests (needs backend running) | |
| 122 | +npm run test:e2e:ui # Playwright interactive UI mode | |
| 123 | +npm run lint # Oxlint + ESLint with --fix | |
| 124 | +npm run type-check # vue-tsc strict type check | |
| 125 | +npm run format # Prettier | |
| 126 | +``` | |
| 127 | + | |
| 128 | +## Testing | |
| 129 | + | |
| 130 | +The project has three testing layers — **39 tests in 7 files**, all green against the phase 3 modular monolith backend. | |
| 131 | + | |
| 132 | +| Layer | Files | Tests | Backend? | Runtime | | |
| 133 | +|---|---|---|---|---| | |
| 134 | +| Unit | 4 | 31 | No (pure functions / isolated component) | ~0.4s | | |
| 135 | +| Integration | 1 | 4 | No (MSW mocks the network at the HTTP layer) | ~0.5s | | |
| 136 | +| E2E | 2 | 4 | **Yes** (real Chromium, real backend) | ~11s | | |
| 137 | + | |
| 138 | +### Unit (`npm run test:unit`) | |
| 139 | + | |
| 140 | +Vitest + jsdom. Each file targets one tightly-scoped piece of the codebase: | |
| 141 | + | |
| 142 | +| File | What it covers | Why it matters | | |
| 143 | +|---|---|---| | |
| 144 | +| `formatCurrency.spec.ts` | sign / symbol / decimals / thousands separators | every page renders amounts — a regression here visually breaks the whole app | | |
| 145 | +| `parseJwt.spec.ts` | base64url decoding (`+/` → `-_`), ASP.NET `nameidentifier` claim with `sub` fallback | wrong user-id parsing silently mis-attributes expenses to the wrong user | | |
| 146 | +| `auth-store.spec.ts` | Pinia store: `localStorage` hydration, `isAuthenticated` reactivity, `logout()`, watcher-based sync | broken sync drifts store and `localStorage` apart → user sees stale identity after reload | | |
| 147 | +| `SplitMethodSelector.spec.ts` | all four split methods, live validation, edit-mode `existingSplits` pre-load | most complex business logic on the front — invalid splits would let the user save a malformed expense | | |
| 148 | + | |
| 149 | +### Integration (`npm run test:unit`, in the same suite) | |
| 150 | + | |
| 151 | +| File | What it covers | | |
| 152 | +|---|---| | |
| 153 | +| `token-refresh.spec.ts` | the **most security-sensitive glue** in the app — `httpClient`'s 401 → refresh → retry pipeline, end to end. MSW intercepts at the network layer so axios behaves exactly as in production. Three scenarios: (1) successful refresh + replay, (2) refresh fails → logout + redirect to `/login`, (3) no refresh token present → straight to logout. | | |
| 154 | + | |
| 155 | +### End-to-end (`npm run test:e2e`) | |
| 156 | + | |
| 157 | +Playwright drives a real Chromium browser. The `webServer` config auto-starts `npm run dev` for you, so you only need the backend reachable. | |
| 158 | + | |
| 159 | +| File | What it covers | | |
| 160 | +|---|---| | |
| 161 | +| `auth.spec.ts` | login as seed user `alice@taltech.ee`, logout, wrong-password stays on `/login`, protected routes redirect anonymous users to `/login` | | |
| 162 | +| `trip-crud.spec.ts` | **positive happy flow** — login → create a uniquely-named trip → see it in the list → open Edit → rename → verify the rename persists. Delete is intentionally skipped because backend `DeleteBehavior.Restrict` blocks deleting a trip that has any participant (even the auto-added Organizer) — that's a backend issue, not a frontend one. | | |
| 163 | + | |
| 164 | +**How to run locally:** | |
| 165 | + | |
| 166 | +E2E hits the backend via whatever URL is in `.env`: | |
| 167 | + | |
| 168 | +```sh | |
| 169 | +# Option A — against deployed phase 3 backend (no local docker needed) | |
| 170 | +# .env already points at https://travel.rasmusj.com/api/v1/ | |
| 171 | +npm run test:e2e | |
| 172 | + | |
| 173 | +# Option B — against a local backend | |
| 174 | +cd ../splitapp-backend-clean-onion && docker compose up -d # backend on :90 | |
| 175 | +# Override .env locally with VITE_API_BASE_URL=http://localhost:90/api/v1/ | |
| 176 | +cd ../splitapp-frontend-vue | |
| 177 | +npm run test:e2e # headless | |
| 178 | +npm run test:e2e:ui # interactive UI mode for debugging | |
| 179 | +``` | |
| 180 | + | |
| 181 | +> **Note:** the `trip-crud` test creates a real trip in whichever backend you point at. Against prod, that's a real row in the deployed database (it does not clean up). | |
| 182 | + | |
| 183 | +## Docker | |
| 184 | + | |
| 185 | +The project ships with a multi-stage `Dockerfile` (Node build → Nginx serve) and a `docker-compose.yml` with two profiles. | |
| 186 | + | |
| 187 | +### Production (Nginx) | |
| 188 | + | |
| 189 | +```sh | |
| 190 | +docker compose up --build | |
| 191 | +``` | |
| 192 | + | |
| 193 | +→ http://localhost:91 | |
| 194 | + | |
| 195 | +The backend URL is injected as a **build arg** (`VITE_API_BASE_URL`) so the same image can target different environments: | |
| 196 | + | |
| 197 | +```sh | |
| 198 | +# Override at build time for a real deploy | |
| 199 | +VITE_API_BASE_URL=https://api.mydomain.com/api/v1/ docker compose up --build | |
| 200 | +``` | |
| 201 | + | |
| 202 | +Nginx is configured with an SPA fallback (`try_files $uri $uri/ /index.html`) so Vue Router's history mode works on direct URLs. | |
| 203 | + | |
| 204 | +### Port allocation | |
| 205 | + | |
| 206 | +| Service | Host port | Notes | | |
| 207 | +| -------------------------- | ----------- | --------------------- | | |
| 208 | +| Frontend (prod, Nginx) | **91** | separate from backend | | |
| 209 | +| Backend API (Docker) | 90 | `rasmju-csweb-phase3` | | |
| 210 | +| Backend API (`dotnet run`) | 5086 / 7040 | http / https | | |
| 211 | +| PostgreSQL | 5432 | | | |
| 212 | + | |
| 213 | +## CI/CD | |
| 214 | + | |
| 215 | +Configured via [.gitlab-ci.yml](.gitlab-ci.yml): | |
| 216 | + | |
| 217 | +- **deploy** stage — on merges to `main`, runs `docker compose -p rasmju-js-a7 up --build --remove-orphans --detach` on a shared runner (deploys the Nginx production image) | |
| 218 | +- **Tests run locally** before pushing — there is intentionally no test stage in the pipeline. Run `npm run test:unit -- --run` and `npm run test:e2e` on your machine before merging. | |
| 219 | + | |
| 220 | +The frontend is hosted on a separate URL from the backend and relies on the backend's permissive CORS policy (`AllowAnyOrigin` in `Program.cs`). | |
| 221 | + | |
| 222 | +## Architecture notes | |
| 223 | + | |
| 224 | +### Authentication flow | |
| 225 | + | |
| 226 | +1. `LoginView` → `AccountService.loginAsync` → backend returns `{ jwt, refreshToken, firstName, lastName }` | |
| 227 | +2. Auth store saves them; `watch`ers mirror values to `localStorage` | |
| 228 | +3. Every outgoing request picks up `Authorization: Bearer <jwt>` from the request interceptor | |
| 229 | +4. On `401`, the response interceptor: | |
| 230 | + - calls `AccountService.refreshTokenAsync(jwt, refreshToken)` | |
| 231 | + - on success: updates the store, replays the original request transparently | |
| 232 | + - on failure: calls `logoutAsync`, clears the store, navigates to `/login` | |
| 233 | +5. `logout()` tears down store state and removes localStorage entries | |
| 234 | + | |
| 235 | +### Nested routing | |
| 236 | + | |
| 237 | +`/trips/:tripId` acts as a parent route whose `DetailView` provides `isOrganizer`, `currentUserId`, `tripStatus`, and `tripCurrencySymbol` to child routes via Vue's `provide/inject`. Children use these to: | |
| 238 | + | |
| 239 | +- Hide organizer-only actions (budget category CRUD, finalize/reopen trip) | |
| 240 | +- Hide creator-only actions (edit/delete own wishlist items and expenses); the edit/delete slot in the expense list reserves layout space even when hidden so amounts stay aligned in a single column | |
| 241 | +- Lock expense CRUD whenever trip status is not `Active` (i.e. `Finalizing`, `Settled`, or `Archived`) | |
| 242 | +- Show suggested payment previews while trip is `Active`, and the real settlement plan once trip enters `Finalizing` or `Settled`; the header badge distinguishes the two states (warning hourglass vs. lock icon) | |
| 243 | +- Hide payment action buttons from users who cannot act on a row — Mark Paid only for the payer, Confirm only for the payee (matching the BLL guards so the UI never surfaces a click that would silently 403) | |
| 244 | + | |
| 245 | +### Currency conversion | |
| 246 | + | |
| 247 | +Expenses can be recorded in any currency (EUR, USD, GBP, SEK, NOK). To keep conversion rules in a single place, the frontend does **not** implement any currency math — it relies on the backend. The `ExpenseDto` returned by `GET /api/v1/expenses/trip/{tripId}` includes an `amountInTripCurrency` field that the backend pre-computes (`SplitApp.Modules.Expenses.Application.CurrencyConverter` in the modular monolith). List totals (`expenses/IndexView.vue`, trip dashboard) sum this field with `e.amount` as a fallback. Per-expense rows still display the original amount + currency symbol so the user sees what was actually entered. | |
| 248 | + | |
| 249 | +### Error handling | |
| 250 | + | |
| 251 | +Services return an `IResultObject<T>` wrapper (`{ data } | { errors: string[] }`) instead of throwing. Views inspect `.errors` and show toast messages via the `useToast` composable. | |
| 252 | + | |
| 253 | +## Assignment compliance | |
| 254 | + | |
| 255 | +This repository targets the **"Separate client app"** portion of the TalTech Personal Project Assignment 3. | |
| 256 | + | |
| 257 | +| Requirement | Status | Where | | |
| 258 | +| ----------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------ | | |
| 259 | +| Written in a chosen technology (react/angular/vue/blazor/…) | ✅ | Vue 3 + TypeScript | | |
| 260 | +| Uses own backend REST API | ✅ | `VITE_API_BASE_URL=http://localhost:90/api/v1/` | | |
| 261 | +| JWT + refresh token authentication | ✅ | `services/httpClient.ts`, `services/AccountService.ts`, `stores/auth.ts` | | |
| 262 | +| Login / logout | ✅ | `LoginView.vue`, `RegisterView.vue`, `App.vue` | | |
| 263 | +| CRUD on ≥3 entities | ✅ (5) | Trips, Expenses, BudgetCategories, Wishlist, Polls | | |
| 264 | +| CI/CD deploy — client hosted on a separate URL from backend | ✅ | `.gitlab-ci.yml`, separate Docker container on port 91 | | |
| 265 | +| CORS handling | ✅ | Backend `CorsAllowAll` policy; frontend on separate origin | | |
| 266 | +| Bonus: full unit + integration + e2e test coverage | ✅ | Vitest + MSW + Playwright — 39 tests, all green against phase 3 backend | | |
| 267 | + | |
| 268 | +### Seed users | |
| 269 | + | |
| 270 | +The backend seeds five demo users on first startup (if `DataInitialization:SeedIdentity=true`). Password for all of them is `Kala.12345`, which is in the source on purpose: this is a demo and the data is invented. | |
| 271 | + | |
| 272 | +The administrator is separate. It is seeded only when `SEED_ADMIN_PASSWORD` is set on the backend, and there is no default, so without that variable there is no admin account at all. | |
| 273 | + | |
| 274 | +| Email | Role | | |
| 275 | +| -------------------- | ----- | | |
| 276 | +| `user@taltech.ee` | user | | |
| 277 | +| `alice@taltech.ee` | user | | |
| 278 | +| `bob@taltech.ee` | user | | |
| 279 | +| `charlie@taltech.ee` | user | | |
| 280 | +| `diana@taltech.ee` | user | | |
| 281 | + | |
| 282 | +Four example trips are also seeded (Barcelona Weekend, London Business Trip, Summer Cabin Getaway, NYC Adventure), each with expenses, polls and wishlist items. | |
| 283 | + | |
| 284 | +## Related documentation | |
| 285 | + | |
| 286 | +- [`YLEVAADE.md`](YLEVAADE.md) — comprehensive system overview in Estonian, covering both frontend and backend | |
| 287 | +- `splitapp-backend-clean-onion` — backend repository (ASP.NET Core 10 + PostgreSQL) | |
| 288 | + | |
| 289 | +## Recommended IDE | |
| 290 | + | |
| 291 | +[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (disable Vetur). |
added YLEVAADE.md +719 −0
| @@ -0,0 +1,719 @@ | ||
| 1 | +# Projekti ülevaade — SplitApp (Reisikulude jagaja) | |
| 2 | + | |
| 3 | +See dokument kirjeldab tervet süsteemi: **frontendi** (Vue 3) ja **backendi** (ASP.NET Core 10) ning seda, kuidas need omavahel suhtlevad. | |
| 4 | + | |
| 5 | +--- | |
| 6 | + | |
| 7 | +## 1. Mis on SplitApp? | |
| 8 | + | |
| 9 | +SplitApp on reisiplaneerimise ja ühiste kulude jagamise rakendus. Kasutajad saavad: | |
| 10 | + | |
| 11 | +- luua reise ja kutsuda kaaslasi | |
| 12 | +- sisestada ühiseid kulusid ja jagada neid nelja erineva meetodi järgi | |
| 13 | +- hallata eelarvekategooriaid ja jälgida kulutusi | |
| 14 | +- pidada soovinimekirja tegevustest/kohtadest koos hääletusega | |
| 15 | +- teha grupiotsuseid küsitluste kaudu | |
| 16 | +- lõpuks arvestada, kes kellele võlgu on (arveldused) | |
| 17 | + | |
| 18 | +Süsteem koosneb kahest eraldiseisvast projektist, mis suhtlevad REST API kaudu: | |
| 19 | + | |
| 20 | +| Projekt | Kaust | Tehnoloogia | | |
| 21 | +|---|---|---| | |
| 22 | +| **Frontend** | `rasmju-js-a7` | Vue 3 + TypeScript + Vite | | |
| 23 | +| **Backend** | `rasmju-csweb-phase3` | ASP.NET Core 10 + PostgreSQL | | |
| 24 | + | |
| 25 | +--- | |
| 26 | + | |
| 27 | +## 2. Frontend — `rasmju-js-a7` | |
| 28 | + | |
| 29 | +### 2.1 Tehnoloogiad | |
| 30 | + | |
| 31 | +- **Vue 3.5** (Composition API) + **TypeScript 6** | |
| 32 | +- **Vite 8** — build tool ja arendusserver | |
| 33 | +- **Vue Router 5** — lehekülgede ruutimine | |
| 34 | +- **Pinia 3** — olekuhaldus | |
| 35 | +- **Axios 1.14** — HTTP klient | |
| 36 | +- **Bootstrap 5.3** — stiilide raamistik | |
| 37 | +- **Vitest 4** — unit testid | |
| 38 | +- **ESLint + Oxlint + Prettier** — koodi kvaliteet | |
| 39 | + | |
| 40 | +### 2.2 Projekti struktuur | |
| 41 | + | |
| 42 | +``` | |
| 43 | +src/ | |
| 44 | +├── components/ # Jagatud komponendid | |
| 45 | +│ ├── SplitMethodSelector.vue # Kulu jagamise UI (4 meetodit) | |
| 46 | +│ └── ToastContainer.vue # Teavituste kuvamine | |
| 47 | +├── composables/ | |
| 48 | +│ └── useToast.ts # Globaalne teavituste süsteem | |
| 49 | +├── directives/ | |
| 50 | +│ └── vAnimate.ts # Scroll-animatsiooni direktiiv | |
| 51 | +├── router/ | |
| 52 | +│ └── index.ts # Kõik ruudid + autentimise valvur | |
| 53 | +├── services/ # API kliendid | |
| 54 | +│ ├── httpClient.ts # Axiose seadistus + interceptorid | |
| 55 | +│ ├── AccountService.ts # Login, register, refresh, logout | |
| 56 | +│ ├── TripService.ts # Reisid | |
| 57 | +│ ├── ExpenseService.ts # Kulud | |
| 58 | +│ ├── BudgetCategoryService.ts # Eelarvekategooriad | |
| 59 | +│ ├── CurrencyService.ts # Valuutad | |
| 60 | +│ ├── PollService.ts # Küsitlused | |
| 61 | +│ ├── WishlistService.ts # Soovinimekiri | |
| 62 | +│ ├── SettlementService.ts # Arveldused | |
| 63 | +│ └── InvitationService.ts # Kutsed | |
| 64 | +├── stores/ | |
| 65 | +│ └── auth.ts # JWT + refreshToken + userName | |
| 66 | +├── types/ # TypeScript liidesed (DTO vastavalt API-le) | |
| 67 | +├── utils/ | |
| 68 | +│ ├── formatCurrency.ts # Valuuta vormindus | |
| 69 | +│ └── parseJwt.ts # JWT dekodeerimine | |
| 70 | +├── views/ # Leheküljed | |
| 71 | +│ ├── HomeView.vue | |
| 72 | +│ ├── LoginView.vue | |
| 73 | +│ ├── RegisterView.vue | |
| 74 | +│ ├── trips/ # IndexView, DetailView, CreateView, EditView | |
| 75 | +│ ├── expenses/ # Index, Create, Edit | |
| 76 | +│ ├── budget-categories/ # Index, Create, Edit | |
| 77 | +│ ├── wishlist/ # Index, Create, Edit | |
| 78 | +│ ├── polls/ # Index, Create, Detail | |
| 79 | +│ ├── invitations/ # AcceptView | |
| 80 | +│ ├── members/ # MembersView | |
| 81 | +│ └── settlements/ # SettlementView | |
| 82 | +├── App.vue # Juurkomponent (navbar + router-view) | |
| 83 | +└── main.ts # Käivituspunkt | |
| 84 | +``` | |
| 85 | + | |
| 86 | +### 2.3 Ruutimine | |
| 87 | + | |
| 88 | +Kogu rakendus kasutab **pesastatud ruuteid** — reis on parent-route, mille all asuvad kõik reisispetsiifilised vaated. `trips/DetailView.vue` pakub child-route'idele `provide()` kaudu konteksti (näiteks `isOrganizer` ja `tripCurrencySymbol`). | |
| 89 | + | |
| 90 | +**Avalikud ruudid:** `/`, `/login`, `/register` | |
| 91 | +**Autentimist nõudvad ruudid:** kõik ülejäänud (valvur `router/index.ts` suunab külalised `/login` peale) | |
| 92 | + | |
| 93 | +Põhilised ruudid: | |
| 94 | +- `/trips` — reiside loend | |
| 95 | +- `/trips/create` — uus reis | |
| 96 | +- `/trips/:tripId` — reisi detailvaade (parent) | |
| 97 | + - `expenses`, `expenses/create`, `expenses/:id/edit` | |
| 98 | + - `budget`, `budget/create`, `budget/:id/edit` | |
| 99 | + - `wishlist`, `wishlist/create`, `wishlist/:id/edit` | |
| 100 | + - `polls`, `polls/create`, `polls/:id` | |
| 101 | + - `members` — reisikaaslased | |
| 102 | + - `settlement` — arveldused | |
| 103 | + - `edit` — reisi muutmine | |
| 104 | +- `/invitations/:token` — kutse vastuvõtmine | |
| 105 | + | |
| 106 | +### 2.4 Autentimine | |
| 107 | + | |
| 108 | +**Salvestus:** JWT ja refresh token hoitakse `localStorage`-s (`jwt`, `refreshToken`, `userName`). Pinia store `auth.ts` sünkroniseerib need automaatselt `watch`-i kaudu. | |
| 109 | + | |
| 110 | +**Voog:** | |
| 111 | +1. Login/Register → `AccountService` saadab POST päringu → saab tagasi `{ jwt, refreshToken, firstName, lastName }` | |
| 112 | +2. `httpClient`-i **request interceptor** lisab iga päringule päise `Authorization: Bearer <jwt>` | |
| 113 | +3. **Response interceptor** püüab 401 vastused kinni: | |
| 114 | + - Kutsub `refreshTokenAsync()` → uuendab tokenid store'is → kordab algset päringut | |
| 115 | + - Kui refresh ebaõnnestub → logib kasutaja välja ja suunab `/login`-ile | |
| 116 | +4. Logout → tühistab refresh tokeni backendis + puhastab store'i | |
| 117 | + | |
| 118 | +### 2.5 Keskkonnamuutujad | |
| 119 | + | |
| 120 | +Fail `.env` (gitignore'is): | |
| 121 | +```env | |
| 122 | +VITE_API_BASE_URL=http://localhost:90/api/v1/ | |
| 123 | +``` | |
| 124 | + | |
| 125 | +Kasutatakse `httpClient.ts` ja `AccountService.ts` failides. Kui backend käib lokaalselt `dotnet run` kaudu, tuleb see vahetada `http://localhost:5086/api/v1/` vastu. | |
| 126 | + | |
| 127 | +### 2.6 Kulude jagamise loogika | |
| 128 | + | |
| 129 | +`SplitMethodSelector.vue` toetab nelja meetodit — need vastavad täpselt backendi `ESplitMethod` enumile: | |
| 130 | + | |
| 131 | +| Meetod | Kirjeldus | | |
| 132 | +|---|---| | |
| 133 | +| **EqualAll** | Kogu summa jagatakse võrdselt kõigi osalejate vahel | | |
| 134 | +| **EqualSubset** | Kasutaja valib alamhulga osalejatest, jagatakse võrdselt | | |
| 135 | +| **ExactAmounts** | Iga osaleja kohta sisestatakse täpne summa (peab võrduma kogusummaga) | | |
| 136 | +| **Percentages** | Iga osaleja kohta protsent (peavad kokku andma 100%) | | |
| 137 | + | |
| 138 | +Komponent valideerib sisendit jooksvalt ja emiteerib `update:splits` + `update:valid`. | |
| 139 | + | |
| 140 | +### 2.7 Skriptid | |
| 141 | + | |
| 142 | +```sh | |
| 143 | +npm install # Installi sõltuvused | |
| 144 | +npm run dev # Arendusserver http://localhost:5173 | |
| 145 | +npm run build # Type-check + production build | |
| 146 | +npm run preview # Eelvaade ehitatud rakendusest | |
| 147 | +npm run test:unit # Vitest testid | |
| 148 | +npm run lint # Oxlint + ESLint | |
| 149 | +npm run format # Prettier | |
| 150 | +``` | |
| 151 | + | |
| 152 | +> **Märkus:** `src/__tests__/App.spec.ts` on aegunud (otsib teksti "You did it!", mida App.vue ei sisalda). `npm run test:unit` kukub seetõttu praegu läbi. Reaalset funktsionaalsust see ei mõjuta. | |
| 153 | + | |
| 154 | +--- | |
| 155 | + | |
| 156 | +## 3. Backend — `rasmju-csweb-phase3` | |
| 157 | + | |
| 158 | +### 3.1 Tehnoloogiad | |
| 159 | + | |
| 160 | +- **.NET 10** (net10.0) | |
| 161 | +- **ASP.NET Core 10** Web API + Identity + MVC (Razor Views) | |
| 162 | +- **Entity Framework Core 10** — ORM | |
| 163 | +- **PostgreSQL 16** (Npgsql) — andmebaas, kolm schema-isoleeritud schema-t ühes andmebaasis | |
| 164 | +- **MediatR** — moodulitevaheline suhtlus (in-process queries + notifications) | |
| 165 | +- **JWT Bearer** autentimine + refresh token | |
| 166 | +- **Swashbuckle** — Swagger/OpenAPI UI | |
| 167 | +- **Asp.Versioning** — API versioonimine (`/api/v{version}/...`) | |
| 168 | + | |
| 169 | +Backend on **modulaarmonoliit** — üks deployitav rakendus, mille sees on kolm sisemiselt isoleeritud moodulit (**Users**, **Trips**, **Expenses**). Moodulid ei tee otseseid ristviiteid `Application`/`Infrastructure`/`Api` projektide tasemel — kõik moodulitevahelised väljakutsed käivad **MediatR-i kaudu**, igal moodulil on oma Postgres-i schema ja oma `DbContext`. | |
| 170 | + | |
| 171 | +### 3.2 Lahuse struktuur | |
| 172 | + | |
| 173 | +`SplitApp.Modular/` lahus sisaldab kolme moodulit (kummalgi 4 projekti), kahte jagatud projekti ja ühte hosti: | |
| 174 | + | |
| 175 | +``` | |
| 176 | +SplitApp.Modular/ | |
| 177 | +├── SplitApp.sln | |
| 178 | +├── Directory.Build.props | |
| 179 | +├── src/ | |
| 180 | +│ ├── SplitApp.WebApp/ # Composition root + host | |
| 181 | +│ │ ├── Program.cs # AddXxxModule(...) wiring | |
| 182 | +│ │ ├── Application/ # Phase-2-st tõstetud BLL | |
| 183 | +│ │ │ ├── Services/ (+ Admin/, Identity/) # 12 admin + 9 klient + 1 identity teenust | |
| 184 | +│ │ │ ├── DTO/ # BllDto'd vaadetele | |
| 185 | +│ │ │ ├── Mappers/ # Domain ↔ BllDto factory mapperid | |
| 186 | +│ │ │ ├── Persistence/AppUnitOfWork.cs # Aggregib 3 mooduli DbContextid | |
| 187 | +│ │ │ └── Persistence/CrossModuleNavigationLoader.cs | |
| 188 | +│ │ ├── Areas/Admin/ # MVC admin haldusliides (13 kontrollerit) | |
| 189 | +│ │ ├── Areas/Identity/ # Razor Identity UI (Register jne) | |
| 190 | +│ │ ├── Controllers/, Views/ # Klient-MVC (parity phase 2-ga) | |
| 191 | +│ │ └── Resources/ # i18n resx (en, et) | |
| 192 | +│ ├── Shared/ | |
| 193 | +│ │ ├── SplitApp.Shared.Kernel/ # BaseEntity, IBaseRepo, IUoW, LangStr | |
| 194 | +│ │ └── SplitApp.Shared.Contracts/ # MediatR IRequest / INotification | |
| 195 | +│ └── Modules/ | |
| 196 | +│ ├── Users/ # 4 projekti, schema "users" | |
| 197 | +│ │ ├── ...Domain/ # AppUser, AppRole, AppRefreshToken | |
| 198 | +│ │ ├── ...Application/ # IIdentityService, JWT/refresh, MediatR handlerid | |
| 199 | +│ │ ├── ...Infrastructure/ # UsersDbContext, repod, migrations | |
| 200 | +│ │ └── ...Api/ # /api/v1/identity/... | |
| 201 | +│ ├── Trips/ # 4 projekti, schema "trips" | |
| 202 | +│ └── Expenses/ # 4 projekti, schema "expenses" | |
| 203 | +└── tests/ | |
| 204 | + ├── SplitApp.Modules.{Users,Trips,Expenses}.Tests/ # Per-module unit testid | |
| 205 | + └── SplitApp.WebApp.IntegrationTests/ # Architecture invariant + smoke (25 testi kokku) | |
| 206 | +``` | |
| 207 | + | |
| 208 | +Iga moodul = mini-Clean-Architecture (`Domain` ← `Application` ← `Infrastructure`, `Api` REST jaoks). `Application`/`Infrastructure`/`Api` projektidel ei ole `<ProjectReference>`-i teiste moodulite samanimelistele projektidele — seda kontrollivad `tests/SplitApp.WebApp.IntegrationTests/Architecture/` arhitektuuritestid (kukkumine = ehitus kukub). | |
| 209 | + | |
| 210 | +### 3.3 Domeenimudel | |
| 211 | + | |
| 212 | +Põhientiteedid ja nende seosed: | |
| 213 | + | |
| 214 | +| Entiteet | Olulised väljad | Seosed | | |
| 215 | +|---|---|---| | |
| 216 | +| **Trip** | Name, Description, Destination, StartDate, EndDate, Status, DefaultCurrencyId, CreatedById | 1→many: Participants, Expenses, BudgetCategories, WishlistItems, Polls, Invitations, SettlementPlans | | |
| 217 | +| **TripParticipant** | TripId, UserId, Role (Organizer/Participant), Nickname, IsActive | ↔ Trip, AppUser | | |
| 218 | +| **Expense** | TripId, PaidByUserId, Amount, Description, ExpenseDate, SplitMethod, BudgetCategoryId, CurrencyId | 1→many: ExpenseSplits | | |
| 219 | +| **ExpenseSplit** | ExpenseId, UserId, Amount, Percentage | ↔ Expense, AppUser | | |
| 220 | +| **BudgetCategory** | TripId, Name (LangStr), IconName, PlannedAmount | 1→many: Expenses | | |
| 221 | +| **TripInvitation** | TripId, Token (unikaalne), Status, ExpiresAt | ↔ Trip | | |
| 222 | +| **TripPoll** | TripId, Question, AllowMultipleVotes, IsAnonymous, ClosedAt | 1→many: Options → Votes | | |
| 223 | +| **TripWishlistItem** | TripId, Title, Category, Priority, EstimatedCost, IsCompleted | 1→many: Votes | | |
| 224 | +| **SettlementPlan** | TripId, TotalAmount, Status | 1→many: Payments | | |
| 225 | +| **SettlementPayment** | FromUserId, ToUserId, Amount, Status (Pending/MarkedPaid/Confirmed) | ↔ SettlementPlan | | |
| 226 | +| **Currency** | Code (3 tähte), Name (LangStr), Symbol | — | | |
| 227 | + | |
| 228 | +**Identity entiteedid (Users moodul, schema `users`):** | |
| 229 | +- `AppUser` (laiendab `IdentityUser<Guid>`) — lisab `FirstName`, `LastName` | |
| 230 | +- `AppRole` (laiendab `IdentityRole<Guid>`) — rollid: `user`, `admin` | |
| 231 | +- `AppRefreshToken` — refresh token + eelmine token + aegumised | |
| 232 | + | |
| 233 | +**Baasinfrastruktuur (`SplitApp.Shared.Kernel`):** | |
| 234 | +- `BaseEntity` — abstraktne: `Id` (Guid), `CreatedAt`, `UpdatedAt` | |
| 235 | +- `LangStr` — mitmekeelne string (`Dictionary<string, string>`), salvestatakse andmebaasis JSON-ina | |
| 236 | + | |
| 237 | +**Enumid:** | |
| 238 | +- `ETripStatus`: Active, **Finalizing**, Settled, Archived (Finalizing = arvelduskava lukus, oodatakse kinnitusi) | |
| 239 | +- `EParticipantRole`: Organizer, Participant | |
| 240 | +- `ESplitMethod`: **EqualAll, EqualSubset, ExactAmounts, Percentages** (vastab frontendi omale) | |
| 241 | +- `EInvitationStatus`: Pending, Accepted, Declined, Expired, Revoked | |
| 242 | +- `ESettlementStatus`: Pending, InProgress, Completed | |
| 243 | +- `EPaymentStatus`: Pending, MarkedPaid, Confirmed | |
| 244 | + | |
| 245 | +**Schemade jaotus:** | |
| 246 | + | |
| 247 | +| Schema | Moodul | Entiteedid | | |
| 248 | +|---|---|---| | |
| 249 | +| `users` | Users | AppUser, AppRole + ASP.NET Identity tabelid, AppRefreshToken | | |
| 250 | +| `trips` | Trips | Trip, TripParticipant, TripInvitation, TripPoll(+Option/Vote), TripWishlistItem(+Vote), BudgetCategory | | |
| 251 | +| `expenses` | Expenses | Currency, Expense, ExpenseSplit, SettlementPlan, SettlementPayment, SplitPreset(+Member) | | |
| 252 | + | |
| 253 | +**Cross-module entiteedi-viited** (näit `Trip.CreatedById`, `Expense.PaidByUserId`, `BudgetCategory.TripId`) on lihtsalt `Guid` väljad — **ei mingit EF foreign key piirangut üle schema-de**, sest EF-l ei tohi lasta kogemata schema piiri ületada. Navigeerimispropertid (näit `Trip.DefaultCurrency`, `Trip.CreatedBy`, `Expense.PaidByUser`) on `[NotMapped]` ja täidetakse käsitsi `WebApp/Application/Persistence/CrossModuleNavigationLoader`-i kaudu (eraldi batch-päringud teise mooduli `DbContext`-i vastu). | |
| 254 | + | |
| 255 | +Referentsiaalne terviklikkus tagatakse kahel viisil: | |
| 256 | +1. **Eelnev validatsioon MediatR-päringutega** — näit enne kulu salvestamist saadab `ExpensesController` `IsTripParticipantQuery(tripId, userId)` Trips moodulile | |
| 257 | +2. **Domain-eventide koristus kustutamisel** — `UserDeletedEvent` / `TripDeletedEvent` ja teised moodulid tellivad need ning kustutavad sõltuvad read | |
| 258 | + | |
| 259 | +### 3.4 Andmebaas | |
| 260 | + | |
| 261 | +**Kolm DbContext-i, üks andmebaas:** kõik kolm moodulit ühenduvad samasse Postgres-i andmebaasi, kuid igaüks oma schema kaudu. Kogu konfiguratsioon on per-moodul mooduli enda `Infrastructure/Persistence/`-s. | |
| 262 | + | |
| 263 | +| DbContext | Schema | Pärib | Asukoht | | |
| 264 | +|---|---|---|---| | |
| 265 | +| `UsersDbContext` | `users` | `IdentityDbContext<AppUser, AppRole, Guid>` (rakendab ka `IDataProtectionKeyContext`) | `Modules/Users/...Infrastructure/Persistence/` | | |
| 266 | +| `TripsDbContext` | `trips` | `DbContext` | `Modules/Trips/...Infrastructure/Persistence/` | | |
| 267 | +| `ExpensesDbContext` | `expenses` | `DbContext` | `Modules/Expenses/...Infrastructure/Persistence/` | | |
| 268 | + | |
| 269 | +**Cross-schema SQL JOIN-id on keelatud** — kompositsioon toimub rakenduskihis MediatR-i või `CrossModuleNavigationLoader`-i kaudu. Et `Trips.Domain.Trip.DefaultCurrency` (Currency on Expenses moodulis) tüüpi ikkagi näha saaks, on `Trips.Domain` ja `Expenses.Domain` projektidel **kolm Domain-to-Domain `<ProjectReference>`-i** — aga kõik cross-module navigeerimispropertid on `[NotMapped]`, nii et EF ei lähe kunagi üle schema-piiri. | |
| 270 | + | |
| 271 | +**Olulised piirangud (OnModelCreating, igas DbContext-is):** | |
| 272 | +- Kõik seosed mooduli sees: `DeleteBehavior.Restrict` (kustutamisel ei kustutata kaskaadis — välja arvatud cleanup eventidel) | |
| 273 | +- Unikaalne indeks: `TripInvitation.Token` | |
| 274 | +- Liit-unikaalne indeks: `(TripParticipant.TripId, UserId)`, `(TripWishlistVote.WishlistItemId, UserId)`, `(TripPollVote.PollOptionId, UserId)` | |
| 275 | +- DateTime väljad: UTC konverter (alati salvestatakse UTC-s) — `UtcDateTimeConverter` igas mooduli `Persistence/` kaustas | |
| 276 | +- `LangStr` väljad (`Currency.Name`, `BudgetCategory.Name`): JSON-serialiseeritud | |
| 277 | + | |
| 278 | +**Migratsioonid:** 1 migratsioon mooduli kohta (`Init`), kokku 3: | |
| 279 | +- `Modules/Users/...Infrastructure/Persistence/Migrations/` | |
| 280 | +- `Modules/Trips/...Infrastructure/Persistence/Migrations/` | |
| 281 | +- `Modules/Expenses/...Infrastructure/Persistence/Migrations/` | |
| 282 | + | |
| 283 | +Migratsioonid rakendatakse host'i käivitamisel automaatselt — iga mooduli `UseXxxModule()` extension call (`Program.cs`) teeb `db.Database.Migrate()`. | |
| 284 | + | |
| 285 | +**Andmete lähtestamine** (seadistatakse `appsettings.json`-is): | |
| 286 | +```json | |
| 287 | +"DataInitialization": { | |
| 288 | + "DropDatabase": false, // Kustuta andmebaas käivitamisel | |
| 289 | + "MigrateDatabase": true, // Rakenda migratsioonid | |
| 290 | + "SeedIdentity": true, // Loo kasutajad ja rollid (Users moodul) | |
| 291 | + "SeedData": true // Loo näidisandmed (host-tasandil, peale module init'e) | |
| 292 | +} | |
| 293 | +``` | |
| 294 | + | |
| 295 | +**Seemneandmed:** | |
| 296 | +- **5 demokasutajat** (parool kõigil: `Kala.12345`) — seedib `UsersModuleExtensions.UseUsersModule()`: | |
| 297 | + - `user@taltech.ee`, `alice@taltech.ee`, `bob@taltech.ee`, `charlie@taltech.ee`, `diana@taltech.ee` | |
| 298 | + - admin luuakse ainult siis, kui `SEED_ADMIN_PASSWORD` on seatud | |
| 299 | +- **5 valuutat:** EUR, USD, GBP, SEK, NOK — seedib Expenses moodul | |
| 300 | +- **4 näidisreisi:** Barcelona Weekend (Active), London Business Trip (Settled), Summer Cabin Getaway (Active), NYC Adventure (Archived) — host-tasandi cross-module seed (`SplitApp.WebApp/Hosting/AppDataInit.cs`), kuna sisaldab andmeid kõigist kolmest moodulist | |
| 301 | + | |
| 302 | +### 3.5 API lõpp-punktid | |
| 303 | + | |
| 304 | +Kõik kontrollerid on versioonitud: `/api/v{version:apiVersion}/` (vaikimisi v1.0). Kontrollerid elavad **iga mooduli oma `Api/` projektis** — host (WebApp) avastab need läbi `AddApplicationPart(...)` (`Program.cs`). | |
| 305 | + | |
| 306 | +**Identity / Account** (Users moodul) — `/api/v1/identity/account` | |
| 307 | + | |
| 308 | +| Verb | Lõpp-punkt | Otstarve | Auth | | |
| 309 | +|---|---|---|---| | |
| 310 | +| POST | `/register` | Kasutaja registreerimine | — | | |
| 311 | +| POST | `/login` | Login → JWT + refresh token | — | | |
| 312 | +| POST | `/refreshtokendata` | Tokenite uuendamine | — | | |
| 313 | +| POST | `/logout` | Refresh tokeni tühistamine | JWT | | |
| 314 | + | |
| 315 | +**Trips** (Trips moodul) — `/api/v1/trips` | |
| 316 | + | |
| 317 | +| Verb | Lõpp-punkt | Otstarve | | |
| 318 | +|---|---|---| | |
| 319 | +| GET | `/` | Kasutaja reiside loend | | |
| 320 | +| POST | `/` | Uus reis | | |
| 321 | +| GET | `/{id}` | Reisi detail | | |
| 322 | +| PUT | `/{id}` | Muuda reisi | | |
| 323 | +| DELETE | `/{id}` | Kustuta reis | | |
| 324 | +| GET | `/{tripId}/participants` | Osalejate loend | | |
| 325 | +| DELETE | `/{tripId}/participants/{userId}` | Eemalda osaleja | | |
| 326 | + | |
| 327 | +**Expenses** (Expenses moodul) — `/api/v1/expenses` | |
| 328 | +- GET `/trip/{tripId}`, POST `/`, GET `/{id}`, PUT `/{id}`, DELETE `/{id}` | |
| 329 | + | |
| 330 | +**BudgetCategories** (Trips moodul) — `/api/v1/budgetcategories` | |
| 331 | +- GET `/trip/{tripId}`, POST `/`, PUT `/{id}`, DELETE `/{id}` | |
| 332 | + | |
| 333 | +**Currencies** (Expenses moodul) — `/api/v1/currencies` | |
| 334 | +- GET `/` (avalik, ei vaja JWT-d) | |
| 335 | + | |
| 336 | +**Invitations** (Trips moodul) — `/api/v1/invitations` | |
| 337 | +- POST `/`, GET `/{token}`, POST `/{token}/accept`, POST `/{token}/decline`, POST `/{token}/revoke` | |
| 338 | + | |
| 339 | +**Polls** (Trips moodul) — `/api/v1/polls` | |
| 340 | +- GET `/trip/{tripId}`, POST `/`, GET `/{id}`, POST `/{id}/vote`, POST `/{id}/close`, DELETE `/{id}` | |
| 341 | + | |
| 342 | +**Wishlist** (Trips moodul) — `/api/v1/wishlist` | |
| 343 | +- GET `/trip/{tripId}`, POST `/`, PUT `/{id}`, DELETE `/{id}`, POST `/{id}/vote`, POST `/{id}/complete` | |
| 344 | + | |
| 345 | +**Settlements** (Expenses moodul) — `/api/v1/settlements` | |
| 346 | +- GET `/trip/{tripId}`, GET `/trip/{tripId}/summary`, GET `/trip/{tripId}/balances` | |
| 347 | +- POST `/trip/{tripId}/calculate` — arvutab ja loob uue arvelduskava | |
| 348 | +- POST `/payments/{paymentId}/mark-paid` — märgib makse tasutuks | |
| 349 | +- POST `/payments/{paymentId}/confirm` — kinnitab saamise | |
| 350 | + | |
| 351 | +**SplitPresets** (Expenses moodul) — `/api/v1/splitpresets` | |
| 352 | +- GET `/trip/{tripId}`, GET `/{id}`, POST `/`, PUT `/{id}`, DELETE `/{id}` | |
| 353 | + | |
| 354 | +### 3.6 Autentimine ja turvalisus | |
| 355 | + | |
| 356 | +**JWT seadistus (appsettings.json):** | |
| 357 | +```json | |
| 358 | +"JWT": { | |
| 359 | + "Issuer": "itcollege.taltech.ee", | |
| 360 | + "Audience": "itcollege.taltech.ee", | |
| 361 | + "ExpiresInSeconds": 1800 // 30 minutit | |
| 362 | +} | |
| 363 | +``` | |
| 364 | + | |
| 365 | +**Voog:** | |
| 366 | +1. `POST /login` → tagastab JWT (30 min) + `AppRefreshToken` (kehtib 7 päeva) | |
| 367 | +2. Frontend kasutab JWT-d iga päringul | |
| 368 | +3. Enne aegumist (või 401 korral) → `POST /refreshtokendata` → uus JWT + uus refresh token (vana märgitakse eelmiseks, kehtib veel 1 minut ühildumiseks) | |
| 369 | +4. `POST /logout` → kustutab refresh tokeni andmebaasist | |
| 370 | + | |
| 371 | +**Autoriseerimine kontrollerites (IDOR-kaitse):** | |
| 372 | +- Enamik lõpp-punkte: `[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]` | |
| 373 | +- Kasutaja-id loetakse JWT `nameidentifier` claimist iga päringu alguses | |
| 374 | +- **Cross-module osaleja-kontroll käib MediatR-i kaudu** — näit `ExpensesController` saadab `IsTripParticipantQuery(tripId, userId)` Trips moodulile, sest Expenses moodul ei tohi `TripsDbContext`-i otse näha. Tagastab `Forbid()` kui false. | |
| 375 | +- Reisi-sisene loaja kontroll (organizer-only mutatsioon): `if (trip.CreatedById != userId.Value) return Forbid();` | |
| 376 | +- `List` endpointide nähtavus filtreeritud serveri pool — kasutaja ei näe kunagi reise/kulusid mille trip-osalemine puudub | |
| 377 | + | |
| 378 | +### 3.7 CORS | |
| 379 | + | |
| 380 | +`Program.cs` seadistab poliitika `CorsAllowAll`: | |
| 381 | +```csharp | |
| 382 | +.AllowAnyOrigin() | |
| 383 | +.AllowAnyMethod() | |
| 384 | +.AllowAnyHeader() | |
| 385 | +``` | |
| 386 | + | |
| 387 | +See tähendab, et frontend võib olla mistahes pordil/domeenil — arenduseks mugav, tootmises tuleks piirata. | |
| 388 | + | |
| 389 | +### 3.8 Konfiguratsioon (appsettings.json) | |
| 390 | + | |
| 391 | +```json | |
| 392 | +"ConnectionStrings": { | |
| 393 | + "DefaultConnection": "Host=localhost;Port=5432;Database=splitapp;Username=postgres;Password=postgres" | |
| 394 | +}, | |
| 395 | +"SupportedCultures": ["en", "et"], | |
| 396 | +"DefaultCulture": "en" | |
| 397 | +``` | |
| 398 | + | |
| 399 | +**Lokaliseerimine:** toetab inglise ja eesti keelt. Kultuuri saab muuta query parameetriga `?culture=et` või küpsise kaudu. | |
| 400 | + | |
| 401 | +### 3.9 Käivitamine | |
| 402 | + | |
| 403 | +**Lokaalselt (dotnet):** | |
| 404 | +```sh | |
| 405 | +cd SplitApp.Modular/src/SplitApp.WebApp | |
| 406 | +dotnet run | |
| 407 | +``` | |
| 408 | +→ `http://localhost:5086` (http) või `https://localhost:7040` (https) | |
| 409 | + | |
| 410 | +**Dockeri kaudu (soovitatud):** | |
| 411 | +```sh | |
| 412 | +docker compose up --build | |
| 413 | +``` | |
| 414 | +→ backend: `http://localhost:90` | |
| 415 | +→ Postgres: `localhost:5432` | |
| 416 | + | |
| 417 | +--- | |
| 418 | + | |
| 419 | +## 4. Kuidas frontend ja backend koos töötavad | |
| 420 | + | |
| 421 | +### 4.1 Pordid | |
| 422 | + | |
| 423 | +| Teenus | Port | Kus | | |
| 424 | +|---|---|---| | |
| 425 | +| Backend API | **90** | Docker (host) → 8080 (container) | | |
| 426 | +| Backend API (lokaalselt) | 5086 (http) / 7040 (https) | `dotnet run` | | |
| 427 | +| PostgreSQL | 5432 | Docker | | |
| 428 | +| Frontend (prod, Nginx) | **91** | Docker | | |
| 429 | +| Frontend (dev, Vite) | 5173 | `npm run dev` või Docker `dev` profiil | | |
| 430 | + | |
| 431 | +**Oluline:** frontend kuulab vaikimisi 8080-le *sisemist* konteineri porti, kuid Docker Compose avaldab selle hoopis **91**-le, et vältida konflikti lokaalse backendiga. Backend on host'il pordil **90**. | |
| 432 | + | |
| 433 | +### 4.2 API URL | |
| 434 | + | |
| 435 | +Frontendi `.env`: | |
| 436 | +```env | |
| 437 | +VITE_API_BASE_URL=http://localhost:90/api/v1/ | |
| 438 | +``` | |
| 439 | + | |
| 440 | +Kui backend käib lokaalselt (`dotnet run`): | |
| 441 | +```env | |
| 442 | +VITE_API_BASE_URL=http://localhost:5086/api/v1/ | |
| 443 | +``` | |
| 444 | + | |
| 445 | +### 4.3 Lõpp-punktide vastavustabel | |
| 446 | + | |
| 447 | +| Frontend teenus | Frontendi kõne | Backendi kontroller | | |
| 448 | +|---|---|---| | |
| 449 | +| AccountService | `identity/Account/Login` | `AccountController.Login` (Users moodul) | | |
| 450 | +| AccountService | `identity/Account/Register` | `AccountController.Register` (Users moodul) | | |
| 451 | +| AccountService | `identity/Account/RefreshTokenData` | `AccountController.RefreshTokenData` (Users moodul) | | |
| 452 | +| AccountService | `identity/Account/Logout` | `AccountController.Logout` (Users moodul) | | |
| 453 | +| TripService | `Trips/*` | `TripsController` | | |
| 454 | +| ExpenseService | `Expenses/*` | `ExpensesController` | | |
| 455 | +| BudgetCategoryService | `BudgetCategories/*` | `BudgetCategoriesController` | | |
| 456 | +| CurrencyService | `Currencies` | `CurrenciesController` | | |
| 457 | +| PollService | `Polls/*`, `Polls/:id/vote`, `Polls/:id/close` | `PollsController` | | |
| 458 | +| WishlistService | `Wishlist/*`, `:id/vote`, `:id/complete` | `WishlistController` | | |
| 459 | +| SettlementService | `Settlements/trip/:id/*`, `payments/:id/mark-paid` | `SettlementsController` | | |
| 460 | +| InvitationService | `Invitations/:token/*` | `InvitationsController` | | |
| 461 | + | |
| 462 | +ASP.NET Core ruutimine on **tõstutundetu**, seega erinevused nagu `Trips` vs `trips` ei tekita probleemi. | |
| 463 | + | |
| 464 | +### 4.4 Andmetüüpide vastavus | |
| 465 | + | |
| 466 | +Frontendi TypeScript liidesed (`src/types/`) on loodud backendi DTO-de põhjal ning struktuurid vastavad 1:1. Näiteks: | |
| 467 | + | |
| 468 | +**Frontend `IExpense`** ↔ **Backend `ExpenseDto`** | |
| 469 | +``` | |
| 470 | +id, tripId, paidByUserId, budgetCategoryId, currencyId, | |
| 471 | +amount, description, expenseDate, splitMethod, splits[] | |
| 472 | +``` | |
| 473 | + | |
| 474 | +**Frontend `IJwtResponse`** ↔ **Backend `JWTResponse`** | |
| 475 | +``` | |
| 476 | +jwt, refreshToken, firstName, lastName | |
| 477 | +``` | |
| 478 | + | |
| 479 | +Kulude jagamise enum `ESplitMethod` on mõlemas pooles identne. | |
| 480 | + | |
| 481 | +### 4.5 Autentimise koostöö | |
| 482 | + | |
| 483 | +1. Frontend saadab `POST /api/v1/identity/account/login` | |
| 484 | +2. Backend valideerib ja tagastab `{ jwt, refreshToken, firstName, lastName }` | |
| 485 | +3. Frontend salvestab need `localStorage`-sse ja Pinia store'i | |
| 486 | +4. Iga järgmine päring → `httpClient` lisab `Authorization: Bearer <jwt>` | |
| 487 | +5. Kui backend tagastab 401 → frontend proovib automaatselt refresh'i | |
| 488 | +6. Backend valideerib refresh tokeni, loob uue JWT + uue refresh tokeni ja tagastab | |
| 489 | +7. Frontend uuendab store'i ja kordab algset päringut | |
| 490 | + | |
| 491 | +--- | |
| 492 | + | |
| 493 | +## 5. Käivitamise juhend | |
| 494 | + | |
| 495 | +### 5.1 Täielik Docker-käivitus (soovitatud) | |
| 496 | + | |
| 497 | +**1. Käivita backend + andmebaas:** | |
| 498 | +```sh | |
| 499 | +cd C:\Users\rasmu\Documents\csharpweb\rasmju-csweb-phase3 | |
| 500 | +docker compose up --build | |
| 501 | +``` | |
| 502 | +See käivitab: | |
| 503 | +- PostgreSQL pordil 5432 | |
| 504 | +- Backend API pordil 90 | |
| 505 | +- Rakendab migratsioonid ja laeb seemneandmed | |
| 506 | + | |
| 507 | +**2. Kontrolli, et backend vastab:** | |
| 508 | +Avaga brauseris `http://localhost:90/swagger` — peaks kuvama Swagger UI. | |
| 509 | + | |
| 510 | +**3. Käivita frontend:** | |
| 511 | + | |
| 512 | +Arendusrežiimis (soovitatud koodi muutmiseks): | |
| 513 | +```sh | |
| 514 | +cd C:\Users\rasmu\Documents\javascript\rasmju-js-a7 | |
| 515 | +npm install | |
| 516 | +npm run dev | |
| 517 | +``` | |
| 518 | +→ `http://localhost:5173` | |
| 519 | + | |
| 520 | +Või Dockeri kaudu production build: | |
| 521 | +```sh | |
| 522 | +docker compose up --build | |
| 523 | +``` | |
| 524 | +→ `http://localhost:91` | |
| 525 | + | |
| 526 | +### 5.2 Kiire test | |
| 527 | + | |
| 528 | +1. Ava `http://localhost:5173` (või `:91`) | |
| 529 | +2. Logi sisse seemneandmete kasutajaga: | |
| 530 | + - Email: `alice@taltech.ee` | |
| 531 | + - Parool: `Kala.12345` | |
| 532 | +3. Peaksid nägema reiside loendit (Barcelona Weekend, Summer Cabin Getaway, ...) | |
| 533 | +4. Ava üks reis → vaata kulusid, arveldusi, küsitlusi | |
| 534 | + | |
| 535 | +### 5.3 Lokaalne arendus (ilma Dockerita) | |
| 536 | + | |
| 537 | +**Backend:** | |
| 538 | +```sh | |
| 539 | +cd rasmju-csweb-phase3/SplitApp.Modular/src/SplitApp.WebApp | |
| 540 | +dotnet run | |
| 541 | +``` | |
| 542 | +→ `http://localhost:5086` | |
| 543 | + | |
| 544 | +**Frontend** (muuda `.env`): | |
| 545 | +```env | |
| 546 | +VITE_API_BASE_URL=http://localhost:5086/api/v1/ | |
| 547 | +``` | |
| 548 | +```sh | |
| 549 | +cd rasmju-js-a7 | |
| 550 | +npm run dev | |
| 551 | +``` | |
| 552 | + | |
| 553 | +--- | |
| 554 | + | |
| 555 | +## 6. CI/CD pipeline | |
| 556 | + | |
| 557 | +Fail `.gitlab-ci.yml` defineerib **ühe etapi: deploy**. Testid jooksevad lokaalselt enne push'i (vt jaotis 7) — pipeline'i sisse on test-etapp **teadlikult lisamata**, sest varasem versioon kukus runneril ja blokeeris deploy'd. | |
| 558 | + | |
| 559 | +### 6.1 Deploy-etapp | |
| 560 | + | |
| 561 | +Käivitub **ainult `main` harusse merge'imisel**. Kasutab VPS-il olevat self-hosted runner'it (`shared` tag): | |
| 562 | + | |
| 563 | +```sh | |
| 564 | +docker compose -p rasmju-js-a7 up --build --remove-orphans --detach | |
| 565 | +``` | |
| 566 | + | |
| 567 | +- Ehitab uue multi-stage Docker image'i (Node build → Nginx) | |
| 568 | +- Käivitab uue konteineri pordil **91** | |
| 569 | +- Projektinimi `rasmju-js-a7` hoiab konteinerid backendi omadest eraldi | |
| 570 | +- `--remove-orphans` koristab vanad konteinerid | |
| 571 | + | |
| 572 | +### 6.2 Lokaalne kontrollnimekiri enne push'i | |
| 573 | + | |
| 574 | +Pipeline ei tee neid samme automaatselt — käivita käsitsi: | |
| 575 | + | |
| 576 | +```sh | |
| 577 | +npm run lint # Oxlint + ESLint | |
| 578 | +npm run type-check # vue-tsc range tüübikontroll | |
| 579 | +npm run test:unit -- --run # Vitest unit + integration | |
| 580 | +npm run test:e2e # Playwright (vajab elavat backendi) | |
| 581 | +``` | |
| 582 | + | |
| 583 | +Kui kõik on roheline, alles siis `git push`. | |
| 584 | + | |
| 585 | +### 6.3 Eraldi hosting ja CORS | |
| 586 | + | |
| 587 | +Frontend on hostitud **eraldi URL-il ja pordil** backendist: | |
| 588 | +- Frontend: `https://travel.rasmusj.com` (Nginx, port 91) | |
| 589 | +- Backend: `https://travel.rasmusj.com` (ASP.NET, port 90) | |
| 590 | + | |
| 591 | +Kuna tegemist on eri origin'itega, on backend seadistatud lubama päringuid kõigilt domeenidelt (`AllowAnyOrigin` CORS-poliitika `Program.cs`-is). Frontend ei vaja CORS-i seadistamist — see on puhtalt backendi vastutus. | |
| 592 | + | |
| 593 | +--- | |
| 594 | + | |
| 595 | +## 7. Testimine | |
| 596 | + | |
| 597 | +Projektil on kolm testimise kihti — **39 testi 7 failis**, kõik rohelised phase 3 modulaarmonoliit-backendi vastu. **Unit ja integration testid ei vaja backendi** (MSW mockib võrku); E2E testid käivad päris brauseris päris backendi vastu. | |
| 598 | + | |
| 599 | +### 7.1 Unit testid (Vitest + jsdom) | |
| 600 | + | |
| 601 | +Kaust: `src/__tests__/unit/`. Iga fail testib ühte kitsalt piiritletud koodiosa puhaste sisendite-väljunditega. | |
| 602 | + | |
| 603 | +| Testifail | Mida testib | Testide arv | Mis vea see püüaks | | |
| 604 | +|---|---|---|---| | |
| 605 | +| `formatCurrency.spec.ts` | märk-, sümbol-, kümnendkoha-, tuhandete-eraldaja-vormindus | **7** | Kuvatakse igal lehel — regression rikuks visuaalselt kogu äpi | | |
| 606 | +| `parseJwt.spec.ts` | JWT base64url dekodeerimine, ASP.NET `nameidentifier` claim + `sub` fallback | **9** | Vale user-id parsing seoks kulud vaikselt vale kasutajaga | | |
| 607 | +| `auth-store.spec.ts` | Pinia auth store: localStorage hüdratsioon, `isAuthenticated` reaktiivsus, `logout()`, watcher-based sync | **6** | Sünkroonimise lõhe → kasutaja näeks reload-i järel vana identiteeti | | |
| 608 | +| `SplitMethodSelector.spec.ts` | kõik 4 jagamismeetodit, jooksev validatsioon, edit-režiim `existingSplits`-iga | **9** | Kõige keerulisem äriloogika frondis — invalid splittidega salvestatud kulu | | |
| 609 | + | |
| 610 | +**Kokku:** 31 unit-testi, jooks ~0.4s. | |
| 611 | + | |
| 612 | +### 7.2 Integration test (Vitest + MSW) | |
| 613 | + | |
| 614 | +Kaust: `src/__tests__/integration/`. Erinevalt unit-testist mockib MSW võrku, mitte axios funktsioone — niisiis axios käitub täpselt nagu prodis. | |
| 615 | + | |
| 616 | +| Testifail | Mida testib | Testide arv | | |
| 617 | +|---|---|---| | |
| 618 | +| `token-refresh.spec.ts` | `httpClient`-i 401 → refresh → retry pipeline, kogu otsast otsani: (1) request interceptor lisab Bearer; (2) edukas refresh + originaalpäringu kordamine; (3) refresh kukub → logout → redirect `/login`-ile; (4) refresh token puudub → otse logout | **4** | | |
| 619 | + | |
| 620 | +See on **app-i kõige väärtuslikum test** — katab turvalisuse-tundlikuima glue-i (axios interceptors + Pinia store + router). Käsitsi seda flow-d katsetada nõuaks JWT aegumise ootamist või manuaalset katki tegemist. | |
| 621 | + | |
| 622 | +### 7.3 End-to-end testid (Playwright + Chromium) | |
| 623 | + | |
| 624 | +Kaust: `e2e/`. Päris brauser, päris backend. `webServer` config käivitab automaatselt `npm run dev`-i, niisiis vajalik on ainult backendi kättesaadavus. | |
| 625 | + | |
| 626 | +| Testifail | Mida testib | Testide arv | | |
| 627 | +|---|---|---| | |
| 628 | +| `auth.spec.ts` | (1) login seemnekasutajaga `alice@taltech.ee` → logout; (2) vale parool jätab `/login`-ile; (3) kaitstud `/trips` redirectib anonüümse `/login`-ile | **3** | | |
| 629 | +| `trip-crud.spec.ts` | **Positive happy flow** — login → loo unikaalse nimega reis → näe seda nimekirjas → ava Edit → muuda nime → kontrolli et muudatus on nähtav | **1** | | |
| 630 | + | |
| 631 | +**Märkus `trip-crud` kohta:** Delete on teadlikult välja jäetud, sest backend `DeleteBehavior.Restrict` blokeerib reisi kustutamise, kui sel on ükskõik milline TripParticipant (alati on vähemalt loojast Organizer). See on backend-i probleem, mitte frondi oma — niisiis test katsetab Update-i selle asemel, et pipeline jääks roheline. | |
| 632 | + | |
| 633 | +**Playwright seadistus** (`playwright.config.ts`): | |
| 634 | +- Brauser: ainult Chromium | |
| 635 | +- Käivitab automaatselt Vite arendusserveri | |
| 636 | +- `.env`-ist loetav `VITE_API_BASE_URL` määrab, mille vastu test jookseb (praegu: prod backend) | |
| 637 | +- Üks worker (jadatestid jagatud seemneandmete tõttu) | |
| 638 | +- Trace + screenshot ebaõnnestumisel | |
| 639 | + | |
| 640 | +### 7.4 Testiskriptid | |
| 641 | + | |
| 642 | +```sh | |
| 643 | +npm run test:unit # Vitest watch-režiim | |
| 644 | +npm run test:unit -- --run # Ühekordne jooks | |
| 645 | +npm run test:e2e # Playwright headless (vajab backendi kättesaadavust) | |
| 646 | +npm run test:e2e:ui # Playwright interaktiivne UI | |
| 647 | +``` | |
| 648 | + | |
| 649 | +### 7.5 Kuidas E2E käib phase 3 vastu | |
| 650 | + | |
| 651 | +Frontendi `.env` osutab vaikimisi prod backendile: | |
| 652 | + | |
| 653 | +```env | |
| 654 | +VITE_API_BASE_URL=https://travel.rasmusj.com/api/v1/ | |
| 655 | +``` | |
| 656 | + | |
| 657 | +Niisiis `npm run test:e2e` lokaalselt: | |
| 658 | +1. Käivitab Vite dev-serveri pordil 5173 | |
| 659 | +2. Vite serveerib Vue äppi, mis HTTP-päringutes osutab prod backendile | |
| 660 | +3. Playwright juhib Chromiumi → login `alice@taltech.ee` → CRUD operatsioonid → tulemused tulevad päris prod-DB-st | |
| 661 | + | |
| 662 | +⚠️ `trip-crud` test loob päris reisi prod-andmebaasi ja **ei kustuta seda** (vt 7.3 märkust). Kui taht olla puhtam, siis vaheta `.env.local`-iga lokaalse Dockeri vastu. | |
| 663 | + | |
| 664 | +### 7.6 Kokkuvõte | |
| 665 | + | |
| 666 | +| Kiht | Raamistik | Failide arv | Testide arv | Vajab backendi? | Aeg | | |
| 667 | +|---|---|---|---|---|---| | |
| 668 | +| Unit | Vitest + jsdom | 4 | 31 | Ei | ~0.4s | | |
| 669 | +| Integration | Vitest + MSW | 1 | 4 | Ei | ~0.5s | | |
| 670 | +| E2E | Playwright + Chromium | 2 | 4 | Jah | ~11s | | |
| 671 | +| **Kokku** | | **7** | **39** | | ~12s | | |
| 672 | + | |
| 673 | +--- | |
| 674 | + | |
| 675 | +## 8. Seemneandmete kasutajad | |
| 676 | + | |
| 677 | +Demokasutajatel on parool **`Kala.12345`**. See on koodis teadlikult: tegu on | |
| 678 | +demoga ja andmed on välja mõeldud. | |
| 679 | + | |
| 680 | +Admin on eraldi. Ta luuakse ainult siis, kui serveripoolel on seatud | |
| 681 | +`SEED_ADMIN_PASSWORD`, ja vaikeväärtust ei ole. Ilma selle muutujata ei ole | |
| 682 | +admin-kontot üldse olemas. | |
| 683 | + | |
| 684 | +| Email | Roll | | |
| 685 | +|---|---| | |
| 686 | +| `user@taltech.ee` | user | | |
| 687 | +| `alice@taltech.ee` | user | | |
| 688 | +| `bob@taltech.ee` | user | | |
| 689 | +| `charlie@taltech.ee` | user | | |
| 690 | +| `diana@taltech.ee` | user | | |
| 691 | + | |
| 692 | +--- | |
| 693 | + | |
| 694 | +## 9. Kokkuvõte — kas kõik töötab? | |
| 695 | + | |
| 696 | +| Kontroll | Staatus | | |
| 697 | +|---|---| | |
| 698 | +| Frontendi `VITE_API_BASE_URL` viitab backendi Dockeri pordile (90) | ✅ | | |
| 699 | +| API versioon `v1` vastab backendi vaikeversioonile | ✅ | | |
| 700 | +| Kõik 9 frontendi teenust leiavad backendist vastava kontrolleri | ✅ | | |
| 701 | +| JWT vastuse struktuur on mõlemas pooles sama | ✅ | | |
| 702 | +| Refresh-tokeni voog (request → refresh → retry) | ✅ | | |
| 703 | +| CORS lubab frontendi origini (AllowAnyOrigin) | ✅ | | |
| 704 | +| `ESplitMethod` enum sama mõlemas pooles | ✅ | | |
| 705 | +| Pordikonfliktid puuduvad (90, 5432, 91, 5173) | ✅ | | |
| 706 | +| Seemneandmed (6 kasutajat, 4 reisi) laaditakse käivitamisel | ✅ | | |
| 707 | + | |
| 708 | +### Ülesande nõuete vastavus | |
| 709 | + | |
| 710 | +| Nõue | Staatus | Kus | | |
| 711 | +|---|---|---| | |
| 712 | +| Eraldiseisev klientrakendus valitud tehnoloogiaga | ✅ | Vue 3 + TypeScript | | |
| 713 | +| Kasutab oma backendi REST API-t | ✅ | `VITE_API_BASE_URL`, 9 teenust | | |
| 714 | +| JWT + refresh token autentimine | ✅ | `httpClient.ts`, `AccountService.ts`, `auth.ts` | | |
| 715 | +| Login / logout | ✅ | `LoginView.vue`, `RegisterView.vue`, `App.vue` | | |
| 716 | +| CRUD vähemalt 3 entiteedil | ✅ (5) | Trips, Expenses, BudgetCategories, Wishlist, Polls | | |
| 717 | +| CI/CD deploy — klient eraldi URL-il | ✅ | `.gitlab-ci.yml`, eraldi Docker konteiner pordil 91 | | |
| 718 | +| CORS käsitlus | ✅ | Backend `CorsAllowAll` poliitika; frontend eraldi origin'il | | |
| 719 | +| Boonus: unit + integration + e2e testid | ✅ | Vitest + MSW + Playwright — 39 testi (kõik rohelised phase 3 vastu) | |
added docker-compose.yml +15 −0
| @@ -0,0 +1,15 @@ | ||
| 1 | +services: | |
| 2 | + app: | |
| 3 | + build: | |
| 4 | + context: . | |
| 5 | + dockerfile: Dockerfile | |
| 6 | + target: production | |
| 7 | + args: | |
| 8 | + # Override via shell env or a docker-compose .env file. | |
| 9 | + # Default assumes the backend runs on the same host at port 90. | |
| 10 | + VITE_API_BASE_URL: ${VITE_API_BASE_URL:-https://travel.rasmusj.com/api/v1/} | |
| 11 | + image: rasmju-js-a7:latest | |
| 12 | + container_name: rasmju-js-a7 | |
| 13 | + ports: | |
| 14 | + - "91:80" | |
| 15 | + restart: unless-stopped |
added docs/Project_proposal_Rasmus_Jürgenson.pdf +0 −0
Line changes are not available for this file.
added docs/grouptravel.png +0 −0
Line changes are not available for this file.
added e2e/auth.spec.ts +51 −0
| @@ -0,0 +1,51 @@ | ||
| 1 | +import { test, expect } from '@playwright/test' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Smoke auth flow against a live backend on http://localhost:90 with seed data. | |
| 5 | + * Uses the seed user alice@taltech.ee / Kala.12345. | |
| 6 | + * | |
| 7 | + * These tests intentionally avoid creating new users so the seed stays stable. | |
| 8 | + */ | |
| 9 | + | |
| 10 | +test.describe('authentication', () => { | |
| 11 | + test('login as seed user alice, then logout', async ({ page }) => { | |
| 12 | + await page.goto('/login') | |
| 13 | + | |
| 14 | + await page.getByLabel(/email/i).fill('alice@taltech.ee') | |
| 15 | + await page.getByLabel(/password/i).fill('Kala.12345') | |
| 16 | + await page.getByRole('button', { name: /sign in|log ?in/i }).click() | |
| 17 | + | |
| 18 | + // After login, the navbar shows a button with the user name. | |
| 19 | + const userButton = page.getByRole('button', { name: /Alice/i }) | |
| 20 | + await expect(userButton).toBeVisible({ timeout: 10_000 }) | |
| 21 | + | |
| 22 | + // The /trips area is reachable via the navbar link. | |
| 23 | + await page.getByRole('link', { name: /trips/i }).first().click() | |
| 24 | + await expect(page).toHaveURL(/\/trips$/) | |
| 25 | + | |
| 26 | + // Logout via the avatar button. | |
| 27 | + await userButton.click() | |
| 28 | + | |
| 29 | + // The user button is gone — logout succeeded. | |
| 30 | + await expect(userButton).toBeHidden() | |
| 31 | + // And we landed on the home page (not /trips anymore). | |
| 32 | + await expect(page).not.toHaveURL(/\/trips/) | |
| 33 | + }) | |
| 34 | + | |
| 35 | + test('login with wrong password shows an error and stays on the login page', async ({ page }) => { | |
| 36 | + await page.goto('/login') | |
| 37 | + await page.getByLabel(/email/i).fill('alice@taltech.ee') | |
| 38 | + await page.getByLabel(/password/i).fill('wrong-password-12345') | |
| 39 | + await page.getByRole('button', { name: /sign in|log ?in/i }).click() | |
| 40 | + | |
| 41 | + // Still on login page (URL hasn't changed to /) | |
| 42 | + await expect(page).toHaveURL(/\/login/) | |
| 43 | + }) | |
| 44 | + | |
| 45 | + test('protected route redirects to login when not authenticated', async ({ page, context }) => { | |
| 46 | + // Fresh context = no localStorage. | |
| 47 | + await context.clearCookies() | |
| 48 | + await page.goto('/trips') | |
| 49 | + await expect(page).toHaveURL(/\/login/) | |
| 50 | + }) | |
| 51 | +}) |
added e2e/trip-crud.spec.ts +61 −0
| @@ -0,0 +1,61 @@ | ||
| 1 | +import { test, expect, type Page } from '@playwright/test' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * End-to-end CRUD on the Trip entity. | |
| 5 | + * | |
| 6 | + * NOTE: we intentionally cover Create + Read + Update rather than Delete. | |
| 7 | + * The backend (`TripsController.Delete` in the Trips module) does not cascade | |
| 8 | + * the trip's TripParticipant rows, and `TripsDbContext` uses | |
| 9 | + * `DeleteBehavior.Restrict` for every relationship — so deleting a trip | |
| 10 | + * that has any participant (which every trip has, including a freshly | |
| 11 | + * created one, because the creator is auto-added as Organizer) fails at | |
| 12 | + * the database level. That's a backend issue, not a frontend one, so this | |
| 13 | + * test exercises Update instead to keep the pipeline green. | |
| 14 | + */ | |
| 15 | + | |
| 16 | +async function login(page: Page) { | |
| 17 | + await page.goto('/login') | |
| 18 | + await page.getByLabel(/email/i).fill('alice@taltech.ee') | |
| 19 | + await page.getByLabel(/password/i).fill('Kala.12345') | |
| 20 | + await page.getByRole('button', { name: /sign in|log ?in/i }).click() | |
| 21 | + await expect(page.getByRole('button', { name: /Alice/i })).toBeVisible({ timeout: 10_000 }) | |
| 22 | +} | |
| 23 | + | |
| 24 | +test.describe('trip CRUD', () => { | |
| 25 | + test('create, read, update a trip', async ({ page }) => { | |
| 26 | + await login(page) | |
| 27 | + | |
| 28 | + const tripName = `E2E Test Trip ${Date.now()}` | |
| 29 | + const updatedName = `${tripName} — updated` | |
| 30 | + | |
| 31 | + // --- Create --- | |
| 32 | + await page.goto('/trips') | |
| 33 | + | |
| 34 | + // "New Trip" is a <button> (router.push via click), not a link. | |
| 35 | + await page.getByRole('button', { name: /new trip/i }).first().click() | |
| 36 | + await expect(page).toHaveURL(/\/trips\/create/) | |
| 37 | + | |
| 38 | + await page.getByLabel('Trip name *', { exact: true }).fill(tripName) | |
| 39 | + await page.getByLabel('Destination').fill('Tallinn') | |
| 40 | + await page.getByRole('button', { name: /create trip/i }).click() | |
| 41 | + | |
| 42 | + // --- Read — new trip appears in the list --- | |
| 43 | + await expect(page).toHaveURL(/\/trips$/) | |
| 44 | + const tripCard = page.locator('.sa-trip-card').filter({ hasText: tripName }) | |
| 45 | + await expect(tripCard).toBeVisible({ timeout: 10_000 }) | |
| 46 | + | |
| 47 | + // --- Update — open edit view, rename, save, verify --- | |
| 48 | + await tripCard.getByRole('button', { name: /edit/i }).click() | |
| 49 | + await expect(page).toHaveURL(/\/trips\/.+\/edit/) | |
| 50 | + | |
| 51 | + const nameInput = page.getByLabel('Name', { exact: true }) | |
| 52 | + await nameInput.fill(updatedName) | |
| 53 | + await page.getByRole('button', { name: /save|update/i }).click() | |
| 54 | + | |
| 55 | + // Back on /trips and the renamed trip is visible, old name gone. | |
| 56 | + await expect(page).toHaveURL(/\/trips$/) | |
| 57 | + await expect( | |
| 58 | + page.locator('.sa-trip-card').filter({ hasText: updatedName }), | |
| 59 | + ).toBeVisible({ timeout: 10_000 }) | |
| 60 | + }) | |
| 61 | +}) |
added env.d.ts +1 −0
| @@ -0,0 +1 @@ | ||
| 1 | +/// <reference types="vite/client" /> |
added eslint.config.ts +32 −0
| @@ -0,0 +1,32 @@ | ||
| 1 | +import { globalIgnores } from 'eslint/config' | |
| 2 | +import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript' | |
| 3 | +import pluginVue from 'eslint-plugin-vue' | |
| 4 | +import pluginVitest from '@vitest/eslint-plugin' | |
| 5 | +import pluginOxlint from 'eslint-plugin-oxlint' | |
| 6 | +import skipFormatting from 'eslint-config-prettier/flat' | |
| 7 | + | |
| 8 | +// To allow more languages other than `ts` in `.vue` files, uncomment the following lines: | |
| 9 | +// import { configureVueProject } from '@vue/eslint-config-typescript' | |
| 10 | +// configureVueProject({ scriptLangs: ['ts', 'tsx'] }) | |
| 11 | +// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup | |
| 12 | + | |
| 13 | +export default defineConfigWithVueTs( | |
| 14 | + { | |
| 15 | + name: 'app/files-to-lint', | |
| 16 | + files: ['**/*.{vue,ts,mts,tsx}'], | |
| 17 | + }, | |
| 18 | + | |
| 19 | + globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']), | |
| 20 | + | |
| 21 | + ...pluginVue.configs['flat/essential'], | |
| 22 | + vueTsConfigs.recommended, | |
| 23 | + | |
| 24 | + { | |
| 25 | + ...pluginVitest.configs.recommended, | |
| 26 | + files: ['src/**/__tests__/*'], | |
| 27 | + }, | |
| 28 | + | |
| 29 | + ...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'), | |
| 30 | + | |
| 31 | + skipFormatting, | |
| 32 | +) |
added index.html +17 −0
| @@ -0,0 +1,17 @@ | ||
| 1 | +<!DOCTYPE html> | |
| 2 | +<html lang=""> | |
| 3 | + <head> | |
| 4 | + <meta charset="UTF-8"> | |
| 5 | + <link rel="icon" href="/favicon.ico"> | |
| 6 | + <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 7 | + <title>SplitApp — Group Travel Companion</title> | |
| 8 | + <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| 9 | + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| 10 | + <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet"> | |
| 11 | + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"> | |
| 12 | + </head> | |
| 13 | + <body> | |
| 14 | + <div id="app"></div> | |
| 15 | + <script type="module" src="/src/main.ts"></script> | |
| 16 | + </body> | |
| 17 | +</html> |
added package-lock.json +7673 −0
Line changes are not available for this file.
added package.json +58 −0
| @@ -0,0 +1,58 @@ | ||
| 1 | +{ | |
| 2 | + "name": "rasmju-js-a7", | |
| 3 | + "version": "0.0.0", | |
| 4 | + "private": true, | |
| 5 | + "type": "module", | |
| 6 | + "scripts": { | |
| 7 | + "dev": "vite", | |
| 8 | + "build": "run-p type-check \"build-only {@}\" --", | |
| 9 | + "preview": "vite preview", | |
| 10 | + "test:unit": "vitest", | |
| 11 | + "test:e2e": "playwright test", | |
| 12 | + "test:e2e:ui": "playwright test --ui", | |
| 13 | + "build-only": "vite build", | |
| 14 | + "type-check": "vue-tsc --build", | |
| 15 | + "lint": "run-s lint:*", | |
| 16 | + "lint:oxlint": "oxlint . --fix", | |
| 17 | + "lint:eslint": "eslint . --fix --cache", | |
| 18 | + "format": "prettier --write --experimental-cli src/" | |
| 19 | + }, | |
| 20 | + "dependencies": { | |
| 21 | + "axios": "^1.14.0", | |
| 22 | + "bootstrap": "^5.3.8", | |
| 23 | + "pinia": "^3.0.4", | |
| 24 | + "vue": "^3.5.31", | |
| 25 | + "vue-i18n": "^11.3.2", | |
| 26 | + "vue-router": "^5.0.4" | |
| 27 | + }, | |
| 28 | + "devDependencies": { | |
| 29 | + "@pinia/testing": "^1.0.3", | |
| 30 | + "@playwright/test": "^1.59.1", | |
| 31 | + "@tsconfig/node24": "^24.0.4", | |
| 32 | + "@types/jsdom": "^28.0.1", | |
| 33 | + "@types/node": "^24.12.0", | |
| 34 | + "@vitejs/plugin-vue": "^6.0.5", | |
| 35 | + "@vitest/eslint-plugin": "^1.6.13", | |
| 36 | + "@vue/eslint-config-typescript": "^14.7.0", | |
| 37 | + "@vue/test-utils": "^2.4.6", | |
| 38 | + "@vue/tsconfig": "^0.9.1", | |
| 39 | + "eslint": "^10.1.0", | |
| 40 | + "eslint-config-prettier": "^10.1.8", | |
| 41 | + "eslint-plugin-oxlint": "~1.57.0", | |
| 42 | + "eslint-plugin-vue": "~10.8.0", | |
| 43 | + "jiti": "^2.6.1", | |
| 44 | + "jsdom": "^29.0.1", | |
| 45 | + "msw": "^2.13.2", | |
| 46 | + "npm-run-all2": "^8.0.4", | |
| 47 | + "oxlint": "~1.57.0", | |
| 48 | + "prettier": "3.8.1", | |
| 49 | + "typescript": "~6.0.0", | |
| 50 | + "vite": "^8.0.3", | |
| 51 | + "vite-plugin-vue-devtools": "^8.1.1", | |
| 52 | + "vitest": "^4.1.2", | |
| 53 | + "vue-tsc": "^3.2.6" | |
| 54 | + }, | |
| 55 | + "engines": { | |
| 56 | + "node": "^20.19.0 || >=22.12.0" | |
| 57 | + } | |
| 58 | +} |
added playwright.config.ts +41 −0
| @@ -0,0 +1,41 @@ | ||
| 1 | +import { defineConfig, devices } from '@playwright/test' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Playwright e2e config. | |
| 5 | + * | |
| 6 | + * Prerequisites to run locally: | |
| 7 | + * 1. Backend + Postgres up on port 90 (docker compose up in rasmju-csweb-phase3) | |
| 8 | + * 2. `.env` has VITE_API_BASE_URL=http://localhost:90/api/v1/ | |
| 9 | + * | |
| 10 | + * Then: | |
| 11 | + * npm run test:e2e # headless | |
| 12 | + * npm run test:e2e:ui # interactive UI mode | |
| 13 | + * | |
| 14 | + * Playwright auto-starts the Vite dev server for you via `webServer` below, | |
| 15 | + * so you do NOT need to `npm run dev` in a separate terminal. | |
| 16 | + */ | |
| 17 | +export default defineConfig({ | |
| 18 | + testDir: './e2e', | |
| 19 | + fullyParallel: false, // shared backend seed data — keep tests serial | |
| 20 | + forbidOnly: !!process.env.CI, | |
| 21 | + retries: process.env.CI ? 1 : 0, | |
| 22 | + workers: 1, | |
| 23 | + reporter: [['list']], | |
| 24 | + use: { | |
| 25 | + baseURL: 'http://localhost:5173', | |
| 26 | + trace: 'on-first-retry', | |
| 27 | + screenshot: 'only-on-failure', | |
| 28 | + }, | |
| 29 | + projects: [ | |
| 30 | + { | |
| 31 | + name: 'chromium', | |
| 32 | + use: { ...devices['Desktop Chrome'] }, | |
| 33 | + }, | |
| 34 | + ], | |
| 35 | + webServer: { | |
| 36 | + command: 'npm run dev', | |
| 37 | + url: 'http://localhost:5173', | |
| 38 | + reuseExistingServer: !process.env.CI, | |
| 39 | + timeout: 60_000, | |
| 40 | + }, | |
| 41 | +}) |
added public/favicon.ico +0 −0
Line changes are not available for this file.
added src/App.vue +97 −0
| @@ -0,0 +1,97 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import { useAuthStore } from '@/stores/auth' | |
| 3 | +import { useRouter } from 'vue-router' | |
| 4 | +import { useI18n } from 'vue-i18n' | |
| 5 | +import AccountService from '@/services/AccountService' | |
| 6 | +import ToastContainer from '@/components/ToastContainer.vue' | |
| 7 | +import LangSwitcher from '@/components/LangSwitcher.vue' | |
| 8 | + | |
| 9 | +const authStore = useAuthStore() | |
| 10 | +const router = useRouter() | |
| 11 | +const { t } = useI18n() | |
| 12 | + | |
| 13 | +function userInitials() { | |
| 14 | + const name = authStore.userName || '' | |
| 15 | + const parts = name.split(' ') | |
| 16 | + if (parts.length >= 2) return (parts[0]![0]! + parts[1]![0]!).toUpperCase() | |
| 17 | + return name.substring(0, 2).toUpperCase() | |
| 18 | +} | |
| 19 | + | |
| 20 | +async function handleLogout() { | |
| 21 | + if (authStore.refreshToken) { | |
| 22 | + await AccountService.logoutAsync(authStore.refreshToken) | |
| 23 | + } | |
| 24 | + authStore.logout() | |
| 25 | + router.push({ name: 'Home' }) | |
| 26 | +} | |
| 27 | +</script> | |
| 28 | + | |
| 29 | +<template> | |
| 30 | + <nav class="navbar navbar-expand-lg sa-navbar"> | |
| 31 | + <div class="container"> | |
| 32 | + <RouterLink class="navbar-brand sa-navbar-brand" :to="{ name: 'Home' }"> | |
| 33 | + <span class="sa-navbar-logo-icon"> | |
| 34 | + <i class="bi bi-airplane-fill"></i> | |
| 35 | + </span> | |
| 36 | + SplitApp | |
| 37 | + </RouterLink> | |
| 38 | + | |
| 39 | + <button | |
| 40 | + class="navbar-toggler border-0" | |
| 41 | + type="button" | |
| 42 | + data-bs-toggle="collapse" | |
| 43 | + data-bs-target="#navbarNav" | |
| 44 | + > | |
| 45 | + <span class="navbar-toggler-icon"></span> | |
| 46 | + </button> | |
| 47 | + | |
| 48 | + <div class="collapse navbar-collapse" id="navbarNav"> | |
| 49 | + <ul class="navbar-nav me-auto" v-if="authStore.isAuthenticated"> | |
| 50 | + <li class="nav-item"> | |
| 51 | + <RouterLink class="nav-link" :to="{ name: 'TripsIndex' }"> | |
| 52 | + <i class="bi bi-suitcase-lg me-1"></i>{{ t('nav.trips') }} | |
| 53 | + </RouterLink> | |
| 54 | + </li> | |
| 55 | + </ul> | |
| 56 | + | |
| 57 | + <div class="navbar-nav ms-auto align-items-center"> | |
| 58 | + <LangSwitcher /> | |
| 59 | + <template v-if="authStore.isAuthenticated"> | |
| 60 | + <button class="sa-nav-avatar-btn" @click="handleLogout" :title="t('nav.logout')"> | |
| 61 | + <span class="sa-avatar sa-avatar-sm sa-avatar-1">{{ userInitials() }}</span> | |
| 62 | + <span>{{ authStore.userName }}</span> | |
| 63 | + <i class="bi bi-box-arrow-right ms-1"></i> | |
| 64 | + </button> | |
| 65 | + </template> | |
| 66 | + <div v-else class="sa-navbar-auth-btns"> | |
| 67 | + <RouterLink class="nav-link" :to="{ name: 'Login' }">{{ t('nav.signIn') }}</RouterLink> | |
| 68 | + <RouterLink class="sa-btn sa-btn-primary sa-btn-sm sa-btn-pill" :to="{ name: 'Register' }"> | |
| 69 | + {{ t('nav.getStarted') }} | |
| 70 | + </RouterLink> | |
| 71 | + </div> | |
| 72 | + </div> | |
| 73 | + </div> | |
| 74 | + </div> | |
| 75 | + </nav> | |
| 76 | + | |
| 77 | + <main class="container" style="padding-top: var(--sa-space-6); padding-bottom: var(--sa-space-8);"> | |
| 78 | + <RouterView /> | |
| 79 | + </main> | |
| 80 | + | |
| 81 | + <ToastContainer /> | |
| 82 | +</template> | |
| 83 | + | |
| 84 | +<style scoped> | |
| 85 | +.sa-navbar-logo-icon { | |
| 86 | + display: inline-flex; | |
| 87 | + align-items: center; | |
| 88 | + justify-content: center; | |
| 89 | + width: 32px; | |
| 90 | + height: 32px; | |
| 91 | + border-radius: 10px; | |
| 92 | + background: linear-gradient(135deg, #e8604c 0%, #d4456a 100%); | |
| 93 | + color: #fff; | |
| 94 | + font-size: 0.875rem; | |
| 95 | + margin-right: 8px; | |
| 96 | +} | |
| 97 | +</style> |
added src/__tests__/integration/token-refresh.spec.ts +157 −0
| @@ -0,0 +1,157 @@ | ||
| 1 | +/** | |
| 2 | + * Integration test: 401 → refresh → retry pipeline in httpClient. | |
| 3 | + * | |
| 4 | + * This exercises the most security-sensitive glue in the app: | |
| 5 | + * - request interceptor attaches Authorization header | |
| 6 | + * - response interceptor catches 401, calls AccountService.refreshTokenAsync, | |
| 7 | + * updates the auth store, and replays the original request | |
| 8 | + * - on refresh failure: calls AccountService.logoutAsync, wipes store, | |
| 9 | + * routes to /login | |
| 10 | + * | |
| 11 | + * We use MSW to intercept axios requests at the network layer so axios | |
| 12 | + * behaves exactly as in production — no method mocking. | |
| 13 | + */ | |
| 14 | + | |
| 15 | +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest' | |
| 16 | +import { setupServer } from 'msw/node' | |
| 17 | +import { http, HttpResponse } from 'msw' | |
| 18 | +import { setActivePinia, createPinia } from 'pinia' | |
| 19 | + | |
| 20 | +// Mock the router BEFORE importing httpClient — httpClient imports it eagerly | |
| 21 | +// and we need to assert .push() was called on 401-with-no-recovery. | |
| 22 | +// vi.mock is hoisted to the top of the file, so the factory cannot reference | |
| 23 | +// any lexical variable declared below. vi.hoisted() makes the spy available. | |
| 24 | +const { routerPush } = vi.hoisted(() => ({ routerPush: vi.fn() })) | |
| 25 | +vi.mock('@/router', () => ({ | |
| 26 | + default: { push: routerPush }, | |
| 27 | +})) | |
| 28 | + | |
| 29 | +// Now import modules under test (after the mock is set up). | |
| 30 | +import { useAuthStore } from '@/stores/auth' | |
| 31 | +import httpClient from '@/services/httpClient' | |
| 32 | + | |
| 33 | +const API = 'http://test.local/api/v1' | |
| 34 | + | |
| 35 | +// Track hits so each test can assert how many times a given endpoint was called. | |
| 36 | +const hits = { | |
| 37 | + trips: 0, | |
| 38 | + refresh: 0, | |
| 39 | + logout: 0, | |
| 40 | +} | |
| 41 | + | |
| 42 | +const server = setupServer( | |
| 43 | + http.get(`${API}/trips`, ({ request }) => { | |
| 44 | + hits.trips++ | |
| 45 | + const auth = request.headers.get('authorization') | |
| 46 | + if (auth === 'Bearer good-new-jwt') { | |
| 47 | + return HttpResponse.json([{ id: 't1', name: 'Barcelona' }]) | |
| 48 | + } | |
| 49 | + // Default: first request with stale jwt → 401 | |
| 50 | + return new HttpResponse(null, { status: 401 }) | |
| 51 | + }), | |
| 52 | + | |
| 53 | + http.post(`${API}/identity/Account/RefreshTokenData`, () => { | |
| 54 | + hits.refresh++ | |
| 55 | + return HttpResponse.json({ | |
| 56 | + jwt: 'good-new-jwt', | |
| 57 | + refreshToken: 'good-new-refresh', | |
| 58 | + firstName: 'Alice', | |
| 59 | + lastName: 'Alpha', | |
| 60 | + }) | |
| 61 | + }), | |
| 62 | + | |
| 63 | + http.post(`${API}/identity/Account/Logout`, () => { | |
| 64 | + hits.logout++ | |
| 65 | + return new HttpResponse(null, { status: 200 }) | |
| 66 | + }), | |
| 67 | +) | |
| 68 | + | |
| 69 | +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) | |
| 70 | +afterAll(() => server.close()) | |
| 71 | + | |
| 72 | +beforeEach(() => { | |
| 73 | + setActivePinia(createPinia()) | |
| 74 | + hits.trips = 0 | |
| 75 | + hits.refresh = 0 | |
| 76 | + hits.logout = 0 | |
| 77 | + routerPush.mockClear() | |
| 78 | +}) | |
| 79 | +afterEach(() => server.resetHandlers()) | |
| 80 | + | |
| 81 | +describe('httpClient — request interceptor', () => { | |
| 82 | + it('attaches the Authorization header from the auth store', async () => { | |
| 83 | + const store = useAuthStore() | |
| 84 | + store.jwt = 'good-new-jwt' | |
| 85 | + | |
| 86 | + const res = await httpClient.get('trips') | |
| 87 | + expect(res.status).toBe(200) | |
| 88 | + expect(res.data).toEqual([{ id: 't1', name: 'Barcelona' }]) | |
| 89 | + expect(hits.trips).toBe(1) | |
| 90 | + expect(hits.refresh).toBe(0) | |
| 91 | + }) | |
| 92 | +}) | |
| 93 | + | |
| 94 | +describe('httpClient — 401 refresh-retry flow', () => { | |
| 95 | + it('refreshes the token and replays the original request on 401', async () => { | |
| 96 | + const store = useAuthStore() | |
| 97 | + store.jwt = 'stale-jwt' | |
| 98 | + store.refreshToken = 'good-refresh' | |
| 99 | + | |
| 100 | + const res = await httpClient.get('trips') | |
| 101 | + | |
| 102 | + // 2 trip calls: first 401, retry 200. | |
| 103 | + expect(hits.trips).toBe(2) | |
| 104 | + expect(hits.refresh).toBe(1) | |
| 105 | + expect(res.data).toEqual([{ id: 't1', name: 'Barcelona' }]) | |
| 106 | + | |
| 107 | + // Store was updated with the new tokens. | |
| 108 | + expect(store.jwt).toBe('good-new-jwt') | |
| 109 | + expect(store.refreshToken).toBe('good-new-refresh') | |
| 110 | + expect(store.userName).toBe('Alice Alpha') | |
| 111 | + | |
| 112 | + // No logout, no redirect — recovery succeeded. | |
| 113 | + expect(hits.logout).toBe(0) | |
| 114 | + expect(routerPush).not.toHaveBeenCalled() | |
| 115 | + }) | |
| 116 | + | |
| 117 | + it('logs the user out and redirects when the refresh call fails', async () => { | |
| 118 | + // Override: refresh endpoint now returns 401 instead of new tokens. | |
| 119 | + server.use( | |
| 120 | + http.post(`${API}/identity/Account/RefreshTokenData`, () => { | |
| 121 | + hits.refresh++ | |
| 122 | + return new HttpResponse(null, { status: 401 }) | |
| 123 | + }), | |
| 124 | + ) | |
| 125 | + | |
| 126 | + const store = useAuthStore() | |
| 127 | + store.jwt = 'stale-jwt' | |
| 128 | + store.refreshToken = 'also-stale' | |
| 129 | + | |
| 130 | + await expect(httpClient.get('trips')).rejects.toMatchObject({ | |
| 131 | + response: { status: 401 }, | |
| 132 | + }) | |
| 133 | + | |
| 134 | + // Store cleared, logout called, router redirected. | |
| 135 | + expect(store.jwt).toBeNull() | |
| 136 | + expect(store.refreshToken).toBeNull() | |
| 137 | + expect(store.userName).toBeNull() | |
| 138 | + expect(hits.logout).toBe(1) | |
| 139 | + expect(routerPush).toHaveBeenCalledWith({ name: 'Login' }) | |
| 140 | + }) | |
| 141 | + | |
| 142 | + it('does not attempt refresh when no refresh token is present', async () => { | |
| 143 | + const store = useAuthStore() | |
| 144 | + store.jwt = 'stale-jwt' | |
| 145 | + // store.refreshToken stays null | |
| 146 | + | |
| 147 | + await expect(httpClient.get('trips')).rejects.toMatchObject({ | |
| 148 | + response: { status: 401 }, | |
| 149 | + }) | |
| 150 | + | |
| 151 | + expect(hits.refresh).toBe(0) | |
| 152 | + expect(hits.logout).toBe(0) | |
| 153 | + // logout() is still called client-side and router redirected. | |
| 154 | + expect(store.jwt).toBeNull() | |
| 155 | + expect(routerPush).toHaveBeenCalledWith({ name: 'Login' }) | |
| 156 | + }) | |
| 157 | +}) |
added src/__tests__/unit/SplitMethodSelector.spec.ts +204 −0
| @@ -0,0 +1,204 @@ | ||
| 1 | +import { describe, it, expect } from 'vitest' | |
| 2 | +import { mount } from '@vue/test-utils' | |
| 3 | +import SplitMethodSelector from '@/components/SplitMethodSelector.vue' | |
| 4 | +import type { ITripParticipant } from '@/types/ITrip' | |
| 5 | +import type { IExpenseSplitCreate } from '@/types/IExpense' | |
| 6 | + | |
| 7 | +function participant(id: string, name: string): ITripParticipant { | |
| 8 | + return { | |
| 9 | + id: `tp-${id}`, | |
| 10 | + tripId: 'trip-1', | |
| 11 | + userId: id, | |
| 12 | + userName: name, | |
| 13 | + userEmail: `${name.toLowerCase()}@t.ee`, | |
| 14 | + role: 'Participant', | |
| 15 | + nickname: null, | |
| 16 | + joinedAt: '2026-01-01', | |
| 17 | + isActive: true, | |
| 18 | + } | |
| 19 | +} | |
| 20 | + | |
| 21 | +const PARTICIPANTS: ITripParticipant[] = [ | |
| 22 | + participant('u1', 'Alice'), | |
| 23 | + participant('u2', 'Bob'), | |
| 24 | + participant('u3', 'Charlie'), | |
| 25 | +] | |
| 26 | + | |
| 27 | +// Grab the latest payload emitted for a given event, or null if never emitted. | |
| 28 | +function latestEmit<T>(wrapper: ReturnType<typeof mount>, event: string): T | null { | |
| 29 | + const calls = wrapper.emitted<[T]>(event) | |
| 30 | + if (!calls || calls.length === 0) return null | |
| 31 | + return calls[calls.length - 1]![0] | |
| 32 | +} | |
| 33 | + | |
| 34 | +describe('SplitMethodSelector — EqualAll', () => { | |
| 35 | + it('splits equally among every participant', () => { | |
| 36 | + const wrapper = mount(SplitMethodSelector, { | |
| 37 | + props: { | |
| 38 | + participants: PARTICIPANTS, | |
| 39 | + totalAmount: 90, | |
| 40 | + splitMethod: 'EqualAll', | |
| 41 | + }, | |
| 42 | + }) | |
| 43 | + | |
| 44 | + const splits = latestEmit<IExpenseSplitCreate[]>(wrapper, 'update:splits') | |
| 45 | + expect(splits).toHaveLength(3) | |
| 46 | + for (const s of splits!) { | |
| 47 | + expect(s.amount).toBe(30) | |
| 48 | + expect(s.percentage).toBeNull() | |
| 49 | + } | |
| 50 | + | |
| 51 | + expect(latestEmit<boolean>(wrapper, 'update:valid')).toBe(true) | |
| 52 | + }) | |
| 53 | + | |
| 54 | + it('is invalid when there are no participants', () => { | |
| 55 | + const wrapper = mount(SplitMethodSelector, { | |
| 56 | + props: { | |
| 57 | + participants: [], | |
| 58 | + totalAmount: 50, | |
| 59 | + splitMethod: 'EqualAll', | |
| 60 | + }, | |
| 61 | + }) | |
| 62 | + expect(latestEmit<boolean>(wrapper, 'update:valid')).toBe(false) | |
| 63 | + expect(latestEmit<IExpenseSplitCreate[]>(wrapper, 'update:splits')).toEqual([]) | |
| 64 | + }) | |
| 65 | +}) | |
| 66 | + | |
| 67 | +describe('SplitMethodSelector — EqualSubset', () => { | |
| 68 | + it('auto-selects every participant on first switch to EqualSubset', () => { | |
| 69 | + const wrapper = mount(SplitMethodSelector, { | |
| 70 | + props: { | |
| 71 | + participants: PARTICIPANTS, | |
| 72 | + totalAmount: 60, | |
| 73 | + splitMethod: 'EqualSubset', | |
| 74 | + }, | |
| 75 | + }) | |
| 76 | + | |
| 77 | + const splits = latestEmit<IExpenseSplitCreate[]>(wrapper, 'update:splits') | |
| 78 | + expect(splits).toHaveLength(3) | |
| 79 | + expect(splits!.map((s) => s.amount)).toEqual([20, 20, 20]) | |
| 80 | + expect(latestEmit<boolean>(wrapper, 'update:valid')).toBe(true) | |
| 81 | + }) | |
| 82 | + | |
| 83 | + it('toggling a checkbox off removes that user and redistributes', async () => { | |
| 84 | + const wrapper = mount(SplitMethodSelector, { | |
| 85 | + props: { | |
| 86 | + participants: PARTICIPANTS, | |
| 87 | + totalAmount: 60, | |
| 88 | + splitMethod: 'EqualSubset', | |
| 89 | + }, | |
| 90 | + }) | |
| 91 | + | |
| 92 | + // Deselect Bob by firing a change event on his hidden checkbox. | |
| 93 | + // (jsdom does not always propagate label clicks to the wrapped checkbox, | |
| 94 | + // so we dispatch on the input directly.) | |
| 95 | + const checkboxes = wrapper.findAll('input[type="checkbox"]') | |
| 96 | + await checkboxes[1]!.trigger('change') | |
| 97 | + | |
| 98 | + const splits = latestEmit<IExpenseSplitCreate[]>(wrapper, 'update:splits') | |
| 99 | + expect(splits).toHaveLength(2) | |
| 100 | + expect(splits!.map((s) => s.userId).sort()).toEqual(['u1', 'u3']) | |
| 101 | + expect(splits!.every((s) => s.amount === 30)).toBe(true) | |
| 102 | + }) | |
| 103 | +}) | |
| 104 | + | |
| 105 | +describe('SplitMethodSelector — ExactAmounts', () => { | |
| 106 | + it('is invalid until entered amounts match the total', async () => { | |
| 107 | + const wrapper = mount(SplitMethodSelector, { | |
| 108 | + props: { | |
| 109 | + participants: PARTICIPANTS, | |
| 110 | + totalAmount: 100, | |
| 111 | + splitMethod: 'ExactAmounts', | |
| 112 | + }, | |
| 113 | + }) | |
| 114 | + | |
| 115 | + // Initially all participants have amount 0 → not valid (0 ≠ 100). | |
| 116 | + expect(latestEmit<boolean>(wrapper, 'update:valid')).toBe(false) | |
| 117 | + | |
| 118 | + // Fill inputs so they sum to exactly 100. | |
| 119 | + const inputs = wrapper.findAll('input[type="number"]') | |
| 120 | + await inputs[0]!.setValue('50') | |
| 121 | + await inputs[1]!.setValue('30') | |
| 122 | + await inputs[2]!.setValue('20') | |
| 123 | + | |
| 124 | + expect(latestEmit<boolean>(wrapper, 'update:valid')).toBe(true) | |
| 125 | + const splits = latestEmit<IExpenseSplitCreate[]>(wrapper, 'update:splits') | |
| 126 | + expect(splits!.map((s) => s.amount)).toEqual([50, 30, 20]) | |
| 127 | + }) | |
| 128 | + | |
| 129 | + it('stays invalid when the sum differs from total', async () => { | |
| 130 | + const wrapper = mount(SplitMethodSelector, { | |
| 131 | + props: { | |
| 132 | + participants: PARTICIPANTS, | |
| 133 | + totalAmount: 100, | |
| 134 | + splitMethod: 'ExactAmounts', | |
| 135 | + }, | |
| 136 | + }) | |
| 137 | + const inputs = wrapper.findAll('input[type="number"]') | |
| 138 | + await inputs[0]!.setValue('50') | |
| 139 | + await inputs[1]!.setValue('30') | |
| 140 | + // Missing 20 → 80 ≠ 100 | |
| 141 | + expect(latestEmit<boolean>(wrapper, 'update:valid')).toBe(false) | |
| 142 | + }) | |
| 143 | +}) | |
| 144 | + | |
| 145 | +describe('SplitMethodSelector — Percentages', () => { | |
| 146 | + it('is valid only when percentages add up to 100', async () => { | |
| 147 | + const wrapper = mount(SplitMethodSelector, { | |
| 148 | + props: { | |
| 149 | + participants: PARTICIPANTS, | |
| 150 | + totalAmount: 200, | |
| 151 | + splitMethod: 'Percentages', | |
| 152 | + }, | |
| 153 | + }) | |
| 154 | + | |
| 155 | + expect(latestEmit<boolean>(wrapper, 'update:valid')).toBe(false) | |
| 156 | + | |
| 157 | + const inputs = wrapper.findAll('input[type="number"]') | |
| 158 | + await inputs[0]!.setValue('50') | |
| 159 | + await inputs[1]!.setValue('30') | |
| 160 | + await inputs[2]!.setValue('20') | |
| 161 | + | |
| 162 | + expect(latestEmit<boolean>(wrapper, 'update:valid')).toBe(true) | |
| 163 | + | |
| 164 | + const splits = latestEmit<IExpenseSplitCreate[]>(wrapper, 'update:splits') | |
| 165 | + expect(splits!.map((s) => s.amount)).toEqual([100, 60, 40]) | |
| 166 | + expect(splits!.map((s) => s.percentage)).toEqual([50, 30, 20]) | |
| 167 | + }) | |
| 168 | + | |
| 169 | + it('90% total is rejected', async () => { | |
| 170 | + const wrapper = mount(SplitMethodSelector, { | |
| 171 | + props: { | |
| 172 | + participants: PARTICIPANTS, | |
| 173 | + totalAmount: 100, | |
| 174 | + splitMethod: 'Percentages', | |
| 175 | + }, | |
| 176 | + }) | |
| 177 | + const inputs = wrapper.findAll('input[type="number"]') | |
| 178 | + await inputs[0]!.setValue('30') | |
| 179 | + await inputs[1]!.setValue('30') | |
| 180 | + await inputs[2]!.setValue('30') | |
| 181 | + expect(latestEmit<boolean>(wrapper, 'update:valid')).toBe(false) | |
| 182 | + }) | |
| 183 | +}) | |
| 184 | + | |
| 185 | +describe('SplitMethodSelector — existingSplits (edit mode)', () => { | |
| 186 | + it('pre-selects users from existingSplits for EqualSubset', () => { | |
| 187 | + const wrapper = mount(SplitMethodSelector, { | |
| 188 | + props: { | |
| 189 | + participants: PARTICIPANTS, | |
| 190 | + totalAmount: 80, | |
| 191 | + splitMethod: 'EqualSubset', | |
| 192 | + existingSplits: [ | |
| 193 | + { id: 's1', userId: 'u1', userName: 'Alice', amount: 40, percentage: null }, | |
| 194 | + { id: 's2', userId: 'u3', userName: 'Charlie', amount: 40, percentage: null }, | |
| 195 | + ], | |
| 196 | + }, | |
| 197 | + }) | |
| 198 | + | |
| 199 | + const splits = latestEmit<IExpenseSplitCreate[]>(wrapper, 'update:splits') | |
| 200 | + expect(splits).toHaveLength(2) | |
| 201 | + expect(splits!.map((s) => s.userId).sort()).toEqual(['u1', 'u3']) | |
| 202 | + expect(splits!.every((s) => s.amount === 40)).toBe(true) | |
| 203 | + }) | |
| 204 | +}) |
added src/__tests__/unit/auth-store.spec.ts +81 −0
| @@ -0,0 +1,81 @@ | ||
| 1 | +import { describe, it, expect, beforeEach } from 'vitest' | |
| 2 | +import { setActivePinia, createPinia } from 'pinia' | |
| 3 | +import { nextTick } from 'vue' | |
| 4 | +import { useAuthStore } from '@/stores/auth' | |
| 5 | + | |
| 6 | +describe('auth store', () => { | |
| 7 | + beforeEach(() => { | |
| 8 | + // Fresh Pinia + clean localStorage for each test. | |
| 9 | + // (vitest.setup.ts already clears localStorage, but we also need a fresh store.) | |
| 10 | + setActivePinia(createPinia()) | |
| 11 | + }) | |
| 12 | + | |
| 13 | + it('starts empty when localStorage has nothing', () => { | |
| 14 | + const store = useAuthStore() | |
| 15 | + expect(store.jwt).toBeNull() | |
| 16 | + expect(store.refreshToken).toBeNull() | |
| 17 | + expect(store.userName).toBeNull() | |
| 18 | + expect(store.isAuthenticated).toBe(false) | |
| 19 | + }) | |
| 20 | + | |
| 21 | + it('hydrates from localStorage on creation', () => { | |
| 22 | + localStorage.setItem('jwt', 'pre-existing-jwt') | |
| 23 | + localStorage.setItem('refreshToken', 'pre-existing-refresh') | |
| 24 | + localStorage.setItem('userName', 'Alice Alpha') | |
| 25 | + | |
| 26 | + const store = useAuthStore() | |
| 27 | + expect(store.jwt).toBe('pre-existing-jwt') | |
| 28 | + expect(store.refreshToken).toBe('pre-existing-refresh') | |
| 29 | + expect(store.userName).toBe('Alice Alpha') | |
| 30 | + expect(store.isAuthenticated).toBe(true) | |
| 31 | + }) | |
| 32 | + | |
| 33 | + it('isAuthenticated reflects whether jwt is present', () => { | |
| 34 | + const store = useAuthStore() | |
| 35 | + expect(store.isAuthenticated).toBe(false) | |
| 36 | + | |
| 37 | + store.jwt = 'new-jwt' | |
| 38 | + expect(store.isAuthenticated).toBe(true) | |
| 39 | + | |
| 40 | + store.jwt = null | |
| 41 | + expect(store.isAuthenticated).toBe(false) | |
| 42 | + }) | |
| 43 | + | |
| 44 | + it('syncs jwt changes to localStorage', async () => { | |
| 45 | + const store = useAuthStore() | |
| 46 | + store.jwt = 'token-abc' | |
| 47 | + await nextTick() | |
| 48 | + expect(localStorage.getItem('jwt')).toBe('token-abc') | |
| 49 | + | |
| 50 | + store.jwt = null | |
| 51 | + await nextTick() | |
| 52 | + expect(localStorage.getItem('jwt')).toBeNull() | |
| 53 | + }) | |
| 54 | + | |
| 55 | + it('syncs refreshToken and userName changes to localStorage', async () => { | |
| 56 | + const store = useAuthStore() | |
| 57 | + store.refreshToken = 'r1' | |
| 58 | + store.userName = 'Bob Beta' | |
| 59 | + await nextTick() | |
| 60 | + expect(localStorage.getItem('refreshToken')).toBe('r1') | |
| 61 | + expect(localStorage.getItem('userName')).toBe('Bob Beta') | |
| 62 | + }) | |
| 63 | + | |
| 64 | + it('logout() clears all three fields', async () => { | |
| 65 | + const store = useAuthStore() | |
| 66 | + store.jwt = 'j' | |
| 67 | + store.refreshToken = 'r' | |
| 68 | + store.userName = 'u' | |
| 69 | + | |
| 70 | + store.logout() | |
| 71 | + await nextTick() | |
| 72 | + | |
| 73 | + expect(store.jwt).toBeNull() | |
| 74 | + expect(store.refreshToken).toBeNull() | |
| 75 | + expect(store.userName).toBeNull() | |
| 76 | + expect(store.isAuthenticated).toBe(false) | |
| 77 | + expect(localStorage.getItem('jwt')).toBeNull() | |
| 78 | + expect(localStorage.getItem('refreshToken')).toBeNull() | |
| 79 | + expect(localStorage.getItem('userName')).toBeNull() | |
| 80 | + }) | |
| 81 | +}) |
added src/__tests__/unit/formatCurrency.spec.ts +34 −0
| @@ -0,0 +1,34 @@ | ||
| 1 | +import { describe, it, expect } from 'vitest' | |
| 2 | +import { formatCurrency } from '@/utils/formatCurrency' | |
| 3 | + | |
| 4 | +describe('formatCurrency', () => { | |
| 5 | + it('formats a positive number with default 2 decimals and no symbol', () => { | |
| 6 | + expect(formatCurrency(1234.5)).toBe('1,234.50') | |
| 7 | + }) | |
| 8 | + | |
| 9 | + it('prefixes the currency symbol when provided', () => { | |
| 10 | + expect(formatCurrency(99.5, '€')).toBe('€99.50') | |
| 11 | + expect(formatCurrency(10, '$')).toBe('$10.00') | |
| 12 | + }) | |
| 13 | + | |
| 14 | + it('handles zero', () => { | |
| 15 | + expect(formatCurrency(0, '€')).toBe('€0.00') | |
| 16 | + }) | |
| 17 | + | |
| 18 | + it('handles negative numbers — sign goes before the symbol', () => { | |
| 19 | + expect(formatCurrency(-42.5, '€')).toBe('-€42.50') | |
| 20 | + }) | |
| 21 | + | |
| 22 | + it('respects custom decimal count', () => { | |
| 23 | + expect(formatCurrency(10, '$', 0)).toBe('$10') | |
| 24 | + expect(formatCurrency(10.12345, '$', 4)).toBe('$10.1235') | |
| 25 | + }) | |
| 26 | + | |
| 27 | + it('ignores null symbol', () => { | |
| 28 | + expect(formatCurrency(5.25, null)).toBe('5.25') | |
| 29 | + }) | |
| 30 | + | |
| 31 | + it('uses thousands separators for large amounts', () => { | |
| 32 | + expect(formatCurrency(1_234_567.89, '$')).toBe('$1,234,567.89') | |
| 33 | + }) | |
| 34 | +}) |
added src/__tests__/unit/parseJwt.spec.ts +59 −0
| @@ -0,0 +1,59 @@ | ||
| 1 | +import { describe, it, expect } from 'vitest' | |
| 2 | +import { parseJwt, getUserIdFromJwt } from '@/utils/parseJwt' | |
| 3 | + | |
| 4 | +// Build a fake JWT: header.payload.signature where payload is base64url(JSON) | |
| 5 | +function makeJwt(payload: Record<string, unknown>): string { | |
| 6 | + const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) | |
| 7 | + const body = btoa(JSON.stringify(payload)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') | |
| 8 | + return `${header}.${body}.signature` | |
| 9 | +} | |
| 10 | + | |
| 11 | +describe('parseJwt', () => { | |
| 12 | + it('decodes the payload of a valid JWT', () => { | |
| 13 | + const token = makeJwt({ sub: 'user-1', email: 'alice@taltech.ee' }) | |
| 14 | + expect(parseJwt(token)).toEqual({ sub: 'user-1', email: 'alice@taltech.ee' }) | |
| 15 | + }) | |
| 16 | + | |
| 17 | + it('returns null for a garbage token', () => { | |
| 18 | + expect(parseJwt('not-a-jwt')).toBeNull() | |
| 19 | + }) | |
| 20 | + | |
| 21 | + it('returns null for a token without payload segment', () => { | |
| 22 | + expect(parseJwt('header')).toBeNull() | |
| 23 | + }) | |
| 24 | + | |
| 25 | + it('handles base64url-safe characters (- and _)', () => { | |
| 26 | + // {"x":"?>?"} in JSON → base64 contains '+/' which parseJwt must normalize | |
| 27 | + const token = makeJwt({ x: '?>?' }) | |
| 28 | + expect(parseJwt(token)).toEqual({ x: '?>?' }) | |
| 29 | + }) | |
| 30 | +}) | |
| 31 | + | |
| 32 | +describe('getUserIdFromJwt', () => { | |
| 33 | + const NAME_ID = | |
| 34 | + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier' | |
| 35 | + | |
| 36 | + it('returns the ASP.NET Core nameidentifier claim when present', () => { | |
| 37 | + const token = makeJwt({ [NAME_ID]: 'aspnet-id' }) | |
| 38 | + expect(getUserIdFromJwt(token)).toBe('aspnet-id') | |
| 39 | + }) | |
| 40 | + | |
| 41 | + it('falls back to `sub` when nameidentifier is missing', () => { | |
| 42 | + const token = makeJwt({ sub: 'sub-id' }) | |
| 43 | + expect(getUserIdFromJwt(token)).toBe('sub-id') | |
| 44 | + }) | |
| 45 | + | |
| 46 | + it('prefers nameidentifier over sub', () => { | |
| 47 | + const token = makeJwt({ [NAME_ID]: 'aspnet-id', sub: 'sub-id' }) | |
| 48 | + expect(getUserIdFromJwt(token)).toBe('aspnet-id') | |
| 49 | + }) | |
| 50 | + | |
| 51 | + it('returns null when both claims are missing', () => { | |
| 52 | + const token = makeJwt({ email: 'alice@taltech.ee' }) | |
| 53 | + expect(getUserIdFromJwt(token)).toBeNull() | |
| 54 | + }) | |
| 55 | + | |
| 56 | + it('returns null for an invalid token', () => { | |
| 57 | + expect(getUserIdFromJwt('broken')).toBeNull() | |
| 58 | + }) | |
| 59 | +}) |
added src/__tests__/vitest.setup.ts +22 −0
| @@ -0,0 +1,22 @@ | ||
| 1 | +// Global Vitest setup — runs before every test file. | |
| 2 | +// | |
| 3 | +// - Pins VITE_API_BASE_URL so services that import httpClient at module load | |
| 4 | +// have a stable URL for MSW to match against. | |
| 5 | +// - Registers the i18n plugin globally so any mounted component can call | |
| 6 | +// useI18n() without the test having to wire it up. | |
| 7 | +// - Clears localStorage between tests so the auth store starts from a clean slate. | |
| 8 | + | |
| 9 | +import { beforeEach } from 'vitest' | |
| 10 | +import { config } from '@vue/test-utils' | |
| 11 | + | |
| 12 | +// Stub env var before any module reads `import.meta.env.VITE_API_BASE_URL`. | |
| 13 | +// Vitest evaluates setupFiles before test file imports, so this is early enough. | |
| 14 | +;(import.meta.env as Record<string, string>).VITE_API_BASE_URL = 'http://test.local/api/v1/' | |
| 15 | + | |
| 16 | +// Lazy-import i18n so the env stub above wins for any module it transitively pulls in. | |
| 17 | +const { default: i18n } = await import('@/i18n') | |
| 18 | +config.global.plugins = [i18n] | |
| 19 | + | |
| 20 | +beforeEach(() => { | |
| 21 | + localStorage.clear() | |
| 22 | +}) |
added src/assets/splitapp-design.css +1566 −0
| @@ -0,0 +1,1566 @@ | ||
| 1 | +/* ============================================================ | |
| 2 | + SplitApp Design System | |
| 3 | + A premium travel app design layer on top of Bootstrap 5 | |
| 4 | + Ported from MVC backend for Vue frontend | |
| 5 | + ============================================================ */ | |
| 6 | + | |
| 7 | +/* ---------- CSS Custom Properties ---------- */ | |
| 8 | +:root { | |
| 9 | + /* Primary: Coral-to-Rose */ | |
| 10 | + --sa-primary: #e8604c; | |
| 11 | + --sa-primary-light: #ff7e6b; | |
| 12 | + --sa-primary-dark: #c94535; | |
| 13 | + --sa-primary-gradient: linear-gradient(135deg, #e8604c 0%, #d4456a 100%); | |
| 14 | + | |
| 15 | + /* Secondary: Ocean Teal */ | |
| 16 | + --sa-secondary: #1a9e8f; | |
| 17 | + --sa-secondary-light: #2ec4b6; | |
| 18 | + --sa-secondary-dark: #147a6e; | |
| 19 | + --sa-secondary-gradient: linear-gradient(135deg, #1a9e8f 0%, #2176ae 100%); | |
| 20 | + | |
| 21 | + /* Accent: Golden Amber */ | |
| 22 | + --sa-accent: #f4a623; | |
| 23 | + --sa-accent-light: #ffc857; | |
| 24 | + --sa-accent-dark: #d48e15; | |
| 25 | + | |
| 26 | + /* Neutrals: Warm Grays */ | |
| 27 | + --sa-gray-50: #faf9f7; | |
| 28 | + --sa-gray-100: #f3f1ed; | |
| 29 | + --sa-gray-200: #e8e5df; | |
| 30 | + --sa-gray-300: #d4d0c8; | |
| 31 | + --sa-gray-400: #a8a29e; | |
| 32 | + --sa-gray-500: #78716c; | |
| 33 | + --sa-gray-600: #57534e; | |
| 34 | + --sa-gray-700: #44403c; | |
| 35 | + --sa-gray-800: #292524; | |
| 36 | + --sa-gray-900: #1c1917; | |
| 37 | + | |
| 38 | + /* Semantic */ | |
| 39 | + --sa-success: #22c55e; | |
| 40 | + --sa-success-light: #dcfce7; | |
| 41 | + --sa-warning: #f59e0b; | |
| 42 | + --sa-warning-light: #fef3c7; | |
| 43 | + --sa-danger: #ef4444; | |
| 44 | + --sa-danger-light: #fee2e2; | |
| 45 | + --sa-info: #3b82f6; | |
| 46 | + --sa-info-light: #dbeafe; | |
| 47 | + | |
| 48 | + /* Spacing (4px base) */ | |
| 49 | + --sa-space-1: 4px; | |
| 50 | + --sa-space-2: 8px; | |
| 51 | + --sa-space-3: 12px; | |
| 52 | + --sa-space-4: 16px; | |
| 53 | + --sa-space-5: 20px; | |
| 54 | + --sa-space-6: 24px; | |
| 55 | + --sa-space-8: 32px; | |
| 56 | + --sa-space-10: 40px; | |
| 57 | + --sa-space-12: 48px; | |
| 58 | + --sa-space-16: 64px; | |
| 59 | + | |
| 60 | + /* Border Radius */ | |
| 61 | + --sa-radius-sm: 6px; | |
| 62 | + --sa-radius-md: 12px; | |
| 63 | + --sa-radius-lg: 16px; | |
| 64 | + --sa-radius-xl: 24px; | |
| 65 | + --sa-radius-full: 9999px; | |
| 66 | + | |
| 67 | + /* Shadows (warm-tinted with coral accent) */ | |
| 68 | + --sa-shadow-sm: 0 1px 3px rgba(28, 25, 23, 0.06), 0 1px 2px rgba(28, 25, 23, 0.04); | |
| 69 | + --sa-shadow-md: 0 4px 12px rgba(28, 25, 23, 0.08), 0 2px 4px rgba(28, 25, 23, 0.04); | |
| 70 | + --sa-shadow-lg: 0 12px 32px rgba(28, 25, 23, 0.1), 0 4px 8px rgba(28, 25, 23, 0.06); | |
| 71 | + --sa-shadow-xl: 0 20px 48px rgba(28, 25, 23, 0.14), 0 8px 16px rgba(28, 25, 23, 0.06); | |
| 72 | + --sa-shadow-glass: 0 8px 32px rgba(232, 96, 76, 0.08), 0 2px 8px rgba(28, 25, 23, 0.06); | |
| 73 | + | |
| 74 | + /* Transitions */ | |
| 75 | + --sa-transition-fast: 150ms ease; | |
| 76 | + --sa-transition-normal: 250ms ease; | |
| 77 | + --sa-transition-slow: 400ms cubic-bezier(0.4, 0, 0.2, 1); | |
| 78 | + | |
| 79 | + /* Typography */ | |
| 80 | + --sa-font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; | |
| 81 | + --sa-font-mono: 'JetBrains Mono', 'Fira Code', monospace; | |
| 82 | +} | |
| 83 | + | |
| 84 | +/* ---------- Base Overrides ---------- */ | |
| 85 | +html { | |
| 86 | + scroll-behavior: smooth; | |
| 87 | +} | |
| 88 | + | |
| 89 | +body { | |
| 90 | + font-family: var(--sa-font-sans); | |
| 91 | + background: var(--sa-gray-50); | |
| 92 | + background-image: radial-gradient(at 20% 0%, rgba(232, 96, 76, 0.04) 0%, transparent 60%), | |
| 93 | + radial-gradient(at 80% 100%, rgba(26, 158, 143, 0.04) 0%, transparent 60%); | |
| 94 | + color: var(--sa-gray-800); | |
| 95 | + font-size: 15px; | |
| 96 | + -webkit-font-smoothing: antialiased; | |
| 97 | + -moz-osx-font-smoothing: grayscale; | |
| 98 | + margin-bottom: 0; | |
| 99 | + min-height: 100vh; | |
| 100 | +} | |
| 101 | + | |
| 102 | +h1, h2, h3, h4, h5, h6 { | |
| 103 | + color: var(--sa-gray-900); | |
| 104 | + letter-spacing: -0.02em; | |
| 105 | +} | |
| 106 | + | |
| 107 | +h1 { font-size: 1.875rem; font-weight: 800; } | |
| 108 | +h2 { font-size: 1.5rem; font-weight: 700; } | |
| 109 | +h3 { font-size: 1.25rem; font-weight: 600; } | |
| 110 | + | |
| 111 | +a { | |
| 112 | + color: var(--sa-secondary); | |
| 113 | + text-decoration: none; | |
| 114 | + transition: color var(--sa-transition-fast); | |
| 115 | +} | |
| 116 | + | |
| 117 | +a:hover { | |
| 118 | + color: var(--sa-secondary-dark); | |
| 119 | +} | |
| 120 | + | |
| 121 | +/* Focus rings */ | |
| 122 | +:focus-visible { | |
| 123 | + outline: 2px solid var(--sa-secondary); | |
| 124 | + outline-offset: 2px; | |
| 125 | + box-shadow: none; | |
| 126 | +} | |
| 127 | + | |
| 128 | +.btn:focus-visible, .form-control:focus-visible, .form-select:focus-visible, .form-check-input:focus-visible { | |
| 129 | + box-shadow: 0 0 0 3px rgba(26, 158, 143, 0.25); | |
| 130 | + border-color: var(--sa-secondary); | |
| 131 | +} | |
| 132 | + | |
| 133 | +/* ---------- Form Enhancements ---------- */ | |
| 134 | +.form-control, .form-select { | |
| 135 | + border-radius: var(--sa-radius-sm); | |
| 136 | + border: 1.5px solid var(--sa-gray-200); | |
| 137 | + padding: 10px 14px; | |
| 138 | + font-size: 0.938rem; | |
| 139 | + transition: border-color var(--sa-transition-fast), box-shadow var(--sa-transition-fast); | |
| 140 | + background-color: #fff; | |
| 141 | +} | |
| 142 | + | |
| 143 | +.form-control:focus, .form-select:focus { | |
| 144 | + border-color: var(--sa-secondary); | |
| 145 | + box-shadow: 0 0 0 3px rgba(26, 158, 143, 0.15); | |
| 146 | +} | |
| 147 | + | |
| 148 | +.form-label { | |
| 149 | + font-weight: 600; | |
| 150 | + font-size: 0.85rem; | |
| 151 | + color: var(--sa-gray-600); | |
| 152 | + text-transform: uppercase; | |
| 153 | + letter-spacing: 0.04em; | |
| 154 | + margin-bottom: 6px; | |
| 155 | +} | |
| 156 | + | |
| 157 | +.form-check-input:checked { | |
| 158 | + background-color: var(--sa-secondary); | |
| 159 | + border-color: var(--sa-secondary); | |
| 160 | +} | |
| 161 | + | |
| 162 | +/* ---------- sa-card ---------- */ | |
| 163 | +.sa-card { | |
| 164 | + background: rgba(255, 255, 255, 0.72); | |
| 165 | + backdrop-filter: blur(16px); | |
| 166 | + -webkit-backdrop-filter: blur(16px); | |
| 167 | + border-radius: var(--sa-radius-lg); | |
| 168 | + box-shadow: var(--sa-shadow-glass); | |
| 169 | + border: 1px solid rgba(255, 255, 255, 0.3); | |
| 170 | + overflow: hidden; | |
| 171 | + transition: transform var(--sa-transition-normal), box-shadow var(--sa-transition-normal); | |
| 172 | +} | |
| 173 | + | |
| 174 | +.sa-card:hover { | |
| 175 | + transform: translateY(-2px); | |
| 176 | + box-shadow: var(--sa-shadow-lg); | |
| 177 | +} | |
| 178 | + | |
| 179 | +.sa-card-static { | |
| 180 | + background: rgba(255, 255, 255, 0.72); | |
| 181 | + backdrop-filter: blur(16px); | |
| 182 | + -webkit-backdrop-filter: blur(16px); | |
| 183 | + border-radius: var(--sa-radius-lg); | |
| 184 | + box-shadow: var(--sa-shadow-glass); | |
| 185 | + border: 1px solid rgba(255, 255, 255, 0.3); | |
| 186 | + overflow: hidden; | |
| 187 | +} | |
| 188 | + | |
| 189 | +.sa-card-body { | |
| 190 | + padding: var(--sa-space-6); | |
| 191 | +} | |
| 192 | + | |
| 193 | +.sa-card-header { | |
| 194 | + padding: var(--sa-space-5) var(--sa-space-6); | |
| 195 | + border-bottom: 1px solid var(--sa-gray-100); | |
| 196 | + font-weight: 600; | |
| 197 | +} | |
| 198 | + | |
| 199 | +.sa-card-footer { | |
| 200 | + padding: var(--sa-space-4) var(--sa-space-6); | |
| 201 | + border-top: 1px solid var(--sa-gray-100); | |
| 202 | + background: var(--sa-gray-50); | |
| 203 | +} | |
| 204 | + | |
| 205 | +.sa-card-accent { | |
| 206 | + border-top: 3px solid; | |
| 207 | +} | |
| 208 | + | |
| 209 | +.sa-card-accent-primary { border-top-color: var(--sa-primary); } | |
| 210 | +.sa-card-accent-secondary { border-top-color: var(--sa-secondary); } | |
| 211 | +.sa-card-accent-accent { border-top-color: var(--sa-accent); } | |
| 212 | +.sa-card-accent-danger { border-top-color: var(--sa-danger); } | |
| 213 | + | |
| 214 | +/* Colored gradient strip on top */ | |
| 215 | +.sa-card-gradient-strip { | |
| 216 | + height: 4px; | |
| 217 | + background: var(--sa-primary-gradient); | |
| 218 | +} | |
| 219 | + | |
| 220 | +.sa-card-gradient-strip-teal { | |
| 221 | + background: var(--sa-secondary-gradient); | |
| 222 | +} | |
| 223 | + | |
| 224 | +.sa-card-gradient-strip-amber { | |
| 225 | + background: linear-gradient(135deg, var(--sa-accent) 0%, var(--sa-primary) 100%); | |
| 226 | +} | |
| 227 | + | |
| 228 | +/* ---------- Buttons ---------- */ | |
| 229 | +.sa-btn { | |
| 230 | + display: inline-flex; | |
| 231 | + align-items: center; | |
| 232 | + justify-content: center; | |
| 233 | + gap: 8px; | |
| 234 | + padding: 10px 20px; | |
| 235 | + font-weight: 600; | |
| 236 | + font-size: 0.938rem; | |
| 237 | + border-radius: 12px; | |
| 238 | + border: none; | |
| 239 | + cursor: pointer; | |
| 240 | + transition: all 0.2s ease; | |
| 241 | + text-decoration: none; | |
| 242 | + line-height: 1.4; | |
| 243 | +} | |
| 244 | + | |
| 245 | +.sa-btn:hover { | |
| 246 | + transform: translateY(-1px); | |
| 247 | + text-decoration: none; | |
| 248 | +} | |
| 249 | + | |
| 250 | +.sa-btn:active { | |
| 251 | + transform: translateY(0); | |
| 252 | +} | |
| 253 | + | |
| 254 | +.sa-btn-primary { | |
| 255 | + background: var(--sa-primary-gradient); | |
| 256 | + color: #fff; | |
| 257 | + box-shadow: 0 2px 8px rgba(232, 96, 76, 0.3); | |
| 258 | +} | |
| 259 | + | |
| 260 | +.sa-btn-primary:hover { | |
| 261 | + background: linear-gradient(135deg, #f07060 0%, #de5578 100%); | |
| 262 | + box-shadow: 0 6px 20px rgba(232, 96, 76, 0.45); | |
| 263 | + transform: translateY(-1px); | |
| 264 | + color: #fff; | |
| 265 | +} | |
| 266 | + | |
| 267 | +.sa-btn-secondary { | |
| 268 | + background: var(--sa-secondary-gradient); | |
| 269 | + color: #fff; | |
| 270 | + box-shadow: 0 2px 8px rgba(26, 158, 143, 0.3); | |
| 271 | +} | |
| 272 | + | |
| 273 | +.sa-btn-secondary:hover { | |
| 274 | + background: linear-gradient(135deg, #20b0a0 0%, #2986be 100%); | |
| 275 | + box-shadow: 0 6px 20px rgba(26, 158, 143, 0.45); | |
| 276 | + transform: translateY(-1px); | |
| 277 | + color: #fff; | |
| 278 | +} | |
| 279 | + | |
| 280 | +.sa-btn-accent { | |
| 281 | + background: linear-gradient(135deg, var(--sa-accent) 0%, var(--sa-accent-dark) 100%); | |
| 282 | + color: #fff; | |
| 283 | + box-shadow: 0 2px 8px rgba(244, 166, 35, 0.3); | |
| 284 | +} | |
| 285 | + | |
| 286 | +.sa-btn-ghost { | |
| 287 | + background: transparent; | |
| 288 | + color: var(--sa-gray-700); | |
| 289 | + border: 1.5px solid var(--sa-gray-300); | |
| 290 | +} | |
| 291 | + | |
| 292 | +.sa-btn-ghost:hover { | |
| 293 | + background: var(--sa-gray-100); | |
| 294 | + color: var(--sa-gray-900); | |
| 295 | + border-color: var(--sa-gray-400); | |
| 296 | +} | |
| 297 | + | |
| 298 | +.sa-btn-danger { | |
| 299 | + background: linear-gradient(135deg, var(--sa-danger) 0%, #dc2626 100%); | |
| 300 | + color: #fff; | |
| 301 | +} | |
| 302 | + | |
| 303 | +.sa-btn-success { | |
| 304 | + background: linear-gradient(135deg, var(--sa-success) 0%, #16a34a 100%); | |
| 305 | + color: #fff; | |
| 306 | +} | |
| 307 | + | |
| 308 | +.sa-btn-sm { | |
| 309 | + padding: 6px 14px; | |
| 310 | + font-size: 0.813rem; | |
| 311 | +} | |
| 312 | + | |
| 313 | +.sa-btn-lg { | |
| 314 | + padding: 14px 28px; | |
| 315 | + font-size: 1.063rem; | |
| 316 | +} | |
| 317 | + | |
| 318 | +.sa-btn-pill { | |
| 319 | + border-radius: var(--sa-radius-full); | |
| 320 | +} | |
| 321 | + | |
| 322 | +.sa-btn-icon { | |
| 323 | + width: 40px; | |
| 324 | + height: 40px; | |
| 325 | + padding: 0; | |
| 326 | + border-radius: var(--sa-radius-full); | |
| 327 | + font-size: 1.1rem; | |
| 328 | +} | |
| 329 | + | |
| 330 | +.sa-btn-icon.sa-btn-sm { | |
| 331 | + width: 32px; | |
| 332 | + height: 32px; | |
| 333 | + font-size: 0.9rem; | |
| 334 | +} | |
| 335 | + | |
| 336 | +/* Loading state */ | |
| 337 | +.sa-btn-loading { | |
| 338 | + position: relative; | |
| 339 | + pointer-events: none; | |
| 340 | + opacity: 0.75; | |
| 341 | +} | |
| 342 | + | |
| 343 | +.sa-btn-loading::after { | |
| 344 | + content: ''; | |
| 345 | + position: absolute; | |
| 346 | + width: 16px; | |
| 347 | + height: 16px; | |
| 348 | + border: 2px solid transparent; | |
| 349 | + border-top-color: currentColor; | |
| 350 | + border-radius: 50%; | |
| 351 | + animation: sa-spin 0.6s linear infinite; | |
| 352 | +} | |
| 353 | + | |
| 354 | +/* ---------- Badges ---------- */ | |
| 355 | +.sa-badge { | |
| 356 | + display: inline-flex; | |
| 357 | + align-items: center; | |
| 358 | + gap: 4px; | |
| 359 | + padding: 3px 10px; | |
| 360 | + font-size: 0.75rem; | |
| 361 | + font-weight: 600; | |
| 362 | + border-radius: var(--sa-radius-full); | |
| 363 | + letter-spacing: 0.02em; | |
| 364 | +} | |
| 365 | + | |
| 366 | +.sa-badge-primary { background: rgba(232, 96, 76, 0.12); color: var(--sa-primary-dark); } | |
| 367 | +.sa-badge-secondary { background: rgba(26, 158, 143, 0.12); color: var(--sa-secondary-dark); } | |
| 368 | +.sa-badge-accent { background: rgba(244, 166, 35, 0.12); color: var(--sa-accent-dark); } | |
| 369 | +.sa-badge-success { background: var(--sa-success-light); color: #15803d; } | |
| 370 | +.sa-badge-warning { background: var(--sa-warning-light); color: #92400e; } | |
| 371 | +.sa-badge-danger { background: var(--sa-danger-light); color: #dc2626; } | |
| 372 | +.sa-badge-info { background: var(--sa-info-light); color: #1d4ed8; } | |
| 373 | +.sa-badge-neutral { background: var(--sa-gray-100); color: var(--sa-gray-600); } | |
| 374 | + | |
| 375 | +.sa-badge-solid-success { background: var(--sa-success); color: #fff; } | |
| 376 | +.sa-badge-solid-warning { background: var(--sa-warning); color: #fff; } | |
| 377 | +.sa-badge-solid-danger { background: var(--sa-danger); color: #fff; } | |
| 378 | +.sa-badge-solid-info { background: var(--sa-info); color: #fff; } | |
| 379 | +.sa-badge-solid-secondary { background: var(--sa-secondary); color: #fff; } | |
| 380 | +.sa-badge-solid-neutral { background: var(--sa-gray-400); color: #fff; } | |
| 381 | + | |
| 382 | +/* ---------- Progress Bar ---------- */ | |
| 383 | +.sa-progress { | |
| 384 | + height: 10px; | |
| 385 | + background: var(--sa-gray-100); | |
| 386 | + border-radius: var(--sa-radius-full); | |
| 387 | + overflow: hidden; | |
| 388 | +} | |
| 389 | + | |
| 390 | +.sa-progress-bar { | |
| 391 | + height: 100%; | |
| 392 | + border-radius: var(--sa-radius-full); | |
| 393 | + transition: width 1s cubic-bezier(0.4, 0, 0.2, 1); | |
| 394 | + background: var(--sa-secondary-gradient); | |
| 395 | +} | |
| 396 | + | |
| 397 | +.sa-progress-bar-success { background: linear-gradient(90deg, #22c55e 0%, #16a34a 100%); } | |
| 398 | +.sa-progress-bar-warning { background: linear-gradient(90deg, #f59e0b 0%, #d97706 100%); } | |
| 399 | +.sa-progress-bar-danger { background: linear-gradient(90deg, #ef4444 0%, #dc2626 100%); } | |
| 400 | +.sa-progress-bar-primary { background: var(--sa-primary-gradient); } | |
| 401 | + | |
| 402 | +.sa-progress-lg { height: 16px; } | |
| 403 | +.sa-progress-sm { height: 6px; } | |
| 404 | + | |
| 405 | +/* ---------- Avatar ---------- */ | |
| 406 | +.sa-avatar { | |
| 407 | + display: inline-flex; | |
| 408 | + align-items: center; | |
| 409 | + justify-content: center; | |
| 410 | + width: 40px; | |
| 411 | + height: 40px; | |
| 412 | + border-radius: 50%; | |
| 413 | + font-weight: 700; | |
| 414 | + font-size: 0.875rem; | |
| 415 | + color: #fff; | |
| 416 | + text-transform: uppercase; | |
| 417 | + flex-shrink: 0; | |
| 418 | + border: 2px solid #fff; | |
| 419 | + box-shadow: 0 1px 3px rgba(0,0,0,0.1); | |
| 420 | +} | |
| 421 | + | |
| 422 | +.sa-avatar-sm { width: 32px; height: 32px; font-size: 0.75rem; } | |
| 423 | +.sa-avatar-lg { width: 52px; height: 52px; font-size: 1.1rem; } | |
| 424 | +.sa-avatar-xl { width: 64px; height: 64px; font-size: 1.3rem; } | |
| 425 | + | |
| 426 | +/* Avatar color palette */ | |
| 427 | +.sa-avatar-1 { background: #e8604c; } | |
| 428 | +.sa-avatar-2 { background: #1a9e8f; } | |
| 429 | +.sa-avatar-3 { background: #6366f1; } | |
| 430 | +.sa-avatar-4 { background: #f59e0b; } | |
| 431 | +.sa-avatar-5 { background: #ec4899; } | |
| 432 | +.sa-avatar-6 { background: #14b8a6; } | |
| 433 | +.sa-avatar-7 { background: #8b5cf6; } | |
| 434 | +.sa-avatar-8 { background: #f97316; } | |
| 435 | + | |
| 436 | +/* Avatar stack (overlapping) */ | |
| 437 | +.sa-avatar-stack { | |
| 438 | + display: flex; | |
| 439 | +} | |
| 440 | + | |
| 441 | +.sa-avatar-stack .sa-avatar { | |
| 442 | + margin-left: -10px; | |
| 443 | +} | |
| 444 | + | |
| 445 | +.sa-avatar-stack .sa-avatar:first-child { | |
| 446 | + margin-left: 0; | |
| 447 | +} | |
| 448 | + | |
| 449 | +.sa-avatar-overflow { | |
| 450 | + background: var(--sa-gray-200); | |
| 451 | + color: var(--sa-gray-600); | |
| 452 | + font-size: 0.7rem; | |
| 453 | +} | |
| 454 | + | |
| 455 | +/* ---------- Stats ---------- */ | |
| 456 | +.sa-stat { | |
| 457 | + text-align: center; | |
| 458 | + padding: var(--sa-space-5); | |
| 459 | +} | |
| 460 | + | |
| 461 | +.sa-stat-value { | |
| 462 | + font-size: 1.75rem; | |
| 463 | + font-weight: 800; | |
| 464 | + line-height: 1.2; | |
| 465 | + color: var(--sa-gray-900); | |
| 466 | + letter-spacing: -0.03em; | |
| 467 | + animation: sa-counter 0.6s ease-out both; | |
| 468 | +} | |
| 469 | + | |
| 470 | +.sa-stat-label { | |
| 471 | + font-size: 0.8rem; | |
| 472 | + font-weight: 500; | |
| 473 | + color: var(--sa-gray-500); | |
| 474 | + text-transform: uppercase; | |
| 475 | + letter-spacing: 0.06em; | |
| 476 | + margin-top: 4px; | |
| 477 | +} | |
| 478 | + | |
| 479 | +.sa-stat-icon { | |
| 480 | + font-size: 1.5rem; | |
| 481 | + margin-bottom: 8px; | |
| 482 | + opacity: 0.8; | |
| 483 | +} | |
| 484 | + | |
| 485 | +/* ---------- Empty State ---------- */ | |
| 486 | +.sa-empty { | |
| 487 | + text-align: center; | |
| 488 | + padding: var(--sa-space-16) var(--sa-space-6); | |
| 489 | +} | |
| 490 | + | |
| 491 | +.sa-empty-icon { | |
| 492 | + font-size: 3.5rem; | |
| 493 | + color: var(--sa-gray-300); | |
| 494 | + margin-bottom: var(--sa-space-4); | |
| 495 | +} | |
| 496 | + | |
| 497 | +.sa-empty-title { | |
| 498 | + font-size: 1.25rem; | |
| 499 | + font-weight: 700; | |
| 500 | + color: var(--sa-gray-700); | |
| 501 | + margin-bottom: var(--sa-space-2); | |
| 502 | +} | |
| 503 | + | |
| 504 | +.sa-empty-text { | |
| 505 | + font-size: 0.938rem; | |
| 506 | + color: var(--sa-gray-500); | |
| 507 | + margin-bottom: var(--sa-space-6); | |
| 508 | + max-width: 360px; | |
| 509 | + margin-left: auto; | |
| 510 | + margin-right: auto; | |
| 511 | +} | |
| 512 | + | |
| 513 | +/* ---------- Gradient Header ---------- */ | |
| 514 | +.sa-gradient-header { | |
| 515 | + background: linear-gradient(135deg, #e8604c 0%, #d4456a 40%, #1a9e8f 100%); | |
| 516 | + color: #fff; | |
| 517 | + padding: 32px 28px; | |
| 518 | + margin: -1rem -0.75rem var(--sa-space-8); | |
| 519 | + border-radius: var(--sa-radius-lg); | |
| 520 | + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.15); | |
| 521 | +} | |
| 522 | + | |
| 523 | +.sa-gradient-header-coral { | |
| 524 | + background: var(--sa-primary-gradient); | |
| 525 | +} | |
| 526 | + | |
| 527 | +.sa-gradient-header-green { | |
| 528 | + background: linear-gradient(135deg, #22c55e 0%, #1a9e8f 100%); | |
| 529 | +} | |
| 530 | + | |
| 531 | +.sa-gradient-header-purple { | |
| 532 | + background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); | |
| 533 | +} | |
| 534 | + | |
| 535 | +.sa-gradient-header-blue { | |
| 536 | + background: linear-gradient(135deg, #3b82f6 0%, #2176ae 100%); | |
| 537 | +} | |
| 538 | + | |
| 539 | +.sa-gradient-header-accent { | |
| 540 | + background: linear-gradient(135deg, var(--sa-accent) 0%, #f97316 100%); | |
| 541 | +} | |
| 542 | + | |
| 543 | +.sa-gradient-header h1, | |
| 544 | +.sa-gradient-header h2, | |
| 545 | +.sa-gradient-header h3, | |
| 546 | +.sa-gradient-header h4 { | |
| 547 | + color: #fff; | |
| 548 | +} | |
| 549 | + | |
| 550 | +.sa-gradient-header .sa-badge { | |
| 551 | + background: rgba(255,255,255,0.2); | |
| 552 | + color: #fff; | |
| 553 | +} | |
| 554 | + | |
| 555 | +.sa-gradient-header .text-muted { | |
| 556 | + color: rgba(255,255,255,0.8) !important; | |
| 557 | +} | |
| 558 | + | |
| 559 | +/* ---------- Navigation Card ---------- */ | |
| 560 | +.sa-nav-card { | |
| 561 | + display: flex; | |
| 562 | + flex-direction: column; | |
| 563 | + align-items: center; | |
| 564 | + gap: 8px; | |
| 565 | + padding: var(--sa-space-5) var(--sa-space-4); | |
| 566 | + background: rgba(255, 255, 255, 0.72); | |
| 567 | + backdrop-filter: blur(16px); | |
| 568 | + -webkit-backdrop-filter: blur(16px); | |
| 569 | + border-radius: var(--sa-radius-lg); | |
| 570 | + box-shadow: var(--sa-shadow-glass); | |
| 571 | + border: 1px solid rgba(255, 255, 255, 0.3); | |
| 572 | + text-decoration: none; | |
| 573 | + color: var(--sa-gray-700); | |
| 574 | + transition: all var(--sa-transition-normal); | |
| 575 | + text-align: center; | |
| 576 | + cursor: pointer; | |
| 577 | +} | |
| 578 | + | |
| 579 | +.sa-nav-card:hover { | |
| 580 | + transform: translateY(-3px); | |
| 581 | + box-shadow: var(--sa-shadow-md); | |
| 582 | + color: var(--sa-gray-900); | |
| 583 | + text-decoration: none; | |
| 584 | +} | |
| 585 | + | |
| 586 | +.sa-nav-card-icon { | |
| 587 | + width: 48px; | |
| 588 | + height: 48px; | |
| 589 | + border-radius: var(--sa-radius-md); | |
| 590 | + display: flex; | |
| 591 | + align-items: center; | |
| 592 | + justify-content: center; | |
| 593 | + font-size: 1.4rem; | |
| 594 | +} | |
| 595 | + | |
| 596 | +.sa-nav-card-label { | |
| 597 | + font-weight: 600; | |
| 598 | + font-size: 0.875rem; | |
| 599 | +} | |
| 600 | + | |
| 601 | +.sa-nav-card-count { | |
| 602 | + font-size: 0.75rem; | |
| 603 | + color: var(--sa-gray-500); | |
| 604 | +} | |
| 605 | + | |
| 606 | +/* Nav card icon themes */ | |
| 607 | +.sa-nav-icon-expenses { background: rgba(232, 96, 76, 0.1); color: var(--sa-primary); } | |
| 608 | +.sa-nav-icon-members { background: rgba(99, 102, 241, 0.1); color: #6366f1; } | |
| 609 | +.sa-nav-icon-budget { background: rgba(34, 197, 94, 0.1); color: var(--sa-success); } | |
| 610 | +.sa-nav-icon-wishlist { background: rgba(244, 166, 35, 0.1); color: var(--sa-accent); } | |
| 611 | +.sa-nav-icon-polls { background: rgba(59, 130, 246, 0.1); color: var(--sa-info); } | |
| 612 | +.sa-nav-icon-settlement { background: rgba(26, 158, 143, 0.1); color: var(--sa-secondary); } | |
| 613 | + | |
| 614 | +/* ---------- Floating Action Button ---------- */ | |
| 615 | +.sa-fab { | |
| 616 | + position: fixed; | |
| 617 | + bottom: 24px; | |
| 618 | + right: 24px; | |
| 619 | + width: 56px; | |
| 620 | + height: 56px; | |
| 621 | + border-radius: 50%; | |
| 622 | + background: var(--sa-primary-gradient); | |
| 623 | + color: #fff; | |
| 624 | + display: flex; | |
| 625 | + align-items: center; | |
| 626 | + justify-content: center; | |
| 627 | + font-size: 1.5rem; | |
| 628 | + border: none; | |
| 629 | + cursor: pointer; | |
| 630 | + box-shadow: 0 4px 16px rgba(232, 96, 76, 0.4); | |
| 631 | + transition: all var(--sa-transition-normal); | |
| 632 | + z-index: 1000; | |
| 633 | + text-decoration: none; | |
| 634 | +} | |
| 635 | + | |
| 636 | +.sa-fab:hover { | |
| 637 | + transform: scale(1.1); | |
| 638 | + box-shadow: 0 6px 24px rgba(232, 96, 76, 0.5); | |
| 639 | + color: #fff; | |
| 640 | +} | |
| 641 | + | |
| 642 | +@media (min-width: 768px) { | |
| 643 | + .sa-fab { | |
| 644 | + display: none; | |
| 645 | + } | |
| 646 | +} | |
| 647 | + | |
| 648 | +/* ---------- Amount Display ---------- */ | |
| 649 | +.sa-amount { | |
| 650 | + font-variant-numeric: tabular-nums; | |
| 651 | + font-weight: 700; | |
| 652 | +} | |
| 653 | + | |
| 654 | +.sa-amount-lg { | |
| 655 | + font-size: 2rem; | |
| 656 | + letter-spacing: -0.02em; | |
| 657 | +} | |
| 658 | + | |
| 659 | +.sa-amount-positive { color: var(--sa-success); } | |
| 660 | +.sa-amount-negative { color: var(--sa-danger); } | |
| 661 | + | |
| 662 | +/* ---------- Balance Bar ---------- */ | |
| 663 | +.sa-balance-bar-container { | |
| 664 | + display: flex; | |
| 665 | + align-items: center; | |
| 666 | + gap: 8px; | |
| 667 | + height: 28px; | |
| 668 | +} | |
| 669 | + | |
| 670 | +.sa-balance-bar-track { | |
| 671 | + flex: 1; | |
| 672 | + display: flex; | |
| 673 | + align-items: center; | |
| 674 | + justify-content: center; | |
| 675 | + position: relative; | |
| 676 | + height: 8px; | |
| 677 | + background: var(--sa-gray-100); | |
| 678 | + border-radius: var(--sa-radius-full); | |
| 679 | +} | |
| 680 | + | |
| 681 | +.sa-balance-bar-fill { | |
| 682 | + position: absolute; | |
| 683 | + height: 100%; | |
| 684 | + border-radius: var(--sa-radius-full); | |
| 685 | + transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1); | |
| 686 | +} | |
| 687 | + | |
| 688 | +.sa-balance-bar-positive { | |
| 689 | + right: 50%; | |
| 690 | + left: auto; | |
| 691 | + background: var(--sa-success); | |
| 692 | +} | |
| 693 | + | |
| 694 | +.sa-balance-bar-negative { | |
| 695 | + left: 50%; | |
| 696 | + right: auto; | |
| 697 | + background: var(--sa-danger); | |
| 698 | +} | |
| 699 | + | |
| 700 | +.sa-balance-bar-center { | |
| 701 | + position: absolute; | |
| 702 | + width: 2px; | |
| 703 | + height: 16px; | |
| 704 | + background: var(--sa-gray-400); | |
| 705 | + left: 50%; | |
| 706 | + transform: translateX(-50%); | |
| 707 | +} | |
| 708 | + | |
| 709 | +/* ---------- Settlement Flow Card ---------- */ | |
| 710 | +.sa-settlement-card { | |
| 711 | + display: flex; | |
| 712 | + align-items: center; | |
| 713 | + gap: var(--sa-space-4); | |
| 714 | + padding: var(--sa-space-4) var(--sa-space-5); | |
| 715 | + background: rgba(255, 255, 255, 0.72); | |
| 716 | + backdrop-filter: blur(16px); | |
| 717 | + -webkit-backdrop-filter: blur(16px); | |
| 718 | + border-radius: var(--sa-radius-lg); | |
| 719 | + box-shadow: var(--sa-shadow-glass); | |
| 720 | + border: 1px solid rgba(255, 255, 255, 0.3); | |
| 721 | +} | |
| 722 | + | |
| 723 | +.sa-settlement-arrow { | |
| 724 | + color: var(--sa-gray-400); | |
| 725 | + font-size: 1.2rem; | |
| 726 | + flex-shrink: 0; | |
| 727 | +} | |
| 728 | + | |
| 729 | +.sa-settlement-amount { | |
| 730 | + font-size: 1.1rem; | |
| 731 | + font-weight: 700; | |
| 732 | + color: var(--sa-gray-900); | |
| 733 | + flex-shrink: 0; | |
| 734 | +} | |
| 735 | + | |
| 736 | +/* Status dots */ | |
| 737 | +.sa-status-dot { | |
| 738 | + width: 10px; | |
| 739 | + height: 10px; | |
| 740 | + border-radius: 50%; | |
| 741 | + display: inline-block; | |
| 742 | +} | |
| 743 | + | |
| 744 | +.sa-status-dot-pending { background: var(--sa-gray-300); } | |
| 745 | +.sa-status-dot-active { background: var(--sa-success); } | |
| 746 | +.sa-status-dot-warning { background: var(--sa-warning); } | |
| 747 | +.sa-status-dot-pulse { | |
| 748 | + animation: sa-pulse 2s ease-in-out infinite; | |
| 749 | +} | |
| 750 | + | |
| 751 | +/* ---------- Toggle Switch ---------- */ | |
| 752 | +.sa-toggle { | |
| 753 | + position: relative; | |
| 754 | + width: 44px; | |
| 755 | + height: 24px; | |
| 756 | + appearance: none; | |
| 757 | + -webkit-appearance: none; | |
| 758 | + background: var(--sa-gray-300); | |
| 759 | + border-radius: var(--sa-radius-full); | |
| 760 | + outline: none; | |
| 761 | + cursor: pointer; | |
| 762 | + transition: background var(--sa-transition-fast); | |
| 763 | +} | |
| 764 | + | |
| 765 | +.sa-toggle:checked { | |
| 766 | + background: var(--sa-secondary); | |
| 767 | +} | |
| 768 | + | |
| 769 | +.sa-toggle::before { | |
| 770 | + content: ''; | |
| 771 | + position: absolute; | |
| 772 | + top: 2px; | |
| 773 | + left: 2px; | |
| 774 | + width: 20px; | |
| 775 | + height: 20px; | |
| 776 | + border-radius: 50%; | |
| 777 | + background: #fff; | |
| 778 | + box-shadow: 0 1px 3px rgba(0,0,0,0.2); | |
| 779 | + transition: transform var(--sa-transition-fast); | |
| 780 | +} | |
| 781 | + | |
| 782 | +.sa-toggle:checked::before { | |
| 783 | + transform: translateX(20px); | |
| 784 | +} | |
| 785 | + | |
| 786 | +/* ---------- Toast ---------- */ | |
| 787 | +.sa-toast-container { | |
| 788 | + position: fixed; | |
| 789 | + top: 20px; | |
| 790 | + right: 20px; | |
| 791 | + z-index: 9999; | |
| 792 | + display: flex; | |
| 793 | + flex-direction: column; | |
| 794 | + gap: 8px; | |
| 795 | + pointer-events: none; | |
| 796 | +} | |
| 797 | + | |
| 798 | +.sa-toast { | |
| 799 | + display: flex; | |
| 800 | + align-items: center; | |
| 801 | + gap: 10px; | |
| 802 | + padding: 12px 20px; | |
| 803 | + background: #fff; | |
| 804 | + border-radius: var(--sa-radius-md); | |
| 805 | + box-shadow: var(--sa-shadow-lg); | |
| 806 | + border-left: 4px solid var(--sa-secondary); | |
| 807 | + font-size: 0.875rem; | |
| 808 | + font-weight: 500; | |
| 809 | + pointer-events: auto; | |
| 810 | + animation: sa-slide-in-right 0.3s ease-out; | |
| 811 | + max-width: 380px; | |
| 812 | +} | |
| 813 | + | |
| 814 | +.sa-toast-success { border-left-color: var(--sa-success); } | |
| 815 | +.sa-toast-error { border-left-color: var(--sa-danger); } | |
| 816 | +.sa-toast-warning { border-left-color: var(--sa-warning); } | |
| 817 | + | |
| 818 | +.sa-toast-dismiss { | |
| 819 | + animation: sa-fade-out 0.3s ease-in forwards; | |
| 820 | +} | |
| 821 | + | |
| 822 | +/* ---------- Expense List Item ---------- */ | |
| 823 | +.sa-expense-item { | |
| 824 | + display: flex; | |
| 825 | + align-items: center; | |
| 826 | + gap: var(--sa-space-4); | |
| 827 | + padding: var(--sa-space-4) var(--sa-space-5); | |
| 828 | + border-bottom: 1px solid var(--sa-gray-100); | |
| 829 | + transition: background var(--sa-transition-fast); | |
| 830 | +} | |
| 831 | + | |
| 832 | +.sa-expense-item:last-child { | |
| 833 | + border-bottom: none; | |
| 834 | +} | |
| 835 | + | |
| 836 | +.sa-expense-item:hover { | |
| 837 | + background: var(--sa-gray-50); | |
| 838 | +} | |
| 839 | + | |
| 840 | +.sa-expense-icon { | |
| 841 | + width: 40px; | |
| 842 | + height: 40px; | |
| 843 | + border-radius: var(--sa-radius-sm); | |
| 844 | + display: flex; | |
| 845 | + align-items: center; | |
| 846 | + justify-content: center; | |
| 847 | + font-size: 1rem; | |
| 848 | + flex-shrink: 0; | |
| 849 | +} | |
| 850 | + | |
| 851 | +.sa-expense-details { | |
| 852 | + flex: 1; | |
| 853 | + min-width: 0; | |
| 854 | +} | |
| 855 | + | |
| 856 | +.sa-expense-desc { | |
| 857 | + font-weight: 600; | |
| 858 | + font-size: 0.938rem; | |
| 859 | + color: var(--sa-gray-800); | |
| 860 | + white-space: nowrap; | |
| 861 | + overflow: hidden; | |
| 862 | + text-overflow: ellipsis; | |
| 863 | +} | |
| 864 | + | |
| 865 | +.sa-expense-meta { | |
| 866 | + font-size: 0.8rem; | |
| 867 | + color: var(--sa-gray-500); | |
| 868 | + margin-top: 2px; | |
| 869 | +} | |
| 870 | + | |
| 871 | +.sa-expense-amount { | |
| 872 | + font-weight: 700; | |
| 873 | + font-size: 1rem; | |
| 874 | + color: var(--sa-gray-900); | |
| 875 | + text-align: right; | |
| 876 | + flex-shrink: 0; | |
| 877 | +} | |
| 878 | + | |
| 879 | +.sa-expense-actions { | |
| 880 | + display: flex; | |
| 881 | + gap: 4px; | |
| 882 | + min-width: 72px; | |
| 883 | + justify-content: flex-end; | |
| 884 | + opacity: 0; | |
| 885 | + transition: opacity var(--sa-transition-fast); | |
| 886 | +} | |
| 887 | + | |
| 888 | +.sa-expense-item:hover .sa-expense-actions { | |
| 889 | + opacity: 1; | |
| 890 | +} | |
| 891 | + | |
| 892 | +@media (max-width: 767px) { | |
| 893 | + .sa-expense-actions { | |
| 894 | + opacity: 1; | |
| 895 | + } | |
| 896 | +} | |
| 897 | + | |
| 898 | +/* ---------- Split Method Cards ---------- */ | |
| 899 | +.sa-split-methods { | |
| 900 | + display: grid; | |
| 901 | + grid-template-columns: repeat(2, 1fr); | |
| 902 | + gap: 8px; | |
| 903 | +} | |
| 904 | + | |
| 905 | +@media (min-width: 576px) { | |
| 906 | + .sa-split-methods { | |
| 907 | + grid-template-columns: repeat(4, 1fr); | |
| 908 | + } | |
| 909 | +} | |
| 910 | + | |
| 911 | +.sa-split-option { | |
| 912 | + position: relative; | |
| 913 | + cursor: pointer; | |
| 914 | +} | |
| 915 | + | |
| 916 | +.sa-split-option input { | |
| 917 | + position: absolute; | |
| 918 | + opacity: 0; | |
| 919 | + width: 0; | |
| 920 | + height: 0; | |
| 921 | +} | |
| 922 | + | |
| 923 | +.sa-split-option-label { | |
| 924 | + display: flex; | |
| 925 | + flex-direction: column; | |
| 926 | + align-items: center; | |
| 927 | + gap: 6px; | |
| 928 | + padding: var(--sa-space-4) var(--sa-space-3); | |
| 929 | + border: 2px solid var(--sa-gray-200); | |
| 930 | + border-radius: var(--sa-radius-md); | |
| 931 | + text-align: center; | |
| 932 | + transition: all var(--sa-transition-fast); | |
| 933 | + background: #fff; | |
| 934 | +} | |
| 935 | + | |
| 936 | +.sa-split-option input:checked + .sa-split-option-label { | |
| 937 | + border-color: var(--sa-secondary); | |
| 938 | + background: rgba(26, 158, 143, 0.06); | |
| 939 | + box-shadow: 0 0 0 1px var(--sa-secondary); | |
| 940 | +} | |
| 941 | + | |
| 942 | +.sa-split-option-icon { | |
| 943 | + font-size: 1.4rem; | |
| 944 | + color: var(--sa-gray-500); | |
| 945 | +} | |
| 946 | + | |
| 947 | +.sa-split-option input:checked + .sa-split-option-label .sa-split-option-icon { | |
| 948 | + color: var(--sa-secondary); | |
| 949 | +} | |
| 950 | + | |
| 951 | +.sa-split-option-text { | |
| 952 | + font-size: 0.75rem; | |
| 953 | + font-weight: 600; | |
| 954 | + color: var(--sa-gray-600); | |
| 955 | +} | |
| 956 | + | |
| 957 | +/* ---------- Poll Option Card ---------- */ | |
| 958 | +.sa-poll-option { | |
| 959 | + border: 2px solid var(--sa-gray-200); | |
| 960 | + border-radius: var(--sa-radius-md); | |
| 961 | + padding: var(--sa-space-4) var(--sa-space-5); | |
| 962 | + margin-bottom: var(--sa-space-3); | |
| 963 | + transition: all var(--sa-transition-fast); | |
| 964 | + background: #fff; | |
| 965 | +} | |
| 966 | + | |
| 967 | +.sa-poll-option-voted { | |
| 968 | + border-color: var(--sa-secondary); | |
| 969 | + background: rgba(26, 158, 143, 0.03); | |
| 970 | +} | |
| 971 | + | |
| 972 | +.sa-poll-option-winner { | |
| 973 | + border-color: var(--sa-accent); | |
| 974 | + background: rgba(244, 166, 35, 0.05); | |
| 975 | +} | |
| 976 | + | |
| 977 | +/* ---------- Wishlist Vote Button ---------- */ | |
| 978 | +.sa-vote-btn { | |
| 979 | + display: inline-flex; | |
| 980 | + align-items: center; | |
| 981 | + gap: 6px; | |
| 982 | + padding: 6px 14px; | |
| 983 | + border: 1.5px solid var(--sa-gray-300); | |
| 984 | + border-radius: var(--sa-radius-full); | |
| 985 | + background: #fff; | |
| 986 | + color: var(--sa-gray-600); | |
| 987 | + font-size: 0.85rem; | |
| 988 | + font-weight: 600; | |
| 989 | + cursor: pointer; | |
| 990 | + transition: all var(--sa-transition-fast); | |
| 991 | +} | |
| 992 | + | |
| 993 | +.sa-vote-btn:hover { | |
| 994 | + border-color: var(--sa-primary); | |
| 995 | + color: var(--sa-primary); | |
| 996 | +} | |
| 997 | + | |
| 998 | +.sa-vote-btn-active { | |
| 999 | + border-color: var(--sa-primary); | |
| 1000 | + background: rgba(232, 96, 76, 0.08); | |
| 1001 | + color: var(--sa-primary); | |
| 1002 | +} | |
| 1003 | + | |
| 1004 | +/* ---------- Navbar ---------- */ | |
| 1005 | +.sa-navbar { | |
| 1006 | + position: sticky; | |
| 1007 | + top: 0; | |
| 1008 | + z-index: 1050; | |
| 1009 | + background: rgba(255, 255, 255, 0.85); | |
| 1010 | + backdrop-filter: blur(12px); | |
| 1011 | + -webkit-backdrop-filter: blur(12px); | |
| 1012 | + border-bottom: 1px solid rgba(0, 0, 0, 0.06); | |
| 1013 | + padding: 0 var(--sa-space-4); | |
| 1014 | +} | |
| 1015 | + | |
| 1016 | +.sa-navbar-brand { | |
| 1017 | + font-weight: 800; | |
| 1018 | + font-size: 1.25rem; | |
| 1019 | + color: var(--sa-primary) !important; | |
| 1020 | + display: flex; | |
| 1021 | + align-items: center; | |
| 1022 | + gap: 8px; | |
| 1023 | + text-decoration: none; | |
| 1024 | +} | |
| 1025 | + | |
| 1026 | +.sa-navbar-brand:hover { | |
| 1027 | + color: var(--sa-primary-dark) !important; | |
| 1028 | +} | |
| 1029 | + | |
| 1030 | +.sa-navbar .nav-link { | |
| 1031 | + font-weight: 500; | |
| 1032 | + color: var(--sa-gray-600) !important; | |
| 1033 | + padding: 8px 16px !important; | |
| 1034 | + border-radius: var(--sa-radius-sm); | |
| 1035 | + transition: all var(--sa-transition-fast); | |
| 1036 | +} | |
| 1037 | + | |
| 1038 | +.sa-navbar .nav-link:hover, | |
| 1039 | +.sa-navbar .nav-link.active { | |
| 1040 | + color: var(--sa-gray-900) !important; | |
| 1041 | + background: var(--sa-gray-100); | |
| 1042 | +} | |
| 1043 | + | |
| 1044 | +.sa-nav-avatar-btn { | |
| 1045 | + display: flex; | |
| 1046 | + align-items: center; | |
| 1047 | + gap: 8px; | |
| 1048 | + padding: 4px 12px 4px 4px; | |
| 1049 | + background: var(--sa-gray-100); | |
| 1050 | + border-radius: var(--sa-radius-full); | |
| 1051 | + border: none; | |
| 1052 | + cursor: pointer; | |
| 1053 | + font-size: 0.875rem; | |
| 1054 | + font-weight: 500; | |
| 1055 | + color: var(--sa-gray-700); | |
| 1056 | + transition: background var(--sa-transition-fast); | |
| 1057 | +} | |
| 1058 | + | |
| 1059 | +.sa-nav-avatar-btn:hover { | |
| 1060 | + background: var(--sa-gray-200); | |
| 1061 | +} | |
| 1062 | + | |
| 1063 | +.sa-navbar-auth-btns { | |
| 1064 | + display: flex; | |
| 1065 | + gap: 8px; | |
| 1066 | + align-items: center; | |
| 1067 | +} | |
| 1068 | + | |
| 1069 | +/* ---------- Hero Section ---------- */ | |
| 1070 | +.sa-hero { | |
| 1071 | + text-align: center; | |
| 1072 | + padding: var(--sa-space-16) var(--sa-space-4); | |
| 1073 | + background: var(--sa-primary-gradient); | |
| 1074 | + color: #fff; | |
| 1075 | + margin: -1rem -0.75rem var(--sa-space-12); | |
| 1076 | + border-radius: 0 0 var(--sa-radius-lg) var(--sa-radius-lg); | |
| 1077 | +} | |
| 1078 | + | |
| 1079 | +.sa-hero h1 { | |
| 1080 | + color: #fff; | |
| 1081 | + font-size: 2.75rem; | |
| 1082 | + font-weight: 800; | |
| 1083 | + letter-spacing: -0.03em; | |
| 1084 | + margin-bottom: var(--sa-space-4); | |
| 1085 | +} | |
| 1086 | + | |
| 1087 | +.sa-hero p { | |
| 1088 | + font-size: 1.2rem; | |
| 1089 | + opacity: 0.9; | |
| 1090 | + max-width: 540px; | |
| 1091 | + margin: 0 auto var(--sa-space-8); | |
| 1092 | +} | |
| 1093 | + | |
| 1094 | +.sa-hero-btns { | |
| 1095 | + display: flex; | |
| 1096 | + gap: 12px; | |
| 1097 | + justify-content: center; | |
| 1098 | + flex-wrap: wrap; | |
| 1099 | +} | |
| 1100 | + | |
| 1101 | +.sa-hero-btn-white { | |
| 1102 | + background: #fff; | |
| 1103 | + color: var(--sa-primary); | |
| 1104 | + font-weight: 700; | |
| 1105 | + padding: 12px 28px; | |
| 1106 | + border-radius: var(--sa-radius-full); | |
| 1107 | + border: none; | |
| 1108 | + font-size: 1rem; | |
| 1109 | + transition: all var(--sa-transition-fast); | |
| 1110 | + text-decoration: none; | |
| 1111 | + display: inline-flex; | |
| 1112 | + align-items: center; | |
| 1113 | + gap: 8px; | |
| 1114 | +} | |
| 1115 | + | |
| 1116 | +.sa-hero-btn-white:hover { | |
| 1117 | + transform: translateY(-2px); | |
| 1118 | + box-shadow: 0 4px 16px rgba(0,0,0,0.15); | |
| 1119 | + color: var(--sa-primary); | |
| 1120 | +} | |
| 1121 | + | |
| 1122 | +.sa-hero-btn-outline { | |
| 1123 | + background: transparent; | |
| 1124 | + color: #fff; | |
| 1125 | + font-weight: 600; | |
| 1126 | + padding: 12px 28px; | |
| 1127 | + border-radius: var(--sa-radius-full); | |
| 1128 | + border: 2px solid rgba(255,255,255,0.4); | |
| 1129 | + font-size: 1rem; | |
| 1130 | + transition: all var(--sa-transition-fast); | |
| 1131 | + text-decoration: none; | |
| 1132 | + display: inline-flex; | |
| 1133 | + align-items: center; | |
| 1134 | + gap: 8px; | |
| 1135 | +} | |
| 1136 | + | |
| 1137 | +.sa-hero-btn-outline:hover { | |
| 1138 | + background: rgba(255,255,255,0.15); | |
| 1139 | + border-color: rgba(255,255,255,0.7); | |
| 1140 | + color: #fff; | |
| 1141 | +} | |
| 1142 | + | |
| 1143 | +@media (max-width: 767px) { | |
| 1144 | + .sa-hero h1 { font-size: 2rem; } | |
| 1145 | + .sa-hero { padding-top: var(--sa-space-10); padding-bottom: var(--sa-space-10); } | |
| 1146 | +} | |
| 1147 | + | |
| 1148 | +/* ---------- Feature Cards ---------- */ | |
| 1149 | +.sa-feature-card { | |
| 1150 | + text-align: center; | |
| 1151 | + padding: var(--sa-space-8) var(--sa-space-6); | |
| 1152 | +} | |
| 1153 | + | |
| 1154 | +.sa-feature-icon { | |
| 1155 | + width: 64px; | |
| 1156 | + height: 64px; | |
| 1157 | + border-radius: var(--sa-radius-lg); | |
| 1158 | + display: flex; | |
| 1159 | + align-items: center; | |
| 1160 | + justify-content: center; | |
| 1161 | + font-size: 1.75rem; | |
| 1162 | + margin: 0 auto var(--sa-space-4); | |
| 1163 | +} | |
| 1164 | + | |
| 1165 | +.sa-feature-title { | |
| 1166 | + font-size: 1.1rem; | |
| 1167 | + font-weight: 700; | |
| 1168 | + margin-bottom: var(--sa-space-2); | |
| 1169 | +} | |
| 1170 | + | |
| 1171 | +.sa-feature-text { | |
| 1172 | + font-size: 0.938rem; | |
| 1173 | + color: var(--sa-gray-500); | |
| 1174 | + line-height: 1.6; | |
| 1175 | +} | |
| 1176 | + | |
| 1177 | +/* ---------- Confirmation Card ---------- */ | |
| 1178 | +.sa-confirm-card { | |
| 1179 | + max-width: 480px; | |
| 1180 | + margin: var(--sa-space-8) auto; | |
| 1181 | +} | |
| 1182 | + | |
| 1183 | +.sa-confirm-icon { | |
| 1184 | + width: 64px; | |
| 1185 | + height: 64px; | |
| 1186 | + border-radius: 50%; | |
| 1187 | + display: flex; | |
| 1188 | + align-items: center; | |
| 1189 | + justify-content: center; | |
| 1190 | + font-size: 1.75rem; | |
| 1191 | + margin: 0 auto var(--sa-space-4); | |
| 1192 | +} | |
| 1193 | + | |
| 1194 | +.sa-confirm-icon-danger { background: var(--sa-danger-light); color: var(--sa-danger); } | |
| 1195 | +.sa-confirm-icon-success { background: var(--sa-success-light); color: var(--sa-success); } | |
| 1196 | +.sa-confirm-icon-warning { background: var(--sa-warning-light); color: var(--sa-warning); } | |
| 1197 | + | |
| 1198 | +/* ---------- Big Amount Input ---------- */ | |
| 1199 | +.sa-amount-input { | |
| 1200 | + font-size: 2.5rem; | |
| 1201 | + font-weight: 800; | |
| 1202 | + text-align: center; | |
| 1203 | + border: none; | |
| 1204 | + border-bottom: 3px solid var(--sa-gray-200); | |
| 1205 | + border-radius: 0; | |
| 1206 | + padding: var(--sa-space-4); | |
| 1207 | + background: transparent; | |
| 1208 | + letter-spacing: -0.02em; | |
| 1209 | +} | |
| 1210 | + | |
| 1211 | +.sa-amount-input:focus { | |
| 1212 | + border-color: var(--sa-secondary); | |
| 1213 | + box-shadow: none; | |
| 1214 | + outline: none; | |
| 1215 | +} | |
| 1216 | + | |
| 1217 | +.sa-amount-input::placeholder { | |
| 1218 | + color: var(--sa-gray-300); | |
| 1219 | +} | |
| 1220 | + | |
| 1221 | +/* ---------- Animations ---------- */ | |
| 1222 | +@keyframes sa-fade-in { | |
| 1223 | + from { opacity: 0; transform: translateY(8px); } | |
| 1224 | + to { opacity: 1; transform: translateY(0); } | |
| 1225 | +} | |
| 1226 | + | |
| 1227 | +@keyframes sa-slide-up { | |
| 1228 | + from { opacity: 0; transform: translateY(8px); } | |
| 1229 | + to { opacity: 1; transform: translateY(0); } | |
| 1230 | +} | |
| 1231 | + | |
| 1232 | +@keyframes sa-counter { | |
| 1233 | + from { opacity: 0; transform: translateY(10px); } | |
| 1234 | + to { opacity: 1; transform: translateY(0); } | |
| 1235 | +} | |
| 1236 | + | |
| 1237 | +@keyframes sa-slide-in-right { | |
| 1238 | + from { opacity: 0; transform: translateX(100px); } | |
| 1239 | + to { opacity: 1; transform: translateX(0); } | |
| 1240 | +} | |
| 1241 | + | |
| 1242 | +@keyframes sa-fade-out { | |
| 1243 | + from { opacity: 1; transform: translateX(0); } | |
| 1244 | + to { opacity: 0; transform: translateX(100px); } | |
| 1245 | +} | |
| 1246 | + | |
| 1247 | +@keyframes sa-scale-in { | |
| 1248 | + from { opacity: 0; transform: scale(0.9); } | |
| 1249 | + to { opacity: 1; transform: scale(1); } | |
| 1250 | +} | |
| 1251 | + | |
| 1252 | +@keyframes sa-spin { | |
| 1253 | + to { transform: rotate(360deg); } | |
| 1254 | +} | |
| 1255 | + | |
| 1256 | +@keyframes sa-pulse { | |
| 1257 | + 0%, 100% { opacity: 1; } | |
| 1258 | + 50% { opacity: 0.5; } | |
| 1259 | +} | |
| 1260 | + | |
| 1261 | +@keyframes sa-bounce-in { | |
| 1262 | + 0% { transform: scale(0); } | |
| 1263 | + 50% { transform: scale(1.15); } | |
| 1264 | + 100% { transform: scale(1); } | |
| 1265 | +} | |
| 1266 | + | |
| 1267 | +@keyframes sa-progress-fill { | |
| 1268 | + from { width: 0; } | |
| 1269 | +} | |
| 1270 | + | |
| 1271 | +.sa-animate-fade-in { animation: sa-fade-in 0.5s ease-out both; } | |
| 1272 | +.sa-animate-slide-up { animation: sa-slide-up 0.5s ease-out both; } | |
| 1273 | +.sa-animate-scale-in { animation: sa-scale-in 0.4s ease-out both; } | |
| 1274 | +.sa-animate-bounce-in { animation: sa-bounce-in 0.5s ease-out both; } | |
| 1275 | +.sa-animate-counter { animation: sa-counter 0.6s ease-out both; } | |
| 1276 | + | |
| 1277 | +/* Hover lift effect */ | |
| 1278 | +.sa-hover-lift { | |
| 1279 | + transition: transform 0.2s ease, box-shadow 0.2s ease; | |
| 1280 | +} | |
| 1281 | + | |
| 1282 | +.sa-hover-lift:hover { | |
| 1283 | + transform: translateY(-2px); | |
| 1284 | + box-shadow: var(--sa-shadow-lg); | |
| 1285 | +} | |
| 1286 | + | |
| 1287 | +/* Stagger delays */ | |
| 1288 | +.sa-stagger-1 { animation-delay: 0.05s; } | |
| 1289 | +.sa-stagger-2 { animation-delay: 0.1s; } | |
| 1290 | +.sa-stagger-3 { animation-delay: 0.15s; } | |
| 1291 | +.sa-stagger-4 { animation-delay: 0.2s; } | |
| 1292 | +.sa-stagger-5 { animation-delay: 0.25s; } | |
| 1293 | +.sa-stagger-6 { animation-delay: 0.3s; } | |
| 1294 | + | |
| 1295 | +/* Scroll-triggered */ | |
| 1296 | +.sa-animate-on-scroll { | |
| 1297 | + opacity: 0; | |
| 1298 | + transform: translateY(20px); | |
| 1299 | + transition: opacity 0.6s ease-out, transform 0.6s ease-out; | |
| 1300 | +} | |
| 1301 | + | |
| 1302 | +.sa-animate-on-scroll.sa-visible { | |
| 1303 | + opacity: 1; | |
| 1304 | + transform: translateY(0); | |
| 1305 | +} | |
| 1306 | + | |
| 1307 | +/* ---------- Utility Classes ---------- */ | |
| 1308 | +.sa-text-gradient { | |
| 1309 | + background: var(--sa-primary-gradient); | |
| 1310 | + -webkit-background-clip: text; | |
| 1311 | + -webkit-text-fill-color: transparent; | |
| 1312 | + background-clip: text; | |
| 1313 | +} | |
| 1314 | + | |
| 1315 | +.sa-text-primary { color: var(--sa-primary); } | |
| 1316 | +.sa-text-secondary { color: var(--sa-secondary); } | |
| 1317 | +.sa-text-accent { color: var(--sa-accent); } | |
| 1318 | +.sa-text-muted { color: var(--sa-gray-500); } | |
| 1319 | + | |
| 1320 | +.sa-bg-soft-primary { background: rgba(232, 96, 76, 0.08); } | |
| 1321 | +.sa-bg-soft-secondary { background: rgba(26, 158, 143, 0.08); } | |
| 1322 | +.sa-bg-soft-success { background: var(--sa-success-light); } | |
| 1323 | +.sa-bg-soft-danger { background: var(--sa-danger-light); } | |
| 1324 | +.sa-bg-soft-warning { background: var(--sa-warning-light); } | |
| 1325 | + | |
| 1326 | +.sa-rounded { border-radius: var(--sa-radius-md); } | |
| 1327 | +.sa-rounded-lg { border-radius: var(--sa-radius-lg); } | |
| 1328 | + | |
| 1329 | +.sa-shadow { box-shadow: var(--sa-shadow-md); } | |
| 1330 | +.sa-shadow-lg { box-shadow: var(--sa-shadow-lg); } | |
| 1331 | + | |
| 1332 | +.sa-divider { | |
| 1333 | + height: 1px; | |
| 1334 | + background: var(--sa-gray-100); | |
| 1335 | + margin: var(--sa-space-4) 0; | |
| 1336 | +} | |
| 1337 | + | |
| 1338 | +.sa-truncate { | |
| 1339 | + white-space: nowrap; | |
| 1340 | + overflow: hidden; | |
| 1341 | + text-overflow: ellipsis; | |
| 1342 | +} | |
| 1343 | + | |
| 1344 | +/* ---------- Category Colors ---------- */ | |
| 1345 | +.sa-category-food { background: #fee2e2; color: #dc2626; } | |
| 1346 | +.sa-category-accommodation { background: #dbeafe; color: #2563eb; } | |
| 1347 | +.sa-category-transport { background: #fef3c7; color: #d97706; } | |
| 1348 | +.sa-category-activities { background: #dcfce7; color: #16a34a; } | |
| 1349 | +.sa-category-shopping { background: #f3e8ff; color: #7c3aed; } | |
| 1350 | +.sa-category-default { background: var(--sa-gray-100); color: var(--sa-gray-600); } | |
| 1351 | + | |
| 1352 | +/* ---------- Responsive Helpers ---------- */ | |
| 1353 | +@media (max-width: 575px) { | |
| 1354 | + .sa-hide-mobile { display: none !important; } | |
| 1355 | + .sa-gradient-header { | |
| 1356 | + padding-top: var(--sa-space-6); | |
| 1357 | + padding-bottom: var(--sa-space-6); | |
| 1358 | + } | |
| 1359 | + h1 { font-size: 1.5rem; } | |
| 1360 | + .sa-stat-value { font-size: 1.4rem; } | |
| 1361 | +} | |
| 1362 | + | |
| 1363 | +@media (min-width: 576px) { | |
| 1364 | + .sa-hide-desktop { display: none !important; } | |
| 1365 | +} | |
| 1366 | + | |
| 1367 | +/* ---------- Dropdown override ---------- */ | |
| 1368 | +.dropdown-menu { | |
| 1369 | + border: 1px solid var(--sa-gray-100); | |
| 1370 | + border-radius: var(--sa-radius-md); | |
| 1371 | + box-shadow: var(--sa-shadow-lg); | |
| 1372 | + padding: 6px; | |
| 1373 | +} | |
| 1374 | + | |
| 1375 | +.dropdown-item { | |
| 1376 | + border-radius: var(--sa-radius-sm); | |
| 1377 | + padding: 8px 14px; | |
| 1378 | + font-size: 0.875rem; | |
| 1379 | + font-weight: 500; | |
| 1380 | + transition: background var(--sa-transition-fast); | |
| 1381 | +} | |
| 1382 | + | |
| 1383 | +.dropdown-item:hover { | |
| 1384 | + background: var(--sa-gray-100); | |
| 1385 | +} | |
| 1386 | + | |
| 1387 | +/* ---------- Table Override ---------- */ | |
| 1388 | +.sa-table { | |
| 1389 | + width: 100%; | |
| 1390 | + border-collapse: separate; | |
| 1391 | + border-spacing: 0; | |
| 1392 | +} | |
| 1393 | + | |
| 1394 | +.sa-table thead th { | |
| 1395 | + font-size: 0.75rem; | |
| 1396 | + font-weight: 600; | |
| 1397 | + text-transform: uppercase; | |
| 1398 | + letter-spacing: 0.06em; | |
| 1399 | + color: var(--sa-gray-500); | |
| 1400 | + border-bottom: 2px solid var(--sa-gray-100); | |
| 1401 | + padding: var(--sa-space-3) var(--sa-space-4); | |
| 1402 | +} | |
| 1403 | + | |
| 1404 | +.sa-table tbody td { | |
| 1405 | + padding: var(--sa-space-3) var(--sa-space-4); | |
| 1406 | + border-bottom: 1px solid var(--sa-gray-100); | |
| 1407 | + font-size: 0.938rem; | |
| 1408 | +} | |
| 1409 | + | |
| 1410 | +.sa-table tbody tr:hover { | |
| 1411 | + background: var(--sa-gray-50); | |
| 1412 | +} | |
| 1413 | + | |
| 1414 | +.sa-table tbody tr:last-child td { | |
| 1415 | + border-bottom: none; | |
| 1416 | +} | |
| 1417 | + | |
| 1418 | +/* ---------- Trip Card ---------- */ | |
| 1419 | +.sa-trip-card { | |
| 1420 | + overflow: hidden; | |
| 1421 | +} | |
| 1422 | + | |
| 1423 | +.sa-trip-card-gradient { | |
| 1424 | + height: 6px; | |
| 1425 | +} | |
| 1426 | + | |
| 1427 | +/* Generate variety with nth-child */ | |
| 1428 | +.sa-trip-card:nth-child(6n+1) .sa-trip-card-gradient { background: var(--sa-primary-gradient); } | |
| 1429 | +.sa-trip-card:nth-child(6n+2) .sa-trip-card-gradient { background: var(--sa-secondary-gradient); } | |
| 1430 | +.sa-trip-card:nth-child(6n+3) .sa-trip-card-gradient { background: linear-gradient(135deg, #6366f1, #8b5cf6); } | |
| 1431 | +.sa-trip-card:nth-child(6n+4) .sa-trip-card-gradient { background: linear-gradient(135deg, var(--sa-accent), #f97316); } | |
| 1432 | +.sa-trip-card:nth-child(6n+5) .sa-trip-card-gradient { background: linear-gradient(135deg, #ec4899, #f43f5e); } | |
| 1433 | +.sa-trip-card:nth-child(6n+6) .sa-trip-card-gradient { background: linear-gradient(135deg, #14b8a6, #06b6d4); } | |
| 1434 | + | |
| 1435 | +/* ---------- Invitation / Accept page ---------- */ | |
| 1436 | +.sa-invite-page { | |
| 1437 | + min-height: 80vh; | |
| 1438 | + display: flex; | |
| 1439 | + align-items: center; | |
| 1440 | + justify-content: center; | |
| 1441 | +} | |
| 1442 | + | |
| 1443 | +.sa-invite-card { | |
| 1444 | + max-width: 440px; | |
| 1445 | + width: 100%; | |
| 1446 | + text-align: center; | |
| 1447 | +} | |
| 1448 | + | |
| 1449 | +.sa-invite-icon { | |
| 1450 | + font-size: 3rem; | |
| 1451 | + margin-bottom: var(--sa-space-4); | |
| 1452 | +} | |
| 1453 | + | |
| 1454 | +/* Copy link input */ | |
| 1455 | +.sa-copy-group { | |
| 1456 | + display: flex; | |
| 1457 | + gap: 0; | |
| 1458 | +} | |
| 1459 | + | |
| 1460 | +.sa-copy-input { | |
| 1461 | + flex: 1; | |
| 1462 | + border-radius: var(--sa-radius-sm) 0 0 var(--sa-radius-sm); | |
| 1463 | + font-family: var(--sa-font-mono); | |
| 1464 | + font-size: 0.85rem; | |
| 1465 | +} | |
| 1466 | + | |
| 1467 | +.sa-copy-btn { | |
| 1468 | + border-radius: 0 var(--sa-radius-sm) var(--sa-radius-sm) 0; | |
| 1469 | + white-space: nowrap; | |
| 1470 | +} | |
| 1471 | + | |
| 1472 | +/* ---------- Participant Chip ---------- */ | |
| 1473 | +.sa-participant-chip { | |
| 1474 | + display: inline-flex; | |
| 1475 | + align-items: center; | |
| 1476 | + gap: 8px; | |
| 1477 | + padding: 4px 12px 4px 4px; | |
| 1478 | + background: var(--sa-gray-100); | |
| 1479 | + border-radius: var(--sa-radius-full); | |
| 1480 | + font-size: 0.85rem; | |
| 1481 | + font-weight: 500; | |
| 1482 | + cursor: pointer; | |
| 1483 | + transition: all var(--sa-transition-fast); | |
| 1484 | + border: 2px solid transparent; | |
| 1485 | +} | |
| 1486 | + | |
| 1487 | +.sa-participant-chip.active { | |
| 1488 | + border-color: var(--sa-secondary); | |
| 1489 | + background: rgba(26, 158, 143, 0.08); | |
| 1490 | +} | |
| 1491 | + | |
| 1492 | +.sa-participant-chip:hover { | |
| 1493 | + background: var(--sa-gray-200); | |
| 1494 | +} | |
| 1495 | + | |
| 1496 | +/* ---------- Auth Pages ---------- */ | |
| 1497 | +.sa-auth-page { | |
| 1498 | + min-height: 80vh; | |
| 1499 | + display: flex; | |
| 1500 | + align-items: center; | |
| 1501 | + justify-content: center; | |
| 1502 | +} | |
| 1503 | + | |
| 1504 | +.sa-auth-card { | |
| 1505 | + max-width: 420px; | |
| 1506 | + width: 100%; | |
| 1507 | +} | |
| 1508 | + | |
| 1509 | +.sa-auth-brand { | |
| 1510 | + text-align: center; | |
| 1511 | + margin-bottom: var(--sa-space-8); | |
| 1512 | +} | |
| 1513 | + | |
| 1514 | +.sa-auth-brand h1 { | |
| 1515 | + font-weight: 800; | |
| 1516 | + font-size: 2rem; | |
| 1517 | + color: var(--sa-primary); | |
| 1518 | +} | |
| 1519 | + | |
| 1520 | +.sa-auth-brand p { | |
| 1521 | + color: var(--sa-gray-500); | |
| 1522 | + font-size: 0.938rem; | |
| 1523 | +} | |
| 1524 | + | |
| 1525 | +/* ---------- Mobile Premium Overrides (< 768px) ---------- */ | |
| 1526 | +@media (max-width: 767px) { | |
| 1527 | + .sa-card { | |
| 1528 | + width: 100%; | |
| 1529 | + border-radius: 12px; | |
| 1530 | + } | |
| 1531 | + | |
| 1532 | + .sa-nav-card { | |
| 1533 | + display: grid; | |
| 1534 | + grid-template-columns: repeat(2, 1fr); | |
| 1535 | + } | |
| 1536 | + | |
| 1537 | + .sa-btn, | |
| 1538 | + .sa-btn-primary, | |
| 1539 | + .sa-btn-secondary, | |
| 1540 | + .sa-btn-ghost, | |
| 1541 | + .sa-btn-danger, | |
| 1542 | + .sa-btn-success, | |
| 1543 | + .sa-btn-accent { | |
| 1544 | + min-height: 44px; | |
| 1545 | + min-width: 44px; | |
| 1546 | + } | |
| 1547 | + | |
| 1548 | + .sa-form-sticky-submit { | |
| 1549 | + position: sticky; | |
| 1550 | + bottom: 0; | |
| 1551 | + background: rgba(255, 255, 255, 0.92); | |
| 1552 | + backdrop-filter: blur(12px); | |
| 1553 | + -webkit-backdrop-filter: blur(12px); | |
| 1554 | + padding: var(--sa-space-4); | |
| 1555 | + margin: 0 calc(-1 * var(--sa-space-4)); | |
| 1556 | + border-top: 1px solid var(--sa-gray-100); | |
| 1557 | + z-index: 10; | |
| 1558 | + } | |
| 1559 | +} | |
| 1560 | + | |
| 1561 | +/* ---------- Print ---------- */ | |
| 1562 | +@media print { | |
| 1563 | + .sa-navbar, .sa-footer, .sa-fab, .sa-toast-container { display: none !important; } | |
| 1564 | + .sa-card, .sa-card-static { box-shadow: none; border: 1px solid #ddd; } | |
| 1565 | + body { background: #fff; } | |
| 1566 | +} |
added src/components/LangSwitcher.vue +61 −0
| @@ -0,0 +1,61 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import { useLangStore } from '@/stores/lang' | |
| 3 | +import { SUPPORTED_LOCALES, type AppLocale } from '@/i18n' | |
| 4 | + | |
| 5 | +const lang = useLangStore() | |
| 6 | + | |
| 7 | +function switchTo(code: AppLocale) { | |
| 8 | + lang.setLocale(code) | |
| 9 | +} | |
| 10 | +</script> | |
| 11 | + | |
| 12 | +<template> | |
| 13 | + <div class="sa-lang-switcher"> | |
| 14 | + <button | |
| 15 | + v-for="code in SUPPORTED_LOCALES" | |
| 16 | + :key="code" | |
| 17 | + type="button" | |
| 18 | + class="sa-lang-btn" | |
| 19 | + :class="{ 'sa-lang-btn-active': lang.currentLocale === code }" | |
| 20 | + @click="switchTo(code)" | |
| 21 | + > | |
| 22 | + {{ code.toUpperCase() }} | |
| 23 | + </button> | |
| 24 | + </div> | |
| 25 | +</template> | |
| 26 | + | |
| 27 | +<style scoped> | |
| 28 | +.sa-lang-switcher { | |
| 29 | + display: inline-flex; | |
| 30 | + align-items: center; | |
| 31 | + gap: 2px; | |
| 32 | + padding: 2px; | |
| 33 | + border-radius: 999px; | |
| 34 | + background: var(--sa-gray-100); | |
| 35 | + border: 1px solid var(--sa-gray-200); | |
| 36 | + margin-right: 12px; | |
| 37 | +} | |
| 38 | + | |
| 39 | +.sa-lang-btn { | |
| 40 | + border: 0; | |
| 41 | + background: transparent; | |
| 42 | + color: var(--sa-gray-500); | |
| 43 | + font-size: 0.75rem; | |
| 44 | + font-weight: 700; | |
| 45 | + letter-spacing: 0.04em; | |
| 46 | + padding: 4px 10px; | |
| 47 | + border-radius: 999px; | |
| 48 | + cursor: pointer; | |
| 49 | + transition: all 0.15s ease; | |
| 50 | +} | |
| 51 | + | |
| 52 | +.sa-lang-btn:hover { | |
| 53 | + color: var(--sa-gray-700); | |
| 54 | +} | |
| 55 | + | |
| 56 | +.sa-lang-btn-active { | |
| 57 | + background: #fff; | |
| 58 | + color: var(--sa-gray-900); | |
| 59 | + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); | |
| 60 | +} | |
| 61 | +</style> |
added src/components/SplitMethodSelector.vue +386 −0
| @@ -0,0 +1,386 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import { ref, computed, watch } from 'vue' | |
| 3 | +import { useI18n } from 'vue-i18n' | |
| 4 | +import type { ITripParticipant } from '@/types/ITrip' | |
| 5 | +import type { IExpenseSplit, IExpenseSplitCreate } from '@/types/IExpense' | |
| 6 | + | |
| 7 | +const props = defineProps<{ | |
| 8 | + participants: ITripParticipant[] | |
| 9 | + totalAmount: number | |
| 10 | + splitMethod: string | |
| 11 | + existingSplits?: IExpenseSplit[] | null | |
| 12 | +}>() | |
| 13 | + | |
| 14 | +const emit = defineEmits<{ | |
| 15 | + 'update:splits': [splits: IExpenseSplitCreate[]] | |
| 16 | + 'update:valid': [valid: boolean] | |
| 17 | +}>() | |
| 18 | + | |
| 19 | +const { t } = useI18n() | |
| 20 | + | |
| 21 | +// EqualSubset: which participants are selected | |
| 22 | +const selectedUserIds = ref<Set<string>>(new Set()) | |
| 23 | + | |
| 24 | +// ExactAmounts: per-participant amount | |
| 25 | +const exactAmounts = ref<Record<string, number>>({}) | |
| 26 | + | |
| 27 | +// Percentages: per-participant percentage | |
| 28 | +const percentages = ref<Record<string, number>>({}) | |
| 29 | + | |
| 30 | +watch( | |
| 31 | + () => props.existingSplits, | |
| 32 | + (splits) => { | |
| 33 | + if (!splits || splits.length === 0) return | |
| 34 | + const ids = new Set(splits.map((s) => s.userId)) | |
| 35 | + selectedUserIds.value = ids | |
| 36 | + | |
| 37 | + const amts: Record<string, number> = {} | |
| 38 | + const pcts: Record<string, number> = {} | |
| 39 | + for (const s of splits) { | |
| 40 | + amts[s.userId] = s.amount | |
| 41 | + pcts[s.userId] = s.percentage ?? 0 | |
| 42 | + } | |
| 43 | + exactAmounts.value = amts | |
| 44 | + percentages.value = pcts | |
| 45 | + }, | |
| 46 | + { immediate: true }, | |
| 47 | +) | |
| 48 | + | |
| 49 | +watch( | |
| 50 | + () => props.splitMethod, | |
| 51 | + (method) => { | |
| 52 | + if (method === 'EqualSubset' && selectedUserIds.value.size === 0) { | |
| 53 | + selectedUserIds.value = new Set(props.participants.map((p) => p.userId)) | |
| 54 | + } | |
| 55 | + if (method === 'ExactAmounts') { | |
| 56 | + for (const p of props.participants) { | |
| 57 | + if (!(p.userId in exactAmounts.value)) { | |
| 58 | + exactAmounts.value[p.userId] = 0 | |
| 59 | + } | |
| 60 | + } | |
| 61 | + } | |
| 62 | + if (method === 'Percentages') { | |
| 63 | + for (const p of props.participants) { | |
| 64 | + if (!(p.userId in percentages.value)) { | |
| 65 | + percentages.value[p.userId] = 0 | |
| 66 | + } | |
| 67 | + } | |
| 68 | + } | |
| 69 | + }, | |
| 70 | + { immediate: true }, | |
| 71 | +) | |
| 72 | + | |
| 73 | +function toggleParticipant(userId: string) { | |
| 74 | + const s = new Set(selectedUserIds.value) | |
| 75 | + if (s.has(userId)) s.delete(userId) | |
| 76 | + else s.add(userId) | |
| 77 | + selectedUserIds.value = s | |
| 78 | +} | |
| 79 | + | |
| 80 | +const equalAllSplits = computed<IExpenseSplitCreate[]>(() => { | |
| 81 | + if (props.participants.length === 0) return [] | |
| 82 | + const amount = Math.round((props.totalAmount / props.participants.length) * 100) / 100 | |
| 83 | + return props.participants.map((p) => ({ | |
| 84 | + userId: p.userId, | |
| 85 | + amount, | |
| 86 | + percentage: null, | |
| 87 | + })) | |
| 88 | +}) | |
| 89 | + | |
| 90 | +const equalSubsetSplits = computed<IExpenseSplitCreate[]>(() => { | |
| 91 | + const selected = props.participants.filter((p) => selectedUserIds.value.has(p.userId)) | |
| 92 | + if (selected.length === 0) return [] | |
| 93 | + const amount = Math.round((props.totalAmount / selected.length) * 100) / 100 | |
| 94 | + return selected.map((p) => ({ | |
| 95 | + userId: p.userId, | |
| 96 | + amount, | |
| 97 | + percentage: null, | |
| 98 | + })) | |
| 99 | +}) | |
| 100 | + | |
| 101 | +const exactSplits = computed<IExpenseSplitCreate[]>(() => { | |
| 102 | + return props.participants.map((p) => ({ | |
| 103 | + userId: p.userId, | |
| 104 | + amount: exactAmounts.value[p.userId] ?? 0, | |
| 105 | + percentage: null, | |
| 106 | + })) | |
| 107 | +}) | |
| 108 | + | |
| 109 | +const percentageSplits = computed<IExpenseSplitCreate[]>(() => { | |
| 110 | + return props.participants.map((p) => { | |
| 111 | + const pct = percentages.value[p.userId] ?? 0 | |
| 112 | + return { | |
| 113 | + userId: p.userId, | |
| 114 | + amount: Math.round(((pct / 100) * props.totalAmount) * 100) / 100, | |
| 115 | + percentage: pct, | |
| 116 | + } | |
| 117 | + }) | |
| 118 | +}) | |
| 119 | + | |
| 120 | +const exactTotal = computed(() => | |
| 121 | + Object.values(exactAmounts.value).reduce((sum, v) => sum + (v || 0), 0), | |
| 122 | +) | |
| 123 | + | |
| 124 | +const percentageTotal = computed(() => | |
| 125 | + Object.values(percentages.value).reduce((sum, v) => sum + (v || 0), 0), | |
| 126 | +) | |
| 127 | + | |
| 128 | +const isValid = computed(() => { | |
| 129 | + switch (props.splitMethod) { | |
| 130 | + case 'EqualAll': | |
| 131 | + return props.participants.length > 0 | |
| 132 | + case 'EqualSubset': | |
| 133 | + return selectedUserIds.value.size > 0 | |
| 134 | + case 'ExactAmounts': | |
| 135 | + return Math.abs(exactTotal.value - props.totalAmount) < 0.01 | |
| 136 | + case 'Percentages': | |
| 137 | + return Math.abs(percentageTotal.value - 100) < 0.01 | |
| 138 | + default: | |
| 139 | + return true | |
| 140 | + } | |
| 141 | +}) | |
| 142 | + | |
| 143 | +const currentSplits = computed<IExpenseSplitCreate[]>(() => { | |
| 144 | + switch (props.splitMethod) { | |
| 145 | + case 'EqualAll': | |
| 146 | + return equalAllSplits.value | |
| 147 | + case 'EqualSubset': | |
| 148 | + return equalSubsetSplits.value | |
| 149 | + case 'ExactAmounts': | |
| 150 | + return exactSplits.value | |
| 151 | + case 'Percentages': | |
| 152 | + return percentageSplits.value | |
| 153 | + default: | |
| 154 | + return equalAllSplits.value | |
| 155 | + } | |
| 156 | +}) | |
| 157 | + | |
| 158 | +watch(currentSplits, (splits) => emit('update:splits', splits), { immediate: true, deep: true }) | |
| 159 | +watch(isValid, (v) => emit('update:valid', v), { immediate: true }) | |
| 160 | + | |
| 161 | +function getInitials(name: string | null) { | |
| 162 | + if (!name) return '?' | |
| 163 | + const parts = name.split(' ') | |
| 164 | + if (parts.length >= 2) return (parts[0]![0]! + parts[1]![0]!).toUpperCase() | |
| 165 | + return name.substring(0, 2).toUpperCase() | |
| 166 | +} | |
| 167 | +</script> | |
| 168 | + | |
| 169 | +<template> | |
| 170 | + <div class="sa-split-selector"> | |
| 171 | + <!-- EqualAll --> | |
| 172 | + <div v-if="splitMethod === 'EqualAll'" class="sa-split-info"> | |
| 173 | + <div class="sa-split-info-card"> | |
| 174 | + <i class="bi bi-people-fill me-2" style="color: var(--sa-secondary)"></i> | |
| 175 | + {{ t('expenses.split.equalAmong', { count: participants.length }) }} | |
| 176 | + <span v-if="participants.length > 0" class="ms-1" style="color: var(--sa-gray-500)"> | |
| 177 | + {{ t('expenses.split.eachGets', { amount: (totalAmount / participants.length).toFixed(2) }) }} | |
| 178 | + </span> | |
| 179 | + </div> | |
| 180 | + </div> | |
| 181 | + | |
| 182 | + <!-- EqualSubset --> | |
| 183 | + <div v-else-if="splitMethod === 'EqualSubset'"> | |
| 184 | + <div class="mb-2" style="font-size: 0.85rem; color: var(--sa-gray-500)"> | |
| 185 | + {{ t('expenses.split.selectWho') }} | |
| 186 | + </div> | |
| 187 | + <div class="sa-split-participant-list"> | |
| 188 | + <label | |
| 189 | + v-for="p in participants" | |
| 190 | + :key="p.userId" | |
| 191 | + class="sa-split-participant" | |
| 192 | + :class="{ 'sa-split-participant-selected': selectedUserIds.has(p.userId) }" | |
| 193 | + > | |
| 194 | + <input | |
| 195 | + type="checkbox" | |
| 196 | + :checked="selectedUserIds.has(p.userId)" | |
| 197 | + @change="toggleParticipant(p.userId)" | |
| 198 | + style="display: none" | |
| 199 | + /> | |
| 200 | + <span class="sa-avatar sa-avatar-xs sa-avatar-1">{{ getInitials(p.userName) }}</span> | |
| 201 | + <span class="sa-split-participant-name">{{ p.userName || p.userEmail || t('common.unknown') }}</span> | |
| 202 | + <span v-if="selectedUserIds.has(p.userId) && selectedUserIds.size > 0" class="sa-split-participant-amount"> | |
| 203 | + {{ (totalAmount / selectedUserIds.size).toFixed(2) }} | |
| 204 | + </span> | |
| 205 | + <i v-if="selectedUserIds.has(p.userId)" class="bi bi-check-circle-fill" style="color: var(--sa-success)"></i> | |
| 206 | + <i v-else class="bi bi-circle" style="color: var(--sa-gray-300)"></i> | |
| 207 | + </label> | |
| 208 | + </div> | |
| 209 | + <div v-if="selectedUserIds.size === 0" class="sa-split-error"> | |
| 210 | + <i class="bi bi-exclamation-circle me-1"></i>{{ t('expenses.split.selectAtLeastOne') }} | |
| 211 | + </div> | |
| 212 | + </div> | |
| 213 | + | |
| 214 | + <!-- ExactAmounts --> | |
| 215 | + <div v-else-if="splitMethod === 'ExactAmounts'"> | |
| 216 | + <div class="mb-2" style="font-size: 0.85rem; color: var(--sa-gray-500)"> | |
| 217 | + {{ t('expenses.split.enterExact') }} | |
| 218 | + </div> | |
| 219 | + <div class="sa-split-participant-list"> | |
| 220 | + <div v-for="p in participants" :key="p.userId" class="sa-split-participant sa-split-participant-input"> | |
| 221 | + <span class="sa-avatar sa-avatar-xs sa-avatar-1">{{ getInitials(p.userName) }}</span> | |
| 222 | + <span class="sa-split-participant-name">{{ p.userName || p.userEmail || t('common.unknown') }}</span> | |
| 223 | + <input | |
| 224 | + type="number" | |
| 225 | + step="0.01" | |
| 226 | + min="0" | |
| 227 | + class="form-control form-control-sm sa-split-amount-input" | |
| 228 | + :value="exactAmounts[p.userId] ?? 0" | |
| 229 | + @input="exactAmounts[p.userId] = parseFloat(($event.target as HTMLInputElement).value) || 0" | |
| 230 | + /> | |
| 231 | + </div> | |
| 232 | + </div> | |
| 233 | + <div class="sa-split-total" :class="{ 'sa-split-total-valid': Math.abs(exactTotal - totalAmount) < 0.01, 'sa-split-total-invalid': Math.abs(exactTotal - totalAmount) >= 0.01 }"> | |
| 234 | + {{ t('expenses.split.total') }} {{ exactTotal.toFixed(2) }} / {{ totalAmount.toFixed(2) }} | |
| 235 | + <span v-if="Math.abs(exactTotal - totalAmount) >= 0.01" class="ms-2"> | |
| 236 | + ({{ exactTotal > totalAmount ? '+' : '' }}{{ (exactTotal - totalAmount).toFixed(2) }}) | |
| 237 | + </span> | |
| 238 | + </div> | |
| 239 | + </div> | |
| 240 | + | |
| 241 | + <!-- Percentages --> | |
| 242 | + <div v-else-if="splitMethod === 'Percentages'"> | |
| 243 | + <div class="mb-2" style="font-size: 0.85rem; color: var(--sa-gray-500)"> | |
| 244 | + {{ t('expenses.split.enterPercentage') }} | |
| 245 | + </div> | |
| 246 | + <div class="sa-split-participant-list"> | |
| 247 | + <div v-for="p in participants" :key="p.userId" class="sa-split-participant sa-split-participant-input"> | |
| 248 | + <span class="sa-avatar sa-avatar-xs sa-avatar-1">{{ getInitials(p.userName) }}</span> | |
| 249 | + <span class="sa-split-participant-name">{{ p.userName || p.userEmail || t('common.unknown') }}</span> | |
| 250 | + <div class="sa-split-pct-group"> | |
| 251 | + <input | |
| 252 | + type="number" | |
| 253 | + step="0.1" | |
| 254 | + min="0" | |
| 255 | + max="100" | |
| 256 | + class="form-control form-control-sm sa-split-amount-input" | |
| 257 | + :value="percentages[p.userId] ?? 0" | |
| 258 | + @input="percentages[p.userId] = parseFloat(($event.target as HTMLInputElement).value) || 0" | |
| 259 | + /> | |
| 260 | + <span class="sa-split-pct-symbol">%</span> | |
| 261 | + </div> | |
| 262 | + <span class="sa-split-participant-amount"> | |
| 263 | + {{ ((percentages[p.userId] ?? 0) / 100 * totalAmount).toFixed(2) }} | |
| 264 | + </span> | |
| 265 | + </div> | |
| 266 | + </div> | |
| 267 | + <div class="sa-split-total" :class="{ 'sa-split-total-valid': Math.abs(percentageTotal - 100) < 0.01, 'sa-split-total-invalid': Math.abs(percentageTotal - 100) >= 0.01 }"> | |
| 268 | + {{ t('expenses.split.total') }} {{ percentageTotal.toFixed(1) }}% / 100% | |
| 269 | + <span v-if="Math.abs(percentageTotal - 100) >= 0.01" class="ms-2"> | |
| 270 | + ({{ percentageTotal > 100 ? '+' : '' }}{{ (percentageTotal - 100).toFixed(1) }}%) | |
| 271 | + </span> | |
| 272 | + </div> | |
| 273 | + </div> | |
| 274 | + </div> | |
| 275 | +</template> | |
| 276 | + | |
| 277 | +<style scoped> | |
| 278 | +.sa-split-selector { | |
| 279 | + margin-top: var(--sa-space-2); | |
| 280 | +} | |
| 281 | + | |
| 282 | +.sa-split-info-card { | |
| 283 | + background: var(--sa-gray-50); | |
| 284 | + border: 1px solid var(--sa-gray-100); | |
| 285 | + border-radius: var(--sa-radius-md); | |
| 286 | + padding: var(--sa-space-3) var(--sa-space-4); | |
| 287 | + font-size: 0.9rem; | |
| 288 | + color: var(--sa-gray-700); | |
| 289 | +} | |
| 290 | + | |
| 291 | +.sa-split-participant-list { | |
| 292 | + display: flex; | |
| 293 | + flex-direction: column; | |
| 294 | + gap: var(--sa-space-2); | |
| 295 | +} | |
| 296 | + | |
| 297 | +.sa-split-participant { | |
| 298 | + display: flex; | |
| 299 | + align-items: center; | |
| 300 | + gap: var(--sa-space-2); | |
| 301 | + padding: var(--sa-space-2) var(--sa-space-3); | |
| 302 | + border-radius: var(--sa-radius-md); | |
| 303 | + border: 1px solid var(--sa-gray-100); | |
| 304 | + cursor: pointer; | |
| 305 | + transition: all 0.15s ease; | |
| 306 | +} | |
| 307 | + | |
| 308 | +.sa-split-participant:hover { | |
| 309 | + border-color: var(--sa-gray-200); | |
| 310 | + background: var(--sa-gray-50); | |
| 311 | +} | |
| 312 | + | |
| 313 | +.sa-split-participant-selected { | |
| 314 | + border-color: var(--sa-success); | |
| 315 | + background: rgba(34, 197, 94, 0.05); | |
| 316 | +} | |
| 317 | + | |
| 318 | +.sa-split-participant-input { | |
| 319 | + cursor: default; | |
| 320 | +} | |
| 321 | + | |
| 322 | +.sa-split-participant-name { | |
| 323 | + flex: 1; | |
| 324 | + font-size: 0.875rem; | |
| 325 | + font-weight: 500; | |
| 326 | + color: var(--sa-gray-700); | |
| 327 | +} | |
| 328 | + | |
| 329 | +.sa-split-participant-amount { | |
| 330 | + font-size: 0.85rem; | |
| 331 | + font-weight: 600; | |
| 332 | + color: var(--sa-gray-500); | |
| 333 | + min-width: 60px; | |
| 334 | + text-align: right; | |
| 335 | +} | |
| 336 | + | |
| 337 | +.sa-split-amount-input { | |
| 338 | + width: 90px; | |
| 339 | + text-align: right; | |
| 340 | + font-size: 0.85rem; | |
| 341 | +} | |
| 342 | + | |
| 343 | +.sa-split-pct-group { | |
| 344 | + display: flex; | |
| 345 | + align-items: center; | |
| 346 | + gap: 2px; | |
| 347 | +} | |
| 348 | + | |
| 349 | +.sa-split-pct-symbol { | |
| 350 | + font-size: 0.85rem; | |
| 351 | + color: var(--sa-gray-400); | |
| 352 | + font-weight: 600; | |
| 353 | +} | |
| 354 | + | |
| 355 | +.sa-split-total { | |
| 356 | + margin-top: var(--sa-space-3); | |
| 357 | + padding: var(--sa-space-2) var(--sa-space-3); | |
| 358 | + border-radius: var(--sa-radius-sm); | |
| 359 | + font-size: 0.85rem; | |
| 360 | + font-weight: 600; | |
| 361 | + text-align: right; | |
| 362 | +} | |
| 363 | + | |
| 364 | +.sa-split-total-valid { | |
| 365 | + background: rgba(34, 197, 94, 0.1); | |
| 366 | + color: var(--sa-success); | |
| 367 | +} | |
| 368 | + | |
| 369 | +.sa-split-total-invalid { | |
| 370 | + background: rgba(239, 68, 68, 0.1); | |
| 371 | + color: var(--sa-danger); | |
| 372 | +} | |
| 373 | + | |
| 374 | +.sa-split-error { | |
| 375 | + margin-top: var(--sa-space-2); | |
| 376 | + font-size: 0.8rem; | |
| 377 | + color: var(--sa-danger); | |
| 378 | + font-weight: 500; | |
| 379 | +} | |
| 380 | + | |
| 381 | +.sa-avatar-xs { | |
| 382 | + width: 28px; | |
| 383 | + height: 28px; | |
| 384 | + font-size: 0.7rem; | |
| 385 | +} | |
| 386 | +</style> |
added src/components/ToastContainer.vue +33 −0
| @@ -0,0 +1,33 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import { useToast } from '@/composables/useToast' | |
| 3 | + | |
| 4 | +const { toasts, dismiss } = useToast() | |
| 5 | + | |
| 6 | +function iconClass(type: string) { | |
| 7 | + switch (type) { | |
| 8 | + case 'success': | |
| 9 | + return 'bi-check-circle-fill' | |
| 10 | + case 'error': | |
| 11 | + return 'bi-x-circle-fill' | |
| 12 | + case 'warning': | |
| 13 | + return 'bi-exclamation-triangle-fill' | |
| 14 | + default: | |
| 15 | + return 'bi-info-circle-fill' | |
| 16 | + } | |
| 17 | +} | |
| 18 | +</script> | |
| 19 | + | |
| 20 | +<template> | |
| 21 | + <div class="sa-toast-container"> | |
| 22 | + <div | |
| 23 | + v-for="toast in toasts" | |
| 24 | + :key="toast.id" | |
| 25 | + class="sa-toast" | |
| 26 | + :class="[`sa-toast-${toast.type}`, { 'sa-toast-dismiss': toast.dismissing }]" | |
| 27 | + @click="dismiss(toast.id)" | |
| 28 | + > | |
| 29 | + <i :class="['bi', iconClass(toast.type)]"></i> | |
| 30 | + <span>{{ toast.message }}</span> | |
| 31 | + </div> | |
| 32 | + </div> | |
| 33 | +</template> |
added src/composables/useToast.ts +39 −0
| @@ -0,0 +1,39 @@ | ||
| 1 | +import { ref } from 'vue' | |
| 2 | + | |
| 3 | +export interface Toast { | |
| 4 | + id: number | |
| 5 | + message: string | |
| 6 | + type: 'success' | 'error' | 'warning' | 'info' | |
| 7 | + dismissing?: boolean | |
| 8 | +} | |
| 9 | + | |
| 10 | +const toasts = ref<Toast[]>([]) | |
| 11 | +let nextId = 0 | |
| 12 | + | |
| 13 | +function addToast(message: string, type: Toast['type'], duration = 4000) { | |
| 14 | + const id = nextId++ | |
| 15 | + toasts.value.push({ id, message, type }) | |
| 16 | + | |
| 17 | + setTimeout(() => dismissToast(id), duration) | |
| 18 | +} | |
| 19 | + | |
| 20 | +function dismissToast(id: number) { | |
| 21 | + const toast = toasts.value.find((t) => t.id === id) | |
| 22 | + if (toast) { | |
| 23 | + toast.dismissing = true | |
| 24 | + setTimeout(() => { | |
| 25 | + toasts.value = toasts.value.filter((t) => t.id !== id) | |
| 26 | + }, 300) | |
| 27 | + } | |
| 28 | +} | |
| 29 | + | |
| 30 | +export function useToast() { | |
| 31 | + return { | |
| 32 | + toasts, | |
| 33 | + success: (message: string) => addToast(message, 'success'), | |
| 34 | + error: (message: string) => addToast(message, 'error', 6000), | |
| 35 | + warning: (message: string) => addToast(message, 'warning'), | |
| 36 | + info: (message: string) => addToast(message, 'info'), | |
| 37 | + dismiss: dismissToast, | |
| 38 | + } | |
| 39 | +} |
added src/directives/vAnimate.ts +23 −0
| @@ -0,0 +1,23 @@ | ||
| 1 | +import type { Directive } from 'vue' | |
| 2 | + | |
| 3 | +const observer = new IntersectionObserver( | |
| 4 | + (entries) => { | |
| 5 | + entries.forEach((entry) => { | |
| 6 | + if (entry.isIntersecting) { | |
| 7 | + entry.target.classList.add('sa-visible') | |
| 8 | + observer.unobserve(entry.target) | |
| 9 | + } | |
| 10 | + }) | |
| 11 | + }, | |
| 12 | + { threshold: 0.1 }, | |
| 13 | +) | |
| 14 | + | |
| 15 | +export const vAnimate: Directive = { | |
| 16 | + mounted(el: HTMLElement) { | |
| 17 | + el.classList.add('sa-animate-on-scroll') | |
| 18 | + observer.observe(el) | |
| 19 | + }, | |
| 20 | + unmounted(el: HTMLElement) { | |
| 21 | + observer.unobserve(el) | |
| 22 | + }, | |
| 23 | +} |
added src/i18n/index.ts +47 −0
| @@ -0,0 +1,47 @@ | ||
| 1 | +import { createI18n } from 'vue-i18n' | |
| 2 | +import en from '@/locales/en.json' | |
| 3 | +import et from '@/locales/et.json' | |
| 4 | + | |
| 5 | +export type AppLocale = 'en' | 'et' | |
| 6 | + | |
| 7 | +const numberFormats = { | |
| 8 | + en: { | |
| 9 | + currency: { style: 'currency', currency: 'EUR', notation: 'standard' }, | |
| 10 | + decimal: { style: 'decimal', minimumFractionDigits: 2, maximumFractionDigits: 2 }, | |
| 11 | + }, | |
| 12 | + et: { | |
| 13 | + currency: { style: 'currency', currency: 'EUR', notation: 'standard' }, | |
| 14 | + decimal: { style: 'decimal', minimumFractionDigits: 2, maximumFractionDigits: 2 }, | |
| 15 | + }, | |
| 16 | +} as const | |
| 17 | + | |
| 18 | +const datetimeFormats = { | |
| 19 | + en: { | |
| 20 | + short: { year: 'numeric', month: 'short', day: 'numeric' }, | |
| 21 | + long: { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' }, | |
| 22 | + }, | |
| 23 | + et: { | |
| 24 | + short: { year: 'numeric', month: 'short', day: 'numeric' }, | |
| 25 | + long: { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' }, | |
| 26 | + }, | |
| 27 | +} as const | |
| 28 | + | |
| 29 | +export const SUPPORTED_LOCALES: AppLocale[] = ['en', 'et'] | |
| 30 | +export const DEFAULT_LOCALE: AppLocale = 'en' | |
| 31 | + | |
| 32 | +function initialLocale(): AppLocale { | |
| 33 | + const saved = localStorage.getItem('locale') | |
| 34 | + if (saved === 'en' || saved === 'et') return saved | |
| 35 | + return DEFAULT_LOCALE | |
| 36 | +} | |
| 37 | + | |
| 38 | +const i18n = createI18n({ | |
| 39 | + legacy: false, | |
| 40 | + locale: initialLocale(), | |
| 41 | + fallbackLocale: DEFAULT_LOCALE, | |
| 42 | + messages: { en, et }, | |
| 43 | + numberFormats, | |
| 44 | + datetimeFormats, | |
| 45 | +}) | |
| 46 | + | |
| 47 | +export default i18n |
added src/locales/en.json +390 −0
| @@ -0,0 +1,390 @@ | ||
| 1 | +{ | |
| 2 | + "common": { | |
| 3 | + "save": "Save", | |
| 4 | + "saving": "Saving...", | |
| 5 | + "saveChanges": "Save Changes", | |
| 6 | + "cancel": "Cancel", | |
| 7 | + "delete": "Delete", | |
| 8 | + "edit": "Edit", | |
| 9 | + "create": "Create", | |
| 10 | + "creating": "Creating...", | |
| 11 | + "back": "Back", | |
| 12 | + "loading": "Loading...", | |
| 13 | + "confirm": "Confirm", | |
| 14 | + "yes": "Yes", | |
| 15 | + "no": "No", | |
| 16 | + "close": "Close", | |
| 17 | + "required": "required", | |
| 18 | + "none": "None", | |
| 19 | + "default": "Default", | |
| 20 | + "unknown": "Unknown", | |
| 21 | + "select": "Select", | |
| 22 | + "viewAll": "View All", | |
| 23 | + "goHome": "Go Home", | |
| 24 | + "goToTrips": "Go to Trips" | |
| 25 | + }, | |
| 26 | + "nav": { | |
| 27 | + "trips": "Trips", | |
| 28 | + "signIn": "Sign in", | |
| 29 | + "getStarted": "Get Started", | |
| 30 | + "logout": "Logout" | |
| 31 | + }, | |
| 32 | + "home": { | |
| 33 | + "heroBadge": "Group Travel Made Simple", | |
| 34 | + "heroTitle1": "Split expenses.", | |
| 35 | + "heroTitle2": "Plan together.", | |
| 36 | + "heroSubtitle": "The complete group travel companion. From planning and budgeting to expense tracking and debt settlement — everything in one place.", | |
| 37 | + "myTrips": "My Trips", | |
| 38 | + "getStartedFree": "Get Started Free", | |
| 39 | + "featuresTitle": "Everything your trip needs", | |
| 40 | + "featuresSubtitle": "One app to replace the spreadsheet, the calculator, and the endless group chat debates.", | |
| 41 | + "features": { | |
| 42 | + "expenseTitle": "Expense Splitting", | |
| 43 | + "expenseText": "Track who paid what and split costs equally, by exact amounts, or by percentages.", | |
| 44 | + "budgetTitle": "Budget Tracking", | |
| 45 | + "budgetText": "Set category budgets and see real-time spending progress throughout your trip.", | |
| 46 | + "wishlistTitle": "Collaborative Wishlist", | |
| 47 | + "wishlistText": "Add places, activities, and restaurants. Vote on favorites and track what you've done.", | |
| 48 | + "pollsTitle": "Group Polls", | |
| 49 | + "pollsText": "Make decisions together with polls. No more lost messages in group chats.", | |
| 50 | + "settlementTitle": "Smart Settlement", | |
| 51 | + "settlementText": "Minimize payments with optimized debt simplification and two-sided confirmation." | |
| 52 | + } | |
| 53 | + }, | |
| 54 | + "auth": { | |
| 55 | + "login": { | |
| 56 | + "welcomeBack": "Welcome back. Sign in to your account.", | |
| 57 | + "email": "Email", | |
| 58 | + "emailPlaceholder": "you{'@'}example.com", | |
| 59 | + "password": "Password", | |
| 60 | + "passwordPlaceholder": "Your password", | |
| 61 | + "signIn": "Sign in", | |
| 62 | + "signingIn": "Signing in...", | |
| 63 | + "noAccount": "Don't have an account?", | |
| 64 | + "createOne": "Create one" | |
| 65 | + }, | |
| 66 | + "register": { | |
| 67 | + "getStarted": "Get started with your free account.", | |
| 68 | + "firstName": "First name", | |
| 69 | + "lastName": "Last name", | |
| 70 | + "firstNamePlaceholder": "Jane", | |
| 71 | + "lastNamePlaceholder": "Doe", | |
| 72 | + "email": "Email", | |
| 73 | + "emailPlaceholder": "you{'@'}example.com", | |
| 74 | + "password": "Password", | |
| 75 | + "passwordPlaceholder": "Min. 6 characters", | |
| 76 | + "createAccount": "Create account", | |
| 77 | + "creatingAccount": "Creating account...", | |
| 78 | + "haveAccount": "Already have an account?", | |
| 79 | + "signIn": "Sign in" | |
| 80 | + } | |
| 81 | + }, | |
| 82 | + "trips": { | |
| 83 | + "index": { | |
| 84 | + "title": "My Trips", | |
| 85 | + "count": "{n} trip | {n} trips", | |
| 86 | + "newTrip": "New Trip", | |
| 87 | + "emptyTitle": "No trips yet", | |
| 88 | + "emptyText": "Create your first trip to start tracking expenses and planning together.", | |
| 89 | + "createFirst": "Create Your First Trip", | |
| 90 | + "confirmDelete": "Are you sure you want to delete this trip? This cannot be undone.", | |
| 91 | + "deleted": "Trip deleted successfully" | |
| 92 | + }, | |
| 93 | + "create": { | |
| 94 | + "title": "Plan a new trip", | |
| 95 | + "subtitle": "Add the basics — you can invite people and log expenses afterwards.", | |
| 96 | + "name": "Trip name", | |
| 97 | + "namePlaceholder": "e.g. Rome weekend 2026", | |
| 98 | + "destination": "Destination", | |
| 99 | + "destinationPlaceholder": "Where are you going?", | |
| 100 | + "description": "Description", | |
| 101 | + "descriptionPlaceholder": "A short note about the trip (optional)", | |
| 102 | + "start": "Start", | |
| 103 | + "end": "End", | |
| 104 | + "nightCount": "{n} night | {n} nights", | |
| 105 | + "defaultCurrency": "Default currency", | |
| 106 | + "selectCurrency": "Select currency", | |
| 107 | + "createTrip": "Create trip", | |
| 108 | + "creating": "Creating…", | |
| 109 | + "dateError": "End date must be on or after start date" | |
| 110 | + }, | |
| 111 | + "edit": { | |
| 112 | + "title": "Edit Trip", | |
| 113 | + "name": "Name", | |
| 114 | + "destination": "Destination", | |
| 115 | + "description": "Description", | |
| 116 | + "startDate": "Start Date", | |
| 117 | + "endDate": "End Date", | |
| 118 | + "currency": "Currency", | |
| 119 | + "selectCurrency": "Select currency", | |
| 120 | + "status": "Status", | |
| 121 | + "confirmDelete": "Are you sure you want to delete this trip?", | |
| 122 | + "notFound": "Trip not found" | |
| 123 | + }, | |
| 124 | + "detail": { | |
| 125 | + "organizer": "Organizer", | |
| 126 | + "participant": "Participant", | |
| 127 | + "memberCount": "{n} member | {n} members", | |
| 128 | + "totalExpenses": "Total Expenses", | |
| 129 | + "budgetUsed": "Budget Used", | |
| 130 | + "yourBalance": "Your Balance", | |
| 131 | + "members": "Members", | |
| 132 | + "budgetProgress": "Budget Progress", | |
| 133 | + "balances": "Balances", | |
| 134 | + "recentExpenses": "Recent Expenses", | |
| 135 | + "about": "About This Trip", | |
| 136 | + "backTo": "Back to {name}", | |
| 137 | + "untitledExpense": "Untitled expense" | |
| 138 | + }, | |
| 139 | + "status": { | |
| 140 | + "Active": "Active", | |
| 141 | + "Completed": "Completed", | |
| 142 | + "Finalizing": "Finalizing", | |
| 143 | + "Settled": "Settled" | |
| 144 | + } | |
| 145 | + }, | |
| 146 | + "expenses": { | |
| 147 | + "index": { | |
| 148 | + "title": "Expenses", | |
| 149 | + "total": "Total:", | |
| 150 | + "addExpense": "Add Expense", | |
| 151 | + "emptyTitle": "No expenses yet", | |
| 152 | + "emptyText": "Add your first expense to start tracking spending.", | |
| 153 | + "untitled": "Untitled expense", | |
| 154 | + "uncategorized": "Uncategorized", | |
| 155 | + "unknown": "Unknown", | |
| 156 | + "confirmDelete": "Delete this expense?", | |
| 157 | + "deleted": "Expense deleted" | |
| 158 | + }, | |
| 159 | + "create": { | |
| 160 | + "title": "Add Expense", | |
| 161 | + "amount": "Amount", | |
| 162 | + "description": "Description", | |
| 163 | + "descriptionPlaceholder": "What was this expense for?", | |
| 164 | + "date": "Date", | |
| 165 | + "paidBy": "Paid by", | |
| 166 | + "selectPayer": "Select payer", | |
| 167 | + "splitMethod": "Split Method", | |
| 168 | + "budgetCategory": "Budget Category", | |
| 169 | + "currency": "Currency", | |
| 170 | + "addExpense": "Add Expense", | |
| 171 | + "added": "Expense added" | |
| 172 | + }, | |
| 173 | + "edit": { | |
| 174 | + "title": "Edit Expense", | |
| 175 | + "confirmDelete": "Are you sure you want to delete this expense?", | |
| 176 | + "updated": "Expense updated", | |
| 177 | + "deleted": "Expense deleted", | |
| 178 | + "notFound": "Expense not found" | |
| 179 | + }, | |
| 180 | + "splitMethod": { | |
| 181 | + "EqualAll": "Equal All", | |
| 182 | + "EqualSubset": "Equal Select", | |
| 183 | + "ExactAmounts": "Exact", | |
| 184 | + "Percentages": "Percentage" | |
| 185 | + }, | |
| 186 | + "split": { | |
| 187 | + "equalAmong": "Split equally among all {count} members", | |
| 188 | + "eachGets": "({amount} each)", | |
| 189 | + "selectWho": "Select who to split with:", | |
| 190 | + "selectAtLeastOne": "Select at least one person", | |
| 191 | + "enterExact": "Enter exact amount for each person:", | |
| 192 | + "enterPercentage": "Enter percentage for each person:", | |
| 193 | + "total": "Total:" | |
| 194 | + } | |
| 195 | + }, | |
| 196 | + "budget": { | |
| 197 | + "index": { | |
| 198 | + "title": "Budget", | |
| 199 | + "categoryCount": "{n} category | {n} categories", | |
| 200 | + "addCategory": "Add Category", | |
| 201 | + "emptyTitle": "No budget categories yet", | |
| 202 | + "emptyText": "Add one to start tracking your spending against planned amounts.", | |
| 203 | + "totalPlanned": "Total Planned", | |
| 204 | + "totalSpent": "Total Spent", | |
| 205 | + "remaining": "Remaining", | |
| 206 | + "overallBudget": "Overall Budget", | |
| 207 | + "overBudget": "Over budget!", | |
| 208 | + "confirmDelete": "Delete this budget category?", | |
| 209 | + "deleted": "Category deleted" | |
| 210 | + }, | |
| 211 | + "create": { | |
| 212 | + "title": "Add Budget Category", | |
| 213 | + "name": "NAME", | |
| 214 | + "namePlaceholder": "e.g. Food, Transport", | |
| 215 | + "iconName": "ICON NAME", | |
| 216 | + "iconNamePlaceholder": "e.g. cup-hot-fill, car-front-fill", | |
| 217 | + "iconHelp": "Bootstrap Icons name without the \"bi-\" prefix", | |
| 218 | + "plannedAmount": "PLANNED AMOUNT", | |
| 219 | + "displayOrder": "DISPLAY ORDER", | |
| 220 | + "addCategory": "Add Category", | |
| 221 | + "created": "Category created" | |
| 222 | + }, | |
| 223 | + "edit": { | |
| 224 | + "title": "Edit Budget Category", | |
| 225 | + "spentAmount": "SPENT AMOUNT", | |
| 226 | + "confirmDelete": "Are you sure you want to delete this budget category?", | |
| 227 | + "updated": "Category updated", | |
| 228 | + "notFound": "Budget category not found" | |
| 229 | + } | |
| 230 | + }, | |
| 231 | + "members": { | |
| 232 | + "title": "Members", | |
| 233 | + "count": "{n} member | {n} members", | |
| 234 | + "invite": "Invite", | |
| 235 | + "createInvitation": "Create Invitation", | |
| 236 | + "emptyTitle": "No members yet", | |
| 237 | + "emptyText": "Invite people to join your trip and start planning together.", | |
| 238 | + "invitationCreated": "Invitation link created!", | |
| 239 | + "shareLink": "Share this link to invite someone to your trip:", | |
| 240 | + "copyLink": "Copy link", | |
| 241 | + "copied": "Link copied to clipboard!", | |
| 242 | + "copyFailed": "Failed to copy link", | |
| 243 | + "joined": "Joined {date}", | |
| 244 | + "role": { | |
| 245 | + "Organizer": "Organizer", | |
| 246 | + "Participant": "Participant" | |
| 247 | + } | |
| 248 | + }, | |
| 249 | + "invitations": { | |
| 250 | + "accept": { | |
| 251 | + "title": "Trip Invitation", | |
| 252 | + "unavailable": "Invitation Unavailable", | |
| 253 | + "allDone": "All Done", | |
| 254 | + "aTrip": "A trip", | |
| 255 | + "invitedBy": "Invited by {name}", | |
| 256 | + "expires": "Expires: {date}", | |
| 257 | + "accept": "Accept", | |
| 258 | + "decline": "Decline", | |
| 259 | + "accepted": "Invitation accepted! You have been added to the trip.", | |
| 260 | + "declined": "Invitation declined." | |
| 261 | + } | |
| 262 | + }, | |
| 263 | + "settlements": { | |
| 264 | + "title": "Settlement", | |
| 265 | + "loading": "Loading settlement...", | |
| 266 | + "somethingWrong": "Something went wrong", | |
| 267 | + "allSettled": "All settled up!", | |
| 268 | + "allSettledText": "There are no outstanding balances or payments.", | |
| 269 | + "balances": "Balances", | |
| 270 | + "noBalances": "No balances to show. Add expenses first.", | |
| 271 | + "suggestedPayments": "Suggested Payments", | |
| 272 | + "preview": "Preview", | |
| 273 | + "previewHint": "These are suggested payments. Finalize the trip to lock them in.", | |
| 274 | + "settlementPlan": "Settlement Plan", | |
| 275 | + "total": "Total", | |
| 276 | + "finalize": "Finalize Trip", | |
| 277 | + "reopen": "Reopen", | |
| 278 | + "finalizing": "Finalizing", | |
| 279 | + "tripSettled": "Trip Settled", | |
| 280 | + "markPaid": "Mark Paid", | |
| 281 | + "confirm": "Confirm", | |
| 282 | + "awaitingConfirmation": "Awaiting Confirmation", | |
| 283 | + "pending": "Pending", | |
| 284 | + "confirmed": "Confirmed", | |
| 285 | + "markedPaid": "MarkedPaid", | |
| 286 | + "finalizeSuccess": "Trip finalized — settlement plan created", | |
| 287 | + "finalizeFailed": "Failed to finalize trip", | |
| 288 | + "reopenSuccess": "Trip reopened — you can add expenses again", | |
| 289 | + "reopenFailed": "Failed to reopen trip", | |
| 290 | + "markPaidSuccess": "Payment marked as paid", | |
| 291 | + "markPaidFailed": "Failed to mark payment as paid", | |
| 292 | + "confirmSuccess": "Payment confirmed", | |
| 293 | + "confirmFailed": "Failed to confirm payment" | |
| 294 | + }, | |
| 295 | + "polls": { | |
| 296 | + "index": { | |
| 297 | + "title": "Polls", | |
| 298 | + "newPoll": "New Poll", | |
| 299 | + "emptyTitle": "No polls yet", | |
| 300 | + "emptyText": "Create a poll to start voting with your group.", | |
| 301 | + "createFirst": "Create First Poll", | |
| 302 | + "optionCount": "{n} options", | |
| 303 | + "multipleVotes": "Multiple votes", | |
| 304 | + "viewResults": "View Results", | |
| 305 | + "vote": "Vote" | |
| 306 | + }, | |
| 307 | + "create": { | |
| 308 | + "title": "New Poll", | |
| 309 | + "question": "Question", | |
| 310 | + "questionPlaceholder": "What would you like to ask?", | |
| 311 | + "allowMultiple": "Allow multiple votes", | |
| 312 | + "allowMultipleHelp": "Members can vote for more than one option", | |
| 313 | + "anonymous": "Anonymous voting", | |
| 314 | + "anonymousHelp": "Votes are hidden from other members", | |
| 315 | + "options": "Options", | |
| 316 | + "optionPlaceholder": "Option {n}", | |
| 317 | + "addOption": "Add Option", | |
| 318 | + "createPoll": "Create Poll", | |
| 319 | + "creating": "Creating...", | |
| 320 | + "atLeastTwo": "At least 2 options are required" | |
| 321 | + }, | |
| 322 | + "detail": { | |
| 323 | + "yourVote": "Your Vote", | |
| 324 | + "winner": "Winner", | |
| 325 | + "voteCount": "{n} vote | {n} votes", | |
| 326 | + "unvote": "Unvote", | |
| 327 | + "vote": "Vote", | |
| 328 | + "closePoll": "Close Poll", | |
| 329 | + "backToPolls": "Back to Polls", | |
| 330 | + "confirmClose": "Close this poll? No more votes will be accepted.", | |
| 331 | + "confirmDelete": "Delete this poll?", | |
| 332 | + "voteRecorded": "Vote recorded", | |
| 333 | + "closed": "Poll closed", | |
| 334 | + "deleted": "Poll deleted" | |
| 335 | + }, | |
| 336 | + "status": { | |
| 337 | + "Open": "Open", | |
| 338 | + "Closed": "Closed" | |
| 339 | + } | |
| 340 | + }, | |
| 341 | + "wishlist": { | |
| 342 | + "index": { | |
| 343 | + "title": "Wishlist", | |
| 344 | + "itemCount": "{n} item | {n} items", | |
| 345 | + "addItem": "Add Item", | |
| 346 | + "emptyTitle": "No wishlist items yet", | |
| 347 | + "emptyText": "Add something you'd like to do on this trip!", | |
| 348 | + "addedBy": "Added by {name}", | |
| 349 | + "complete": "Complete", | |
| 350 | + "undo": "Undo", | |
| 351 | + "confirmDelete": "Delete this wishlist item?", | |
| 352 | + "completed": "Item marked as complete!", | |
| 353 | + "uncompleted": "Item marked as not complete", | |
| 354 | + "deleted": "Item deleted" | |
| 355 | + }, | |
| 356 | + "create": { | |
| 357 | + "title": "Add Wishlist Item", | |
| 358 | + "itemTitle": "Title", | |
| 359 | + "titlePlaceholder": "What do you want to do?", | |
| 360 | + "description": "Description", | |
| 361 | + "descriptionPlaceholder": "Add some details...", | |
| 362 | + "category": "Category", | |
| 363 | + "priority": "Priority", | |
| 364 | + "estimatedCost": "Estimated Cost", | |
| 365 | + "url": "URL", | |
| 366 | + "location": "Location", | |
| 367 | + "locationPlaceholder": "Where is it?", | |
| 368 | + "addItem": "Add Item", | |
| 369 | + "saving": "Saving...", | |
| 370 | + "pleaseFix": "Please fix the following:" | |
| 371 | + }, | |
| 372 | + "edit": { | |
| 373 | + "title": "Edit Wishlist Item", | |
| 374 | + "somethingWrong": "Something went wrong", | |
| 375 | + "confirmDelete": "Are you sure you want to delete this wishlist item?", | |
| 376 | + "notFound": "Wishlist item not found" | |
| 377 | + }, | |
| 378 | + "category": { | |
| 379 | + "Place": "Place", | |
| 380 | + "Activity": "Activity", | |
| 381 | + "Restaurant": "Restaurant", | |
| 382 | + "Other": "Other" | |
| 383 | + }, | |
| 384 | + "priority": { | |
| 385 | + "MustDo": "Must Do", | |
| 386 | + "NiceToHave": "Nice to Have", | |
| 387 | + "Optional": "Optional" | |
| 388 | + } | |
| 389 | + } | |
| 390 | +} |
added src/locales/et.json +390 −0
| @@ -0,0 +1,390 @@ | ||
| 1 | +{ | |
| 2 | + "common": { | |
| 3 | + "save": "Salvesta", | |
| 4 | + "saving": "Salvestan...", | |
| 5 | + "saveChanges": "Salvesta muudatused", | |
| 6 | + "cancel": "Tühista", | |
| 7 | + "delete": "Kustuta", | |
| 8 | + "edit": "Muuda", | |
| 9 | + "create": "Loo", | |
| 10 | + "creating": "Loon...", | |
| 11 | + "back": "Tagasi", | |
| 12 | + "loading": "Laadin...", | |
| 13 | + "confirm": "Kinnita", | |
| 14 | + "yes": "Jah", | |
| 15 | + "no": "Ei", | |
| 16 | + "close": "Sulge", | |
| 17 | + "required": "kohustuslik", | |
| 18 | + "none": "Puudub", | |
| 19 | + "default": "Vaikimisi", | |
| 20 | + "unknown": "Tundmatu", | |
| 21 | + "select": "Vali", | |
| 22 | + "viewAll": "Vaata kõiki", | |
| 23 | + "goHome": "Avalehele", | |
| 24 | + "goToTrips": "Reisid" | |
| 25 | + }, | |
| 26 | + "nav": { | |
| 27 | + "trips": "Reisid", | |
| 28 | + "signIn": "Logi sisse", | |
| 29 | + "getStarted": "Alusta", | |
| 30 | + "logout": "Logi välja" | |
| 31 | + }, | |
| 32 | + "home": { | |
| 33 | + "heroBadge": "Grupireisi lihtsus", | |
| 34 | + "heroTitle1": "Jaga kulusid.", | |
| 35 | + "heroTitle2": "Planeeri koos.", | |
| 36 | + "heroSubtitle": "Täielik grupireisi kaaslane. Planeerimisest ja eelarvestamisest kuluarvestuse ja võlgade arvelduseni — kõik ühes kohas.", | |
| 37 | + "myTrips": "Minu reisid", | |
| 38 | + "getStartedFree": "Alusta tasuta", | |
| 39 | + "featuresTitle": "Kõik mida reis vajab", | |
| 40 | + "featuresSubtitle": "Üks rakendus, mis asendab tabelarvutuse, kalkulaatori ja lõputud grupivestlused.", | |
| 41 | + "features": { | |
| 42 | + "expenseTitle": "Kulude jagamine", | |
| 43 | + "expenseText": "Jälgi, kes mille eest maksis, ja jaga kulud võrdselt, täpsete summade või protsentide järgi.", | |
| 44 | + "budgetTitle": "Eelarve jälgimine", | |
| 45 | + "budgetText": "Sea kategooriate eelarved ja näe reaalajas kulutuste progressi kogu reisi vältel.", | |
| 46 | + "wishlistTitle": "Ühine soovinimekiri", | |
| 47 | + "wishlistText": "Lisa kohti, tegevusi ja restorane. Hääleta lemmikute poolt ja jälgi, mis juba tehtud.", | |
| 48 | + "pollsTitle": "Grupiküsitlused", | |
| 49 | + "pollsText": "Tehke otsuseid koos küsitluste abil. Lõpp kadunud sõnumitele grupivestlustes.", | |
| 50 | + "settlementTitle": "Tark arveldus", | |
| 51 | + "settlementText": "Minimeeri makseid optimeeritud võlasimplifikatsiooni ja kahepoolse kinnitusega." | |
| 52 | + } | |
| 53 | + }, | |
| 54 | + "auth": { | |
| 55 | + "login": { | |
| 56 | + "welcomeBack": "Tere tulemast tagasi. Logi oma kontole sisse.", | |
| 57 | + "email": "E-post", | |
| 58 | + "emailPlaceholder": "sina{'@'}näide.ee", | |
| 59 | + "password": "Parool", | |
| 60 | + "passwordPlaceholder": "Sinu parool", | |
| 61 | + "signIn": "Logi sisse", | |
| 62 | + "signingIn": "Sisselogimine...", | |
| 63 | + "noAccount": "Pole veel kontot?", | |
| 64 | + "createOne": "Loo konto" | |
| 65 | + }, | |
| 66 | + "register": { | |
| 67 | + "getStarted": "Alusta tasuta kontoga.", | |
| 68 | + "firstName": "Eesnimi", | |
| 69 | + "lastName": "Perekonnanimi", | |
| 70 | + "firstNamePlaceholder": "Mari", | |
| 71 | + "lastNamePlaceholder": "Maasikas", | |
| 72 | + "email": "E-post", | |
| 73 | + "emailPlaceholder": "sina{'@'}näide.ee", | |
| 74 | + "password": "Parool", | |
| 75 | + "passwordPlaceholder": "Vähemalt 6 tähemärki", | |
| 76 | + "createAccount": "Loo konto", | |
| 77 | + "creatingAccount": "Konto loomine...", | |
| 78 | + "haveAccount": "Konto juba olemas?", | |
| 79 | + "signIn": "Logi sisse" | |
| 80 | + } | |
| 81 | + }, | |
| 82 | + "trips": { | |
| 83 | + "index": { | |
| 84 | + "title": "Minu reisid", | |
| 85 | + "count": "{n} reis | {n} reisi", | |
| 86 | + "newTrip": "Uus reis", | |
| 87 | + "emptyTitle": "Reise veel pole", | |
| 88 | + "emptyText": "Loo oma esimene reis, et alustada kulude jälgimist ja koos planeerimist.", | |
| 89 | + "createFirst": "Loo esimene reis", | |
| 90 | + "confirmDelete": "Oled kindel, et soovid selle reisi kustutada? Seda ei saa tagasi võtta.", | |
| 91 | + "deleted": "Reis edukalt kustutatud" | |
| 92 | + }, | |
| 93 | + "create": { | |
| 94 | + "title": "Planeeri uus reis", | |
| 95 | + "subtitle": "Lisa põhilised andmed — inimesi saad kutsuda ja kulusid lisada hiljem.", | |
| 96 | + "name": "Reisi nimi", | |
| 97 | + "namePlaceholder": "nt Rooma nädalavahetus 2026", | |
| 98 | + "destination": "Sihtkoht", | |
| 99 | + "destinationPlaceholder": "Kuhu sa lähed?", | |
| 100 | + "description": "Kirjeldus", | |
| 101 | + "descriptionPlaceholder": "Lühike märge reisi kohta (valikuline)", | |
| 102 | + "start": "Algus", | |
| 103 | + "end": "Lõpp", | |
| 104 | + "nightCount": "{n} öö | {n} ööd", | |
| 105 | + "defaultCurrency": "Vaikevaluuta", | |
| 106 | + "selectCurrency": "Vali valuuta", | |
| 107 | + "createTrip": "Loo reis", | |
| 108 | + "creating": "Loon…", | |
| 109 | + "dateError": "Lõppkuupäev peab olema alguskuupäeval või hiljem" | |
| 110 | + }, | |
| 111 | + "edit": { | |
| 112 | + "title": "Muuda reisi", | |
| 113 | + "name": "Nimi", | |
| 114 | + "destination": "Sihtkoht", | |
| 115 | + "description": "Kirjeldus", | |
| 116 | + "startDate": "Alguskuupäev", | |
| 117 | + "endDate": "Lõppkuupäev", | |
| 118 | + "currency": "Valuuta", | |
| 119 | + "selectCurrency": "Vali valuuta", | |
| 120 | + "status": "Olek", | |
| 121 | + "confirmDelete": "Oled kindel, et soovid selle reisi kustutada?", | |
| 122 | + "notFound": "Reisi ei leitud" | |
| 123 | + }, | |
| 124 | + "detail": { | |
| 125 | + "organizer": "Korraldaja", | |
| 126 | + "participant": "Osaleja", | |
| 127 | + "memberCount": "{n} liige | {n} liiget", | |
| 128 | + "totalExpenses": "Kulud kokku", | |
| 129 | + "budgetUsed": "Eelarvest kasutatud", | |
| 130 | + "yourBalance": "Sinu saldo", | |
| 131 | + "members": "Liikmed", | |
| 132 | + "budgetProgress": "Eelarve progress", | |
| 133 | + "balances": "Saldod", | |
| 134 | + "recentExpenses": "Hiljutised kulud", | |
| 135 | + "about": "Reisi kohta", | |
| 136 | + "backTo": "Tagasi reisi \"{name}\" juurde", | |
| 137 | + "untitledExpense": "Pealkirjata kulu" | |
| 138 | + }, | |
| 139 | + "status": { | |
| 140 | + "Active": "Aktiivne", | |
| 141 | + "Completed": "Lõpetatud", | |
| 142 | + "Finalizing": "Lõpetamine", | |
| 143 | + "Settled": "Arveldatud" | |
| 144 | + } | |
| 145 | + }, | |
| 146 | + "expenses": { | |
| 147 | + "index": { | |
| 148 | + "title": "Kulud", | |
| 149 | + "total": "Kokku:", | |
| 150 | + "addExpense": "Lisa kulu", | |
| 151 | + "emptyTitle": "Kulusid veel pole", | |
| 152 | + "emptyText": "Lisa esimene kulu, et alustada kulutuste jälgimist.", | |
| 153 | + "untitled": "Pealkirjata kulu", | |
| 154 | + "uncategorized": "Kategoriseerimata", | |
| 155 | + "unknown": "Tundmatu", | |
| 156 | + "confirmDelete": "Kas kustutan selle kulu?", | |
| 157 | + "deleted": "Kulu kustutatud" | |
| 158 | + }, | |
| 159 | + "create": { | |
| 160 | + "title": "Lisa kulu", | |
| 161 | + "amount": "Summa", | |
| 162 | + "description": "Kirjeldus", | |
| 163 | + "descriptionPlaceholder": "Mille eest see kulu oli?", | |
| 164 | + "date": "Kuupäev", | |
| 165 | + "paidBy": "Maksja", | |
| 166 | + "selectPayer": "Vali maksja", | |
| 167 | + "splitMethod": "Jagamise meetod", | |
| 168 | + "budgetCategory": "Eelarvekategooria", | |
| 169 | + "currency": "Valuuta", | |
| 170 | + "addExpense": "Lisa kulu", | |
| 171 | + "added": "Kulu lisatud" | |
| 172 | + }, | |
| 173 | + "edit": { | |
| 174 | + "title": "Muuda kulu", | |
| 175 | + "confirmDelete": "Oled kindel, et soovid selle kulu kustutada?", | |
| 176 | + "updated": "Kulu uuendatud", | |
| 177 | + "deleted": "Kulu kustutatud", | |
| 178 | + "notFound": "Kulu ei leitud" | |
| 179 | + }, | |
| 180 | + "splitMethod": { | |
| 181 | + "EqualAll": "Võrdselt kõik", | |
| 182 | + "EqualSubset": "Võrdselt valitud", | |
| 183 | + "ExactAmounts": "Täpne", | |
| 184 | + "Percentages": "Protsent" | |
| 185 | + }, | |
| 186 | + "split": { | |
| 187 | + "equalAmong": "Jagatud võrdselt kõigi {count} liikme vahel", | |
| 188 | + "eachGets": "({amount} kumbki)", | |
| 189 | + "selectWho": "Vali, kellega jagad:", | |
| 190 | + "selectAtLeastOne": "Vali vähemalt üks inimene", | |
| 191 | + "enterExact": "Sisesta täpne summa iga inimese kohta:", | |
| 192 | + "enterPercentage": "Sisesta protsent iga inimese kohta:", | |
| 193 | + "total": "Kokku:" | |
| 194 | + } | |
| 195 | + }, | |
| 196 | + "budget": { | |
| 197 | + "index": { | |
| 198 | + "title": "Eelarve", | |
| 199 | + "categoryCount": "{n} kategooria | {n} kategooriat", | |
| 200 | + "addCategory": "Lisa kategooria", | |
| 201 | + "emptyTitle": "Eelarvekategooriaid veel pole", | |
| 202 | + "emptyText": "Lisa üks, et alustada kulutuste jälgimist plaanitud summade vastu.", | |
| 203 | + "totalPlanned": "Plaanitud kokku", | |
| 204 | + "totalSpent": "Kulutatud kokku", | |
| 205 | + "remaining": "Järelejäänud", | |
| 206 | + "overallBudget": "Üldine eelarve", | |
| 207 | + "overBudget": "Üle eelarve!", | |
| 208 | + "confirmDelete": "Kas kustutan selle eelarvekategooria?", | |
| 209 | + "deleted": "Kategooria kustutatud" | |
| 210 | + }, | |
| 211 | + "create": { | |
| 212 | + "title": "Lisa eelarvekategooria", | |
| 213 | + "name": "NIMI", | |
| 214 | + "namePlaceholder": "nt Toit, Transport", | |
| 215 | + "iconName": "IKOONI NIMI", | |
| 216 | + "iconNamePlaceholder": "nt cup-hot-fill, car-front-fill", | |
| 217 | + "iconHelp": "Bootstrap Icons'i nimi ilma \"bi-\" eesliiteta", | |
| 218 | + "plannedAmount": "PLAANITUD SUMMA", | |
| 219 | + "displayOrder": "KUVAMISE JÄRJEKORD", | |
| 220 | + "addCategory": "Lisa kategooria", | |
| 221 | + "created": "Kategooria loodud" | |
| 222 | + }, | |
| 223 | + "edit": { | |
| 224 | + "title": "Muuda eelarvekategooriat", | |
| 225 | + "spentAmount": "KULUTATUD SUMMA", | |
| 226 | + "confirmDelete": "Oled kindel, et soovid selle eelarvekategooria kustutada?", | |
| 227 | + "updated": "Kategooria uuendatud", | |
| 228 | + "notFound": "Eelarvekategooriat ei leitud" | |
| 229 | + } | |
| 230 | + }, | |
| 231 | + "members": { | |
| 232 | + "title": "Liikmed", | |
| 233 | + "count": "{n} liige | {n} liiget", | |
| 234 | + "invite": "Kutsu", | |
| 235 | + "createInvitation": "Loo kutse", | |
| 236 | + "emptyTitle": "Liikmeid veel pole", | |
| 237 | + "emptyText": "Kutsu inimesi reisiga liituma ja hakake koos planeerima.", | |
| 238 | + "invitationCreated": "Kutselink loodud!", | |
| 239 | + "shareLink": "Jaga seda linki, et kutsuda kedagi oma reisile:", | |
| 240 | + "copyLink": "Kopeeri link", | |
| 241 | + "copied": "Link kopeeritud lõikelauale!", | |
| 242 | + "copyFailed": "Lingi kopeerimine ebaõnnestus", | |
| 243 | + "joined": "Liitus {date}", | |
| 244 | + "role": { | |
| 245 | + "Organizer": "Korraldaja", | |
| 246 | + "Participant": "Osaleja" | |
| 247 | + } | |
| 248 | + }, | |
| 249 | + "invitations": { | |
| 250 | + "accept": { | |
| 251 | + "title": "Reisikutse", | |
| 252 | + "unavailable": "Kutse pole saadaval", | |
| 253 | + "allDone": "Valmis", | |
| 254 | + "aTrip": "Reis", | |
| 255 | + "invitedBy": "Kutsus {name}", | |
| 256 | + "expires": "Aegub: {date}", | |
| 257 | + "accept": "Võta vastu", | |
| 258 | + "decline": "Keeldu", | |
| 259 | + "accepted": "Kutse vastu võetud! Sind lisati reisile.", | |
| 260 | + "declined": "Kutse tagasi lükatud." | |
| 261 | + } | |
| 262 | + }, | |
| 263 | + "settlements": { | |
| 264 | + "title": "Arveldus", | |
| 265 | + "loading": "Laadin arveldust...", | |
| 266 | + "somethingWrong": "Midagi läks valesti", | |
| 267 | + "allSettled": "Kõik arveldatud!", | |
| 268 | + "allSettledText": "Pooleliolevaid saldode ega makseid pole.", | |
| 269 | + "balances": "Saldod", | |
| 270 | + "noBalances": "Saldode näitamiseks pole andmeid. Lisa esmalt kulud.", | |
| 271 | + "suggestedPayments": "Soovitatud maksed", | |
| 272 | + "preview": "Eelvaade", | |
| 273 | + "previewHint": "Need on soovitatud maksed. Lõpeta reis, et need fikseerida.", | |
| 274 | + "settlementPlan": "Arvelduseplaan", | |
| 275 | + "total": "Kokku", | |
| 276 | + "finalize": "Lõpeta reis", | |
| 277 | + "reopen": "Ava uuesti", | |
| 278 | + "finalizing": "Lõpetamine", | |
| 279 | + "tripSettled": "Reis arveldatud", | |
| 280 | + "markPaid": "Märgi makstuks", | |
| 281 | + "confirm": "Kinnita", | |
| 282 | + "awaitingConfirmation": "Ootab kinnitust", | |
| 283 | + "pending": "Ootel", | |
| 284 | + "confirmed": "Kinnitatud", | |
| 285 | + "markedPaid": "Märgitud makstuks", | |
| 286 | + "finalizeSuccess": "Reis lõpetatud — arvelduseplaan loodud", | |
| 287 | + "finalizeFailed": "Reisi lõpetamine ebaõnnestus", | |
| 288 | + "reopenSuccess": "Reis uuesti avatud — saad taas kulusid lisada", | |
| 289 | + "reopenFailed": "Reisi uuesti avamine ebaõnnestus", | |
| 290 | + "markPaidSuccess": "Makse märgitud makstuks", | |
| 291 | + "markPaidFailed": "Makse märgimine ebaõnnestus", | |
| 292 | + "confirmSuccess": "Makse kinnitatud", | |
| 293 | + "confirmFailed": "Makse kinnitamine ebaõnnestus" | |
| 294 | + }, | |
| 295 | + "polls": { | |
| 296 | + "index": { | |
| 297 | + "title": "Küsitlused", | |
| 298 | + "newPoll": "Uus küsitlus", | |
| 299 | + "emptyTitle": "Küsitlusi veel pole", | |
| 300 | + "emptyText": "Loo küsitlus, et grupiga hääletama hakata.", | |
| 301 | + "createFirst": "Loo esimene küsitlus", | |
| 302 | + "optionCount": "{n} valikut", | |
| 303 | + "multipleVotes": "Mitu häält", | |
| 304 | + "viewResults": "Vaata tulemusi", | |
| 305 | + "vote": "Hääleta" | |
| 306 | + }, | |
| 307 | + "create": { | |
| 308 | + "title": "Uus küsitlus", | |
| 309 | + "question": "Küsimus", | |
| 310 | + "questionPlaceholder": "Mida sa küsida tahaksid?", | |
| 311 | + "allowMultiple": "Luba mitu häält", | |
| 312 | + "allowMultipleHelp": "Liikmed saavad hääletada mitme valiku poolt", | |
| 313 | + "anonymous": "Anonüümne hääletus", | |
| 314 | + "anonymousHelp": "Hääled on teistele liikmetele peidetud", | |
| 315 | + "options": "Valikud", | |
| 316 | + "optionPlaceholder": "Valik {n}", | |
| 317 | + "addOption": "Lisa valik", | |
| 318 | + "createPoll": "Loo küsitlus", | |
| 319 | + "creating": "Loon...", | |
| 320 | + "atLeastTwo": "Vaja on vähemalt 2 valikut" | |
| 321 | + }, | |
| 322 | + "detail": { | |
| 323 | + "yourVote": "Sinu hääl", | |
| 324 | + "winner": "Võitja", | |
| 325 | + "voteCount": "{n} hääl | {n} häält", | |
| 326 | + "unvote": "Tühista hääl", | |
| 327 | + "vote": "Hääleta", | |
| 328 | + "closePoll": "Sulge küsitlus", | |
| 329 | + "backToPolls": "Tagasi küsitluste juurde", | |
| 330 | + "confirmClose": "Kas sulgen selle küsitluse? Uusi hääli enam vastu ei võeta.", | |
| 331 | + "confirmDelete": "Kas kustutan selle küsitluse?", | |
| 332 | + "voteRecorded": "Hääl salvestatud", | |
| 333 | + "closed": "Küsitlus suletud", | |
| 334 | + "deleted": "Küsitlus kustutatud" | |
| 335 | + }, | |
| 336 | + "status": { | |
| 337 | + "Open": "Avatud", | |
| 338 | + "Closed": "Suletud" | |
| 339 | + } | |
| 340 | + }, | |
| 341 | + "wishlist": { | |
| 342 | + "index": { | |
| 343 | + "title": "Soovinimekiri", | |
| 344 | + "itemCount": "{n} kirje | {n} kirjet", | |
| 345 | + "addItem": "Lisa kirje", | |
| 346 | + "emptyTitle": "Soovinimekirjas pole veel ühtegi kirjet", | |
| 347 | + "emptyText": "Lisa midagi, mida sa selle reisi ajal teha tahaksid!", | |
| 348 | + "addedBy": "Lisas {name}", | |
| 349 | + "complete": "Tehtud", | |
| 350 | + "undo": "Võta tagasi", | |
| 351 | + "confirmDelete": "Kas kustutan selle soovinimekirja kirje?", | |
| 352 | + "completed": "Kirje märgitud tehtuks!", | |
| 353 | + "uncompleted": "Kirje märgitud tegemata", | |
| 354 | + "deleted": "Kirje kustutatud" | |
| 355 | + }, | |
| 356 | + "create": { | |
| 357 | + "title": "Lisa soovinimekirja kirje", | |
| 358 | + "itemTitle": "Pealkiri", | |
| 359 | + "titlePlaceholder": "Mida sa teha tahaksid?", | |
| 360 | + "description": "Kirjeldus", | |
| 361 | + "descriptionPlaceholder": "Lisa detaile...", | |
| 362 | + "category": "Kategooria", | |
| 363 | + "priority": "Prioriteet", | |
| 364 | + "estimatedCost": "Hinnanguline maksumus", | |
| 365 | + "url": "URL", | |
| 366 | + "location": "Asukoht", | |
| 367 | + "locationPlaceholder": "Kus see on?", | |
| 368 | + "addItem": "Lisa kirje", | |
| 369 | + "saving": "Salvestan...", | |
| 370 | + "pleaseFix": "Palun paranda järgmine:" | |
| 371 | + }, | |
| 372 | + "edit": { | |
| 373 | + "title": "Muuda soovinimekirja kirjet", | |
| 374 | + "somethingWrong": "Midagi läks valesti", | |
| 375 | + "confirmDelete": "Oled kindel, et soovid selle soovinimekirja kirje kustutada?", | |
| 376 | + "notFound": "Soovinimekirja kirjet ei leitud" | |
| 377 | + }, | |
| 378 | + "category": { | |
| 379 | + "Place": "Koht", | |
| 380 | + "Activity": "Tegevus", | |
| 381 | + "Restaurant": "Restoran", | |
| 382 | + "Other": "Muu" | |
| 383 | + }, | |
| 384 | + "priority": { | |
| 385 | + "MustDo": "Kindlasti teha", | |
| 386 | + "NiceToHave": "Tore oleks", | |
| 387 | + "Optional": "Valikuline" | |
| 388 | + } | |
| 389 | + } | |
| 390 | +} |
added src/main.ts +20 −0
| @@ -0,0 +1,20 @@ | ||
| 1 | +import { createApp } from 'vue' | |
| 2 | +import { createPinia } from 'pinia' | |
| 3 | +import 'bootstrap/dist/css/bootstrap.min.css' | |
| 4 | +import './assets/splitapp-design.css' | |
| 5 | + | |
| 6 | +import App from './App.vue' | |
| 7 | +import router from './router' | |
| 8 | +import i18n from './i18n' | |
| 9 | +import { useLangStore } from './stores/lang' | |
| 10 | +import { vAnimate } from './directives/vAnimate' | |
| 11 | + | |
| 12 | +const app = createApp(App) | |
| 13 | + | |
| 14 | +app.use(createPinia()) | |
| 15 | +app.use(i18n) | |
| 16 | +useLangStore() | |
| 17 | +app.use(router) | |
| 18 | +app.directive('animate', vAnimate) | |
| 19 | + | |
| 20 | +app.mount('#app') |
added src/router/index.ts +138 −0
| @@ -0,0 +1,138 @@ | ||
| 1 | +import { createRouter, createWebHistory } from 'vue-router' | |
| 2 | +import { useAuthStore } from '@/stores/auth' | |
| 3 | +import HomeView from '@/views/HomeView.vue' | |
| 4 | + | |
| 5 | +const router = createRouter({ | |
| 6 | + history: createWebHistory(import.meta.env.BASE_URL), | |
| 7 | + routes: [ | |
| 8 | + { | |
| 9 | + path: '/', | |
| 10 | + name: 'Home', | |
| 11 | + component: HomeView, | |
| 12 | + }, | |
| 13 | + { | |
| 14 | + path: '/login', | |
| 15 | + name: 'Login', | |
| 16 | + component: () => import('@/views/LoginView.vue'), | |
| 17 | + }, | |
| 18 | + { | |
| 19 | + path: '/register', | |
| 20 | + name: 'Register', | |
| 21 | + component: () => import('@/views/RegisterView.vue'), | |
| 22 | + }, | |
| 23 | + { | |
| 24 | + path: '/trips', | |
| 25 | + name: 'TripsIndex', | |
| 26 | + component: () => import('@/views/trips/IndexView.vue'), | |
| 27 | + meta: { requiresAuth: true }, | |
| 28 | + }, | |
| 29 | + { | |
| 30 | + path: '/trips/create', | |
| 31 | + name: 'TripsCreate', | |
| 32 | + component: () => import('@/views/trips/CreateView.vue'), | |
| 33 | + meta: { requiresAuth: true }, | |
| 34 | + }, | |
| 35 | + { | |
| 36 | + path: '/trips/:tripId', | |
| 37 | + component: () => import('@/views/trips/DetailView.vue'), | |
| 38 | + meta: { requiresAuth: true }, | |
| 39 | + children: [ | |
| 40 | + { | |
| 41 | + path: '', | |
| 42 | + name: 'TripDetail', | |
| 43 | + component: () => import('@/views/expenses/IndexView.vue'), | |
| 44 | + }, | |
| 45 | + { | |
| 46 | + path: 'edit', | |
| 47 | + name: 'TripsEdit', | |
| 48 | + component: () => import('@/views/trips/EditView.vue'), | |
| 49 | + }, | |
| 50 | + { | |
| 51 | + path: 'expenses', | |
| 52 | + name: 'ExpensesIndex', | |
| 53 | + component: () => import('@/views/expenses/IndexView.vue'), | |
| 54 | + }, | |
| 55 | + { | |
| 56 | + path: 'expenses/create', | |
| 57 | + name: 'ExpensesCreate', | |
| 58 | + component: () => import('@/views/expenses/CreateView.vue'), | |
| 59 | + }, | |
| 60 | + { | |
| 61 | + path: 'expenses/:id/edit', | |
| 62 | + name: 'ExpensesEdit', | |
| 63 | + component: () => import('@/views/expenses/EditView.vue'), | |
| 64 | + }, | |
| 65 | + { | |
| 66 | + path: 'budget', | |
| 67 | + name: 'BudgetIndex', | |
| 68 | + component: () => import('@/views/budget-categories/IndexView.vue'), | |
| 69 | + }, | |
| 70 | + { | |
| 71 | + path: 'budget/create', | |
| 72 | + name: 'BudgetCreate', | |
| 73 | + component: () => import('@/views/budget-categories/CreateView.vue'), | |
| 74 | + }, | |
| 75 | + { | |
| 76 | + path: 'budget/:id/edit', | |
| 77 | + name: 'BudgetEdit', | |
| 78 | + component: () => import('@/views/budget-categories/EditView.vue'), | |
| 79 | + }, | |
| 80 | + { | |
| 81 | + path: 'wishlist', | |
| 82 | + name: 'WishlistIndex', | |
| 83 | + component: () => import('@/views/wishlist/IndexView.vue'), | |
| 84 | + }, | |
| 85 | + { | |
| 86 | + path: 'wishlist/create', | |
| 87 | + name: 'WishlistCreate', | |
| 88 | + component: () => import('@/views/wishlist/CreateView.vue'), | |
| 89 | + }, | |
| 90 | + { | |
| 91 | + path: 'wishlist/:id/edit', | |
| 92 | + name: 'WishlistEdit', | |
| 93 | + component: () => import('@/views/wishlist/EditView.vue'), | |
| 94 | + }, | |
| 95 | + { | |
| 96 | + path: 'polls', | |
| 97 | + name: 'PollsIndex', | |
| 98 | + component: () => import('@/views/polls/IndexView.vue'), | |
| 99 | + }, | |
| 100 | + { | |
| 101 | + path: 'polls/create', | |
| 102 | + name: 'PollsCreate', | |
| 103 | + component: () => import('@/views/polls/CreateView.vue'), | |
| 104 | + }, | |
| 105 | + { | |
| 106 | + path: 'polls/:id', | |
| 107 | + name: 'PollDetail', | |
| 108 | + component: () => import('@/views/polls/DetailView.vue'), | |
| 109 | + }, | |
| 110 | + { | |
| 111 | + path: 'members', | |
| 112 | + name: 'MembersView', | |
| 113 | + component: () => import('@/views/members/MembersView.vue'), | |
| 114 | + }, | |
| 115 | + { | |
| 116 | + path: 'settlement', | |
| 117 | + name: 'SettlementView', | |
| 118 | + component: () => import('@/views/settlements/SettlementView.vue'), | |
| 119 | + }, | |
| 120 | + ], | |
| 121 | + }, | |
| 122 | + { | |
| 123 | + path: '/invitations/:token', | |
| 124 | + name: 'AcceptInvitation', | |
| 125 | + component: () => import('@/views/invitations/AcceptView.vue'), | |
| 126 | + meta: { requiresAuth: true }, | |
| 127 | + }, | |
| 128 | + ], | |
| 129 | +}) | |
| 130 | + | |
| 131 | +router.beforeEach((to) => { | |
| 132 | + const authStore = useAuthStore() | |
| 133 | + if (to.meta.requiresAuth && !authStore.isAuthenticated) { | |
| 134 | + return { name: 'Login' } | |
| 135 | + } | |
| 136 | +}) | |
| 137 | + | |
| 138 | +export default router |
added src/services/AccountService.ts +86 −0
| @@ -0,0 +1,86 @@ | ||
| 1 | +import axios from 'axios' | |
| 2 | +import type { IResultObject } from '@/types/IResultObject' | |
| 3 | +import type { IJwtResponse } from '@/types/IJwtResponse' | |
| 4 | + | |
| 5 | +const apiClient = axios.create({ | |
| 6 | + baseURL: import.meta.env.VITE_API_BASE_URL, | |
| 7 | +}) | |
| 8 | + | |
| 9 | +export default class AccountService { | |
| 10 | + static async loginAsync( | |
| 11 | + email: string, | |
| 12 | + password: string, | |
| 13 | + ): Promise<IResultObject<IJwtResponse>> { | |
| 14 | + try { | |
| 15 | + const response = await apiClient.post<IJwtResponse>('identity/Account/Login', { | |
| 16 | + email, | |
| 17 | + password, | |
| 18 | + }) | |
| 19 | + return { data: response.data } | |
| 20 | + } catch (e) { | |
| 21 | + return { errors: AccountService.handleError(e) } | |
| 22 | + } | |
| 23 | + } | |
| 24 | + | |
| 25 | + static async registerAsync( | |
| 26 | + email: string, | |
| 27 | + password: string, | |
| 28 | + firstName: string, | |
| 29 | + lastName: string, | |
| 30 | + ): Promise<IResultObject<IJwtResponse>> { | |
| 31 | + try { | |
| 32 | + const response = await apiClient.post<IJwtResponse>('identity/Account/Register', { | |
| 33 | + email, | |
| 34 | + password, | |
| 35 | + firstName, | |
| 36 | + lastName, | |
| 37 | + }) | |
| 38 | + return { data: response.data } | |
| 39 | + } catch (e) { | |
| 40 | + return { errors: AccountService.handleError(e) } | |
| 41 | + } | |
| 42 | + } | |
| 43 | + | |
| 44 | + static async refreshTokenAsync( | |
| 45 | + jwt: string, | |
| 46 | + refreshToken: string, | |
| 47 | + ): Promise<IResultObject<IJwtResponse>> { | |
| 48 | + try { | |
| 49 | + const response = await apiClient.post<IJwtResponse>('identity/Account/RefreshTokenData', { | |
| 50 | + jwt, | |
| 51 | + refreshToken, | |
| 52 | + }) | |
| 53 | + return { data: response.data } | |
| 54 | + } catch (e) { | |
| 55 | + return { errors: AccountService.handleError(e) } | |
| 56 | + } | |
| 57 | + } | |
| 58 | + | |
| 59 | + static async logoutAsync(refreshToken: string): Promise<void> { | |
| 60 | + try { | |
| 61 | + await apiClient.post('identity/Account/Logout', { refreshToken }) | |
| 62 | + } catch { | |
| 63 | + // Ignore logout errors — token cleanup happens client-side regardless | |
| 64 | + } | |
| 65 | + } | |
| 66 | + | |
| 67 | + private static handleError(e: unknown): string[] { | |
| 68 | + if (axios.isAxiosError(e)) { | |
| 69 | + const data = e.response?.data | |
| 70 | + if (data?.errors && typeof data.errors === 'object') { | |
| 71 | + const messages: string[] = [] | |
| 72 | + for (const field of Object.values(data.errors)) { | |
| 73 | + if (Array.isArray(field)) { | |
| 74 | + messages.push(...field) | |
| 75 | + } | |
| 76 | + } | |
| 77 | + if (messages.length > 0) return messages | |
| 78 | + } | |
| 79 | + if (data?.title) { | |
| 80 | + return [data.title] | |
| 81 | + } | |
| 82 | + return [e.response?.statusText ?? 'Unknown error occurred'] | |
| 83 | + } | |
| 84 | + return ['Network error or server unavailable'] | |
| 85 | + } | |
| 86 | +} |
added src/services/BudgetCategoryService.ts +58 −0
| @@ -0,0 +1,58 @@ | ||
| 1 | +import httpClient from '@/services/httpClient' | |
| 2 | +import type { IResultObject } from '@/types/IResultObject' | |
| 3 | +import type { IBudgetCategory, IBudgetCategoryCreate } from '@/types/IBudgetCategory' | |
| 4 | +import axios from 'axios' | |
| 5 | + | |
| 6 | +export default class BudgetCategoryService { | |
| 7 | + static async getByTrip(tripId: string): Promise<IResultObject<IBudgetCategory[]>> { | |
| 8 | + try { | |
| 9 | + const response = await httpClient.get<IBudgetCategory[]>(`BudgetCategories/trip/${tripId}`) | |
| 10 | + return { data: response.data } | |
| 11 | + } catch (e) { | |
| 12 | + return { errors: BudgetCategoryService.handleError(e) } | |
| 13 | + } | |
| 14 | + } | |
| 15 | + | |
| 16 | + static async create(entity: IBudgetCategoryCreate): Promise<IResultObject<IBudgetCategory>> { | |
| 17 | + try { | |
| 18 | + const response = await httpClient.post<IBudgetCategory>('BudgetCategories', entity) | |
| 19 | + return { data: response.data } | |
| 20 | + } catch (e) { | |
| 21 | + return { errors: BudgetCategoryService.handleError(e) } | |
| 22 | + } | |
| 23 | + } | |
| 24 | + | |
| 25 | + static async update(id: string, entity: IBudgetCategoryCreate): Promise<IResultObject<void>> { | |
| 26 | + try { | |
| 27 | + await httpClient.put(`BudgetCategories/${id}`, entity) | |
| 28 | + return { data: undefined } | |
| 29 | + } catch (e) { | |
| 30 | + return { errors: BudgetCategoryService.handleError(e) } | |
| 31 | + } | |
| 32 | + } | |
| 33 | + | |
| 34 | + static async delete(id: string): Promise<IResultObject<void>> { | |
| 35 | + try { | |
| 36 | + await httpClient.delete(`BudgetCategories/${id}`) | |
| 37 | + return { data: undefined } | |
| 38 | + } catch (e) { | |
| 39 | + return { errors: BudgetCategoryService.handleError(e) } | |
| 40 | + } | |
| 41 | + } | |
| 42 | + | |
| 43 | + private static handleError(e: unknown): string[] { | |
| 44 | + if (axios.isAxiosError(e)) { | |
| 45 | + const data = e.response?.data | |
| 46 | + if (data?.errors && typeof data.errors === 'object') { | |
| 47 | + const messages: string[] = [] | |
| 48 | + for (const field of Object.values(data.errors)) { | |
| 49 | + if (Array.isArray(field)) messages.push(...field) | |
| 50 | + } | |
| 51 | + if (messages.length > 0) return messages | |
| 52 | + } | |
| 53 | + if (data?.title) return [data.title] | |
| 54 | + return [e.response?.statusText ?? 'Unknown error'] | |
| 55 | + } | |
| 56 | + return ['Network error or server unavailable'] | |
| 57 | + } | |
| 58 | +} |
added src/services/CurrencyService.ts +31 −0
| @@ -0,0 +1,31 @@ | ||
| 1 | +import httpClient from '@/services/httpClient' | |
| 2 | +import type { IResultObject } from '@/types/IResultObject' | |
| 3 | +import type { ICurrency } from '@/types/ICurrency' | |
| 4 | +import axios from 'axios' | |
| 5 | + | |
| 6 | +export default class CurrencyService { | |
| 7 | + static async getAll(): Promise<IResultObject<ICurrency[]>> { | |
| 8 | + try { | |
| 9 | + const response = await httpClient.get<ICurrency[]>('Currencies') | |
| 10 | + return { data: response.data } | |
| 11 | + } catch (e) { | |
| 12 | + return { errors: CurrencyService.handleError(e) } | |
| 13 | + } | |
| 14 | + } | |
| 15 | + | |
| 16 | + private static handleError(e: unknown): string[] { | |
| 17 | + if (axios.isAxiosError(e)) { | |
| 18 | + const data = e.response?.data | |
| 19 | + if (data?.errors && typeof data.errors === 'object') { | |
| 20 | + const messages: string[] = [] | |
| 21 | + for (const field of Object.values(data.errors)) { | |
| 22 | + if (Array.isArray(field)) messages.push(...field) | |
| 23 | + } | |
| 24 | + if (messages.length > 0) return messages | |
| 25 | + } | |
| 26 | + if (data?.title) return [data.title] | |
| 27 | + return [e.response?.statusText ?? 'Unknown error'] | |
| 28 | + } | |
| 29 | + return ['Network error or server unavailable'] | |
| 30 | + } | |
| 31 | +} |
added src/services/ExpenseService.ts +67 −0
| @@ -0,0 +1,67 @@ | ||
| 1 | +import httpClient from '@/services/httpClient' | |
| 2 | +import type { IResultObject } from '@/types/IResultObject' | |
| 3 | +import type { IExpense, IExpenseCreate } from '@/types/IExpense' | |
| 4 | +import axios from 'axios' | |
| 5 | + | |
| 6 | +export default class ExpenseService { | |
| 7 | + static async getByTrip(tripId: string): Promise<IResultObject<IExpense[]>> { | |
| 8 | + try { | |
| 9 | + const response = await httpClient.get<IExpense[]>(`Expenses/trip/${tripId}`) | |
| 10 | + return { data: response.data } | |
| 11 | + } catch (e) { | |
| 12 | + return { errors: ExpenseService.handleError(e) } | |
| 13 | + } | |
| 14 | + } | |
| 15 | + | |
| 16 | + static async getById(id: string): Promise<IResultObject<IExpense>> { | |
| 17 | + try { | |
| 18 | + const response = await httpClient.get<IExpense>(`Expenses/${id}`) | |
| 19 | + return { data: response.data } | |
| 20 | + } catch (e) { | |
| 21 | + return { errors: ExpenseService.handleError(e) } | |
| 22 | + } | |
| 23 | + } | |
| 24 | + | |
| 25 | + static async create(entity: IExpenseCreate): Promise<IResultObject<IExpense>> { | |
| 26 | + try { | |
| 27 | + const response = await httpClient.post<IExpense>('Expenses', entity) | |
| 28 | + return { data: response.data } | |
| 29 | + } catch (e) { | |
| 30 | + return { errors: ExpenseService.handleError(e) } | |
| 31 | + } | |
| 32 | + } | |
| 33 | + | |
| 34 | + static async update(id: string, entity: IExpenseCreate): Promise<IResultObject<void>> { | |
| 35 | + try { | |
| 36 | + await httpClient.put(`Expenses/${id}`, entity) | |
| 37 | + return { data: undefined } | |
| 38 | + } catch (e) { | |
| 39 | + return { errors: ExpenseService.handleError(e) } | |
| 40 | + } | |
| 41 | + } | |
| 42 | + | |
| 43 | + static async delete(id: string): Promise<IResultObject<void>> { | |
| 44 | + try { | |
| 45 | + await httpClient.delete(`Expenses/${id}`) | |
| 46 | + return { data: undefined } | |
| 47 | + } catch (e) { | |
| 48 | + return { errors: ExpenseService.handleError(e) } | |
| 49 | + } | |
| 50 | + } | |
| 51 | + | |
| 52 | + private static handleError(e: unknown): string[] { | |
| 53 | + if (axios.isAxiosError(e)) { | |
| 54 | + const data = e.response?.data | |
| 55 | + if (data?.errors && typeof data.errors === 'object') { | |
| 56 | + const messages: string[] = [] | |
| 57 | + for (const field of Object.values(data.errors)) { | |
| 58 | + if (Array.isArray(field)) messages.push(...field) | |
| 59 | + } | |
| 60 | + if (messages.length > 0) return messages | |
| 61 | + } | |
| 62 | + if (data?.title) return [data.title] | |
| 63 | + return [e.response?.statusText ?? 'Unknown error'] | |
| 64 | + } | |
| 65 | + return ['Network error or server unavailable'] | |
| 66 | + } | |
| 67 | +} |
added src/services/InvitationService.ts +67 −0
| @@ -0,0 +1,67 @@ | ||
| 1 | +import httpClient from '@/services/httpClient' | |
| 2 | +import type { IResultObject } from '@/types/IResultObject' | |
| 3 | +import type { IInvitation, IInvitationCreate } from '@/types/IInvitation' | |
| 4 | +import axios from 'axios' | |
| 5 | + | |
| 6 | +export default class InvitationService { | |
| 7 | + static async create(entity: IInvitationCreate): Promise<IResultObject<IInvitation>> { | |
| 8 | + try { | |
| 9 | + const response = await httpClient.post<IInvitation>('Invitations', entity) | |
| 10 | + return { data: response.data } | |
| 11 | + } catch (e) { | |
| 12 | + return { errors: InvitationService.handleError(e) } | |
| 13 | + } | |
| 14 | + } | |
| 15 | + | |
| 16 | + static async getByToken(token: string): Promise<IResultObject<IInvitation>> { | |
| 17 | + try { | |
| 18 | + const response = await httpClient.get<IInvitation>(`Invitations/${token}`) | |
| 19 | + return { data: response.data } | |
| 20 | + } catch (e) { | |
| 21 | + return { errors: InvitationService.handleError(e) } | |
| 22 | + } | |
| 23 | + } | |
| 24 | + | |
| 25 | + static async accept(token: string): Promise<IResultObject<void>> { | |
| 26 | + try { | |
| 27 | + await httpClient.post(`Invitations/${token}/accept`) | |
| 28 | + return { data: undefined } | |
| 29 | + } catch (e) { | |
| 30 | + return { errors: InvitationService.handleError(e) } | |
| 31 | + } | |
| 32 | + } | |
| 33 | + | |
| 34 | + static async decline(token: string): Promise<IResultObject<void>> { | |
| 35 | + try { | |
| 36 | + await httpClient.post(`Invitations/${token}/decline`) | |
| 37 | + return { data: undefined } | |
| 38 | + } catch (e) { | |
| 39 | + return { errors: InvitationService.handleError(e) } | |
| 40 | + } | |
| 41 | + } | |
| 42 | + | |
| 43 | + static async revoke(token: string): Promise<IResultObject<void>> { | |
| 44 | + try { | |
| 45 | + await httpClient.post(`Invitations/${token}/revoke`) | |
| 46 | + return { data: undefined } | |
| 47 | + } catch (e) { | |
| 48 | + return { errors: InvitationService.handleError(e) } | |
| 49 | + } | |
| 50 | + } | |
| 51 | + | |
| 52 | + private static handleError(e: unknown): string[] { | |
| 53 | + if (axios.isAxiosError(e)) { | |
| 54 | + const data = e.response?.data | |
| 55 | + if (data?.errors && typeof data.errors === 'object') { | |
| 56 | + const messages: string[] = [] | |
| 57 | + for (const field of Object.values(data.errors)) { | |
| 58 | + if (Array.isArray(field)) messages.push(...field) | |
| 59 | + } | |
| 60 | + if (messages.length > 0) return messages | |
| 61 | + } | |
| 62 | + if (data?.title) return [data.title] | |
| 63 | + return [e.response?.statusText ?? 'Unknown error'] | |
| 64 | + } | |
| 65 | + return ['Network error or server unavailable'] | |
| 66 | + } | |
| 67 | +} |
added src/services/PollService.ts +76 −0
| @@ -0,0 +1,76 @@ | ||
| 1 | +import httpClient from '@/services/httpClient' | |
| 2 | +import type { IResultObject } from '@/types/IResultObject' | |
| 3 | +import type { IPoll, IPollCreate } from '@/types/IPoll' | |
| 4 | +import axios from 'axios' | |
| 5 | + | |
| 6 | +export default class PollService { | |
| 7 | + static async getByTrip(tripId: string): Promise<IResultObject<IPoll[]>> { | |
| 8 | + try { | |
| 9 | + const response = await httpClient.get<IPoll[]>(`Polls/trip/${tripId}`) | |
| 10 | + return { data: response.data } | |
| 11 | + } catch (e) { | |
| 12 | + return { errors: PollService.handleError(e) } | |
| 13 | + } | |
| 14 | + } | |
| 15 | + | |
| 16 | + static async getById(id: string): Promise<IResultObject<IPoll>> { | |
| 17 | + try { | |
| 18 | + const response = await httpClient.get<IPoll>(`Polls/${id}`) | |
| 19 | + return { data: response.data } | |
| 20 | + } catch (e) { | |
| 21 | + return { errors: PollService.handleError(e) } | |
| 22 | + } | |
| 23 | + } | |
| 24 | + | |
| 25 | + static async create(entity: IPollCreate): Promise<IResultObject<IPoll>> { | |
| 26 | + try { | |
| 27 | + const response = await httpClient.post<IPoll>('Polls', entity) | |
| 28 | + return { data: response.data } | |
| 29 | + } catch (e) { | |
| 30 | + return { errors: PollService.handleError(e) } | |
| 31 | + } | |
| 32 | + } | |
| 33 | + | |
| 34 | + static async vote(pollId: string, optionId: string): Promise<IResultObject<void>> { | |
| 35 | + try { | |
| 36 | + await httpClient.post(`Polls/${pollId}/vote`, { optionId }) | |
| 37 | + return { data: undefined } | |
| 38 | + } catch (e) { | |
| 39 | + return { errors: PollService.handleError(e) } | |
| 40 | + } | |
| 41 | + } | |
| 42 | + | |
| 43 | + static async close(id: string): Promise<IResultObject<void>> { | |
| 44 | + try { | |
| 45 | + await httpClient.post(`Polls/${id}/close`) | |
| 46 | + return { data: undefined } | |
| 47 | + } catch (e) { | |
| 48 | + return { errors: PollService.handleError(e) } | |
| 49 | + } | |
| 50 | + } | |
| 51 | + | |
| 52 | + static async delete(id: string): Promise<IResultObject<void>> { | |
| 53 | + try { | |
| 54 | + await httpClient.delete(`Polls/${id}`) | |
| 55 | + return { data: undefined } | |
| 56 | + } catch (e) { | |
| 57 | + return { errors: PollService.handleError(e) } | |
| 58 | + } | |
| 59 | + } | |
| 60 | + | |
| 61 | + private static handleError(e: unknown): string[] { | |
| 62 | + if (axios.isAxiosError(e)) { | |
| 63 | + const data = e.response?.data | |
| 64 | + if (data?.errors && typeof data.errors === 'object') { | |
| 65 | + const messages: string[] = [] | |
| 66 | + for (const field of Object.values(data.errors)) { | |
| 67 | + if (Array.isArray(field)) messages.push(...field) | |
| 68 | + } | |
| 69 | + if (messages.length > 0) return messages | |
| 70 | + } | |
| 71 | + if (data?.title) return [data.title] | |
| 72 | + return [e.response?.statusText ?? 'Unknown error'] | |
| 73 | + } | |
| 74 | + return ['Network error or server unavailable'] | |
| 75 | + } | |
| 76 | +} |
added src/services/SettlementService.ts +58 −0
| @@ -0,0 +1,58 @@ | ||
| 1 | +import httpClient from '@/services/httpClient' | |
| 2 | +import type { IResultObject } from '@/types/IResultObject' | |
| 3 | +import type { ISettlementPlan, ISettlementSummary, IBalance } from '@/types/ISettlement' | |
| 4 | +import axios from 'axios' | |
| 5 | + | |
| 6 | +export default class SettlementService { | |
| 7 | + static async getSummary(tripId: string): Promise<IResultObject<ISettlementSummary>> { | |
| 8 | + try { | |
| 9 | + const response = await httpClient.get<ISettlementSummary>(`Settlements/trip/${tripId}/summary`) | |
| 10 | + return { data: response.data } | |
| 11 | + } catch (e) { | |
| 12 | + return { errors: SettlementService.handleError(e) } | |
| 13 | + } | |
| 14 | + } | |
| 15 | + | |
| 16 | + static async getBalances(tripId: string): Promise<IResultObject<IBalance[]>> { | |
| 17 | + try { | |
| 18 | + const response = await httpClient.get<IBalance[]>(`Settlements/trip/${tripId}/balances`) | |
| 19 | + return { data: response.data } | |
| 20 | + } catch (e) { | |
| 21 | + return { errors: SettlementService.handleError(e) } | |
| 22 | + } | |
| 23 | + } | |
| 24 | + | |
| 25 | + static async markPaid(paymentId: string): Promise<IResultObject<void>> { | |
| 26 | + try { | |
| 27 | + await httpClient.post(`Settlements/payments/${paymentId}/mark-paid`) | |
| 28 | + return { data: undefined } | |
| 29 | + } catch (e) { | |
| 30 | + return { errors: SettlementService.handleError(e) } | |
| 31 | + } | |
| 32 | + } | |
| 33 | + | |
| 34 | + static async confirmPayment(paymentId: string): Promise<IResultObject<void>> { | |
| 35 | + try { | |
| 36 | + await httpClient.post(`Settlements/payments/${paymentId}/confirm`) | |
| 37 | + return { data: undefined } | |
| 38 | + } catch (e) { | |
| 39 | + return { errors: SettlementService.handleError(e) } | |
| 40 | + } | |
| 41 | + } | |
| 42 | + | |
| 43 | + private static handleError(e: unknown): string[] { | |
| 44 | + if (axios.isAxiosError(e)) { | |
| 45 | + const data = e.response?.data | |
| 46 | + if (data?.errors && typeof data.errors === 'object') { | |
| 47 | + const messages: string[] = [] | |
| 48 | + for (const field of Object.values(data.errors)) { | |
| 49 | + if (Array.isArray(field)) messages.push(...field) | |
| 50 | + } | |
| 51 | + if (messages.length > 0) return messages | |
| 52 | + } | |
| 53 | + if (data?.title) return [data.title] | |
| 54 | + return [e.response?.statusText ?? 'Unknown error'] | |
| 55 | + } | |
| 56 | + return ['Network error or server unavailable'] | |
| 57 | + } | |
| 58 | +} |
added src/services/TripService.ts +94 −0
| @@ -0,0 +1,94 @@ | ||
| 1 | +import httpClient from '@/services/httpClient' | |
| 2 | +import type { IResultObject } from '@/types/IResultObject' | |
| 3 | +import type { ITrip, ITripCreate, ITripUpdate } from '@/types/ITrip' | |
| 4 | +import axios from 'axios' | |
| 5 | + | |
| 6 | +export default class TripService { | |
| 7 | + static async getAll(): Promise<IResultObject<ITrip[]>> { | |
| 8 | + try { | |
| 9 | + const response = await httpClient.get<ITrip[]>('Trips') | |
| 10 | + return { data: response.data } | |
| 11 | + } catch (e) { | |
| 12 | + return { errors: TripService.handleError(e) } | |
| 13 | + } | |
| 14 | + } | |
| 15 | + | |
| 16 | + static async getById(id: string): Promise<IResultObject<ITrip>> { | |
| 17 | + try { | |
| 18 | + const response = await httpClient.get<ITrip>(`Trips/${id}`) | |
| 19 | + return { data: response.data } | |
| 20 | + } catch (e) { | |
| 21 | + return { errors: TripService.handleError(e) } | |
| 22 | + } | |
| 23 | + } | |
| 24 | + | |
| 25 | + static async create(entity: ITripCreate): Promise<IResultObject<ITrip>> { | |
| 26 | + try { | |
| 27 | + const response = await httpClient.post<ITrip>('Trips', entity) | |
| 28 | + return { data: response.data } | |
| 29 | + } catch (e) { | |
| 30 | + return { errors: TripService.handleError(e) } | |
| 31 | + } | |
| 32 | + } | |
| 33 | + | |
| 34 | + static async update(id: string, entity: ITripUpdate): Promise<IResultObject<void>> { | |
| 35 | + try { | |
| 36 | + await httpClient.put(`Trips/${id}`, entity) | |
| 37 | + return { data: undefined } | |
| 38 | + } catch (e) { | |
| 39 | + return { errors: TripService.handleError(e) } | |
| 40 | + } | |
| 41 | + } | |
| 42 | + | |
| 43 | + static async delete(id: string): Promise<IResultObject<void>> { | |
| 44 | + try { | |
| 45 | + await httpClient.delete(`Trips/${id}`) | |
| 46 | + return { data: undefined } | |
| 47 | + } catch (e) { | |
| 48 | + return { errors: TripService.handleError(e) } | |
| 49 | + } | |
| 50 | + } | |
| 51 | + | |
| 52 | + static async finalize(tripId: string): Promise<IResultObject<void>> { | |
| 53 | + try { | |
| 54 | + await httpClient.post(`Trips/${tripId}/finalize`) | |
| 55 | + return { data: undefined } | |
| 56 | + } catch (e) { | |
| 57 | + return { errors: TripService.handleError(e) } | |
| 58 | + } | |
| 59 | + } | |
| 60 | + | |
| 61 | + static async reopen(tripId: string): Promise<IResultObject<void>> { | |
| 62 | + try { | |
| 63 | + await httpClient.post(`Trips/${tripId}/reopen`) | |
| 64 | + return { data: undefined } | |
| 65 | + } catch (e) { | |
| 66 | + return { errors: TripService.handleError(e) } | |
| 67 | + } | |
| 68 | + } | |
| 69 | + | |
| 70 | + static async getParticipants(tripId: string): Promise<IResultObject<import('@/types/ITrip').ITripParticipant[]>> { | |
| 71 | + try { | |
| 72 | + const response = await httpClient.get<import('@/types/ITrip').ITripParticipant[]>(`Trips/${tripId}/participants`) | |
| 73 | + return { data: response.data } | |
| 74 | + } catch (e) { | |
| 75 | + return { errors: TripService.handleError(e) } | |
| 76 | + } | |
| 77 | + } | |
| 78 | + | |
| 79 | + private static handleError(e: unknown): string[] { | |
| 80 | + if (axios.isAxiosError(e)) { | |
| 81 | + const data = e.response?.data | |
| 82 | + if (data?.errors && typeof data.errors === 'object') { | |
| 83 | + const messages: string[] = [] | |
| 84 | + for (const field of Object.values(data.errors)) { | |
| 85 | + if (Array.isArray(field)) messages.push(...field) | |
| 86 | + } | |
| 87 | + if (messages.length > 0) return messages | |
| 88 | + } | |
| 89 | + if (data?.title) return [data.title] | |
| 90 | + return [e.response?.statusText ?? 'Unknown error'] | |
| 91 | + } | |
| 92 | + return ['Network error or server unavailable'] | |
| 93 | + } | |
| 94 | +} |
added src/services/WishlistService.ts +77 −0
| @@ -0,0 +1,77 @@ | ||
| 1 | +import httpClient from '@/services/httpClient' | |
| 2 | +import type { IResultObject } from '@/types/IResultObject' | |
| 3 | +import type { IWishlistItem, IWishlistItemCreate } from '@/types/IWishlist' | |
| 4 | +import axios from 'axios' | |
| 5 | + | |
| 6 | +export default class WishlistService { | |
| 7 | + static async getByTrip(tripId: string): Promise<IResultObject<IWishlistItem[]>> { | |
| 8 | + try { | |
| 9 | + const response = await httpClient.get<IWishlistItem[]>(`Wishlist/trip/${tripId}`) | |
| 10 | + return { data: response.data } | |
| 11 | + } catch (e) { | |
| 12 | + return { errors: WishlistService.handleError(e) } | |
| 13 | + } | |
| 14 | + } | |
| 15 | + | |
| 16 | + static async create(entity: IWishlistItemCreate): Promise<IResultObject<IWishlistItem>> { | |
| 17 | + try { | |
| 18 | + const response = await httpClient.post<IWishlistItem>('Wishlist', entity) | |
| 19 | + return { data: response.data } | |
| 20 | + } catch (e) { | |
| 21 | + return { errors: WishlistService.handleError(e) } | |
| 22 | + } | |
| 23 | + } | |
| 24 | + | |
| 25 | + static async update(id: string, entity: IWishlistItemCreate): Promise<IResultObject<void>> { | |
| 26 | + try { | |
| 27 | + await httpClient.put(`Wishlist/${id}`, entity) | |
| 28 | + return { data: undefined } | |
| 29 | + } catch (e) { | |
| 30 | + return { errors: WishlistService.handleError(e) } | |
| 31 | + } | |
| 32 | + } | |
| 33 | + | |
| 34 | + static async delete(id: string): Promise<IResultObject<void>> { | |
| 35 | + try { | |
| 36 | + await httpClient.delete(`Wishlist/${id}`) | |
| 37 | + return { data: undefined } | |
| 38 | + } catch (e) { | |
| 39 | + return { errors: WishlistService.handleError(e) } | |
| 40 | + } | |
| 41 | + } | |
| 42 | + | |
| 43 | + static async vote(id: string): Promise<IResultObject<void>> { | |
| 44 | + try { | |
| 45 | + await httpClient.post(`Wishlist/${id}/vote`) | |
| 46 | + return { data: undefined } | |
| 47 | + } catch (e) { | |
| 48 | + return { errors: WishlistService.handleError(e) } | |
| 49 | + } | |
| 50 | + } | |
| 51 | + | |
| 52 | + static async complete(id: string): Promise<IResultObject<void>> { | |
| 53 | + try { | |
| 54 | + await httpClient.post(`Wishlist/${id}/complete`) | |
| 55 | + return { data: undefined } | |
| 56 | + } catch (e) { | |
| 57 | + return { errors: WishlistService.handleError(e) } | |
| 58 | + } | |
| 59 | + } | |
| 60 | + | |
| 61 | + | |
| 62 | + private static handleError(e: unknown): string[] { | |
| 63 | + if (axios.isAxiosError(e)) { | |
| 64 | + const data = e.response?.data | |
| 65 | + if (data?.errors && typeof data.errors === 'object') { | |
| 66 | + const messages: string[] = [] | |
| 67 | + for (const field of Object.values(data.errors)) { | |
| 68 | + if (Array.isArray(field)) messages.push(...field) | |
| 69 | + } | |
| 70 | + if (messages.length > 0) return messages | |
| 71 | + } | |
| 72 | + if (data?.title) return [data.title] | |
| 73 | + return [e.response?.statusText ?? 'Unknown error'] | |
| 74 | + } | |
| 75 | + return ['Network error or server unavailable'] | |
| 76 | + } | |
| 77 | +} |
added src/services/httpClient.ts +57 −0
| @@ -0,0 +1,57 @@ | ||
| 1 | +import axios from 'axios' | |
| 2 | +import { useAuthStore } from '@/stores/auth' | |
| 3 | +import { useLangStore } from '@/stores/lang' | |
| 4 | +import AccountService from '@/services/AccountService' | |
| 5 | +import router from '@/router' | |
| 6 | + | |
| 7 | +const httpClient = axios.create({ | |
| 8 | + baseURL: import.meta.env.VITE_API_BASE_URL, | |
| 9 | +}) | |
| 10 | + | |
| 11 | +httpClient.interceptors.request.use((config) => { | |
| 12 | + const authStore = useAuthStore() | |
| 13 | + if (authStore.jwt) { | |
| 14 | + config.headers.Authorization = `Bearer ${authStore.jwt}` | |
| 15 | + } | |
| 16 | + const langStore = useLangStore() | |
| 17 | + config.headers['Accept-Language'] = langStore.currentLocale | |
| 18 | + return config | |
| 19 | +}) | |
| 20 | + | |
| 21 | +httpClient.interceptors.response.use( | |
| 22 | + (response) => response, | |
| 23 | + async (error) => { | |
| 24 | + const originalRequest = error.config | |
| 25 | + | |
| 26 | + if (error.response?.status === 401 && !originalRequest._retry) { | |
| 27 | + originalRequest._retry = true | |
| 28 | + | |
| 29 | + const authStore = useAuthStore() | |
| 30 | + if (authStore.jwt && authStore.refreshToken) { | |
| 31 | + const result = await AccountService.refreshTokenAsync( | |
| 32 | + authStore.jwt, | |
| 33 | + authStore.refreshToken, | |
| 34 | + ) | |
| 35 | + | |
| 36 | + if (result.data) { | |
| 37 | + authStore.jwt = result.data.jwt | |
| 38 | + authStore.refreshToken = result.data.refreshToken | |
| 39 | + authStore.userName = `${result.data.firstName} ${result.data.lastName}` | |
| 40 | + | |
| 41 | + originalRequest.headers.Authorization = `Bearer ${result.data.jwt}` | |
| 42 | + return httpClient(originalRequest) | |
| 43 | + } | |
| 44 | + } | |
| 45 | + | |
| 46 | + if (authStore.refreshToken) { | |
| 47 | + await AccountService.logoutAsync(authStore.refreshToken) | |
| 48 | + } | |
| 49 | + authStore.logout() | |
| 50 | + await router.push({ name: 'Login' }) | |
| 51 | + } | |
| 52 | + | |
| 53 | + return Promise.reject(error) | |
| 54 | + }, | |
| 55 | +) | |
| 56 | + | |
| 57 | +export default httpClient |
added src/stores/auth.ts +33 −0
| @@ -0,0 +1,33 @@ | ||
| 1 | +import { ref, computed, watch } from 'vue' | |
| 2 | +import { defineStore } from 'pinia' | |
| 3 | + | |
| 4 | +export const useAuthStore = defineStore('auth', () => { | |
| 5 | + const jwt = ref<string | null>(localStorage.getItem('jwt')) | |
| 6 | + const refreshToken = ref<string | null>(localStorage.getItem('refreshToken')) | |
| 7 | + const userName = ref<string | null>(localStorage.getItem('userName')) | |
| 8 | + | |
| 9 | + const isAuthenticated = computed(() => !!jwt.value) | |
| 10 | + | |
| 11 | + watch(jwt, (val) => { | |
| 12 | + if (val) localStorage.setItem('jwt', val) | |
| 13 | + else localStorage.removeItem('jwt') | |
| 14 | + }) | |
| 15 | + | |
| 16 | + watch(refreshToken, (val) => { | |
| 17 | + if (val) localStorage.setItem('refreshToken', val) | |
| 18 | + else localStorage.removeItem('refreshToken') | |
| 19 | + }) | |
| 20 | + | |
| 21 | + watch(userName, (val) => { | |
| 22 | + if (val) localStorage.setItem('userName', val) | |
| 23 | + else localStorage.removeItem('userName') | |
| 24 | + }) | |
| 25 | + | |
| 26 | + function logout() { | |
| 27 | + jwt.value = null | |
| 28 | + refreshToken.value = null | |
| 29 | + userName.value = null | |
| 30 | + } | |
| 31 | + | |
| 32 | + return { jwt, refreshToken, userName, isAuthenticated, logout } | |
| 33 | +}) |
added src/stores/counter.ts +12 −0
| @@ -0,0 +1,12 @@ | ||
| 1 | +import { ref, computed } from 'vue' | |
| 2 | +import { defineStore } from 'pinia' | |
| 3 | + | |
| 4 | +export const useCounterStore = defineStore('counter', () => { | |
| 5 | + const count = ref(0) | |
| 6 | + const doubleCount = computed(() => count.value * 2) | |
| 7 | + function increment() { | |
| 8 | + count.value++ | |
| 9 | + } | |
| 10 | + | |
| 11 | + return { count, doubleCount, increment } | |
| 12 | +}) |
added src/stores/lang.ts +42 −0
| @@ -0,0 +1,42 @@ | ||
| 1 | +import { ref, watch } from 'vue' | |
| 2 | +import { defineStore } from 'pinia' | |
| 3 | +import i18n, { DEFAULT_LOCALE, SUPPORTED_LOCALES, type AppLocale } from '@/i18n' | |
| 4 | + | |
| 5 | +function readCookieCulture(): AppLocale | null { | |
| 6 | + const match = document.cookie.match(/(?:^|;\s*)\.AspNetCore\.Culture=([^;]+)/) | |
| 7 | + if (!match) return null | |
| 8 | + const decoded = decodeURIComponent(match[1]!) | |
| 9 | + const m = decoded.match(/c=([a-zA-Z-]+)/) | |
| 10 | + const code = m?.[1]?.toLowerCase() | |
| 11 | + if (code === 'en' || code === 'et') return code | |
| 12 | + return null | |
| 13 | +} | |
| 14 | + | |
| 15 | +function writeCookieCulture(locale: AppLocale) { | |
| 16 | + const value = `c=${locale}|uic=${locale}` | |
| 17 | + const oneYear = 60 * 60 * 24 * 365 | |
| 18 | + document.cookie = `.AspNetCore.Culture=${encodeURIComponent(value)}; path=/; max-age=${oneYear}; samesite=lax` | |
| 19 | +} | |
| 20 | + | |
| 21 | +export const useLangStore = defineStore('lang', () => { | |
| 22 | + const stored = (localStorage.getItem('locale') as AppLocale | null) ?? readCookieCulture() ?? DEFAULT_LOCALE | |
| 23 | + const currentLocale = ref<AppLocale>(stored) | |
| 24 | + | |
| 25 | + function apply(locale: AppLocale) { | |
| 26 | + i18n.global.locale.value = locale | |
| 27 | + document.documentElement.lang = locale | |
| 28 | + localStorage.setItem('locale', locale) | |
| 29 | + writeCookieCulture(locale) | |
| 30 | + } | |
| 31 | + | |
| 32 | + apply(currentLocale.value) | |
| 33 | + | |
| 34 | + watch(currentLocale, (val) => apply(val)) | |
| 35 | + | |
| 36 | + function setLocale(locale: AppLocale) { | |
| 37 | + if (!SUPPORTED_LOCALES.includes(locale)) return | |
| 38 | + currentLocale.value = locale | |
| 39 | + } | |
| 40 | + | |
| 41 | + return { currentLocale, setLocale } | |
| 42 | +}) |
added src/types/IBudgetCategory.ts +17 −0
| @@ -0,0 +1,17 @@ | ||
| 1 | +export interface IBudgetCategory { | |
| 2 | + id: string | |
| 3 | + tripId: string | |
| 4 | + name: string | |
| 5 | + iconName: string | null | |
| 6 | + plannedAmount: number | null | |
| 7 | + spentAmount: number | |
| 8 | + displayOrder: number | |
| 9 | +} | |
| 10 | + | |
| 11 | +export interface IBudgetCategoryCreate { | |
| 12 | + tripId: string | |
| 13 | + name: string | |
| 14 | + iconName: string | null | |
| 15 | + plannedAmount: number | null | |
| 16 | + displayOrder: number | |
| 17 | +} |
added src/types/ICurrency.ts +6 −0
| @@ -0,0 +1,6 @@ | ||
| 1 | +export interface ICurrency { | |
| 2 | + id: string | |
| 3 | + code: string | |
| 4 | + name: string | |
| 5 | + symbol: string | |
| 6 | +} |
added src/types/IExpense.ts +43 −0
| @@ -0,0 +1,43 @@ | ||
| 1 | +export interface IExpenseSplit { | |
| 2 | + id: string | |
| 3 | + userId: string | |
| 4 | + userName: string | null | |
| 5 | + amount: number | |
| 6 | + percentage: number | null | |
| 7 | +} | |
| 8 | + | |
| 9 | +export interface IExpenseSplitCreate { | |
| 10 | + userId: string | |
| 11 | + amount: number | |
| 12 | + percentage: number | null | |
| 13 | +} | |
| 14 | + | |
| 15 | +export interface IExpense { | |
| 16 | + id: string | |
| 17 | + tripId: string | |
| 18 | + paidByUserId: string | |
| 19 | + paidByUserName: string | null | |
| 20 | + budgetCategoryId: string | null | |
| 21 | + budgetCategoryName: string | null | |
| 22 | + currencyId: string | null | |
| 23 | + currencyCode: string | null | |
| 24 | + currencySymbol: string | null | |
| 25 | + amount: number | |
| 26 | + amountInTripCurrency: number | null | |
| 27 | + description: string | null | |
| 28 | + expenseDate: string | |
| 29 | + splitMethod: string | |
| 30 | + splits: IExpenseSplit[] | null | |
| 31 | +} | |
| 32 | + | |
| 33 | +export interface IExpenseCreate { | |
| 34 | + tripId: string | |
| 35 | + paidByUserId?: string | null | |
| 36 | + budgetCategoryId: string | null | |
| 37 | + currencyId: string | null | |
| 38 | + amount: number | |
| 39 | + description: string | null | |
| 40 | + expenseDate: string | |
| 41 | + splitMethod: string | |
| 42 | + splits: IExpenseSplitCreate[] | null | |
| 43 | +} |
added src/types/IInvitation.ts +13 −0
| @@ -0,0 +1,13 @@ | ||
| 1 | +export interface IInvitation { | |
| 2 | + id: string | |
| 3 | + tripId: string | |
| 4 | + tripName: string | null | |
| 5 | + token: string | |
| 6 | + status: string | |
| 7 | + expiresAt: string | |
| 8 | + invitedByUserName: string | null | |
| 9 | +} | |
| 10 | + | |
| 11 | +export interface IInvitationCreate { | |
| 12 | + tripId: string | |
| 13 | +} |
added src/types/IJwtResponse.ts +6 −0
| @@ -0,0 +1,6 @@ | ||
| 1 | +export interface IJwtResponse { | |
| 2 | + jwt: string | |
| 3 | + refreshToken: string | |
| 4 | + firstName: string | |
| 5 | + lastName: string | |
| 6 | +} |
added src/types/IPoll.ts +26 −0
| @@ -0,0 +1,26 @@ | ||
| 1 | +export interface IPollOption { | |
| 2 | + id: string | |
| 3 | + text: string | |
| 4 | + voteCount: number | |
| 5 | + votedByCurrentUser: boolean | |
| 6 | + displayOrder: number | |
| 7 | +} | |
| 8 | + | |
| 9 | +export interface IPoll { | |
| 10 | + id: string | |
| 11 | + tripId: string | |
| 12 | + createdByUserId: string | |
| 13 | + question: string | |
| 14 | + allowMultipleVotes: boolean | |
| 15 | + isAnonymous: boolean | |
| 16 | + closedAt: string | null | |
| 17 | + options: IPollOption[] | null | |
| 18 | +} | |
| 19 | + | |
| 20 | +export interface IPollCreate { | |
| 21 | + tripId: string | |
| 22 | + question: string | |
| 23 | + allowMultipleVotes: boolean | |
| 24 | + isAnonymous: boolean | |
| 25 | + options: string[] | |
| 26 | +} |
added src/types/IResultObject.ts +4 −0
| @@ -0,0 +1,4 @@ | ||
| 1 | +export interface IResultObject<TData> { | |
| 2 | + errors?: string[] | |
| 3 | + data?: TData | |
| 4 | +} |
added src/types/ISettlement.ts +31 −0
| @@ -0,0 +1,31 @@ | ||
| 1 | +export interface ISettlementPayment { | |
| 2 | + id: string | |
| 3 | + fromUserId: string | |
| 4 | + fromUserName: string | null | |
| 5 | + toUserId: string | |
| 6 | + toUserName: string | null | |
| 7 | + amount: number | |
| 8 | + status: string | |
| 9 | + markedPaidAt: string | null | |
| 10 | + confirmedAt: string | null | |
| 11 | +} | |
| 12 | + | |
| 13 | +export interface ISettlementPlan { | |
| 14 | + id: string | |
| 15 | + tripId: string | |
| 16 | + totalAmount: number | |
| 17 | + status: string | |
| 18 | + completedAt: string | null | |
| 19 | + payments: ISettlementPayment[] | null | |
| 20 | +} | |
| 21 | + | |
| 22 | +export interface IBalance { | |
| 23 | + userId: string | |
| 24 | + userName: string | null | |
| 25 | + balance: number | |
| 26 | +} | |
| 27 | + | |
| 28 | +export interface ISettlementSummary { | |
| 29 | + balances: IBalance[] | |
| 30 | + latestPlan: ISettlementPlan | null | |
| 31 | +} |
added src/types/ITrip.ts +47 −0
| @@ -0,0 +1,47 @@ | ||
| 1 | +export interface ITripParticipant { | |
| 2 | + id: string | |
| 3 | + tripId: string | |
| 4 | + userId: string | |
| 5 | + userName: string | null | |
| 6 | + userEmail: string | null | |
| 7 | + role: string | |
| 8 | + nickname: string | null | |
| 9 | + joinedAt: string | |
| 10 | + isActive: boolean | |
| 11 | +} | |
| 12 | + | |
| 13 | +export interface ITrip { | |
| 14 | + id: string | |
| 15 | + name: string | |
| 16 | + description: string | null | |
| 17 | + destination: string | null | |
| 18 | + startDate: string | null | |
| 19 | + endDate: string | null | |
| 20 | + status: string | |
| 21 | + defaultCurrencyId: string | |
| 22 | + defaultCurrencyCode: string | null | |
| 23 | + defaultCurrencySymbol: string | null | |
| 24 | + createdById: string | |
| 25 | + participantCount: number | |
| 26 | + participants: ITripParticipant[] | null | |
| 27 | +} | |
| 28 | + | |
| 29 | +export interface ITripCreate { | |
| 30 | + name: string | |
| 31 | + description: string | null | |
| 32 | + destination: string | null | |
| 33 | + startDate: string | null | |
| 34 | + endDate: string | null | |
| 35 | + defaultCurrencyId: string | |
| 36 | +} | |
| 37 | + | |
| 38 | +export interface ITripUpdate { | |
| 39 | + id: string | |
| 40 | + name: string | |
| 41 | + description: string | null | |
| 42 | + destination: string | null | |
| 43 | + startDate: string | null | |
| 44 | + endDate: string | null | |
| 45 | + status: string | null | |
| 46 | + defaultCurrencyId: string | |
| 47 | +} |
added src/types/IWishlist.ts +28 −0
| @@ -0,0 +1,28 @@ | ||
| 1 | +export interface IWishlistItem { | |
| 2 | + id: string | |
| 3 | + tripId: string | |
| 4 | + addedByUserId: string | |
| 5 | + addedByUserName: string | null | |
| 6 | + title: string | |
| 7 | + description: string | null | |
| 8 | + category: string | |
| 9 | + priority: string | |
| 10 | + estimatedCost: number | null | |
| 11 | + url: string | null | |
| 12 | + location: string | null | |
| 13 | + isCompleted: boolean | |
| 14 | + voteCount: number | |
| 15 | + userHasVoted: boolean | |
| 16 | + displayOrder: number | |
| 17 | +} | |
| 18 | + | |
| 19 | +export interface IWishlistItemCreate { | |
| 20 | + tripId: string | |
| 21 | + title: string | |
| 22 | + description: string | null | |
| 23 | + category: string | |
| 24 | + priority: string | |
| 25 | + estimatedCost: number | null | |
| 26 | + url: string | null | |
| 27 | + location: string | null | |
| 28 | +} |
added src/utils/formatCurrency.ts +17 −0
| @@ -0,0 +1,17 @@ | ||
| 1 | +import i18n from '@/i18n' | |
| 2 | + | |
| 3 | +export function formatCurrency( | |
| 4 | + amount: number, | |
| 5 | + symbol?: string | null, | |
| 6 | + decimals: number = 2, | |
| 7 | +): string { | |
| 8 | + const locale = i18n.global.locale.value === 'et' ? 'et-EE' : 'en-US' | |
| 9 | + const abs = Math.abs(amount) | |
| 10 | + const formatted = abs.toLocaleString(locale, { | |
| 11 | + minimumFractionDigits: decimals, | |
| 12 | + maximumFractionDigits: decimals, | |
| 13 | + }) | |
| 14 | + const sign = amount < 0 ? '-' : '' | |
| 15 | + const sym = symbol ?? '' | |
| 16 | + return `${sign}${sym}${formatted}` | |
| 17 | +} |
added src/utils/parseJwt.ts +21 −0
| @@ -0,0 +1,21 @@ | ||
| 1 | +export function parseJwt(token: string): Record<string, unknown> | null { | |
| 2 | + try { | |
| 3 | + const payload = token.split('.')[1] | |
| 4 | + if (!payload) return null | |
| 5 | + const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/')) | |
| 6 | + return JSON.parse(json) | |
| 7 | + } catch { | |
| 8 | + return null | |
| 9 | + } | |
| 10 | +} | |
| 11 | + | |
| 12 | +export function getUserIdFromJwt(token: string): string | null { | |
| 13 | + const claims = parseJwt(token) | |
| 14 | + if (!claims) return null | |
| 15 | + // ASP.NET Core uses this claim URI for NameIdentifier | |
| 16 | + return ( | |
| 17 | + (claims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier'] as string) ?? | |
| 18 | + (claims['sub'] as string) ?? | |
| 19 | + null | |
| 20 | + ) | |
| 21 | +} |
added src/views/HomeView.vue +137 −0
| @@ -0,0 +1,137 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import { computed } from 'vue' | |
| 3 | +import { useI18n } from 'vue-i18n' | |
| 4 | +import { useAuthStore } from '@/stores/auth' | |
| 5 | + | |
| 6 | +const authStore = useAuthStore() | |
| 7 | +const { t } = useI18n() | |
| 8 | + | |
| 9 | +const features = computed(() => [ | |
| 10 | + { icon: 'bi-receipt-cutoff', title: t('home.features.expenseTitle'), text: t('home.features.expenseText') }, | |
| 11 | + { icon: 'bi-pie-chart-fill', title: t('home.features.budgetTitle'), text: t('home.features.budgetText') }, | |
| 12 | + { icon: 'bi-heart-fill', title: t('home.features.wishlistTitle'), text: t('home.features.wishlistText') }, | |
| 13 | + { icon: 'bi-bar-chart-fill', title: t('home.features.pollsTitle'), text: t('home.features.pollsText') }, | |
| 14 | + { icon: 'bi-arrow-left-right', title: t('home.features.settlementTitle'), text: t('home.features.settlementText') }, | |
| 15 | +]) | |
| 16 | +</script> | |
| 17 | + | |
| 18 | +<template> | |
| 19 | + <div> | |
| 20 | + <!-- Hero Section --> | |
| 21 | + <div class="sa-hero"> | |
| 22 | + <div class="sa-hero-content"> | |
| 23 | + <div class="sa-hero-badge sa-animate-fade-in"> | |
| 24 | + <i class="bi bi-airplane-fill me-1"></i> {{ t('home.heroBadge') }} | |
| 25 | + </div> | |
| 26 | + <h1 class="sa-hero-title sa-animate-fade-in"> | |
| 27 | + {{ t('home.heroTitle1') }} | |
| 28 | + <span class="sa-hero-title-soft">{{ t('home.heroTitle2') }}</span> | |
| 29 | + </h1> | |
| 30 | + <p class="sa-hero-subtitle sa-animate-slide-up sa-stagger-1"> | |
| 31 | + {{ t('home.heroSubtitle') }} | |
| 32 | + </p> | |
| 33 | + | |
| 34 | + <div class="sa-hero-btns sa-animate-slide-up sa-stagger-2"> | |
| 35 | + <template v-if="authStore.isAuthenticated"> | |
| 36 | + <RouterLink :to="{ name: 'TripsIndex' }" class="sa-hero-btn-white"> | |
| 37 | + <i class="bi bi-suitcase-lg me-1"></i> {{ t('home.myTrips') }} | |
| 38 | + </RouterLink> | |
| 39 | + </template> | |
| 40 | + <template v-else> | |
| 41 | + <RouterLink :to="{ name: 'Register' }" class="sa-hero-btn-white"> | |
| 42 | + {{ t('home.getStartedFree') }} <i class="bi bi-arrow-right ms-1"></i> | |
| 43 | + </RouterLink> | |
| 44 | + <RouterLink :to="{ name: 'Login' }" class="sa-hero-btn-outline"> | |
| 45 | + {{ t('nav.signIn') }} | |
| 46 | + </RouterLink> | |
| 47 | + </template> | |
| 48 | + </div> | |
| 49 | + | |
| 50 | + </div> | |
| 51 | + </div> | |
| 52 | + | |
| 53 | + <!-- Features Grid --> | |
| 54 | + <div class="sa-home-section"> | |
| 55 | + <h2 class="text-center mb-2 sa-animate-fade-in" style="font-size: 1.75rem; font-weight: 800">{{ t('home.featuresTitle') }}</h2> | |
| 56 | + <p class="text-center mb-5 sa-animate-fade-in" style="color: var(--sa-gray-500); max-width: 520px; margin: 0 auto"> | |
| 57 | + {{ t('home.featuresSubtitle') }} | |
| 58 | + </p> | |
| 59 | + <div class="row g-4 mb-5 justify-content-center"> | |
| 60 | + <div | |
| 61 | + v-for="(feature, index) in features" | |
| 62 | + :key="feature.title" | |
| 63 | + class="col-md-6 col-lg-4 sa-animate-slide-up" | |
| 64 | + :class="`sa-stagger-${index + 1}`" | |
| 65 | + > | |
| 66 | + <div class="sa-card sa-feature-card sa-hover-lift h-100"> | |
| 67 | + <div class="sa-card-body"> | |
| 68 | + <div class="sa-feature-icon-wrap"> | |
| 69 | + <i :class="['bi', feature.icon]"></i> | |
| 70 | + </div> | |
| 71 | + <div class="sa-feature-title">{{ feature.title }}</div> | |
| 72 | + <div class="sa-feature-text">{{ feature.text }}</div> | |
| 73 | + </div> | |
| 74 | + </div> | |
| 75 | + </div> | |
| 76 | + </div> | |
| 77 | + </div> | |
| 78 | + </div> | |
| 79 | +</template> | |
| 80 | + | |
| 81 | +<style scoped> | |
| 82 | +.sa-home-section { | |
| 83 | + padding-top: var(--sa-space-8); | |
| 84 | +} | |
| 85 | + | |
| 86 | +.sa-hero-badge { | |
| 87 | + display: inline-block; | |
| 88 | + background: rgba(255, 255, 255, 0.2); | |
| 89 | + border: 1px solid rgba(255, 255, 255, 0.3); | |
| 90 | + border-radius: 9999px; | |
| 91 | + padding: 6px 16px; | |
| 92 | + font-size: 0.813rem; | |
| 93 | + font-weight: 600; | |
| 94 | + color: #fff; | |
| 95 | + margin-bottom: 20px; | |
| 96 | + backdrop-filter: blur(8px); | |
| 97 | +} | |
| 98 | + | |
| 99 | +.sa-hero-title { | |
| 100 | + font-size: 3rem; | |
| 101 | + font-weight: 900; | |
| 102 | + line-height: 1.15; | |
| 103 | + margin-bottom: 16px; | |
| 104 | + letter-spacing: -0.03em; | |
| 105 | + color: #fff; | |
| 106 | +} | |
| 107 | + | |
| 108 | +.sa-hero-title-soft { | |
| 109 | + color: rgba(255, 255, 255, 0.85); | |
| 110 | + font-weight: 800; | |
| 111 | +} | |
| 112 | + | |
| 113 | +.sa-hero-subtitle { | |
| 114 | + font-size: 1.125rem; | |
| 115 | + line-height: 1.6; | |
| 116 | + max-width: 520px; | |
| 117 | +} | |
| 118 | + | |
| 119 | +.sa-feature-icon-wrap { | |
| 120 | + width: 48px; | |
| 121 | + height: 48px; | |
| 122 | + border-radius: 14px; | |
| 123 | + display: flex; | |
| 124 | + align-items: center; | |
| 125 | + justify-content: center; | |
| 126 | + margin: 0 auto 16px; | |
| 127 | + background: var(--sa-gray-100); | |
| 128 | + color: var(--sa-primary); | |
| 129 | + font-size: 1.4rem; | |
| 130 | +} | |
| 131 | + | |
| 132 | +@media (max-width: 767px) { | |
| 133 | + .sa-hero-title { | |
| 134 | + font-size: 2rem; | |
| 135 | + } | |
| 136 | +} | |
| 137 | +</style> |
added src/views/LoginView.vue +118 −0
| @@ -0,0 +1,118 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import { ref } from 'vue' | |
| 3 | +import { useRouter } from 'vue-router' | |
| 4 | +import { useI18n } from 'vue-i18n' | |
| 5 | +import { useAuthStore } from '@/stores/auth' | |
| 6 | +import AccountService from '@/services/AccountService' | |
| 7 | + | |
| 8 | +const router = useRouter() | |
| 9 | +const authStore = useAuthStore() | |
| 10 | +const { t } = useI18n() | |
| 11 | + | |
| 12 | +const email = ref('') | |
| 13 | +const password = ref('') | |
| 14 | +const errors = ref<string[]>([]) | |
| 15 | +const isLoading = ref(false) | |
| 16 | + | |
| 17 | +async function handleLogin() { | |
| 18 | + errors.value = [] | |
| 19 | + isLoading.value = true | |
| 20 | + | |
| 21 | + const result = await AccountService.loginAsync(email.value, password.value) | |
| 22 | + if (result.errors) { | |
| 23 | + errors.value = result.errors | |
| 24 | + } else if (result.data) { | |
| 25 | + authStore.jwt = result.data.jwt | |
| 26 | + authStore.refreshToken = result.data.refreshToken | |
| 27 | + authStore.userName = `${result.data.firstName} ${result.data.lastName}` | |
| 28 | + router.push({ name: 'Home' }) | |
| 29 | + } | |
| 30 | + | |
| 31 | + isLoading.value = false | |
| 32 | +} | |
| 33 | +</script> | |
| 34 | + | |
| 35 | +<template> | |
| 36 | + <div class="sa-auth-page"> | |
| 37 | + <div class="sa-auth-card sa-card sa-animate-fade-in"> | |
| 38 | + <div class="sa-card-body" style="padding: var(--sa-space-10) var(--sa-space-8)"> | |
| 39 | + <!-- Brand --> | |
| 40 | + <div class="sa-auth-brand"> | |
| 41 | + <div class="sa-auth-logo"> | |
| 42 | + <i class="bi bi-airplane-fill"></i> | |
| 43 | + </div> | |
| 44 | + <h1 class="sa-text-primary">SplitApp</h1> | |
| 45 | + <p>{{ t('auth.login.welcomeBack') }}</p> | |
| 46 | + </div> | |
| 47 | + | |
| 48 | + <!-- Errors --> | |
| 49 | + <div v-if="errors.length" class="sa-card-static sa-card-accent sa-card-accent-danger mb-4"> | |
| 50 | + <div class="sa-card-body" style="padding: var(--sa-space-3) var(--sa-space-4)"> | |
| 51 | + <div v-for="error in errors" :key="error" style="font-size: 0.875rem"> | |
| 52 | + <i class="bi bi-exclamation-circle me-1"></i>{{ error }} | |
| 53 | + </div> | |
| 54 | + </div> | |
| 55 | + </div> | |
| 56 | + | |
| 57 | + <!-- Form --> | |
| 58 | + <form @submit.prevent="handleLogin"> | |
| 59 | + <div class="mb-3"> | |
| 60 | + <label for="email" class="form-label">{{ t('auth.login.email') }}</label> | |
| 61 | + <input | |
| 62 | + id="email" | |
| 63 | + v-model="email" | |
| 64 | + type="email" | |
| 65 | + class="form-control" | |
| 66 | + :placeholder="t('auth.login.emailPlaceholder')" | |
| 67 | + required | |
| 68 | + autocomplete="email" | |
| 69 | + /> | |
| 70 | + </div> | |
| 71 | + | |
| 72 | + <div class="mb-4"> | |
| 73 | + <label for="password" class="form-label">{{ t('auth.login.password') }}</label> | |
| 74 | + <input | |
| 75 | + id="password" | |
| 76 | + v-model="password" | |
| 77 | + type="password" | |
| 78 | + class="form-control" | |
| 79 | + :placeholder="t('auth.login.passwordPlaceholder')" | |
| 80 | + required | |
| 81 | + autocomplete="current-password" | |
| 82 | + /> | |
| 83 | + </div> | |
| 84 | + | |
| 85 | + <button | |
| 86 | + type="submit" | |
| 87 | + class="sa-btn sa-btn-primary w-100" | |
| 88 | + :class="{ 'sa-btn-loading': isLoading }" | |
| 89 | + :disabled="isLoading" | |
| 90 | + > | |
| 91 | + {{ isLoading ? t('auth.login.signingIn') : t('auth.login.signIn') }} | |
| 92 | + </button> | |
| 93 | + </form> | |
| 94 | + | |
| 95 | + <!-- Register link --> | |
| 96 | + <div class="text-center mt-4" style="font-size: 0.938rem; color: var(--sa-gray-500)"> | |
| 97 | + {{ t('auth.login.noAccount') }} | |
| 98 | + <RouterLink :to="{ name: 'Register' }" style="font-weight: 600">{{ t('auth.login.createOne') }}</RouterLink> | |
| 99 | + </div> | |
| 100 | + </div> | |
| 101 | + </div> | |
| 102 | + </div> | |
| 103 | +</template> | |
| 104 | + | |
| 105 | +<style scoped> | |
| 106 | +.sa-auth-logo { | |
| 107 | + width: 56px; | |
| 108 | + height: 56px; | |
| 109 | + border-radius: 16px; | |
| 110 | + background: linear-gradient(135deg, #e8604c 0%, #d4456a 100%); | |
| 111 | + display: flex; | |
| 112 | + align-items: center; | |
| 113 | + justify-content: center; | |
| 114 | + font-size: 1.5rem; | |
| 115 | + color: #fff; | |
| 116 | + margin: 0 auto 16px; | |
| 117 | +} | |
| 118 | +</style> |
added src/views/RegisterView.vue +152 −0
| @@ -0,0 +1,152 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import { ref } from 'vue' | |
| 3 | +import { useRouter } from 'vue-router' | |
| 4 | +import { useI18n } from 'vue-i18n' | |
| 5 | +import { useAuthStore } from '@/stores/auth' | |
| 6 | +import AccountService from '@/services/AccountService' | |
| 7 | + | |
| 8 | +const router = useRouter() | |
| 9 | +const authStore = useAuthStore() | |
| 10 | +const { t } = useI18n() | |
| 11 | + | |
| 12 | +const firstName = ref('') | |
| 13 | +const lastName = ref('') | |
| 14 | +const email = ref('') | |
| 15 | +const password = ref('') | |
| 16 | +const errors = ref<string[]>([]) | |
| 17 | +const isLoading = ref(false) | |
| 18 | + | |
| 19 | +async function handleRegister() { | |
| 20 | + errors.value = [] | |
| 21 | + isLoading.value = true | |
| 22 | + | |
| 23 | + const result = await AccountService.registerAsync( | |
| 24 | + email.value, | |
| 25 | + password.value, | |
| 26 | + firstName.value, | |
| 27 | + lastName.value, | |
| 28 | + ) | |
| 29 | + | |
| 30 | + if (result.errors) { | |
| 31 | + errors.value = result.errors | |
| 32 | + } else if (result.data) { | |
| 33 | + authStore.jwt = result.data.jwt | |
| 34 | + authStore.refreshToken = result.data.refreshToken | |
| 35 | + authStore.userName = `${result.data.firstName} ${result.data.lastName}` | |
| 36 | + router.push({ name: 'Home' }) | |
| 37 | + } | |
| 38 | + | |
| 39 | + isLoading.value = false | |
| 40 | +} | |
| 41 | +</script> | |
| 42 | + | |
| 43 | +<template> | |
| 44 | + <div class="sa-auth-page"> | |
| 45 | + <div class="sa-auth-card sa-card sa-animate-fade-in"> | |
| 46 | + <div class="sa-card-body" style="padding: var(--sa-space-10) var(--sa-space-8)"> | |
| 47 | + <!-- Brand --> | |
| 48 | + <div class="sa-auth-brand"> | |
| 49 | + <div class="sa-auth-logo"> | |
| 50 | + <i class="bi bi-airplane-fill"></i> | |
| 51 | + </div> | |
| 52 | + <h1 class="sa-text-primary">SplitApp</h1> | |
| 53 | + <p>{{ t('auth.register.getStarted') }}</p> | |
| 54 | + </div> | |
| 55 | + | |
| 56 | + <!-- Errors --> | |
| 57 | + <div v-if="errors.length" class="sa-card-static sa-card-accent sa-card-accent-danger mb-4"> | |
| 58 | + <div class="sa-card-body" style="padding: var(--sa-space-3) var(--sa-space-4)"> | |
| 59 | + <div v-for="error in errors" :key="error" style="font-size: 0.875rem"> | |
| 60 | + <i class="bi bi-exclamation-circle me-1"></i>{{ error }} | |
| 61 | + </div> | |
| 62 | + </div> | |
| 63 | + </div> | |
| 64 | + | |
| 65 | + <!-- Form --> | |
| 66 | + <form @submit.prevent="handleRegister"> | |
| 67 | + <div class="d-flex gap-3 mb-3"> | |
| 68 | + <div class="flex-fill"> | |
| 69 | + <label for="firstName" class="form-label">{{ t('auth.register.firstName') }}</label> | |
| 70 | + <input | |
| 71 | + id="firstName" | |
| 72 | + v-model="firstName" | |
| 73 | + type="text" | |
| 74 | + class="form-control" | |
| 75 | + :placeholder="t('auth.register.firstNamePlaceholder')" | |
| 76 | + required | |
| 77 | + /> | |
| 78 | + </div> | |
| 79 | + <div class="flex-fill"> | |
| 80 | + <label for="lastName" class="form-label">{{ t('auth.register.lastName') }}</label> | |
| 81 | + <input | |
| 82 | + id="lastName" | |
| 83 | + v-model="lastName" | |
| 84 | + type="text" | |
| 85 | + class="form-control" | |
| 86 | + :placeholder="t('auth.register.lastNamePlaceholder')" | |
| 87 | + required | |
| 88 | + /> | |
| 89 | + </div> | |
| 90 | + </div> | |
| 91 | + | |
| 92 | + <div class="mb-3"> | |
| 93 | + <label for="email" class="form-label">{{ t('auth.register.email') }}</label> | |
| 94 | + <input | |
| 95 | + id="email" | |
| 96 | + v-model="email" | |
| 97 | + type="email" | |
| 98 | + class="form-control" | |
| 99 | + :placeholder="t('auth.register.emailPlaceholder')" | |
| 100 | + required | |
| 101 | + autocomplete="email" | |
| 102 | + /> | |
| 103 | + </div> | |
| 104 | + | |
| 105 | + <div class="mb-4"> | |
| 106 | + <label for="password" class="form-label">{{ t('auth.register.password') }}</label> | |
| 107 | + <input | |
| 108 | + id="password" | |
| 109 | + v-model="password" | |
| 110 | + type="password" | |
| 111 | + class="form-control" | |
| 112 | + :placeholder="t('auth.register.passwordPlaceholder')" | |
| 113 | + required | |
| 114 | + minlength="6" | |
| 115 | + autocomplete="new-password" | |
| 116 | + /> | |
| 117 | + </div> | |
| 118 | + | |
| 119 | + <button | |
| 120 | + type="submit" | |
| 121 | + class="sa-btn sa-btn-primary w-100" | |
| 122 | + :class="{ 'sa-btn-loading': isLoading }" | |
| 123 | + :disabled="isLoading" | |
| 124 | + > | |
| 125 | + {{ isLoading ? t('auth.register.creatingAccount') : t('auth.register.createAccount') }} | |
| 126 | + </button> | |
| 127 | + </form> | |
| 128 | + | |
| 129 | + <!-- Login link --> | |
| 130 | + <div class="text-center mt-4" style="font-size: 0.938rem; color: var(--sa-gray-500)"> | |
| 131 | + {{ t('auth.register.haveAccount') }} | |
| 132 | + <RouterLink :to="{ name: 'Login' }" style="font-weight: 600">{{ t('auth.register.signIn') }}</RouterLink> | |
| 133 | + </div> | |
| 134 | + </div> | |
| 135 | + </div> | |
| 136 | + </div> | |
| 137 | +</template> | |
| 138 | + | |
| 139 | +<style scoped> | |
| 140 | +.sa-auth-logo { | |
| 141 | + width: 56px; | |
| 142 | + height: 56px; | |
| 143 | + border-radius: 16px; | |
| 144 | + background: linear-gradient(135deg, #e8604c 0%, #d4456a 100%); | |
| 145 | + display: flex; | |
| 146 | + align-items: center; | |
| 147 | + justify-content: center; | |
| 148 | + font-size: 1.5rem; | |
| 149 | + color: #fff; | |
| 150 | + margin: 0 auto 16px; | |
| 151 | +} | |
| 152 | +</style> |
added src/views/budget-categories/CreateView.vue +118 −0
| @@ -0,0 +1,118 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import { ref } from 'vue' | |
| 3 | +import { useRouter, useRoute } from 'vue-router' | |
| 4 | +import { useI18n } from 'vue-i18n' | |
| 5 | +import BudgetCategoryService from '@/services/BudgetCategoryService' | |
| 6 | +import { useToast } from '@/composables/useToast' | |
| 7 | + | |
| 8 | +const router = useRouter() | |
| 9 | +const route = useRoute() | |
| 10 | +const toast = useToast() | |
| 11 | +const { t } = useI18n() | |
| 12 | + | |
| 13 | +const tripId = route.params.tripId as string | |
| 14 | + | |
| 15 | +const name = ref('') | |
| 16 | +const iconName = ref('') | |
| 17 | +const plannedAmount = ref<number | null>(null) | |
| 18 | +const displayOrder = ref<number>(0) | |
| 19 | +const errors = ref<string[]>([]) | |
| 20 | +const isSaving = ref(false) | |
| 21 | + | |
| 22 | +async function handleSubmit() { | |
| 23 | + errors.value = [] | |
| 24 | + isSaving.value = true | |
| 25 | + | |
| 26 | + const result = await BudgetCategoryService.create({ | |
| 27 | + tripId, | |
| 28 | + name: name.value, | |
| 29 | + iconName: iconName.value || null, | |
| 30 | + plannedAmount: plannedAmount.value, | |
| 31 | + displayOrder: displayOrder.value, | |
| 32 | + }) | |
| 33 | + | |
| 34 | + if (result.errors) { | |
| 35 | + errors.value = result.errors | |
| 36 | + } else { | |
| 37 | + toast.success(t('budget.create.created')) | |
| 38 | + router.push({ name: 'BudgetIndex', params: { tripId } }) | |
| 39 | + } | |
| 40 | + | |
| 41 | + isSaving.value = false | |
| 42 | +} | |
| 43 | +</script> | |
| 44 | + | |
| 45 | +<template> | |
| 46 | + <div class="row justify-content-center"> | |
| 47 | + <div class="col-md-8 col-lg-6"> | |
| 48 | + <h3 class="mb-4"><i class="bi bi-plus-circle me-2 sa-text-primary"></i>{{ t('budget.create.title') }}</h3> | |
| 49 | + | |
| 50 | + <div v-if="errors.length" class="sa-card-static sa-card-accent sa-card-accent-danger mb-4"> | |
| 51 | + <div class="sa-card-body"> | |
| 52 | + <div v-for="err in errors" :key="err" style="color: var(--sa-danger); font-size: 0.9rem">{{ err }}</div> | |
| 53 | + </div> | |
| 54 | + </div> | |
| 55 | + | |
| 56 | + <form @submit.prevent="handleSubmit"> | |
| 57 | + <div class="mb-3"> | |
| 58 | + <label for="name" class="form-label">{{ t('budget.create.name') }}</label> | |
| 59 | + <input id="name" v-model="name" type="text" class="form-control" :placeholder="t('budget.create.namePlaceholder')" required /> | |
| 60 | + </div> | |
| 61 | + | |
| 62 | + <div class="mb-3"> | |
| 63 | + <label for="iconName" class="form-label">{{ t('budget.create.iconName') }}</label> | |
| 64 | + <input | |
| 65 | + id="iconName" | |
| 66 | + v-model="iconName" | |
| 67 | + type="text" | |
| 68 | + class="form-control" | |
| 69 | + :placeholder="t('budget.create.iconNamePlaceholder')" | |
| 70 | + /> | |
| 71 | + <div class="form-text">{{ t('budget.create.iconHelp') }}</div> | |
| 72 | + </div> | |
| 73 | + | |
| 74 | + <div class="mb-3"> | |
| 75 | + <label for="plannedAmount" class="form-label">{{ t('budget.create.plannedAmount') }}</label> | |
| 76 | + <input | |
| 77 | + id="plannedAmount" | |
| 78 | + v-model.number="plannedAmount" | |
| 79 | + type="number" | |
| 80 | + step="0.01" | |
| 81 | + min="0" | |
| 82 | + class="form-control" | |
| 83 | + placeholder="0.00" | |
| 84 | + /> | |
| 85 | + </div> | |
| 86 | + | |
| 87 | + <div class="mb-4"> | |
| 88 | + <label for="displayOrder" class="form-label">{{ t('budget.create.displayOrder') }}</label> | |
| 89 | + <input | |
| 90 | + id="displayOrder" | |
| 91 | + v-model.number="displayOrder" | |
| 92 | + type="number" | |
| 93 | + min="0" | |
| 94 | + class="form-control" | |
| 95 | + /> | |
| 96 | + </div> | |
| 97 | + | |
| 98 | + <div class="d-flex gap-2 justify-content-end"> | |
| 99 | + <button | |
| 100 | + type="button" | |
| 101 | + class="sa-btn sa-btn-ghost" | |
| 102 | + @click="router.push({ name: 'BudgetIndex', params: { tripId } })" | |
| 103 | + > | |
| 104 | + {{ t('common.cancel') }} | |
| 105 | + </button> | |
| 106 | + <button | |
| 107 | + type="submit" | |
| 108 | + class="sa-btn sa-btn-primary" | |
| 109 | + :class="{ 'sa-btn-loading': isSaving }" | |
| 110 | + :disabled="isSaving" | |
| 111 | + > | |
| 112 | + <i class="bi bi-check-lg"></i> {{ t('budget.create.addCategory') }} | |
| 113 | + </button> | |
| 114 | + </div> | |
| 115 | + </form> | |
| 116 | + </div> | |
| 117 | + </div> | |
| 118 | +</template> |
added src/views/budget-categories/EditView.vue +170 −0
| @@ -0,0 +1,170 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import { ref, onMounted } from 'vue' | |
| 3 | +import { useRouter, useRoute } from 'vue-router' | |
| 4 | +import { useI18n } from 'vue-i18n' | |
| 5 | +import BudgetCategoryService from '@/services/BudgetCategoryService' | |
| 6 | +import { useToast } from '@/composables/useToast' | |
| 7 | + | |
| 8 | +const router = useRouter() | |
| 9 | +const route = useRoute() | |
| 10 | +const toast = useToast() | |
| 11 | +const { t } = useI18n() | |
| 12 | + | |
| 13 | +const tripId = route.params.tripId as string | |
| 14 | +const categoryId = route.params.id as string | |
| 15 | + | |
| 16 | +const name = ref('') | |
| 17 | +const iconName = ref('') | |
| 18 | +const plannedAmount = ref<number | null>(null) | |
| 19 | +const spentAmount = ref<number>(0) | |
| 20 | +const displayOrder = ref<number>(0) | |
| 21 | +const errors = ref<string[]>([]) | |
| 22 | +const isSaving = ref(false) | |
| 23 | +const isLoading = ref(true) | |
| 24 | + | |
| 25 | +onMounted(async () => { | |
| 26 | + const result = await BudgetCategoryService.getByTrip(tripId) | |
| 27 | + if (result.data) { | |
| 28 | + const cat = result.data.find((c) => c.id === categoryId) | |
| 29 | + if (cat) { | |
| 30 | + name.value = cat.name | |
| 31 | + iconName.value = cat.iconName || '' | |
| 32 | + plannedAmount.value = cat.plannedAmount | |
| 33 | + spentAmount.value = cat.spentAmount | |
| 34 | + displayOrder.value = cat.displayOrder | |
| 35 | + } else { | |
| 36 | + errors.value = [t('budget.edit.notFound')] | |
| 37 | + } | |
| 38 | + } else if (result.errors) { | |
| 39 | + errors.value = result.errors | |
| 40 | + } | |
| 41 | + isLoading.value = false | |
| 42 | +}) | |
| 43 | + | |
| 44 | +async function handleSubmit() { | |
| 45 | + errors.value = [] | |
| 46 | + isSaving.value = true | |
| 47 | + | |
| 48 | + const result = await BudgetCategoryService.update(categoryId, { | |
| 49 | + tripId, | |
| 50 | + name: name.value, | |
| 51 | + iconName: iconName.value || null, | |
| 52 | + plannedAmount: plannedAmount.value, | |
| 53 | + displayOrder: displayOrder.value, | |
| 54 | + }) | |
| 55 | + | |
| 56 | + if (result.errors) { | |
| 57 | + errors.value = result.errors | |
| 58 | + } else { | |
| 59 | + toast.success(t('budget.edit.updated')) | |
| 60 | + router.push({ name: 'BudgetIndex', params: { tripId } }) | |
| 61 | + } | |
| 62 | + | |
| 63 | + isSaving.value = false | |
| 64 | +} | |
| 65 | + | |
| 66 | +async function handleDelete() { | |
| 67 | + if (!confirm(t('budget.edit.confirmDelete'))) return | |
| 68 | + const result = await BudgetCategoryService.delete(categoryId) | |
| 69 | + if (result.errors) { | |
| 70 | + toast.error(result.errors.join(', ')) | |
| 71 | + } else { | |
| 72 | + toast.success(t('budget.index.deleted')) | |
| 73 | + router.push({ name: 'BudgetIndex', params: { tripId } }) | |
| 74 | + } | |
| 75 | +} | |
| 76 | +</script> | |
| 77 | + | |
| 78 | +<template> | |
| 79 | + <div class="row justify-content-center"> | |
| 80 | + <div v-if="isLoading" class="text-center py-5"> | |
| 81 | + <div class="spinner-border" style="color: var(--sa-primary)" role="status"></div> | |
| 82 | + </div> | |
| 83 | + | |
| 84 | + <div v-else class="col-md-8 col-lg-6"> | |
| 85 | + <h3 class="mb-4"><i class="bi bi-pencil me-2 sa-text-primary"></i>{{ t('budget.edit.title') }}</h3> | |
| 86 | + | |
| 87 | + <div v-if="errors.length" class="sa-card-static sa-card-accent sa-card-accent-danger mb-4"> | |
| 88 | + <div class="sa-card-body"> | |
| 89 | + <div v-for="err in errors" :key="err" style="color: var(--sa-danger); font-size: 0.9rem">{{ err }}</div> | |
| 90 | + </div> | |
| 91 | + </div> | |
| 92 | + | |
| 93 | + <form @submit.prevent="handleSubmit"> | |
| 94 | + <div class="mb-3"> | |
| 95 | + <label for="name" class="form-label">{{ t('budget.create.name') }}</label> | |
| 96 | + <input id="name" v-model="name" type="text" class="form-control" required /> | |
| 97 | + </div> | |
| 98 | + | |
| 99 | + <div class="mb-3"> | |
| 100 | + <label for="iconName" class="form-label">{{ t('budget.create.iconName') }}</label> | |
| 101 | + <input | |
| 102 | + id="iconName" | |
| 103 | + v-model="iconName" | |
| 104 | + type="text" | |
| 105 | + class="form-control" | |
| 106 | + :placeholder="t('budget.create.iconNamePlaceholder')" | |
| 107 | + /> | |
| 108 | + <div class="form-text">{{ t('budget.create.iconHelp') }}</div> | |
| 109 | + </div> | |
| 110 | + | |
| 111 | + <div class="mb-3"> | |
| 112 | + <label for="plannedAmount" class="form-label">{{ t('budget.create.plannedAmount') }}</label> | |
| 113 | + <input | |
| 114 | + id="plannedAmount" | |
| 115 | + v-model.number="plannedAmount" | |
| 116 | + type="number" | |
| 117 | + step="0.01" | |
| 118 | + min="0" | |
| 119 | + class="form-control" | |
| 120 | + placeholder="0.00" | |
| 121 | + /> | |
| 122 | + </div> | |
| 123 | + | |
| 124 | + <div class="mb-3"> | |
| 125 | + <label class="form-label">{{ t('budget.edit.spentAmount') }}</label> | |
| 126 | + <input | |
| 127 | + type="text" | |
| 128 | + class="form-control" | |
| 129 | + :value="spentAmount.toFixed(2)" | |
| 130 | + readonly | |
| 131 | + disabled | |
| 132 | + /> | |
| 133 | + </div> | |
| 134 | + | |
| 135 | + <div class="mb-4"> | |
| 136 | + <label for="displayOrder" class="form-label">{{ t('budget.create.displayOrder') }}</label> | |
| 137 | + <input | |
| 138 | + id="displayOrder" | |
| 139 | + v-model.number="displayOrder" | |
| 140 | + type="number" | |
| 141 | + min="0" | |
| 142 | + class="form-control" | |
| 143 | + /> | |
| 144 | + </div> | |
| 145 | + | |
| 146 | + <div class="d-flex gap-2"> | |
| 147 | + <button type="button" class="sa-btn sa-btn-danger sa-btn-sm" @click="handleDelete"> | |
| 148 | + <i class="bi bi-trash3"></i> {{ t('common.delete') }} | |
| 149 | + </button> | |
| 150 | + <div class="flex-grow-1"></div> | |
| 151 | + <button | |
| 152 | + type="button" | |
| 153 | + class="sa-btn sa-btn-ghost" | |
| 154 | + @click="router.push({ name: 'BudgetIndex', params: { tripId } })" | |
| 155 | + > | |
| 156 | + {{ t('common.cancel') }} | |
| 157 | + </button> | |
| 158 | + <button | |
| 159 | + type="submit" | |
| 160 | + class="sa-btn sa-btn-primary" | |
| 161 | + :class="{ 'sa-btn-loading': isSaving }" | |
| 162 | + :disabled="isSaving" | |
| 163 | + > | |
| 164 | + <i class="bi bi-check-lg"></i> {{ t('common.saveChanges') }} | |
| 165 | + </button> | |
| 166 | + </div> | |
| 167 | + </form> | |
| 168 | + </div> | |
| 169 | + </div> | |
| 170 | +</template> |
added src/views/budget-categories/IndexView.vue +244 −0
| @@ -0,0 +1,244 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import { ref, computed, onMounted, inject, type ComputedRef } from 'vue' | |
| 3 | +import { useRouter, useRoute } from 'vue-router' | |
| 4 | +import { useI18n } from 'vue-i18n' | |
| 5 | +import BudgetCategoryService from '@/services/BudgetCategoryService' | |
| 6 | +import type { IBudgetCategory } from '@/types/IBudgetCategory' | |
| 7 | +import { useToast } from '@/composables/useToast' | |
| 8 | +import { formatCurrency } from '@/utils/formatCurrency' | |
| 9 | + | |
| 10 | +const { t } = useI18n() | |
| 11 | + | |
| 12 | +const _tripCurrencySymbol = inject<ComputedRef<string>>('tripCurrencySymbol') | |
| 13 | +const tripCurrencySymbol = computed(() => _tripCurrencySymbol?.value ?? '') | |
| 14 | + | |
| 15 | +const _isOrganizer = inject<ComputedRef<boolean>>('isOrganizer') | |
| 16 | +const isOrganizer = computed(() => _isOrganizer?.value ?? false) | |
| 17 | + | |
| 18 | +const router = useRouter() | |
| 19 | +const route = useRoute() | |
| 20 | +const toast = useToast() | |
| 21 | + | |
| 22 | +const categories = ref<IBudgetCategory[]>([]) | |
| 23 | +const isLoading = ref(true) | |
| 24 | +const error = ref<string | null>(null) | |
| 25 | + | |
| 26 | +const tripId = route.params.tripId as string | |
| 27 | + | |
| 28 | +const totalPlanned = computed(() => | |
| 29 | + categories.value.reduce((sum, c) => sum + (c.plannedAmount || 0), 0), | |
| 30 | +) | |
| 31 | + | |
| 32 | +const totalSpent = computed(() => | |
| 33 | + categories.value.reduce((sum, c) => sum + c.spentAmount, 0), | |
| 34 | +) | |
| 35 | + | |
| 36 | +const remaining = computed(() => totalPlanned.value - totalSpent.value) | |
| 37 | + | |
| 38 | +const overallPercent = computed(() => { | |
| 39 | + if (totalPlanned.value <= 0) return 0 | |
| 40 | + return Math.min(100, Math.round((totalSpent.value / totalPlanned.value) * 100)) | |
| 41 | +}) | |
| 42 | + | |
| 43 | +function overallBarClass(): string { | |
| 44 | + const pct = overallPercent.value | |
| 45 | + if (pct > 85) return 'sa-progress-bar-danger' | |
| 46 | + if (pct > 60) return 'sa-progress-bar-warning' | |
| 47 | + return 'sa-progress-bar-success' | |
| 48 | +} | |
| 49 | + | |
| 50 | +function spentStatColor(): string { | |
| 51 | + const pct = overallPercent.value | |
| 52 | + if (pct > 85) return 'color: var(--sa-danger)' | |
| 53 | + if (pct > 60) return 'color: var(--sa-warning)' | |
| 54 | + return 'color: var(--sa-success)' | |
| 55 | +} | |
| 56 | + | |
| 57 | +onMounted(async () => { | |
| 58 | + const result = await BudgetCategoryService.getByTrip(tripId) | |
| 59 | + if (result.data) { | |
| 60 | + categories.value = result.data | |
| 61 | + } else if (result.errors) { | |
| 62 | + error.value = result.errors.join(', ') | |
| 63 | + } | |
| 64 | + isLoading.value = false | |
| 65 | +}) | |
| 66 | + | |
| 67 | +async function deleteCategory(id: string) { | |
| 68 | + if (!confirm(t('budget.index.confirmDelete'))) return | |
| 69 | + error.value = null | |
| 70 | + | |
| 71 | + const result = await BudgetCategoryService.delete(id) | |
| 72 | + if (result.errors) { | |
| 73 | + toast.error(result.errors.join(', ')) | |
| 74 | + } else { | |
| 75 | + categories.value = categories.value.filter((c) => c.id !== id) | |
| 76 | + toast.success(t('budget.index.deleted')) | |
| 77 | + } | |
| 78 | +} | |
| 79 | + | |
| 80 | +function progressPercent(cat: IBudgetCategory): number { | |
| 81 | + if (!cat.plannedAmount || cat.plannedAmount <= 0) return 0 | |
| 82 | + return Math.min(100, Math.round((cat.spentAmount / cat.plannedAmount) * 100)) | |
| 83 | +} | |
| 84 | + | |
| 85 | +function progressBarClass(cat: IBudgetCategory): string { | |
| 86 | + const pct = progressPercent(cat) | |
| 87 | + if (pct > 85) return 'sa-progress-bar-danger' | |
| 88 | + if (pct > 60) return 'sa-progress-bar-warning' | |
| 89 | + return 'sa-progress-bar-success' | |
| 90 | +} | |
| 91 | + | |
| 92 | +function isOverBudget(cat: IBudgetCategory): boolean { | |
| 93 | + return !!cat.plannedAmount && cat.spentAmount > cat.plannedAmount | |
| 94 | +} | |
| 95 | + | |
| 96 | +function categoryIcon(cat: IBudgetCategory): string { | |
| 97 | + if (cat.iconName) return 'bi-' + cat.iconName | |
| 98 | + return 'bi-tag-fill' | |
| 99 | +} | |
| 100 | +</script> | |
| 101 | + | |
| 102 | +<template> | |
| 103 | + <div> | |
| 104 | + <!-- Gradient Header --> | |
| 105 | + <div class="sa-gradient-header sa-gradient-header-green d-flex justify-content-between align-items-center"> | |
| 106 | + <div> | |
| 107 | + <h3 class="mb-1"><i class="bi bi-pie-chart-fill me-2"></i>{{ t('budget.index.title') }}</h3> | |
| 108 | + <p v-if="!isLoading && categories.length > 0" class="mb-0" style="font-size: 0.9rem; opacity: 0.85"> | |
| 109 | + {{ t('budget.index.categoryCount', { n: categories.length }, categories.length) }} | |
| 110 | + </p> | |
| 111 | + </div> | |
| 112 | + <button | |
| 113 | + v-if="isOrganizer" | |
| 114 | + class="sa-btn sa-btn-primary sa-btn-pill sa-hide-mobile" | |
| 115 | + style="background: #fff; color: var(--sa-success)" | |
| 116 | + @click="router.push({ name: 'BudgetCreate', params: { tripId } })" | |
| 117 | + > | |
| 118 | + <i class="bi bi-plus-lg"></i> {{ t('budget.index.addCategory') }} | |
| 119 | + </button> | |
| 120 | + </div> | |
| 121 | + | |
| 122 | + <div v-if="error" class="alert alert-danger">{{ error }}</div> | |
| 123 | + | |
| 124 | + <div v-if="isLoading" class="text-center py-5"> | |
| 125 | + <div class="spinner-border" style="color: var(--sa-primary)" role="status"></div> | |
| 126 | + </div> | |
| 127 | + | |
| 128 | + <div v-else-if="categories.length === 0" class="sa-empty"> | |
| 129 | + <div class="sa-empty-icon"><i class="bi bi-pie-chart"></i></div> | |
| 130 | + <div class="sa-empty-title">{{ t('budget.index.emptyTitle') }}</div> | |
| 131 | + <div class="sa-empty-text">{{ t('budget.index.emptyText') }}</div> | |
| 132 | + <button | |
| 133 | + v-if="isOrganizer" | |
| 134 | + class="sa-btn sa-btn-primary sa-btn-pill" | |
| 135 | + @click="router.push({ name: 'BudgetCreate', params: { tripId } })" | |
| 136 | + > | |
| 137 | + <i class="bi bi-plus-lg"></i> {{ t('budget.index.addCategory') }} | |
| 138 | + </button> | |
| 139 | + </div> | |
| 140 | + | |
| 141 | + <template v-else> | |
| 142 | + <!-- Stat Cards --> | |
| 143 | + <div class="row g-3 mb-4"> | |
| 144 | + <div class="col-4"> | |
| 145 | + <div class="sa-card-static sa-card-body sa-stat"> | |
| 146 | + <div class="sa-stat-icon"><i class="bi bi-calculator" style="color: var(--sa-gray-500)"></i></div> | |
| 147 | + <div class="sa-stat-value">{{ formatCurrency(totalPlanned, tripCurrencySymbol) }}</div> | |
| 148 | + <div class="sa-stat-label">{{ t('budget.index.totalPlanned') }}</div> | |
| 149 | + </div> | |
| 150 | + </div> | |
| 151 | + <div class="col-4"> | |
| 152 | + <div class="sa-card-static sa-card-body sa-stat"> | |
| 153 | + <div class="sa-stat-icon"><i class="bi bi-credit-card" :style="spentStatColor()"></i></div> | |
| 154 | + <div class="sa-stat-value" :style="spentStatColor()">{{ formatCurrency(totalSpent, tripCurrencySymbol) }}</div> | |
| 155 | + <div class="sa-stat-label">{{ t('budget.index.totalSpent') }}</div> | |
| 156 | + </div> | |
| 157 | + </div> | |
| 158 | + <div class="col-4"> | |
| 159 | + <div class="sa-card-static sa-card-body sa-stat"> | |
| 160 | + <div class="sa-stat-icon"> | |
| 161 | + <i class="bi bi-wallet2" :style="remaining >= 0 ? 'color: var(--sa-success)' : 'color: var(--sa-danger)'"></i> | |
| 162 | + </div> | |
| 163 | + <div class="sa-stat-value" :class="remaining >= 0 ? 'sa-amount-positive' : 'sa-amount-negative'"> | |
| 164 | + {{ formatCurrency(remaining, tripCurrencySymbol) }} | |
| 165 | + </div> | |
| 166 | + <div class="sa-stat-label">{{ t('budget.index.remaining') }}</div> | |
| 167 | + </div> | |
| 168 | + </div> | |
| 169 | + </div> | |
| 170 | + | |
| 171 | + <!-- Overall Progress --> | |
| 172 | + <div class="sa-card-static mb-4"> | |
| 173 | + <div class="sa-card-body"> | |
| 174 | + <div class="d-flex justify-content-between align-items-center mb-2"> | |
| 175 | + <span style="font-weight: 600; font-size: 0.9rem">{{ t('budget.index.overallBudget') }}</span> | |
| 176 | + <span style="font-size: 0.85rem; font-weight: 600" :style="overallPercent > 85 ? 'color: var(--sa-danger)' : overallPercent > 60 ? 'color: var(--sa-warning)' : 'color: var(--sa-success)'"> | |
| 177 | + {{ overallPercent }}% | |
| 178 | + </span> | |
| 179 | + </div> | |
| 180 | + <div class="sa-progress"> | |
| 181 | + <div | |
| 182 | + class="sa-progress-bar" | |
| 183 | + :class="overallBarClass()" | |
| 184 | + :style="{ width: overallPercent + '%' }" | |
| 185 | + ></div> | |
| 186 | + </div> | |
| 187 | + </div> | |
| 188 | + </div> | |
| 189 | + | |
| 190 | + <!-- Category List --> | |
| 191 | + <div class="sa-card-static"> | |
| 192 | + <div v-for="cat in categories" :key="cat.id" class="sa-expense-item"> | |
| 193 | + <div class="sa-expense-icon sa-category-default"> | |
| 194 | + <i :class="['bi', categoryIcon(cat)]"></i> | |
| 195 | + </div> | |
| 196 | + <div class="sa-expense-details"> | |
| 197 | + <div class="sa-expense-desc">{{ cat.name }}</div> | |
| 198 | + <div class="sa-expense-meta"> | |
| 199 | + <span>{{ formatCurrency(cat.spentAmount, tripCurrencySymbol) }}</span> | |
| 200 | + <span v-if="cat.plannedAmount"> / {{ formatCurrency(cat.plannedAmount, tripCurrencySymbol) }}</span> | |
| 201 | + <span v-if="cat.plannedAmount" class="ms-1" style="font-size: 0.75rem; font-weight: 600" | |
| 202 | + :style="progressPercent(cat) > 85 ? 'color: var(--sa-danger)' : progressPercent(cat) > 60 ? 'color: var(--sa-warning)' : 'color: var(--sa-success)'" | |
| 203 | + > | |
| 204 | + ({{ progressPercent(cat) }}%) | |
| 205 | + </span> | |
| 206 | + </div> | |
| 207 | + <div v-if="cat.plannedAmount" class="sa-progress sa-progress-sm mt-1"> | |
| 208 | + <div | |
| 209 | + class="sa-progress-bar" | |
| 210 | + :class="progressBarClass(cat)" | |
| 211 | + :style="{ width: progressPercent(cat) + '%' }" | |
| 212 | + ></div> | |
| 213 | + </div> | |
| 214 | + <div v-if="isOverBudget(cat)" style="font-size: 0.75rem; color: var(--sa-danger); font-weight: 600; margin-top: 2px"> | |
| 215 | + <i class="bi bi-exclamation-triangle-fill me-1"></i>{{ t('budget.index.overBudget') }} | |
| 216 | + </div> | |
| 217 | + </div> | |
| 218 | + <div v-if="isOrganizer" class="sa-expense-actions"> | |
| 219 | + <button | |
| 220 | + class="sa-btn sa-btn-ghost sa-btn-icon sa-btn-sm" | |
| 221 | + @click="router.push({ name: 'BudgetEdit', params: { tripId, id: cat.id } })" | |
| 222 | + :title="t('common.edit')" | |
| 223 | + > | |
| 224 | + <i class="bi bi-pencil"></i> | |
| 225 | + </button> | |
| 226 | + <button | |
| 227 | + class="sa-btn sa-btn-ghost sa-btn-icon sa-btn-sm" | |
| 228 | + style="color: var(--sa-danger)" | |
| 229 | + @click="deleteCategory(cat.id)" | |
| 230 | + :title="t('common.delete')" | |
| 231 | + > | |
| 232 | + <i class="bi bi-trash3"></i> | |
| 233 | + </button> | |
| 234 | + </div> | |
| 235 | + </div> | |
| 236 | + </div> | |
| 237 | + </template> | |
| 238 | + | |
| 239 | + <!-- Mobile FAB --> | |
| 240 | + <button v-if="isOrganizer" class="sa-fab" @click="router.push({ name: 'BudgetCreate', params: { tripId } })"> | |
| 241 | + <i class="bi bi-plus-lg"></i> | |
| 242 | + </button> | |
| 243 | + </div> | |
| 244 | +</template> |
added src/views/expenses/CreateView.vue +188 −0
| @@ -0,0 +1,188 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import { ref, computed, onMounted } from 'vue' | |
| 3 | +import { useRouter, useRoute } from 'vue-router' | |
| 4 | +import { useI18n } from 'vue-i18n' | |
| 5 | +import ExpenseService from '@/services/ExpenseService' | |
| 6 | +import BudgetCategoryService from '@/services/BudgetCategoryService' | |
| 7 | +import CurrencyService from '@/services/CurrencyService' | |
| 8 | +import TripService from '@/services/TripService' | |
| 9 | +import type { IBudgetCategory } from '@/types/IBudgetCategory' | |
| 10 | +import type { ICurrency } from '@/types/ICurrency' | |
| 11 | +import type { ITripParticipant } from '@/types/ITrip' | |
| 12 | +import type { IExpenseSplitCreate } from '@/types/IExpense' | |
| 13 | +import { useToast } from '@/composables/useToast' | |
| 14 | +import { useAuthStore } from '@/stores/auth' | |
| 15 | +import { getUserIdFromJwt } from '@/utils/parseJwt' | |
| 16 | +import SplitMethodSelector from '@/components/SplitMethodSelector.vue' | |
| 17 | + | |
| 18 | +const router = useRouter() | |
| 19 | +const route = useRoute() | |
| 20 | +const toast = useToast() | |
| 21 | +const auth = useAuthStore() | |
| 22 | +const { t } = useI18n() | |
| 23 | + | |
| 24 | +const tripId = route.params.tripId as string | |
| 25 | + | |
| 26 | +const amount = ref<number>(0) | |
| 27 | +const description = ref('') | |
| 28 | +const expenseDate = ref(new Date().toISOString().substring(0, 10)) | |
| 29 | +const splitMethod = ref('EqualAll') | |
| 30 | +const budgetCategoryId = ref('') | |
| 31 | +const currencyId = ref('') | |
| 32 | +const paidByUserId = ref('') | |
| 33 | + | |
| 34 | +const budgetCategories = ref<IBudgetCategory[]>([]) | |
| 35 | +const currencies = ref<ICurrency[]>([]) | |
| 36 | +const participants = ref<ITripParticipant[]>([]) | |
| 37 | +const splits = ref<IExpenseSplitCreate[]>([]) | |
| 38 | +const splitsValid = ref(true) | |
| 39 | +const errors = ref<string[]>([]) | |
| 40 | +const isSaving = ref(false) | |
| 41 | + | |
| 42 | +const splitMethods = computed(() => [ | |
| 43 | + { value: 'EqualAll', label: t('expenses.splitMethod.EqualAll'), icon: 'bi-people-fill' }, | |
| 44 | + { value: 'EqualSubset', label: t('expenses.splitMethod.EqualSubset'), icon: 'bi-person-check-fill' }, | |
| 45 | + { value: 'ExactAmounts', label: t('expenses.splitMethod.ExactAmounts'), icon: 'bi-hash' }, | |
| 46 | + { value: 'Percentages', label: t('expenses.splitMethod.Percentages'), icon: 'bi-percent' }, | |
| 47 | +]) | |
| 48 | + | |
| 49 | +onMounted(async () => { | |
| 50 | + const [catResult, currResult, partResult] = await Promise.all([ | |
| 51 | + BudgetCategoryService.getByTrip(tripId), | |
| 52 | + CurrencyService.getAll(), | |
| 53 | + TripService.getParticipants(tripId), | |
| 54 | + ]) | |
| 55 | + | |
| 56 | + if (catResult.data) budgetCategories.value = catResult.data | |
| 57 | + if (currResult.data) currencies.value = currResult.data | |
| 58 | + if (partResult.data) { | |
| 59 | + participants.value = partResult.data | |
| 60 | + const currentUserId = auth.jwt ? getUserIdFromJwt(auth.jwt) : null | |
| 61 | + const match = partResult.data.find((p) => p.userId === currentUserId) | |
| 62 | + paidByUserId.value = match?.userId ?? partResult.data[0]?.userId ?? '' | |
| 63 | + } | |
| 64 | +}) | |
| 65 | + | |
| 66 | +async function handleSubmit() { | |
| 67 | + errors.value = [] | |
| 68 | + isSaving.value = true | |
| 69 | + | |
| 70 | + const result = await ExpenseService.create({ | |
| 71 | + tripId, | |
| 72 | + paidByUserId: paidByUserId.value || null, | |
| 73 | + amount: amount.value, | |
| 74 | + description: description.value || null, | |
| 75 | + expenseDate: new Date(expenseDate.value).toISOString(), | |
| 76 | + splitMethod: splitMethod.value, | |
| 77 | + budgetCategoryId: budgetCategoryId.value || null, | |
| 78 | + currencyId: currencyId.value || null, | |
| 79 | + splits: splits.value, | |
| 80 | + }) | |
| 81 | + | |
| 82 | + if (result.errors) { | |
| 83 | + errors.value = result.errors | |
| 84 | + } else { | |
| 85 | + toast.success(t('expenses.create.added')) | |
| 86 | + router.push({ name: 'ExpensesIndex', params: { tripId } }) | |
| 87 | + } | |
| 88 | + | |
| 89 | + isSaving.value = false | |
| 90 | +} | |
| 91 | +</script> | |
| 92 | + | |
| 93 | +<template> | |
| 94 | + <div class="row justify-content-center"> | |
| 95 | + <div class="col-md-8 col-lg-6"> | |
| 96 | + <h3 class="mb-4"><i class="bi bi-plus-circle me-2 sa-text-primary"></i>{{ t('expenses.create.title') }}</h3> | |
| 97 | + | |
| 98 | + <div v-if="errors.length" class="sa-card-static sa-card-accent sa-card-accent-danger mb-4"> | |
| 99 | + <div class="sa-card-body"> | |
| 100 | + <div v-for="err in errors" :key="err" style="color: var(--sa-danger); font-size: 0.9rem">{{ err }}</div> | |
| 101 | + </div> | |
| 102 | + </div> | |
| 103 | + | |
| 104 | + <form @submit.prevent="handleSubmit"> | |
| 105 | + <div class="mb-4 text-center"> | |
| 106 | + <label for="amount" class="form-label">{{ t('expenses.create.amount') }}</label> | |
| 107 | + <input | |
| 108 | + id="amount" | |
| 109 | + v-model.number="amount" | |
| 110 | + type="number" | |
| 111 | + step="0.01" | |
| 112 | + min="0.01" | |
| 113 | + class="form-control sa-amount-input" | |
| 114 | + placeholder="0.00" | |
| 115 | + required | |
| 116 | + /> | |
| 117 | + </div> | |
| 118 | + | |
| 119 | + <div class="mb-3"> | |
| 120 | + <label for="description" class="form-label">{{ t('expenses.create.description') }}</label> | |
| 121 | + <input id="description" v-model="description" type="text" class="form-control" :placeholder="t('expenses.create.descriptionPlaceholder')" /> | |
| 122 | + </div> | |
| 123 | + | |
| 124 | + <div class="mb-3"> | |
| 125 | + <label for="expenseDate" class="form-label">{{ t('expenses.create.date') }}</label> | |
| 126 | + <input id="expenseDate" v-model="expenseDate" type="date" class="form-control" required /> | |
| 127 | + </div> | |
| 128 | + | |
| 129 | + <div class="mb-3"> | |
| 130 | + <label for="paidBy" class="form-label">{{ t('expenses.create.paidBy') }}</label> | |
| 131 | + <select id="paidBy" v-model="paidByUserId" class="form-select" required> | |
| 132 | + <option value="" disabled>{{ t('expenses.create.selectPayer') }}</option> | |
| 133 | + <option v-for="p in participants" :key="p.userId" :value="p.userId"> | |
| 134 | + {{ p.nickname || p.userName || p.userEmail }} | |
| 135 | + </option> | |
| 136 | + </select> | |
| 137 | + </div> | |
| 138 | + | |
| 139 | + <div class="mb-4"> | |
| 140 | + <label class="form-label">{{ t('expenses.create.splitMethod') }}</label> | |
| 141 | + <div class="sa-split-methods"> | |
| 142 | + <label v-for="method in splitMethods" :key="method.value" class="sa-split-option"> | |
| 143 | + <input type="radio" :value="method.value" v-model="splitMethod" /> | |
| 144 | + <div class="sa-split-option-label"> | |
| 145 | + <div class="sa-split-option-icon"><i :class="['bi', method.icon]"></i></div> | |
| 146 | + <div class="sa-split-option-text">{{ method.label }}</div> | |
| 147 | + </div> | |
| 148 | + </label> | |
| 149 | + </div> | |
| 150 | + | |
| 151 | + <SplitMethodSelector | |
| 152 | + v-if="participants.length > 0" | |
| 153 | + :participants="participants" | |
| 154 | + :total-amount="amount" | |
| 155 | + :split-method="splitMethod" | |
| 156 | + @update:splits="splits = $event" | |
| 157 | + @update:valid="splitsValid = $event" | |
| 158 | + /> | |
| 159 | + </div> | |
| 160 | + | |
| 161 | + <div class="mb-3"> | |
| 162 | + <label for="budgetCategory" class="form-label">{{ t('expenses.create.budgetCategory') }}</label> | |
| 163 | + <select id="budgetCategory" v-model="budgetCategoryId" class="form-select"> | |
| 164 | + <option value="">{{ t('common.none') }}</option> | |
| 165 | + <option v-for="cat in budgetCategories" :key="cat.id" :value="cat.id">{{ cat.name }}</option> | |
| 166 | + </select> | |
| 167 | + </div> | |
| 168 | + | |
| 169 | + <div class="mb-4"> | |
| 170 | + <label for="currency" class="form-label">{{ t('expenses.create.currency') }}</label> | |
| 171 | + <select id="currency" v-model="currencyId" class="form-select"> | |
| 172 | + <option value="">{{ t('common.default') }}</option> | |
| 173 | + <option v-for="c in currencies" :key="c.id" :value="c.id">{{ c.code }} — {{ c.name }} ({{ c.symbol }})</option> | |
| 174 | + </select> | |
| 175 | + </div> | |
| 176 | + | |
| 177 | + <div class="d-flex gap-2 justify-content-end"> | |
| 178 | + <button type="button" class="sa-btn sa-btn-ghost" @click="router.push({ name: 'ExpensesIndex', params: { tripId } })"> | |
| 179 | + {{ t('common.cancel') }} | |
| 180 | + </button> | |
| 181 | + <button type="submit" class="sa-btn sa-btn-primary" :class="{ 'sa-btn-loading': isSaving }" :disabled="isSaving || !splitsValid"> | |
| 182 | + <i class="bi bi-check-lg"></i> {{ t('expenses.create.addExpense') }} | |
| 183 | + </button> | |
| 184 | + </div> | |
| 185 | + </form> | |
| 186 | + </div> | |
| 187 | + </div> | |
| 188 | +</template> |
added src/views/expenses/EditView.vue +197 −0
| @@ -0,0 +1,197 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import { ref, computed, onMounted } from 'vue' | |
| 3 | +import { useRouter, useRoute } from 'vue-router' | |
| 4 | +import { useI18n } from 'vue-i18n' | |
| 5 | +import ExpenseService from '@/services/ExpenseService' | |
| 6 | +import BudgetCategoryService from '@/services/BudgetCategoryService' | |
| 7 | +import CurrencyService from '@/services/CurrencyService' | |
| 8 | +import TripService from '@/services/TripService' | |
| 9 | +import type { IBudgetCategory } from '@/types/IBudgetCategory' | |
| 10 | +import type { ICurrency } from '@/types/ICurrency' | |
| 11 | +import type { ITripParticipant } from '@/types/ITrip' | |
| 12 | +import type { IExpenseSplit, IExpenseSplitCreate } from '@/types/IExpense' | |
| 13 | +import { useToast } from '@/composables/useToast' | |
| 14 | +import SplitMethodSelector from '@/components/SplitMethodSelector.vue' | |
| 15 | + | |
| 16 | +const router = useRouter() | |
| 17 | +const route = useRoute() | |
| 18 | +const toast = useToast() | |
| 19 | +const { t } = useI18n() | |
| 20 | + | |
| 21 | +const tripId = route.params.tripId as string | |
| 22 | +const expenseId = route.params.id as string | |
| 23 | + | |
| 24 | +const amount = ref<number>(0) | |
| 25 | +const description = ref('') | |
| 26 | +const expenseDate = ref('') | |
| 27 | +const splitMethod = ref('EqualAll') | |
| 28 | +const budgetCategoryId = ref('') | |
| 29 | +const currencyId = ref('') | |
| 30 | + | |
| 31 | +const budgetCategories = ref<IBudgetCategory[]>([]) | |
| 32 | +const currencies = ref<ICurrency[]>([]) | |
| 33 | +const participants = ref<ITripParticipant[]>([]) | |
| 34 | +const existingSplits = ref<IExpenseSplit[] | null>(null) | |
| 35 | +const splits = ref<IExpenseSplitCreate[]>([]) | |
| 36 | +const splitsValid = ref(true) | |
| 37 | +const errors = ref<string[]>([]) | |
| 38 | +const isSaving = ref(false) | |
| 39 | +const isLoading = ref(true) | |
| 40 | + | |
| 41 | +const splitMethods = computed(() => [ | |
| 42 | + { value: 'EqualAll', label: t('expenses.splitMethod.EqualAll'), icon: 'bi-people-fill' }, | |
| 43 | + { value: 'EqualSubset', label: t('expenses.splitMethod.EqualSubset'), icon: 'bi-person-check-fill' }, | |
| 44 | + { value: 'ExactAmounts', label: t('expenses.splitMethod.ExactAmounts'), icon: 'bi-hash' }, | |
| 45 | + { value: 'Percentages', label: t('expenses.splitMethod.Percentages'), icon: 'bi-percent' }, | |
| 46 | +]) | |
| 47 | + | |
| 48 | +onMounted(async () => { | |
| 49 | + const [expResult, catResult, currResult, partResult] = await Promise.all([ | |
| 50 | + ExpenseService.getById(expenseId), | |
| 51 | + BudgetCategoryService.getByTrip(tripId), | |
| 52 | + CurrencyService.getAll(), | |
| 53 | + TripService.getParticipants(tripId), | |
| 54 | + ]) | |
| 55 | + | |
| 56 | + if (catResult.data) budgetCategories.value = catResult.data | |
| 57 | + if (currResult.data) currencies.value = currResult.data | |
| 58 | + if (partResult.data) participants.value = partResult.data | |
| 59 | + | |
| 60 | + if (expResult.data) { | |
| 61 | + amount.value = expResult.data.amount | |
| 62 | + description.value = expResult.data.description ?? '' | |
| 63 | + expenseDate.value = expResult.data.expenseDate.substring(0, 10) | |
| 64 | + splitMethod.value = expResult.data.splitMethod | |
| 65 | + budgetCategoryId.value = expResult.data.budgetCategoryId ?? '' | |
| 66 | + currencyId.value = expResult.data.currencyId ?? '' | |
| 67 | + existingSplits.value = expResult.data.splits ?? null | |
| 68 | + } else { | |
| 69 | + errors.value = expResult.errors ?? [t('expenses.edit.notFound')] | |
| 70 | + } | |
| 71 | + | |
| 72 | + isLoading.value = false | |
| 73 | +}) | |
| 74 | + | |
| 75 | +async function handleSubmit() { | |
| 76 | + errors.value = [] | |
| 77 | + isSaving.value = true | |
| 78 | + | |
| 79 | + const result = await ExpenseService.update(expenseId, { | |
| 80 | + tripId, | |
| 81 | + amount: amount.value, | |
| 82 | + description: description.value || null, | |
| 83 | + expenseDate: new Date(expenseDate.value).toISOString(), | |
| 84 | + splitMethod: splitMethod.value, | |
| 85 | + budgetCategoryId: budgetCategoryId.value || null, | |
| 86 | + currencyId: currencyId.value || null, | |
| 87 | + splits: splits.value, | |
| 88 | + }) | |
| 89 | + | |
| 90 | + if (result.errors) { | |
| 91 | + errors.value = result.errors | |
| 92 | + } else { | |
| 93 | + toast.success(t('expenses.edit.updated')) | |
| 94 | + router.push({ name: 'ExpensesIndex', params: { tripId } }) | |
| 95 | + } | |
| 96 | + | |
| 97 | + isSaving.value = false | |
| 98 | +} | |
| 99 | + | |
| 100 | +async function handleDelete() { | |
| 101 | + if (!confirm(t('expenses.edit.confirmDelete'))) return | |
| 102 | + const result = await ExpenseService.delete(expenseId) | |
| 103 | + if (result.errors) { | |
| 104 | + errors.value = result.errors | |
| 105 | + } else { | |
| 106 | + toast.success(t('expenses.edit.deleted')) | |
| 107 | + router.push({ name: 'ExpensesIndex', params: { tripId } }) | |
| 108 | + } | |
| 109 | +} | |
| 110 | +</script> | |
| 111 | + | |
| 112 | +<template> | |
| 113 | + <div class="row justify-content-center"> | |
| 114 | + <div v-if="isLoading" class="text-center py-5"> | |
| 115 | + <div class="spinner-border" style="color: var(--sa-primary)" role="status"></div> | |
| 116 | + </div> | |
| 117 | + | |
| 118 | + <div v-else class="col-md-8 col-lg-6"> | |
| 119 | + <h3 class="mb-4"><i class="bi bi-pencil me-2 sa-text-primary"></i>{{ t('expenses.edit.title') }}</h3> | |
| 120 | + | |
| 121 | + <div v-if="errors.length" class="sa-card-static sa-card-accent sa-card-accent-danger mb-4"> | |
| 122 | + <div class="sa-card-body"> | |
| 123 | + <div v-for="err in errors" :key="err" style="color: var(--sa-danger); font-size: 0.9rem">{{ err }}</div> | |
| 124 | + </div> | |
| 125 | + </div> | |
| 126 | + | |
| 127 | + <form @submit.prevent="handleSubmit"> | |
| 128 | + <div class="mb-4 text-center"> | |
| 129 | + <label for="amount" class="form-label">{{ t('expenses.create.amount') }}</label> | |
| 130 | + <input id="amount" v-model.number="amount" type="number" step="0.01" min="0.01" class="form-control sa-amount-input" required /> | |
| 131 | + </div> | |
| 132 | + | |
| 133 | + <div class="mb-3"> | |
| 134 | + <label for="description" class="form-label">{{ t('expenses.create.description') }}</label> | |
| 135 | + <input id="description" v-model="description" type="text" class="form-control" /> | |
| 136 | + </div> | |
| 137 | + | |
| 138 | + <div class="mb-3"> | |
| 139 | + <label for="expenseDate" class="form-label">{{ t('expenses.create.date') }}</label> | |
| 140 | + <input id="expenseDate" v-model="expenseDate" type="date" class="form-control" required /> | |
| 141 | + </div> | |
| 142 | + | |
| 143 | + <div class="mb-4"> | |
| 144 | + <label class="form-label">{{ t('expenses.create.splitMethod') }}</label> | |
| 145 | + <div class="sa-split-methods"> | |
| 146 | + <label v-for="method in splitMethods" :key="method.value" class="sa-split-option"> | |
| 147 | + <input type="radio" :value="method.value" v-model="splitMethod" /> | |
| 148 | + <div class="sa-split-option-label"> | |
| 149 | + <div class="sa-split-option-icon"><i :class="['bi', method.icon]"></i></div> | |
| 150 | + <div class="sa-split-option-text">{{ method.label }}</div> | |
| 151 | + </div> | |
| 152 | + </label> | |
| 153 | + </div> | |
| 154 | + | |
| 155 | + <SplitMethodSelector | |
| 156 | + v-if="participants.length > 0" | |
| 157 | + :participants="participants" | |
| 158 | + :total-amount="amount" | |
| 159 | + :split-method="splitMethod" | |
| 160 | + :existing-splits="existingSplits" | |
| 161 | + @update:splits="splits = $event" | |
| 162 | + @update:valid="splitsValid = $event" | |
| 163 | + /> | |
| 164 | + </div> | |
| 165 | + | |
| 166 | + <div class="mb-3"> | |
| 167 | + <label for="budgetCategory" class="form-label">{{ t('expenses.create.budgetCategory') }}</label> | |
| 168 | + <select id="budgetCategory" v-model="budgetCategoryId" class="form-select"> | |
| 169 | + <option value="">{{ t('common.none') }}</option> | |
| 170 | + <option v-for="cat in budgetCategories" :key="cat.id" :value="cat.id">{{ cat.name }}</option> | |
| 171 | + </select> | |
| 172 | + </div> | |
| 173 | + | |
| 174 | + <div class="mb-4"> | |
| 175 | + <label for="currency" class="form-label">{{ t('expenses.create.currency') }}</label> | |
| 176 | + <select id="currency" v-model="currencyId" class="form-select"> | |
| 177 | + <option value="">{{ t('common.default') }}</option> | |
| 178 | + <option v-for="c in currencies" :key="c.id" :value="c.id">{{ c.code }} — {{ c.name }} ({{ c.symbol }})</option> | |
| 179 | + </select> | |
| 180 | + </div> | |
| 181 | + | |
| 182 | + <div class="d-flex gap-2"> | |
| 183 | + <button type="button" class="sa-btn sa-btn-danger sa-btn-sm" @click="handleDelete"> | |
| 184 | + <i class="bi bi-trash3"></i> {{ t('common.delete') }} | |
| 185 | + </button> | |
| 186 | + <div class="flex-grow-1"></div> | |
| 187 | + <button type="button" class="sa-btn sa-btn-ghost" @click="router.push({ name: 'ExpensesIndex', params: { tripId } })"> | |
| 188 | + {{ t('common.cancel') }} | |
| 189 | + </button> | |
| 190 | + <button type="submit" class="sa-btn sa-btn-primary" :class="{ 'sa-btn-loading': isSaving }" :disabled="isSaving || !splitsValid"> | |
| 191 | + <i class="bi bi-check-lg"></i> {{ t('common.saveChanges') }} | |
| 192 | + </button> | |
| 193 | + </div> | |
| 194 | + </form> | |
| 195 | + </div> | |
| 196 | + </div> | |
| 197 | +</template> |
added src/views/expenses/IndexView.vue +168 −0
| @@ -0,0 +1,168 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import { ref, computed, onMounted, inject, type ComputedRef } from 'vue' | |
| 3 | +import { useRouter, useRoute } from 'vue-router' | |
| 4 | +import { useI18n } from 'vue-i18n' | |
| 5 | +import ExpenseService from '@/services/ExpenseService' | |
| 6 | +import type { IExpense } from '@/types/IExpense' | |
| 7 | +import { useToast } from '@/composables/useToast' | |
| 8 | +import { formatCurrency } from '@/utils/formatCurrency' | |
| 9 | + | |
| 10 | +const { t, d } = useI18n() | |
| 11 | + | |
| 12 | +const _tripCurrencySymbol = inject<ComputedRef<string>>('tripCurrencySymbol') | |
| 13 | +const tripCurrencySymbol = computed(() => _tripCurrencySymbol?.value ?? '') | |
| 14 | + | |
| 15 | +const _isOrganizer = inject<ComputedRef<boolean>>('isOrganizer') | |
| 16 | +const isOrganizer = computed(() => _isOrganizer?.value ?? false) | |
| 17 | + | |
| 18 | +const _currentUserId = inject<ComputedRef<string | null>>('currentUserId') | |
| 19 | +const currentUserId = computed(() => _currentUserId?.value ?? null) | |
| 20 | + | |
| 21 | +const _tripStatus = inject<ComputedRef<string>>('tripStatus') | |
| 22 | +const tripStatus = computed(() => _tripStatus?.value ?? 'Active') | |
| 23 | + | |
| 24 | +const isActive = computed(() => tripStatus.value === 'Active') | |
| 25 | + | |
| 26 | +function canEditExpense(expense: IExpense): boolean { | |
| 27 | + return isActive.value && (isOrganizer.value || currentUserId.value === expense.paidByUserId) | |
| 28 | +} | |
| 29 | + | |
| 30 | +const router = useRouter() | |
| 31 | +const route = useRoute() | |
| 32 | +const toast = useToast() | |
| 33 | + | |
| 34 | +const expenses = ref<IExpense[]>([]) | |
| 35 | +const isLoading = ref(true) | |
| 36 | +const error = ref<string | null>(null) | |
| 37 | + | |
| 38 | +const tripId = route.params.tripId as string | |
| 39 | + | |
| 40 | +const totalAmount = computed(() => expenses.value.reduce((sum, e) => sum + (e.amountInTripCurrency ?? e.amount), 0)) | |
| 41 | + | |
| 42 | +onMounted(async () => { | |
| 43 | + const result = await ExpenseService.getByTrip(tripId) | |
| 44 | + if (result.data) { | |
| 45 | + expenses.value = result.data | |
| 46 | + } else if (result.errors) { | |
| 47 | + error.value = result.errors.join(', ') | |
| 48 | + } | |
| 49 | + isLoading.value = false | |
| 50 | +}) | |
| 51 | + | |
| 52 | +async function deleteExpense(id: string) { | |
| 53 | + if (!confirm(t('expenses.index.confirmDelete'))) return | |
| 54 | + const result = await ExpenseService.delete(id) | |
| 55 | + if (result.errors) { | |
| 56 | + toast.error(result.errors.join(', ')) | |
| 57 | + } else { | |
| 58 | + expenses.value = expenses.value.filter((e) => e.id !== id) | |
| 59 | + toast.success(t('expenses.index.deleted')) | |
| 60 | + } | |
| 61 | +} | |
| 62 | + | |
| 63 | +function formatDate(dateStr: string) { | |
| 64 | + return d(new Date(dateStr), 'short') | |
| 65 | +} | |
| 66 | + | |
| 67 | +function categoryIconClass(name: string | null) { | |
| 68 | + if (!name) return 'sa-category-default' | |
| 69 | + const lower = name.toLowerCase() | |
| 70 | + if (lower.includes('food') || lower.includes('meal') || lower.includes('dining')) return 'sa-category-food' | |
| 71 | + if (lower.includes('accommodation') || lower.includes('hotel')) return 'sa-category-accommodation' | |
| 72 | + if (lower.includes('transport') || lower.includes('taxi') || lower.includes('flight')) return 'sa-category-transport' | |
| 73 | + if (lower.includes('activit') || lower.includes('tour')) return 'sa-category-activities' | |
| 74 | + if (lower.includes('shopping')) return 'sa-category-shopping' | |
| 75 | + return 'sa-category-default' | |
| 76 | +} | |
| 77 | + | |
| 78 | +function categoryIcon(name: string | null) { | |
| 79 | + if (!name) return 'bi-tag-fill' | |
| 80 | + const lower = name.toLowerCase() | |
| 81 | + if (lower.includes('food') || lower.includes('meal')) return 'bi-cup-hot-fill' | |
| 82 | + if (lower.includes('accommodation') || lower.includes('hotel')) return 'bi-house-fill' | |
| 83 | + if (lower.includes('transport') || lower.includes('taxi')) return 'bi-car-front-fill' | |
| 84 | + if (lower.includes('activit') || lower.includes('tour')) return 'bi-lightning-fill' | |
| 85 | + if (lower.includes('shopping')) return 'bi-bag-fill' | |
| 86 | + return 'bi-tag-fill' | |
| 87 | +} | |
| 88 | +</script> | |
| 89 | + | |
| 90 | +<template> | |
| 91 | + <div> | |
| 92 | + <!-- Gradient Header --> | |
| 93 | + <div class="sa-gradient-header sa-gradient-header-coral d-flex justify-content-between align-items-center"> | |
| 94 | + <div> | |
| 95 | + <h3 class="mb-1"><i class="bi bi-receipt me-2"></i>{{ t('expenses.index.title') }}</h3> | |
| 96 | + <p v-if="!isLoading && expenses.length > 0" class="mb-0 text-muted" style="font-size: 0.9rem"> | |
| 97 | + {{ t('expenses.index.total') }} <strong class="sa-amount-lg" style="font-size: 1.3rem; color: #fff">{{ formatCurrency(totalAmount, tripCurrencySymbol) }}</strong> | |
| 98 | + </p> | |
| 99 | + </div> | |
| 100 | + <button | |
| 101 | + v-if="isActive" | |
| 102 | + class="sa-btn sa-btn-primary sa-btn-pill sa-hide-mobile" | |
| 103 | + style="background: #fff; color: var(--sa-primary)" | |
| 104 | + @click="router.push({ name: 'ExpensesCreate', params: { tripId } })" | |
| 105 | + > | |
| 106 | + <i class="bi bi-plus-lg"></i> {{ t('expenses.index.addExpense') }} | |
| 107 | + </button> | |
| 108 | + </div> | |
| 109 | + | |
| 110 | + <div v-if="error" class="alert alert-danger">{{ error }}</div> | |
| 111 | + | |
| 112 | + <div v-if="isLoading" class="text-center py-5"> | |
| 113 | + <div class="spinner-border" style="color: var(--sa-primary)" role="status"></div> | |
| 114 | + </div> | |
| 115 | + | |
| 116 | + <div v-else-if="expenses.length === 0" class="sa-empty"> | |
| 117 | + <div class="sa-empty-icon"><i class="bi bi-receipt"></i></div> | |
| 118 | + <div class="sa-empty-title">{{ t('expenses.index.emptyTitle') }}</div> | |
| 119 | + <div class="sa-empty-text">{{ t('expenses.index.emptyText') }}</div> | |
| 120 | + <button v-if="isActive" class="sa-btn sa-btn-primary sa-btn-pill" @click="router.push({ name: 'ExpensesCreate', params: { tripId } })"> | |
| 121 | + <i class="bi bi-plus-lg"></i> {{ t('expenses.index.addExpense') }} | |
| 122 | + </button> | |
| 123 | + </div> | |
| 124 | + | |
| 125 | + <div v-else class="sa-card-static"> | |
| 126 | + <div v-for="expense in expenses" :key="expense.id" class="sa-expense-item"> | |
| 127 | + <div class="sa-expense-icon" :class="categoryIconClass(expense.budgetCategoryName)"> | |
| 128 | + <i :class="['bi', categoryIcon(expense.budgetCategoryName)]"></i> | |
| 129 | + </div> | |
| 130 | + <div class="sa-expense-details"> | |
| 131 | + <div class="sa-expense-desc">{{ expense.description || t('expenses.index.untitled') }}</div> | |
| 132 | + <div class="sa-expense-meta"> | |
| 133 | + {{ expense.paidByUserName || t('expenses.index.unknown') }} · {{ formatDate(expense.expenseDate) }} | |
| 134 | + · | |
| 135 | + <span class="sa-badge sa-badge-neutral" style="font-size: 0.7rem; padding: 1px 6px"> | |
| 136 | + {{ (expense.budgetCategoryName && expense.budgetCategoryName !== 'null') ? expense.budgetCategoryName : t('expenses.index.uncategorized') }} | |
| 137 | + </span> | |
| 138 | + </div> | |
| 139 | + </div> | |
| 140 | + <div class="sa-expense-amount">{{ formatCurrency(expense.amount, expense.currencySymbol ?? tripCurrencySymbol) }}</div> | |
| 141 | + <div class="sa-expense-actions"> | |
| 142 | + <template v-if="canEditExpense(expense)"> | |
| 143 | + <button | |
| 144 | + class="sa-btn sa-btn-ghost sa-btn-icon sa-btn-sm" | |
| 145 | + @click="router.push({ name: 'ExpensesEdit', params: { tripId, id: expense.id } })" | |
| 146 | + :title="t('common.edit')" | |
| 147 | + > | |
| 148 | + <i class="bi bi-pencil"></i> | |
| 149 | + </button> | |
| 150 | + <button | |
| 151 | + class="sa-btn sa-btn-ghost sa-btn-icon sa-btn-sm" | |
| 152 | + style="color: var(--sa-danger)" | |
| 153 | + @click="deleteExpense(expense.id)" | |
| 154 | + :title="t('common.delete')" | |
| 155 | + > | |
| 156 | + <i class="bi bi-trash3"></i> | |
| 157 | + </button> | |
| 158 | + </template> | |
| 159 | + </div> | |
| 160 | + </div> | |
| 161 | + </div> | |
| 162 | + | |
| 163 | + <!-- Mobile FAB --> | |
| 164 | + <button v-if="isActive" class="sa-fab" @click="router.push({ name: 'ExpensesCreate', params: { tripId } })"> | |
| 165 | + <i class="bi bi-plus-lg"></i> | |
| 166 | + </button> | |
| 167 | + </div> | |
| 168 | +</template> |
added src/views/invitations/AcceptView.vue +163 −0
| @@ -0,0 +1,163 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import { ref, onMounted } from 'vue' | |
| 3 | +import { useRouter, useRoute } from 'vue-router' | |
| 4 | +import { useI18n } from 'vue-i18n' | |
| 5 | +import InvitationService from '@/services/InvitationService' | |
| 6 | +import type { IInvitation } from '@/types/IInvitation' | |
| 7 | + | |
| 8 | +const router = useRouter() | |
| 9 | +const route = useRoute() | |
| 10 | +const { t, d } = useI18n() | |
| 11 | + | |
| 12 | +const token = route.params.token as string | |
| 13 | + | |
| 14 | +const invitation = ref<IInvitation | null>(null) | |
| 15 | +const isLoading = ref(true) | |
| 16 | +const error = ref<string | null>(null) | |
| 17 | +const actionDone = ref(false) | |
| 18 | +const actionMessage = ref('') | |
| 19 | +const isProcessing = ref(false) | |
| 20 | + | |
| 21 | +onMounted(async () => { | |
| 22 | + const result = await InvitationService.getByToken(token) | |
| 23 | + if (result.data) { | |
| 24 | + invitation.value = result.data | |
| 25 | + } else if (result.errors) { | |
| 26 | + error.value = result.errors.join(', ') | |
| 27 | + } | |
| 28 | + isLoading.value = false | |
| 29 | +}) | |
| 30 | + | |
| 31 | +async function acceptInvitation() { | |
| 32 | + isProcessing.value = true | |
| 33 | + error.value = null | |
| 34 | + | |
| 35 | + const result = await InvitationService.accept(token) | |
| 36 | + if (result.errors) { | |
| 37 | + error.value = result.errors.join(', ') | |
| 38 | + } else { | |
| 39 | + actionDone.value = true | |
| 40 | + actionMessage.value = t('invitations.accept.accepted') | |
| 41 | + } | |
| 42 | + | |
| 43 | + isProcessing.value = false | |
| 44 | +} | |
| 45 | + | |
| 46 | +async function declineInvitation() { | |
| 47 | + isProcessing.value = true | |
| 48 | + error.value = null | |
| 49 | + | |
| 50 | + const result = await InvitationService.decline(token) | |
| 51 | + if (result.errors) { | |
| 52 | + error.value = result.errors.join(', ') | |
| 53 | + } else { | |
| 54 | + actionDone.value = true | |
| 55 | + actionMessage.value = t('invitations.accept.declined') | |
| 56 | + } | |
| 57 | + | |
| 58 | + isProcessing.value = false | |
| 59 | +} | |
| 60 | + | |
| 61 | +function formatDate(dateStr: string) { | |
| 62 | + return d(new Date(dateStr), 'long') | |
| 63 | +} | |
| 64 | +</script> | |
| 65 | + | |
| 66 | +<template> | |
| 67 | + <div class="sa-invite-page"> | |
| 68 | + <!-- Loading --> | |
| 69 | + <div v-if="isLoading" class="text-center py-5"> | |
| 70 | + <div class="spinner-border text-secondary" role="status"></div> | |
| 71 | + </div> | |
| 72 | + | |
| 73 | + <!-- Error / Expired (no invitation loaded) --> | |
| 74 | + <div v-else-if="error && !invitation" class="sa-card-static sa-invite-card"> | |
| 75 | + <div class="sa-card-body" style="padding: var(--sa-space-10) var(--sa-space-8)"> | |
| 76 | + <div class="sa-invite-icon" style="color: var(--sa-danger)"> | |
| 77 | + <i class="bi bi-exclamation-triangle"></i> | |
| 78 | + </div> | |
| 79 | + <h3 class="mb-3">{{ t('invitations.accept.unavailable') }}</h3> | |
| 80 | + <p style="color: var(--sa-gray-500); font-size: 0.938rem">{{ error }}</p> | |
| 81 | + <button | |
| 82 | + class="sa-btn sa-btn-primary sa-btn-pill" | |
| 83 | + @click="router.push({ name: 'Home' })" | |
| 84 | + > | |
| 85 | + {{ t('common.goHome') }} | |
| 86 | + </button> | |
| 87 | + </div> | |
| 88 | + </div> | |
| 89 | + | |
| 90 | + <!-- Action complete --> | |
| 91 | + <div v-else-if="actionDone" class="sa-card-static sa-invite-card"> | |
| 92 | + <div class="sa-card-body" style="padding: var(--sa-space-10) var(--sa-space-8)"> | |
| 93 | + <div class="sa-invite-icon" style="color: var(--sa-success)"> | |
| 94 | + <i class="bi bi-check-circle"></i> | |
| 95 | + </div> | |
| 96 | + <h3 class="mb-3">{{ t('invitations.accept.allDone') }}</h3> | |
| 97 | + <p style="color: var(--sa-gray-500); font-size: 0.938rem; margin-bottom: var(--sa-space-6)"> | |
| 98 | + {{ actionMessage }} | |
| 99 | + </p> | |
| 100 | + <button | |
| 101 | + class="sa-btn sa-btn-primary sa-btn-pill" | |
| 102 | + @click="router.push({ name: 'TripsIndex' })" | |
| 103 | + > | |
| 104 | + <i class="bi bi-airplane"></i> {{ t('common.goToTrips') }} | |
| 105 | + </button> | |
| 106 | + </div> | |
| 107 | + </div> | |
| 108 | + | |
| 109 | + <!-- Invitation card --> | |
| 110 | + <div v-else-if="invitation" class="sa-card-static sa-invite-card"> | |
| 111 | + <div class="sa-card-gradient-strip sa-card-gradient-strip-teal"></div> | |
| 112 | + <div class="sa-card-body" style="padding: var(--sa-space-10) var(--sa-space-8)"> | |
| 113 | + <!-- Icon --> | |
| 114 | + <div class="sa-invite-icon" style="color: var(--sa-secondary)"> | |
| 115 | + <i class="bi bi-envelope-heart"></i> | |
| 116 | + </div> | |
| 117 | + | |
| 118 | + <h3 class="mb-2">{{ t('invitations.accept.title') }}</h3> | |
| 119 | + | |
| 120 | + <!-- Trip name --> | |
| 121 | + <p class="fw-bold mb-2" style="font-size: 1.25rem; color: var(--sa-gray-900)"> | |
| 122 | + {{ invitation.tripName || t('invitations.accept.aTrip') }} | |
| 123 | + </p> | |
| 124 | + | |
| 125 | + <!-- Invited by --> | |
| 126 | + <p v-if="invitation.invitedByUserName" style="color: var(--sa-gray-500); font-size: 0.938rem; margin-bottom: var(--sa-space-2)"> | |
| 127 | + <i class="bi bi-person me-1"></i> {{ t('invitations.accept.invitedBy', { name: invitation.invitedByUserName }) }} | |
| 128 | + </p> | |
| 129 | + | |
| 130 | + <!-- Expiry --> | |
| 131 | + <p style="color: var(--sa-gray-400); font-size: 0.85rem; margin-bottom: var(--sa-space-6)"> | |
| 132 | + <i class="bi bi-clock me-1"></i> {{ t('invitations.accept.expires', { date: formatDate(invitation.expiresAt) }) }} | |
| 133 | + </p> | |
| 134 | + | |
| 135 | + <!-- Inline error --> | |
| 136 | + <div v-if="error" class="sa-card-static sa-card-accent sa-card-accent-danger mb-4"> | |
| 137 | + <div class="sa-card-body" style="padding: var(--sa-space-3) var(--sa-space-4); font-size: 0.875rem"> | |
| 138 | + <i class="bi bi-exclamation-circle me-1"></i>{{ error }} | |
| 139 | + </div> | |
| 140 | + </div> | |
| 141 | + | |
| 142 | + <!-- Action buttons --> | |
| 143 | + <div class="d-flex gap-3 justify-content-center"> | |
| 144 | + <button | |
| 145 | + class="sa-btn sa-btn-primary sa-btn-pill" | |
| 146 | + :class="{ 'sa-btn-loading': isProcessing }" | |
| 147 | + :disabled="isProcessing" | |
| 148 | + @click="acceptInvitation" | |
| 149 | + > | |
| 150 | + <i class="bi bi-check-lg"></i> {{ t('invitations.accept.accept') }} | |
| 151 | + </button> | |
| 152 | + <button | |
| 153 | + class="sa-btn sa-btn-ghost sa-btn-pill" | |
| 154 | + :disabled="isProcessing" | |
| 155 | + @click="declineInvitation" | |
| 156 | + > | |
| 157 | + {{ t('invitations.accept.decline') }} | |
| 158 | + </button> | |
| 159 | + </div> | |
| 160 | + </div> | |
| 161 | + </div> | |
| 162 | + </div> | |
| 163 | +</template> |
added src/views/members/MembersView.vue +212 −0
| @@ -0,0 +1,212 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import { ref, onMounted, computed, inject, type ComputedRef } from 'vue' | |
| 3 | +import { useRoute } from 'vue-router' | |
| 4 | +import { useI18n } from 'vue-i18n' | |
| 5 | +import TripService from '@/services/TripService' | |
| 6 | +import InvitationService from '@/services/InvitationService' | |
| 7 | +import type { ITripParticipant } from '@/types/ITrip' | |
| 8 | +import { useToast } from '@/composables/useToast' | |
| 9 | + | |
| 10 | +const { t, d } = useI18n() | |
| 11 | + | |
| 12 | +const _isOrganizer = inject<ComputedRef<boolean>>('isOrganizer') | |
| 13 | +const isOrganizer = computed(() => _isOrganizer?.value ?? false) | |
| 14 | + | |
| 15 | +const sortedParticipants = computed(() => { | |
| 16 | + return [...participants.value].sort((a, b) => { | |
| 17 | + if (a.role === 'Organizer' && b.role !== 'Organizer') return -1 | |
| 18 | + if (a.role !== 'Organizer' && b.role === 'Organizer') return 1 | |
| 19 | + return 0 | |
| 20 | + }) | |
| 21 | +}) | |
| 22 | + | |
| 23 | +const route = useRoute() | |
| 24 | +const toast = useToast() | |
| 25 | + | |
| 26 | +const tripId = route.params.tripId as string | |
| 27 | + | |
| 28 | +const participants = ref<ITripParticipant[]>([]) | |
| 29 | +const isLoading = ref(true) | |
| 30 | +const error = ref<string | null>(null) | |
| 31 | + | |
| 32 | +const invitationToken = ref<string | null>(null) | |
| 33 | +const isCreatingInvite = ref(false) | |
| 34 | + | |
| 35 | +onMounted(async () => { | |
| 36 | + const result = await TripService.getParticipants(tripId) | |
| 37 | + if (result.data) { | |
| 38 | + participants.value = result.data | |
| 39 | + } else if (result.errors) { | |
| 40 | + error.value = result.errors.join(', ') | |
| 41 | + } | |
| 42 | + isLoading.value = false | |
| 43 | +}) | |
| 44 | + | |
| 45 | +async function createInvitation() { | |
| 46 | + invitationToken.value = null | |
| 47 | + isCreatingInvite.value = true | |
| 48 | + | |
| 49 | + const result = await InvitationService.create({ tripId }) | |
| 50 | + if (result.data) { | |
| 51 | + invitationToken.value = result.data.token | |
| 52 | + toast.success(t('members.invitationCreated')) | |
| 53 | + } else if (result.errors) { | |
| 54 | + toast.error(result.errors.join(', ')) | |
| 55 | + } | |
| 56 | + | |
| 57 | + isCreatingInvite.value = false | |
| 58 | +} | |
| 59 | + | |
| 60 | +function formatDate(dateStr: string) { | |
| 61 | + return d(new Date(dateStr), 'short') | |
| 62 | +} | |
| 63 | + | |
| 64 | +function invitationLink(): string { | |
| 65 | + if (!invitationToken.value) return '' | |
| 66 | + return `${window.location.origin}/invitations/${invitationToken.value}` | |
| 67 | +} | |
| 68 | + | |
| 69 | +function getInitials(name: string | null, email: string | null): string { | |
| 70 | + if (name) { | |
| 71 | + return name | |
| 72 | + .split(' ') | |
| 73 | + .map((w) => w[0]) | |
| 74 | + .slice(0, 2) | |
| 75 | + .join('') | |
| 76 | + .toUpperCase() | |
| 77 | + } | |
| 78 | + if (email) return email[0]!.toUpperCase() | |
| 79 | + return '?' | |
| 80 | +} | |
| 81 | + | |
| 82 | +function avatarClass(index: number): string { | |
| 83 | + return `sa-avatar-${(index % 8) + 1}` | |
| 84 | +} | |
| 85 | + | |
| 86 | +function copyLink() { | |
| 87 | + const link = invitationLink() | |
| 88 | + if (!link) return | |
| 89 | + navigator.clipboard.writeText(link).then(() => { | |
| 90 | + toast.success(t('members.copied')) | |
| 91 | + }).catch(() => { | |
| 92 | + toast.error(t('members.copyFailed')) | |
| 93 | + }) | |
| 94 | +} | |
| 95 | +</script> | |
| 96 | + | |
| 97 | +<template> | |
| 98 | + <div> | |
| 99 | + <!-- Gradient Header --> | |
| 100 | + <div class="sa-gradient-header sa-gradient-header-purple d-flex justify-content-between align-items-center"> | |
| 101 | + <div> | |
| 102 | + <h3 class="mb-1"><i class="bi bi-people-fill me-2"></i>{{ t('members.title') }}</h3> | |
| 103 | + <p v-if="!isLoading && participants.length > 0" class="mb-0 text-muted" style="font-size: 0.9rem"> | |
| 104 | + {{ t('members.count', { n: participants.length }, participants.length) }} | |
| 105 | + </p> | |
| 106 | + </div> | |
| 107 | + <button | |
| 108 | + v-if="isOrganizer" | |
| 109 | + class="sa-btn sa-btn-pill sa-hide-mobile" | |
| 110 | + style="background: #fff; color: #6366f1" | |
| 111 | + :class="{ 'sa-btn-loading': isCreatingInvite }" | |
| 112 | + :disabled="isCreatingInvite" | |
| 113 | + @click="createInvitation" | |
| 114 | + > | |
| 115 | + <i class="bi bi-link-45deg"></i> {{ t('members.invite') }} | |
| 116 | + </button> | |
| 117 | + </div> | |
| 118 | + | |
| 119 | + <div v-if="error" class="alert alert-danger">{{ error }}</div> | |
| 120 | + | |
| 121 | + <!-- Invitation Link --> | |
| 122 | + <div v-if="invitationToken" class="sa-card-static sa-card-accent sa-card-accent-secondary mb-4"> | |
| 123 | + <div class="sa-card-body"> | |
| 124 | + <div class="d-flex align-items-center mb-2"> | |
| 125 | + <i class="bi bi-check-circle-fill me-2" style="color: var(--sa-success); font-size: 1.1rem"></i> | |
| 126 | + <strong style="color: var(--sa-gray-800)">{{ t('members.invitationCreated') }}</strong> | |
| 127 | + </div> | |
| 128 | + <p class="mb-2" style="font-size: 0.875rem; color: var(--sa-gray-500)">{{ t('members.shareLink') }}</p> | |
| 129 | + <div class="d-flex align-items-center gap-2"> | |
| 130 | + <code style=" | |
| 131 | + flex: 1; | |
| 132 | + padding: 8px 12px; | |
| 133 | + background: var(--sa-success-light); | |
| 134 | + color: #15803d; | |
| 135 | + border-radius: var(--sa-radius-sm); | |
| 136 | + font-size: 0.813rem; | |
| 137 | + word-break: break-all; | |
| 138 | + ">{{ invitationLink() }}</code> | |
| 139 | + <button | |
| 140 | + class="sa-btn sa-btn-ghost sa-btn-sm sa-btn-icon" | |
| 141 | + :title="t('members.copyLink')" | |
| 142 | + @click="copyLink" | |
| 143 | + > | |
| 144 | + <i class="bi bi-clipboard"></i> | |
| 145 | + </button> | |
| 146 | + </div> | |
| 147 | + </div> | |
| 148 | + </div> | |
| 149 | + | |
| 150 | + <!-- Loading --> | |
| 151 | + <div v-if="isLoading" class="text-center py-5"> | |
| 152 | + <div class="spinner-border" style="color: #6366f1" role="status"></div> | |
| 153 | + </div> | |
| 154 | + | |
| 155 | + <!-- Empty State --> | |
| 156 | + <div v-else-if="participants.length === 0" class="sa-empty"> | |
| 157 | + <div class="sa-empty-icon"><i class="bi bi-people"></i></div> | |
| 158 | + <div class="sa-empty-title">{{ t('members.emptyTitle') }}</div> | |
| 159 | + <div class="sa-empty-text">{{ t('members.emptyText') }}</div> | |
| 160 | + <button | |
| 161 | + class="sa-btn sa-btn-primary sa-btn-pill" | |
| 162 | + :disabled="isCreatingInvite" | |
| 163 | + @click="createInvitation" | |
| 164 | + > | |
| 165 | + <i class="bi bi-link-45deg"></i> {{ t('members.createInvitation') }} | |
| 166 | + </button> | |
| 167 | + </div> | |
| 168 | + | |
| 169 | + <!-- Member List --> | |
| 170 | + <div v-else class="sa-card-static"> | |
| 171 | + <div | |
| 172 | + v-for="(member, index) in sortedParticipants" | |
| 173 | + :key="member.id" | |
| 174 | + class="d-flex align-items-center gap-3 px-4 py-3" | |
| 175 | + :style="index < participants.length - 1 ? 'border-bottom: 1px solid var(--sa-gray-100)' : ''" | |
| 176 | + > | |
| 177 | + <div class="sa-avatar" :class="avatarClass(index)"> | |
| 178 | + {{ getInitials(member.userName, member.userEmail) }} | |
| 179 | + </div> | |
| 180 | + <div class="flex-grow-1"> | |
| 181 | + <div class="fw-bold" style="color: var(--sa-gray-800)"> | |
| 182 | + {{ member.userName || member.userEmail || t('common.unknown') }} | |
| 183 | + </div> | |
| 184 | + <small v-if="member.userEmail" style="color: var(--sa-gray-500)">{{ member.userEmail }}</small> | |
| 185 | + </div> | |
| 186 | + <div class="text-end"> | |
| 187 | + <span | |
| 188 | + class="sa-badge" | |
| 189 | + :class="member.role === 'Organizer' ? 'sa-badge-primary' : 'sa-badge-neutral'" | |
| 190 | + > | |
| 191 | + {{ t(`members.role.${member.role === 'Organizer' ? 'Organizer' : 'Participant'}`) }} | |
| 192 | + </span> | |
| 193 | + <div class="mt-1"> | |
| 194 | + <small style="color: var(--sa-gray-400); font-size: 0.75rem"> | |
| 195 | + {{ t('members.joined', { date: formatDate(member.joinedAt) }) }} | |
| 196 | + </small> | |
| 197 | + </div> | |
| 198 | + </div> | |
| 199 | + </div> | |
| 200 | + </div> | |
| 201 | + | |
| 202 | + <!-- Mobile FAB --> | |
| 203 | + <button | |
| 204 | + v-if="isOrganizer" | |
| 205 | + class="sa-fab" | |
| 206 | + style="background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); box-shadow: 0 4px 16px rgba(99, 102, 241, 0.4)" | |
| 207 | + @click="createInvitation" | |
| 208 | + > | |
| 209 | + <i class="bi bi-link-45deg"></i> | |
| 210 | + </button> | |
| 211 | + </div> | |
| 212 | +</template> |
added src/views/polls/CreateView.vue +171 −0
| @@ -0,0 +1,171 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import { ref } from 'vue' | |
| 3 | +import { useRouter, useRoute } from 'vue-router' | |
| 4 | +import { useI18n } from 'vue-i18n' | |
| 5 | +import PollService from '@/services/PollService' | |
| 6 | + | |
| 7 | +const router = useRouter() | |
| 8 | +const route = useRoute() | |
| 9 | +const { t } = useI18n() | |
| 10 | + | |
| 11 | +const tripId = route.params.tripId as string | |
| 12 | + | |
| 13 | +const question = ref('') | |
| 14 | +const allowMultipleVotes = ref(false) | |
| 15 | +const isAnonymous = ref(false) | |
| 16 | +const options = ref<string[]>(['', '']) | |
| 17 | +const errors = ref<string[]>([]) | |
| 18 | +const isSaving = ref(false) | |
| 19 | + | |
| 20 | +function addOption() { | |
| 21 | + options.value.push('') | |
| 22 | +} | |
| 23 | + | |
| 24 | +function removeOption(index: number) { | |
| 25 | + if (options.value.length > 2) { | |
| 26 | + options.value.splice(index, 1) | |
| 27 | + } | |
| 28 | +} | |
| 29 | + | |
| 30 | +async function handleSubmit() { | |
| 31 | + errors.value = [] | |
| 32 | + | |
| 33 | + const filledOptions = options.value.filter((o) => o.trim() !== '') | |
| 34 | + if (filledOptions.length < 2) { | |
| 35 | + errors.value = [t('polls.create.atLeastTwo')] | |
| 36 | + return | |
| 37 | + } | |
| 38 | + | |
| 39 | + isSaving.value = true | |
| 40 | + | |
| 41 | + const result = await PollService.create({ | |
| 42 | + tripId, | |
| 43 | + question: question.value, | |
| 44 | + allowMultipleVotes: allowMultipleVotes.value, | |
| 45 | + isAnonymous: isAnonymous.value, | |
| 46 | + options: filledOptions, | |
| 47 | + }) | |
| 48 | + | |
| 49 | + if (result.errors) { | |
| 50 | + errors.value = result.errors | |
| 51 | + } else { | |
| 52 | + router.push({ name: 'PollsIndex', params: { tripId } }) | |
| 53 | + } | |
| 54 | + | |
| 55 | + isSaving.value = false | |
| 56 | +} | |
| 57 | +</script> | |
| 58 | + | |
| 59 | +<template> | |
| 60 | + <div style="max-width: 600px; margin: 0 auto"> | |
| 61 | + <!-- Header --> | |
| 62 | + <div class="sa-gradient-header sa-gradient-header-blue"> | |
| 63 | + <h2 class="mb-0">{{ t('polls.create.title') }}</h2> | |
| 64 | + </div> | |
| 65 | + | |
| 66 | + <!-- Errors --> | |
| 67 | + <div v-if="errors.length" class="sa-card-static sa-card-accent sa-card-accent-danger mb-4"> | |
| 68 | + <div class="sa-card-body"> | |
| 69 | + <div v-for="error in errors" :key="error"> | |
| 70 | + <i class="bi bi-exclamation-triangle me-2"></i>{{ error }} | |
| 71 | + </div> | |
| 72 | + </div> | |
| 73 | + </div> | |
| 74 | + | |
| 75 | + <!-- Form --> | |
| 76 | + <div class="sa-card-static"> | |
| 77 | + <div class="sa-card-body"> | |
| 78 | + <form @submit.prevent="handleSubmit"> | |
| 79 | + <!-- Question --> | |
| 80 | + <div class="mb-4"> | |
| 81 | + <label for="question" class="form-label">{{ t('polls.create.question') }}</label> | |
| 82 | + <input | |
| 83 | + id="question" | |
| 84 | + v-model="question" | |
| 85 | + type="text" | |
| 86 | + class="form-control" | |
| 87 | + :placeholder="t('polls.create.questionPlaceholder')" | |
| 88 | + required | |
| 89 | + /> | |
| 90 | + </div> | |
| 91 | + | |
| 92 | + <!-- Toggle: Allow Multiple Votes --> | |
| 93 | + <div class="d-flex align-items-center justify-content-between mb-3 p-3" style="background: var(--sa-gray-50); border-radius: var(--sa-radius-sm)"> | |
| 94 | + <div> | |
| 95 | + <div class="fw-bold" style="font-size: 0.938rem">{{ t('polls.create.allowMultiple') }}</div> | |
| 96 | + <div style="font-size: 0.8rem; color: var(--sa-gray-500)">{{ t('polls.create.allowMultipleHelp') }}</div> | |
| 97 | + </div> | |
| 98 | + <input | |
| 99 | + id="allowMultiple" | |
| 100 | + v-model="allowMultipleVotes" | |
| 101 | + type="checkbox" | |
| 102 | + class="sa-toggle" | |
| 103 | + /> | |
| 104 | + </div> | |
| 105 | + | |
| 106 | + <!-- Toggle: Anonymous Voting --> | |
| 107 | + <div class="d-flex align-items-center justify-content-between mb-4 p-3" style="background: var(--sa-gray-50); border-radius: var(--sa-radius-sm)"> | |
| 108 | + <div> | |
| 109 | + <div class="fw-bold" style="font-size: 0.938rem">{{ t('polls.create.anonymous') }}</div> | |
| 110 | + <div style="font-size: 0.8rem; color: var(--sa-gray-500)">{{ t('polls.create.anonymousHelp') }}</div> | |
| 111 | + </div> | |
| 112 | + <input | |
| 113 | + id="isAnonymous" | |
| 114 | + v-model="isAnonymous" | |
| 115 | + type="checkbox" | |
| 116 | + class="sa-toggle" | |
| 117 | + /> | |
| 118 | + </div> | |
| 119 | + | |
| 120 | + <!-- Options --> | |
| 121 | + <div class="mb-4"> | |
| 122 | + <label class="form-label">{{ t('polls.create.options') }}</label> | |
| 123 | + <div v-for="(_, index) in options" :key="index" class="d-flex gap-2 mb-2"> | |
| 124 | + <input | |
| 125 | + v-model="options[index]" | |
| 126 | + type="text" | |
| 127 | + class="form-control" | |
| 128 | + :placeholder="t('polls.create.optionPlaceholder', { n: index + 1 })" | |
| 129 | + /> | |
| 130 | + <button | |
| 131 | + v-if="options.length > 2" | |
| 132 | + type="button" | |
| 133 | + class="sa-btn sa-btn-icon sa-btn-sm sa-btn-ghost" | |
| 134 | + style="flex-shrink: 0" | |
| 135 | + @click="removeOption(index)" | |
| 136 | + > | |
| 137 | + <i class="bi bi-x-lg"></i> | |
| 138 | + </button> | |
| 139 | + </div> | |
| 140 | + <button | |
| 141 | + type="button" | |
| 142 | + class="sa-btn sa-btn-ghost sa-btn-sm" | |
| 143 | + @click="addOption" | |
| 144 | + > | |
| 145 | + <i class="bi bi-plus-lg"></i> {{ t('polls.create.addOption') }} | |
| 146 | + </button> | |
| 147 | + </div> | |
| 148 | + | |
| 149 | + <!-- Actions --> | |
| 150 | + <div class="d-flex gap-2"> | |
| 151 | + <button | |
| 152 | + type="button" | |
| 153 | + class="sa-btn sa-btn-ghost" | |
| 154 | + @click="router.push({ name: 'PollsIndex', params: { tripId } })" | |
| 155 | + > | |
| 156 | + {{ t('common.cancel') }} | |
| 157 | + </button> | |
| 158 | + <button | |
| 159 | + type="submit" | |
| 160 | + class="sa-btn sa-btn-primary" | |
| 161 | + :class="{ 'sa-btn-loading': isSaving }" | |
| 162 | + :disabled="isSaving" | |
| 163 | + > | |
| 164 | + {{ isSaving ? t('polls.create.creating') : t('polls.create.createPoll') }} | |
| 165 | + </button> | |
| 166 | + </div> | |
| 167 | + </form> | |
| 168 | + </div> | |
| 169 | + </div> | |
| 170 | + </div> | |
| 171 | +</template> |
added src/views/polls/DetailView.vue +194 −0
| @@ -0,0 +1,194 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import { ref, onMounted, computed } from 'vue' | |
| 3 | +import { useRouter, useRoute } from 'vue-router' | |
| 4 | +import { useI18n } from 'vue-i18n' | |
| 5 | +import PollService from '@/services/PollService' | |
| 6 | +import type { IPoll } from '@/types/IPoll' | |
| 7 | +import { useToast } from '@/composables/useToast' | |
| 8 | + | |
| 9 | +const router = useRouter() | |
| 10 | +const route = useRoute() | |
| 11 | +const toast = useToast() | |
| 12 | +const { t } = useI18n() | |
| 13 | + | |
| 14 | +const tripId = route.params.tripId as string | |
| 15 | +const pollId = route.params.id as string | |
| 16 | + | |
| 17 | +const poll = ref<IPoll | null>(null) | |
| 18 | +const isLoading = ref(true) | |
| 19 | +const error = ref<string | null>(null) | |
| 20 | +const actionError = ref<string | null>(null) | |
| 21 | + | |
| 22 | +const isClosed = computed(() => !!poll.value?.closedAt) | |
| 23 | + | |
| 24 | +const totalVotes = computed(() => { | |
| 25 | + if (!poll.value?.options) return 0 | |
| 26 | + return poll.value.options.reduce((sum, o) => sum + o.voteCount, 0) | |
| 27 | +}) | |
| 28 | + | |
| 29 | +const maxVotes = computed(() => { | |
| 30 | + if (!poll.value?.options) return 0 | |
| 31 | + return Math.max(...poll.value.options.map((o) => o.voteCount)) | |
| 32 | +}) | |
| 33 | + | |
| 34 | +onMounted(async () => { | |
| 35 | + await loadPoll() | |
| 36 | +}) | |
| 37 | + | |
| 38 | +async function loadPoll() { | |
| 39 | + isLoading.value = true | |
| 40 | + error.value = null | |
| 41 | + | |
| 42 | + const result = await PollService.getById(pollId) | |
| 43 | + if (result.data) { | |
| 44 | + poll.value = result.data | |
| 45 | + } else if (result.errors) { | |
| 46 | + error.value = result.errors.join(', ') | |
| 47 | + } | |
| 48 | + isLoading.value = false | |
| 49 | +} | |
| 50 | + | |
| 51 | +async function vote(optionId: string) { | |
| 52 | + actionError.value = null | |
| 53 | + const result = await PollService.vote(pollId, optionId) | |
| 54 | + if (result.errors) { | |
| 55 | + toast.error(result.errors.join(', ')) | |
| 56 | + } else { | |
| 57 | + toast.success(t('polls.detail.voteRecorded')) | |
| 58 | + await loadPoll() | |
| 59 | + } | |
| 60 | +} | |
| 61 | + | |
| 62 | +async function closePoll() { | |
| 63 | + if (!confirm(t('polls.detail.confirmClose'))) return | |
| 64 | + actionError.value = null | |
| 65 | + const result = await PollService.close(pollId) | |
| 66 | + if (result.errors) { | |
| 67 | + toast.error(result.errors.join(', ')) | |
| 68 | + } else { | |
| 69 | + toast.success(t('polls.detail.closed')) | |
| 70 | + await loadPoll() | |
| 71 | + } | |
| 72 | +} | |
| 73 | + | |
| 74 | +async function deletePoll() { | |
| 75 | + if (!confirm(t('polls.detail.confirmDelete'))) return | |
| 76 | + const result = await PollService.delete(pollId) | |
| 77 | + if (result.errors) { | |
| 78 | + toast.error(result.errors.join(', ')) | |
| 79 | + } else { | |
| 80 | + toast.success(t('polls.detail.deleted')) | |
| 81 | + router.push({ name: 'PollsIndex', params: { tripId } }) | |
| 82 | + } | |
| 83 | +} | |
| 84 | + | |
| 85 | +function votePercent(voteCount: number): number { | |
| 86 | + if (totalVotes.value === 0) return 0 | |
| 87 | + return Math.round((voteCount / totalVotes.value) * 100) | |
| 88 | +} | |
| 89 | + | |
| 90 | +function isWinner(voteCount: number): boolean { | |
| 91 | + return isClosed.value && voteCount > 0 && voteCount === maxVotes.value | |
| 92 | +} | |
| 93 | +</script> | |
| 94 | + | |
| 95 | +<template> | |
| 96 | + <div> | |
| 97 | + <!-- Loading --> | |
| 98 | + <div v-if="isLoading" class="text-center py-5"> | |
| 99 | + <div class="spinner-border text-secondary" role="status"></div> | |
| 100 | + </div> | |
| 101 | + | |
| 102 | + <!-- Error --> | |
| 103 | + <div v-else-if="error" class="sa-card-static sa-card-accent sa-card-accent-danger"> | |
| 104 | + <div class="sa-card-body"> | |
| 105 | + <i class="bi bi-exclamation-triangle me-2"></i>{{ error }} | |
| 106 | + </div> | |
| 107 | + </div> | |
| 108 | + | |
| 109 | + <!-- Poll Detail --> | |
| 110 | + <div v-else-if="poll"> | |
| 111 | + <!-- Question heading with status --> | |
| 112 | + <div class="d-flex align-items-center gap-3 mb-4"> | |
| 113 | + <span | |
| 114 | + class="sa-status-dot" | |
| 115 | + :class="isClosed ? 'sa-status-dot-pending' : 'sa-status-dot-active sa-status-dot-pulse'" | |
| 116 | + ></span> | |
| 117 | + <h2 class="mb-0" style="flex: 1">{{ poll.question }}</h2> | |
| 118 | + <span | |
| 119 | + class="sa-badge" | |
| 120 | + :class="isClosed ? 'sa-badge-neutral' : 'sa-badge-success'" | |
| 121 | + > | |
| 122 | + {{ isClosed ? t('polls.status.Closed') : t('polls.status.Open') }} | |
| 123 | + </span> | |
| 124 | + </div> | |
| 125 | + | |
| 126 | + <!-- Poll Options --> | |
| 127 | + <div class="mb-4"> | |
| 128 | + <div | |
| 129 | + v-for="option in poll.options" | |
| 130 | + :key="option.id" | |
| 131 | + class="sa-poll-option" | |
| 132 | + :class="{ | |
| 133 | + 'sa-poll-option-voted': option.votedByCurrentUser, | |
| 134 | + 'sa-poll-option-winner': isWinner(option.voteCount), | |
| 135 | + }" | |
| 136 | + > | |
| 137 | + <div class="d-flex justify-content-between align-items-center mb-2"> | |
| 138 | + <div class="d-flex align-items-center gap-2 flex-wrap"> | |
| 139 | + <span class="fw-bold" style="font-size: 1.02rem">{{ option.text }}</span> | |
| 140 | + <span v-if="option.votedByCurrentUser" class="sa-badge sa-badge-secondary"> | |
| 141 | + <i class="bi bi-check-circle-fill"></i> {{ t('polls.detail.yourVote') }} | |
| 142 | + </span> | |
| 143 | + <span v-if="isWinner(option.voteCount)" class="sa-badge sa-badge-accent"> | |
| 144 | + <i class="bi bi-trophy-fill"></i> {{ t('polls.detail.winner') }} | |
| 145 | + </span> | |
| 146 | + </div> | |
| 147 | + <div class="d-flex align-items-center gap-3"> | |
| 148 | + <span class="text-muted" style="font-size: 0.85rem; white-space: nowrap"> | |
| 149 | + {{ t('polls.detail.voteCount', { n: option.voteCount }, option.voteCount) }} | |
| 150 | + <span class="fw-bold ms-1">{{ votePercent(option.voteCount) }}%</span> | |
| 151 | + </span> | |
| 152 | + <button | |
| 153 | + v-if="!isClosed" | |
| 154 | + class="sa-btn sa-btn-sm" | |
| 155 | + :class="option.votedByCurrentUser ? 'sa-btn-ghost' : 'sa-btn-secondary'" | |
| 156 | + @click="vote(option.id)" | |
| 157 | + > | |
| 158 | + {{ option.votedByCurrentUser ? t('polls.detail.unvote') : t('polls.detail.vote') }} | |
| 159 | + </button> | |
| 160 | + </div> | |
| 161 | + </div> | |
| 162 | + <div class="sa-progress sa-progress-sm"> | |
| 163 | + <div | |
| 164 | + class="sa-progress-bar" | |
| 165 | + :class="{ 'sa-progress-bar-primary': isWinner(option.voteCount) }" | |
| 166 | + :style="{ width: votePercent(option.voteCount) + '%' }" | |
| 167 | + ></div> | |
| 168 | + </div> | |
| 169 | + </div> | |
| 170 | + </div> | |
| 171 | + | |
| 172 | + <!-- Actions --> | |
| 173 | + <div class="d-flex gap-2 flex-wrap"> | |
| 174 | + <button | |
| 175 | + v-if="!isClosed" | |
| 176 | + class="sa-btn sa-btn-accent sa-btn-sm" | |
| 177 | + @click="closePoll" | |
| 178 | + > | |
| 179 | + <i class="bi bi-lock-fill"></i> {{ t('polls.detail.closePoll') }} | |
| 180 | + </button> | |
| 181 | + <button class="sa-btn sa-btn-danger sa-btn-sm" @click="deletePoll"> | |
| 182 | + <i class="bi bi-trash3"></i> {{ t('common.delete') }} | |
| 183 | + </button> | |
| 184 | + <div class="flex-grow-1"></div> | |
| 185 | + <button | |
| 186 | + class="sa-btn sa-btn-ghost sa-btn-sm" | |
| 187 | + @click="router.push({ name: 'PollsIndex', params: { tripId } })" | |
| 188 | + > | |
| 189 | + <i class="bi bi-arrow-left"></i> {{ t('polls.detail.backToPolls') }} | |
| 190 | + </button> | |
| 191 | + </div> | |
| 192 | + </div> | |
| 193 | + </div> | |
| 194 | +</template> |
added src/views/polls/IndexView.vue +117 −0
| @@ -0,0 +1,117 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import { ref, onMounted } from 'vue' | |
| 3 | +import { useRouter, useRoute } from 'vue-router' | |
| 4 | +import { useI18n } from 'vue-i18n' | |
| 5 | +import PollService from '@/services/PollService' | |
| 6 | +import type { IPoll } from '@/types/IPoll' | |
| 7 | + | |
| 8 | +const router = useRouter() | |
| 9 | +const route = useRoute() | |
| 10 | +const { t } = useI18n() | |
| 11 | + | |
| 12 | +const polls = ref<IPoll[]>([]) | |
| 13 | +const isLoading = ref(true) | |
| 14 | +const error = ref<string | null>(null) | |
| 15 | + | |
| 16 | +const tripId = route.params.tripId as string | |
| 17 | + | |
| 18 | +onMounted(async () => { | |
| 19 | + const result = await PollService.getByTrip(tripId) | |
| 20 | + if (result.data) { | |
| 21 | + polls.value = result.data | |
| 22 | + } else if (result.errors) { | |
| 23 | + error.value = result.errors.join(', ') | |
| 24 | + } | |
| 25 | + isLoading.value = false | |
| 26 | +}) | |
| 27 | +</script> | |
| 28 | + | |
| 29 | +<template> | |
| 30 | + <div> | |
| 31 | + <!-- Gradient Header --> | |
| 32 | + <div class="sa-gradient-header sa-gradient-header-blue"> | |
| 33 | + <div class="d-flex justify-content-between align-items-center"> | |
| 34 | + <div> | |
| 35 | + <h2 class="mb-0">{{ t('polls.index.title') }}</h2> | |
| 36 | + </div> | |
| 37 | + <button | |
| 38 | + class="sa-btn sa-btn-primary sa-btn-sm sa-btn-pill" | |
| 39 | + @click="router.push({ name: 'PollsCreate', params: { tripId } })" | |
| 40 | + > | |
| 41 | + <i class="bi bi-plus-lg"></i> {{ t('polls.index.newPoll') }} | |
| 42 | + </button> | |
| 43 | + </div> | |
| 44 | + </div> | |
| 45 | + | |
| 46 | + <!-- Error --> | |
| 47 | + <div v-if="error" class="sa-card-static sa-card-accent sa-card-accent-danger mb-4"> | |
| 48 | + <div class="sa-card-body"> | |
| 49 | + <i class="bi bi-exclamation-triangle me-2"></i>{{ error }} | |
| 50 | + </div> | |
| 51 | + </div> | |
| 52 | + | |
| 53 | + <!-- Loading --> | |
| 54 | + <div v-if="isLoading" class="text-center py-5"> | |
| 55 | + <div class="spinner-border text-secondary" role="status"></div> | |
| 56 | + </div> | |
| 57 | + | |
| 58 | + <!-- Empty State --> | |
| 59 | + <div v-else-if="polls.length === 0" class="sa-empty"> | |
| 60 | + <div class="sa-empty-icon"><i class="bi bi-clipboard2-check"></i></div> | |
| 61 | + <div class="sa-empty-title">{{ t('polls.index.emptyTitle') }}</div> | |
| 62 | + <div class="sa-empty-text">{{ t('polls.index.emptyText') }}</div> | |
| 63 | + <button | |
| 64 | + class="sa-btn sa-btn-primary sa-btn-pill" | |
| 65 | + @click="router.push({ name: 'PollsCreate', params: { tripId } })" | |
| 66 | + > | |
| 67 | + <i class="bi bi-plus-lg"></i> {{ t('polls.index.createFirst') }} | |
| 68 | + </button> | |
| 69 | + </div> | |
| 70 | + | |
| 71 | + <!-- Poll Cards --> | |
| 72 | + <div v-else class="d-flex flex-column gap-3"> | |
| 73 | + <div | |
| 74 | + v-for="poll in polls" | |
| 75 | + :key="poll.id" | |
| 76 | + class="sa-card" | |
| 77 | + style="cursor: pointer" | |
| 78 | + @click="router.push({ name: 'PollDetail', params: { tripId, id: poll.id } })" | |
| 79 | + > | |
| 80 | + <div class="sa-card-body"> | |
| 81 | + <div class="d-flex justify-content-between align-items-start"> | |
| 82 | + <div class="flex-grow-1"> | |
| 83 | + <div class="fw-bold mb-2" style="font-size: 1.05rem">{{ poll.question }}</div> | |
| 84 | + <div class="d-flex align-items-center gap-3 flex-wrap"> | |
| 85 | + <!-- Status --> | |
| 86 | + <span class="d-inline-flex align-items-center gap-1"> | |
| 87 | + <span | |
| 88 | + class="sa-status-dot" | |
| 89 | + :class="poll.closedAt ? 'sa-status-dot-pending' : 'sa-status-dot-active sa-status-dot-pulse'" | |
| 90 | + ></span> | |
| 91 | + <span style="font-size: 0.85rem; font-weight: 500" :style="{ color: poll.closedAt ? 'var(--sa-gray-500)' : 'var(--sa-success)' }"> | |
| 92 | + {{ poll.closedAt ? t('polls.status.Closed') : t('polls.status.Open') }} | |
| 93 | + </span> | |
| 94 | + </span> | |
| 95 | + <!-- Option count --> | |
| 96 | + <span class="sa-badge sa-badge-neutral"> | |
| 97 | + <i class="bi bi-list-ul"></i> {{ t('polls.index.optionCount', { n: poll.options?.length ?? 0 }) }} | |
| 98 | + </span> | |
| 99 | + <!-- Multiple votes badge --> | |
| 100 | + <span v-if="poll.allowMultipleVotes" class="sa-badge sa-badge-info"> | |
| 101 | + <i class="bi bi-check2-all"></i> {{ t('polls.index.multipleVotes') }} | |
| 102 | + </span> | |
| 103 | + </div> | |
| 104 | + </div> | |
| 105 | + <button | |
| 106 | + class="sa-btn sa-btn-sm" | |
| 107 | + :class="poll.closedAt ? 'sa-btn-ghost' : 'sa-btn-secondary'" | |
| 108 | + @click.stop="router.push({ name: 'PollDetail', params: { tripId, id: poll.id } })" | |
| 109 | + > | |
| 110 | + {{ poll.closedAt ? t('polls.index.viewResults') : t('polls.index.vote') }} | |
| 111 | + </button> | |
| 112 | + </div> | |
| 113 | + </div> | |
| 114 | + </div> | |
| 115 | + </div> | |
| 116 | + </div> | |
| 117 | +</template> |
added src/views/settlements/SettlementView.vue +510 −0
| @@ -0,0 +1,510 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import { ref, computed, onMounted, inject, type ComputedRef } from 'vue' | |
| 3 | +import { useRoute } from 'vue-router' | |
| 4 | +import { useI18n } from 'vue-i18n' | |
| 5 | +import SettlementService from '@/services/SettlementService' | |
| 6 | +import TripService from '@/services/TripService' | |
| 7 | +import type { ISettlementSummary } from '@/types/ISettlement' | |
| 8 | +import { useToast } from '@/composables/useToast' | |
| 9 | +import { formatCurrency } from '@/utils/formatCurrency' | |
| 10 | +import { useAuthStore } from '@/stores/auth' | |
| 11 | +import { getUserIdFromJwt } from '@/utils/parseJwt' | |
| 12 | + | |
| 13 | +const { t } = useI18n() | |
| 14 | + | |
| 15 | +const _tripCurrencySymbol = inject<ComputedRef<string>>('tripCurrencySymbol') | |
| 16 | +const tripCurrencySymbol = computed(() => _tripCurrencySymbol?.value ?? '') | |
| 17 | + | |
| 18 | +const _isOrganizer = inject<ComputedRef<boolean>>('isOrganizer') | |
| 19 | +const isOrganizer = computed(() => _isOrganizer?.value ?? false) | |
| 20 | + | |
| 21 | +const _tripStatus = inject<ComputedRef<string>>('tripStatus') | |
| 22 | +const tripStatus = computed(() => _tripStatus?.value ?? 'Active') | |
| 23 | + | |
| 24 | +const isActive = computed(() => tripStatus.value === 'Active') | |
| 25 | +const isFinalizing = computed(() => tripStatus.value === 'Finalizing') | |
| 26 | +const isSettled = computed(() => tripStatus.value === 'Settled') | |
| 27 | +const hasActivePlan = computed(() => isFinalizing.value || isSettled.value) | |
| 28 | + | |
| 29 | +const authStore = useAuthStore() | |
| 30 | +const currentUserId = computed(() => | |
| 31 | + authStore.jwt ? getUserIdFromJwt(authStore.jwt) : null, | |
| 32 | +) | |
| 33 | + | |
| 34 | +const route = useRoute() | |
| 35 | +const toast = useToast() | |
| 36 | + | |
| 37 | +const tripId = route.params.tripId as string | |
| 38 | + | |
| 39 | +const summary = ref<ISettlementSummary | null>(null) | |
| 40 | +const isLoading = ref(true) | |
| 41 | +const error = ref<string | null>(null) | |
| 42 | +const actionError = ref<string | null>(null) | |
| 43 | + | |
| 44 | +onMounted(async () => { | |
| 45 | + await loadSummary() | |
| 46 | +}) | |
| 47 | + | |
| 48 | +async function loadSummary() { | |
| 49 | + isLoading.value = true | |
| 50 | + error.value = null | |
| 51 | + | |
| 52 | + const result = await SettlementService.getSummary(tripId) | |
| 53 | + if (result.data) { | |
| 54 | + summary.value = result.data | |
| 55 | + } else if (result.errors) { | |
| 56 | + error.value = result.errors.join(', ') | |
| 57 | + } | |
| 58 | + isLoading.value = false | |
| 59 | +} | |
| 60 | + | |
| 61 | +async function finalizeTrip() { | |
| 62 | + actionError.value = null | |
| 63 | + const result = await TripService.finalize(tripId) | |
| 64 | + if (result.errors) { | |
| 65 | + actionError.value = result.errors.join(', ') | |
| 66 | + toast.error(t('settlements.finalizeFailed')) | |
| 67 | + } else { | |
| 68 | + toast.success(t('settlements.finalizeSuccess')) | |
| 69 | + window.location.reload() | |
| 70 | + } | |
| 71 | +} | |
| 72 | + | |
| 73 | +async function reopenTrip() { | |
| 74 | + actionError.value = null | |
| 75 | + const result = await TripService.reopen(tripId) | |
| 76 | + if (result.errors) { | |
| 77 | + actionError.value = result.errors.join(', ') | |
| 78 | + toast.error(t('settlements.reopenFailed')) | |
| 79 | + } else { | |
| 80 | + toast.success(t('settlements.reopenSuccess')) | |
| 81 | + window.location.reload() | |
| 82 | + } | |
| 83 | +} | |
| 84 | + | |
| 85 | +async function markPaid(paymentId: string) { | |
| 86 | + actionError.value = null | |
| 87 | + const result = await SettlementService.markPaid(paymentId) | |
| 88 | + if (result.errors) { | |
| 89 | + actionError.value = result.errors.join(', ') | |
| 90 | + toast.error(t('settlements.markPaidFailed')) | |
| 91 | + } else { | |
| 92 | + toast.success(t('settlements.markPaidSuccess')) | |
| 93 | + await loadSummary() | |
| 94 | + } | |
| 95 | +} | |
| 96 | + | |
| 97 | +async function confirmPayment(paymentId: string) { | |
| 98 | + actionError.value = null | |
| 99 | + const result = await SettlementService.confirmPayment(paymentId) | |
| 100 | + if (result.errors) { | |
| 101 | + actionError.value = result.errors.join(', ') | |
| 102 | + toast.error(t('settlements.confirmFailed')) | |
| 103 | + } else { | |
| 104 | + toast.success(t('settlements.confirmSuccess')) | |
| 105 | + await loadSummary() | |
| 106 | + } | |
| 107 | +} | |
| 108 | + | |
| 109 | +function getInitials(name: string | null) { | |
| 110 | + if (!name) return '?' | |
| 111 | + const parts = name.split(' ') | |
| 112 | + if (parts.length >= 2) return (parts[0]![0]! + parts[1]![0]!).toUpperCase() | |
| 113 | + return name.substring(0, 2).toUpperCase() | |
| 114 | +} | |
| 115 | + | |
| 116 | +function avatarColor(index: number) { | |
| 117 | + return `sa-avatar-${(index % 8) + 1}` | |
| 118 | +} | |
| 119 | + | |
| 120 | +const maxBalance = computed(() => { | |
| 121 | + if (!summary.value || summary.value.balances.length === 0) return 1 | |
| 122 | + return Math.max(...summary.value.balances.map((b) => Math.abs(b.balance)), 1) | |
| 123 | +}) | |
| 124 | + | |
| 125 | +function balanceBarWidth(balance: number) { | |
| 126 | + return Math.min((Math.abs(balance) / maxBalance.value) * 100, 100) | |
| 127 | +} | |
| 128 | + | |
| 129 | +const allSettledUp = computed(() => { | |
| 130 | + if (!summary.value) return false | |
| 131 | + const noDebts = summary.value.balances.every((b) => Math.abs(b.balance) < 0.01) | |
| 132 | + const noPayments = | |
| 133 | + !summary.value.latestPlan || | |
| 134 | + !summary.value.latestPlan.payments || | |
| 135 | + summary.value.latestPlan.payments.length === 0 || | |
| 136 | + summary.value.latestPlan.payments.every((p) => p.status === 'Confirmed') | |
| 137 | + return noDebts && noPayments | |
| 138 | +}) | |
| 139 | + | |
| 140 | +const pendingPayments = computed(() => { | |
| 141 | + if (!hasActivePlan.value) return [] | |
| 142 | + if (!summary.value?.latestPlan?.payments) return [] | |
| 143 | + return summary.value.latestPlan.payments | |
| 144 | +}) | |
| 145 | + | |
| 146 | +const totalAmount = computed(() => { | |
| 147 | + return summary.value?.latestPlan?.totalAmount ?? 0 | |
| 148 | +}) | |
| 149 | + | |
| 150 | +const previewPayments = computed(() => { | |
| 151 | + if (!isActive.value || !summary.value) return [] | |
| 152 | + const balances = summary.value.balances | |
| 153 | + | |
| 154 | + const creditors = balances | |
| 155 | + .filter((b) => b.balance > 0.01) | |
| 156 | + .map((b) => ({ name: b.userName ?? t('common.unknown'), amount: b.balance })) | |
| 157 | + .sort((a, b) => b.amount - a.amount) | |
| 158 | + | |
| 159 | + const debtors = balances | |
| 160 | + .filter((b) => b.balance < -0.01) | |
| 161 | + .map((b) => ({ name: b.userName ?? t('common.unknown'), amount: -b.balance })) | |
| 162 | + .sort((a, b) => b.amount - a.amount) | |
| 163 | + | |
| 164 | + if (!creditors.length || !debtors.length) return [] | |
| 165 | + | |
| 166 | + const result: { from: string; to: string; amount: number }[] = [] | |
| 167 | + let ci = 0 | |
| 168 | + let di = 0 | |
| 169 | + | |
| 170 | + while (ci < creditors.length && di < debtors.length) { | |
| 171 | + const amount = Math.min(creditors[ci]!.amount, debtors[di]!.amount) | |
| 172 | + if (amount > 0.01) { | |
| 173 | + result.push({ | |
| 174 | + from: debtors[di]!.name, | |
| 175 | + to: creditors[ci]!.name, | |
| 176 | + amount: Math.round(amount * 100) / 100, | |
| 177 | + }) | |
| 178 | + } | |
| 179 | + creditors[ci]!.amount -= amount | |
| 180 | + debtors[di]!.amount -= amount | |
| 181 | + if (creditors[ci]!.amount < 0.01) ci++ | |
| 182 | + if (debtors[di]!.amount < 0.01) di++ | |
| 183 | + } | |
| 184 | + | |
| 185 | + return result | |
| 186 | +}) | |
| 187 | +</script> | |
| 188 | + | |
| 189 | +<template> | |
| 190 | + <div> | |
| 191 | + <!-- Gradient Header --> | |
| 192 | + <div class="sa-gradient-header sa-gradient-header-green"> | |
| 193 | + <div style="display: flex; align-items: center; justify-content: space-between"> | |
| 194 | + <div> | |
| 195 | + <h2 class="mb-1">{{ t('settlements.title') }}</h2> | |
| 196 | + <span v-if="isFinalizing" class="sa-badge sa-badge-warning" style="font-size: 0.75rem"> | |
| 197 | + <i class="bi bi-hourglass-split me-1"></i>{{ t('settlements.finalizing') }} | |
| 198 | + </span> | |
| 199 | + <span v-else-if="isSettled" class="sa-badge sa-badge-info" style="font-size: 0.75rem"> | |
| 200 | + <i class="bi bi-lock-fill me-1"></i>{{ t('settlements.tripSettled') }} | |
| 201 | + </span> | |
| 202 | + </div> | |
| 203 | + <div class="d-flex gap-2"> | |
| 204 | + <button | |
| 205 | + v-if="isOrganizer && isActive" | |
| 206 | + class="sa-btn sa-btn-sm" | |
| 207 | + style="background: #fff; color: var(--sa-success)" | |
| 208 | + @click="finalizeTrip" | |
| 209 | + > | |
| 210 | + <i class="bi bi-check-circle"></i> {{ t('settlements.finalize') }} | |
| 211 | + </button> | |
| 212 | + <button | |
| 213 | + v-if="isOrganizer && hasActivePlan && summary?.latestPlan?.status !== 'Completed'" | |
| 214 | + class="sa-btn sa-btn-sm" | |
| 215 | + style="background: #fff; color: var(--sa-warning)" | |
| 216 | + @click="reopenTrip" | |
| 217 | + > | |
| 218 | + <i class="bi bi-unlock"></i> {{ t('settlements.reopen') }} | |
| 219 | + </button> | |
| 220 | + </div> | |
| 221 | + </div> | |
| 222 | + </div> | |
| 223 | + | |
| 224 | + <!-- Loading --> | |
| 225 | + <div v-if="isLoading" class="sa-empty"> | |
| 226 | + <div class="sa-empty-icon"> | |
| 227 | + <div class="spinner-border" role="status"></div> | |
| 228 | + </div> | |
| 229 | + <div class="sa-empty-text">{{ t('settlements.loading') }}</div> | |
| 230 | + </div> | |
| 231 | + | |
| 232 | + <!-- Error --> | |
| 233 | + <div v-else-if="error" class="sa-empty"> | |
| 234 | + <div class="sa-empty-icon" style="color: var(--sa-danger)"> | |
| 235 | + <i class="bi bi-exclamation-triangle" style="font-size: 2rem"></i> | |
| 236 | + </div> | |
| 237 | + <div class="sa-empty-title">{{ t('settlements.somethingWrong') }}</div> | |
| 238 | + <div class="sa-empty-text">{{ error }}</div> | |
| 239 | + </div> | |
| 240 | + | |
| 241 | + <!-- All Settled Up Empty State --> | |
| 242 | + <div v-else-if="summary && allSettledUp" class="sa-empty"> | |
| 243 | + <div class="sa-empty-icon" style="color: var(--sa-success)"> | |
| 244 | + <i class="bi bi-check-circle" style="font-size: 3rem"></i> | |
| 245 | + </div> | |
| 246 | + <div class="sa-empty-title">{{ t('settlements.allSettled') }}</div> | |
| 247 | + <div class="sa-empty-text">{{ t('settlements.allSettledText') }}</div> | |
| 248 | + </div> | |
| 249 | + | |
| 250 | + <!-- Main Content --> | |
| 251 | + <div v-else-if="summary"> | |
| 252 | + <!-- Action Error --> | |
| 253 | + <div | |
| 254 | + v-if="actionError" | |
| 255 | + style=" | |
| 256 | + background: var(--sa-danger-light); | |
| 257 | + color: #dc2626; | |
| 258 | + padding: var(--sa-space-3) var(--sa-space-4); | |
| 259 | + border-radius: var(--sa-radius-md); | |
| 260 | + margin-bottom: var(--sa-space-4); | |
| 261 | + font-size: 0.9rem; | |
| 262 | + " | |
| 263 | + > | |
| 264 | + <i class="bi bi-exclamation-circle"></i> {{ actionError }} | |
| 265 | + </div> | |
| 266 | + | |
| 267 | + <!-- BALANCES Section --> | |
| 268 | + <div style="margin-bottom: var(--sa-space-6)"> | |
| 269 | + <h4 | |
| 270 | + style=" | |
| 271 | + font-size: 0.75rem; | |
| 272 | + font-weight: 700; | |
| 273 | + text-transform: uppercase; | |
| 274 | + letter-spacing: 0.08em; | |
| 275 | + color: var(--sa-gray-400); | |
| 276 | + margin-bottom: var(--sa-space-4); | |
| 277 | + " | |
| 278 | + > | |
| 279 | + {{ t('settlements.balances') }} | |
| 280 | + </h4> | |
| 281 | + | |
| 282 | + <div v-if="summary.balances.length === 0" class="sa-empty"> | |
| 283 | + <div class="sa-empty-text">{{ t('settlements.noBalances') }}</div> | |
| 284 | + </div> | |
| 285 | + | |
| 286 | + <div | |
| 287 | + v-for="(bal, i) in summary.balances" | |
| 288 | + :key="bal.userId" | |
| 289 | + style=" | |
| 290 | + display: flex; | |
| 291 | + align-items: center; | |
| 292 | + gap: var(--sa-space-3); | |
| 293 | + padding: var(--sa-space-3) 0; | |
| 294 | + border-bottom: 1px solid var(--sa-gray-100); | |
| 295 | + " | |
| 296 | + > | |
| 297 | + <!-- Avatar --> | |
| 298 | + <span class="sa-avatar sa-avatar-sm" :class="avatarColor(i)"> | |
| 299 | + {{ getInitials(bal.userName) }} | |
| 300 | + </span> | |
| 301 | + | |
| 302 | + <!-- Name --> | |
| 303 | + <span | |
| 304 | + style=" | |
| 305 | + min-width: 90px; | |
| 306 | + font-size: 0.9rem; | |
| 307 | + font-weight: 500; | |
| 308 | + color: var(--sa-gray-700); | |
| 309 | + flex-shrink: 0; | |
| 310 | + " | |
| 311 | + > | |
| 312 | + {{ bal.userName || t('common.unknown') }} | |
| 313 | + </span> | |
| 314 | + | |
| 315 | + <!-- Balance Bar --> | |
| 316 | + <div class="sa-balance-bar-container" style="flex: 1"> | |
| 317 | + <div class="sa-balance-bar-track"> | |
| 318 | + <div class="sa-balance-bar-center"></div> | |
| 319 | + <div | |
| 320 | + v-if="bal.balance > 0" | |
| 321 | + class="sa-balance-bar-fill" | |
| 322 | + style="left: 50%; right: auto; background: var(--sa-success)" | |
| 323 | + :style="{ width: balanceBarWidth(bal.balance) / 2 + '%' }" | |
| 324 | + ></div> | |
| 325 | + <div | |
| 326 | + v-if="bal.balance < 0" | |
| 327 | + class="sa-balance-bar-fill" | |
| 328 | + style="right: 50%; left: auto; background: var(--sa-danger)" | |
| 329 | + :style="{ width: balanceBarWidth(bal.balance) / 2 + '%' }" | |
| 330 | + ></div> | |
| 331 | + </div> | |
| 332 | + </div> | |
| 333 | + | |
| 334 | + <!-- Amount --> | |
| 335 | + <span | |
| 336 | + style="min-width: 70px; text-align: right; font-weight: 700; font-size: 0.9rem" | |
| 337 | + :style="{ color: bal.balance >= 0 ? 'var(--sa-success)' : 'var(--sa-danger)' }" | |
| 338 | + > | |
| 339 | + {{ bal.balance >= 0 ? '+' : '' }}{{ formatCurrency(bal.balance, tripCurrencySymbol) }} | |
| 340 | + </span> | |
| 341 | + </div> | |
| 342 | + </div> | |
| 343 | + | |
| 344 | + <!-- PREVIEW Section --> | |
| 345 | + <div v-if="isActive && previewPayments.length > 0" style="margin-bottom: var(--sa-space-6)"> | |
| 346 | + <div class="d-flex justify-content-between align-items-center" style="margin-bottom: var(--sa-space-4)"> | |
| 347 | + <h4 style="font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--sa-gray-400); margin: 0"> | |
| 348 | + {{ t('settlements.suggestedPayments') }} | |
| 349 | + </h4> | |
| 350 | + <span class="sa-badge sa-badge-neutral">{{ t('settlements.preview') }}</span> | |
| 351 | + </div> | |
| 352 | + | |
| 353 | + <div style="display: flex; flex-direction: column; gap: var(--sa-space-3)"> | |
| 354 | + <div v-for="(p, pi) in previewPayments" :key="pi" class="sa-settlement-card"> | |
| 355 | + <div style="display: flex; align-items: center; gap: var(--sa-space-2); flex: 1; min-width: 0"> | |
| 356 | + <span class="sa-avatar sa-avatar-sm" :class="avatarColor(pi)">{{ getInitials(p.from) }}</span> | |
| 357 | + <span style="font-weight: 600; font-size: 0.9rem; color: var(--sa-gray-700)">{{ p.from }}</span> | |
| 358 | + </div> | |
| 359 | + <div style="display: flex; align-items: center; gap: var(--sa-space-2); flex-shrink: 0"> | |
| 360 | + <i class="bi bi-arrow-right sa-settlement-arrow"></i> | |
| 361 | + <span class="sa-settlement-amount">{{ formatCurrency(p.amount, tripCurrencySymbol) }}</span> | |
| 362 | + <i class="bi bi-arrow-right sa-settlement-arrow"></i> | |
| 363 | + </div> | |
| 364 | + <div style="display: flex; align-items: center; gap: var(--sa-space-2); flex: 1; min-width: 0; justify-content: flex-end"> | |
| 365 | + <span style="font-weight: 600; font-size: 0.9rem; color: var(--sa-gray-700)">{{ p.to }}</span> | |
| 366 | + <span class="sa-avatar sa-avatar-sm" :class="avatarColor(pi + 3)">{{ getInitials(p.to) }}</span> | |
| 367 | + </div> | |
| 368 | + </div> | |
| 369 | + </div> | |
| 370 | + <p style="font-size: 0.8rem; color: var(--sa-gray-400); margin-top: var(--sa-space-3)"> | |
| 371 | + <i class="bi bi-info-circle me-1"></i>{{ t('settlements.previewHint') }} | |
| 372 | + </p> | |
| 373 | + </div> | |
| 374 | + | |
| 375 | + <!-- SETTLEMENT PLAN Section --> | |
| 376 | + <div v-if="hasActivePlan && summary.latestPlan && pendingPayments.length > 0"> | |
| 377 | + <h4 | |
| 378 | + style=" | |
| 379 | + font-size: 0.75rem; | |
| 380 | + font-weight: 700; | |
| 381 | + text-transform: uppercase; | |
| 382 | + letter-spacing: 0.08em; | |
| 383 | + color: var(--sa-gray-400); | |
| 384 | + margin-bottom: var(--sa-space-4); | |
| 385 | + " | |
| 386 | + > | |
| 387 | + {{ t('settlements.settlementPlan') }} | |
| 388 | + </h4> | |
| 389 | + | |
| 390 | + <div style="display: flex; flex-direction: column; gap: var(--sa-space-3)"> | |
| 391 | + <div | |
| 392 | + v-for="(payment, pi) in pendingPayments" | |
| 393 | + :key="payment.id" | |
| 394 | + class="sa-settlement-card" | |
| 395 | + > | |
| 396 | + <!-- From user --> | |
| 397 | + <div style="display: flex; align-items: center; gap: var(--sa-space-2); flex: 1; min-width: 0"> | |
| 398 | + <span class="sa-avatar sa-avatar-sm" :class="avatarColor(pi)"> | |
| 399 | + {{ getInitials(payment.fromUserName) }} | |
| 400 | + </span> | |
| 401 | + <span | |
| 402 | + style=" | |
| 403 | + font-weight: 600; | |
| 404 | + font-size: 0.9rem; | |
| 405 | + color: var(--sa-gray-700); | |
| 406 | + white-space: nowrap; | |
| 407 | + overflow: hidden; | |
| 408 | + text-overflow: ellipsis; | |
| 409 | + " | |
| 410 | + > | |
| 411 | + {{ payment.fromUserName || t('common.unknown') }} | |
| 412 | + </span> | |
| 413 | + </div> | |
| 414 | + | |
| 415 | + <!-- Arrow + Amount --> | |
| 416 | + <div | |
| 417 | + style=" | |
| 418 | + display: flex; | |
| 419 | + align-items: center; | |
| 420 | + gap: var(--sa-space-2); | |
| 421 | + flex-shrink: 0; | |
| 422 | + " | |
| 423 | + > | |
| 424 | + <i class="bi bi-arrow-right sa-settlement-arrow"></i> | |
| 425 | + <span class="sa-settlement-amount">{{ formatCurrency(payment.amount, tripCurrencySymbol) }}</span> | |
| 426 | + <i class="bi bi-arrow-right sa-settlement-arrow"></i> | |
| 427 | + </div> | |
| 428 | + | |
| 429 | + <!-- To user --> | |
| 430 | + <div style="display: flex; align-items: center; gap: var(--sa-space-2); flex: 1; min-width: 0; justify-content: flex-end"> | |
| 431 | + <span | |
| 432 | + style=" | |
| 433 | + font-weight: 600; | |
| 434 | + font-size: 0.9rem; | |
| 435 | + color: var(--sa-gray-700); | |
| 436 | + white-space: nowrap; | |
| 437 | + overflow: hidden; | |
| 438 | + text-overflow: ellipsis; | |
| 439 | + " | |
| 440 | + > | |
| 441 | + {{ payment.toUserName || t('common.unknown') }} | |
| 442 | + </span> | |
| 443 | + <span class="sa-avatar sa-avatar-sm" :class="avatarColor(pi + 3)"> | |
| 444 | + {{ getInitials(payment.toUserName) }} | |
| 445 | + </span> | |
| 446 | + </div> | |
| 447 | + | |
| 448 | + <!-- Status Badge --> | |
| 449 | + <div style="flex-shrink: 0; min-width: 90px; text-align: center"> | |
| 450 | + <span v-if="payment.status === 'Confirmed'" class="sa-badge sa-badge-success"> | |
| 451 | + <i class="bi bi-check-circle"></i> {{ t('settlements.confirmed') }} | |
| 452 | + </span> | |
| 453 | + <span v-else-if="payment.status === 'MarkedPaid'" class="sa-badge sa-badge-info"> | |
| 454 | + {{ t('settlements.markedPaid') }} | |
| 455 | + </span> | |
| 456 | + </div> | |
| 457 | + | |
| 458 | + <!-- Action Button --> | |
| 459 | + <div style="flex-shrink: 0"> | |
| 460 | + <button | |
| 461 | + v-if="!payment.markedPaidAt && payment.fromUserId === currentUserId" | |
| 462 | + class="sa-btn sa-btn-success sa-btn-sm" | |
| 463 | + @click="markPaid(payment.id)" | |
| 464 | + > | |
| 465 | + {{ t('settlements.markPaid') }} | |
| 466 | + </button> | |
| 467 | + <button | |
| 468 | + v-else-if="payment.markedPaidAt && !payment.confirmedAt && payment.toUserId === currentUserId" | |
| 469 | + class="sa-btn sa-btn-secondary sa-btn-sm" | |
| 470 | + @click="confirmPayment(payment.id)" | |
| 471 | + > | |
| 472 | + {{ t('settlements.confirm') }} | |
| 473 | + </button> | |
| 474 | + <span | |
| 475 | + v-else-if="payment.markedPaidAt && !payment.confirmedAt" | |
| 476 | + class="sa-badge sa-badge-warning" | |
| 477 | + > | |
| 478 | + {{ t('settlements.awaitingConfirmation') }} | |
| 479 | + </span> | |
| 480 | + <span | |
| 481 | + v-else-if="!payment.markedPaidAt" | |
| 482 | + class="sa-badge sa-badge-neutral" | |
| 483 | + > | |
| 484 | + {{ t('settlements.pending') }} | |
| 485 | + </span> | |
| 486 | + </div> | |
| 487 | + </div> | |
| 488 | + </div> | |
| 489 | + | |
| 490 | + <!-- Total --> | |
| 491 | + <div | |
| 492 | + style=" | |
| 493 | + display: flex; | |
| 494 | + justify-content: flex-end; | |
| 495 | + align-items: center; | |
| 496 | + gap: var(--sa-space-2); | |
| 497 | + margin-top: var(--sa-space-4); | |
| 498 | + padding-top: var(--sa-space-3); | |
| 499 | + border-top: 1px solid var(--sa-gray-200); | |
| 500 | + " | |
| 501 | + > | |
| 502 | + <span style="font-size: 0.85rem; color: var(--sa-gray-500); font-weight: 500">{{ t('settlements.total') }}</span> | |
| 503 | + <span style="font-size: 1.1rem; font-weight: 700; color: var(--sa-gray-900)"> | |
| 504 | + {{ formatCurrency(totalAmount, tripCurrencySymbol) }} | |
| 505 | + </span> | |
| 506 | + </div> | |
| 507 | + </div> | |
| 508 | + </div> | |
| 509 | + </div> | |
| 510 | +</template> |
added src/views/trips/CreateView.vue +214 −0
| @@ -0,0 +1,214 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import { ref, computed, onMounted } from 'vue' | |
| 3 | +import { useRouter } from 'vue-router' | |
| 4 | +import { useI18n } from 'vue-i18n' | |
| 5 | +import TripService from '@/services/TripService' | |
| 6 | +import CurrencyService from '@/services/CurrencyService' | |
| 7 | +import type { ICurrency } from '@/types/ICurrency' | |
| 8 | + | |
| 9 | +const router = useRouter() | |
| 10 | +const { t } = useI18n() | |
| 11 | + | |
| 12 | +const name = ref('') | |
| 13 | +const description = ref('') | |
| 14 | +const destination = ref('') | |
| 15 | +const startDate = ref('') | |
| 16 | +const endDate = ref('') | |
| 17 | +const defaultCurrencyId = ref('') | |
| 18 | +const currencies = ref<ICurrency[]>([]) | |
| 19 | +const errors = ref<string[]>([]) | |
| 20 | +const isSaving = ref(false) | |
| 21 | + | |
| 22 | +// Inline validation: end date must be on or after start date. | |
| 23 | +// The ISO yyyy-MM-dd format from <input type="date"> compares lexicographically. | |
| 24 | +const dateError = computed<string | null>(() => { | |
| 25 | + if (!startDate.value || !endDate.value) return null | |
| 26 | + return endDate.value < startDate.value | |
| 27 | + ? t('trips.create.dateError') | |
| 28 | + : null | |
| 29 | +}) | |
| 30 | + | |
| 31 | +const tripNights = computed<number | null>(() => { | |
| 32 | + if (!startDate.value || !endDate.value || dateError.value) return null | |
| 33 | + const ms = new Date(endDate.value).getTime() - new Date(startDate.value).getTime() | |
| 34 | + return Math.max(0, Math.round(ms / 86_400_000)) | |
| 35 | +}) | |
| 36 | + | |
| 37 | +onMounted(async () => { | |
| 38 | + const result = await CurrencyService.getAll() | |
| 39 | + if (result.data) { | |
| 40 | + currencies.value = result.data | |
| 41 | + const eur = result.data.find((c) => c.code === 'EUR') | |
| 42 | + if (eur) defaultCurrencyId.value = eur.id | |
| 43 | + } | |
| 44 | +}) | |
| 45 | + | |
| 46 | +async function handleSubmit() { | |
| 47 | + if (dateError.value) { | |
| 48 | + errors.value = [dateError.value] | |
| 49 | + return | |
| 50 | + } | |
| 51 | + errors.value = [] | |
| 52 | + isSaving.value = true | |
| 53 | + | |
| 54 | + const result = await TripService.create({ | |
| 55 | + name: name.value, | |
| 56 | + description: description.value || null, | |
| 57 | + destination: destination.value || null, | |
| 58 | + startDate: startDate.value ? new Date(startDate.value).toISOString() : null, | |
| 59 | + endDate: endDate.value ? new Date(endDate.value).toISOString() : null, | |
| 60 | + defaultCurrencyId: defaultCurrencyId.value, | |
| 61 | + }) | |
| 62 | + | |
| 63 | + if (result.errors) { | |
| 64 | + errors.value = result.errors | |
| 65 | + } else { | |
| 66 | + router.push({ name: 'TripsIndex' }) | |
| 67 | + } | |
| 68 | + | |
| 69 | + isSaving.value = false | |
| 70 | +} | |
| 71 | +</script> | |
| 72 | + | |
| 73 | +<template> | |
| 74 | + <div class="row justify-content-center"> | |
| 75 | + <div class="col-lg-8 col-xl-6"> | |
| 76 | + <!-- Gradient hero --> | |
| 77 | + <div class="sa-gradient-header"> | |
| 78 | + <h2 class="mb-1"><i class="bi bi-suitcase-lg me-2"></i>{{ t('trips.create.title') }}</h2> | |
| 79 | + <p class="mb-0 text-muted" style="font-size: 0.9rem"> | |
| 80 | + {{ t('trips.create.subtitle') }} | |
| 81 | + </p> | |
| 82 | + </div> | |
| 83 | + | |
| 84 | + <div v-if="errors.length" class="alert alert-danger"> | |
| 85 | + <div v-for="error in errors" :key="error">{{ error }}</div> | |
| 86 | + </div> | |
| 87 | + | |
| 88 | + <!-- Form card --> | |
| 89 | + <div class="sa-card"> | |
| 90 | + <div class="sa-card-body"> | |
| 91 | + <form @submit.prevent="handleSubmit"> | |
| 92 | + <div class="mb-3"> | |
| 93 | + <label for="name" class="form-label"> | |
| 94 | + <i class="bi bi-pencil-square me-1"></i> {{ t('trips.create.name') }} * | |
| 95 | + </label> | |
| 96 | + <input | |
| 97 | + id="name" | |
| 98 | + v-model="name" | |
| 99 | + type="text" | |
| 100 | + class="form-control" | |
| 101 | + :placeholder="t('trips.create.namePlaceholder')" | |
| 102 | + required | |
| 103 | + /> | |
| 104 | + </div> | |
| 105 | + | |
| 106 | + <div class="mb-3"> | |
| 107 | + <label for="destination" class="form-label"> | |
| 108 | + <i class="bi bi-geo-alt me-1"></i> {{ t('trips.create.destination') }} | |
| 109 | + </label> | |
| 110 | + <input | |
| 111 | + id="destination" | |
| 112 | + v-model="destination" | |
| 113 | + type="text" | |
| 114 | + class="form-control" | |
| 115 | + :placeholder="t('trips.create.destinationPlaceholder')" | |
| 116 | + /> | |
| 117 | + </div> | |
| 118 | + | |
| 119 | + <div class="mb-3"> | |
| 120 | + <label for="description" class="form-label"> | |
| 121 | + <i class="bi bi-card-text me-1"></i> {{ t('trips.create.description') }} | |
| 122 | + </label> | |
| 123 | + <textarea | |
| 124 | + id="description" | |
| 125 | + v-model="description" | |
| 126 | + class="form-control" | |
| 127 | + rows="3" | |
| 128 | + :placeholder="t('trips.create.descriptionPlaceholder')" | |
| 129 | + ></textarea> | |
| 130 | + </div> | |
| 131 | + | |
| 132 | + <div class="row g-3 mb-1"> | |
| 133 | + <div class="col-6"> | |
| 134 | + <label for="startDate" class="form-label"> | |
| 135 | + <i class="bi bi-calendar-event me-1"></i> {{ t('trips.create.start') }} | |
| 136 | + </label> | |
| 137 | + <input | |
| 138 | + id="startDate" | |
| 139 | + v-model="startDate" | |
| 140 | + type="date" | |
| 141 | + class="form-control" | |
| 142 | + /> | |
| 143 | + </div> | |
| 144 | + <div class="col-6"> | |
| 145 | + <label for="endDate" class="form-label"> | |
| 146 | + <i class="bi bi-calendar-check me-1"></i> {{ t('trips.create.end') }} | |
| 147 | + </label> | |
| 148 | + <input | |
| 149 | + id="endDate" | |
| 150 | + v-model="endDate" | |
| 151 | + type="date" | |
| 152 | + class="form-control" | |
| 153 | + :class="{ 'is-invalid': dateError }" | |
| 154 | + :min="startDate || undefined" | |
| 155 | + /> | |
| 156 | + <div v-if="dateError" class="invalid-feedback">{{ dateError }}</div> | |
| 157 | + </div> | |
| 158 | + </div> | |
| 159 | + <div class="mb-3" style="min-height: 1.25rem"> | |
| 160 | + <small v-if="tripNights !== null" class="text-muted"> | |
| 161 | + <i class="bi bi-moon-stars me-1"></i> | |
| 162 | + {{ t('trips.create.nightCount', { n: tripNights }, tripNights) }} | |
| 163 | + </small> | |
| 164 | + </div> | |
| 165 | + | |
| 166 | + <div class="mb-4"> | |
| 167 | + <label for="currency" class="form-label"> | |
| 168 | + <i class="bi bi-currency-exchange me-1"></i> {{ t('trips.create.defaultCurrency') }} | |
| 169 | + </label> | |
| 170 | + <select | |
| 171 | + id="currency" | |
| 172 | + v-model="defaultCurrencyId" | |
| 173 | + class="form-select" | |
| 174 | + required | |
| 175 | + > | |
| 176 | + <option value="" disabled>{{ t('trips.create.selectCurrency') }}</option> | |
| 177 | + <option v-for="c in currencies" :key="c.id" :value="c.id"> | |
| 178 | + {{ c.code }} — {{ c.name }} ({{ c.symbol }}) | |
| 179 | + </option> | |
| 180 | + </select> | |
| 181 | + </div> | |
| 182 | + | |
| 183 | + <div class="d-flex justify-content-end gap-2"> | |
| 184 | + <button | |
| 185 | + type="button" | |
| 186 | + class="sa-btn sa-btn-ghost sa-btn-pill" | |
| 187 | + @click="router.push({ name: 'TripsIndex' })" | |
| 188 | + > | |
| 189 | + {{ t('common.cancel') }} | |
| 190 | + </button> | |
| 191 | + <button | |
| 192 | + type="submit" | |
| 193 | + class="sa-btn sa-btn-primary sa-btn-pill" | |
| 194 | + :disabled="isSaving || !!dateError" | |
| 195 | + > | |
| 196 | + <span v-if="isSaving"> | |
| 197 | + <span | |
| 198 | + class="spinner-border spinner-border-sm me-1" | |
| 199 | + role="status" | |
| 200 | + aria-hidden="true" | |
| 201 | + ></span> | |
| 202 | + {{ t('trips.create.creating') }} | |
| 203 | + </span> | |
| 204 | + <span v-else> | |
| 205 | + <i class="bi bi-check-lg me-1"></i> {{ t('trips.create.createTrip') }} | |
| 206 | + </span> | |
| 207 | + </button> | |
| 208 | + </div> | |
| 209 | + </form> | |
| 210 | + </div> | |
| 211 | + </div> | |
| 212 | + </div> | |
| 213 | + </div> | |
| 214 | +</template> |
added src/views/trips/DetailView.vue +404 −0
| @@ -0,0 +1,404 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import { ref, computed, onMounted, provide } from 'vue' | |
| 3 | +import { useRouter, useRoute } from 'vue-router' | |
| 4 | +import { useI18n } from 'vue-i18n' | |
| 5 | +import TripService from '@/services/TripService' | |
| 6 | +import ExpenseService from '@/services/ExpenseService' | |
| 7 | +import BudgetCategoryService from '@/services/BudgetCategoryService' | |
| 8 | +import SettlementService from '@/services/SettlementService' | |
| 9 | +import type { ITrip, ITripParticipant } from '@/types/ITrip' | |
| 10 | +import type { IExpense } from '@/types/IExpense' | |
| 11 | +import type { IBudgetCategory } from '@/types/IBudgetCategory' | |
| 12 | +import type { IBalance } from '@/types/ISettlement' | |
| 13 | +import { useAuthStore } from '@/stores/auth' | |
| 14 | +import { getUserIdFromJwt } from '@/utils/parseJwt' | |
| 15 | +import { formatCurrency } from '@/utils/formatCurrency' | |
| 16 | + | |
| 17 | +const router = useRouter() | |
| 18 | +const route = useRoute() | |
| 19 | +const authStore = useAuthStore() | |
| 20 | +const { t, d } = useI18n() | |
| 21 | + | |
| 22 | +const trip = ref<ITrip | null>(null) | |
| 23 | +const participants = ref<ITripParticipant[]>([]) | |
| 24 | +const expenses = ref<IExpense[]>([]) | |
| 25 | +const categories = ref<IBudgetCategory[]>([]) | |
| 26 | +const balances = ref<IBalance[]>([]) | |
| 27 | +const isLoading = ref(true) | |
| 28 | +const error = ref<string | null>(null) | |
| 29 | + | |
| 30 | +const tripId = route.params.tripId as string | |
| 31 | + | |
| 32 | +const currentUserId = computed(() => { | |
| 33 | + if (!authStore.jwt) return null | |
| 34 | + return getUserIdFromJwt(authStore.jwt) | |
| 35 | +}) | |
| 36 | + | |
| 37 | +const isOrganizer = computed(() => { | |
| 38 | + if (!currentUserId.value) return false | |
| 39 | + if (participants.value.length > 0) { | |
| 40 | + const me = participants.value.find(p => p.userId === currentUserId.value) | |
| 41 | + return me?.role === 'Organizer' | |
| 42 | + } | |
| 43 | + return trip.value?.createdById === currentUserId.value | |
| 44 | +}) | |
| 45 | + | |
| 46 | +provide('isOrganizer', isOrganizer) | |
| 47 | +provide('currentUserId', currentUserId) | |
| 48 | +provide('tripStatus', computed(() => trip.value?.status ?? 'Active')) | |
| 49 | +provide('tripCurrencySymbol', computed(() => trip.value?.defaultCurrencySymbol ?? '')) | |
| 50 | + | |
| 51 | +const isChildRoute = computed(() => { | |
| 52 | + return route.name !== 'TripDetail' | |
| 53 | +}) | |
| 54 | + | |
| 55 | +const totalExpenses = computed(() => expenses.value.reduce((sum, e) => sum + (e.amountInTripCurrency ?? e.amount), 0)) | |
| 56 | + | |
| 57 | +const totalPlanned = computed(() => categories.value.reduce((sum, c) => sum + (c.plannedAmount || 0), 0)) | |
| 58 | + | |
| 59 | +const totalSpent = computed(() => categories.value.reduce((sum, c) => sum + c.spentAmount, 0)) | |
| 60 | + | |
| 61 | +const budgetPercent = computed(() => { | |
| 62 | + if (totalPlanned.value === 0) return 0 | |
| 63 | + return Math.round((totalSpent.value / totalPlanned.value) * 100) | |
| 64 | +}) | |
| 65 | + | |
| 66 | +const recentExpenses = computed(() => { | |
| 67 | + return [...expenses.value] | |
| 68 | + .sort((a, b) => new Date(b.expenseDate).getTime() - new Date(a.expenseDate).getTime()) | |
| 69 | + .slice(0, 5) | |
| 70 | +}) | |
| 71 | + | |
| 72 | +const visibleParticipants = computed(() => participants.value.slice(0, 6)) | |
| 73 | +const overflowCount = computed(() => Math.max(0, participants.value.length - 6)) | |
| 74 | + | |
| 75 | +const myBalance = computed(() => { | |
| 76 | + if (!currentUserId.value) return 0 | |
| 77 | + return balances.value.find(b => b.userId === currentUserId.value)?.balance ?? 0 | |
| 78 | +}) | |
| 79 | + | |
| 80 | +onMounted(async () => { | |
| 81 | + const [tripResult, participantsResult, expensesResult, categoriesResult, balancesResult] = | |
| 82 | + await Promise.all([ | |
| 83 | + TripService.getById(tripId), | |
| 84 | + TripService.getParticipants(tripId), | |
| 85 | + ExpenseService.getByTrip(tripId), | |
| 86 | + BudgetCategoryService.getByTrip(tripId), | |
| 87 | + SettlementService.getBalances(tripId), | |
| 88 | + ]) | |
| 89 | + | |
| 90 | + if (tripResult.data) trip.value = tripResult.data | |
| 91 | + else if (tripResult.errors) error.value = tripResult.errors.join(', ') | |
| 92 | + | |
| 93 | + if (participantsResult.data) participants.value = participantsResult.data | |
| 94 | + if (expensesResult.data) expenses.value = expensesResult.data | |
| 95 | + if (categoriesResult.data) categories.value = categoriesResult.data | |
| 96 | + if (balancesResult.data) balances.value = balancesResult.data | |
| 97 | + | |
| 98 | + isLoading.value = false | |
| 99 | +}) | |
| 100 | + | |
| 101 | +function formatDate(dateStr: string | null) { | |
| 102 | + if (!dateStr) return '—' | |
| 103 | + return d(new Date(dateStr), 'short') | |
| 104 | +} | |
| 105 | + | |
| 106 | +function getInitials(name: string | null) { | |
| 107 | + if (!name) return '?' | |
| 108 | + const parts = name.split(' ') | |
| 109 | + if (parts.length >= 2) return (parts[0]![0]! + parts[1]![0]!).toUpperCase() | |
| 110 | + return name.substring(0, 2).toUpperCase() | |
| 111 | +} | |
| 112 | + | |
| 113 | +function avatarColor(index: number) { | |
| 114 | + return `sa-avatar-${(index % 8) + 1}` | |
| 115 | +} | |
| 116 | + | |
| 117 | +function budgetBarClass(percent: number) { | |
| 118 | + if (percent > 85) return 'sa-progress-bar-danger' | |
| 119 | + if (percent > 60) return 'sa-progress-bar-warning' | |
| 120 | + return 'sa-progress-bar-success' | |
| 121 | +} | |
| 122 | + | |
| 123 | +function categoryIconClass(name: string | null) { | |
| 124 | + if (!name) return 'sa-category-default' | |
| 125 | + const lower = name.toLowerCase() | |
| 126 | + if (lower.includes('food') || lower.includes('meal') || lower.includes('dining')) return 'sa-category-food' | |
| 127 | + if (lower.includes('accommodation') || lower.includes('hotel') || lower.includes('stay')) return 'sa-category-accommodation' | |
| 128 | + if (lower.includes('transport') || lower.includes('travel') || lower.includes('taxi') || lower.includes('flight')) return 'sa-category-transport' | |
| 129 | + if (lower.includes('activit') || lower.includes('tour') || lower.includes('entertainment')) return 'sa-category-activities' | |
| 130 | + if (lower.includes('shopping') || lower.includes('souvenir')) return 'sa-category-shopping' | |
| 131 | + return 'sa-category-default' | |
| 132 | +} | |
| 133 | + | |
| 134 | +function categoryIcon(name: string | null) { | |
| 135 | + if (!name) return 'bi-tag' | |
| 136 | + const lower = name.toLowerCase() | |
| 137 | + if (lower.includes('food') || lower.includes('meal') || lower.includes('dining')) return 'bi-cup-hot-fill' | |
| 138 | + if (lower.includes('accommodation') || lower.includes('hotel')) return 'bi-house-fill' | |
| 139 | + if (lower.includes('transport') || lower.includes('taxi') || lower.includes('flight')) return 'bi-car-front-fill' | |
| 140 | + if (lower.includes('activit') || lower.includes('tour')) return 'bi-lightning-fill' | |
| 141 | + if (lower.includes('shopping') || lower.includes('souvenir')) return 'bi-bag-fill' | |
| 142 | + return 'bi-tag-fill' | |
| 143 | +} | |
| 144 | + | |
| 145 | +const navItems = computed(() => [ | |
| 146 | + { name: 'ExpensesIndex', label: t('expenses.index.title'), icon: 'bi-receipt', iconClass: 'sa-nav-icon-expenses' }, | |
| 147 | + { name: 'MembersView', label: t('trips.detail.members'), icon: 'bi-people-fill', iconClass: 'sa-nav-icon-members' }, | |
| 148 | + { name: 'BudgetIndex', label: t('budget.index.title'), icon: 'bi-pie-chart-fill', iconClass: 'sa-nav-icon-budget' }, | |
| 149 | + { name: 'WishlistIndex', label: t('wishlist.index.title'), icon: 'bi-heart-fill', iconClass: 'sa-nav-icon-wishlist' }, | |
| 150 | + { name: 'PollsIndex', label: t('polls.index.title'), icon: 'bi-bar-chart-fill', iconClass: 'sa-nav-icon-polls' }, | |
| 151 | + { name: 'SettlementView', label: t('settlements.title'), icon: 'bi-arrow-left-right', iconClass: 'sa-nav-icon-settlement' }, | |
| 152 | +]) | |
| 153 | +</script> | |
| 154 | + | |
| 155 | +<template> | |
| 156 | + <div> | |
| 157 | + <div v-if="isLoading" class="text-center py-5"> | |
| 158 | + <div class="spinner-border" style="color: var(--sa-primary)" role="status"></div> | |
| 159 | + </div> | |
| 160 | + | |
| 161 | + <div v-else-if="error" class="alert alert-danger">{{ error }}</div> | |
| 162 | + | |
| 163 | + <div v-else-if="trip"> | |
| 164 | + <!-- Show dashboard when on main detail route, show child view otherwise --> | |
| 165 | + <template v-if="!isChildRoute"> | |
| 166 | + <!-- Hero Header --> | |
| 167 | + <div class="sa-gradient-header"> | |
| 168 | + <div class="d-flex justify-content-between align-items-start"> | |
| 169 | + <div> | |
| 170 | + <div class="d-flex align-items-center gap-2 mb-2"> | |
| 171 | + <h1 class="mb-0" style="font-weight: 800">{{ trip.name }}</h1> | |
| 172 | + <span class="sa-badge" :class="trip.status === 'Active' ? 'sa-badge-success' : 'sa-badge-info'"> | |
| 173 | + {{ t(`trips.status.${trip.status}`) }} | |
| 174 | + </span> | |
| 175 | + <span v-if="isOrganizer" class="sa-badge sa-badge-primary"> | |
| 176 | + <i class="bi bi-shield-fill me-1"></i>{{ t('trips.detail.organizer') }} | |
| 177 | + </span> | |
| 178 | + </div> | |
| 179 | + <p v-if="trip.destination" class="text-muted mb-1"> | |
| 180 | + <i class="bi bi-geo-alt-fill me-1"></i>{{ trip.destination }} | |
| 181 | + </p> | |
| 182 | + <p class="text-muted mb-0" style="font-size: 0.9rem"> | |
| 183 | + <i class="bi bi-calendar3 me-1"></i> | |
| 184 | + {{ formatDate(trip.startDate) }} — {{ formatDate(trip.endDate) }} | |
| 185 | + <span v-if="trip.defaultCurrencyCode" class="ms-2"> | |
| 186 | + <i class="bi bi-currency-exchange me-1"></i>{{ trip.defaultCurrencyCode }} | |
| 187 | + </span> | |
| 188 | + </p> | |
| 189 | + </div> | |
| 190 | + <button | |
| 191 | + v-if="isOrganizer" | |
| 192 | + class="sa-btn sa-btn-ghost sa-btn-sm" | |
| 193 | + style="color: #fff; border-color: rgba(255,255,255,0.3)" | |
| 194 | + @click="router.push({ name: 'TripsEdit', params: { tripId: trip!.id } })" | |
| 195 | + > | |
| 196 | + <i class="bi bi-pencil"></i> {{ t('common.edit') }} | |
| 197 | + </button> | |
| 198 | + </div> | |
| 199 | + | |
| 200 | + <!-- Avatar Stack --> | |
| 201 | + <div v-if="participants.length > 0" class="d-flex align-items-center gap-3 mt-3"> | |
| 202 | + <div class="sa-avatar-stack"> | |
| 203 | + <span | |
| 204 | + v-for="(p, i) in visibleParticipants" | |
| 205 | + :key="p.id" | |
| 206 | + class="sa-avatar sa-avatar-sm" | |
| 207 | + :class="avatarColor(i)" | |
| 208 | + :title="p.userName ?? p.userEmail ?? ''" | |
| 209 | + > | |
| 210 | + {{ getInitials(p.userName) }} | |
| 211 | + </span> | |
| 212 | + <span v-if="overflowCount > 0" class="sa-avatar sa-avatar-sm sa-avatar-overflow"> | |
| 213 | + +{{ overflowCount }} | |
| 214 | + </span> | |
| 215 | + </div> | |
| 216 | + <span class="text-muted" style="font-size: 0.85rem">{{ t('trips.detail.memberCount', { n: participants.length }, participants.length) }}</span> | |
| 217 | + </div> | |
| 218 | + </div> | |
| 219 | + | |
| 220 | + <!-- Stats Row --> | |
| 221 | + <div class="row g-3 mb-4"> | |
| 222 | + <div class="col-6 col-md-3"> | |
| 223 | + <div class="sa-card-static sa-card-body sa-stat"> | |
| 224 | + <div class="sa-stat-icon sa-text-primary"><i class="bi bi-receipt"></i></div> | |
| 225 | + <div class="sa-stat-value">{{ formatCurrency(totalExpenses, trip?.defaultCurrencySymbol, 0) }}</div> | |
| 226 | + <div class="sa-stat-label">{{ t('trips.detail.totalExpenses') }}</div> | |
| 227 | + </div> | |
| 228 | + </div> | |
| 229 | + <div class="col-6 col-md-3"> | |
| 230 | + <div class="sa-card-static sa-card-body sa-stat"> | |
| 231 | + <div class="sa-stat-icon" :class="budgetPercent > 85 ? 'sa-text-primary' : 'sa-text-secondary'"> | |
| 232 | + <i class="bi bi-pie-chart-fill"></i> | |
| 233 | + </div> | |
| 234 | + <div class="sa-stat-value">{{ totalPlanned > 0 ? budgetPercent + '%' : '—' }}</div> | |
| 235 | + <div class="sa-stat-label">{{ t('trips.detail.budgetUsed') }}</div> | |
| 236 | + </div> | |
| 237 | + </div> | |
| 238 | + <div class="col-6 col-md-3"> | |
| 239 | + <div class="sa-card-static sa-card-body sa-stat"> | |
| 240 | + <div class="sa-stat-icon"><i class="bi bi-wallet2" style="color: var(--sa-accent)"></i></div> | |
| 241 | + <div class="sa-stat-value" :class="myBalance >= 0 ? 'sa-amount-positive' : 'sa-amount-negative'"> | |
| 242 | + {{ formatCurrency(myBalance, trip?.defaultCurrencySymbol, 0) }} | |
| 243 | + </div> | |
| 244 | + <div class="sa-stat-label">{{ t('trips.detail.yourBalance') }}</div> | |
| 245 | + </div> | |
| 246 | + </div> | |
| 247 | + <div class="col-6 col-md-3"> | |
| 248 | + <div class="sa-card-static sa-card-body sa-stat"> | |
| 249 | + <div class="sa-stat-icon" style="color: #6366f1"><i class="bi bi-people-fill"></i></div> | |
| 250 | + <div class="sa-stat-value">{{ participants.length }}</div> | |
| 251 | + <div class="sa-stat-label">{{ t('trips.detail.members') }}</div> | |
| 252 | + </div> | |
| 253 | + </div> | |
| 254 | + </div> | |
| 255 | + | |
| 256 | + <!-- Navigation Grid --> | |
| 257 | + <div class="row g-3 mb-4"> | |
| 258 | + <div v-for="nav in navItems" :key="nav.name" class="col-4 col-md-2"> | |
| 259 | + <div | |
| 260 | + class="sa-nav-card" | |
| 261 | + @click="router.push({ name: nav.name, params: { tripId } })" | |
| 262 | + > | |
| 263 | + <div class="sa-nav-card-icon" :class="nav.iconClass"> | |
| 264 | + <i :class="['bi', nav.icon]"></i> | |
| 265 | + </div> | |
| 266 | + <div class="sa-nav-card-label">{{ nav.label }}</div> | |
| 267 | + </div> | |
| 268 | + </div> | |
| 269 | + </div> | |
| 270 | + | |
| 271 | + <!-- Budget Progress + Balances --> | |
| 272 | + <div v-if="categories.length > 0" class="row g-3 mb-4"> | |
| 273 | + <div class="col-12 col-lg-6"> | |
| 274 | + <div class="sa-card-static h-100"> | |
| 275 | + <div class="sa-card-header d-flex justify-content-between align-items-center"> | |
| 276 | + <span><i class="bi bi-pie-chart-fill me-2" style="color: var(--sa-success)"></i>{{ t('trips.detail.budgetProgress') }}</span> | |
| 277 | + <RouterLink :to="{ name: 'BudgetIndex', params: { tripId } }" class="sa-btn sa-btn-ghost sa-btn-sm">{{ t('common.viewAll') }}</RouterLink> | |
| 278 | + </div> | |
| 279 | + <div class="sa-card-body"> | |
| 280 | + <div v-for="cat in categories.slice(0, 4)" :key="cat.id" class="mb-3"> | |
| 281 | + <div class="d-flex justify-content-between mb-1"> | |
| 282 | + <span style="font-weight: 600; font-size: 0.9rem">{{ cat.name }}</span> | |
| 283 | + <span style="font-size: 0.85rem; color: var(--sa-gray-500)"> | |
| 284 | + {{ formatCurrency(cat.spentAmount, trip?.defaultCurrencySymbol, 0) }} / {{ formatCurrency(cat.plannedAmount || 0, trip?.defaultCurrencySymbol, 0) }} | |
| 285 | + </span> | |
| 286 | + </div> | |
| 287 | + <div class="sa-progress sa-progress-sm"> | |
| 288 | + <div | |
| 289 | + class="sa-progress-bar" | |
| 290 | + :class="budgetBarClass(cat.plannedAmount ? (cat.spentAmount / cat.plannedAmount) * 100 : 0)" | |
| 291 | + :style="{ width: cat.plannedAmount ? Math.min(100, (cat.spentAmount / cat.plannedAmount) * 100) + '%' : '0%' }" | |
| 292 | + ></div> | |
| 293 | + </div> | |
| 294 | + </div> | |
| 295 | + </div> | |
| 296 | + </div> | |
| 297 | + </div> | |
| 298 | + | |
| 299 | + <div v-if="balances.length > 0" class="col-12 col-lg-6"> | |
| 300 | + <div class="sa-card-static h-100"> | |
| 301 | + <div class="sa-card-header d-flex justify-content-between align-items-center"> | |
| 302 | + <span><i class="bi bi-arrow-left-right me-2" style="color: var(--sa-secondary)"></i>{{ t('trips.detail.balances') }}</span> | |
| 303 | + <RouterLink :to="{ name: 'SettlementView', params: { tripId } }" class="sa-btn sa-btn-ghost sa-btn-sm">{{ t('common.viewAll') }}</RouterLink> | |
| 304 | + </div> | |
| 305 | + <div class="sa-card-body"> | |
| 306 | + <div v-for="(bal, i) in balances.slice(0, 5)" :key="bal.userId" class="d-flex align-items-center gap-3 mb-3"> | |
| 307 | + <span class="sa-avatar sa-avatar-sm" :class="avatarColor(i)">{{ getInitials(bal.userName) }}</span> | |
| 308 | + <span class="flex-grow-1" style="font-weight: 500; font-size: 0.9rem">{{ bal.userName }}</span> | |
| 309 | + <span class="sa-amount" :class="bal.balance >= 0 ? 'sa-amount-positive' : 'sa-amount-negative'"> | |
| 310 | + {{ bal.balance >= 0 ? '+' : '' }}{{ formatCurrency(bal.balance, trip?.defaultCurrencySymbol) }} | |
| 311 | + </span> | |
| 312 | + </div> | |
| 313 | + </div> | |
| 314 | + </div> | |
| 315 | + </div> | |
| 316 | + </div> | |
| 317 | + | |
| 318 | + <!-- Balances + Recent Expenses (when NO budget) --> | |
| 319 | + <div v-if="categories.length === 0 && (balances.length > 0 || recentExpenses.length > 0)" class="row g-3 mb-4"> | |
| 320 | + <div v-if="balances.length > 0" class="col-12 col-lg-6"> | |
| 321 | + <div class="sa-card-static h-100"> | |
| 322 | + <div class="sa-card-header d-flex justify-content-between align-items-center"> | |
| 323 | + <span><i class="bi bi-arrow-left-right me-2" style="color: var(--sa-secondary)"></i>{{ t('trips.detail.balances') }}</span> | |
| 324 | + <RouterLink :to="{ name: 'SettlementView', params: { tripId } }" class="sa-btn sa-btn-ghost sa-btn-sm">{{ t('common.viewAll') }}</RouterLink> | |
| 325 | + </div> | |
| 326 | + <div class="sa-card-body"> | |
| 327 | + <div v-for="(bal, i) in balances.slice(0, 5)" :key="bal.userId" class="d-flex align-items-center gap-3 mb-3"> | |
| 328 | + <span class="sa-avatar sa-avatar-sm" :class="avatarColor(i)">{{ getInitials(bal.userName) }}</span> | |
| 329 | + <span class="flex-grow-1" style="font-weight: 500; font-size: 0.9rem">{{ bal.userName }}</span> | |
| 330 | + <span class="sa-amount" :class="bal.balance >= 0 ? 'sa-amount-positive' : 'sa-amount-negative'"> | |
| 331 | + {{ bal.balance >= 0 ? '+' : '' }}{{ formatCurrency(bal.balance, trip?.defaultCurrencySymbol) }} | |
| 332 | + </span> | |
| 333 | + </div> | |
| 334 | + </div> | |
| 335 | + </div> | |
| 336 | + </div> | |
| 337 | + | |
| 338 | + <div v-if="recentExpenses.length > 0" class="col-12 col-lg-6"> | |
| 339 | + <div class="sa-card-static h-100"> | |
| 340 | + <div class="sa-card-header d-flex justify-content-between align-items-center"> | |
| 341 | + <span><i class="bi bi-receipt me-2" style="color: var(--sa-primary)"></i>{{ t('trips.detail.recentExpenses') }}</span> | |
| 342 | + <RouterLink :to="{ name: 'ExpensesIndex', params: { tripId } }" class="sa-btn sa-btn-ghost sa-btn-sm">{{ t('common.viewAll') }}</RouterLink> | |
| 343 | + </div> | |
| 344 | + <div> | |
| 345 | + <div v-for="expense in recentExpenses" :key="expense.id" class="sa-expense-item"> | |
| 346 | + <div class="sa-expense-icon" :class="categoryIconClass(expense.budgetCategoryName)"> | |
| 347 | + <i :class="['bi', categoryIcon(expense.budgetCategoryName)]"></i> | |
| 348 | + </div> | |
| 349 | + <div class="sa-expense-details"> | |
| 350 | + <div class="sa-expense-desc">{{ expense.description || t('trips.detail.untitledExpense') }}</div> | |
| 351 | + <div class="sa-expense-meta"> | |
| 352 | + {{ expense.paidByUserName }} · {{ formatDate(expense.expenseDate) }} | |
| 353 | + </div> | |
| 354 | + </div> | |
| 355 | + <div class="sa-expense-amount">{{ formatCurrency(expense.amount, expense.currencySymbol ?? trip?.defaultCurrencySymbol) }}</div> | |
| 356 | + </div> | |
| 357 | + </div> | |
| 358 | + </div> | |
| 359 | + </div> | |
| 360 | + </div> | |
| 361 | + | |
| 362 | + <!-- Recent Expenses (full-width, when budget exists) --> | |
| 363 | + <div v-if="categories.length > 0 && recentExpenses.length > 0" class="sa-card-static mb-4"> | |
| 364 | + <div class="sa-card-header d-flex justify-content-between align-items-center"> | |
| 365 | + <span><i class="bi bi-receipt me-2" style="color: var(--sa-primary)"></i>{{ t('trips.detail.recentExpenses') }}</span> | |
| 366 | + <RouterLink :to="{ name: 'ExpensesIndex', params: { tripId } }" class="sa-btn sa-btn-ghost sa-btn-sm">{{ t('common.viewAll') }}</RouterLink> | |
| 367 | + </div> | |
| 368 | + <div> | |
| 369 | + <div v-for="expense in recentExpenses" :key="expense.id" class="sa-expense-item"> | |
| 370 | + <div class="sa-expense-icon" :class="categoryIconClass(expense.budgetCategoryName)"> | |
| 371 | + <i :class="['bi', categoryIcon(expense.budgetCategoryName)]"></i> | |
| 372 | + </div> | |
| 373 | + <div class="sa-expense-details"> | |
| 374 | + <div class="sa-expense-desc">{{ expense.description || t('trips.detail.untitledExpense') }}</div> | |
| 375 | + <div class="sa-expense-meta"> | |
| 376 | + {{ expense.paidByUserName }} · {{ formatDate(expense.expenseDate) }} | |
| 377 | + </div> | |
| 378 | + </div> | |
| 379 | + <div class="sa-expense-amount">{{ formatCurrency(expense.amount, expense.currencySymbol ?? trip?.defaultCurrencySymbol) }}</div> | |
| 380 | + </div> | |
| 381 | + </div> | |
| 382 | + </div> | |
| 383 | + | |
| 384 | + <!-- Description --> | |
| 385 | + <div v-if="trip.description" class="sa-card-static mb-4"> | |
| 386 | + <div class="sa-card-header"><i class="bi bi-info-circle me-2"></i>{{ t('trips.detail.about') }}</div> | |
| 387 | + <div class="sa-card-body" style="color: var(--sa-gray-600); line-height: 1.7"> | |
| 388 | + {{ trip.description }} | |
| 389 | + </div> | |
| 390 | + </div> | |
| 391 | + </template> | |
| 392 | + | |
| 393 | + <!-- Child Route Content --> | |
| 394 | + <template v-else> | |
| 395 | + <div class="d-flex align-items-center gap-3 mb-4"> | |
| 396 | + <button class="sa-btn sa-btn-ghost sa-btn-sm" @click="router.push({ name: 'TripDetail', params: { tripId } })"> | |
| 397 | + <i class="bi bi-arrow-left"></i> {{ t('trips.detail.backTo', { name: trip.name }) }} | |
| 398 | + </button> | |
| 399 | + </div> | |
| 400 | + <RouterView /> | |
| 401 | + </template> | |
| 402 | + </div> | |
| 403 | + </div> | |
| 404 | +</template> |
added src/views/trips/EditView.vue +180 −0
| @@ -0,0 +1,180 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import { ref, computed, onMounted } from 'vue' | |
| 3 | +import { useRouter, useRoute } from 'vue-router' | |
| 4 | +import { useI18n } from 'vue-i18n' | |
| 5 | +import TripService from '@/services/TripService' | |
| 6 | +import CurrencyService from '@/services/CurrencyService' | |
| 7 | +import type { ICurrency } from '@/types/ICurrency' | |
| 8 | + | |
| 9 | +const router = useRouter() | |
| 10 | +const route = useRoute() | |
| 11 | +const { t } = useI18n() | |
| 12 | + | |
| 13 | +const name = ref('') | |
| 14 | +const description = ref('') | |
| 15 | +const destination = ref('') | |
| 16 | +const startDate = ref('') | |
| 17 | +const endDate = ref('') | |
| 18 | +const defaultCurrencyId = ref('') | |
| 19 | +const status = ref('Active') | |
| 20 | +const currencies = ref<ICurrency[]>([]) | |
| 21 | +const errors = ref<string[]>([]) | |
| 22 | +const isSaving = ref(false) | |
| 23 | +const isLoading = ref(true) | |
| 24 | + | |
| 25 | +const dateError = computed<string | null>(() => { | |
| 26 | + if (!startDate.value || !endDate.value) return null | |
| 27 | + return endDate.value < startDate.value | |
| 28 | + ? t('trips.create.dateError') | |
| 29 | + : null | |
| 30 | +}) | |
| 31 | + | |
| 32 | +const tripId = route.params.tripId as string | |
| 33 | + | |
| 34 | +onMounted(async () => { | |
| 35 | + const [tripResult, currencyResult] = await Promise.all([ | |
| 36 | + TripService.getById(tripId), | |
| 37 | + CurrencyService.getAll(), | |
| 38 | + ]) | |
| 39 | + | |
| 40 | + if (currencyResult.data) { | |
| 41 | + currencies.value = currencyResult.data | |
| 42 | + } | |
| 43 | + | |
| 44 | + if (tripResult.data) { | |
| 45 | + name.value = tripResult.data.name | |
| 46 | + description.value = tripResult.data.description ?? '' | |
| 47 | + destination.value = tripResult.data.destination ?? '' | |
| 48 | + startDate.value = tripResult.data.startDate?.substring(0, 10) ?? '' | |
| 49 | + endDate.value = tripResult.data.endDate?.substring(0, 10) ?? '' | |
| 50 | + defaultCurrencyId.value = tripResult.data.defaultCurrencyId | |
| 51 | + status.value = tripResult.data.status | |
| 52 | + } else { | |
| 53 | + errors.value = tripResult.errors ?? [t('trips.edit.notFound')] | |
| 54 | + } | |
| 55 | + isLoading.value = false | |
| 56 | +}) | |
| 57 | + | |
| 58 | +async function handleSubmit() { | |
| 59 | + if (dateError.value) { | |
| 60 | + errors.value = [dateError.value] | |
| 61 | + return | |
| 62 | + } | |
| 63 | + errors.value = [] | |
| 64 | + isSaving.value = true | |
| 65 | + | |
| 66 | + const result = await TripService.update(tripId, { | |
| 67 | + id: tripId, | |
| 68 | + name: name.value, | |
| 69 | + description: description.value || null, | |
| 70 | + destination: destination.value || null, | |
| 71 | + startDate: startDate.value ? new Date(startDate.value).toISOString() : null, | |
| 72 | + endDate: endDate.value ? new Date(endDate.value).toISOString() : null, | |
| 73 | + defaultCurrencyId: defaultCurrencyId.value, | |
| 74 | + status: status.value, | |
| 75 | + }) | |
| 76 | + | |
| 77 | + if (result.errors) { | |
| 78 | + errors.value = result.errors | |
| 79 | + } else { | |
| 80 | + router.push({ name: 'TripsIndex' }) | |
| 81 | + } | |
| 82 | + | |
| 83 | + isSaving.value = false | |
| 84 | +} | |
| 85 | + | |
| 86 | +async function handleDelete() { | |
| 87 | + if (!confirm(t('trips.edit.confirmDelete'))) return | |
| 88 | + const result = await TripService.delete(tripId) | |
| 89 | + if (result.errors) { | |
| 90 | + errors.value = result.errors | |
| 91 | + } else { | |
| 92 | + router.push({ name: 'TripsIndex' }) | |
| 93 | + } | |
| 94 | +} | |
| 95 | +</script> | |
| 96 | + | |
| 97 | +<template> | |
| 98 | + <div class="row justify-content-center"> | |
| 99 | + <div v-if="isLoading" class="text-center py-5"> | |
| 100 | + <div class="spinner-border" role="status"></div> | |
| 101 | + </div> | |
| 102 | + | |
| 103 | + <div v-else class="col-md-8 col-lg-6"> | |
| 104 | + <h2>{{ t('trips.edit.title') }}</h2> | |
| 105 | + | |
| 106 | + <div v-if="errors.length" class="alert alert-danger"> | |
| 107 | + <div v-for="error in errors" :key="error">{{ error }}</div> | |
| 108 | + </div> | |
| 109 | + | |
| 110 | + <form @submit.prevent="handleSubmit"> | |
| 111 | + <div class="mb-3"> | |
| 112 | + <label for="name" class="form-label">{{ t('trips.edit.name') }}</label> | |
| 113 | + <input id="name" v-model="name" type="text" class="form-control" required /> | |
| 114 | + </div> | |
| 115 | + | |
| 116 | + <div class="mb-3"> | |
| 117 | + <label for="destination" class="form-label">{{ t('trips.edit.destination') }}</label> | |
| 118 | + <input id="destination" v-model="destination" type="text" class="form-control" /> | |
| 119 | + </div> | |
| 120 | + | |
| 121 | + <div class="mb-3"> | |
| 122 | + <label for="description" class="form-label">{{ t('trips.edit.description') }}</label> | |
| 123 | + <textarea id="description" v-model="description" class="form-control" rows="3"></textarea> | |
| 124 | + </div> | |
| 125 | + | |
| 126 | + <div class="row mb-3"> | |
| 127 | + <div class="col-6"> | |
| 128 | + <label for="startDate" class="form-label">{{ t('trips.edit.startDate') }}</label> | |
| 129 | + <input id="startDate" v-model="startDate" type="date" class="form-control" /> | |
| 130 | + </div> | |
| 131 | + <div class="col-6"> | |
| 132 | + <label for="endDate" class="form-label">{{ t('trips.edit.endDate') }}</label> | |
| 133 | + <input | |
| 134 | + id="endDate" | |
| 135 | + v-model="endDate" | |
| 136 | + type="date" | |
| 137 | + class="form-control" | |
| 138 | + :class="{ 'is-invalid': dateError }" | |
| 139 | + :min="startDate || undefined" | |
| 140 | + /> | |
| 141 | + <div v-if="dateError" class="invalid-feedback">{{ dateError }}</div> | |
| 142 | + </div> | |
| 143 | + </div> | |
| 144 | + | |
| 145 | + <div class="mb-3"> | |
| 146 | + <label for="currency" class="form-label">{{ t('trips.edit.currency') }}</label> | |
| 147 | + <select id="currency" v-model="defaultCurrencyId" class="form-select" required> | |
| 148 | + <option value="" disabled>{{ t('trips.edit.selectCurrency') }}</option> | |
| 149 | + <option v-for="c in currencies" :key="c.id" :value="c.id"> | |
| 150 | + {{ c.code }} — {{ c.name }} ({{ c.symbol }}) | |
| 151 | + </option> | |
| 152 | + </select> | |
| 153 | + </div> | |
| 154 | + | |
| 155 | + <div class="mb-3"> | |
| 156 | + <label for="status" class="form-label">{{ t('trips.edit.status') }}</label> | |
| 157 | + <select id="status" v-model="status" class="form-select"> | |
| 158 | + <option value="Active">{{ t('trips.status.Active') }}</option> | |
| 159 | + <option value="Completed">{{ t('trips.status.Completed') }}</option> | |
| 160 | + </select> | |
| 161 | + </div> | |
| 162 | + | |
| 163 | + <div class="d-flex gap-2"> | |
| 164 | + <button type="button" class="btn btn-danger" @click="handleDelete">{{ t('common.delete') }}</button> | |
| 165 | + <div class="flex-grow-1"></div> | |
| 166 | + <button | |
| 167 | + type="button" | |
| 168 | + class="btn btn-outline-secondary" | |
| 169 | + @click="router.push({ name: 'TripsIndex' })" | |
| 170 | + > | |
| 171 | + {{ t('common.cancel') }} | |
| 172 | + </button> | |
| 173 | + <button type="submit" class="btn btn-primary" :disabled="isSaving || !!dateError"> | |
| 174 | + {{ isSaving ? t('common.saving') : t('common.saveChanges') }} | |
| 175 | + </button> | |
| 176 | + </div> | |
| 177 | + </form> | |
| 178 | + </div> | |
| 179 | + </div> | |
| 180 | +</template> |
added src/views/trips/IndexView.vue +154 −0
| @@ -0,0 +1,154 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import { ref, onMounted } from 'vue' | |
| 3 | +import { useRouter } from 'vue-router' | |
| 4 | +import { useI18n } from 'vue-i18n' | |
| 5 | +import TripService from '@/services/TripService' | |
| 6 | +import type { ITrip } from '@/types/ITrip' | |
| 7 | +import { useToast } from '@/composables/useToast' | |
| 8 | +import { useAuthStore } from '@/stores/auth' | |
| 9 | +import { getUserIdFromJwt } from '@/utils/parseJwt' | |
| 10 | + | |
| 11 | +const authStore = useAuthStore() | |
| 12 | +const { t, d } = useI18n() | |
| 13 | + | |
| 14 | +function isUserOrganizer(trip: ITrip): boolean { | |
| 15 | + if (!authStore.jwt) return false | |
| 16 | + const userId = getUserIdFromJwt(authStore.jwt) | |
| 17 | + if (!userId) return false | |
| 18 | + if (trip.participants) { | |
| 19 | + const me = trip.participants.find(p => p.userId === userId) | |
| 20 | + return me?.role === 'Organizer' | |
| 21 | + } | |
| 22 | + return trip.createdById === userId | |
| 23 | +} | |
| 24 | + | |
| 25 | +const router = useRouter() | |
| 26 | +const toast = useToast() | |
| 27 | + | |
| 28 | +const trips = ref<ITrip[]>([]) | |
| 29 | +const isLoading = ref(true) | |
| 30 | +const error = ref<string | null>(null) | |
| 31 | + | |
| 32 | +onMounted(async () => { | |
| 33 | + const result = await TripService.getAll() | |
| 34 | + if (result.data) { | |
| 35 | + trips.value = result.data | |
| 36 | + } else if (result.errors) { | |
| 37 | + error.value = result.errors.join(', ') | |
| 38 | + } | |
| 39 | + isLoading.value = false | |
| 40 | +}) | |
| 41 | + | |
| 42 | +async function deleteTrip(id: string) { | |
| 43 | + if (!confirm(t('trips.index.confirmDelete'))) return | |
| 44 | + error.value = null | |
| 45 | + | |
| 46 | + const result = await TripService.delete(id) | |
| 47 | + if (result.errors) { | |
| 48 | + toast.error(result.errors.join(', ')) | |
| 49 | + } else { | |
| 50 | + trips.value = trips.value.filter((t) => t.id !== id) | |
| 51 | + toast.success(t('trips.index.deleted')) | |
| 52 | + } | |
| 53 | +} | |
| 54 | + | |
| 55 | +function formatDate(dateStr: string | null) { | |
| 56 | + if (!dateStr) return '—' | |
| 57 | + return d(new Date(dateStr), 'short') | |
| 58 | +} | |
| 59 | +</script> | |
| 60 | + | |
| 61 | +<template> | |
| 62 | + <div> | |
| 63 | + <!-- Gradient Header --> | |
| 64 | + <div class="sa-gradient-header d-flex justify-content-between align-items-center"> | |
| 65 | + <div> | |
| 66 | + <h2 class="mb-1"><i class="bi bi-suitcase-lg me-2"></i>{{ t('trips.index.title') }}</h2> | |
| 67 | + <p class="mb-0 text-muted" style="font-size: 0.9rem"> | |
| 68 | + {{ t('trips.index.count', { n: trips.length }, trips.length) }} | |
| 69 | + </p> | |
| 70 | + </div> | |
| 71 | + <button | |
| 72 | + class="sa-btn sa-btn-primary sa-btn-pill sa-hide-mobile" | |
| 73 | + @click="router.push({ name: 'TripsCreate' })" | |
| 74 | + > | |
| 75 | + <i class="bi bi-plus-lg"></i> {{ t('trips.index.newTrip') }} | |
| 76 | + </button> | |
| 77 | + </div> | |
| 78 | + | |
| 79 | + <div v-if="error" class="alert alert-danger">{{ error }}</div> | |
| 80 | + | |
| 81 | + <!-- Loading --> | |
| 82 | + <div v-if="isLoading" class="text-center py-5"> | |
| 83 | + <div class="spinner-border" style="color: var(--sa-primary)" role="status"></div> | |
| 84 | + </div> | |
| 85 | + | |
| 86 | + <!-- Empty State --> | |
| 87 | + <div v-else-if="trips.length === 0" class="sa-empty"> | |
| 88 | + <div class="sa-empty-icon"><i class="bi bi-suitcase-lg"></i></div> | |
| 89 | + <div class="sa-empty-title">{{ t('trips.index.emptyTitle') }}</div> | |
| 90 | + <div class="sa-empty-text">{{ t('trips.index.emptyText') }}</div> | |
| 91 | + <button class="sa-btn sa-btn-primary sa-btn-pill" @click="router.push({ name: 'TripsCreate' })"> | |
| 92 | + <i class="bi bi-plus-lg"></i> {{ t('trips.index.createFirst') }} | |
| 93 | + </button> | |
| 94 | + </div> | |
| 95 | + | |
| 96 | + <!-- Trip Cards Grid --> | |
| 97 | + <div v-else class="row g-4"> | |
| 98 | + <div | |
| 99 | + v-for="(trip, index) in trips" | |
| 100 | + :key="trip.id" | |
| 101 | + class="col-md-6 col-lg-4 sa-animate-slide-up" | |
| 102 | + :class="`sa-stagger-${(index % 6) + 1}`" | |
| 103 | + > | |
| 104 | + <div | |
| 105 | + class="sa-card sa-trip-card h-100" | |
| 106 | + style="cursor: pointer" | |
| 107 | + @click="router.push({ name: 'TripDetail', params: { tripId: trip.id } })" | |
| 108 | + > | |
| 109 | + <div class="sa-trip-card-gradient"></div> | |
| 110 | + <div class="sa-card-body"> | |
| 111 | + <h5 class="mb-2" style="font-weight: 700">{{ trip.name }}</h5> | |
| 112 | + <p v-if="trip.destination" class="mb-1" style="color: var(--sa-gray-500); font-size: 0.9rem"> | |
| 113 | + <i class="bi bi-geo-alt me-1"></i>{{ trip.destination }} | |
| 114 | + </p> | |
| 115 | + <p class="mb-3" style="color: var(--sa-gray-400); font-size: 0.85rem"> | |
| 116 | + <i class="bi bi-calendar3 me-1"></i> | |
| 117 | + {{ formatDate(trip.startDate) }} — {{ formatDate(trip.endDate) }} | |
| 118 | + </p> | |
| 119 | + <div class="d-flex flex-wrap gap-2"> | |
| 120 | + <span class="sa-badge" :class="trip.status === 'Active' ? 'sa-badge-success' : 'sa-badge-info'"> | |
| 121 | + {{ t(`trips.status.${trip.status}`) }} | |
| 122 | + </span> | |
| 123 | + <span class="sa-badge sa-badge-neutral"> | |
| 124 | + <i class="bi bi-people-fill me-1"></i>{{ trip.participantCount }} | |
| 125 | + </span> | |
| 126 | + <span v-if="trip.defaultCurrencyCode" class="sa-badge sa-badge-accent"> | |
| 127 | + {{ trip.defaultCurrencyCode }} | |
| 128 | + </span> | |
| 129 | + <span class="sa-badge" :class="isUserOrganizer(trip) ? 'sa-badge-primary' : 'sa-badge-neutral'"> | |
| 130 | + <i :class="isUserOrganizer(trip) ? 'bi bi-shield-fill me-1' : 'bi bi-person me-1'"></i>{{ isUserOrganizer(trip) ? t('trips.detail.organizer') : t('trips.detail.participant') }} | |
| 131 | + </span> | |
| 132 | + </div> | |
| 133 | + </div> | |
| 134 | + <div v-if="isUserOrganizer(trip)" class="sa-card-footer d-flex gap-2" @click.stop> | |
| 135 | + <button | |
| 136 | + class="sa-btn sa-btn-ghost sa-btn-sm" | |
| 137 | + @click="router.push({ name: 'TripsEdit', params: { tripId: trip.id } })" | |
| 138 | + > | |
| 139 | + <i class="bi bi-pencil"></i> {{ t('common.edit') }} | |
| 140 | + </button> | |
| 141 | + <button class="sa-btn sa-btn-ghost sa-btn-sm" style="color: var(--sa-danger)" @click="deleteTrip(trip.id)"> | |
| 142 | + <i class="bi bi-trash3"></i> {{ t('common.delete') }} | |
| 143 | + </button> | |
| 144 | + </div> | |
| 145 | + </div> | |
| 146 | + </div> | |
| 147 | + </div> | |
| 148 | + | |
| 149 | + <!-- Mobile FAB --> | |
| 150 | + <button class="sa-fab" @click="router.push({ name: 'TripsCreate' })"> | |
| 151 | + <i class="bi bi-plus-lg"></i> | |
| 152 | + </button> | |
| 153 | + </div> | |
| 154 | +</template> |
added src/views/wishlist/CreateView.vue +146 −0
| @@ -0,0 +1,146 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import { ref } from 'vue' | |
| 3 | +import { useRouter, useRoute } from 'vue-router' | |
| 4 | +import { useI18n } from 'vue-i18n' | |
| 5 | +import WishlistService from '@/services/WishlistService' | |
| 6 | + | |
| 7 | +const router = useRouter() | |
| 8 | +const route = useRoute() | |
| 9 | +const { t } = useI18n() | |
| 10 | + | |
| 11 | +const tripId = route.params.tripId as string | |
| 12 | + | |
| 13 | +const title = ref('') | |
| 14 | +const description = ref('') | |
| 15 | +const category = ref('Place') | |
| 16 | +const priority = ref('NiceToHave') | |
| 17 | +const estimatedCost = ref<number | null>(null) | |
| 18 | +const url = ref('') | |
| 19 | +const location = ref('') | |
| 20 | +const errors = ref<string[]>([]) | |
| 21 | +const isSaving = ref(false) | |
| 22 | + | |
| 23 | +async function handleSubmit() { | |
| 24 | + errors.value = [] | |
| 25 | + isSaving.value = true | |
| 26 | + | |
| 27 | + const result = await WishlistService.create({ | |
| 28 | + tripId, | |
| 29 | + title: title.value, | |
| 30 | + description: description.value || null, | |
| 31 | + category: category.value, | |
| 32 | + priority: priority.value, | |
| 33 | + estimatedCost: estimatedCost.value, | |
| 34 | + url: url.value || null, | |
| 35 | + location: location.value || null, | |
| 36 | + }) | |
| 37 | + | |
| 38 | + if (result.errors) { | |
| 39 | + errors.value = result.errors | |
| 40 | + } else { | |
| 41 | + router.push({ name: 'WishlistIndex', params: { tripId } }) | |
| 42 | + } | |
| 43 | + | |
| 44 | + isSaving.value = false | |
| 45 | +} | |
| 46 | +</script> | |
| 47 | + | |
| 48 | +<template> | |
| 49 | + <div class="row justify-content-center"> | |
| 50 | + <div class="col-md-8 col-lg-6"> | |
| 51 | + <h4 class="mb-4" style="color: var(--sa-gray-900)"> | |
| 52 | + <i class="bi bi-plus-circle me-2" style="color: var(--sa-accent)"></i>{{ t('wishlist.create.title') }} | |
| 53 | + </h4> | |
| 54 | + | |
| 55 | + <!-- Error Display --> | |
| 56 | + <div v-if="errors.length" class="sa-card-static sa-card-accent sa-card-accent-danger mb-4"> | |
| 57 | + <div class="sa-card-body"> | |
| 58 | + <div class="d-flex align-items-center mb-1"> | |
| 59 | + <i class="bi bi-exclamation-triangle-fill me-2" style="color: var(--sa-danger)"></i> | |
| 60 | + <strong style="color: var(--sa-danger)">{{ t('wishlist.create.pleaseFix') }}</strong> | |
| 61 | + </div> | |
| 62 | + <div v-for="err in errors" :key="err" style="font-size: 0.875rem; color: var(--sa-gray-600)"> | |
| 63 | + {{ err }} | |
| 64 | + </div> | |
| 65 | + </div> | |
| 66 | + </div> | |
| 67 | + | |
| 68 | + <div class="sa-card-static"> | |
| 69 | + <div class="sa-card-body"> | |
| 70 | + <form @submit.prevent="handleSubmit"> | |
| 71 | + <div class="mb-3"> | |
| 72 | + <label for="title" class="form-label">{{ t('wishlist.create.itemTitle') }}</label> | |
| 73 | + <input id="title" v-model="title" type="text" class="form-control" required :placeholder="t('wishlist.create.titlePlaceholder')" /> | |
| 74 | + </div> | |
| 75 | + | |
| 76 | + <div class="mb-3"> | |
| 77 | + <label for="description" class="form-label">{{ t('wishlist.create.description') }}</label> | |
| 78 | + <textarea id="description" v-model="description" class="form-control" rows="3" :placeholder="t('wishlist.create.descriptionPlaceholder')"></textarea> | |
| 79 | + </div> | |
| 80 | + | |
| 81 | + <div class="row mb-3"> | |
| 82 | + <div class="col-6"> | |
| 83 | + <label for="category" class="form-label">{{ t('wishlist.create.category') }}</label> | |
| 84 | + <select id="category" v-model="category" class="form-select"> | |
| 85 | + <option value="Place">{{ t('wishlist.category.Place') }}</option> | |
| 86 | + <option value="Activity">{{ t('wishlist.category.Activity') }}</option> | |
| 87 | + <option value="Restaurant">{{ t('wishlist.category.Restaurant') }}</option> | |
| 88 | + <option value="Other">{{ t('wishlist.category.Other') }}</option> | |
| 89 | + </select> | |
| 90 | + </div> | |
| 91 | + <div class="col-6"> | |
| 92 | + <label for="priority" class="form-label">{{ t('wishlist.create.priority') }}</label> | |
| 93 | + <select id="priority" v-model="priority" class="form-select"> | |
| 94 | + <option value="MustDo">{{ t('wishlist.priority.MustDo') }}</option> | |
| 95 | + <option value="NiceToHave">{{ t('wishlist.priority.NiceToHave') }}</option> | |
| 96 | + <option value="Optional">{{ t('wishlist.priority.Optional') }}</option> | |
| 97 | + </select> | |
| 98 | + </div> | |
| 99 | + </div> | |
| 100 | + | |
| 101 | + <div class="mb-3"> | |
| 102 | + <label for="estimatedCost" class="form-label">{{ t('wishlist.create.estimatedCost') }}</label> | |
| 103 | + <input | |
| 104 | + id="estimatedCost" | |
| 105 | + v-model.number="estimatedCost" | |
| 106 | + type="number" | |
| 107 | + step="0.01" | |
| 108 | + min="0" | |
| 109 | + class="form-control" | |
| 110 | + placeholder="0.00" | |
| 111 | + /> | |
| 112 | + </div> | |
| 113 | + | |
| 114 | + <div class="mb-3"> | |
| 115 | + <label for="url" class="form-label">{{ t('wishlist.create.url') }}</label> | |
| 116 | + <input id="url" v-model="url" type="url" class="form-control" placeholder="https://..." /> | |
| 117 | + </div> | |
| 118 | + | |
| 119 | + <div class="mb-4"> | |
| 120 | + <label for="location" class="form-label">{{ t('wishlist.create.location') }}</label> | |
| 121 | + <input id="location" v-model="location" type="text" class="form-control" :placeholder="t('wishlist.create.locationPlaceholder')" /> | |
| 122 | + </div> | |
| 123 | + | |
| 124 | + <div class="d-flex gap-2 justify-content-end"> | |
| 125 | + <button | |
| 126 | + type="button" | |
| 127 | + class="sa-btn sa-btn-ghost" | |
| 128 | + @click="router.push({ name: 'WishlistIndex', params: { tripId } })" | |
| 129 | + > | |
| 130 | + {{ t('common.cancel') }} | |
| 131 | + </button> | |
| 132 | + <button | |
| 133 | + type="submit" | |
| 134 | + class="sa-btn sa-btn-primary" | |
| 135 | + :class="{ 'sa-btn-loading': isSaving }" | |
| 136 | + :disabled="isSaving" | |
| 137 | + > | |
| 138 | + {{ isSaving ? t('wishlist.create.saving') : t('wishlist.create.addItem') }} | |
| 139 | + </button> | |
| 140 | + </div> | |
| 141 | + </form> | |
| 142 | + </div> | |
| 143 | + </div> | |
| 144 | + </div> | |
| 145 | + </div> | |
| 146 | +</template> |
added src/views/wishlist/EditView.vue +192 −0
| @@ -0,0 +1,192 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import { ref, onMounted } from 'vue' | |
| 3 | +import { useRouter, useRoute } from 'vue-router' | |
| 4 | +import { useI18n } from 'vue-i18n' | |
| 5 | +import WishlistService from '@/services/WishlistService' | |
| 6 | + | |
| 7 | +const router = useRouter() | |
| 8 | +const route = useRoute() | |
| 9 | +const { t } = useI18n() | |
| 10 | + | |
| 11 | +const tripId = route.params.tripId as string | |
| 12 | +const itemId = route.params.id as string | |
| 13 | + | |
| 14 | +const title = ref('') | |
| 15 | +const description = ref('') | |
| 16 | +const category = ref('Place') | |
| 17 | +const priority = ref('NiceToHave') | |
| 18 | +const estimatedCost = ref<number | null>(null) | |
| 19 | +const url = ref('') | |
| 20 | +const location = ref('') | |
| 21 | +const errors = ref<string[]>([]) | |
| 22 | +const isSaving = ref(false) | |
| 23 | +const isLoading = ref(true) | |
| 24 | + | |
| 25 | +onMounted(async () => { | |
| 26 | + const result = await WishlistService.getByTrip(tripId) | |
| 27 | + if (result.data) { | |
| 28 | + const item = result.data.find((i) => i.id === itemId) | |
| 29 | + if (item) { | |
| 30 | + title.value = item.title | |
| 31 | + description.value = item.description ?? '' | |
| 32 | + category.value = item.category | |
| 33 | + priority.value = item.priority | |
| 34 | + estimatedCost.value = item.estimatedCost | |
| 35 | + url.value = item.url ?? '' | |
| 36 | + location.value = item.location ?? '' | |
| 37 | + } else { | |
| 38 | + errors.value = [t('wishlist.edit.notFound')] | |
| 39 | + } | |
| 40 | + } else if (result.errors) { | |
| 41 | + errors.value = result.errors | |
| 42 | + } | |
| 43 | + isLoading.value = false | |
| 44 | +}) | |
| 45 | + | |
| 46 | +async function handleSubmit() { | |
| 47 | + errors.value = [] | |
| 48 | + isSaving.value = true | |
| 49 | + | |
| 50 | + const result = await WishlistService.update(itemId, { | |
| 51 | + tripId, | |
| 52 | + title: title.value, | |
| 53 | + description: description.value || null, | |
| 54 | + category: category.value, | |
| 55 | + priority: priority.value, | |
| 56 | + estimatedCost: estimatedCost.value, | |
| 57 | + url: url.value || null, | |
| 58 | + location: location.value || null, | |
| 59 | + }) | |
| 60 | + | |
| 61 | + if (result.errors) { | |
| 62 | + errors.value = result.errors | |
| 63 | + } else { | |
| 64 | + router.push({ name: 'WishlistIndex', params: { tripId } }) | |
| 65 | + } | |
| 66 | + | |
| 67 | + isSaving.value = false | |
| 68 | +} | |
| 69 | + | |
| 70 | +async function handleDelete() { | |
| 71 | + if (!confirm(t('wishlist.edit.confirmDelete'))) return | |
| 72 | + const result = await WishlistService.delete(itemId) | |
| 73 | + if (result.errors) { | |
| 74 | + errors.value = result.errors | |
| 75 | + } else { | |
| 76 | + router.push({ name: 'WishlistIndex', params: { tripId } }) | |
| 77 | + } | |
| 78 | +} | |
| 79 | +</script> | |
| 80 | + | |
| 81 | +<template> | |
| 82 | + <div class="row justify-content-center"> | |
| 83 | + <!-- Loading --> | |
| 84 | + <div v-if="isLoading" class="text-center py-5"> | |
| 85 | + <div class="spinner-border" style="color: var(--sa-primary)" role="status"></div> | |
| 86 | + </div> | |
| 87 | + | |
| 88 | + <div v-else class="col-md-8 col-lg-6"> | |
| 89 | + <h4 class="mb-4" style="color: var(--sa-gray-900)"> | |
| 90 | + <i class="bi bi-pencil-square me-2" style="color: var(--sa-accent)"></i>{{ t('wishlist.edit.title') }} | |
| 91 | + </h4> | |
| 92 | + | |
| 93 | + <!-- Error Display --> | |
| 94 | + <div v-if="errors.length" class="sa-card-static sa-card-accent sa-card-accent-danger mb-4"> | |
| 95 | + <div class="sa-card-body"> | |
| 96 | + <div class="d-flex align-items-center mb-1"> | |
| 97 | + <i class="bi bi-exclamation-triangle-fill me-2" style="color: var(--sa-danger)"></i> | |
| 98 | + <strong style="color: var(--sa-danger)">{{ t('wishlist.edit.somethingWrong') }}</strong> | |
| 99 | + </div> | |
| 100 | + <div v-for="err in errors" :key="err" style="font-size: 0.875rem; color: var(--sa-gray-600)"> | |
| 101 | + {{ err }} | |
| 102 | + </div> | |
| 103 | + </div> | |
| 104 | + </div> | |
| 105 | + | |
| 106 | + <div class="sa-card-static"> | |
| 107 | + <div class="sa-card-body"> | |
| 108 | + <form @submit.prevent="handleSubmit"> | |
| 109 | + <div class="mb-3"> | |
| 110 | + <label for="title" class="form-label">{{ t('wishlist.create.itemTitle') }}</label> | |
| 111 | + <input id="title" v-model="title" type="text" class="form-control" required :placeholder="t('wishlist.create.titlePlaceholder')" /> | |
| 112 | + </div> | |
| 113 | + | |
| 114 | + <div class="mb-3"> | |
| 115 | + <label for="description" class="form-label">{{ t('wishlist.create.description') }}</label> | |
| 116 | + <textarea id="description" v-model="description" class="form-control" rows="3" :placeholder="t('wishlist.create.descriptionPlaceholder')"></textarea> | |
| 117 | + </div> | |
| 118 | + | |
| 119 | + <div class="row mb-3"> | |
| 120 | + <div class="col-6"> | |
| 121 | + <label for="category" class="form-label">{{ t('wishlist.create.category') }}</label> | |
| 122 | + <select id="category" v-model="category" class="form-select"> | |
| 123 | + <option value="Place">{{ t('wishlist.category.Place') }}</option> | |
| 124 | + <option value="Activity">{{ t('wishlist.category.Activity') }}</option> | |
| 125 | + <option value="Restaurant">{{ t('wishlist.category.Restaurant') }}</option> | |
| 126 | + <option value="Other">{{ t('wishlist.category.Other') }}</option> | |
| 127 | + </select> | |
| 128 | + </div> | |
| 129 | + <div class="col-6"> | |
| 130 | + <label for="priority" class="form-label">{{ t('wishlist.create.priority') }}</label> | |
| 131 | + <select id="priority" v-model="priority" class="form-select"> | |
| 132 | + <option value="MustDo">{{ t('wishlist.priority.MustDo') }}</option> | |
| 133 | + <option value="NiceToHave">{{ t('wishlist.priority.NiceToHave') }}</option> | |
| 134 | + <option value="Optional">{{ t('wishlist.priority.Optional') }}</option> | |
| 135 | + </select> | |
| 136 | + </div> | |
| 137 | + </div> | |
| 138 | + | |
| 139 | + <div class="mb-3"> | |
| 140 | + <label for="estimatedCost" class="form-label">{{ t('wishlist.create.estimatedCost') }}</label> | |
| 141 | + <input | |
| 142 | + id="estimatedCost" | |
| 143 | + v-model.number="estimatedCost" | |
| 144 | + type="number" | |
| 145 | + step="0.01" | |
| 146 | + min="0" | |
| 147 | + class="form-control" | |
| 148 | + placeholder="0.00" | |
| 149 | + /> | |
| 150 | + </div> | |
| 151 | + | |
| 152 | + <div class="mb-3"> | |
| 153 | + <label for="url" class="form-label">{{ t('wishlist.create.url') }}</label> | |
| 154 | + <input id="url" v-model="url" type="url" class="form-control" placeholder="https://..." /> | |
| 155 | + </div> | |
| 156 | + | |
| 157 | + <div class="mb-4"> | |
| 158 | + <label for="location" class="form-label">{{ t('wishlist.create.location') }}</label> | |
| 159 | + <input id="location" v-model="location" type="text" class="form-control" :placeholder="t('wishlist.create.locationPlaceholder')" /> | |
| 160 | + </div> | |
| 161 | + | |
| 162 | + <div class="d-flex gap-2"> | |
| 163 | + <button | |
| 164 | + type="button" | |
| 165 | + class="sa-btn sa-btn-danger sa-btn-sm" | |
| 166 | + @click="handleDelete" | |
| 167 | + > | |
| 168 | + <i class="bi bi-trash3"></i> {{ t('common.delete') }} | |
| 169 | + </button> | |
| 170 | + <div class="flex-grow-1"></div> | |
| 171 | + <button | |
| 172 | + type="button" | |
| 173 | + class="sa-btn sa-btn-ghost" | |
| 174 | + @click="router.push({ name: 'WishlistIndex', params: { tripId } })" | |
| 175 | + > | |
| 176 | + {{ t('common.cancel') }} | |
| 177 | + </button> | |
| 178 | + <button | |
| 179 | + type="submit" | |
| 180 | + class="sa-btn sa-btn-primary" | |
| 181 | + :class="{ 'sa-btn-loading': isSaving }" | |
| 182 | + :disabled="isSaving" | |
| 183 | + > | |
| 184 | + {{ isSaving ? t('wishlist.create.saving') : t('common.saveChanges') }} | |
| 185 | + </button> | |
| 186 | + </div> | |
| 187 | + </form> | |
| 188 | + </div> | |
| 189 | + </div> | |
| 190 | + </div> | |
| 191 | + </div> | |
| 192 | +</template> |
added src/views/wishlist/IndexView.vue +286 −0
| @@ -0,0 +1,286 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import { ref, onMounted, computed, inject, type ComputedRef } from 'vue' | |
| 3 | +import { useRouter, useRoute } from 'vue-router' | |
| 4 | +import { useI18n } from 'vue-i18n' | |
| 5 | +import WishlistService from '@/services/WishlistService' | |
| 6 | +import type { IWishlistItem } from '@/types/IWishlist' | |
| 7 | +import { useToast } from '@/composables/useToast' | |
| 8 | +import { formatCurrency } from '@/utils/formatCurrency' | |
| 9 | + | |
| 10 | +const { t } = useI18n() | |
| 11 | + | |
| 12 | +const _tripCurrencySymbol = inject<ComputedRef<string>>('tripCurrencySymbol') | |
| 13 | +const tripCurrencySymbol = computed(() => _tripCurrencySymbol?.value ?? '') | |
| 14 | + | |
| 15 | +const _currentUserId = inject<ComputedRef<string | null>>('currentUserId') | |
| 16 | +const currentUserId = computed(() => _currentUserId?.value ?? null) | |
| 17 | + | |
| 18 | +const router = useRouter() | |
| 19 | +const route = useRoute() | |
| 20 | +const toast = useToast() | |
| 21 | + | |
| 22 | +const items = ref<IWishlistItem[]>([]) | |
| 23 | +const isLoading = ref(true) | |
| 24 | +const error = ref<string | null>(null) | |
| 25 | + | |
| 26 | +const tripId = route.params.tripId as string | |
| 27 | + | |
| 28 | +const sortedItems = computed(() => | |
| 29 | + [...items.value].sort((a, b) => { | |
| 30 | + if (a.isCompleted !== b.isCompleted) return a.isCompleted ? 1 : -1 | |
| 31 | + return b.voteCount - a.voteCount | |
| 32 | + }), | |
| 33 | +) | |
| 34 | + | |
| 35 | +onMounted(async () => { | |
| 36 | + await loadItems() | |
| 37 | +}) | |
| 38 | + | |
| 39 | +async function loadItems() { | |
| 40 | + isLoading.value = true | |
| 41 | + const result = await WishlistService.getByTrip(tripId) | |
| 42 | + if (result.data) { | |
| 43 | + items.value = result.data | |
| 44 | + } else if (result.errors) { | |
| 45 | + error.value = result.errors.join(', ') | |
| 46 | + } | |
| 47 | + isLoading.value = false | |
| 48 | +} | |
| 49 | + | |
| 50 | +async function toggleVote(item: IWishlistItem) { | |
| 51 | + error.value = null | |
| 52 | + const result = await WishlistService.vote(item.id) | |
| 53 | + if (result.errors) { | |
| 54 | + toast.error(result.errors.join(', ')) | |
| 55 | + } else { | |
| 56 | + await loadItems() | |
| 57 | + } | |
| 58 | +} | |
| 59 | + | |
| 60 | +async function markComplete(id: string) { | |
| 61 | + error.value = null | |
| 62 | + const result = await WishlistService.complete(id) | |
| 63 | + if (result.errors) { | |
| 64 | + toast.error(result.errors.join(', ')) | |
| 65 | + } else { | |
| 66 | + toast.success(t('wishlist.index.completed')) | |
| 67 | + await loadItems() | |
| 68 | + } | |
| 69 | +} | |
| 70 | + | |
| 71 | +async function markUncomplete(id: string) { | |
| 72 | + error.value = null | |
| 73 | + const result = await WishlistService.complete(id) | |
| 74 | + if (result.errors) { | |
| 75 | + toast.error(result.errors.join(', ')) | |
| 76 | + } else { | |
| 77 | + toast.success(t('wishlist.index.uncompleted')) | |
| 78 | + await loadItems() | |
| 79 | + } | |
| 80 | +} | |
| 81 | + | |
| 82 | +async function deleteItem(id: string) { | |
| 83 | + if (!confirm(t('wishlist.index.confirmDelete'))) return | |
| 84 | + error.value = null | |
| 85 | + const result = await WishlistService.delete(id) | |
| 86 | + if (result.errors) { | |
| 87 | + toast.error(result.errors.join(', ')) | |
| 88 | + } else { | |
| 89 | + items.value = items.value.filter((i) => i.id !== id) | |
| 90 | + toast.success(t('wishlist.index.deleted')) | |
| 91 | + } | |
| 92 | +} | |
| 93 | + | |
| 94 | +function categoryStripeStyle(category: string): Record<string, string> { | |
| 95 | + switch (category) { | |
| 96 | + case 'Place': | |
| 97 | + return { height: '4px', background: 'linear-gradient(90deg, #3b82f6, #2176ae)' } | |
| 98 | + case 'Activity': | |
| 99 | + return { height: '4px', background: 'linear-gradient(90deg, #22c55e, #16a34a)' } | |
| 100 | + case 'Restaurant': | |
| 101 | + return { height: '4px', background: 'linear-gradient(90deg, #f97316, #ea580c)' } | |
| 102 | + default: | |
| 103 | + return { height: '4px', background: 'linear-gradient(90deg, var(--sa-gray-300), var(--sa-gray-400))' } | |
| 104 | + } | |
| 105 | +} | |
| 106 | + | |
| 107 | +function categoryBadgeClass(category: string): string { | |
| 108 | + switch (category) { | |
| 109 | + case 'Place': return 'sa-badge-info' | |
| 110 | + case 'Activity': return 'sa-badge-success' | |
| 111 | + case 'Restaurant': return 'sa-badge-accent' | |
| 112 | + default: return 'sa-badge-neutral' | |
| 113 | + } | |
| 114 | +} | |
| 115 | + | |
| 116 | +function priorityBadgeClass(priority: string): string { | |
| 117 | + return priority === 'MustDo' ? 'sa-badge-danger' : 'sa-badge-info' | |
| 118 | +} | |
| 119 | + | |
| 120 | +function categoryLabel(category: string): string { | |
| 121 | + const key = `wishlist.category.${category}` | |
| 122 | + const translated = t(key) | |
| 123 | + return translated === key ? category : translated | |
| 124 | +} | |
| 125 | + | |
| 126 | +function priorityLabel(priority: string): string { | |
| 127 | + const key = `wishlist.priority.${priority}` | |
| 128 | + const translated = t(key) | |
| 129 | + return translated === key ? priority : translated | |
| 130 | +} | |
| 131 | +</script> | |
| 132 | + | |
| 133 | +<template> | |
| 134 | + <div> | |
| 135 | + <!-- Gradient Header --> | |
| 136 | + <div class="sa-gradient-header sa-gradient-header-accent d-flex justify-content-between align-items-center"> | |
| 137 | + <div> | |
| 138 | + <h3 class="mb-1"><i class="bi bi-stars me-2"></i>{{ t('wishlist.index.title') }}</h3> | |
| 139 | + <p v-if="!isLoading && items.length > 0" class="mb-0 text-muted" style="font-size: 0.9rem"> | |
| 140 | + {{ t('wishlist.index.itemCount', { n: items.length }, items.length) }} | |
| 141 | + </p> | |
| 142 | + </div> | |
| 143 | + <button | |
| 144 | + class="sa-btn sa-btn-pill sa-hide-mobile" | |
| 145 | + style="background: #fff; color: var(--sa-accent-dark)" | |
| 146 | + @click="router.push({ name: 'WishlistCreate', params: { tripId } })" | |
| 147 | + > | |
| 148 | + <i class="bi bi-plus-lg"></i> {{ t('wishlist.index.addItem') }} | |
| 149 | + </button> | |
| 150 | + </div> | |
| 151 | + | |
| 152 | + <div v-if="error" class="alert alert-danger">{{ error }}</div> | |
| 153 | + | |
| 154 | + <!-- Loading --> | |
| 155 | + <div v-if="isLoading" class="text-center py-5"> | |
| 156 | + <div class="spinner-border" style="color: var(--sa-accent)" role="status"></div> | |
| 157 | + </div> | |
| 158 | + | |
| 159 | + <!-- Empty State --> | |
| 160 | + <div v-else-if="sortedItems.length === 0" class="sa-empty"> | |
| 161 | + <div class="sa-empty-icon"><i class="bi bi-stars"></i></div> | |
| 162 | + <div class="sa-empty-title">{{ t('wishlist.index.emptyTitle') }}</div> | |
| 163 | + <div class="sa-empty-text">{{ t('wishlist.index.emptyText') }}</div> | |
| 164 | + <button | |
| 165 | + class="sa-btn sa-btn-primary sa-btn-pill" | |
| 166 | + @click="router.push({ name: 'WishlistCreate', params: { tripId } })" | |
| 167 | + > | |
| 168 | + <i class="bi bi-plus-lg"></i> {{ t('wishlist.index.addItem') }} | |
| 169 | + </button> | |
| 170 | + </div> | |
| 171 | + | |
| 172 | + <!-- Card Grid --> | |
| 173 | + <div v-else class="row g-4"> | |
| 174 | + <div v-for="item in sortedItems" :key="item.id" class="col-md-6 col-lg-4"> | |
| 175 | + <div class="sa-card d-flex flex-column h-100"> | |
| 176 | + <!-- Category Color Stripe --> | |
| 177 | + <div :style="categoryStripeStyle(item.category)"></div> | |
| 178 | + | |
| 179 | + <!-- Card Body --> | |
| 180 | + <div class="sa-card-body flex-grow-1"> | |
| 181 | + <h5 | |
| 182 | + class="mb-2" | |
| 183 | + :style="item.isCompleted ? 'text-decoration: line-through; opacity: 0.6' : ''" | |
| 184 | + > | |
| 185 | + {{ item.title }} | |
| 186 | + </h5> | |
| 187 | + | |
| 188 | + <p | |
| 189 | + v-if="item.description" | |
| 190 | + class="mb-3" | |
| 191 | + style="font-size: 0.875rem; color: var(--sa-gray-500)" | |
| 192 | + :style="item.isCompleted ? 'opacity: 0.6' : ''" | |
| 193 | + > | |
| 194 | + {{ item.description }} | |
| 195 | + </p> | |
| 196 | + | |
| 197 | + <!-- Badges --> | |
| 198 | + <div class="d-flex flex-wrap gap-1 mb-3"> | |
| 199 | + <span class="sa-badge" :class="categoryBadgeClass(item.category)"> | |
| 200 | + {{ categoryLabel(item.category) }} | |
| 201 | + </span> | |
| 202 | + <span class="sa-badge" :class="priorityBadgeClass(item.priority)"> | |
| 203 | + {{ priorityLabel(item.priority) }} | |
| 204 | + </span> | |
| 205 | + <span v-if="item.estimatedCost" class="sa-badge sa-badge-neutral"> | |
| 206 | + {{ formatCurrency(item.estimatedCost, tripCurrencySymbol) }} | |
| 207 | + </span> | |
| 208 | + </div> | |
| 209 | + | |
| 210 | + <!-- Location --> | |
| 211 | + <div v-if="item.location" class="mb-1" style="font-size: 0.813rem; color: var(--sa-gray-500)"> | |
| 212 | + <i class="bi bi-geo-alt me-1"></i>{{ item.location }} | |
| 213 | + </div> | |
| 214 | + | |
| 215 | + <!-- URL --> | |
| 216 | + <div v-if="item.url" class="mb-2" style="font-size: 0.813rem"> | |
| 217 | + <i class="bi bi-link-45deg me-1" style="color: var(--sa-gray-400)"></i> | |
| 218 | + <a :href="item.url" target="_blank" rel="noopener noreferrer" style="color: var(--sa-secondary)"> | |
| 219 | + {{ item.url.replace(/^https?:\/\//, '').substring(0, 30) }}{{ item.url.replace(/^https?:\/\//, '').length > 30 ? '...' : '' }} | |
| 220 | + </a> | |
| 221 | + </div> | |
| 222 | + | |
| 223 | + <!-- Added by --> | |
| 224 | + <small style="color: var(--sa-gray-400); font-size: 0.75rem"> | |
| 225 | + {{ t('wishlist.index.addedBy', { name: item.addedByUserName || t('common.unknown') }) }} | |
| 226 | + </small> | |
| 227 | + </div> | |
| 228 | + | |
| 229 | + <!-- Card Footer --> | |
| 230 | + <div class="sa-card-footer d-flex align-items-center justify-content-between"> | |
| 231 | + <button | |
| 232 | + class="sa-vote-btn" | |
| 233 | + :class="{ 'sa-vote-btn-active': item.userHasVoted }" | |
| 234 | + @click="toggleVote(item)" | |
| 235 | + > | |
| 236 | + <i :class="item.userHasVoted ? 'bi bi-heart-fill' : 'bi bi-heart'"></i> | |
| 237 | + {{ item.voteCount }} | |
| 238 | + </button> | |
| 239 | + <div class="d-flex gap-1"> | |
| 240 | + <button | |
| 241 | + v-if="!item.isCompleted" | |
| 242 | + class="sa-btn sa-btn-success sa-btn-sm" | |
| 243 | + @click="markComplete(item.id)" | |
| 244 | + > | |
| 245 | + {{ t('wishlist.index.complete') }} | |
| 246 | + </button> | |
| 247 | + <button | |
| 248 | + v-if="item.isCompleted" | |
| 249 | + class="sa-btn sa-btn-ghost sa-btn-sm" | |
| 250 | + @click="markUncomplete(item.id)" | |
| 251 | + > | |
| 252 | + {{ t('wishlist.index.undo') }} | |
| 253 | + </button> | |
| 254 | + <button | |
| 255 | + v-if="currentUserId === item.addedByUserId" | |
| 256 | + class="sa-btn sa-btn-ghost sa-btn-icon sa-btn-sm" | |
| 257 | + :title="t('common.edit')" | |
| 258 | + @click="router.push({ name: 'WishlistEdit', params: { tripId, id: item.id } })" | |
| 259 | + > | |
| 260 | + <i class="bi bi-pencil"></i> | |
| 261 | + </button> | |
| 262 | + <button | |
| 263 | + v-if="currentUserId === item.addedByUserId" | |
| 264 | + class="sa-btn sa-btn-ghost sa-btn-icon sa-btn-sm" | |
| 265 | + style="color: var(--sa-danger)" | |
| 266 | + :title="t('common.delete')" | |
| 267 | + @click="deleteItem(item.id)" | |
| 268 | + > | |
| 269 | + <i class="bi bi-trash3"></i> | |
| 270 | + </button> | |
| 271 | + </div> | |
| 272 | + </div> | |
| 273 | + </div> | |
| 274 | + </div> | |
| 275 | + </div> | |
| 276 | + | |
| 277 | + <!-- Mobile FAB --> | |
| 278 | + <button | |
| 279 | + class="sa-fab" | |
| 280 | + style="background: linear-gradient(135deg, var(--sa-accent) 0%, #f97316 100%); box-shadow: 0 4px 16px rgba(244, 166, 35, 0.4)" | |
| 281 | + @click="router.push({ name: 'WishlistCreate', params: { tripId } })" | |
| 282 | + > | |
| 283 | + <i class="bi bi-plus-lg"></i> | |
| 284 | + </button> | |
| 285 | + </div> | |
| 286 | +</template> |
added tsconfig.app.json +18 −0
| @@ -0,0 +1,18 @@ | ||
| 1 | +{ | |
| 2 | + "extends": "@vue/tsconfig/tsconfig.dom.json", | |
| 3 | + "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], | |
| 4 | + "exclude": ["src/**/__tests__/**/*"], | |
| 5 | + "compilerOptions": { | |
| 6 | + // Extra safety for array and object lookups, but may have false positives. | |
| 7 | + "noUncheckedIndexedAccess": true, | |
| 8 | + | |
| 9 | + // Path mapping for cleaner imports. | |
| 10 | + "paths": { | |
| 11 | + "@/*": ["./src/*"] | |
| 12 | + }, | |
| 13 | + | |
| 14 | + // `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking. | |
| 15 | + // Specified here to keep it out of the root directory. | |
| 16 | + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo" | |
| 17 | + } | |
| 18 | +} |
added tsconfig.json +14 −0
| @@ -0,0 +1,14 @@ | ||
| 1 | +{ | |
| 2 | + "files": [], | |
| 3 | + "references": [ | |
| 4 | + { | |
| 5 | + "path": "./tsconfig.node.json" | |
| 6 | + }, | |
| 7 | + { | |
| 8 | + "path": "./tsconfig.app.json" | |
| 9 | + }, | |
| 10 | + { | |
| 11 | + "path": "./tsconfig.vitest.json" | |
| 12 | + } | |
| 13 | + ] | |
| 14 | +} |
added tsconfig.node.json +27 −0
| @@ -0,0 +1,27 @@ | ||
| 1 | +// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping. | |
| 2 | +{ | |
| 3 | + "extends": "@tsconfig/node24/tsconfig.json", | |
| 4 | + "include": [ | |
| 5 | + "vite.config.*", | |
| 6 | + "vitest.config.*", | |
| 7 | + "cypress.config.*", | |
| 8 | + "playwright.config.*", | |
| 9 | + "eslint.config.*" | |
| 10 | + ], | |
| 11 | + "compilerOptions": { | |
| 12 | + // Most tools use transpilation instead of Node.js's native type-stripping. | |
| 13 | + // Bundler mode provides a smoother developer experience. | |
| 14 | + "module": "preserve", | |
| 15 | + "moduleResolution": "bundler", | |
| 16 | + | |
| 17 | + // Include Node.js types and avoid accidentally including other `@types/*` packages. | |
| 18 | + "types": ["node"], | |
| 19 | + | |
| 20 | + // Disable emitting output during `vue-tsc --build`, which is used for type-checking only. | |
| 21 | + "noEmit": true, | |
| 22 | + | |
| 23 | + // `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking. | |
| 24 | + // Specified here to keep it out of the root directory. | |
| 25 | + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo" | |
| 26 | + } | |
| 27 | +} |
added tsconfig.vitest.json +19 −0
| @@ -0,0 +1,19 @@ | ||
| 1 | +{ | |
| 2 | + "extends": "./tsconfig.app.json", | |
| 3 | + | |
| 4 | + // Override to include only test files and clear exclusions. | |
| 5 | + // Application code imported in tests is automatically included via module resolution. | |
| 6 | + "include": ["src/**/__tests__/**/*", "env.d.ts"], | |
| 7 | + "exclude": [], | |
| 8 | + | |
| 9 | + "compilerOptions": { | |
| 10 | + // Vitest runs in a different environment than the application code. | |
| 11 | + // Adjust lib and types accordingly. | |
| 12 | + "lib": [], | |
| 13 | + "types": ["node", "jsdom"], | |
| 14 | + | |
| 15 | + // `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking. | |
| 16 | + // Specified here to keep it out of the root directory. | |
| 17 | + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo" | |
| 18 | + } | |
| 19 | +} |
added vite.config.ts +18 −0
| @@ -0,0 +1,18 @@ | ||
| 1 | +import { fileURLToPath, URL } from 'node:url' | |
| 2 | + | |
| 3 | +import { defineConfig } from 'vite' | |
| 4 | +import vue from '@vitejs/plugin-vue' | |
| 5 | +import vueDevTools from 'vite-plugin-vue-devtools' | |
| 6 | + | |
| 7 | +// https://vite.dev/config/ | |
| 8 | +export default defineConfig({ | |
| 9 | + plugins: [ | |
| 10 | + vue(), | |
| 11 | + vueDevTools(), | |
| 12 | + ], | |
| 13 | + resolve: { | |
| 14 | + alias: { | |
| 15 | + '@': fileURLToPath(new URL('./src', import.meta.url)) | |
| 16 | + }, | |
| 17 | + }, | |
| 18 | +}) |
added vitest.config.ts +15 −0
| @@ -0,0 +1,15 @@ | ||
| 1 | +import { fileURLToPath } from 'node:url' | |
| 2 | +import { mergeConfig, defineConfig, configDefaults } from 'vitest/config' | |
| 3 | +import viteConfig from './vite.config' | |
| 4 | + | |
| 5 | +export default mergeConfig( | |
| 6 | + viteConfig, | |
| 7 | + defineConfig({ | |
| 8 | + test: { | |
| 9 | + environment: 'jsdom', | |
| 10 | + exclude: [...configDefaults.exclude, 'e2e/**'], | |
| 11 | + root: fileURLToPath(new URL('./', import.meta.url)), | |
| 12 | + setupFiles: ['./src/__tests__/vitest.setup.ts'], | |
| 13 | + }, | |
| 14 | + }), | |
| 15 | +) |