admin felület különválasztva
132
frontend_app/.gitignore
vendored
Normal file
@@ -0,0 +1,132 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# Vite
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
31
frontend_app/Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy nginx configuration
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
# Copy built assets from builder stage
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Expose port 80
|
||||
EXPOSE 80
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
19
frontend_app/Dockerfile.dev
Normal file
@@ -0,0 +1,19 @@
|
||||
# Development Dockerfile for Vue.js Vite frontend
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Expose Vite development port (default 5173)
|
||||
EXPOSE 5173
|
||||
|
||||
# Start development server with host binding for hot-reload
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
132
frontend_app/README.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Service Finder Frontend
|
||||
|
||||
Modern Vue 3 frontend for the Service Finder platform with dual layout system (B2B/B2C), internationalization, and Docker deployment.
|
||||
|
||||
## 🚀 Tech Stack
|
||||
|
||||
- **Vue 3** (Composition API)
|
||||
- **TypeScript** for type safety
|
||||
- **Vite** for fast development and builds
|
||||
- **Pinia** for state management
|
||||
- **Vue Router** for navigation
|
||||
- **Vue I18n** for translations
|
||||
- **Tailwind CSS** for styling
|
||||
- **Axios** for HTTP requests
|
||||
- **Docker** (multi-stage) + **Nginx** for production
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── assets/ # Styles, images, fonts
|
||||
│ ├── components/ # Reusable Vue components
|
||||
│ ├── layouts/ # Layout components (Corporate, Consumer)
|
||||
│ ├── router/ # Vue Router configuration
|
||||
│ ├── stores/ # Pinia stores (app mode, translations)
|
||||
│ ├── services/ # API services (translation, auth)
|
||||
│ ├── views/ # Page components (Home, About)
|
||||
│ ├── locales/ # Translation files (future)
|
||||
│ ├── App.vue # Root component
|
||||
│ └── main.ts # Application entry point
|
||||
├── public/ # Static assets
|
||||
├── Dockerfile # Multi-stage Docker build
|
||||
├── nginx.conf # Nginx configuration with API proxy
|
||||
├── vite.config.ts # Vite configuration
|
||||
├── tailwind.config.js # Tailwind CSS configuration
|
||||
├── tsconfig.json # TypeScript configuration
|
||||
└── package.json # Dependencies and scripts
|
||||
```
|
||||
|
||||
## 🎯 Features
|
||||
|
||||
### 1. **Dual Layout System**
|
||||
- **CorporateLayout**: Clean, data-oriented design for B2B users (fleet management)
|
||||
- **ConsumerLayout**: Playful, bento-grid design for B2C users (individual vehicle owners)
|
||||
- Dynamic switching via Pinia store
|
||||
|
||||
### 2. **Internationalization (i18n)**
|
||||
- Integrated vue-i18n with backend translation API
|
||||
- Language switcher component
|
||||
- Fallback to English
|
||||
- Translation loading from `/api/v1/translations`
|
||||
|
||||
### 3. **App Mode Store (Pinia)**
|
||||
- Centralized state for app mode (B2B/B2C) and language
|
||||
- Persistent storage in localStorage
|
||||
- Reactive computed properties
|
||||
|
||||
### 4. **Docker Deployment**
|
||||
- Multi-stage build (Node 20 + Nginx Alpine)
|
||||
- Nginx reverse proxy to backend API (`sf_api:8000`)
|
||||
- Health check endpoint
|
||||
- Optimized production image (~50MB)
|
||||
|
||||
### 5. **Development Proxy**
|
||||
- Vite dev server proxies `/api` requests to backend
|
||||
- Hot module replacement (HMR)
|
||||
- TypeScript support
|
||||
|
||||
## 🐳 Docker Commands
|
||||
|
||||
### Build the image
|
||||
```bash
|
||||
docker build -t service-finder-frontend .
|
||||
```
|
||||
|
||||
### Run locally (with network)
|
||||
```bash
|
||||
docker run -p 8080:80 --network sf_net service-finder-frontend
|
||||
```
|
||||
|
||||
### Development (with hot reload)
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Environment Variables
|
||||
Create `.env` file:
|
||||
```env
|
||||
VITE_API_BASE=/api
|
||||
```
|
||||
|
||||
### Nginx Proxy
|
||||
All `/api/*` requests are proxied to `http://sf_api:8000` (internal Docker network).
|
||||
|
||||
### Tailwind CSS
|
||||
Custom colors for corporate (`corporate-blue`) and consumer (`consumer-orange`, `consumer-teal`, `consumer-purple`) themes.
|
||||
|
||||
## 📦 Scripts
|
||||
|
||||
- `npm run dev` – Start development server
|
||||
- `npm run build` – Build for production
|
||||
- `npm run preview` – Preview production build
|
||||
- `npm run lint` – Lint code with ESLint
|
||||
|
||||
## 🔗 Backend Integration
|
||||
|
||||
The frontend expects the following backend endpoints:
|
||||
|
||||
- `GET /api/v1/translations?lang={code}` – Fetch translations
|
||||
- `GET /api/v1/translations/all` – Fetch all translations
|
||||
- `POST /api/v1/translations` – Update translations (admin)
|
||||
|
||||
## 🎨 Design System
|
||||
|
||||
### Corporate Theme
|
||||
- Colors: Blue (#1e3a8a), Gray (#6b7280), Light (#f9fafb)
|
||||
- Font: Inter (sans-serif)
|
||||
- Layout: Sidebar navigation, data tables, analytics
|
||||
|
||||
### Consumer Theme
|
||||
- Colors: Orange (#f97316), Teal (#0d9488), Purple (#7c3aed)
|
||||
- Font: Inter (sans-serif) with playful elements
|
||||
- Layout: Bento grid, cards, gradient backgrounds
|
||||
|
||||
## 📄 License
|
||||
|
||||
Proprietary – Service Finder Platform
|
||||
14
frontend_app/docker-compose.yml
Normal file
@@ -0,0 +1,14 @@
|
||||
# docker-compose.yml (Frontend kiegészítés a meglévő mellé)
|
||||
services:
|
||||
sf_frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: sf_frontend
|
||||
networks:
|
||||
- sf_net
|
||||
ports:
|
||||
- "8503:80" # A fejlesztői gépeden ezen éred el
|
||||
depends_on:
|
||||
- sf_api
|
||||
restart: unless-stopped
|
||||
17
frontend_app/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Service Finder | Modern Vehicle Service Platform</title>
|
||||
<meta name="description" content="Find trusted service providers for your vehicle with transparent pricing and reviews.">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Fira+Code:wght@400;500&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
51
frontend_app/nginx.conf
Normal file
@@ -0,0 +1,51 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# Logging
|
||||
access_log /var/log/nginx/access.log;
|
||||
error_log /var/log/nginx/error.log;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Serve static files
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy API requests to backend
|
||||
location /api/ {
|
||||
proxy_pass http://sf_api:8000/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket support if needed
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
}
|
||||
}
|
||||
4835
frontend_app/package-lock.json
generated
Executable file
35
frontend_app/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "service-finder-frontend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fingerprintjs/fingerprintjs": "^5.2.0",
|
||||
"@lucide/vue": "^1.17.0",
|
||||
"axios": "^1.6.0",
|
||||
"flag-icons": "^7.5.0",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.0",
|
||||
"vue-i18n": "^9.11.0",
|
||||
"vue-router": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"@vue/eslint-config-typescript": "^12.0.0",
|
||||
"@vue/tsconfig": "^0.5.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-vue": "^9.19.0",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "~5.3.0",
|
||||
"vite": "^5.0.0",
|
||||
"vue-tsc": "^1.8.0"
|
||||
}
|
||||
}
|
||||
11
frontend_app/playwright.remote.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
export default defineConfig({
|
||||
timeout: 60000,
|
||||
use: {
|
||||
connectOptions: {
|
||||
// faktor01 Tailscale IP - Browserless headless Chrome service
|
||||
wsEndpoint: 'ws://100.93.27.29:3030',
|
||||
},
|
||||
screenshot: 'on',
|
||||
},
|
||||
});
|
||||
6
frontend_app/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
frontend_app/public/SF_logo.png
Normal file
|
After Width: | Height: | Size: 4.2 MiB |
BIN
frontend_app/public/garage1.png
Normal file
|
After Width: | Height: | Size: 7.1 MiB |
BIN
frontend_app/public/garage2.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
frontend_app/public/garage_clean.png
Normal file
|
After Width: | Height: | Size: 7.0 MiB |
BIN
frontend_app/public/images/sf_landing_back.png
Normal file
|
After Width: | Height: | Size: 7.0 MiB |
BIN
frontend_app/public/logo.png
Normal file
|
After Width: | Height: | Size: 4.2 MiB |
BIN
frontend_app/public/sf_alarm_light_00.png
Normal file
|
After Width: | Height: | Size: 459 KiB |
BIN
frontend_app/public/sf_alarm_light_01.png
Normal file
|
After Width: | Height: | Size: 570 KiB |
BIN
frontend_app/public/sf_card.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
frontend_app/public/sf_logo_ok.png
Normal file
|
After Width: | Height: | Size: 4.9 MiB |
BIN
frontend_app/public/sf_mobile_garage_1.png
Normal file
|
After Width: | Height: | Size: 6.0 MiB |
BIN
frontend_app/public/sf_naptar.png
Normal file
|
After Width: | Height: | Size: 2.9 MiB |
15
frontend_app/src/App.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
onMounted(() => {
|
||||
// Restore session from localStorage token on app start
|
||||
authStore.init()
|
||||
})
|
||||
</script>
|
||||
37
frontend_app/src/api/axios.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// Request interceptor: attach JWT token from localStorage
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// Response interceptor: handle 401 Unauthorized (expired/invalid token)
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Token expired or invalid — clear it
|
||||
localStorage.removeItem('access_token')
|
||||
// Optionally redirect to login could be added here later
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default api
|
||||
BIN
frontend_app/src/assets/garage-bg.png
Normal file
|
After Width: | Height: | Size: 5.6 MiB |
99
frontend_app/src/assets/logo copy.svg
Normal file
@@ -0,0 +1,99 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="623"
|
||||
height="469"
|
||||
viewBox="0 0 1600 1204"
|
||||
version="1.1"
|
||||
id="svg440"
|
||||
sodipodi:docname="SF_csak_logo_ (1).svg"
|
||||
inkscape:version="1.4.3 (0d15f75, 2025-12-25)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs440" />
|
||||
<sodipodi:namedview
|
||||
id="namedview440"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="1.7206823"
|
||||
inkscape:cx="390.83333"
|
||||
inkscape:cy="234.5"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1009"
|
||||
inkscape:window-x="1912"
|
||||
inkscape:window-y="646"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg440" />
|
||||
<path
|
||||
fill="#418890"
|
||||
d="m 1200,296 16,8 20,11 22,14 17,12 16,13 12,11 21,21 9,11 12,15 13,19 9,14 10,18 13,27 6,16 11,30 6,25 5,31 2,30 v 36 l -1,21 -4,29 -5,24 -8,26 -8,21 -13,31 -10,18 -20,30 -3,6 1,5 9,10 13,16 8,5 3,1 2,-4 5,-1 h 11 l 13,5 13,10 11,12 11,11 7,8 25,25 7,8 7,7 7,8 18,18 8,7 9,9 v 2 h 2 l 7,8 9,9 7,8 11,11 7,8 7,10 8,17 2,10 v 15 l -3,12 -4,9 -6,10 -9,10 -10,9 -16,8 -9,2 h -23 l -13,-4 -14,-7 -13,-11 -11,-11 -7,-8 -11,-12 -9,-10 -7,-7 -7,-8 -13,-14 -7,-8 -14,-15 -16,-17 -7,-7 -7,-8 -15,-15 v -2 l -4,-2 -8,-8 -10,-13 -4,-10 -1,-23 -5,-8 -9,-10 -7,-8 -7,-6 -6,1 -15,9 -18,12 -19,11 -26,13 -26,10 -29,8 -21,4 -21,2 h -49 l -26,-4 -12,-1 -18,14 -16,12 -17,11 -25,14 -23,11 -26,10 -28,9 -50,11 -4,2 -13,40 -13,37 -10,27 -2,6 h -2 l -13,-35 -24,-71 -3,-6 v -2 h -11 l -26,-4 -19,-5 -38,-13 -25,-11 -20,-10 -3,-3 16,-12 16,-13 11,-9 13,-11 5,-4 6,2 16,6 26,8 24,5 22,3 12,1 h 24 l 23,-2 29,-5 21,-5 36,-12 20,-8 20,-10 5,-3 -1,-3 -22,-11 -18,-11 -12,-9 -11,-9 -24,-22 -7,-8 -9,-10 -2,-5 9,-9 8,-7 9,-11 12,-17 4,-7 7,6 13,13 11,9 13,11 20,14 19,11 16,8 25,10 24,7 19,4 15,2 h 40 l 26,-3 22,-5 24,-8 21,-9 19,-10 19,-12 17,-12 14,-12 9,-9 h 2 l 2,-4 8,-8 15,-20 12,-19 13,-24 11,-27 10,-34 6,-31 2,-16 v -52 l -4,-27 -6,-24 -10,-30 -9,-21 -11,-20 -13,-21 -13,-17 -12,-14 -17,-17 -11,-9 -13,-11 -18,-13 -22,-13 -25,-12 -1,-3 10,-18 13,-21 z"
|
||||
id="path2"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#13597b"
|
||||
d="m 658,427 19,1 26,2 4,2 20,4 43,13 2,1 -1,4 -12,10 -14,11 -11,10 -8,7 -10,9 -16,15 -2,2 -17,-1 h -34 l -15,3 -13,5 -13,7 -9,8 -6,10 -3,9 v 11 l 4,12 9,10 12,7 20,8 32,8 43,10 25,7 18,6 20,9 14,8 13,10 9,8 9,11 9,13 8,18 4,15 2,10 1,13 v 13 l -2,18 -4,18 -6,16 -6,11 -10,13 -12,13 -14,11 -17,10 -21,9 -25,9 -25,5 -18,2 -20,1 h -23 l -24,-2 -38,-6 -48,-16 -23,-10 -17,-10 -24,-16 1,-4 41,-82 8,6 17,12 18,10 16,8 21,8 27,7 24,4 h 35 l 19,-3 16,-5 10,-5 8,-7 6,-12 2,-8 v -8 l -3,-12 -6,-10 -9,-8 -16,-8 -26,-8 -43,-10 -42,-12 -22,-8 -25,-12 -15,-10 -11,-9 -10,-10 -9,-14 -7,-15 -5,-16 -2,-10 -1,-13 v -12 l 3,-22 6,-20 7,-14 12,-17 9,-11 8,-8 17,-13 21,-12 24,-10 22,-6 23,-4 z"
|
||||
id="path3"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#418890"
|
||||
d="m 775,90 2,1 12,36 14,41 12,35 23,4 31,5 24,7 36,12 23,10 32,16 20,12 15,10 -5,5 -16,10 -23,15 -18,11 -6,-1 -22,-12 -19,-8 -27,-9 -37,-10 -21,-4 -13,-2 -14,-1 h -47 l -27,3 -30,6 -31,9 -25,9 -24,11 -19,10 -19,12 -19,14 -13,11 -12,11 -28,28 -11,14 -15,20 -11,17 -14,24 -13,25 -11,29 -9,29 -8,36 -3,25 -1,14 v 55 l 3,27 6,31 4,19 v 4 l -16,12 -35,23 -24,15 -42,28 -46,30 -5,2 -6,-5 1,-7 6,-10 9,-11 15,-16 22,-22 8,-7 24,-24 8,-7 12,-12 -1,-9 -4,-21 -3,-27 -5,-5 -21,-11 -27,-11 -28,-11 -21,-9 -5,-2 v -2 l 17,-8 64,-25 22,-9 3,-5 7,-39 10,-40 15,-43 9,-20 13,-25 14,-24 13,-21 12,-16 7,-8 -1,-7 -11,-24 -17,-39 -5,-10 -3,-4 1,-2 11,5 39,16 27,13 3,3 4,-2 14,-12 20,-15 29,-19 22,-13 23,-12 15,-6 27,-10 31,-9 26,-6 34,-6 4,-3 12,-36 13,-37 z"
|
||||
id="path4"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#408790"
|
||||
d="m 1241,171 1,2 -5,9 -12,19 -12,21 -8,13 -11,19 -15,24 -12,20 -31,51 -9,15 -11,18 -8,14 -16,26 -9,15 -17,28 -11,19 -10,17 -13,21 -16,27 -8,13 -11,19 -9,15 -17,29 -10,16 -10,19 -15,24 -11,18 -13,23 -9,15 -4,8 -3,1 -1,-4 -2,-27 -4,-29 -6,-25 -9,-24 -9,-22 -7,-12 -9,-12 -4,-5 1,-4 8,-10 7,1 6,2 12,1 12,-3 8,-5 3,-1 v -2 h 2 l 7,-14 1,-5 v -9 l -4,-13 -8,-10 -12,-6 -6,-1 h -11 l -9,3 -9,7 -6,9 -3,7 -1,12 4,13 1,5 -10,10 -1,2 -4,-2 -24,-16 -21,-12 -21,-10 -19,-7 -22,-6 6,-7 16,-16 22,-18 18,-13 16,-11 18,-13 33,-22 16,-11 23,-16 11,-8 22,-14 17,-12 12,-8 35,-23 19,-12 17,-12 18,-12 20,-13 18,-11 23,-15 20,-12 17,-11 25,-16 22,-14 33,-22 12,-7 z"
|
||||
id="path5"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#79b085"
|
||||
d="m 1228,180 4,1 -8,10 h -2 l -2,4 -14,15 -7,8 -31,33 -9,9 -7,8 -7,7 -7,8 -11,11 -7,8 -14,15 -67,67 -7,8 -14,15 -20,20 -7,8 -22,22 -7,8 -8,8 -7,8 -13,13 -7,8 -15,14 -4,4 -2,-1 -3,-5 h -8 l -7,-3 -6,-1 h -11 l -9,3 -9,7 -6,9 -3,7 -1,12 4,13 1,5 -10,10 -1,2 -4,-2 -24,-16 -21,-12 -21,-10 -19,-7 -22,-6 6,-7 16,-16 22,-18 18,-13 16,-11 18,-13 33,-22 16,-11 23,-16 11,-8 22,-14 17,-12 12,-8 35,-23 19,-12 17,-12 18,-12 20,-13 18,-11 23,-15 20,-12 17,-11 25,-16 22,-14 33,-22 z"
|
||||
id="path6"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#418790"
|
||||
d="m 1032,575 h 2 l 1,52 h 181 l 1,2 v 79 l -154,1 h -29 v 89 l -1,65 -8,1 h -87 l -1,-1 V 744 l 1,-14 51,-84 8,-14 12,-19 10,-17 11,-19 z"
|
||||
id="path7"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#408790"
|
||||
d="m 478,900 4,1 24,10 33,12 1,3 -24,16 -26,17 -24,15 -19,12 -32,19 -21,13 -19,11 -28,16 -24,14 -24,13 -19,10 -27,14 -22,12 -30,15 -19,9 -22,12 -30,15 -28,13 -20,9 -13,5 -8,1 -2,-1 1,-4 13,-11 14,-11 16,-13 16,-12 13,-10 48,-32 25,-15 19,-12 25,-16 40,-25 26,-17 19,-12 17,-11 19,-12 36,-24 41,-28 24,-16 z"
|
||||
id="path10"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#13597b"
|
||||
d="m 577,931 7,1 16,4 24,3 50,3 1,2 -4,6 -7,7 -11,9 -14,13 -11,9 -16,13 -9,7 -15,12 -18,13 -17,12 -20,14 -29,19 -19,12 -24,15 -24,14 -14,6 h -6 l -5,-4 -2,-4 1,-7 6,-8 8,-8 11,-9 16,-13 13,-10 11,-9 15,-12 19,-14 2,-2 -1,-4 -15,-14 -3,-3 1,-4 10,-10 17,-13 16,-13 16,-12 15,-13 z"
|
||||
id="path11"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#78b085"
|
||||
d="m 1115,443 h 125 l 3,2 v 80 l -1,1 -45,1 h -98 l -37,-1 v -3 l 4,-5 16,-27 15,-26 4,-6 3,-6 5,-4 4,-5 z"
|
||||
id="path13"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#408690"
|
||||
d="m 433,813 3,1 9,15 3,6 -2,4 1,10 1,9 -10,7 -28,19 -17,12 -15,10 -22,14 -17,12 -24,16 -13,6 h -8 l -8,-5 -4,-5 -2,-5 v -7 l 4,-10 9,-11 10,-10 20,-14 26,-17 22,-14 15,-10 19,-13 19,-14 z"
|
||||
id="path14"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#408790"
|
||||
d="m 233,945 h 7 l 6,5 2,5 -1,10 -9,10 -8,7 -18,12 -15,8 -10,3 h -11 l -8,-4 -3,-5 1,-7 4,-6 9,-9 10,-7 18,-10 23,-11 z"
|
||||
id="path20"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#0f4664"
|
||||
d="m 370,1125 h 10 l 6,5 2,5 v 8 l -4,6 -8,8 -13,8 -3,1 h -8 l -7,-6 -1,-2 v -7 l 4,-9 7,-9 11,-7 z"
|
||||
id="path32"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#3f8690"
|
||||
d="m 209,874 6,1 4,5 1,3 v 9 l -3,5 -7,7 -10,6 -3,1 h -7 l -6,-5 -1,-6 5,-10 6,-7 10,-7 z"
|
||||
id="path38"
|
||||
style="display:inline" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.1 KiB |
82
frontend_app/src/assets/logo.svg
Normal file
@@ -0,0 +1,82 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="623"
|
||||
height="469"
|
||||
viewBox="0 0 1600 1204"
|
||||
version="1.1"
|
||||
id="svg440"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs440">
|
||||
<linearGradient id="sfGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#00E5A0" />
|
||||
<stop offset="100%" stop-color="#70BC84" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
fill="url(#sfGradient)"
|
||||
d="m 1200,296 16,8 20,11 22,14 17,12 16,13 12,11 21,21 9,11 12,15 13,19 9,14 10,18 13,27 6,16 11,30 6,25 5,31 2,30 v 36 l -1,21 -4,29 -5,24 -8,26 -8,21 -13,31 -10,18 -20,30 -3,6 1,5 9,10 13,16 8,5 3,1 2,-4 5,-1 h 11 l 13,5 13,10 11,12 11,11 7,8 25,25 7,8 7,7 7,8 18,18 8,7 9,9 v 2 h 2 l 7,8 9,9 7,8 11,11 7,8 7,10 8,17 2,10 v 15 l -3,12 -4,9 -6,10 -9,10 -10,9 -16,8 -9,2 h -23 l -13,-4 -14,-7 -13,-11 -11,-11 -7,-8 -11,-12 -9,-10 -7,-7 -7,-8 -13,-14 -7,-8 -14,-15 -16,-17 -7,-7 -7,-8 -15,-15 v -2 l -4,-2 -8,-8 -10,-13 -4,-10 -1,-23 -5,-8 -9,-10 -7,-8 -7,-6 -6,1 -15,9 -18,12 -19,11 -26,13 -26,10 -29,8 -21,4 -21,2 h -49 l -26,-4 -12,-1 -18,14 -16,12 -17,11 -25,14 -23,11 -26,10 -28,9 -50,11 -4,2 -13,40 -13,37 -10,27 -2,6 h -2 l -13,-35 -24,-71 -3,-6 v -2 h -11 l -26,-4 -19,-5 -38,-13 -25,-11 -20,-10 -3,-3 16,-12 16,-13 11,-9 13,-11 5,-4 6,2 16,6 26,8 24,5 22,3 12,1 h 24 l 23,-2 29,-5 21,-5 36,-12 20,-8 20,-10 5,-3 -1,-3 -22,-11 -18,-11 -12,-9 -11,-9 -24,-22 -7,-8 -9,-10 -2,-5 9,-9 8,-7 9,-11 12,-17 4,-7 7,6 13,13 11,9 13,11 20,14 19,11 16,8 25,10 24,7 19,4 15,2 h 40 l 26,-3 22,-5 24,-8 21,-9 19,-10 19,-12 17,-12 14,-12 9,-9 h 2 l 2,-4 8,-8 15,-20 12,-19 13,-24 11,-27 10,-34 6,-31 2,-16 v -52 l -4,-27 -6,-24 -10,-30 -9,-21 -11,-20 -13,-21 -13,-17 -12,-14 -17,-17 -11,-9 -13,-11 -18,-13 -22,-13 -25,-12 -1,-3 10,-18 13,-21 z"
|
||||
id="path2"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="url(#sfGradient)"
|
||||
d="m 658,427 19,1 26,2 4,2 20,4 43,13 2,1 -1,4 -12,10 -14,11 -11,10 -8,7 -10,9 -16,15 -2,2 -17,-1 h -34 l -15,3 -13,5 -13,7 -9,8 -6,10 -3,9 v 11 l 4,12 9,10 12,7 20,8 32,8 43,10 25,7 18,6 20,9 14,8 13,10 9,8 9,11 9,13 8,18 4,15 2,10 1,13 v 13 l -2,18 -4,18 -6,16 -6,11 -10,13 -12,13 -14,11 -17,10 -21,9 -25,9 -25,5 -18,2 -20,1 h -23 l -24,-2 -38,-6 -48,-16 -23,-10 -17,-10 -24,-16 1,-4 41,-82 8,6 17,12 18,10 16,8 21,8 27,7 24,4 h 35 l 19,-3 16,-5 10,-5 8,-7 6,-12 2,-8 v -8 l -3,-12 -6,-10 -9,-8 -16,-8 -26,-8 -43,-10 -42,-12 -22,-8 -25,-12 -15,-10 -11,-9 -10,-10 -9,-14 -7,-15 -5,-16 -2,-10 -1,-13 v -12 l 3,-22 6,-20 7,-14 12,-17 9,-11 8,-8 17,-13 21,-12 24,-10 22,-6 23,-4 z"
|
||||
id="path3"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="url(#sfGradient)"
|
||||
d="m 775,90 2,1 12,36 14,41 12,35 23,4 31,5 24,7 36,12 23,10 32,16 20,12 15,10 -5,5 -16,10 -23,15 -18,11 -6,-1 -22,-12 -19,-8 -27,-9 -37,-10 -21,-4 -13,-2 -14,-1 h -47 l -27,3 -30,6 -31,9 -25,9 -24,11 -19,10 -19,12 -19,14 -13,11 -12,11 -28,28 -11,14 -15,20 -11,17 -14,24 -13,25 -11,29 -9,29 -8,36 -3,25 -1,14 v 55 l 3,27 6,31 4,19 v 4 l -16,12 -35,23 -24,15 -42,28 -46,30 -5,2 -6,-5 1,-7 6,-10 9,-11 15,-16 22,-22 8,-7 24,-24 8,-7 12,-12 -1,-9 -4,-21 -3,-27 -5,-5 -21,-11 -27,-11 -28,-11 -21,-9 -5,-2 v -2 l 17,-8 64,-25 22,-9 3,-5 7,-39 10,-40 15,-43 9,-20 13,-25 14,-24 13,-21 12,-16 7,-8 -1,-7 -11,-24 -17,-39 -5,-10 -3,-4 1,-2 11,5 39,16 27,13 3,3 4,-2 14,-12 20,-15 29,-19 22,-13 23,-12 15,-6 27,-10 31,-9 26,-6 34,-6 4,-3 12,-36 13,-37 z"
|
||||
id="path4"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="url(#sfGradient)"
|
||||
d="m 1241,171 1,2 -5,9 -12,19 -12,21 -8,13 -11,19 -15,24 -12,20 -31,51 -9,15 -11,18 -8,14 -16,26 -9,15 -17,28 -11,19 -10,17 -13,21 -16,27 -8,13 -11,19 -9,15 -17,29 -10,16 -10,19 -15,24 -11,18 -13,23 -9,15 -4,8 -3,1 -1,-4 -2,-27 -4,-29 -6,-25 -9,-24 -9,-22 -7,-12 -9,-12 -4,-5 1,-4 8,-10 7,1 6,2 12,1 12,-3 8,-5 3,-1 v -2 h 2 l 7,-14 1,-5 v -9 l -4,-13 -8,-10 -12,-6 -6,-1 h -11 l -9,3 -9,7 -6,9 -3,7 -1,12 4,13 1,5 -10,10 -1,2 -4,-2 -24,-16 -21,-12 -21,-10 -19,-7 -22,-6 6,-7 16,-16 22,-18 18,-13 16,-11 18,-13 33,-22 16,-11 23,-16 11,-8 22,-14 17,-12 12,-8 35,-23 19,-12 17,-12 18,-12 20,-13 18,-11 23,-15 20,-12 17,-11 25,-16 22,-14 33,-22 12,-7 z"
|
||||
id="path5"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="url(#sfGradient)"
|
||||
d="m 1228,180 4,1 -8,10 h -2 l -2,4 -14,15 -7,8 -31,33 -9,9 -7,8 -7,7 -7,8 -11,11 -7,8 -14,15 -67,67 -7,8 -14,15 -20,20 -7,8 -22,22 -7,8 -8,8 -7,8 -13,13 -7,8 -15,14 -4,4 -2,-1 -3,-5 h -8 l -7,-3 -6,-1 h -11 l -9,3 -9,7 -6,9 -3,7 -1,12 4,13 1,5 -10,10 -1,2 -4,-2 -24,-16 -21,-12 -21,-10 -19,-7 -22,-6 6,-7 16,-16 22,-18 18,-13 16,-11 18,-13 33,-22 16,-11 23,-16 11,-8 22,-14 17,-12 12,-8 35,-23 19,-12 17,-12 18,-12 20,-13 18,-11 23,-15 20,-12 17,-11 25,-16 22,-14 33,-22 z"
|
||||
id="path6"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="url(#sfGradient)"
|
||||
d="m 1032,575 h 2 l 1,52 h 181 l 1,2 v 79 l -154,1 h -29 v 89 l -1,65 -8,1 h -87 l -1,-1 V 744 l 1,-14 51,-84 8,-14 12,-19 10,-17 11,-19 z"
|
||||
id="path7"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="url(#sfGradient)"
|
||||
d="m 478,900 4,1 24,10 33,12 1,3 -24,16 -26,17 -24,15 -19,12 -32,19 -21,13 -19,11 -28,16 -24,14 -24,13 -19,10 -27,14 -22,12 -30,15 -19,9 -22,12 -30,15 -28,13 -20,9 -13,5 -8,1 -2,-1 1,-4 13,-11 14,-11 16,-13 16,-12 13,-10 48,-32 25,-15 19,-12 25,-16 40,-25 26,-17 19,-12 17,-11 19,-12 36,-24 41,-28 24,-16 z"
|
||||
id="path10"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="url(#sfGradient)"
|
||||
d="m 577,931 7,1 16,4 24,3 50,3 1,2 -4,6 -7,7 -11,9 -14,13 -11,9 -16,13 -9,7 -15,12 -18,13 -17,12 -20,14 -29,19 -19,12 -24,15 -24,14 -14,6 h -6 l -5,-4 -2,-4 1,-7 6,-8 8,-8 11,-9 16,-13 13,-10 11,-9 15,-12 19,-14 2,-2 -1,-4 -15,-14 -3,-3 1,-4 10,-10 17,-13 16,-13 16,-12 15,-13 z"
|
||||
id="path11"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="url(#sfGradient)"
|
||||
d="m 1115,443 h 125 l 3,2 v 80 l -1,1 -45,1 h -98 l -37,-1 v -3 l 4,-5 16,-27 15,-26 4,-6 3,-6 5,-4 4,-5 z"
|
||||
id="path13"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="url(#sfGradient)"
|
||||
d="m 433,813 3,1 9,15 3,6 -2,4 1,10 1,9 -10,7 -28,19 -17,12 -15,10 -22,14 -17,12 -24,16 -13,6 h -8 l -8,-5 -4,-5 -2,-5 v -7 l 4,-10 9,-11 10,-10 20,-14 26,-17 22,-14 15,-10 19,-13 19,-14 z"
|
||||
id="path14"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="url(#sfGradient)"
|
||||
d="m 233,945 h 7 l 6,5 2,5 -1,10 -9,10 -8,7 -18,12 -15,8 -10,3 h -11 l -8,-4 -3,-5 1,-7 4,-6 9,-9 10,-7 18,-10 23,-11 z"
|
||||
id="path20"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="url(#sfGradient)"
|
||||
d="m 370,1125 h 10 l 6,5 2,5 v 8 l -4,6 -8,8 -13,8 -3,1 h -8 l -7,-6 -1,-2 v -7 l 4,-9 7,-9 11,-7 z"
|
||||
id="path32"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="url(#sfGradient)"
|
||||
d="m 209,874 6,1 4,5 1,3 v 9 l -3,5 -7,7 -10,6 -3,1 h -7 l -6,-5 -1,-6 5,-10 6,-7 10,-7 z"
|
||||
id="path38"
|
||||
style="display:inline" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.7 KiB |
116
frontend_app/src/assets/main.css
Normal file
@@ -0,0 +1,116 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
body {
|
||||
@apply bg-[#04151F] text-white;
|
||||
background-image: radial-gradient(ellipse 80% 60% at 50% 0%, #0a2a40 0%, #04151F 70%);
|
||||
background-attachment: fixed;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Chrome, Safari, Edge autofill override — keeps dark theme */
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus,
|
||||
input:-webkit-autofill:active {
|
||||
-webkit-box-shadow: 0 0 0 30px #04151F inset !important;
|
||||
-webkit-text-fill-color: white !important;
|
||||
transition: background-color 5000s ease-in-out 0s;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn-primary {
|
||||
@apply px-4 py-2 bg-sf-blue text-white rounded-lg hover:bg-sf-accent transition-colors;
|
||||
}
|
||||
.btn-secondary {
|
||||
@apply px-4 py-2 bg-sf-wall text-sf-blue rounded-lg hover:bg-sf-green hover:text-white transition-colors;
|
||||
}
|
||||
.card {
|
||||
@apply bg-white rounded-xl shadow-md p-6;
|
||||
}
|
||||
.bento-grid {
|
||||
@apply grid grid-cols-1 md:grid-cols-3 gap-6;
|
||||
}
|
||||
.bento-cell {
|
||||
@apply rounded-2xl p-6 shadow-lg transition-transform hover:scale-[1.02];
|
||||
}
|
||||
|
||||
/* Service Finder specific components */
|
||||
.sf-header-bg {
|
||||
@apply bg-sf-wall bg-sf-wall-pattern;
|
||||
}
|
||||
.sf-card-green {
|
||||
@apply bg-sf-green text-white;
|
||||
}
|
||||
.sf-card-blue {
|
||||
@apply bg-sf-blue text-white;
|
||||
}
|
||||
.sf-card-accent {
|
||||
@apply bg-sf-accent text-white;
|
||||
}
|
||||
|
||||
/* ── Premium Button: Glassmorphism base, turquoise/green glow on hover, white flash on click ── */
|
||||
.btn-premium {
|
||||
@apply relative inline-flex items-center justify-center rounded-xl bg-white/10 px-6 py-3 font-bold text-white backdrop-blur-md border border-white/20 transition-all duration-300 cursor-pointer;
|
||||
}
|
||||
.btn-premium:hover {
|
||||
@apply bg-gradient-to-r from-[#418890] to-[#70BC84] border-transparent shadow-[0_0_20px_rgba(0,229,160,0.5)] -translate-y-0.5;
|
||||
}
|
||||
.btn-premium:active {
|
||||
@apply !bg-white !bg-none text-[#04151F] shadow-[0_0_30px_rgba(255,255,255,0.8)] scale-95 transition-none;
|
||||
}
|
||||
|
||||
/* ── Premium Gradient Text for large headings ── */
|
||||
.text-gradient-premium {
|
||||
@apply bg-clip-text text-transparent bg-gradient-to-r from-white via-[#00E5A0] to-[#418890];
|
||||
}
|
||||
|
||||
/* ── Fade Transition (Zen Mode toggle) ── */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ── Hotspot Pulse Animation ── */
|
||||
@keyframes hotspot-pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.5);
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
.animate-hotspot-pulse {
|
||||
animation: hotspot-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ── Hotspot Ring Ripple ── */
|
||||
@keyframes hotspot-ring {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.6;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2.5);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.animate-hotspot-ring {
|
||||
animation: hotspot-ring 2.5s ease-out infinite;
|
||||
}
|
||||
.animate-hotspot-ring-delayed {
|
||||
animation: hotspot-ring 2.5s ease-out 0.8s infinite;
|
||||
}
|
||||
}
|
||||
99
frontend_app/src/assets/sf-brand-logo.svg
Normal file
@@ -0,0 +1,99 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="623"
|
||||
height="469"
|
||||
viewBox="0 0 1600 1204"
|
||||
version="1.1"
|
||||
id="svg440"
|
||||
sodipodi:docname="SF_csak_logo_ (1).svg"
|
||||
inkscape:version="1.4.3 (0d15f75, 2025-12-25)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs440" />
|
||||
<sodipodi:namedview
|
||||
id="namedview440"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="1.7206823"
|
||||
inkscape:cx="390.83333"
|
||||
inkscape:cy="234.5"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1009"
|
||||
inkscape:window-x="1912"
|
||||
inkscape:window-y="646"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg440" />
|
||||
<path
|
||||
fill="#418890"
|
||||
d="m 1200,296 16,8 20,11 22,14 17,12 16,13 12,11 21,21 9,11 12,15 13,19 9,14 10,18 13,27 6,16 11,30 6,25 5,31 2,30 v 36 l -1,21 -4,29 -5,24 -8,26 -8,21 -13,31 -10,18 -20,30 -3,6 1,5 9,10 13,16 8,5 3,1 2,-4 5,-1 h 11 l 13,5 13,10 11,12 11,11 7,8 25,25 7,8 7,7 7,8 18,18 8,7 9,9 v 2 h 2 l 7,8 9,9 7,8 11,11 7,8 7,10 8,17 2,10 v 15 l -3,12 -4,9 -6,10 -9,10 -10,9 -16,8 -9,2 h -23 l -13,-4 -14,-7 -13,-11 -11,-11 -7,-8 -11,-12 -9,-10 -7,-7 -7,-8 -13,-14 -7,-8 -14,-15 -16,-17 -7,-7 -7,-8 -15,-15 v -2 l -4,-2 -8,-8 -10,-13 -4,-10 -1,-23 -5,-8 -9,-10 -7,-8 -7,-6 -6,1 -15,9 -18,12 -19,11 -26,13 -26,10 -29,8 -21,4 -21,2 h -49 l -26,-4 -12,-1 -18,14 -16,12 -17,11 -25,14 -23,11 -26,10 -28,9 -50,11 -4,2 -13,40 -13,37 -10,27 -2,6 h -2 l -13,-35 -24,-71 -3,-6 v -2 h -11 l -26,-4 -19,-5 -38,-13 -25,-11 -20,-10 -3,-3 16,-12 16,-13 11,-9 13,-11 5,-4 6,2 16,6 26,8 24,5 22,3 12,1 h 24 l 23,-2 29,-5 21,-5 36,-12 20,-8 20,-10 5,-3 -1,-3 -22,-11 -18,-11 -12,-9 -11,-9 -24,-22 -7,-8 -9,-10 -2,-5 9,-9 8,-7 9,-11 12,-17 4,-7 7,6 13,13 11,9 13,11 20,14 19,11 16,8 25,10 24,7 19,4 15,2 h 40 l 26,-3 22,-5 24,-8 21,-9 19,-10 19,-12 17,-12 14,-12 9,-9 h 2 l 2,-4 8,-8 15,-20 12,-19 13,-24 11,-27 10,-34 6,-31 2,-16 v -52 l -4,-27 -6,-24 -10,-30 -9,-21 -11,-20 -13,-21 -13,-17 -12,-14 -17,-17 -11,-9 -13,-11 -18,-13 -22,-13 -25,-12 -1,-3 10,-18 13,-21 z"
|
||||
id="path2"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#13597b"
|
||||
d="m 658,427 19,1 26,2 4,2 20,4 43,13 2,1 -1,4 -12,10 -14,11 -11,10 -8,7 -10,9 -16,15 -2,2 -17,-1 h -34 l -15,3 -13,5 -13,7 -9,8 -6,10 -3,9 v 11 l 4,12 9,10 12,7 20,8 32,8 43,10 25,7 18,6 20,9 14,8 13,10 9,8 9,11 9,13 8,18 4,15 2,10 1,13 v 13 l -2,18 -4,18 -6,16 -6,11 -10,13 -12,13 -14,11 -17,10 -21,9 -25,9 -25,5 -18,2 -20,1 h -23 l -24,-2 -38,-6 -48,-16 -23,-10 -17,-10 -24,-16 1,-4 41,-82 8,6 17,12 18,10 16,8 21,8 27,7 24,4 h 35 l 19,-3 16,-5 10,-5 8,-7 6,-12 2,-8 v -8 l -3,-12 -6,-10 -9,-8 -16,-8 -26,-8 -43,-10 -42,-12 -22,-8 -25,-12 -15,-10 -11,-9 -10,-10 -9,-14 -7,-15 -5,-16 -2,-10 -1,-13 v -12 l 3,-22 6,-20 7,-14 12,-17 9,-11 8,-8 17,-13 21,-12 24,-10 22,-6 23,-4 z"
|
||||
id="path3"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#418890"
|
||||
d="m 775,90 2,1 12,36 14,41 12,35 23,4 31,5 24,7 36,12 23,10 32,16 20,12 15,10 -5,5 -16,10 -23,15 -18,11 -6,-1 -22,-12 -19,-8 -27,-9 -37,-10 -21,-4 -13,-2 -14,-1 h -47 l -27,3 -30,6 -31,9 -25,9 -24,11 -19,10 -19,12 -19,14 -13,11 -12,11 -28,28 -11,14 -15,20 -11,17 -14,24 -13,25 -11,29 -9,29 -8,36 -3,25 -1,14 v 55 l 3,27 6,31 4,19 v 4 l -16,12 -35,23 -24,15 -42,28 -46,30 -5,2 -6,-5 1,-7 6,-10 9,-11 15,-16 22,-22 8,-7 24,-24 8,-7 12,-12 -1,-9 -4,-21 -3,-27 -5,-5 -21,-11 -27,-11 -28,-11 -21,-9 -5,-2 v -2 l 17,-8 64,-25 22,-9 3,-5 7,-39 10,-40 15,-43 9,-20 13,-25 14,-24 13,-21 12,-16 7,-8 -1,-7 -11,-24 -17,-39 -5,-10 -3,-4 1,-2 11,5 39,16 27,13 3,3 4,-2 14,-12 20,-15 29,-19 22,-13 23,-12 15,-6 27,-10 31,-9 26,-6 34,-6 4,-3 12,-36 13,-37 z"
|
||||
id="path4"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#408790"
|
||||
d="m 1241,171 1,2 -5,9 -12,19 -12,21 -8,13 -11,19 -15,24 -12,20 -31,51 -9,15 -11,18 -8,14 -16,26 -9,15 -17,28 -11,19 -10,17 -13,21 -16,27 -8,13 -11,19 -9,15 -17,29 -10,16 -10,19 -15,24 -11,18 -13,23 -9,15 -4,8 -3,1 -1,-4 -2,-27 -4,-29 -6,-25 -9,-24 -9,-22 -7,-12 -9,-12 -4,-5 1,-4 8,-10 7,1 6,2 12,1 12,-3 8,-5 3,-1 v -2 h 2 l 7,-14 1,-5 v -9 l -4,-13 -8,-10 -12,-6 -6,-1 h -11 l -9,3 -9,7 -6,9 -3,7 -1,12 4,13 1,5 -10,10 -1,2 -4,-2 -24,-16 -21,-12 -21,-10 -19,-7 -22,-6 6,-7 16,-16 22,-18 18,-13 16,-11 18,-13 33,-22 16,-11 23,-16 11,-8 22,-14 17,-12 12,-8 35,-23 19,-12 17,-12 18,-12 20,-13 18,-11 23,-15 20,-12 17,-11 25,-16 22,-14 33,-22 12,-7 z"
|
||||
id="path5"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#79b085"
|
||||
d="m 1228,180 4,1 -8,10 h -2 l -2,4 -14,15 -7,8 -31,33 -9,9 -7,8 -7,7 -7,8 -11,11 -7,8 -14,15 -67,67 -7,8 -14,15 -20,20 -7,8 -22,22 -7,8 -8,8 -7,8 -13,13 -7,8 -15,14 -4,4 -2,-1 -3,-5 h -8 l -7,-3 -6,-1 h -11 l -9,3 -9,7 -6,9 -3,7 -1,12 4,13 1,5 -10,10 -1,2 -4,-2 -24,-16 -21,-12 -21,-10 -19,-7 -22,-6 6,-7 16,-16 22,-18 18,-13 16,-11 18,-13 33,-22 16,-11 23,-16 11,-8 22,-14 17,-12 12,-8 35,-23 19,-12 17,-12 18,-12 20,-13 18,-11 23,-15 20,-12 17,-11 25,-16 22,-14 33,-22 z"
|
||||
id="path6"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#418790"
|
||||
d="m 1032,575 h 2 l 1,52 h 181 l 1,2 v 79 l -154,1 h -29 v 89 l -1,65 -8,1 h -87 l -1,-1 V 744 l 1,-14 51,-84 8,-14 12,-19 10,-17 11,-19 z"
|
||||
id="path7"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#408790"
|
||||
d="m 478,900 4,1 24,10 33,12 1,3 -24,16 -26,17 -24,15 -19,12 -32,19 -21,13 -19,11 -28,16 -24,14 -24,13 -19,10 -27,14 -22,12 -30,15 -19,9 -22,12 -30,15 -28,13 -20,9 -13,5 -8,1 -2,-1 1,-4 13,-11 14,-11 16,-13 16,-12 13,-10 48,-32 25,-15 19,-12 25,-16 40,-25 26,-17 19,-12 17,-11 19,-12 36,-24 41,-28 24,-16 z"
|
||||
id="path10"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#13597b"
|
||||
d="m 577,931 7,1 16,4 24,3 50,3 1,2 -4,6 -7,7 -11,9 -14,13 -11,9 -16,13 -9,7 -15,12 -18,13 -17,12 -20,14 -29,19 -19,12 -24,15 -24,14 -14,6 h -6 l -5,-4 -2,-4 1,-7 6,-8 8,-8 11,-9 16,-13 13,-10 11,-9 15,-12 19,-14 2,-2 -1,-4 -15,-14 -3,-3 1,-4 10,-10 17,-13 16,-13 16,-12 15,-13 z"
|
||||
id="path11"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#78b085"
|
||||
d="m 1115,443 h 125 l 3,2 v 80 l -1,1 -45,1 h -98 l -37,-1 v -3 l 4,-5 16,-27 15,-26 4,-6 3,-6 5,-4 4,-5 z"
|
||||
id="path13"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#408690"
|
||||
d="m 433,813 3,1 9,15 3,6 -2,4 1,10 1,9 -10,7 -28,19 -17,12 -15,10 -22,14 -17,12 -24,16 -13,6 h -8 l -8,-5 -4,-5 -2,-5 v -7 l 4,-10 9,-11 10,-10 20,-14 26,-17 22,-14 15,-10 19,-13 19,-14 z"
|
||||
id="path14"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#408790"
|
||||
d="m 233,945 h 7 l 6,5 2,5 -1,10 -9,10 -8,7 -18,12 -15,8 -10,3 h -11 l -8,-4 -3,-5 1,-7 4,-6 9,-9 10,-7 18,-10 23,-11 z"
|
||||
id="path20"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#0f4664"
|
||||
d="m 370,1125 h 10 l 6,5 2,5 v 8 l -4,6 -8,8 -13,8 -3,1 h -8 l -7,-6 -1,-2 v -7 l 4,-9 7,-9 11,-7 z"
|
||||
id="path32"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#3f8690"
|
||||
d="m 209,874 6,1 4,5 1,3 v 9 l -3,5 -7,7 -10,6 -3,1 h -7 l -6,-5 -1,-6 5,-10 6,-7 10,-7 z"
|
||||
id="path38"
|
||||
style="display:inline" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.1 KiB |
BIN
frontend_app/src/assets/sf_landing_back.png
Normal file
|
After Width: | Height: | Size: 7.0 MiB |
103
frontend_app/src/components/LanguageSwitcher.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div class="relative flex items-center lang-switcher-container">
|
||||
<button
|
||||
@click.stop="isOpen = !isOpen"
|
||||
class="flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm font-medium text-white/70 transition-all duration-200 hover:bg-white/5 hover:text-white cursor-pointer"
|
||||
:title="t('header.switchLanguage')"
|
||||
>
|
||||
<span :class="['fi', `fi-${currentFlagCode}`, 'fis', 'rounded-sm', 'shadow-sm']"></span>
|
||||
<span class="hidden sm:inline font-semibold uppercase">{{ currentLocale }}</span>
|
||||
<!-- Chevron down -->
|
||||
<svg
|
||||
class="w-3.5 h-3.5 ml-0.5 text-white/40 transition-transform duration-200"
|
||||
:class="{ 'rotate-180': isOpen }"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- ── Language Dropdown (Glassmorphism) ────────────────────────── -->
|
||||
<Transition
|
||||
enter-active-class="transition duration-150 ease-out"
|
||||
enter-from-class="scale-95 opacity-0 -translate-y-2"
|
||||
enter-to-class="scale-100 opacity-100 translate-y-0"
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-from-class="scale-100 opacity-100 translate-y-0"
|
||||
leave-to-class="scale-95 opacity-0 -translate-y-2"
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="absolute left-0 top-full mt-2 w-48 z-50 rounded-xl border border-white/10 bg-[#04151F]/90 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
|
||||
>
|
||||
<div class="py-1">
|
||||
<button
|
||||
v-for="lang in languages"
|
||||
:key="lang.code"
|
||||
@click="setLanguage(lang.code)"
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
|
||||
:class="{ 'text-[#70BC84] font-semibold': currentLocale === lang.code }"
|
||||
>
|
||||
<span :class="['fi', `fi-${lang.flagCode}`, 'fis', 'rounded-sm', 'shadow-sm']"></span>
|
||||
<span class="uppercase font-medium">{{ lang.code }}</span>
|
||||
<span class="text-white/50">{{ lang.label }}</span>
|
||||
<span v-if="currentLocale === lang.code" class="ml-auto text-[#70BC84]">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const currentLocale = computed(() => locale.value)
|
||||
|
||||
interface Language {
|
||||
code: string
|
||||
flagCode: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const languages: Language[] = [
|
||||
{ code: 'hu', flagCode: 'hu', label: 'Magyar' },
|
||||
{ code: 'en', flagCode: 'gb', label: 'English' },
|
||||
{ code: 'de', flagCode: 'de', label: 'Deutsch' },
|
||||
{ code: 'ro', flagCode: 'ro', label: 'Română' },
|
||||
{ code: 'cz', flagCode: 'cz', label: 'Čeština' },
|
||||
{ code: 'sk', flagCode: 'sk', label: 'Slovenčina' },
|
||||
]
|
||||
|
||||
const currentFlagCode = computed(() => {
|
||||
const lang = languages.find(l => l.code === currentLocale.value)
|
||||
return lang ? lang.flagCode : 'gb'
|
||||
})
|
||||
|
||||
function setLanguage(lang: string) {
|
||||
locale.value = lang
|
||||
localStorage.setItem('user-locale', lang)
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
// ── Close dropdown on outside click ───────────────────────────────
|
||||
function handleOutsideClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
if (isOpen.value && !target.closest('.lang-switcher-container')) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('mousedown', handleOutsideClick))
|
||||
onUnmounted(() => document.removeEventListener('mousedown', handleOutsideClick))
|
||||
</script>
|
||||
1223
frontend_app/src/components/LoginModal.vue
Normal file
66
frontend_app/src/components/Logo.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-4 relative -mb-8 z-50 group cursor-pointer">
|
||||
|
||||
<svg
|
||||
viewBox="0 0 240 240"
|
||||
class="w-24 h-24 drop-shadow-[0_10px_20px_rgba(0,0,0,0.5)] transition-transform duration-500 group-hover:scale-105"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="sf-green" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#418890" />
|
||||
<stop offset="100%" stop-color="#79B085" />
|
||||
</linearGradient>
|
||||
<linearGradient id="sf-white" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#FFFFFF" />
|
||||
<stop offset="100%" stop-color="#D1D5DB" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<path d="M 80 180 L 30 210" stroke="url(#sf-green)" stroke-width="8" stroke-linecap="round"/>
|
||||
<circle cx="15" cy="219" r="4" fill="url(#sf-green)"/>
|
||||
|
||||
<path d="M 110 205 L 40 245" stroke="url(#sf-green)" stroke-width="12" stroke-linecap="round"/>
|
||||
<circle cx="20" cy="255" r="6" fill="url(#sf-green)"/>
|
||||
|
||||
<path d="M 140 225 L 90 255" stroke="url(#sf-green)" stroke-width="6" stroke-linecap="round"/>
|
||||
<circle cx="75" cy="264" r="3" fill="url(#sf-green)"/>
|
||||
|
||||
<polygon points="120,30 108,50 132,50" fill="url(#sf-white)"/>
|
||||
<polygon points="120,210 108,190 132,190" fill="url(#sf-white)"/>
|
||||
<polygon points="30,120 50,108 50,132" fill="url(#sf-white)"/>
|
||||
<polygon points="65,65 85,78 70,88" fill="url(#sf-white)"/>
|
||||
|
||||
<circle cx="120" cy="120" r="70" stroke="url(#sf-white)" stroke-width="14"/>
|
||||
|
||||
<text x="90" y="155" font-family="Arial, Helvetica, sans-serif" font-weight="900" font-size="95" text-anchor="middle" fill="url(#sf-white)">S</text>
|
||||
<text x="155" y="155" font-family="Arial, Helvetica, sans-serif" font-weight="900" font-size="95" text-anchor="middle" fill="url(#sf-green)">F</text>
|
||||
|
||||
<path d="M 170 170 L 215 215" stroke="url(#sf-white)" stroke-width="26" stroke-linecap="round"/>
|
||||
<path d="M 180 180 L 210 210" stroke="#062535" stroke-width="6" stroke-linecap="round"/>
|
||||
|
||||
<circle cx="125" cy="110" r="15" fill="url(#sf-white)"/>
|
||||
<circle cx="125" cy="110" r="6" fill="#062535"/>
|
||||
|
||||
<polygon points="215,25 125,110 95,70" fill="#2C5A63"/>
|
||||
|
||||
<polygon points="215,25 125,110 165,140" fill="url(#sf-green)"/>
|
||||
|
||||
</svg>
|
||||
|
||||
<div class="hidden sm:flex flex-col justify-center mt-3">
|
||||
<div class="font-black tracking-widest text-3xl leading-none drop-shadow-md">
|
||||
<span class="text-white">SERVICE</span> <span class="text-[#75A882]">FINDER</span>
|
||||
</div>
|
||||
<div class="text-[#75A882] text-[0.65rem] tracking-[0.25em] font-bold mt-2 uppercase opacity-90">
|
||||
Online Járműnyilvántartó & Szervizkereső
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// Tiszta, újrahasználható, kézzel optimalizált SVG logó.
|
||||
</script>
|
||||
81
frontend_app/src/components/ModeSwitcher.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div class="mode-switcher">
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- B2B/B2C Toggle -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('switchMode') }}</span>
|
||||
<button
|
||||
@click="toggleAppMode"
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
:class="isCorporate ? 'bg-corporate-blue' : 'bg-consumer-orange'"
|
||||
>
|
||||
<span class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
||||
:class="isCorporate ? 'translate-x-6' : 'translate-x-1'"
|
||||
/>
|
||||
</button>
|
||||
<span class="text-sm font-medium min-w-[60px] dark:text-gray-300">
|
||||
{{ isCorporate ? $t('corporate') : $t('consumer') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Admin Switch (only for admins) -->
|
||||
<div v-if="showAdminSwitch" class="flex items-center space-x-2">
|
||||
<div class="h-6 w-px bg-gray-300 dark:bg-gray-600"></div>
|
||||
<button
|
||||
@click="goToAdmin"
|
||||
class="flex items-center space-x-2 px-3 py-1.5 rounded-lg bg-gradient-to-r from-gray-800 to-gray-900 dark:from-gray-700 dark:to-gray-800 text-white hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<span class="text-sm">⚙️</span>
|
||||
<span class="text-sm font-medium">Admin</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="isInAdminMode"
|
||||
@click="exitAdmin"
|
||||
class="px-3 py-1.5 rounded-lg bg-red-900/30 hover:bg-red-900/50 text-red-400 text-sm font-medium transition-colors"
|
||||
>
|
||||
Exit Admin
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppModeStore } from '../stores/appModeStore'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const appModeStore = useAppModeStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const isCorporate = computed(() => appModeStore.isCorporate)
|
||||
const isInAdminMode = computed(() => route.path.startsWith('/admin'))
|
||||
|
||||
const showAdminSwitch = computed(() => {
|
||||
// Check if user has admin privileges
|
||||
return authStore.isAdmin
|
||||
})
|
||||
|
||||
function toggleAppMode() {
|
||||
appModeStore.toggleMode()
|
||||
}
|
||||
|
||||
function goToAdmin() {
|
||||
router.push('/admin')
|
||||
}
|
||||
|
||||
function exitAdmin() {
|
||||
router.push('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mode-switcher {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
</style>
|
||||
721
frontend_app/src/components/admin/PackageEditModal.vue
Normal file
@@ -0,0 +1,721 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
@click.self="$emit('close')"
|
||||
>
|
||||
<div class="bg-gray-800 border border-gray-700 rounded-2xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-6 py-5 border-b border-gray-700">
|
||||
<h2 class="text-xl font-bold text-white">
|
||||
{{ isEditing ? $t('admin.packages.modal_edit_title') : $t('admin.packages.modal_create_title') }}
|
||||
</h2>
|
||||
<button
|
||||
class="p-2 text-gray-400 hover:text-gray-200 hover:bg-gray-700 rounded-lg transition-colors"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="px-6 py-5 space-y-6">
|
||||
<!-- Display Name (human-readable) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
{{ $t('admin.packages.field_display_name') || 'Megjelenített név' }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.display_name"
|
||||
type="text"
|
||||
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Pl. Privát Ingyenes"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">Emberi olvasásra szánt megjelenítési név</p>
|
||||
</div>
|
||||
|
||||
<!-- Name (technical) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
{{ $t('admin.packages.field_name') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
:placeholder="$t('admin.packages.field_name_placeholder')"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">Technikai azonosító (pl. private_free_v1)</p>
|
||||
</div>
|
||||
|
||||
<!-- Type -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
{{ $t('admin.packages.field_type') }}
|
||||
</label>
|
||||
<select
|
||||
v-model="form.type"
|
||||
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="private">{{ $t('admin.packages.type_private') }}</option>
|
||||
<option value="corporate">{{ $t('admin.packages.type_corporate') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Pricing Row -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
{{ $t('admin.packages.field_monthly_price') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="form.monthly_price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
{{ $t('admin.packages.field_yearly_price') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="form.yearly_price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
{{ $t('admin.packages.field_currency') }}
|
||||
</label>
|
||||
<select
|
||||
v-model="form.currency"
|
||||
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="HUF">HUF</option>
|
||||
<option value="USD">USD</option>
|
||||
<option value="GBP">GBP</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Allowances Row -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
{{ $t('admin.packages.field_max_vehicles') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="form.max_vehicles"
|
||||
type="number"
|
||||
min="0"
|
||||
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
{{ $t('admin.packages.field_max_garages') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="form.max_garages"
|
||||
type="number"
|
||||
min="0"
|
||||
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
{{ $t('admin.packages.field_free_credits') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="form.monthly_free_credits"
|
||||
type="number"
|
||||
min="0"
|
||||
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Credit Price (Kredit Ár) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
{{ $t('admin.packages.field_credit_price') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="form.credit_price"
|
||||
type="number"
|
||||
min="0"
|
||||
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">Havi kredit költség a csomaghoz</p>
|
||||
</div>
|
||||
|
||||
<!-- Lifecycle Row -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
{{ $t('admin.packages.field_is_public') }}
|
||||
</label>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
v-model="form.is_public"
|
||||
type="checkbox"
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div class="w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-500 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
<span class="ms-3 text-sm text-gray-300">{{ form.is_public ? $t('admin.packages.active') : $t('admin.packages.inactive') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
{{ $t('admin.packages.field_available_until') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.available_until"
|
||||
type="date"
|
||||
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">Ha üres, nincs lejárat</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Affiliate Row -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
{{ $t('admin.packages.field_commission_rate') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="form.commission_rate_percent"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
{{ $t('admin.packages.field_referral_bonus') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="form.referral_bonus_credits"
|
||||
type="number"
|
||||
min="0"
|
||||
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Entitlements (Chip Selector) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||||
{{ $t('admin.packages.field_entitlements') }}
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2 mb-3">
|
||||
<span
|
||||
v-for="ent in selectedEntitlements"
|
||||
:key="ent"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-blue-900/50 text-blue-300 border border-blue-700 rounded-full text-sm"
|
||||
>
|
||||
{{ ent }}
|
||||
<button
|
||||
class="p-0.5 hover:bg-blue-800 rounded-full transition-colors"
|
||||
@click="removeEntitlement(ent)"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<select
|
||||
v-model="entitlementToAdd"
|
||||
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
@change="addEntitlement"
|
||||
>
|
||||
<option value="">{{ $t('admin.packages.entitlement_placeholder') }}</option>
|
||||
<option
|
||||
v-for="ent in availableEntitlements"
|
||||
:key="ent"
|
||||
:value="ent"
|
||||
:disabled="selectedEntitlements.includes(ent)"
|
||||
>
|
||||
{{ ent }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Marketing Section ──────────────────────────────────────────── -->
|
||||
<div class="border-t border-gray-700 pt-4">
|
||||
<h3 class="text-md font-semibold text-gray-200 mb-3 flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 3.055A9.001 9.001 0 1020.945 13H11V3.055z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.488 9H15V3.512A9.025 9.025 0 0120.488 9z" />
|
||||
</svg>
|
||||
Marketing & Megjelenítés
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
Alcím (Subtitle)
|
||||
</label>
|
||||
<input
|
||||
v-model="form.marketing_subtitle"
|
||||
type="text"
|
||||
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Pl. Kisebb flotta esetén."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
Jelvény (Badge)
|
||||
</label>
|
||||
<input
|
||||
v-model="form.marketing_badge"
|
||||
type="text"
|
||||
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Pl. Legnépszerűbb"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
Kiemelő szín (Hex)
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="form.marketing_highlight_color"
|
||||
type="color"
|
||||
class="w-10 h-10 p-0.5 bg-gray-700 border border-gray-600 rounded-lg cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
v-model="form.marketing_highlight_color"
|
||||
type="text"
|
||||
class="flex-1 px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="#70BC84"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Pricing Zones Section ──────────────────────────────────────── -->
|
||||
<div class="border-t border-gray-700 pt-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-md font-semibold text-gray-200 flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Több régiós árazás (Pricing Zones)
|
||||
</h3>
|
||||
<button
|
||||
class="px-3 py-1.5 text-xs font-medium text-green-300 bg-green-900/30 border border-green-700 rounded-lg hover:bg-green-900/50 transition-colors"
|
||||
@click="addPricingZone"
|
||||
>
|
||||
+ Zóna hozzáadása
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mb-3">
|
||||
Ország-specifikus árazás. Kulcs: ISO 3166-1 alpha-3 országkód (pl. HU, DE, US) vagy "DEFAULT" az alapértelmezett zónához.
|
||||
</p>
|
||||
|
||||
<!-- Zone List -->
|
||||
<div v-if="pricingZones.length === 0" class="text-center py-4 text-gray-500 text-sm">
|
||||
Nincsenek árazási zónák beállítva. Kattints a "+ Zóna hozzáadása" gombra.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(zone, index) in pricingZones"
|
||||
:key="index"
|
||||
class="mb-3 p-3 bg-gray-750 border border-gray-650 rounded-lg"
|
||||
:style="{ borderColor: zone.currency === 'HUF' ? '#065F46' : zone.currency === 'USD' ? '#1E3A5F' : '#374151' }"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-mono text-gray-400">#{{ index + 1 }}</span>
|
||||
<input
|
||||
v-model="zone.country_code"
|
||||
type="text"
|
||||
class="w-24 px-2 py-1 text-sm font-mono bg-gray-700 border border-gray-600 rounded text-gray-100 uppercase placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="HU"
|
||||
maxlength="10"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="p-1 text-gray-500 hover:text-red-400 hover:bg-red-900/30 rounded transition-colors"
|
||||
@click="removePricingZone(index)"
|
||||
title="Zóna eltávolítása"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-0.5">Havi ár</label>
|
||||
<input
|
||||
v-model.number="zone.monthly_price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="w-full px-2 py-1.5 text-sm bg-gray-700 border border-gray-600 rounded text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-0.5">Éves ár</label>
|
||||
<input
|
||||
v-model.number="zone.yearly_price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="w-full px-2 py-1.5 text-sm bg-gray-700 border border-gray-600 rounded text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-0.5">Pénznem</label>
|
||||
<select
|
||||
v-model="zone.currency"
|
||||
class="w-full px-2 py-1.5 text-sm bg-gray-700 border border-gray-600 rounded text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="HUF">HUF</option>
|
||||
<option value="USD">USD</option>
|
||||
<option value="GBP">GBP</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-0.5">Kredit ár</label>
|
||||
<input
|
||||
v-model.number="zone.credit_price"
|
||||
type="number"
|
||||
min="0"
|
||||
class="w-full px-2 py-1.5 text-sm bg-gray-700 border border-gray-600 rounded text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Display -->
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="px-4 py-3 bg-red-900/40 border border-red-700 rounded-lg text-red-300 text-sm"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-700">
|
||||
<button
|
||||
class="px-4 py-2.5 text-sm font-medium text-gray-300 hover:text-white bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
{{ $t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="isSaving || !isFormValid"
|
||||
@click="handleSave"
|
||||
>
|
||||
<span v-if="isSaving" class="flex items-center gap-2">
|
||||
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
{{ $t('common.saving') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ isEditing ? $t('admin.packages.save_changes') : $t('admin.packages.create') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useAdminPackagesStore, AVAILABLE_ENTITLEMENTS } from '../../stores/adminPackages'
|
||||
import type { SubscriptionTierItem, SubscriptionRulesModel } from '../../stores/adminPackages'
|
||||
|
||||
const props = defineProps<{
|
||||
package?: SubscriptionTierItem | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const store = useAdminPackagesStore()
|
||||
|
||||
const isEditing = computed(() => !!props.package)
|
||||
|
||||
// ── Pricing Zone Form Item Interface ────────────────────────────────────────
|
||||
|
||||
interface PricingZoneFormItem {
|
||||
country_code: string
|
||||
monthly_price: number
|
||||
yearly_price: number
|
||||
currency: string
|
||||
credit_price: number | null
|
||||
}
|
||||
|
||||
// ── Form State ──────────────────────────────────────────────────────────────
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
display_name: '',
|
||||
type: 'private' as 'private' | 'corporate',
|
||||
monthly_price: 0,
|
||||
yearly_price: 0,
|
||||
currency: 'EUR',
|
||||
credit_price: null as number | null,
|
||||
max_vehicles: 0,
|
||||
max_garages: 0,
|
||||
monthly_free_credits: 0,
|
||||
is_public: true,
|
||||
available_until: '' as string,
|
||||
commission_rate_percent: 0,
|
||||
referral_bonus_credits: 0,
|
||||
// Marketing fields
|
||||
marketing_subtitle: '' as string,
|
||||
marketing_badge: '' as string,
|
||||
marketing_highlight_color: '' as string,
|
||||
})
|
||||
|
||||
const selectedEntitlements = ref<string[]>([])
|
||||
const entitlementToAdd = ref('')
|
||||
const pricingZones = ref<PricingZoneFormItem[]>([])
|
||||
const isSaving = ref(false)
|
||||
const errorMessage = ref<string | null>(null)
|
||||
|
||||
const availableEntitlements = computed(() => {
|
||||
return AVAILABLE_ENTITLEMENTS.filter((e) => !selectedEntitlements.value.includes(e))
|
||||
})
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
return form.value.name.trim().length >= 2
|
||||
})
|
||||
|
||||
// ── Initialize form when editing ────────────────────────────────────────────
|
||||
|
||||
watch(
|
||||
() => props.package,
|
||||
(pkg) => {
|
||||
if (pkg) {
|
||||
const rules = pkg.rules
|
||||
form.value.name = pkg.name
|
||||
form.value.display_name = rules.display_name || ''
|
||||
form.value.type = rules.type
|
||||
form.value.monthly_price = rules.pricing.monthly_price
|
||||
form.value.yearly_price = rules.pricing.yearly_price
|
||||
form.value.currency = rules.pricing.currency
|
||||
form.value.credit_price = rules.pricing.credit_price ?? null
|
||||
form.value.max_vehicles = rules.allowances.max_vehicles
|
||||
form.value.max_garages = rules.allowances.max_garages
|
||||
form.value.monthly_free_credits = rules.allowances.monthly_free_credits
|
||||
form.value.is_public = rules.lifecycle?.is_public ?? true
|
||||
form.value.available_until = rules.lifecycle?.available_until
|
||||
? rules.lifecycle.available_until.slice(0, 10)
|
||||
: ''
|
||||
form.value.commission_rate_percent = rules.affiliate.commission_rate_percent
|
||||
form.value.referral_bonus_credits = rules.affiliate.referral_bonus_credits
|
||||
selectedEntitlements.value = [...rules.entitlements]
|
||||
|
||||
// Marketing fields
|
||||
form.value.marketing_subtitle = rules.marketing?.subtitle || ''
|
||||
form.value.marketing_badge = rules.marketing?.badge || ''
|
||||
form.value.marketing_highlight_color = rules.marketing?.highlight_color || ''
|
||||
|
||||
// Pricing Zones
|
||||
pricingZones.value = []
|
||||
if (rules.pricing_zones) {
|
||||
for (const [countryCode, zone] of Object.entries(rules.pricing_zones)) {
|
||||
pricingZones.value.push({
|
||||
country_code: countryCode,
|
||||
monthly_price: zone.monthly_price,
|
||||
yearly_price: zone.yearly_price,
|
||||
currency: zone.currency,
|
||||
credit_price: zone.credit_price ?? null,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
resetForm()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function resetForm() {
|
||||
form.value = {
|
||||
name: '',
|
||||
display_name: '',
|
||||
type: 'private',
|
||||
monthly_price: 0,
|
||||
yearly_price: 0,
|
||||
currency: 'EUR',
|
||||
credit_price: null,
|
||||
max_vehicles: 0,
|
||||
max_garages: 0,
|
||||
monthly_free_credits: 0,
|
||||
is_public: true,
|
||||
available_until: '',
|
||||
commission_rate_percent: 0,
|
||||
referral_bonus_credits: 0,
|
||||
marketing_subtitle: '',
|
||||
marketing_badge: '',
|
||||
marketing_highlight_color: '',
|
||||
}
|
||||
selectedEntitlements.value = []
|
||||
pricingZones.value = []
|
||||
errorMessage.value = null
|
||||
}
|
||||
|
||||
// ── Entitlement Chip Management ─────────────────────────────────────────────
|
||||
|
||||
function addEntitlement() {
|
||||
const val = entitlementToAdd.value
|
||||
if (val && !selectedEntitlements.value.includes(val)) {
|
||||
selectedEntitlements.value.push(val)
|
||||
}
|
||||
entitlementToAdd.value = ''
|
||||
}
|
||||
|
||||
function removeEntitlement(ent: string) {
|
||||
selectedEntitlements.value = selectedEntitlements.value.filter((e) => e !== ent)
|
||||
}
|
||||
|
||||
// ── Pricing Zone Management ─────────────────────────────────────────────────
|
||||
|
||||
function addPricingZone() {
|
||||
pricingZones.value.push({
|
||||
country_code: '',
|
||||
monthly_price: 0,
|
||||
yearly_price: 0,
|
||||
currency: 'EUR',
|
||||
credit_price: null,
|
||||
})
|
||||
}
|
||||
|
||||
function removePricingZone(index: number) {
|
||||
pricingZones.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// ── Build Rules Object ──────────────────────────────────────────────────────
|
||||
|
||||
function buildRules(): SubscriptionRulesModel {
|
||||
const rules: SubscriptionRulesModel = {
|
||||
type: form.value.type,
|
||||
pricing: {
|
||||
monthly_price: form.value.monthly_price,
|
||||
yearly_price: form.value.yearly_price,
|
||||
currency: form.value.currency,
|
||||
},
|
||||
allowances: {
|
||||
max_vehicles: form.value.max_vehicles,
|
||||
max_garages: form.value.max_garages,
|
||||
monthly_free_credits: form.value.monthly_free_credits,
|
||||
},
|
||||
entitlements: [...selectedEntitlements.value],
|
||||
affiliate: {
|
||||
commission_rate_percent: form.value.commission_rate_percent,
|
||||
referral_bonus_credits: form.value.referral_bonus_credits,
|
||||
},
|
||||
}
|
||||
if (form.value.display_name.trim()) {
|
||||
rules.display_name = form.value.display_name.trim()
|
||||
}
|
||||
// Credit price
|
||||
if (form.value.credit_price !== null && form.value.credit_price >= 0) {
|
||||
rules.pricing.credit_price = form.value.credit_price
|
||||
}
|
||||
// Lifecycle
|
||||
const lifecycle: Record<string, any> = {
|
||||
is_public: form.value.is_public,
|
||||
}
|
||||
if (form.value.available_until) {
|
||||
lifecycle.available_until = form.value.available_until
|
||||
}
|
||||
rules.lifecycle = lifecycle as any
|
||||
|
||||
// Marketing
|
||||
const marketing: Record<string, string> = {}
|
||||
if (form.value.marketing_subtitle.trim()) {
|
||||
marketing.subtitle = form.value.marketing_subtitle.trim()
|
||||
}
|
||||
if (form.value.marketing_badge.trim()) {
|
||||
marketing.badge = form.value.marketing_badge.trim()
|
||||
}
|
||||
if (form.value.marketing_highlight_color.trim()) {
|
||||
marketing.highlight_color = form.value.marketing_highlight_color.trim()
|
||||
}
|
||||
if (Object.keys(marketing).length > 0) {
|
||||
rules.marketing = marketing as any
|
||||
}
|
||||
|
||||
// Pricing Zones
|
||||
const validZones = pricingZones.value.filter((z) => z.country_code.trim().length > 0)
|
||||
if (validZones.length > 0) {
|
||||
const zones: Record<string, any> = {}
|
||||
for (const zone of validZones) {
|
||||
const z: Record<string, any> = {
|
||||
monthly_price: zone.monthly_price,
|
||||
yearly_price: zone.yearly_price,
|
||||
currency: zone.currency,
|
||||
}
|
||||
if (zone.credit_price !== null && zone.credit_price >= 0) {
|
||||
z.credit_price = zone.credit_price
|
||||
}
|
||||
zones[zone.country_code.trim().toUpperCase()] = z
|
||||
}
|
||||
rules.pricing_zones = zones as any
|
||||
}
|
||||
|
||||
return rules
|
||||
}
|
||||
|
||||
// ── Save Handler ────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleSave() {
|
||||
if (!isFormValid.value) return
|
||||
|
||||
isSaving.value = true
|
||||
errorMessage.value = null
|
||||
|
||||
try {
|
||||
const rules = buildRules()
|
||||
|
||||
if (isEditing.value && props.package) {
|
||||
await store.updatePackage(props.package.id, {
|
||||
name: form.value.name,
|
||||
rules,
|
||||
})
|
||||
} else {
|
||||
// Create: do NOT send id — PostgreSQL auto-increments it
|
||||
await store.createPackage({
|
||||
name: form.value.name,
|
||||
rules,
|
||||
})
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
} catch (err: any) {
|
||||
errorMessage.value = err.response?.data?.detail || store.error || 'An error occurred'
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
1007
frontend_app/src/components/cost/CostEntryWizard.vue
Normal file
391
frontend_app/src/components/cost/CostManagerModal.vue
Normal file
@@ -0,0 +1,391 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 z-[9999] flex items-center justify-center bg-slate-900/85 backdrop-blur-md"
|
||||
@click.self="$emit('close')"
|
||||
>
|
||||
<div class="bg-white w-11/12 max-w-4xl max-h-[85vh] rounded-3xl shadow-2xl flex flex-col overflow-hidden animate-flip-in relative">
|
||||
<!-- Close button -->
|
||||
<button
|
||||
class="absolute top-4 right-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-slate-200/80 text-slate-600 hover:bg-slate-300 hover:text-slate-800 transition-colors text-xl font-bold"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
<!-- ── Header ── -->
|
||||
<div class="h-16 bg-slate-700 w-full shrink-0 flex items-center px-6">
|
||||
<span class="text-white font-bold text-lg tracking-wide">📊 {{ t('finance.costManager') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- ── Control Bar: Filter + Add Button ── -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-100 shrink-0">
|
||||
<!-- Left: Vehicle Filter Dropdown -->
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="text-sm font-semibold text-slate-600 whitespace-nowrap">{{ t('finance.filterByVehicle') || 'Jármű szűrő' }}:</label>
|
||||
<select
|
||||
v-model="selectedVehicleFilter"
|
||||
class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm text-slate-700 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all min-w-[220px] appearance-none cursor-pointer"
|
||||
@change="onVehicleFilterChange"
|
||||
>
|
||||
<option value="">{{ t('finance.allVehicles') || 'Teljes flotta (Összes jármű)' }}</option>
|
||||
<option
|
||||
v-for="v in vehicleStore.sortedVehicles"
|
||||
:key="v.id"
|
||||
:value="v.id"
|
||||
>
|
||||
{{ v.license_plate || '—' }} ({{ v.brand || '' }} {{ v.model || '' }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Right: Add Cost Button -->
|
||||
<button
|
||||
@click="openCostWizard"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-sf-accent px-5 py-2.5 text-sm font-bold text-white shadow-md transition-all hover:bg-sf-blue hover:shadow-lg"
|
||||
>
|
||||
<span class="text-base">➕</span>
|
||||
{{ t('finance.addCost') || 'Új Költség' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Scrollable Content ── -->
|
||||
<div class="flex-1 overflow-y-auto p-6 text-slate-800">
|
||||
<!-- Description -->
|
||||
<p class="text-sm text-slate-500 mb-4">{{ t('finance.costManagerDescription') }}</p>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex items-center justify-center py-20"
|
||||
>
|
||||
<div class="h-10 w-10 animate-spin rounded-full border-4 border-slate-200 border-t-sf-accent" />
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="flex flex-col items-center justify-center py-12 text-center"
|
||||
>
|
||||
<svg class="w-12 h-12 text-amber-500 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<p class="text-sm text-slate-500">{{ error }}</p>
|
||||
<button
|
||||
@click="fetchCosts"
|
||||
class="mt-3 text-sm text-sf-accent hover:underline font-medium"
|
||||
>
|
||||
{{ t('common.retry') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Cost Table ── -->
|
||||
<div v-else>
|
||||
<!-- Table Header -->
|
||||
<div class="hidden md:grid grid-cols-12 gap-3 px-4 py-2 text-xs font-semibold text-slate-500 uppercase tracking-wider bg-slate-50 rounded-xl mb-2">
|
||||
<div class="col-span-2">{{ t('finance.costDate') }}</div>
|
||||
<div class="col-span-2">{{ t('finance.costType') }}</div>
|
||||
<div class="col-span-2">{{ t('finance.costAmount') }}</div>
|
||||
<div class="col-span-3">{{ t('finance.costVehicle') }}</div>
|
||||
<div class="col-span-3">{{ t('finance.costDescription') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
v-if="costs.length === 0"
|
||||
class="flex flex-col items-center justify-center py-16 text-slate-400"
|
||||
>
|
||||
<svg class="w-12 h-12 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
<p class="text-sm">{{ t('finance.noCosts') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Cost Rows -->
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="cost in costs"
|
||||
:key="cost.id"
|
||||
class="grid grid-cols-1 md:grid-cols-12 gap-2 md:gap-3 px-4 py-3 rounded-xl border border-slate-100 bg-white hover:bg-slate-50 transition-colors items-center"
|
||||
>
|
||||
<!-- Date -->
|
||||
<div class="col-span-2 text-sm text-slate-700">
|
||||
<span class="md:hidden text-xs text-slate-400 font-medium mr-1">{{ t('finance.costDate') }}:</span>
|
||||
{{ formatDate(cost.created_at || cost.date) }}
|
||||
</div>
|
||||
|
||||
<!-- Type -->
|
||||
<div class="col-span-2">
|
||||
<span
|
||||
class="inline-block rounded-full px-2.5 py-0.5 text-xs font-medium"
|
||||
:class="costTypeBadge(cost.category_code || cost.cost_type)"
|
||||
>
|
||||
{{ cost.category_name || cost.cost_type || '—' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Amount -->
|
||||
<div class="col-span-2 text-sm font-bold text-slate-800">
|
||||
<span class="md:hidden text-xs text-slate-400 font-medium mr-1">{{ t('finance.costAmount') }}:</span>
|
||||
{{ formatAmount(cost.amount_gross, cost.currency) }}
|
||||
</div>
|
||||
|
||||
<!-- Vehicle -->
|
||||
<div class="col-span-3 text-sm text-slate-600 truncate">
|
||||
<span class="md:hidden text-xs text-slate-400 font-medium mr-1">{{ t('finance.costVehicle') }}:</span>
|
||||
{{ cost.vehicle_name || cost.license_plate || '—' }}
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="col-span-3 text-sm text-slate-500 truncate">
|
||||
<span class="md:hidden text-xs text-slate-400 font-medium mr-1">{{ t('finance.costDescription') }}:</span>
|
||||
{{ cost.description || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div
|
||||
v-if="totalPages > 1"
|
||||
class="flex items-center justify-between pt-4 mt-4 border-t border-slate-100"
|
||||
>
|
||||
<button
|
||||
@click="prevPage"
|
||||
:disabled="currentPage <= 1"
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||
:class="currentPage > 1
|
||||
? 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
: 'bg-slate-50 text-slate-300 cursor-not-allowed'"
|
||||
>
|
||||
← {{ t('finance.prev') }}
|
||||
</button>
|
||||
<span class="text-xs text-slate-400">
|
||||
{{ currentPage }} / {{ totalPages }}
|
||||
</span>
|
||||
<button
|
||||
@click="nextPage"
|
||||
:disabled="currentPage >= totalPages"
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||
:class="currentPage < totalPages
|
||||
? 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
: 'bg-slate-50 text-slate-300 cursor-not-allowed'"
|
||||
>
|
||||
{{ t('finance.next') }} →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Cost Entry Wizard ── -->
|
||||
<CostEntryWizard
|
||||
:is-open="showWizard"
|
||||
:vehicle="selectedVehicleForWizard"
|
||||
@close="closeCostWizard"
|
||||
@saved="onCostSaved"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '../../api/axios'
|
||||
import { useVehicleStore } from '../../stores/vehicle'
|
||||
import type { Vehicle } from '../../stores/vehicle'
|
||||
import CostEntryWizard from './CostEntryWizard.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const vehicleStore = useVehicleStore()
|
||||
|
||||
// ── Props ──
|
||||
const props = defineProps<{
|
||||
isOpen: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
// ── State ──
|
||||
const costs = ref<any[]>([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const currentPage = ref(1)
|
||||
const totalPages = ref(1)
|
||||
const pageSize = 20
|
||||
|
||||
// ── Vehicle Filter State ──
|
||||
const selectedVehicleFilter = ref('')
|
||||
const showWizard = ref(false)
|
||||
const selectedVehicleForWizard = ref<Vehicle | null>(null)
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return '—'
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function formatAmount(amount: string | number | null, currency?: string): string {
|
||||
if (amount == null) return '—'
|
||||
const num = typeof amount === 'string' ? parseFloat(amount) : amount
|
||||
const formatted = Number(num).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
return currency ? `${formatted} ${currency}` : `${formatted} Ft`
|
||||
}
|
||||
|
||||
function costTypeBadge(type: string | null): string {
|
||||
switch (type) {
|
||||
case 'fuel':
|
||||
case 'FUEL':
|
||||
case 'Üzemanyag':
|
||||
return 'bg-orange-100 text-orange-700'
|
||||
case 'service':
|
||||
case 'SERVICE':
|
||||
case 'Szerviz':
|
||||
return 'bg-blue-100 text-blue-700'
|
||||
case 'insurance':
|
||||
case 'INSURANCE':
|
||||
case 'Biztosítás':
|
||||
return 'bg-purple-100 text-purple-700'
|
||||
case 'tax':
|
||||
case 'TAX':
|
||||
case 'Adó':
|
||||
return 'bg-red-100 text-red-700'
|
||||
case 'parking':
|
||||
case 'PARKING':
|
||||
case 'Parkolás':
|
||||
return 'bg-amber-100 text-amber-700'
|
||||
case 'toll':
|
||||
case 'TOLL':
|
||||
case 'Útdíj':
|
||||
return 'bg-yellow-100 text-yellow-700'
|
||||
default:
|
||||
return 'bg-slate-100 text-slate-600'
|
||||
}
|
||||
}
|
||||
|
||||
// ── Data Fetching ──
|
||||
|
||||
async function fetchCosts() {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const params: Record<string, any> = {
|
||||
page: currentPage.value,
|
||||
page_size: pageSize,
|
||||
}
|
||||
// Add asset_id filter if a specific vehicle is selected
|
||||
if (selectedVehicleFilter.value) {
|
||||
params.asset_id = selectedVehicleFilter.value
|
||||
}
|
||||
|
||||
const res = await api.get('/expenses', { params })
|
||||
const data = res.data
|
||||
costs.value = data.data || data.items || data.results || []
|
||||
totalPages.value = data.total_pages || data.pagination?.total_pages || 1
|
||||
} catch (err: any) {
|
||||
const message =
|
||||
err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
'Nem sikerült betölteni a költségeket.'
|
||||
error.value = message
|
||||
console.error('[CostManagerModal] fetchCosts error:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onVehicleFilterChange() {
|
||||
currentPage.value = 1
|
||||
fetchCosts()
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (currentPage.value > 1) {
|
||||
currentPage.value--
|
||||
fetchCosts()
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (currentPage.value < totalPages.value) {
|
||||
currentPage.value++
|
||||
fetchCosts()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cost Entry Wizard ──
|
||||
|
||||
function openCostWizard() {
|
||||
// If a vehicle is selected in the filter, pass it to the wizard
|
||||
if (selectedVehicleFilter.value) {
|
||||
selectedVehicleForWizard.value =
|
||||
vehicleStore.vehicles.find(v => v.id === selectedVehicleFilter.value) || null
|
||||
} else {
|
||||
selectedVehicleForWizard.value = null
|
||||
}
|
||||
showWizard.value = true
|
||||
}
|
||||
|
||||
function closeCostWizard() {
|
||||
showWizard.value = false
|
||||
selectedVehicleForWizard.value = null
|
||||
}
|
||||
|
||||
function onCostSaved() {
|
||||
// Refresh the table data after a cost is saved
|
||||
closeCostWizard()
|
||||
currentPage.value = 1
|
||||
fetchCosts()
|
||||
}
|
||||
|
||||
// ── Lifecycle ──
|
||||
watch(() => props.isOpen, (open) => {
|
||||
if (open) {
|
||||
currentPage.value = 1
|
||||
selectedVehicleFilter.value = ''
|
||||
// Ensure vehicles are loaded for the filter dropdown
|
||||
if (vehicleStore.vehicles.length === 0) {
|
||||
vehicleStore.fetchVehicles()
|
||||
}
|
||||
fetchCosts()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (props.isOpen) {
|
||||
if (vehicleStore.vehicles.length === 0) {
|
||||
vehicleStore.fetchVehicles()
|
||||
}
|
||||
fetchCosts()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes flipIn {
|
||||
from {
|
||||
transform: perspective(1200px) rotateY(-90deg);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: perspective(1200px) rotateY(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.animate-flip-in {
|
||||
animation: flipIn 0.5s cubic-bezier(0.25, 0.8, 0.25, 1) forwards;
|
||||
}
|
||||
</style>
|
||||
140
frontend_app/src/components/dashboard/AdPlacementWidget.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<div class="ad-placement-widget">
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex items-center justify-center py-4"
|
||||
>
|
||||
<div class="h-6 w-6 animate-spin rounded-full border-2 border-white/10 border-t-[#70BC84]" />
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="text-xs text-red-400/60 text-center py-2"
|
||||
>
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Empty State (no ads available) -->
|
||||
<div
|
||||
v-else-if="ads.length === 0"
|
||||
class="block w-full"
|
||||
>
|
||||
<!-- Empty block – no ad to display, takes no visual space -->
|
||||
</div>
|
||||
|
||||
<!-- Ad Display -->
|
||||
<div
|
||||
v-else
|
||||
class="ad-content"
|
||||
>
|
||||
<div
|
||||
v-for="ad in ads"
|
||||
:key="ad.id"
|
||||
class="mb-2 last:mb-0"
|
||||
>
|
||||
<!-- Image Ad -->
|
||||
<a
|
||||
v-if="ad.creative_type === 'IMAGE' && ad.content_url"
|
||||
:href="ad.target_url || '#'"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="block rounded-lg overflow-hidden border border-white/10 hover:border-white/20 transition-all duration-200"
|
||||
>
|
||||
<img
|
||||
:src="ad.content_url"
|
||||
:alt="ad.alt_text || ad.campaign_name"
|
||||
class="w-full h-auto object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<!-- HTML Snippet Ad -->
|
||||
<div
|
||||
v-else-if="ad.creative_type === 'HTML_SNIPPET' && ad.html_snippet"
|
||||
v-html="ad.html_snippet"
|
||||
class="rounded-lg overflow-hidden"
|
||||
/>
|
||||
|
||||
<!-- Fallback: text-only ad -->
|
||||
<div
|
||||
v-else
|
||||
class="text-xs text-white/40 text-center py-1"
|
||||
>
|
||||
{{ ad.campaign_name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import api from '../../api/axios'
|
||||
|
||||
interface AdData {
|
||||
id: number
|
||||
campaign_id: number
|
||||
campaign_name: string
|
||||
creative_id: number
|
||||
creative_type: string
|
||||
content_url: string | null
|
||||
html_snippet: string | null
|
||||
alt_text: string | null
|
||||
target_url: string | null
|
||||
priority: number
|
||||
}
|
||||
|
||||
interface PlacementResponse {
|
||||
zone: string
|
||||
ads: AdData[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
zone?: string
|
||||
}>(), {
|
||||
zone: 'dashboard_widget',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'ads-loaded', hasAds: boolean): void
|
||||
}>()
|
||||
|
||||
const isLoading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const ads = ref<AdData[]>([])
|
||||
|
||||
async function fetchAds() {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const res = await api.get<PlacementResponse>(`/marketing/placements/${props.zone}`)
|
||||
ads.value = res.data.ads || []
|
||||
emit('ads-loaded', ads.value.length > 0)
|
||||
} catch (err: any) {
|
||||
// Silently handle 404 (placement not configured yet) — no console noise
|
||||
if (err.response?.status === 404) {
|
||||
ads.value = []
|
||||
error.value = null
|
||||
} else {
|
||||
error.value = 'Failed to load ads'
|
||||
console.error('[AdPlacementWidget] Error:', err)
|
||||
}
|
||||
emit('ads-loaded', false)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchAds()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ad-placement-widget {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
329
frontend_app/src/components/dashboard/ComplexExpenseModal.vue
Normal file
@@ -0,0 +1,329 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 z-[9999] flex items-center justify-center bg-slate-900/60 backdrop-blur-sm"
|
||||
@click.self="$emit('close')"
|
||||
>
|
||||
<div class="bg-white w-full max-w-lg rounded-2xl shadow-2xl overflow-hidden animate-fade-in-up">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between bg-slate-700 px-6 py-4">
|
||||
<h3 class="text-lg font-bold text-white flex items-center gap-2">
|
||||
<span>{{ isFeeMode ? '🅿️' : '➕' }}</span>
|
||||
{{ isFeeMode ? 'Parkolás / Útdíj rögzítése' : 'További költség rögzítése' }}
|
||||
</h3>
|
||||
<button
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-white/10 text-white/70 hover:bg-white/20 hover:text-white transition-colors"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Form Body -->
|
||||
<form @submit.prevent="handleSubmit" class="p-6 space-y-5">
|
||||
<!-- Vehicle selector (read-only display) -->
|
||||
<div class="rounded-lg bg-slate-50 border border-slate-200 px-4 py-3 text-sm text-slate-600">
|
||||
<span class="font-semibold text-slate-800">{{ vehicleLabel }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Date -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Dátum</label>
|
||||
<input
|
||||
v-model="form.date"
|
||||
type="date"
|
||||
required
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Amount (HUF) -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Összeg (HUF)</label>
|
||||
<input
|
||||
v-model.number="form.amount"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
required
|
||||
placeholder="pl. 5000"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Cascade Category: Main Category -->
|
||||
<div v-if="!isFeeMode">
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Főkategória</label>
|
||||
<select
|
||||
v-model="form.mainCategory"
|
||||
required
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
|
||||
@change="onMainCategoryChange"
|
||||
>
|
||||
<option value="" disabled>Válassz kategóriát...</option>
|
||||
<option
|
||||
v-for="cat in mainCategories"
|
||||
:key="cat.id"
|
||||
:value="cat.id"
|
||||
>
|
||||
{{ cat.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Cascade Category: Sub Category -->
|
||||
<div v-if="!isFeeMode && subCategories.length > 0">
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Alkategória</label>
|
||||
<select
|
||||
v-model="form.subCategory"
|
||||
required
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
|
||||
>
|
||||
<option value="" disabled>Válassz alkategóriát...</option>
|
||||
<option
|
||||
v-for="sub in subCategories"
|
||||
:key="sub.id"
|
||||
:value="sub.id"
|
||||
>
|
||||
{{ sub.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Description / Notes -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Megjegyzés</label>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
rows="3"
|
||||
placeholder="Rövid leírás a költségről..."
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Recurring Toggle -->
|
||||
<div v-if="!isFeeMode" class="flex items-center gap-3">
|
||||
<input
|
||||
id="is-recurring"
|
||||
v-model="form.isRecurring"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-slate-300 text-sf-accent focus:ring-sf-accent/20"
|
||||
/>
|
||||
<label for="is-recurring" class="text-sm text-slate-700">Rendszeres költség (havi ismétlődő)</label>
|
||||
</div>
|
||||
|
||||
<!-- Error message -->
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<!-- Submit button -->
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="costStore.isSubmitting"
|
||||
class="w-full rounded-xl bg-sf-accent py-3 text-sm font-bold text-white shadow-md transition-all hover:bg-sf-blue hover:shadow-lg disabled:opacity-60 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg
|
||||
v-if="costStore.isSubmitting"
|
||||
class="h-4 w-4 animate-spin"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
<span v-else>✅ Költség rögzítése</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { useCostStore } from '../../stores/cost'
|
||||
import api from '../../api/axios'
|
||||
import type { Vehicle } from '../../stores/vehicle'
|
||||
import { getLocalDateString } from '../../services/dateUtils'
|
||||
|
||||
const props = defineProps<{
|
||||
isOpen: boolean
|
||||
vehicle: Vehicle | null
|
||||
/** If true, pre-fills as FEES category (parking/toll) */
|
||||
isFeeMode?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const costStore = useCostStore()
|
||||
|
||||
// ── Form State ──
|
||||
const form = reactive({
|
||||
date: getLocalDateString(),
|
||||
amount: 0,
|
||||
mainCategory: '',
|
||||
subCategory: '',
|
||||
description: '',
|
||||
isRecurring: false,
|
||||
})
|
||||
|
||||
const errorMessage = ref<string | null>(null)
|
||||
|
||||
// ── Category Cascade State ──
|
||||
interface CategoryOption {
|
||||
id: number
|
||||
name: string
|
||||
code?: string
|
||||
parent_id: number | null
|
||||
}
|
||||
|
||||
const mainCategories = ref<CategoryOption[]>([])
|
||||
const subCategories = ref<CategoryOption[]>([])
|
||||
|
||||
const vehicleLabel = computed(() => {
|
||||
if (!props.vehicle) return 'Nincs kiválasztva jármű'
|
||||
const plate = props.vehicle.license_plate || ''
|
||||
const brand = props.vehicle.brand || ''
|
||||
const model = props.vehicle.model || ''
|
||||
return `${plate} (${brand} ${model})`.trim()
|
||||
})
|
||||
|
||||
/**
|
||||
* Fetch hierarchical categories from the dictionaries API.
|
||||
*/
|
||||
async function fetchCategories() {
|
||||
try {
|
||||
const res = await api.get('/dictionaries/cost-categories')
|
||||
const allCategories = res.data as CategoryOption[]
|
||||
|
||||
// Split into main (parent_id === null) and sub categories
|
||||
mainCategories.value = allCategories.filter((c) => c.parent_id === null)
|
||||
// Store all for sub-category filtering
|
||||
window.__allCostCategories = allCategories
|
||||
} catch (err) {
|
||||
console.error('[ComplexExpenseModal] Failed to load categories:', err)
|
||||
// Fallback: provide basic categories — IDs must match vehicle.cost_categories in DB
|
||||
mainCategories.value = [
|
||||
{ id: 1, name: 'Üzemanyag / Töltés', parent_id: null },
|
||||
{ id: 2, name: 'Szerviz & Karbantartás', parent_id: null },
|
||||
{ id: 3, name: 'Gumiabroncsok', parent_id: null },
|
||||
{ id: 4, name: 'Biztosítás', parent_id: null },
|
||||
{ id: 5, name: 'Adók', parent_id: null },
|
||||
{ id: 6, name: 'Útdíj & Parkolás', parent_id: null },
|
||||
{ id: 9, name: 'Ápolás & Kozmetika', parent_id: null },
|
||||
{ id: 10, name: 'Egyéb', parent_id: null },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function onMainCategoryChange() {
|
||||
// Reset sub-category when main changes
|
||||
form.subCategory = ''
|
||||
subCategories.value = []
|
||||
|
||||
if (!form.mainCategory) return
|
||||
|
||||
const allCats: CategoryOption[] = (window as any).__allCostCategories || []
|
||||
subCategories.value = allCats.filter(
|
||||
(c) => c.parent_id === Number(form.mainCategory)
|
||||
)
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!props.vehicle) {
|
||||
errorMessage.value = 'Nincs kiválasztva jármű.'
|
||||
return
|
||||
}
|
||||
|
||||
errorMessage.value = null
|
||||
|
||||
// Determine cost_type and category_id
|
||||
let costType: string
|
||||
let categoryId: number
|
||||
|
||||
if (props.isFeeMode) {
|
||||
// Fee mode: pre-filled as FEES — resolve dynamically by code, not by hardcoded ID
|
||||
costType = 'fee'
|
||||
const allCats: CategoryOption[] = (window as any).__allCostCategories || []
|
||||
const feesCat = allCats.find((c: CategoryOption) => c.code === 'FEES' && c.parent_id === null)
|
||||
categoryId = feesCat?.id || 6 // Fallback to ID 6 if lookup fails
|
||||
} else {
|
||||
// Complex mode: use selected sub-category or main category
|
||||
costType = 'other'
|
||||
categoryId = Number(form.subCategory || form.mainCategory) || 10
|
||||
}
|
||||
|
||||
const payload = {
|
||||
asset_id: props.vehicle.id,
|
||||
// organization_id is omitted — backend auto-resolves from asset
|
||||
category_id: categoryId,
|
||||
/** GROSS-FIRST (Masterbook 2.0.1): amount_gross is the primary field */
|
||||
amount_gross: form.amount,
|
||||
currency: 'HUF' as const,
|
||||
date: new Date(form.date).toISOString(),
|
||||
mileage_at_cost: null,
|
||||
description: form.description || (props.isFeeMode ? 'Parkolás / Útdíj' : 'Egyéb költség'),
|
||||
data: {
|
||||
is_recurring: form.isRecurring,
|
||||
category_id: categoryId,
|
||||
main_category_id: form.mainCategory || null,
|
||||
sub_category_id: form.subCategory || null,
|
||||
},
|
||||
}
|
||||
|
||||
const result = await costStore.addExpense(payload)
|
||||
|
||||
if (result) {
|
||||
// Reset form
|
||||
form.date = getLocalDateString()
|
||||
form.amount = 0
|
||||
form.mainCategory = ''
|
||||
form.subCategory = ''
|
||||
form.description = ''
|
||||
form.isRecurring = false
|
||||
emit('saved')
|
||||
} else {
|
||||
errorMessage.value = costStore.lastError || 'Ismeretlen hiba történt.'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the form date to today's local date every time the modal opens.
|
||||
* This ensures the date-picker always shows the correct local date,
|
||||
* even if the component is kept alive between opens.
|
||||
*/
|
||||
watch(() => props.isOpen, (open) => {
|
||||
if (open) {
|
||||
form.date = getLocalDateString()
|
||||
errorMessage.value = null
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchCategories()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
.animate-fade-in-up {
|
||||
animation: fadeInUp 0.25s ease-out forwards;
|
||||
}
|
||||
</style>
|
||||
209
frontend_app/src/components/dashboard/CostsActionsCard.vue
Normal file
@@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<div
|
||||
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
|
||||
>
|
||||
<!-- Top header bar — clickable to navigate to full Costs page -->
|
||||
<div
|
||||
class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4 gap-2 cursor-pointer transition-colors hover:bg-slate-600"
|
||||
@click="navigateToCostsPage"
|
||||
>
|
||||
<span class="text-white font-bold text-sm tracking-wide">💰 {{ t('dashboard.costsTitle') }}</span>
|
||||
<svg class="ml-auto h-4 w-4 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
|
||||
<!-- Vehicle Selector Dropdown -->
|
||||
<div class="mb-3">
|
||||
<label class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1 block">{{ t('dashboard.vehicle') }}</label>
|
||||
<select
|
||||
v-model="selectedActionVehicleId"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
|
||||
>
|
||||
<option
|
||||
v-for="v in vehicleStore.sortedVehicles"
|
||||
:key="v.id"
|
||||
:value="v.id"
|
||||
>
|
||||
{{ v.license_plate || '—' }} ({{ v.brand || '' }} {{ v.model || '' }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex-1 flex flex-col gap-2.5 justify-center">
|
||||
<!-- No vehicle warning -->
|
||||
<div
|
||||
v-if="!selectedActionVehicle"
|
||||
class="flex items-center justify-center rounded-xl bg-amber-50 border border-amber-200 px-4 py-3 text-sm text-amber-700"
|
||||
>
|
||||
<span>⚠️ {{ t('dashboard.noVehicleSelected') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- ⛽ Record Fueling (Big, prominent) -->
|
||||
<button
|
||||
v-if="selectedActionVehicle"
|
||||
@click="openFuelModal"
|
||||
class="flex items-center gap-3 rounded-xl bg-gradient-to-r from-sf-accent to-emerald-500 px-4 py-3 text-sm font-bold text-white shadow-md transition-all hover:shadow-lg hover:scale-[1.02] active:scale-[0.98]"
|
||||
>
|
||||
<span class="flex h-9 w-9 items-center justify-center rounded-lg bg-white/20 text-lg">⛽</span>
|
||||
<span>{{ t('dashboard.recordFueling') }}</span>
|
||||
<span class="ml-auto text-white/60 text-xs">→</span>
|
||||
</button>
|
||||
|
||||
<!-- 🅿️ Parking / Toll (Medium) -->
|
||||
<button
|
||||
v-if="selectedActionVehicle"
|
||||
@click="openFeeModal"
|
||||
class="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-700 transition-all hover:bg-slate-100 hover:shadow-md active:scale-[0.98]"
|
||||
>
|
||||
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-200 text-base">🅿️</span>
|
||||
<span>{{ t('dashboard.parkingToll') }}</span>
|
||||
<span class="ml-auto text-slate-400 text-xs">→</span>
|
||||
</button>
|
||||
|
||||
<!-- ➕ Additional Costs (Medium) — navigates to full Costs page -->
|
||||
<button
|
||||
v-if="selectedActionVehicle"
|
||||
@click="navigateToCostsPage"
|
||||
class="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-700 transition-all hover:bg-slate-100 hover:shadow-md active:scale-[0.98]"
|
||||
>
|
||||
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-200 text-base">➕</span>
|
||||
<span>{{ t('dashboard.additionalCosts') }}</span>
|
||||
<span class="ml-auto text-slate-400 text-xs">→</span>
|
||||
</button>
|
||||
|
||||
<!-- 🧾 Detailed Invoice (Medium) -->
|
||||
<button
|
||||
v-if="selectedActionVehicle"
|
||||
@click="openWizardModal"
|
||||
class="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-700 transition-all hover:bg-slate-100 hover:shadow-md active:scale-[0.98]"
|
||||
>
|
||||
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-200 text-base">🧾</span>
|
||||
<span>{{ t('dashboard.detailedInvoice') }}</span>
|
||||
<span class="ml-auto text-slate-400 text-xs">→</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════
|
||||
Cost Action Modals (Teleported to body)
|
||||
═══════════════════════════════════════════════════════════════════ -->
|
||||
<SimpleFuelModal
|
||||
:is-open="isFuelModalOpen"
|
||||
:vehicle="selectedActionVehicle"
|
||||
@close="isFuelModalOpen = false"
|
||||
@saved="onModalSaved"
|
||||
/>
|
||||
<ComplexExpenseModal
|
||||
:is-open="isFeeModalOpen"
|
||||
:vehicle="selectedActionVehicle"
|
||||
:is-fee-mode="true"
|
||||
@close="isFeeModalOpen = false"
|
||||
@saved="onModalSaved"
|
||||
/>
|
||||
<ComplexExpenseModal
|
||||
:is-open="isComplexModalOpen"
|
||||
:vehicle="selectedActionVehicle"
|
||||
:is-fee-mode="false"
|
||||
@close="isComplexModalOpen = false"
|
||||
@saved="onModalSaved"
|
||||
/>
|
||||
<CostEntryWizard
|
||||
:is-open="isWizardModalOpen"
|
||||
:vehicle="selectedActionVehicle"
|
||||
@close="isWizardModalOpen = false"
|
||||
@saved="onModalSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useVehicleStore } from '../../stores/vehicle'
|
||||
import SimpleFuelModal from './SimpleFuelModal.vue'
|
||||
import ComplexExpenseModal from './ComplexExpenseModal.vue'
|
||||
import CostEntryWizard from '../cost/CostEntryWizard.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const vehicleStore = useVehicleStore()
|
||||
|
||||
/** Navigate to the full Costs page */
|
||||
function navigateToCostsPage() {
|
||||
router.push('/dashboard/costs')
|
||||
}
|
||||
|
||||
// ── Emits ──
|
||||
const emit = defineEmits<{
|
||||
/**
|
||||
* F5 Bug fix (RBAC Phase 3): Értesíti a szülő komponenst, hogy egy költség sikeresen el lett mentve.
|
||||
* A szülő (DashboardView) továbbítja ezt a VehicleDetailModal-nak, ami újratölti a költségeket.
|
||||
*/
|
||||
'cost-saved': [vehicleId: string]
|
||||
}>()
|
||||
|
||||
// ── Cost Card Action Center State ──
|
||||
const selectedActionVehicleId = ref<string>('')
|
||||
const isFuelModalOpen = ref(false)
|
||||
const isFeeModalOpen = ref(false)
|
||||
const isComplexModalOpen = ref(false)
|
||||
const isWizardModalOpen = ref(false)
|
||||
|
||||
/** The currently selected vehicle object for the action modals */
|
||||
const selectedActionVehicle = computed(() => {
|
||||
if (!selectedActionVehicleId.value) return null
|
||||
return vehicleStore.vehicles.find(v => v.id === selectedActionVehicleId.value) || null
|
||||
})
|
||||
|
||||
/** Auto-select the first (primary/sorted) vehicle on load */
|
||||
function autoSelectVehicle() {
|
||||
if (vehicleStore.sortedVehicles.length > 0 && !selectedActionVehicleId.value) {
|
||||
selectedActionVehicleId.value = vehicleStore.sortedVehicles[0].id
|
||||
}
|
||||
}
|
||||
|
||||
function openFuelModal() {
|
||||
if (!selectedActionVehicle.value) return
|
||||
isFuelModalOpen.value = true
|
||||
}
|
||||
|
||||
function openFeeModal() {
|
||||
if (!selectedActionVehicle.value) return
|
||||
isFeeModalOpen.value = true
|
||||
}
|
||||
function openComplexModal() {
|
||||
if (!selectedActionVehicle.value) return
|
||||
isComplexModalOpen.value = true
|
||||
}
|
||||
|
||||
function openWizardModal() {
|
||||
if (!selectedActionVehicle.value) return
|
||||
isWizardModalOpen.value = true
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* F5 Bug fix (RBAC Phase 3): Modal mentés után bezárja a modalt,
|
||||
* és értesíti a szülőt, hogy az új költség megjelent.
|
||||
*/
|
||||
function onModalSaved() {
|
||||
isFuelModalOpen.value = false
|
||||
isFeeModalOpen.value = false
|
||||
isComplexModalOpen.value = false
|
||||
isWizardModalOpen.value = false
|
||||
|
||||
// Értesítjük a szülőt, hogy frissítse a VehicleDetailModal költségeit
|
||||
if (selectedActionVehicleId.value) {
|
||||
emit('cost-saved', selectedActionVehicleId.value)
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for vehicles to load, then auto-select
|
||||
watch(() => vehicleStore.vehicles.length, () => {
|
||||
autoSelectVehicle()
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
180
frontend_app/src/components/dashboard/FinancialCard.vue
Normal file
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<div
|
||||
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative cursor-pointer transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
|
||||
@click="$emit('open-details')"
|
||||
>
|
||||
<!-- Top header bar -->
|
||||
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
|
||||
<span class="text-white font-bold text-sm tracking-wide">💰 {{ t('finance.wallet') }}</span>
|
||||
<span
|
||||
v-if="!isLoading && !error"
|
||||
class="ml-auto inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700"
|
||||
>
|
||||
{{ t('finance.live') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex-1 flex items-center justify-center"
|
||||
>
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-4 border-slate-200 border-t-sf-accent" />
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="flex-1 flex flex-col items-center justify-center text-center px-4"
|
||||
>
|
||||
<svg class="w-10 h-10 text-amber-500 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<p class="text-xs text-slate-500">{{ error }}</p>
|
||||
<button
|
||||
@click="retry"
|
||||
class="mt-2 text-xs text-sf-accent hover:underline"
|
||||
>
|
||||
{{ t('common.retry') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Balance Content -->
|
||||
<div v-else class="flex-1 flex flex-col gap-3">
|
||||
<!-- Total Credits (big number) -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3 text-center">
|
||||
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wider mb-1">
|
||||
{{ t('finance.totalCredits') }}
|
||||
</p>
|
||||
<p class="text-3xl font-extrabold text-slate-800">
|
||||
{{ formatCredits(totalCredits) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Breakdown: Purchased + Service Coins -->
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2.5 text-center">
|
||||
<p class="text-xs text-slate-500 font-medium uppercase tracking-wider">{{ t('finance.purchased') }}</p>
|
||||
<p class="text-lg font-bold text-slate-800 mt-0.5">{{ formatCredits(purchasedCredits) }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2.5 text-center">
|
||||
<p class="text-xs text-slate-500 font-medium uppercase tracking-wider">{{ t('finance.bonus') }}</p>
|
||||
<p class="text-lg font-bold text-emerald-600 mt-0.5">{{ formatCredits(serviceCoins) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Earned + Voucher mini row -->
|
||||
<div class="flex items-center justify-between text-xs text-slate-400 px-1">
|
||||
<span>{{ t('finance.earned') }}: <strong class="text-slate-600">{{ formatCredits(earnedCredits) }}</strong></span>
|
||||
<span>{{ t('finance.voucher') }}: <strong class="text-slate-600">{{ formatCredits(voucherBalance) }}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom CTA: Top Up button -->
|
||||
<button
|
||||
@click="handleTopUp"
|
||||
class="mt-auto mb-4 mx-auto px-8 py-3 bg-gradient-to-r from-sf-accent to-emerald-500 hover:from-sf-accent/90 hover:to-emerald-600 text-white rounded-2xl font-medium text-sm transition-all shadow-md hover:shadow-lg active:scale-[0.98]"
|
||||
>
|
||||
{{ t('finance.topUp') }} ⚡
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useFinanceStore } from '../../stores/finance'
|
||||
|
||||
const { t } = useI18n()
|
||||
const financeStore = useFinanceStore()
|
||||
|
||||
// ── Emits ──
|
||||
const emit = defineEmits<{
|
||||
'open-details': []
|
||||
}>()
|
||||
|
||||
// ── Reactive state from store ──
|
||||
const isLoading = computed(() => financeStore.isLoading)
|
||||
const error = computed(() => financeStore.error)
|
||||
const totalCredits = computed(() => financeStore.totalCredits)
|
||||
const purchasedCredits = computed(() => financeStore.purchasedCredits)
|
||||
const serviceCoins = computed(() => financeStore.serviceCoins)
|
||||
const earnedCredits = computed(() => financeStore.earnedCredits)
|
||||
const voucherBalance = computed(() => financeStore.voucherBalance)
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
/**
|
||||
* Format credit amount for display.
|
||||
* Shows 2 decimal places if fractional, otherwise integer.
|
||||
*/
|
||||
function formatCredits(value: number): string {
|
||||
if (Number.isInteger(value)) {
|
||||
return value.toLocaleString()
|
||||
}
|
||||
return value.toFixed(2)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the Top Up button click.
|
||||
* Currently shows a toast notification — payment gateway coming soon.
|
||||
*/
|
||||
function handleTopUp() {
|
||||
// Show a toast-like notification
|
||||
const toast = document.createElement('div')
|
||||
toast.className =
|
||||
'fixed top-6 right-6 z-[99999] bg-slate-800 text-white px-6 py-3 rounded-xl shadow-2xl text-sm font-medium animate-slide-in'
|
||||
toast.textContent = '🔔 Fizetési kapu hamarosan elérhető!'
|
||||
document.body.appendChild(toast)
|
||||
|
||||
// Auto-remove after 3 seconds
|
||||
setTimeout(() => {
|
||||
toast.classList.add('animate-slide-out')
|
||||
setTimeout(() => toast.remove(), 300)
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry fetching the wallet balance.
|
||||
*/
|
||||
function retry() {
|
||||
financeStore.fetchWalletBalance()
|
||||
}
|
||||
|
||||
// ── Lifecycle ──
|
||||
onMounted(() => {
|
||||
financeStore.fetchWalletBalance()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes slideOut {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.animate-slide-in {
|
||||
animation: slideIn 0.3s ease-out forwards;
|
||||
}
|
||||
.animate-slide-out {
|
||||
animation: slideOut 0.3s ease-in forwards;
|
||||
}
|
||||
</style>
|
||||
62
frontend_app/src/components/dashboard/GamificationCard.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div
|
||||
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative cursor-pointer transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
|
||||
@click="$emit('open-card', 'gamification')"
|
||||
>
|
||||
<!-- Top header bar -->
|
||||
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
|
||||
<span class="text-white font-bold text-sm tracking-wide">🏆 {{ t('dashboard.statsAndPoints') }}</span>
|
||||
<span class="ml-auto inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700">
|
||||
{{ t('dashboard.live') }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
|
||||
<div class="flex-1 space-y-3">
|
||||
<!-- Score card -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3 text-center">
|
||||
<p class="text-3xl font-extrabold text-emerald-600">2,450</p>
|
||||
<p class="text-xs text-slate-500 mt-1">{{ t('dashboard.monthlyScore') }}</p>
|
||||
</div>
|
||||
<!-- Achievement badges -->
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">
|
||||
{{ t('dashboard.achievements') }}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="badge in badges"
|
||||
:key="badge.label"
|
||||
class="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs text-slate-600"
|
||||
>
|
||||
{{ badge.icon }} {{ badge.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Bottom CTA button -->
|
||||
<button class="mt-auto mb-4 mx-auto px-8 py-3 bg-slate-700 hover:bg-slate-800 text-white rounded-2xl font-medium text-sm transition-colors shadow-md">
|
||||
{{ t('dashboard.refresh') }} 🏆
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'open-card': [cardId: string]
|
||||
}>()
|
||||
|
||||
// ── Placeholder badges ──
|
||||
const badges = ref([
|
||||
{ icon: '🌱', label: 'Eco Driver' },
|
||||
{ icon: '📏', label: '10k km Club' },
|
||||
{ icon: '🔧', label: 'Service Pro' },
|
||||
{ icon: '⭐', label: 'Top Rater' },
|
||||
])
|
||||
</script>
|
||||
95
frontend_app/src/components/dashboard/MyVehiclesCard.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div
|
||||
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
|
||||
>
|
||||
<!-- Invisible overlay to capture clicks on the whole card -->
|
||||
<div
|
||||
class="absolute inset-0 z-10 cursor-pointer"
|
||||
@click="navigateToFleet"
|
||||
/>
|
||||
|
||||
<!-- Top header bar -->
|
||||
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
|
||||
<span class="text-white font-bold text-sm tracking-wide">🚗 {{ t('dashboard.myVehicles') }}</span>
|
||||
<span class="ml-auto text-xs text-white/60">{{ vehicleStore.vehicles.length }} {{ t('dashboard.vehicles') }}</span>
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div class="p-4 flex-1 flex flex-col text-slate-800 min-h-0">
|
||||
<div class="flex-1 overflow-y-auto space-y-2">
|
||||
<VehicleCardCompact
|
||||
v-for="vehicle in displayVehicles.slice(0, 3)"
|
||||
:key="vehicle.id"
|
||||
:vehicle="vehicle"
|
||||
class="relative z-20"
|
||||
@click.stop="navigateToFleet"
|
||||
@plate-click.stop="openVehicleDetail(vehicle)"
|
||||
/>
|
||||
<!-- Empty state (only when BOTH real and mock are empty) -->
|
||||
<div
|
||||
v-if="displayVehicles.length === 0"
|
||||
class="flex flex-col items-center justify-center py-6 text-slate-400 relative z-20"
|
||||
@click.stop="navigateToFleet"
|
||||
>
|
||||
<svg class="w-10 h-10 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<p class="text-sm">{{ t('dashboard.noVehicles') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { useVehicleStore } from '../../stores/vehicle'
|
||||
import type { VehicleData } from '../../types/vehicle'
|
||||
import VehicleCardCompact from '../vehicle/VehicleCardCompact.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
const vehicleStore = useVehicleStore()
|
||||
|
||||
/**
|
||||
* Display vehicles from the backend API only.
|
||||
* Sorted by is_primary flag (favorite first), then alphabetically.
|
||||
* Shows all real vehicles; no mock/fallback data is injected.
|
||||
*/
|
||||
const displayVehicles = computed(() => {
|
||||
return vehicleStore.sortedVehicles
|
||||
})
|
||||
|
||||
/**
|
||||
* Navigate directly to the Vehicle Details page.
|
||||
* Dynamically decides the route based on the user's current context:
|
||||
* - Corporate mode → /organization/:id/vehicles/:vehicleId
|
||||
* - Private mode → /dashboard/vehicles/:vehicleId
|
||||
*/
|
||||
function openVehicleDetail(vehicle: VehicleData) {
|
||||
const vehicleId = vehicle.id
|
||||
if (authStore.isCorporateMode && authStore.user?.active_organization_id) {
|
||||
router.push(`/organization/${authStore.user.active_organization_id}/vehicles/${vehicleId}`)
|
||||
} else {
|
||||
router.push(`/dashboard/vehicles/${vehicleId}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the Fleet View (Garage) page.
|
||||
* Dynamically decides the route based on the user's current context:
|
||||
* - Corporate mode → /organization/:id/vehicles
|
||||
* - Private mode → /dashboard/vehicles
|
||||
*/
|
||||
function navigateToFleet() {
|
||||
if (authStore.isCorporateMode && authStore.user?.active_organization_id) {
|
||||
router.push(`/organization/${authStore.user.active_organization_id}/vehicles`)
|
||||
} else {
|
||||
// FleetView is a child route under /dashboard
|
||||
router.push('/dashboard/vehicles')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
194
frontend_app/src/components/dashboard/PrivateVehicleManager.vue
Normal file
@@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- ── Header: Title + Counter + Add Button (nav arrows removed) ── -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<h2 class="text-2xl font-bold text-slate-800">Saját Járművek</h2>
|
||||
<span
|
||||
v-if="vehicles.length > 0"
|
||||
class="inline-flex items-center rounded-full bg-sf-accent/10 px-3 py-1 text-xs font-semibold text-sf-accent"
|
||||
>
|
||||
{{ vehicles.length }} jármű
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
@click="$emit('add-new')"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-sf-accent px-5 py-2.5 text-sm font-semibold text-white shadow-md transition-all hover:bg-sf-blue hover:shadow-lg"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Új jármű hozzáadása
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Carousel (with vehicles) or Empty State ── -->
|
||||
<template v-if="vehicles.length > 0">
|
||||
<div class="relative">
|
||||
<!-- Left Edge Navigation Strip (only show when scrolling is needed) -->
|
||||
<div
|
||||
v-if="vehicles.length > 2"
|
||||
@click="scrollLeft"
|
||||
class="absolute left-0 top-0 bottom-0 w-12 bg-gradient-to-r from-white/80 to-transparent flex items-center justify-start cursor-pointer z-10 rounded-l-2xl"
|
||||
>
|
||||
<svg class="h-6 w-6 text-slate-500 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Right Edge Navigation Strip (only show when scrolling is needed) -->
|
||||
<div
|
||||
v-if="vehicles.length > 2"
|
||||
@click="scrollRight"
|
||||
class="absolute right-0 top-0 bottom-0 w-12 bg-gradient-to-l from-white/80 to-transparent flex items-center justify-end cursor-pointer z-10 rounded-r-2xl"
|
||||
>
|
||||
<svg class="h-6 w-6 text-slate-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="carouselRef"
|
||||
class="flex overflow-x-auto snap-x snap-mandatory gap-6 pb-4 w-full scroll-smooth no-scrollbar"
|
||||
:class="vehicles.length > 2 ? 'px-12' : 'px-2'"
|
||||
style="scrollbar-width: none; -ms-overflow-style: none;"
|
||||
>
|
||||
<VehicleCardStandard
|
||||
v-for="vehicle in vehicles"
|
||||
:key="vehicle.id"
|
||||
:vehicle="vehicle"
|
||||
@click="openDetailView(vehicle)"
|
||||
@edit="$emit('edit-vehicle', vehicle)"
|
||||
@set-primary="setPrimaryVehicle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── Empty State (v-else) ── -->
|
||||
<template v-else>
|
||||
<div class="flex flex-1 flex-col items-center justify-center text-slate-400">
|
||||
<svg class="mb-4 h-20 w-20 text-slate-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<h3 class="text-lg font-semibold text-slate-500">Még nincsenek járművek</h3>
|
||||
<p class="mt-1 text-sm text-slate-400">Kattints az "Új jármű hozzáadása" gombra az első jármű felvételéhez.</p>
|
||||
<button
|
||||
@click="$emit('add-new')"
|
||||
class="mt-6 inline-flex items-center gap-2 rounded-xl bg-sf-accent px-6 py-3 text-sm font-semibold text-white shadow-md transition-all hover:bg-sf-blue hover:shadow-lg"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Új jármű hozzáadása
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── Vehicle Detail Modal (read-only) ── -->
|
||||
<VehicleDetailModal
|
||||
:is-open="isDetailOpen"
|
||||
:vehicle="detailVehicle"
|
||||
@close="isDetailOpen = false"
|
||||
@edit="openEditFromDetail"
|
||||
@set-primary="setPrimaryVehicle"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, onMounted, watch } from 'vue'
|
||||
import type { VehicleData } from '../../types/vehicle'
|
||||
import { useVehicleStore } from '../../stores/vehicle'
|
||||
import VehicleDetailModal from '../vehicle/VehicleDetailModal.vue'
|
||||
import VehicleCardStandard from '../vehicle/VehicleCardStandard.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
targetVehicleId?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'add-new': []
|
||||
'edit-vehicle': [vehicle: VehicleData]
|
||||
}>()
|
||||
|
||||
const vehicleStore = useVehicleStore()
|
||||
|
||||
/**
|
||||
* Vehicle list sourced exclusively from the backend API.
|
||||
* Sorted by is_primary flag (favorite first), then alphabetically.
|
||||
* No mock/fallback data is injected — if the API returns an empty list,
|
||||
* the UI displays an empty garage.
|
||||
*/
|
||||
const vehicles = computed<VehicleData[]>(() => {
|
||||
return vehicleStore.sortedVehicles as unknown as VehicleData[]
|
||||
})
|
||||
|
||||
// ── Carousel ref & scroll functions ──
|
||||
const carouselRef = ref<HTMLElement | null>(null)
|
||||
|
||||
function scrollLeft() {
|
||||
if (carouselRef.value) {
|
||||
carouselRef.value.scrollBy({ left: -300, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
function scrollRight() {
|
||||
if (carouselRef.value) {
|
||||
carouselRef.value.scrollBy({ left: 300, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Detail Modal state ──
|
||||
const isDetailOpen = ref(false)
|
||||
const detailVehicle = ref<VehicleData | null>(null)
|
||||
|
||||
function openDetailView(vehicle: VehicleData) {
|
||||
detailVehicle.value = vehicle
|
||||
isDetailOpen.value = true
|
||||
}
|
||||
|
||||
// ── Bridge: Detail → Edit (emits to parent DashboardView) ──
|
||||
async function openEditFromDetail(vehicle: VehicleData) {
|
||||
isDetailOpen.value = false
|
||||
// Use nextTick so detail modal closes before edit opens
|
||||
await nextTick()
|
||||
emit('edit-vehicle', vehicle)
|
||||
}
|
||||
|
||||
// ── Targeted scroll to vehicle card ──
|
||||
function scrollToVehicle(licensePlate: string) {
|
||||
const el = document.getElementById('vehicle-card-' + licensePlate)
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'instant', block: 'nearest', inline: 'center' })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.targetVehicleId) {
|
||||
// Small delay to ensure DOM is rendered
|
||||
setTimeout(() => scrollToVehicle(props.targetVehicleId!), 100)
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.targetVehicleId, (newVal) => {
|
||||
if (newVal) {
|
||||
setTimeout(() => scrollToVehicle(newVal), 100)
|
||||
}
|
||||
})
|
||||
|
||||
// ── Set Primary Vehicle ──
|
||||
async function setPrimaryVehicle(vehicle: VehicleData) {
|
||||
console.log('[PrivateVehicleManager] setPrimaryVehicle called for:', vehicle.id, vehicle.license_plate)
|
||||
vehicleStore.setPrimaryVehicle(vehicle.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Hide scrollbar for the carousel container */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
63
frontend_app/src/components/dashboard/ProfileTrustCard.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<BaseCard
|
||||
hoverable
|
||||
@click="$emit('open-card', 'stats')"
|
||||
>
|
||||
<!-- ═══ HEADER: Profile title ═══ -->
|
||||
<template #header>
|
||||
<span class="text-white font-bold text-sm tracking-wide">🛡️ {{ t('common.profileSettings') }}</span>
|
||||
</template>
|
||||
|
||||
<!-- ═══ BODY: User info + Trust Score + Stats ═══ -->
|
||||
<div class="flex-1 space-y-3">
|
||||
<!-- User info -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||
<p class="text-sm font-bold text-slate-800">{{ authStore.userName || t('dashboard.guest') }}</p>
|
||||
<p class="text-xs text-slate-400 mt-0.5">{{ authStore.user?.email }}</p>
|
||||
</div>
|
||||
<!-- Trust Score -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs text-slate-500 font-semibold uppercase tracking-wider">Trust Score</span>
|
||||
<span class="text-sm font-bold text-emerald-600">A+</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-full bg-slate-200">
|
||||
<div class="h-full rounded-full bg-gradient-to-r from-emerald-400 to-emerald-600 transition-all duration-500" style="width: 92%" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Quick stats -->
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2 text-center">
|
||||
<p class="text-lg font-bold text-slate-800">{{ authStore.myOrganizations.length }}</p>
|
||||
<p class="text-xs text-slate-400">{{ t('header.myCompanies') }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2 text-center">
|
||||
<p class="text-lg font-bold text-slate-800">{{ vehicleStore.vehicles.length }}</p>
|
||||
<p class="text-xs text-slate-400">{{ t('dashboard.totalVehicles') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ FOOTER: CTA button ═══ -->
|
||||
<template #footer>
|
||||
<button class="w-full py-3 bg-slate-700 hover:bg-slate-800 text-white rounded-2xl font-medium text-sm transition-colors shadow-md">
|
||||
{{ t('common.profileSettings') }} →
|
||||
</button>
|
||||
</template>
|
||||
</BaseCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { useVehicleStore } from '../../stores/vehicle'
|
||||
import BaseCard from '../ui/BaseCard.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
const vehicleStore = useVehicleStore()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'open-card': [cardId: string]
|
||||
}>()
|
||||
</script>
|
||||
68
frontend_app/src/components/dashboard/ServiceFinderCard.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div
|
||||
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl cursor-pointer"
|
||||
@click="openExternalServiceFinder"
|
||||
>
|
||||
<!-- Top header bar -->
|
||||
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
|
||||
<span class="text-white font-bold text-sm tracking-wide">{{ t('serviceFinder.title') }}</span>
|
||||
</div>
|
||||
<!-- Content: 3 simple buttons stacked vertically -->
|
||||
<div class="p-4 flex-1 flex flex-col justify-center gap-2">
|
||||
<button
|
||||
@click.stop="openExternalServiceFinder"
|
||||
class="w-full rounded-xl bg-sf-accent px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-all hover:bg-sf-accent/90 hover:shadow-md active:scale-[0.97] cursor-pointer"
|
||||
>
|
||||
🔍 {{ t('serviceFinder.plannedMaintenance') }}
|
||||
</button>
|
||||
<button
|
||||
@click.stop="router.push('/dashboard/service-finder?mode=sos')"
|
||||
class="w-full rounded-xl border border-red-300 bg-red-50 px-4 py-2.5 text-sm font-semibold text-red-700 shadow-sm transition-all hover:bg-red-100 hover:shadow-md active:scale-[0.97] cursor-pointer"
|
||||
>
|
||||
🚨 SOS {{ t('serviceFinder.sosTitle') }}
|
||||
</button>
|
||||
<button
|
||||
@click.stop="isSfQuickAddOpen = true"
|
||||
class="w-full rounded-xl border border-dashed border-slate-300 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-700 shadow-sm transition-all hover:border-sf-accent hover:bg-sf-accent/5 hover:shadow-md active:scale-[0.97] cursor-pointer"
|
||||
>
|
||||
➕ {{ t('provider.quickAddTitle') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Provider Quick Add Modal -->
|
||||
<ProviderQuickAddModal
|
||||
:is-open="isSfQuickAddOpen"
|
||||
@close="isSfQuickAddOpen = false"
|
||||
@saved="onSfQuickAddSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ProviderQuickAddModal from '../provider/ProviderQuickAddModal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
// ── Service Finder (Card 3) — Launcher ──
|
||||
const isSfQuickAddOpen = ref(false)
|
||||
|
||||
/**
|
||||
* Navigates to the Service Finder page in the same tab.
|
||||
* Used by the card click and the "Tervezett karbantartás" button.
|
||||
*/
|
||||
function openExternalServiceFinder() {
|
||||
router.push('/dashboard/service-finder')
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when ProviderQuickAddModal saves a new provider.
|
||||
*/
|
||||
function onSfQuickAddSaved(provider: { id: number; name: string; category?: string | null; city?: string | null; address?: string | null }) {
|
||||
isSfQuickAddOpen.value = false
|
||||
alert(`🎉 ${t('provider.successMessage', { points: 0 })}`)
|
||||
}
|
||||
</script>
|
||||
234
frontend_app/src/components/dashboard/SimpleFuelModal.vue
Normal file
@@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 z-[9999] flex items-center justify-center bg-slate-900/60 backdrop-blur-sm"
|
||||
@click.self="$emit('close')"
|
||||
>
|
||||
<div class="bg-white w-full max-w-md rounded-2xl shadow-2xl overflow-hidden animate-fade-in-up">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between bg-slate-700 px-6 py-4">
|
||||
<h3 class="text-lg font-bold text-white flex items-center gap-2">
|
||||
<span>⛽</span> Tankolás rögzítése
|
||||
</h3>
|
||||
<button
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-white/10 text-white/70 hover:bg-white/20 hover:text-white transition-colors"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Form Body -->
|
||||
<form @submit.prevent="handleSubmit" class="p-6 space-y-5">
|
||||
<!-- Vehicle selector (read-only display of selected vehicle) -->
|
||||
<div class="rounded-lg bg-slate-50 border border-slate-200 px-4 py-3 text-sm text-slate-600">
|
||||
<span class="font-semibold text-slate-800">{{ vehicleLabel }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Date -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Dátum</label>
|
||||
<input
|
||||
v-model="form.date"
|
||||
type="date"
|
||||
required
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Amount (HUF) -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Összeg (HUF)</label>
|
||||
<input
|
||||
v-model.number="form.amount"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
required
|
||||
placeholder="pl. 15000"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Quantity (Liters/kWh) -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Mennyiség (Liter / kWh)</label>
|
||||
<input
|
||||
v-model.number="form.quantity"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
required
|
||||
placeholder="pl. 45.5"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Odometer -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Km óra állás</label>
|
||||
<input
|
||||
v-model.number="form.odometer"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="pl. 123456"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Error message -->
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<!-- Submit button -->
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="costStore.isSubmitting"
|
||||
class="w-full rounded-xl bg-sf-accent py-3 text-sm font-bold text-white shadow-md transition-all hover:bg-sf-blue hover:shadow-lg disabled:opacity-60 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg
|
||||
v-if="costStore.isSubmitting"
|
||||
class="h-4 w-4 animate-spin"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
<span v-else>✅ Tankolás rögzítése</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { useCostStore } from '../../stores/cost'
|
||||
import api from '../../api/axios'
|
||||
import type { Vehicle } from '../../stores/vehicle'
|
||||
import { getLocalDateString } from '../../services/dateUtils'
|
||||
|
||||
const props = defineProps<{
|
||||
isOpen: boolean
|
||||
vehicle: Vehicle | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const costStore = useCostStore()
|
||||
|
||||
const form = reactive({
|
||||
date: getLocalDateString(),
|
||||
amount: 0,
|
||||
quantity: 0,
|
||||
odometer: 0,
|
||||
})
|
||||
|
||||
const errorMessage = ref<string | null>(null)
|
||||
|
||||
// ── Category Resolution ──
|
||||
const fuelCategoryId = ref<number>(1) // Default fallback
|
||||
|
||||
async function resolveFuelCategory() {
|
||||
try {
|
||||
const res = await api.get('/dictionaries/cost-categories')
|
||||
const allCats = res.data as Array<{ id: number; code?: string; name: string; parent_id: number | null }>
|
||||
const fuelCat = allCats.find((c) => c.code === 'FUEL' && c.parent_id === null)
|
||||
if (fuelCat) {
|
||||
fuelCategoryId.value = fuelCat.id
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[SimpleFuelModal] Could not fetch categories, using fallback ID 1:', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the form date to today's local date every time the modal opens.
|
||||
* This ensures the date-picker always shows the correct local date,
|
||||
* even if the component is kept alive between opens.
|
||||
*/
|
||||
watch(() => props.isOpen, (open) => {
|
||||
if (open) {
|
||||
form.date = getLocalDateString()
|
||||
errorMessage.value = null
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
resolveFuelCategory()
|
||||
})
|
||||
|
||||
const vehicleLabel = computed(() => {
|
||||
if (!props.vehicle) return 'Nincs kiválasztva jármű'
|
||||
const plate = props.vehicle.license_plate || ''
|
||||
const brand = props.vehicle.brand || ''
|
||||
const model = props.vehicle.model || ''
|
||||
return `${plate} (${brand} ${model})`.trim()
|
||||
})
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!props.vehicle) {
|
||||
errorMessage.value = 'Nincs kiválasztva jármű.'
|
||||
return
|
||||
}
|
||||
|
||||
errorMessage.value = null
|
||||
|
||||
// Build payload: category auto-set to FUEL (resolved dynamically by code)
|
||||
// organization_id is omitted — backend auto-resolves from asset
|
||||
const payload = {
|
||||
asset_id: props.vehicle.id,
|
||||
category_id: fuelCategoryId.value, // FUEL category (resolved by code)
|
||||
/** GROSS-FIRST (Masterbook 2.0.1): amount_gross is the primary field */
|
||||
amount_gross: form.amount,
|
||||
currency: 'HUF' as const,
|
||||
date: new Date(form.date).toISOString(),
|
||||
mileage_at_cost: form.odometer > 0 ? form.odometer : null,
|
||||
description: `Tankolás: ${form.quantity} L/kWh`,
|
||||
data: {
|
||||
quantity: form.quantity,
|
||||
fuel_type: props.vehicle.fuel_type || null,
|
||||
},
|
||||
}
|
||||
|
||||
const result = await costStore.addExpense(payload)
|
||||
|
||||
if (result) {
|
||||
// Reset form
|
||||
form.date = getLocalDateString()
|
||||
form.amount = 0
|
||||
form.quantity = 0
|
||||
form.odometer = 0
|
||||
emit('saved')
|
||||
} else {
|
||||
errorMessage.value = costStore.lastError || 'Ismeretlen hiba történt.'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
.animate-fade-in-up {
|
||||
animation: fadeInUp 0.25s ease-out forwards;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,213 @@
|
||||
<template>
|
||||
<div class="subscription-status-widget">
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex items-center justify-center py-3"
|
||||
>
|
||||
<div class="h-5 w-5 animate-spin rounded-full border-2 border-white/10 border-t-[#70BC84]" />
|
||||
</div>
|
||||
|
||||
<!-- No subscription data -->
|
||||
<div
|
||||
v-else-if="!subscriptionPlan"
|
||||
class="text-xs text-white/40 text-center py-2"
|
||||
>
|
||||
{{ t('subscription.noPlan') }}
|
||||
</div>
|
||||
|
||||
<!-- Subscription Status Display -->
|
||||
<div
|
||||
v-else
|
||||
class="subscription-info space-y-2"
|
||||
>
|
||||
<!-- Plan Name & Status -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-semibold text-white/90 flex items-center gap-1.5">
|
||||
<svg class="w-4 h-4 text-[#70BC84]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
|
||||
</svg>
|
||||
{{ subscriptionPlan }}
|
||||
</span>
|
||||
<span
|
||||
v-if="daysRemaining !== null"
|
||||
class="text-xs font-medium"
|
||||
:class="daysRemaining <= 7 ? 'text-amber-400' : 'text-white/50'"
|
||||
>
|
||||
{{ daysRemaining > 0
|
||||
? `${daysRemaining} ${t('subscription.daysLeft')}`
|
||||
: t('subscription.expired')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Expiry date -->
|
||||
<div v-if="validUntil" class="text-xs text-white/40">
|
||||
{{ t('subscription.expiresAt') }}: {{ formatDate(validUntil) }}
|
||||
</div>
|
||||
|
||||
<!-- Time Progress Bar -->
|
||||
<div v-if="progressPercent !== null">
|
||||
<div class="flex items-center justify-between text-xs text-white/40 mb-1">
|
||||
<span>{{ t('subscription.timeRemaining') }}</span>
|
||||
<span>{{ daysRemaining }} {{ t('subscription.days') }}</span>
|
||||
</div>
|
||||
<div class="w-full h-1.5 rounded-full bg-white/10 overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-500"
|
||||
:class="progressBarClass"
|
||||
:style="{ width: `${Math.min(progressPercent, 100)}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vehicle Usage Progress Bar -->
|
||||
<div v-if="maxVehicles && maxVehicles > 0">
|
||||
<div class="flex items-center justify-between text-xs text-white/40 mb-1">
|
||||
<span>🚗 {{ t('subscription.vehicles') }}</span>
|
||||
<span>{{ vehicleCount }} / {{ maxVehicles }}</span>
|
||||
</div>
|
||||
<div class="w-full h-1.5 rounded-full bg-white/10 overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-500"
|
||||
:class="vehicleBarClass"
|
||||
:style="{ width: `${Math.min(vehiclePercent, 100)}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unlimited vehicles -->
|
||||
<div v-else-if="maxVehicles === 0 || maxVehicles === null" class="text-xs text-white/40">
|
||||
🚗 {{ t('subscription.vehicles') }}: {{ vehicleCount }} — {{ t('subscription.unlimited') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { useVehicleStore } from '../../stores/vehicle'
|
||||
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
const vehicleStore = useVehicleStore()
|
||||
|
||||
const isLoading = ref(true)
|
||||
|
||||
/**
|
||||
* Resolve the current subscription plan from the auth store.
|
||||
* For individual mode: use user-level subscription.
|
||||
* For corporate mode: use the active organization's subscription_plan.
|
||||
*/
|
||||
const subscriptionPlan = computed(() => {
|
||||
if (authStore.isCorporateMode) {
|
||||
const activeOrg = authStore.myOrganizations.find(
|
||||
(o) => o.organization_id === authStore.user?.active_organization_id
|
||||
)
|
||||
return activeOrg?.subscription_plan || null
|
||||
}
|
||||
// Individual mode — fallback to user's subscription
|
||||
return authStore.user?.subscription_plan || null
|
||||
})
|
||||
|
||||
/**
|
||||
* Calculate days remaining until subscription expires.
|
||||
* Uses valid_until from the organization or user data.
|
||||
*/
|
||||
const validUntil = computed(() => {
|
||||
if (authStore.isCorporateMode) {
|
||||
const activeOrg = authStore.myOrganizations.find(
|
||||
(o) => o.organization_id === authStore.user?.active_organization_id
|
||||
)
|
||||
return activeOrg?.subscription_valid_until || null
|
||||
}
|
||||
return authStore.user?.subscription_valid_until || null
|
||||
})
|
||||
|
||||
const daysRemaining = computed(() => {
|
||||
if (!validUntil.value) return null
|
||||
const now = new Date()
|
||||
const end = new Date(validUntil.value)
|
||||
const diffMs = end.getTime() - now.getTime()
|
||||
return Math.max(0, Math.ceil(diffMs / (1000 * 60 * 60 * 24)))
|
||||
})
|
||||
|
||||
const progressPercent = computed(() => {
|
||||
if (!validUntil.value || !subscriptionPlan.value) return null
|
||||
// Assume a 30-day cycle for progress calculation
|
||||
const now = new Date()
|
||||
const end = new Date(validUntil.value)
|
||||
const totalMs = 30 * 24 * 60 * 60 * 1000 // 30 days
|
||||
const elapsedMs = totalMs - (end.getTime() - now.getTime())
|
||||
return Math.max(0, Math.min(100, (elapsedMs / totalMs) * 100))
|
||||
})
|
||||
|
||||
const progressBarClass = computed(() => {
|
||||
if (daysRemaining.value === null) return 'bg-[#70BC84]'
|
||||
if (daysRemaining.value <= 3) return 'bg-red-500'
|
||||
if (daysRemaining.value <= 7) return 'bg-amber-400'
|
||||
return 'bg-[#70BC84]'
|
||||
})
|
||||
|
||||
// ── Vehicle usage ──────────────────────────────────────────────────
|
||||
const vehicleCount = computed(() => {
|
||||
return vehicleStore.vehicles?.length || 0
|
||||
})
|
||||
|
||||
const maxVehicles = computed(() => {
|
||||
if (authStore.isCorporateMode) {
|
||||
const activeOrg = authStore.myOrganizations.find(
|
||||
(o) => o.organization_id === authStore.user?.active_organization_id
|
||||
)
|
||||
return activeOrg?.max_vehicles ?? authStore.user?.max_vehicles ?? 0
|
||||
}
|
||||
return authStore.user?.max_vehicles ?? 0
|
||||
})
|
||||
|
||||
const vehiclePercent = computed(() => {
|
||||
if (!maxVehicles.value || maxVehicles.value <= 0) return 0
|
||||
return Math.min(100, (vehicleCount.value / maxVehicles.value) * 100)
|
||||
})
|
||||
|
||||
const vehicleBarClass = computed(() => {
|
||||
const pct = vehiclePercent.value
|
||||
if (pct >= 90) return 'bg-red-500'
|
||||
if (pct >= 75) return 'bg-amber-400'
|
||||
return 'bg-[#70BC84]'
|
||||
})
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return ''
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Ensure organizations are loaded
|
||||
if (authStore.myOrganizations.length === 0) {
|
||||
try {
|
||||
await authStore.fetchMyOrganizations()
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
// Ensure vehicles are loaded
|
||||
if (vehicleStore.vehicles.length === 0) {
|
||||
try {
|
||||
await vehicleStore.fetchVehicles()
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
isLoading.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.subscription-status-widget {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
2626
frontend_app/src/components/dashboard/VehicleFormModal.vue
Normal file
1159
frontend_app/src/components/dashboard/VehicleFormModal_original.vue
Normal file
475
frontend_app/src/components/dashboard/WalletDetailsModal.vue
Normal file
@@ -0,0 +1,475 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 z-[9999] flex items-center justify-center bg-slate-900/85 backdrop-blur-md"
|
||||
@click.self="$emit('close')"
|
||||
>
|
||||
<div class="bg-white w-11/12 max-w-3xl max-h-[85vh] rounded-3xl shadow-2xl flex flex-col overflow-hidden animate-flip-in relative">
|
||||
<!-- Close button -->
|
||||
<button
|
||||
class="absolute top-4 right-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-slate-200/80 text-slate-600 hover:bg-slate-300 hover:text-slate-800 transition-colors text-xl font-bold"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
<!-- ── Header ── -->
|
||||
<div class="h-16 bg-slate-700 w-full shrink-0 flex items-center px-6">
|
||||
<span class="text-white font-bold text-lg tracking-wide">💰 {{ t('finance.walletDetails') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- ── Scrollable Content ── -->
|
||||
<div class="flex-1 overflow-y-auto p-6 text-slate-800">
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex items-center justify-center py-20"
|
||||
>
|
||||
<div class="h-10 w-10 animate-spin rounded-full border-4 border-slate-200 border-t-sf-accent" />
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="flex flex-col items-center justify-center py-12 text-center"
|
||||
>
|
||||
<svg class="w-12 h-12 text-amber-500 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<p class="text-sm text-slate-500">{{ error }}</p>
|
||||
<button
|
||||
@click="retry"
|
||||
class="mt-3 text-sm text-sf-accent hover:underline font-medium"
|
||||
>
|
||||
{{ t('common.retry') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Wallet Content ── -->
|
||||
<div v-else class="space-y-6">
|
||||
<!-- ════════════════════════════════════════════════════════
|
||||
Section 1: 4-Pocket Overview
|
||||
════════════════════════════════════════════════════════ -->
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-slate-500 uppercase tracking-wider mb-3">
|
||||
{{ t('finance.walletBreakdown') }}
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<!-- Purchased -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
💳 {{ t('finance.purchased') }}
|
||||
</span>
|
||||
<span class="text-xs text-slate-400">{{ t('finance.topUpWallet') }}</span>
|
||||
</div>
|
||||
<p class="text-2xl font-extrabold text-slate-800">{{ formatCredits(purchasedCredits) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Earned -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
🏆 {{ t('finance.earned') }}
|
||||
</span>
|
||||
<span class="text-xs text-slate-400">{{ t('finance.referralRewards') }}</span>
|
||||
</div>
|
||||
<p class="text-2xl font-extrabold text-emerald-600">{{ formatCredits(earnedCredits) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Service Coins -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
🪙 {{ t('finance.bonus') }}
|
||||
</span>
|
||||
<span class="text-xs text-slate-400">{{ t('finance.rewards') }}</span>
|
||||
</div>
|
||||
<p class="text-2xl font-extrabold text-amber-600">{{ formatCredits(serviceCoins) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Voucher -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
🎟️ {{ t('finance.voucher') }}
|
||||
</span>
|
||||
<span class="text-xs text-slate-400">{{ t('finance.activeVouchers') }}</span>
|
||||
</div>
|
||||
<p class="text-2xl font-extrabold text-purple-600">{{ formatCredits(voucherBalance) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Balance Bar -->
|
||||
<div class="mt-3 rounded-xl bg-gradient-to-r from-sf-accent to-emerald-500 p-4 text-white">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-semibold opacity-90">{{ t('finance.totalBalance') }}</span>
|
||||
<span class="text-2xl font-extrabold">{{ formatCredits(totalCredits) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════
|
||||
Section 2: Action Buttons
|
||||
════════════════════════════════════════════════════════ -->
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
@click="handleTopUp"
|
||||
class="flex-1 px-6 py-3 bg-gradient-to-r from-sf-accent to-emerald-500 hover:from-sf-accent/90 hover:to-emerald-600 text-white rounded-2xl font-medium text-sm transition-all shadow-md hover:shadow-lg active:scale-[0.98]"
|
||||
>
|
||||
⚡ {{ t('finance.topUp') }}
|
||||
</button>
|
||||
<button
|
||||
disabled
|
||||
class="flex-1 px-6 py-3 rounded-2xl font-medium text-sm bg-slate-200 text-slate-400 cursor-not-allowed opacity-50 shadow-md"
|
||||
:title="t('finance.payoutComingSoon')"
|
||||
>
|
||||
💸 {{ t('finance.requestPayout') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════
|
||||
Section 3: Transaction History
|
||||
════════════════════════════════════════════════════════ -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-semibold text-slate-500 uppercase tracking-wider">
|
||||
📋 {{ t('finance.transactionHistory') }}
|
||||
</h3>
|
||||
<!-- Filter chips -->
|
||||
<div class="flex gap-1.5">
|
||||
<button
|
||||
v-for="filter in availableFilters"
|
||||
:key="filter.value"
|
||||
@click="activeFilter = filter.value; fetchTransactions()"
|
||||
class="px-2.5 py-1 rounded-full text-xs font-medium transition-colors"
|
||||
:class="activeFilter === filter.value
|
||||
? 'bg-sf-accent text-white'
|
||||
: 'bg-slate-100 text-slate-500 hover:bg-slate-200'"
|
||||
>
|
||||
{{ filter.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transaction List -->
|
||||
<div class="space-y-2">
|
||||
<!-- Loading -->
|
||||
<div
|
||||
v-if="txLoading"
|
||||
class="flex items-center justify-center py-8"
|
||||
>
|
||||
<div class="h-6 w-6 animate-spin rounded-full border-4 border-slate-200 border-t-sf-accent" />
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div
|
||||
v-else-if="transactions.length === 0"
|
||||
class="flex flex-col items-center justify-center py-8 text-slate-400"
|
||||
>
|
||||
<svg class="w-10 h-10 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
<p class="text-sm">{{ t('finance.noTransactions') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Transaction Items -->
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="tx in transactions"
|
||||
:key="tx.id"
|
||||
class="flex items-center justify-between rounded-xl border border-slate-100 bg-white p-3.5 hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
<div class="flex items-start gap-3 min-w-0">
|
||||
<!-- Icon -->
|
||||
<div
|
||||
class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg"
|
||||
:class="tx.entry_type === 'CREDIT' ? 'bg-emerald-100' : 'bg-red-100'"
|
||||
>
|
||||
<span class="text-sm">{{ tx.entry_type === 'CREDIT' ? '⬆' : '⬇' }}</span>
|
||||
</div>
|
||||
<!-- Details -->
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-slate-800 truncate">
|
||||
{{ tx.description || tx.transaction_type || t('finance.transaction') }}
|
||||
</p>
|
||||
<p class="text-xs text-slate-400 mt-0.5">
|
||||
{{ formatDate(tx.created_at) }}
|
||||
</p>
|
||||
<div class="flex gap-2 mt-1">
|
||||
<span
|
||||
class="inline-block rounded-full px-2 py-0.5 text-[10px] font-medium"
|
||||
:class="walletTypeBadge(tx.wallet_type)"
|
||||
>
|
||||
{{ tx.wallet_type || '—' }}
|
||||
</span>
|
||||
<span
|
||||
class="inline-block rounded-full px-2 py-0.5 text-[10px] font-medium"
|
||||
:class="tx.status === 'SUCCESS' ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700'"
|
||||
>
|
||||
{{ tx.status || '—' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Amount -->
|
||||
<div class="shrink-0 text-right ml-4">
|
||||
<p
|
||||
class="text-sm font-bold"
|
||||
:class="tx.entry_type === 'CREDIT' ? 'text-emerald-600' : 'text-red-600'"
|
||||
>
|
||||
{{ tx.entry_type === 'CREDIT' ? '+' : '-' }}{{ formatCredits(tx.amount) }}
|
||||
</p>
|
||||
<p class="text-[10px] text-slate-400 mt-0.5 font-mono">
|
||||
#{{ String(tx.id).slice(0, 8) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div
|
||||
v-if="totalPages > 1"
|
||||
class="flex items-center justify-between pt-2"
|
||||
>
|
||||
<button
|
||||
@click="prevPage"
|
||||
:disabled="currentPage <= 1"
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||
:class="currentPage > 1
|
||||
? 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
: 'bg-slate-50 text-slate-300 cursor-not-allowed'"
|
||||
>
|
||||
← {{ t('finance.prev') }}
|
||||
</button>
|
||||
<span class="text-xs text-slate-400">
|
||||
{{ currentPage }} / {{ totalPages }}
|
||||
</span>
|
||||
<button
|
||||
@click="nextPage"
|
||||
:disabled="currentPage >= totalPages"
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||
:class="currentPage < totalPages
|
||||
? 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
: 'bg-slate-50 text-slate-300 cursor-not-allowed'"
|
||||
>
|
||||
{{ t('finance.next') }} →
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useFinanceStore } from '../../stores/finance'
|
||||
import api from '../../api/axios'
|
||||
|
||||
const { t } = useI18n()
|
||||
const financeStore = useFinanceStore()
|
||||
|
||||
// ── Props ──
|
||||
const props = defineProps<{
|
||||
isOpen: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
// ── Wallet balance from store ──
|
||||
const isLoading = computed(() => financeStore.isLoading)
|
||||
const error = computed(() => financeStore.error)
|
||||
const totalCredits = computed(() => financeStore.totalCredits)
|
||||
const purchasedCredits = computed(() => financeStore.purchasedCredits)
|
||||
const serviceCoins = computed(() => financeStore.serviceCoins)
|
||||
const earnedCredits = computed(() => financeStore.earnedCredits)
|
||||
const voucherBalance = computed(() => financeStore.voucherBalance)
|
||||
|
||||
// ── Transaction state ──
|
||||
const transactions = ref<any[]>([])
|
||||
const txLoading = ref(false)
|
||||
const currentPage = ref(1)
|
||||
const totalPages = ref(1)
|
||||
const totalCount = ref(0)
|
||||
const activeFilter = ref<string | null>(null)
|
||||
|
||||
const availableFilters = [
|
||||
{ value: null, label: t('finance.all') },
|
||||
{ value: 'PURCHASED', label: t('finance.purchased') },
|
||||
{ value: 'EARNED', label: t('finance.earned') },
|
||||
{ value: 'SERVICE_COINS', label: t('finance.bonus') },
|
||||
]
|
||||
|
||||
const pageSize = 20
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function formatCredits(value: number): string {
|
||||
if (Number.isInteger(value)) {
|
||||
return value.toLocaleString()
|
||||
}
|
||||
return value.toFixed(2)
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return '—'
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function walletTypeBadge(type: string | null): string {
|
||||
switch (type) {
|
||||
case 'PURCHASED': return 'bg-blue-100 text-blue-700'
|
||||
case 'EARNED': return 'bg-emerald-100 text-emerald-700'
|
||||
case 'SERVICE_COINS': return 'bg-amber-100 text-amber-700'
|
||||
case 'VOUCHER': return 'bg-purple-100 text-purple-700'
|
||||
default: return 'bg-slate-100 text-slate-600'
|
||||
}
|
||||
}
|
||||
|
||||
// ── Actions ──
|
||||
|
||||
async function fetchTransactions() {
|
||||
txLoading.value = true
|
||||
try {
|
||||
const params: any = {
|
||||
page: currentPage.value,
|
||||
page_size: pageSize,
|
||||
}
|
||||
if (activeFilter.value) {
|
||||
params.wallet_type = activeFilter.value
|
||||
}
|
||||
|
||||
const res = await api.get('/billing/wallet/transactions', { params })
|
||||
const data = res.data
|
||||
|
||||
transactions.value = data.data || []
|
||||
totalCount.value = data.pagination?.total_count || 0
|
||||
totalPages.value = data.pagination?.total_pages || 1
|
||||
} catch (err: any) {
|
||||
console.error('[WalletDetailsModal] fetchTransactions error:', err)
|
||||
transactions.value = []
|
||||
} finally {
|
||||
txLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (currentPage.value > 1) {
|
||||
currentPage.value--
|
||||
fetchTransactions()
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (currentPage.value < totalPages.value) {
|
||||
currentPage.value++
|
||||
fetchTransactions()
|
||||
}
|
||||
}
|
||||
|
||||
function handleTopUp() {
|
||||
const toast = document.createElement('div')
|
||||
toast.className =
|
||||
'fixed top-6 right-6 z-[99999] bg-slate-800 text-white px-6 py-3 rounded-xl shadow-2xl text-sm font-medium animate-slide-in'
|
||||
toast.textContent = '🔔 Fizetési kapu hamarosan elérhető!'
|
||||
document.body.appendChild(toast)
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.add('animate-slide-out')
|
||||
setTimeout(() => toast.remove(), 300)
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
function handleWithdraw() {
|
||||
if (earnedCredits.value <= 0) return
|
||||
|
||||
const toast = document.createElement('div')
|
||||
toast.className =
|
||||
'fixed top-6 right-6 z-[99999] bg-slate-800 text-white px-6 py-3 rounded-xl shadow-2xl text-sm font-medium animate-slide-in'
|
||||
toast.textContent = `💸 Kifizetési kérelem: ${formatCredits(earnedCredits.value)} egység — hamarosan elérhető!`
|
||||
document.body.appendChild(toast)
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.add('animate-slide-out')
|
||||
setTimeout(() => toast.remove(), 300)
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
function retry() {
|
||||
financeStore.fetchWalletBalance()
|
||||
fetchTransactions()
|
||||
}
|
||||
|
||||
// ── Lifecycle ──
|
||||
watch(() => props.isOpen, (open) => {
|
||||
if (open) {
|
||||
financeStore.fetchWalletBalance()
|
||||
currentPage.value = 1
|
||||
fetchTransactions()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (props.isOpen) {
|
||||
financeStore.fetchWalletBalance()
|
||||
fetchTransactions()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes flipIn {
|
||||
from {
|
||||
transform: perspective(1200px) rotateY(-90deg);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: perspective(1200px) rotateY(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes slideOut {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.animate-flip-in {
|
||||
animation: flipIn 0.5s cubic-bezier(0.25, 0.8, 0.25, 1) forwards;
|
||||
}
|
||||
.animate-slide-in {
|
||||
animation: slideIn 0.3s ease-out forwards;
|
||||
}
|
||||
.animate-slide-out {
|
||||
animation: slideOut 0.3s ease-in forwards;
|
||||
}
|
||||
</style>
|
||||
63
frontend_app/src/components/header/HeaderBackButton.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<button
|
||||
@click="handleClick"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-xl text-sm font-medium transition-all duration-200 cursor-pointer"
|
||||
:class="btnClass"
|
||||
:aria-label="ariaLabel || label"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
<span class="hidden sm:inline">{{ label }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** Route to navigate to on click */
|
||||
to?: string
|
||||
/** Button label text */
|
||||
label?: string
|
||||
/** Optional aria-label override */
|
||||
ariaLabel?: string
|
||||
/** Visual variant */
|
||||
variant?: 'default' | 'warning'
|
||||
/** Optional click handler override (prevents navigation if provided) */
|
||||
onClick?: () => void
|
||||
}>(),
|
||||
{
|
||||
to: '/dashboard',
|
||||
label: '',
|
||||
ariaLabel: '',
|
||||
variant: 'default',
|
||||
onClick: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const btnClass = computed(() => {
|
||||
if (props.variant === 'warning') {
|
||||
return 'text-yellow-400/80 hover:bg-white/5 hover:text-yellow-300'
|
||||
}
|
||||
// Default variant uses dynamic theme colors (white-labeling support)
|
||||
// bg-themePrimary picks up the --theme-primary CSS variable set by the theme store
|
||||
return 'bg-themePrimary text-white hover:opacity-90'
|
||||
})
|
||||
|
||||
function handleClick() {
|
||||
if (props.onClick) {
|
||||
props.onClick()
|
||||
return
|
||||
}
|
||||
if (props.to) {
|
||||
router.push(props.to)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
239
frontend_app/src/components/header/HeaderCompanySwitcher.vue
Normal file
@@ -0,0 +1,239 @@
|
||||
<template>
|
||||
<div class="relative org-dropdown-container">
|
||||
<button
|
||||
v-if="authStore.isAuthenticated"
|
||||
@click.stop="isOpen = !isOpen"
|
||||
:disabled="authStore.isLoading"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-xl text-sm font-medium transition-all duration-200 cursor-pointer text-white/70 hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<!-- Garage icon -->
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
<span class="truncate max-w-[120px]">{{ activeGarageName }}</span>
|
||||
<!-- Chevron down -->
|
||||
<svg
|
||||
class="w-3 h-3 transition-transform"
|
||||
:class="{ 'rotate-180': isOpen }"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- ── Garage Selector Dropdown ─────────────────────────────── -->
|
||||
<Transition
|
||||
enter-active-class="transition duration-150 ease-out"
|
||||
enter-from-class="scale-95 opacity-0 -translate-y-2"
|
||||
enter-to-class="scale-100 opacity-100 translate-y-0"
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-from-class="scale-100 opacity-100 translate-y-0"
|
||||
leave-to-class="scale-95 opacity-0 -translate-y-2"
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="absolute right-0 top-full mt-2 w-64 z-50 rounded-xl border border-white/10 bg-[#04151F]/95 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
|
||||
>
|
||||
<!-- ── Private Garages (individual orgs) ─────────────────── -->
|
||||
<template v-if="individualOrganizations.length > 0">
|
||||
<div class="px-4 py-1.5 pt-3 text-xs text-white/40 uppercase tracking-wider font-semibold">
|
||||
👤 {{ t('header.privateGarage') }}
|
||||
</div>
|
||||
<div class="max-h-60 overflow-y-auto">
|
||||
<button
|
||||
v-for="org in individualOrganizations"
|
||||
:key="org.organization_id"
|
||||
@click="switchToOrg(org)"
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm transition-all duration-150 hover:bg-white/5"
|
||||
:class="isActiveOrg(org) ? 'text-[#00E5A0] font-semibold' : 'text-white/80 hover:text-white'"
|
||||
>
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
<span class="truncate">{{ org.display_name || org.name || `${t('header.companyPrefix')} #${org.organization_id}` }}</span>
|
||||
<svg
|
||||
v-if="isActiveOrg(org)"
|
||||
class="w-4 h-4 ml-auto text-[#00E5A0] flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── Company Garages (business orgs) ──────────────────── -->
|
||||
<template v-if="companyOrganizations.length > 0">
|
||||
<div v-if="individualOrganizations.length > 0" class="border-t border-white/10 my-1"></div>
|
||||
<div class="px-4 py-1.5 pt-3 text-xs text-white/40 uppercase tracking-wider font-semibold">
|
||||
🏢 {{ t('header.myCompanies') }}
|
||||
</div>
|
||||
<div class="max-h-60 overflow-y-auto">
|
||||
<router-link
|
||||
v-for="org in companyOrganizations"
|
||||
:key="org.organization_id"
|
||||
:to="'/organization/' + org.organization_id"
|
||||
@click="isOpen = false"
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm transition-all duration-150 hover:bg-white/5"
|
||||
:class="isActiveOrg(org) ? 'text-[#00E5A0] font-semibold' : 'text-white/80 hover:text-white'"
|
||||
>
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
<span class="truncate">{{ org.display_name || org.name || `${t('header.companyPrefix')} #${org.organization_id}` }}</span>
|
||||
<svg
|
||||
v-if="isActiveOrg(org)"
|
||||
class="w-4 h-4 ml-auto text-[#00E5A0] flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── Empty state ──────────────────────────────────────── -->
|
||||
<div
|
||||
v-if="individualOrganizations.length === 0 && companyOrganizations.length === 0"
|
||||
class="px-4 py-3 text-sm text-white/40 text-center"
|
||||
>
|
||||
{{ t('header.noOrganizations') }}
|
||||
</div>
|
||||
|
||||
<!-- ── Separator + PLG actions ─────────────────────────── -->
|
||||
<div class="border-t border-white/10 my-1"></div>
|
||||
<button
|
||||
@click="handleCreateCompany"
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<svg class="w-4 h-4 text-[#00E5A0]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
<span>{{ t('header.createCompany') }}</span>
|
||||
</button>
|
||||
<button
|
||||
@click="handleJoinCompany"
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<svg class="w-4 h-4 text-[#00E5A0]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7l3-3m0 0l3-3m-3 3l-3-3m3 3l3 3M8 21l-3 3m0 0l-3 3m3-3l-3-3m3 3l3-3M4 4l16 16" />
|
||||
</svg>
|
||||
<span>{{ t('header.joinCompany') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { OrganizationItem } from '@/types/organization'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const isOpen = ref(false)
|
||||
|
||||
// ── Debug: log organizations on mount ───────────────────────────────
|
||||
onMounted(async () => {
|
||||
// Ensure organizations are loaded (safety net if init() didn't run)
|
||||
if (authStore.myOrganizations.length === 0 && authStore.isAuthenticated) {
|
||||
await authStore.fetchMyOrganizations()
|
||||
}
|
||||
console.log('🔍 [HeaderCompanySwitcher] All orgs:', authStore.myOrganizations)
|
||||
})
|
||||
|
||||
// ── Individual (private) organizations ──────────────────────────────
|
||||
const individualOrganizations = computed(() => {
|
||||
return authStore.myOrganizations.filter(
|
||||
(org) => org.org_type === 'individual'
|
||||
)
|
||||
})
|
||||
|
||||
// ── Company organizations (exclude individual, service_provider, service) ──
|
||||
const companyOrganizations = computed(() => {
|
||||
return authStore.myOrganizations.filter(
|
||||
(org) => org.org_type &&
|
||||
org.org_type !== 'individual' &&
|
||||
org.org_type !== 'service_provider' &&
|
||||
org.org_type !== 'service'
|
||||
)
|
||||
})
|
||||
|
||||
// ── Route-based detection ─────────────────────────────────────────
|
||||
const isOnCompanyGarage = computed(() => {
|
||||
return route.path.startsWith('/organization/')
|
||||
})
|
||||
|
||||
// ── Active garage name for the button label ────────────────────────
|
||||
const activeGarageName = computed(() => {
|
||||
// If on a company garage route, show the active org name
|
||||
if (isOnCompanyGarage.value && authStore.user?.active_organization_id) {
|
||||
const activeOrg = authStore.myOrganizations.find(
|
||||
(org) => org.organization_id === authStore.user?.active_organization_id
|
||||
)
|
||||
if (activeOrg) {
|
||||
return activeOrg.display_name || activeOrg.name || `${t('header.companyPrefix')} #${activeOrg.organization_id}`
|
||||
}
|
||||
}
|
||||
// If on dashboard and has an active individual org, show its name
|
||||
if (!isOnCompanyGarage.value && authStore.user?.active_organization_id) {
|
||||
const activeOrg = authStore.myOrganizations.find(
|
||||
(org) => org.organization_id === authStore.user?.active_organization_id
|
||||
)
|
||||
if (activeOrg) {
|
||||
return activeOrg.display_name || activeOrg.name || t('header.garageSelector')
|
||||
}
|
||||
}
|
||||
// Default: show personal garage label
|
||||
return t('header.garageSelector')
|
||||
})
|
||||
|
||||
// ── Check if org is the active one ─────────────────────────────────
|
||||
function isActiveOrg(org: OrganizationItem): boolean {
|
||||
return authStore.user?.active_organization_id === org.organization_id
|
||||
}
|
||||
|
||||
// ── Switch to an individual org (private garage) ───────────────────
|
||||
async function switchToOrg(org: OrganizationItem) {
|
||||
isOpen.value = false
|
||||
await authStore.switchOrganization(org.organization_id)
|
||||
router.push('/dashboard')
|
||||
}
|
||||
|
||||
// ── PLG: Create Company ───────────────────────────────────────────
|
||||
function handleCreateCompany() {
|
||||
isOpen.value = false
|
||||
console.log('🔧 [PLG] Create Company clicked — modal/onboarding coming soon')
|
||||
router.push('/company/onboard')
|
||||
}
|
||||
|
||||
// ── PLG: Join Company ─────────────────────────────────────────────
|
||||
function handleJoinCompany() {
|
||||
isOpen.value = false
|
||||
console.log('🔧 [PLG] Join Company clicked — join flow coming soon')
|
||||
alert('🔗 Join Company — This feature is coming soon!')
|
||||
}
|
||||
|
||||
// ── Close dropdown on outside click ───────────────────────────────
|
||||
function handleOutsideClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
if (isOpen.value && !target.closest('.org-dropdown-container')) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('mousedown', handleOutsideClick))
|
||||
onUnmounted(() => document.removeEventListener('mousedown', handleOutsideClick))
|
||||
</script>
|
||||
39
frontend_app/src/components/header/HeaderLogo.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<router-link
|
||||
:to="targetRoute"
|
||||
class="flex items-center gap-3 cursor-pointer transition-all duration-300 hover:opacity-80 hover:scale-[1.02]"
|
||||
:aria-label="t('header.home')"
|
||||
>
|
||||
<img
|
||||
src="@/assets/sf-brand-logo.svg"
|
||||
class="h-10 w-auto mr-3 drop-shadow-md"
|
||||
alt="ServiceFinder Logo"
|
||||
/>
|
||||
<div class="hidden sm:block">
|
||||
<span class="text-base font-black tracking-widest text-white">SERVICE</span>
|
||||
<span class="text-base font-black tracking-widest text-[#418890]">FINDER</span>
|
||||
</div>
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
|
||||
/**
|
||||
* Compute the target route for the logo link.
|
||||
* - If the user is on an /organization/:id page, navigate to the org root.
|
||||
* - Otherwise, navigate to /dashboard (private garage).
|
||||
*/
|
||||
const targetRoute = computed(() => {
|
||||
if (route.path.startsWith('/organization/')) {
|
||||
const orgId = route.params.id
|
||||
return orgId ? `/organization/${orgId}` : route.path
|
||||
}
|
||||
return '/dashboard'
|
||||
})
|
||||
</script>
|
||||
217
frontend_app/src/components/header/HeaderProfile.vue
Normal file
@@ -0,0 +1,217 @@
|
||||
<template>
|
||||
<div ref="containerRef" class="relative flex items-center">
|
||||
<!-- Avatar button — click to toggle dropdown -->
|
||||
<button
|
||||
@click="isOpen = !isOpen"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-full bg-[#70BC84]/20 text-sm font-bold text-[#70BC84] ring-2 ring-white/10 transition-all duration-200 hover:ring-[#70BC84]/50 focus:outline-none focus:ring-[#70BC84]/50 cursor-pointer"
|
||||
:aria-label="t('header.userMenu')"
|
||||
>
|
||||
{{ initials }}
|
||||
</button>
|
||||
|
||||
<!-- ── Dropdown Menu (Glassmorphism) ──────────────────────────── -->
|
||||
<Transition
|
||||
enter-active-class="transition duration-150 ease-out"
|
||||
enter-from-class="scale-95 opacity-0 -translate-y-2"
|
||||
enter-to-class="scale-100 opacity-100 translate-y-0"
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-from-class="scale-100 opacity-100 translate-y-0"
|
||||
leave-to-class="scale-95 opacity-0 -translate-y-2"
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="absolute right-0 top-full mt-2 w-64 z-50 rounded-xl border border-white/10 bg-[#04151F]/90 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
|
||||
>
|
||||
<!-- User info header -->
|
||||
<div class="px-4 py-3 border-b border-white/5">
|
||||
<p class="text-sm font-semibold text-white/90 truncate">{{ fullName }}</p>
|
||||
<p class="text-xs text-white/40 truncate mt-0.5">{{ email }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Menu items -->
|
||||
<div class="py-1">
|
||||
<!-- Profile settings -->
|
||||
<button
|
||||
@click="goToProfile"
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 text-white/40"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('common.profileSettings') }}
|
||||
</button>
|
||||
|
||||
<!-- Subscription Info -->
|
||||
<button
|
||||
@click="openSubscriptionModal"
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 text-white/40"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('header.subscription') }}
|
||||
</button>
|
||||
|
||||
<!-- Admin Center — only for admin/superadmin roles -->
|
||||
<button
|
||||
v-if="isAdmin"
|
||||
@click="goToAdmin"
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-yellow-400/80 transition-all duration-150 hover:bg-white/5 hover:text-yellow-300"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 text-yellow-400/60"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('header.adminCenter') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<hr class="border-white/5 my-1" />
|
||||
|
||||
<!-- Logout -->
|
||||
<div class="py-1">
|
||||
<button
|
||||
@click="handleLogout"
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/70 transition-all duration-150 hover:bg-white/5 hover:text-red-400"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 text-white/40"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('header.logout') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Subscription Info Modal -->
|
||||
<SubscriptionInfoModal
|
||||
:visible="showSubscriptionModal"
|
||||
@close="showSubscriptionModal = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import SubscriptionInfoModal from '@/components/subscription/SubscriptionInfoModal.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const showSubscriptionModal = ref(false)
|
||||
|
||||
// ── Computed display helpers ───────────────────────────────────────
|
||||
const fullName = computed(() => {
|
||||
if (!authStore.user) return t('header.user')
|
||||
const { first_name, last_name } = authStore.user
|
||||
if (first_name && last_name) return `${first_name} ${last_name}`
|
||||
if (first_name) return first_name
|
||||
if (last_name) return last_name
|
||||
return authStore.user.email || t('header.user')
|
||||
})
|
||||
|
||||
const email = computed(() => {
|
||||
return authStore.user?.email || ''
|
||||
})
|
||||
|
||||
const initials = computed(() => {
|
||||
if (!authStore.user) return '?'
|
||||
const { first_name, last_name } = authStore.user
|
||||
if (first_name && last_name) {
|
||||
return (first_name[0] + last_name[0]).toUpperCase()
|
||||
}
|
||||
if (first_name) return first_name[0].toUpperCase()
|
||||
if (authStore.user.email) return authStore.user.email[0].toUpperCase()
|
||||
return '?'
|
||||
})
|
||||
|
||||
// ── Admin check: only SUPERADMIN, ADMIN, MODERATOR ──
|
||||
const isAdmin = computed(() => {
|
||||
const role = authStore.user?.role
|
||||
if (!role) return false
|
||||
// CRITICAL: Backend sends UPPERCASE roles (SUPERADMIN, ADMIN, MODERATOR)
|
||||
const adminRoles = ['SUPERADMIN', 'ADMIN', 'MODERATOR']
|
||||
return adminRoles.includes(role.toUpperCase())
|
||||
})
|
||||
|
||||
// ── Navigate to Profile ────────────────────────────────────────────
|
||||
function goToProfile() {
|
||||
isOpen.value = false
|
||||
router.push('/profile')
|
||||
}
|
||||
|
||||
// ── Navigate to Admin Center ───────────────────────────────────────
|
||||
function goToAdmin() {
|
||||
isOpen.value = false
|
||||
router.push('/admin')
|
||||
}
|
||||
|
||||
// ── Open Subscription Info Modal ───────────────────────────────────
|
||||
function openSubscriptionModal() {
|
||||
isOpen.value = false
|
||||
showSubscriptionModal.value = true
|
||||
}
|
||||
|
||||
// ── Logout handler ─────────────────────────────────────────────────
|
||||
function handleLogout() {
|
||||
isOpen.value = false
|
||||
authStore.logout()
|
||||
}
|
||||
|
||||
// ── Close dropdown on outside click ───────────────────────────────
|
||||
function handleOutsideClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
if (isOpen.value && containerRef.value && !containerRef.value.contains(target)) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('mousedown', handleOutsideClick))
|
||||
onUnmounted(() => document.removeEventListener('mousedown', handleOutsideClick))
|
||||
</script>
|
||||
48
frontend_app/src/components/layout/BaseHeader.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<header
|
||||
class="sticky top-0 z-40 w-full border-b border-white/[0.06] bg-[#04151F]/80 backdrop-blur-2xl"
|
||||
>
|
||||
<div class="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||
<!-- ── Left Slot ─────────────────────────────────────────────── -->
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<slot name="left" />
|
||||
</div>
|
||||
|
||||
<!-- ── Center Slot ───────────────────────────────────────────── -->
|
||||
<div class="absolute left-1/2 -translate-x-1/2 flex items-center gap-2">
|
||||
<slot name="center" />
|
||||
<!-- Teleport target for dynamic header content -->
|
||||
<div id="header-teleport-target" class="flex items-center gap-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── Spacer (pushes right items when center is empty) ──────── -->
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<!-- ── Right Slot ────────────────────────────────────────────── -->
|
||||
<div class="flex items-center gap-2">
|
||||
<slot name="right" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* BaseHeader.vue — A "dumb" glassmorphism header frame.
|
||||
*
|
||||
* Provides three named slots:
|
||||
* - #left : Logo, back button, welcome text
|
||||
* - #center : Context label, teleport target for dynamic content
|
||||
* - #right : Language switcher, profile avatar, org switcher
|
||||
*
|
||||
* The teleport target div (#header-teleport-target) allows child components
|
||||
* to teleport content into the header center area from anywhere in the DOM.
|
||||
*
|
||||
* Usage:
|
||||
* <BaseHeader>
|
||||
* <template #left>...</template>
|
||||
* <template #center>...</template>
|
||||
* <template #right>...</template>
|
||||
* </BaseHeader>
|
||||
*/
|
||||
</script>
|
||||
69
frontend_app/src/components/logo1.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-4 relative -mb-8 z-50 group cursor-pointer">
|
||||
|
||||
<svg
|
||||
viewBox="0 0 240 240"
|
||||
class="w-24 h-24 drop-shadow-[0_10px_20px_rgba(0,0,0,0.4)] transition-transform duration-500 group-hover:scale-105"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="sf-green" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#418890" />
|
||||
<stop offset="100%" stop-color="#79B085" />
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="sf-white" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#FFFFFF" />
|
||||
<stop offset="100%" stop-color="#D1D5DB" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<path d="M 80 160 L 30 190" stroke="url(#sf-green)" stroke-width="8" stroke-linecap="round"/>
|
||||
<circle cx="15" cy="199" r="4" fill="url(#sf-green)"/>
|
||||
<path d="M 105 185 L 40 225" stroke="url(#sf-green)" stroke-width="12" stroke-linecap="round"/>
|
||||
<circle cx="20" cy="237" r="6" fill="url(#sf-green)"/>
|
||||
<path d="M 130 205 L 80 235" stroke="url(#sf-green)" stroke-width="6" stroke-linecap="round"/>
|
||||
<circle cx="65" cy="244" r="3" fill="url(#sf-green)"/>
|
||||
|
||||
<polygon points="120,25 105,45 135,45" fill="url(#sf-green)"/>
|
||||
<polygon points="35,120 55,105 55,135" fill="url(#sf-green)"/>
|
||||
<polygon points="120,215 105,195 135,195" fill="url(#sf-green)"/>
|
||||
<polygon points="60,60 80,75 65,85" fill="url(#sf-green)"/>
|
||||
|
||||
<circle cx="120" cy="120" r="75" stroke="url(#sf-white)" stroke-width="16"/>
|
||||
|
||||
<text x="120" y="155" font-family="Arial, sans-serif" font-weight="900" font-size="95" text-anchor="middle">
|
||||
<tspan fill="url(#sf-white)" dx="-15">S</tspan>
|
||||
<tspan fill="url(#sf-green)" dx="5">F</tspan>
|
||||
</text>
|
||||
|
||||
<path d="M 165 165 L 210 210" stroke="url(#sf-white)" stroke-width="24" stroke-linecap="round"/>
|
||||
<path d="M 175 175 L 205 205" stroke="#062535" stroke-width="6" stroke-linecap="round"/>
|
||||
|
||||
<polygon points="215,25 130,110 100,70" fill="#2C5A63"/>
|
||||
<polygon points="215,25 130,110 160,140" fill="url(#sf-green)"/>
|
||||
|
||||
<circle cx="130" cy="110" r="14" fill="url(#sf-white)"/>
|
||||
<circle cx="130" cy="110" r="5" fill="#062535"/>
|
||||
|
||||
</svg>
|
||||
|
||||
<div class="hidden sm:flex flex-col justify-center mt-3">
|
||||
<div class="font-black tracking-widest text-3xl leading-none drop-shadow-md">
|
||||
<span class="text-white">SERVICE</span> <span class="text-[#75A882]">FINDER</span>
|
||||
</div>
|
||||
<div class="text-[#75A882] text-[0.65rem] tracking-[0.25em] font-bold mt-2 uppercase opacity-90">
|
||||
Online Járműnyilvántartó & Szervizkereső
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* Service Finder - Végleges SVG Logo Komponens
|
||||
* Optimalizált, kézzel írt kód <linearGradient> használatával.
|
||||
*/
|
||||
</script>
|
||||
318
frontend_app/src/components/organization/CompanyDataModal.vue
Normal file
@@ -0,0 +1,318 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
@click.self="handleBackdropClick"
|
||||
>
|
||||
<div
|
||||
class="relative w-full max-w-lg mx-4 rounded-2xl bg-[#04151F] border border-white/10 shadow-2xl shadow-black/40 overflow-hidden"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-white/10">
|
||||
<h3 class="text-lg font-semibold text-white">
|
||||
{{ isEditing ? t('company.settingsModalTitle') : t('company.companyDataTitle') }}
|
||||
</h3>
|
||||
<button
|
||||
@click="$emit('close')"
|
||||
class="p-1 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="px-6 py-4 space-y-4 max-h-[70vh] overflow-y-auto">
|
||||
<!-- Loading -->
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
||||
<svg class="animate-spin h-8 w-8 text-[#00E5A0]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Company Name -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsName') }}</label>
|
||||
<input
|
||||
v-if="isEditing"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
|
||||
/>
|
||||
<p v-else class="text-sm text-white/90 px-1">{{ org?.name || '—' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Full Company Name -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsFullName') }}</label>
|
||||
<input
|
||||
v-if="isEditing"
|
||||
v-model="form.full_name"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
|
||||
/>
|
||||
<p v-else class="text-sm text-white/90 px-1">{{ org?.full_name || '—' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Display Name -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsDisplayName') }}</label>
|
||||
<input
|
||||
v-if="isEditing"
|
||||
v-model="form.display_name"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
|
||||
/>
|
||||
<p v-else class="text-sm text-white/90 px-1">{{ org?.display_name || '—' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Tax Number (DISABLED in edit mode too) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsTaxNumber') }}</label>
|
||||
<input
|
||||
v-if="isEditing"
|
||||
v-model="form.tax_number"
|
||||
type="text"
|
||||
disabled
|
||||
class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white/50 text-sm cursor-not-allowed"
|
||||
/>
|
||||
<p v-else class="text-sm text-white/90 px-1">{{ org?.tax_number || '—' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Separator -->
|
||||
<div class="border-t border-white/10 my-2"></div>
|
||||
<p class="text-sm font-medium text-white/70 mb-2">{{ t('company.addressTitle') || 'Address' }}</p>
|
||||
|
||||
<!-- ZIP Code -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsAddressZip') }}</label>
|
||||
<input
|
||||
v-if="isEditing"
|
||||
v-model="form.address_zip"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
|
||||
/>
|
||||
<p v-else class="text-sm text-white/90 px-1">{{ org?.address_zip || '—' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- City -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsAddressCity') }}</label>
|
||||
<input
|
||||
v-if="isEditing"
|
||||
v-model="form.address_city"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
|
||||
/>
|
||||
<p v-else class="text-sm text-white/90 px-1">{{ org?.address_city || '—' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Street -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsAddressStreet') }}</label>
|
||||
<input
|
||||
v-if="isEditing"
|
||||
v-model="form.address_street_name"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
|
||||
/>
|
||||
<p v-else class="text-sm text-white/90 px-1">{{ org?.address_street_name || '—' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- House Number -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsAddressHouseNumber') }}</label>
|
||||
<input
|
||||
v-if="isEditing"
|
||||
v-model="form.address_house_number"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
|
||||
/>
|
||||
<p v-else class="text-sm text-white/90 px-1">{{ org?.address_house_number || '—' }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-white/10">
|
||||
<button
|
||||
@click="isEditing ? cancelEdit() : $emit('close')"
|
||||
class="px-4 py-2 rounded-lg text-sm text-white/70 hover:text-white hover:bg-white/10 transition-colors"
|
||||
>
|
||||
{{ isEditing ? t('common.cancel') : t('company.close') || t('header.close') }}
|
||||
</button>
|
||||
|
||||
<!-- Edit button (read-only mode) -->
|
||||
<button
|
||||
v-if="!isEditing"
|
||||
@click="startEdit"
|
||||
class="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm transition-colors"
|
||||
>
|
||||
{{ t('profile.edit') }}
|
||||
</button>
|
||||
|
||||
<!-- Save button (edit mode) -->
|
||||
<button
|
||||
v-else
|
||||
@click="handleSave"
|
||||
:disabled="isSaving"
|
||||
class="px-4 py-2 rounded-lg bg-[#00E5A0] hover:bg-[#00E5A0]/80 text-[#04151F] font-semibold text-sm transition-all duration-200 disabled:opacity-50"
|
||||
>
|
||||
<span v-if="isSaving" class="flex items-center gap-2">
|
||||
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
{{ t('company.submitting') }}
|
||||
</span>
|
||||
<span v-else>{{ t('common.save') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import api from '@/api/axios'
|
||||
import type { OrganizationItem } from '@/types/organization'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const isLoading = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const isEditing = ref(false)
|
||||
const org = ref<OrganizationItem | null>(null)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
full_name: '',
|
||||
display_name: '',
|
||||
tax_number: '',
|
||||
address_zip: '',
|
||||
address_city: '',
|
||||
address_street_name: '',
|
||||
address_house_number: '',
|
||||
})
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────────────────────────
|
||||
|
||||
onMounted(async () => {
|
||||
const orgId = Number(route.params.id)
|
||||
if (!orgId) return
|
||||
|
||||
// Find org from store
|
||||
const found = authStore.myOrganizations.find((o) => o.organization_id === orgId)
|
||||
if (found) {
|
||||
org.value = found
|
||||
prefillForm(found)
|
||||
} else {
|
||||
// Try to fetch fresh data
|
||||
isLoading.value = true
|
||||
try {
|
||||
await authStore.fetchMyOrganizations()
|
||||
const refound = authStore.myOrganizations.find((o) => o.organization_id === orgId)
|
||||
if (refound) {
|
||||
org.value = refound
|
||||
prefillForm(refound)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load organization:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function prefillForm(data: OrganizationItem) {
|
||||
form.name = data.name || ''
|
||||
form.full_name = data.full_name || ''
|
||||
form.display_name = data.display_name || ''
|
||||
form.tax_number = data.tax_number || ''
|
||||
// Address fields may come from a nested address object or flat fields
|
||||
// We use type-safe access with optional chaining
|
||||
form.address_zip = (data as any).address_zip || ''
|
||||
form.address_city = (data as any).address_city || ''
|
||||
form.address_street_name = (data as any).address_street_name || ''
|
||||
form.address_house_number = (data as any).address_house_number || ''
|
||||
}
|
||||
|
||||
function startEdit() {
|
||||
// Re-populate form from current org data
|
||||
if (org.value) {
|
||||
prefillForm(org.value)
|
||||
}
|
||||
isEditing.value = true
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
isEditing.value = false
|
||||
// Reset form to original values
|
||||
if (org.value) {
|
||||
prefillForm(org.value)
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick() {
|
||||
if (isEditing.value) {
|
||||
cancelEdit()
|
||||
} else {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Save Handler ────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleSave() {
|
||||
const orgId = Number(route.params.id)
|
||||
if (!orgId) return
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
// Build payload with only non-empty fields
|
||||
const payload: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(form)) {
|
||||
if (value.trim()) {
|
||||
payload[key] = value.trim()
|
||||
}
|
||||
}
|
||||
|
||||
await api.patch(`/organizations/${orgId}`, payload)
|
||||
|
||||
// Refresh org data in store
|
||||
await authStore.fetchMyOrganizations()
|
||||
|
||||
// Update local org reference
|
||||
const updated = authStore.myOrganizations.find((o) => o.organization_id === orgId)
|
||||
if (updated) {
|
||||
org.value = updated
|
||||
}
|
||||
|
||||
isEditing.value = false
|
||||
emit('saved')
|
||||
} catch (err: any) {
|
||||
console.error('Failed to save organization data:', err)
|
||||
alert(t('company.settingsSaveError'))
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,350 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
@click.self="$emit('close')"
|
||||
>
|
||||
<div
|
||||
class="relative w-full max-w-lg mx-4 rounded-2xl bg-[#04151F] border border-white/10 shadow-2xl shadow-black/40 overflow-hidden"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-white/10">
|
||||
<h3 class="text-lg font-semibold text-white">{{ t('company.settingsModalTitle') }}</h3>
|
||||
<button
|
||||
@click="$emit('close')"
|
||||
class="p-1 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="px-6 py-4 space-y-4 max-h-[70vh] overflow-y-auto">
|
||||
<!-- Loading -->
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
||||
<svg class="animate-spin h-8 w-8 text-[#00E5A0]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Company Name -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsName') }}</label>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
|
||||
:placeholder="org?.name || ''"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Full Company Name -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsFullName') }}</label>
|
||||
<input
|
||||
v-model="form.full_name"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
|
||||
:placeholder="org?.full_name || ''"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Display Name -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsDisplayName') }}</label>
|
||||
<input
|
||||
v-model="form.display_name"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
|
||||
:placeholder="org?.display_name || ''"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Tax Number -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsTaxNumber') }}</label>
|
||||
<input
|
||||
v-model="form.tax_number"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
|
||||
:placeholder="org?.tax_number || ''"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Separator: Subscription Section (P0 Feature) -->
|
||||
<div class="border-t border-white/10 my-2"></div>
|
||||
<p class="text-sm font-medium text-white/70 mb-2">{{ t('company.subscriptionTitle') || 'Előfizetés' }}</p>
|
||||
|
||||
<!-- Subscription Tier Dropdown -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.subscriptionTier') || 'Előfizetési csomag' }}</label>
|
||||
<div class="flex gap-2">
|
||||
<select
|
||||
v-model="selectedTierId"
|
||||
class="flex-1 px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0]"
|
||||
>
|
||||
<option :value="null" class="bg-[#04151F]">{{ t('company.noSubscription') || 'Nincs csomag' }}</option>
|
||||
<option
|
||||
v-for="tier in availableTiers"
|
||||
:key="tier.id"
|
||||
:value="tier.id"
|
||||
class="bg-[#04151F]"
|
||||
>
|
||||
{{ tier.name }} {{ tier.rules?.allowances?.max_vehicles ? `(${tier.rules.allowances.max_vehicles} jármű)` : '' }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
v-if="selectedTierId !== org?.subscription_tier_id"
|
||||
@click="assignSubscription"
|
||||
:disabled="isAssigning"
|
||||
class="px-3 py-2 rounded-lg bg-[#00E5A0] hover:bg-[#00E5A0]/80 text-[#04151F] font-semibold text-sm transition-all duration-200 disabled:opacity-50"
|
||||
>
|
||||
<span v-if="isAssigning" class="flex items-center gap-1">
|
||||
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span v-else>{{ t('common.save') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="org?.subscription_plan" class="text-xs text-white/40 mt-1">
|
||||
{{ t('company.currentPlan') || 'Jelenlegi csomag' }}: {{ org.subscription_plan }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Separator -->
|
||||
<div class="border-t border-white/10 my-2"></div>
|
||||
<p class="text-sm font-medium text-white/70 mb-2">{{ t('company.addressTitle') }}</p>
|
||||
|
||||
<!-- ZIP Code -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsAddressZip') }}</label>
|
||||
<input
|
||||
v-model="form.address_zip"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- City -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsAddressCity') }}</label>
|
||||
<input
|
||||
v-model="form.address_city"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Street -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsAddressStreet') }}</label>
|
||||
<input
|
||||
v-model="form.address_street_name"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- House Number -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsAddressHouseNumber') }}</label>
|
||||
<input
|
||||
v-model="form.address_house_number"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-white/10">
|
||||
<button
|
||||
@click="$emit('close')"
|
||||
class="px-4 py-2 rounded-lg text-sm text-white/70 hover:text-white hover:bg-white/10 transition-colors"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
@click="handleSave"
|
||||
:disabled="isSaving"
|
||||
class="px-4 py-2 rounded-lg bg-[#00E5A0] hover:bg-[#00E5A0]/80 text-[#04151F] font-semibold text-sm transition-all duration-200 disabled:opacity-50"
|
||||
>
|
||||
<span v-if="isSaving" class="flex items-center gap-2">
|
||||
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
{{ t('company.submitting') }}
|
||||
</span>
|
||||
<span v-else>{{ t('common.save') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import api from '@/api/axios'
|
||||
import type { OrganizationItem } from '@/types/organization'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const isLoading = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const org = ref<OrganizationItem | null>(null)
|
||||
|
||||
// P0 Feature: Subscription tier assignment
|
||||
const availableTiers = ref<any[]>([])
|
||||
const selectedTierId = ref<number | null>(null)
|
||||
const isAssigning = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
full_name: '',
|
||||
display_name: '',
|
||||
tax_number: '',
|
||||
address_zip: '',
|
||||
address_city: '',
|
||||
address_street_name: '',
|
||||
address_street_type: '',
|
||||
address_house_number: '',
|
||||
address_hrsz: '',
|
||||
})
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────────────────────────
|
||||
|
||||
onMounted(async () => {
|
||||
const orgId = Number(route.params.id)
|
||||
if (!orgId) return
|
||||
|
||||
// Find org from store
|
||||
const found = authStore.myOrganizations.find((o) => o.organization_id === orgId)
|
||||
if (found) {
|
||||
org.value = found
|
||||
selectedTierId.value = found.subscription_tier_id ?? null
|
||||
// Pre-fill form with existing values
|
||||
form.name = found.name || ''
|
||||
form.full_name = found.full_name || ''
|
||||
form.display_name = found.display_name || ''
|
||||
form.tax_number = found.tax_number || ''
|
||||
form.address_zip = found.address_zip || ''
|
||||
form.address_city = found.address_city || ''
|
||||
form.address_street_name = found.address_street_name || ''
|
||||
form.address_street_type = found.address_street_type || ''
|
||||
form.address_house_number = found.address_house_number || ''
|
||||
form.address_hrsz = found.address_hrsz || ''
|
||||
} else {
|
||||
// Try to fetch fresh data
|
||||
isLoading.value = true
|
||||
try {
|
||||
await authStore.fetchMyOrganizations()
|
||||
const refound = authStore.myOrganizations.find((o) => o.organization_id === orgId)
|
||||
if (refound) {
|
||||
org.value = refound
|
||||
selectedTierId.value = refound.subscription_tier_id ?? null
|
||||
form.name = refound.name || ''
|
||||
form.full_name = refound.full_name || ''
|
||||
form.display_name = refound.display_name || ''
|
||||
form.tax_number = refound.tax_number || ''
|
||||
form.address_zip = refound.address_zip || ''
|
||||
form.address_city = refound.address_city || ''
|
||||
form.address_street_name = refound.address_street_name || ''
|
||||
form.address_street_type = refound.address_street_type || ''
|
||||
form.address_house_number = refound.address_house_number || ''
|
||||
form.address_hrsz = refound.address_hrsz || ''
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load organization:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch available subscription tiers
|
||||
try {
|
||||
const res = await api.get('/admin/packages/')
|
||||
availableTiers.value = res.data?.tiers || res.data || []
|
||||
} catch (err) {
|
||||
console.error('Failed to load subscription tiers:', err)
|
||||
}
|
||||
})
|
||||
|
||||
// ── P0 Feature: Assign Subscription Tier ────────────────────────────────────
|
||||
|
||||
async function assignSubscription() {
|
||||
const orgId = Number(route.params.id)
|
||||
if (!orgId || selectedTierId.value === null) return
|
||||
|
||||
isAssigning.value = true
|
||||
try {
|
||||
await api.put(`/organizations/${orgId}/subscription`, {
|
||||
tier_id: selectedTierId.value
|
||||
})
|
||||
|
||||
// Refresh org data in store
|
||||
await authStore.fetchMyOrganizations()
|
||||
const updated = authStore.myOrganizations.find((o) => o.organization_id === orgId)
|
||||
if (updated) {
|
||||
org.value = updated
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
} catch (err: any) {
|
||||
console.error('Failed to assign subscription:', err)
|
||||
alert(err?.response?.data?.detail || 'Failed to assign subscription')
|
||||
} finally {
|
||||
isAssigning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Save Handler ────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleSave() {
|
||||
const orgId = Number(route.params.id)
|
||||
if (!orgId) return
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
// Build payload with only non-empty fields
|
||||
const payload: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(form)) {
|
||||
if (value.trim()) {
|
||||
payload[key] = value.trim()
|
||||
}
|
||||
}
|
||||
|
||||
await api.patch(`/organizations/${orgId}`, payload)
|
||||
|
||||
// Refresh org data in store
|
||||
await authStore.fetchMyOrganizations()
|
||||
|
||||
emit('saved')
|
||||
} catch (err: any) {
|
||||
console.error('Failed to save organization settings:', err)
|
||||
alert(t('company.settingsSaveError'))
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
299
frontend_app/src/components/provider/ProviderAutocomplete.vue
Normal file
@@ -0,0 +1,299 @@
|
||||
<template>
|
||||
<div class="relative w-full">
|
||||
<!-- ── Search Input ── -->
|
||||
<div
|
||||
v-if="!selectedProvider"
|
||||
class="relative"
|
||||
>
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:placeholder="t('provider.autocompletePlaceholder')"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 pl-10 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
|
||||
@input="onSearchInput"
|
||||
@focus="onSearchFocus"
|
||||
@blur="onSearchBlur"
|
||||
@keydown.down.prevent="highlightIndex < filteredResults.length - 1 && highlightIndex++"
|
||||
@keydown.up.prevent="highlightIndex > 0 && highlightIndex--"
|
||||
@keydown.enter.prevent="selectResult(filteredResults[highlightIndex])"
|
||||
@keydown.escape="showDropdown = false"
|
||||
/>
|
||||
<!-- Search Icon -->
|
||||
<svg
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
|
||||
<!-- Loading Spinner -->
|
||||
<svg
|
||||
v-if="isSearching"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-sf-accent"
|
||||
fill="none" viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- ── Selected Provider Card ── -->
|
||||
<div
|
||||
v-if="selectedProvider"
|
||||
class="flex items-start gap-3 rounded-xl border border-sf-accent/30 bg-sf-accent/5 p-3"
|
||||
>
|
||||
<!-- Avatar Placeholder -->
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-sf-accent/10 text-sf-accent font-bold text-sm">
|
||||
{{ selectedProvider.name.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-semibold text-slate-800 truncate">{{ selectedProvider.name }}</p>
|
||||
<p v-if="selectedProvider.category" class="text-xs text-slate-500">{{ selectedProvider.category }}</p>
|
||||
<p v-if="selectedProvider.city || selectedProvider.address" class="text-xs text-slate-400 truncate">
|
||||
{{ selectedProvider.city }}{{ selectedProvider.city && selectedProvider.address ? ', ' : '' }}{{ selectedProvider.address }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Remove Button -->
|
||||
<button
|
||||
@click="clearSelection"
|
||||
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-red-50 hover:text-red-500 cursor-pointer"
|
||||
:aria-label="t('provider.autocompleteClear')"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Dropdown Results ── -->
|
||||
<div
|
||||
v-if="showDropdown && filteredResults.length > 0"
|
||||
class="absolute z-50 mt-1 w-full rounded-xl border border-slate-200 bg-white shadow-lg max-h-64 overflow-y-auto"
|
||||
>
|
||||
<button
|
||||
v-for="(result, idx) in filteredResults"
|
||||
:key="`${result.source}-${result.id}`"
|
||||
@mousedown.prevent="selectResult(result)"
|
||||
class="w-full flex items-start gap-3 px-4 py-3 text-left transition-colors border-b border-slate-100 last:border-b-0"
|
||||
:class="idx === highlightIndex ? 'bg-sf-accent/10' : 'hover:bg-slate-50'"
|
||||
>
|
||||
<!-- Source Badge -->
|
||||
<div
|
||||
class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold"
|
||||
:class="sourceBadgeClass(result.source)"
|
||||
>
|
||||
{{ result.name.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-slate-800 truncate">{{ result.name }}</p>
|
||||
<p class="text-xs text-slate-500">
|
||||
<span v-if="result.category">{{ result.category }}</span>
|
||||
<span v-if="result.city" class="ml-1">· {{ result.city }}</span>
|
||||
</p>
|
||||
<p class="text-xs text-slate-400">
|
||||
<span
|
||||
class="inline-block rounded-full px-1.5 py-0.5 text-[10px] font-medium"
|
||||
:class="sourceLabelClass(result.source)"
|
||||
>
|
||||
{{ sourceLabel(result.source) }}
|
||||
</span>
|
||||
<span v-if="result.is_verified" class="ml-1 text-emerald-600">{{ t('provider.autocompleteVerified') }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- ── "Add New Provider" Footer ── -->
|
||||
<div class="border-t border-slate-200 px-3 py-2">
|
||||
<button
|
||||
@mousedown.prevent="$emit('open-quick-add')"
|
||||
class="flex w-full items-center justify-center gap-2 rounded-lg bg-gradient-to-r from-sf-accent to-sf-blue px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-all hover:shadow-md cursor-pointer"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{{ t('provider.autocompleteAddNew') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Empty State (no results) ── -->
|
||||
<div
|
||||
v-if="showDropdown && filteredResults.length === 0 && searchQuery.length >= 2 && !isSearching"
|
||||
class="absolute z-50 mt-1 w-full rounded-xl border border-slate-200 bg-white shadow-lg p-4"
|
||||
>
|
||||
<p class="text-sm text-slate-500 text-center mb-3">{{ t('provider.autocompleteNoResults') }}</p>
|
||||
<button
|
||||
@mousedown.prevent="$emit('open-quick-add')"
|
||||
class="flex w-full items-center justify-center gap-2 rounded-lg bg-gradient-to-r from-sf-accent to-sf-blue px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-all hover:shadow-md cursor-pointer"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{{ t('provider.autocompleteAddNew') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '@/api/axios'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// ── Types ──
|
||||
export interface ProviderSearchResult {
|
||||
id: number
|
||||
name: string
|
||||
category: string | null
|
||||
specialization: string[]
|
||||
city: string | null
|
||||
address: string | null
|
||||
source: 'verified_org' | 'staged_data' | 'crowd_added'
|
||||
is_verified: boolean
|
||||
rating: number | null
|
||||
}
|
||||
|
||||
// ── Props ──
|
||||
const props = defineProps<{
|
||||
modelValue?: ProviderSearchResult | null
|
||||
}>()
|
||||
|
||||
// ── Emits ──
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: ProviderSearchResult | null): void
|
||||
(e: 'select', value: ProviderSearchResult): void
|
||||
(e: 'clear'): void
|
||||
(e: 'open-quick-add'): void
|
||||
}>()
|
||||
|
||||
// ── State ──
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
const searchQuery = ref('')
|
||||
const selectedProvider = ref<ProviderSearchResult | null>(props.modelValue || null)
|
||||
const results = ref<ProviderSearchResult[]>([])
|
||||
const isSearching = ref(false)
|
||||
const showDropdown = ref(false)
|
||||
const highlightIndex = ref(0)
|
||||
|
||||
// ── Debounce Timer ──
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// ── Computed ──
|
||||
const filteredResults = computed(() => {
|
||||
return results.value
|
||||
})
|
||||
|
||||
// ── Source Badge / Label Helpers ──
|
||||
function sourceBadgeClass(source: string): string {
|
||||
switch (source) {
|
||||
case 'verified_org': return 'bg-emerald-100 text-emerald-700'
|
||||
case 'staged_data': return 'bg-amber-100 text-amber-700'
|
||||
case 'crowd_added': return 'bg-blue-100 text-blue-700'
|
||||
default: return 'bg-slate-100 text-slate-700'
|
||||
}
|
||||
}
|
||||
|
||||
function sourceLabelClass(source: string): string {
|
||||
switch (source) {
|
||||
case 'verified_org': return 'bg-emerald-50 text-emerald-600'
|
||||
case 'staged_data': return 'bg-amber-50 text-amber-600'
|
||||
case 'crowd_added': return 'bg-blue-50 text-blue-600'
|
||||
default: return 'bg-slate-50 text-slate-600'
|
||||
}
|
||||
}
|
||||
|
||||
function sourceLabel(source: string): string {
|
||||
switch (source) {
|
||||
case 'verified_org': return t('provider.autocompleteSourceVerified')
|
||||
case 'staged_data': return t('provider.autocompleteSourceRobot')
|
||||
case 'crowd_added': return t('provider.autocompleteSourceCommunity')
|
||||
default: return source
|
||||
}
|
||||
}
|
||||
|
||||
// ── Search API ──
|
||||
async function performSearch(query: string) {
|
||||
if (query.length < 2) {
|
||||
results.value = []
|
||||
showDropdown.value = false
|
||||
return
|
||||
}
|
||||
|
||||
isSearching.value = true
|
||||
try {
|
||||
const response = await api.get('providers/search', {
|
||||
params: { q: query, limit: 10 },
|
||||
})
|
||||
results.value = response.data.results || []
|
||||
highlightIndex.value = 0
|
||||
showDropdown.value = true
|
||||
} catch (error) {
|
||||
console.error('[ProviderAutocomplete] Search error:', error)
|
||||
results.value = []
|
||||
showDropdown.value = false
|
||||
} finally {
|
||||
isSearching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event Handlers ──
|
||||
function onSearchInput() {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
performSearch(searchQuery.value)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function onSearchFocus() {
|
||||
if (results.value.length > 0) {
|
||||
showDropdown.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function onSearchBlur() {
|
||||
setTimeout(() => {
|
||||
showDropdown.value = false
|
||||
}, 200)
|
||||
}
|
||||
|
||||
function selectResult(result: ProviderSearchResult) {
|
||||
selectedProvider.value = result
|
||||
searchQuery.value = ''
|
||||
results.value = []
|
||||
showDropdown.value = false
|
||||
emit('update:modelValue', result)
|
||||
emit('select', result)
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedProvider.value = null
|
||||
searchQuery.value = ''
|
||||
results.value = []
|
||||
showDropdown.value = false
|
||||
emit('update:modelValue', null)
|
||||
emit('clear')
|
||||
}
|
||||
|
||||
// ── Watch modelValue from parent ──
|
||||
watch(() => props.modelValue, (val) => {
|
||||
selectedProvider.value = val || null
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.animate-spin {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
</style>
|
||||
367
frontend_app/src/components/provider/ProviderDetailModal.vue
Normal file
@@ -0,0 +1,367 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isOpen && provider"
|
||||
class="fixed inset-0 z-[10001] flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
@click.self="emit('close')"
|
||||
>
|
||||
<div
|
||||
class="relative w-full max-w-lg mx-4 rounded-2xl border border-slate-200 bg-white shadow-2xl overflow-hidden animate-fade-in-up"
|
||||
>
|
||||
<!-- ── Header ── -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-200">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-sf-accent to-sf-blue text-white shadow-sm">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-slate-800">{{ t('provider.detailTitle') }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="emit('close')"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-red-50 hover:text-red-500 cursor-pointer"
|
||||
:aria-label="t('serviceFinder.close')"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Body ── -->
|
||||
<div class="p-6 space-y-5">
|
||||
<!-- Provider Name + Source Badge -->
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="text-2xl font-extrabold text-slate-800 truncate">{{ provider.name }}</h2>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold mt-1"
|
||||
:class="sourceBadgeClass"
|
||||
>
|
||||
{{ sourceLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Categories (chips/badges) ── -->
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">{{ t('provider.detailCategories') }}</p>
|
||||
<div v-if="provider.categories && provider.categories.length > 0" class="flex flex-wrap gap-1.5">
|
||||
<span
|
||||
v-for="cat in provider.categories"
|
||||
:key="cat.id"
|
||||
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"
|
||||
:class="categoryBadgeClass(cat.level)"
|
||||
>
|
||||
{{ categoryName(cat) }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-else class="text-sm text-slate-400 italic">{{ t('provider.detailNoCategories') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- ── Contact Grid ── -->
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">{{ t('provider.detailContact') }}</p>
|
||||
<div class="grid grid-cols-1 gap-2">
|
||||
<!-- Phone -->
|
||||
<a
|
||||
v-if="provider.contact_phone"
|
||||
:href="'tel:' + provider.contact_phone"
|
||||
class="flex items-center gap-3 rounded-xl bg-slate-50 px-4 py-3 text-sm font-medium text-slate-700 transition-colors hover:bg-sf-accent/10 hover:text-sf-accent group"
|
||||
>
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent transition-colors group-hover:bg-sf-accent group-hover:text-white">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span>{{ provider.contact_phone }}</span>
|
||||
</a>
|
||||
<div v-else class="flex items-center gap-3 rounded-xl bg-slate-50 px-4 py-3 text-sm text-slate-400">
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-slate-400">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="italic">{{ t('provider.detailNoPhone') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<a
|
||||
v-if="provider.contact_email"
|
||||
:href="'mailto:' + provider.contact_email"
|
||||
class="flex items-center gap-3 rounded-xl bg-slate-50 px-4 py-3 text-sm font-medium text-slate-700 transition-colors hover:bg-sf-accent/10 hover:text-sf-accent group"
|
||||
>
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent transition-colors group-hover:bg-sf-accent group-hover:text-white">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="truncate">{{ provider.contact_email }}</span>
|
||||
</a>
|
||||
<div v-else class="flex items-center gap-3 rounded-xl bg-slate-50 px-4 py-3 text-sm text-slate-400">
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-slate-400">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="italic">{{ t('provider.detailNoEmail') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Website -->
|
||||
<a
|
||||
v-if="provider.website"
|
||||
:href="provider.website"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-3 rounded-xl bg-slate-50 px-4 py-3 text-sm font-medium text-slate-700 transition-colors hover:bg-sf-accent/10 hover:text-sf-accent group"
|
||||
>
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent transition-colors group-hover:bg-sf-accent group-hover:text-white">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="truncate">{{ provider.website }}</span>
|
||||
<svg class="h-3.5 w-3.5 shrink-0 text-slate-400 ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
<div v-else class="flex items-center gap-3 rounded-xl bg-slate-50 px-4 py-3 text-sm text-slate-400">
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-slate-400">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="italic">{{ t('provider.detailNoWebsite') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Location & Plus Code ── -->
|
||||
<div v-if="hasAddress || provider.plus_code">
|
||||
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">{{ t('provider.detailLocation') }}</p>
|
||||
<div class="rounded-xl bg-slate-50 p-4 space-y-3">
|
||||
<!-- Address -->
|
||||
<div v-if="hasAddress" class="flex items-start gap-3">
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-slate-700">{{ formattedAddress }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Plus Code -->
|
||||
<div v-if="provider.plus_code" class="flex items-start gap-3">
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-slate-700 font-mono">{{ provider.plus_code }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Navigate Button (Google Maps) -->
|
||||
<a
|
||||
:href="googleMapsUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="w-full inline-flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-sf-accent to-sf-blue px-4 py-2.5 text-sm font-bold text-white shadow-sm transition-all hover:shadow-md hover:scale-[1.02] active:scale-[0.98] cursor-pointer"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||
</svg>
|
||||
{{ t('provider.detailNavigate') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<hr class="border-slate-200" />
|
||||
|
||||
<!-- ✏️ Edit / Complete Data Button -->
|
||||
<button
|
||||
@click="emit('edit-request', provider)"
|
||||
class="w-full inline-flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-sf-accent to-sf-blue px-6 py-3.5 text-sm font-bold text-white shadow-md transition-all hover:shadow-lg hover:scale-[1.02] active:scale-[0.98] cursor-pointer"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
{{ t('serviceFinder.editRequest') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
// ── Types matching backend ProviderSearchResult ──
|
||||
interface CategoryInfo {
|
||||
id: number
|
||||
name_hu: string | null
|
||||
name_en: string | null
|
||||
level: number
|
||||
key: string
|
||||
}
|
||||
|
||||
interface ProviderSearchResult {
|
||||
id: number
|
||||
name: string
|
||||
category: string | null
|
||||
specialization: string[]
|
||||
categories: CategoryInfo[]
|
||||
city: string | null
|
||||
address: string | null
|
||||
address_zip: string | null
|
||||
address_street_name: string | null
|
||||
address_street_type: string | null
|
||||
address_house_number: string | null
|
||||
plus_code: string | null
|
||||
contact_phone: string | null
|
||||
contact_email: string | null
|
||||
website: string | null
|
||||
tags: string[]
|
||||
source: 'verified_org' | 'staged_data' | 'crowd_added'
|
||||
is_verified: boolean
|
||||
rating: number | null
|
||||
}
|
||||
|
||||
// ── Props ──
|
||||
const props = defineProps<{
|
||||
isOpen: boolean
|
||||
provider: ProviderSearchResult | null
|
||||
}>()
|
||||
|
||||
// ── Emits ──
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'edit-request', provider: ProviderSearchResult): void
|
||||
}>()
|
||||
|
||||
// ── Computed helpers ──
|
||||
const sourceBadgeClass = computed(() => {
|
||||
switch (props.provider?.source) {
|
||||
case 'verified_org': return 'bg-emerald-100 text-emerald-700'
|
||||
case 'crowd_added': return 'bg-amber-100 text-amber-700'
|
||||
case 'staged_data': return 'bg-slate-100 text-slate-600'
|
||||
default: return 'bg-slate-100 text-slate-600'
|
||||
}
|
||||
})
|
||||
|
||||
const sourceLabel = computed(() => {
|
||||
switch (props.provider?.source) {
|
||||
case 'verified_org': return t('serviceFinder.sourceVerified')
|
||||
case 'crowd_added': return t('serviceFinder.sourceCommunity')
|
||||
case 'staged_data': return t('serviceFinder.sourceRobot')
|
||||
default: return props.provider?.source || ''
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Category badge color based on level.
|
||||
* Level 0 = Vehicle Type (purple), Level 1 = Industry (blue),
|
||||
* Level 2 = Profession (teal), Level 3 = Specific Tag (slate).
|
||||
*/
|
||||
function categoryBadgeClass(level: number): string {
|
||||
switch (level) {
|
||||
case 0: return 'bg-purple-100 text-purple-700'
|
||||
case 1: return 'bg-blue-100 text-blue-700'
|
||||
case 2: return 'bg-teal-100 text-teal-700'
|
||||
case 3: return 'bg-slate-100 text-slate-600'
|
||||
default: return 'bg-slate-100 text-slate-600'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the localized category name based on current locale.
|
||||
*/
|
||||
function categoryName(cat: CategoryInfo): string {
|
||||
if (locale.value === 'hu' && cat.name_hu) return cat.name_hu
|
||||
if (cat.name_en) return cat.name_en
|
||||
return cat.key
|
||||
}
|
||||
|
||||
/**
|
||||
* P1 CRITICAL ALIGN: Atomizált címmezők intelligens összefűzése.
|
||||
*/
|
||||
const hasAddress = computed(() => {
|
||||
const p = props.provider
|
||||
if (!p) return false
|
||||
return !!(p.city || p.address_street_name || p.address_street_type || p.address_house_number)
|
||||
})
|
||||
|
||||
const formattedAddress = computed(() => {
|
||||
const p = props.provider
|
||||
if (!p) return ''
|
||||
|
||||
const parts: string[] = []
|
||||
|
||||
// ZIP + City
|
||||
const locationParts: string[] = []
|
||||
if (p.address_zip) locationParts.push(p.address_zip)
|
||||
if (p.city) locationParts.push(p.city)
|
||||
if (locationParts.length > 0) parts.push(locationParts.join(' '))
|
||||
|
||||
// Atomizált cím: street_name + street_type + house_number
|
||||
const streetParts: string[] = []
|
||||
if (p.address_street_name) streetParts.push(p.address_street_name)
|
||||
if (p.address_street_type) streetParts.push(p.address_street_type)
|
||||
if (p.address_house_number) streetParts.push(p.address_house_number)
|
||||
|
||||
if (streetParts.length > 0) {
|
||||
parts.push(streetParts.join(' '))
|
||||
}
|
||||
|
||||
return parts.join(', ')
|
||||
})
|
||||
|
||||
/**
|
||||
* Google Maps URL: prefer plus_code, fall back to address query.
|
||||
*/
|
||||
const googleMapsUrl = computed(() => {
|
||||
const p = props.provider
|
||||
if (!p) return 'https://maps.google.com'
|
||||
|
||||
if (p.plus_code) {
|
||||
return `https://maps.google.com/maps?q=${encodeURIComponent(p.plus_code)}`
|
||||
}
|
||||
|
||||
const queryParts: string[] = []
|
||||
if (p.address_street_name) queryParts.push(p.address_street_name)
|
||||
if (p.address_street_type) queryParts.push(p.address_street_type)
|
||||
if (p.address_house_number) queryParts.push(p.address_house_number)
|
||||
if (p.city) queryParts.push(p.city)
|
||||
if (p.address_zip) queryParts.push(p.address_zip)
|
||||
|
||||
if (queryParts.length > 0) {
|
||||
return `https://maps.google.com/maps?q=${encodeURIComponent(queryParts.join(' '))}`
|
||||
}
|
||||
|
||||
return `https://maps.google.com/maps?q=${encodeURIComponent(p.name)}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
.animate-fade-in-up {
|
||||
animation: fadeInUp 0.25s ease-out forwards;
|
||||
}
|
||||
</style>
|
||||
846
frontend_app/src/components/provider/ProviderEditModal.vue
Normal file
@@ -0,0 +1,846 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isOpen && provider"
|
||||
class="fixed inset-0 z-[10002] flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
@click.self="emit('close')"
|
||||
>
|
||||
<div
|
||||
class="relative w-full max-w-lg mx-4 flex flex-col rounded-2xl border border-slate-200 bg-white shadow-2xl animate-fade-in-up"
|
||||
style="max-height: 85vh;"
|
||||
>
|
||||
<!-- ── Header ── -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-200 shrink-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-sf-accent to-sf-blue text-white shadow-sm">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-slate-800">{{ t('provider.editTitle') }}</h3>
|
||||
<p class="text-xs text-slate-500">{{ provider.name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="emit('close')"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-red-50 hover:text-red-500 cursor-pointer"
|
||||
:aria-label="t('serviceFinder.close')"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Tab Bar ── -->
|
||||
<div class="flex border-b border-slate-200 px-6 shrink-0">
|
||||
<button
|
||||
@click="activeTab = 'basic'"
|
||||
class="inline-flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all cursor-pointer"
|
||||
:class="activeTab === 'basic'
|
||||
? 'border-sf-accent text-sf-accent'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700'"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
{{ t('provider.tabBasic') }}
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'services'"
|
||||
class="inline-flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all cursor-pointer"
|
||||
:class="activeTab === 'services'
|
||||
? 'border-sf-accent text-sf-accent'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700'"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
{{ t('provider.tabServices') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Scrollable Body ── -->
|
||||
<div class="overflow-y-auto p-6 space-y-5" style="max-height: calc(85vh - 140px);">
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<!-- TAB 1: Alapadatok (Basic Data) -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<template v-if="activeTab === 'basic'">
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
{{ t('provider.nameLabel') }} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
|
||||
:placeholder="t('provider.namePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── Atomizált címmezők (P1 CRITICAL ALIGN) ── -->
|
||||
<div class="border-t border-slate-100 pt-4">
|
||||
<p class="mb-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('provider.addressSection') }}</p>
|
||||
<!-- Street Name -->
|
||||
<div class="mb-3">
|
||||
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
{{ t('common.streetName') }}
|
||||
<span class="text-xs font-normal text-slate-400 lowercase">({{ t('provider.streetNameOptional') }})</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.address_street_name"
|
||||
type="text"
|
||||
maxlength="150"
|
||||
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
|
||||
:placeholder="t('provider.streetNamePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Street Type + House Number row -->
|
||||
<div class="grid grid-cols-2 gap-4 mb-3">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
{{ t('provider.streetTypeLabel') }}
|
||||
</label>
|
||||
<select
|
||||
v-model="form.address_street_type"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20 appearance-none cursor-pointer"
|
||||
>
|
||||
<option value="">{{ t('provider.streetTypePlaceholder') }}</option>
|
||||
<option value="utca">utca</option>
|
||||
<option value="út">út</option>
|
||||
<option value="tér">tér</option>
|
||||
<option value="körút">körút</option>
|
||||
<option value="sugárút">sugárút</option>
|
||||
<option value="sétány">sétány</option>
|
||||
<option value="köz">köz</option>
|
||||
<option value="sor">sor</option>
|
||||
<option value="liget">liget</option>
|
||||
<option value="park">park</option>
|
||||
<option value="rakpart">rakpart</option>
|
||||
<option value="dűlő">dűlő</option>
|
||||
<option value="hegy">hegy</option>
|
||||
<option value="lakópark">lakópark</option>
|
||||
<option value="üdülőtelep">üdülőtelep</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
{{ t('provider.houseNumberLabel') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.address_house_number"
|
||||
type="text"
|
||||
maxlength="20"
|
||||
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
|
||||
:placeholder="t('provider.houseNumberPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- City + ZIP row -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
{{ t('provider.zipLabel') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.zip"
|
||||
type="text"
|
||||
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
|
||||
:placeholder="t('provider.zipPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
{{ t('provider.cityLabel') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.city"
|
||||
type="text"
|
||||
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
|
||||
:placeholder="t('provider.cityPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plus Code -->
|
||||
<div class="mt-3">
|
||||
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
{{ t('provider.plusCodeLabel') }}
|
||||
<span class="text-xs font-normal text-slate-400 lowercase">({{ t('provider.plusCodeOptional') }})</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.plus_code"
|
||||
type="text"
|
||||
maxlength="20"
|
||||
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
|
||||
:placeholder="t('provider.plusCodePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Elérhetőségek (Contact) ── -->
|
||||
<div class="border-t border-slate-100 pt-4">
|
||||
<p class="mb-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('provider.contactSection') }}</p>
|
||||
<!-- Phone -->
|
||||
<div class="mb-3">
|
||||
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
{{ t('common.phone') }}
|
||||
<span class="text-xs font-normal text-slate-400 lowercase">({{ t('provider.phoneOptional') }})</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.phone"
|
||||
type="tel"
|
||||
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
|
||||
:placeholder="t('provider.phonePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div class="mb-3">
|
||||
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
{{ t('common.email') }}
|
||||
<span class="text-xs font-normal text-slate-400 lowercase">({{ t('provider.emailOptional') }})</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
|
||||
:placeholder="t('provider.emailPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Website -->
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
{{ t('provider.websiteLabel') }}
|
||||
<span class="text-xs font-normal text-slate-400 lowercase">({{ t('provider.websiteOptional') }})</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.website"
|
||||
type="url"
|
||||
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
|
||||
:placeholder="t('provider.websitePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<!-- TAB 2: Szolgáltatások (Services) -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<template v-if="activeTab === 'services'">
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<!-- BLOCK A: Fix Struktúra (Szint 0 + Szint 1) -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="flex h-6 w-6 items-center justify-center rounded-full bg-sf-accent/10 text-sf-accent">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-bold text-slate-700">{{ t('categories.block_a_title') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="categoryTreeLoading" class="flex items-center gap-2 text-sm text-slate-400 py-2">
|
||||
<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
|
||||
<!-- Level 0: Járműtípus (always visible) -->
|
||||
<div v-if="level0Categories.length > 0" class="mb-3">
|
||||
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">{{ t('provider.level0Label') }}</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="cat in level0Categories"
|
||||
:key="cat.id"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-medium text-slate-600 transition-all hover:border-sf-accent/40 hover:bg-sf-accent/5 cursor-pointer"
|
||||
:class="{ 'border-sf-accent bg-sf-accent/10 text-sf-accent': selectedCategoryIds.includes(cat.id) }"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedCategoryIds.includes(cat.id)"
|
||||
@change="toggleCategory(cat.id)"
|
||||
class="h-3.5 w-3.5 rounded border-slate-300 text-sf-accent focus:ring-sf-accent/30"
|
||||
/>
|
||||
{{ cat.name_hu || cat.key }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Level 1: Iparág/Főcsoport (children of CHECKED Level 0) -->
|
||||
<div v-if="vehicleLevel1Categories.length > 0" class="mb-3 ml-4 pl-3 border-l-2 border-sf-accent/20">
|
||||
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">{{ t('provider.level1Label') }}</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="l1 in vehicleLevel1Categories"
|
||||
:key="l1.id"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-medium text-slate-600 transition-all hover:border-sf-accent/40 hover:bg-sf-accent/5 cursor-pointer"
|
||||
:class="{ 'border-sf-accent bg-sf-accent/10 text-sf-accent': selectedCategoryIds.includes(l1.id) }"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedCategoryIds.includes(l1.id)"
|
||||
@change="toggleCategory(l1.id)"
|
||||
class="h-3.5 w-3.5 rounded border-slate-300 text-sf-accent focus:ring-sf-accent/30"
|
||||
/>
|
||||
{{ l1.name_hu || l1.key }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Universal Level 1: Iparágak és Univerzális Szolgáltatások (independent of Level 0) -->
|
||||
<div v-if="universalLevel1Categories.length > 0" class="mb-3 mt-4 pt-3 border-t-2 border-dashed border-amber-200">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="flex h-6 w-6 items-center justify-center rounded-full bg-amber-50 text-amber-500">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-bold text-slate-700">{{ t('categories.universal_title') }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-400 mb-2">{{ t('categories.universal_hint') }}</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="l1 in universalLevel1Categories"
|
||||
:key="l1.id"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-amber-200 px-3 py-1.5 text-xs font-medium text-slate-600 transition-all hover:border-amber-400 hover:bg-amber-50 cursor-pointer"
|
||||
:class="{ 'border-amber-500 bg-amber-50 text-amber-700 font-semibold': selectedCategoryIds.includes(l1.id) }"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedCategoryIds.includes(l1.id)"
|
||||
@change="toggleCategory(l1.id)"
|
||||
class="h-3.5 w-3.5 rounded border-amber-300 text-amber-500 focus:ring-amber-300"
|
||||
/>
|
||||
{{ l1.name_hu || l1.key }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<!-- BLOCK B: Okos Szövegmező (Szint 2 + Szint 3) -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<div class="border-t border-slate-100 pt-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="flex h-6 w-6 items-center justify-center rounded-full bg-blue-50 text-blue-500">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-bold text-slate-700">{{ t('categories.block_b_title') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Autocomplete input -->
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="autocompleteQuery"
|
||||
type="text"
|
||||
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
|
||||
:placeholder="t('categories.search_placeholder')"
|
||||
@input="onAutocompleteInput"
|
||||
@keydown.enter.prevent="onAutocompleteEnter"
|
||||
@keydown.backspace="onAutocompleteBackspace"
|
||||
@blur="onAutocompleteBlur"
|
||||
/>
|
||||
<!-- Autocomplete dropdown -->
|
||||
<div
|
||||
v-if="autocompleteQuery.length >= 2"
|
||||
class="absolute z-10 mt-1 w-full rounded-xl border border-slate-200 bg-white shadow-lg max-h-48 overflow-y-auto"
|
||||
>
|
||||
<!-- Results list -->
|
||||
<button
|
||||
v-for="item in autocompleteResults"
|
||||
:key="item.id"
|
||||
@mousedown.prevent="selectAutocompleteItem(item)"
|
||||
class="w-full px-4 py-2.5 text-left text-sm text-slate-700 transition-colors hover:bg-sf-accent/5 hover:text-sf-accent flex items-center gap-2"
|
||||
>
|
||||
<span class="text-xs text-slate-400 font-mono">L{{ item.level }}</span>
|
||||
<span>{{ item.name_hu || item.key }}</span>
|
||||
</button>
|
||||
<!-- No results message -->
|
||||
<div
|
||||
v-if="autocompleteResults.length === 0"
|
||||
class="px-4 py-3 text-sm text-slate-400 text-center"
|
||||
>
|
||||
{{ t('categories.no_results_create') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selected tags (chips) -->
|
||||
<div v-if="selectedTags.length > 0" class="mt-3 flex flex-wrap gap-1.5">
|
||||
<span
|
||||
v-for="(tag, idx) in selectedTags"
|
||||
:key="'tag-' + idx"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-600 border border-blue-100"
|
||||
>
|
||||
{{ tag.name_hu || tag.key }}
|
||||
<button
|
||||
@click="removeTag(idx)"
|
||||
class="ml-0.5 text-blue-400 hover:text-red-500 cursor-pointer"
|
||||
>×</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gamification Toast -->
|
||||
<div
|
||||
v-if="gamificationToast"
|
||||
class="flex items-center gap-2 rounded-xl px-4 py-3 text-sm font-medium shadow-sm transition-all duration-300"
|
||||
:class="gamificationToast.type === 'success'
|
||||
? 'bg-green-50 text-green-700 border border-green-200'
|
||||
: 'bg-amber-50 text-amber-700 border border-amber-200'"
|
||||
>
|
||||
<svg v-if="gamificationToast.type === 'success'" class="h-5 w-5 shrink-0 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<svg v-else class="h-5 w-5 shrink-0 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<span>{{ gamificationToast.message }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<hr class="border-slate-200" />
|
||||
|
||||
<!-- Info text -->
|
||||
<p class="text-xs text-slate-400 italic">
|
||||
{{ t('provider.editInfo') }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ── Sticky Footer (Save / Cancel) ── -->
|
||||
<div class="shrink-0 border-t border-slate-200 bg-white px-6 py-4 rounded-b-2xl">
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
@click="emit('close')"
|
||||
class="flex-1 rounded-xl border border-slate-300 bg-white px-6 py-3 text-sm font-semibold text-slate-600 shadow-sm transition-all hover:bg-slate-50 cursor-pointer"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
@click="handleSave"
|
||||
:disabled="isSaving || !form.name.trim()"
|
||||
class="flex-1 inline-flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-sf-accent to-sf-blue px-6 py-3 text-sm font-bold text-white shadow-md transition-all hover:shadow-lg hover:scale-[1.02] active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 cursor-pointer"
|
||||
>
|
||||
<svg v-if="isSaving" class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<svg v-else class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{{ isSaving ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ProviderEditModal.vue — Teljes Szerkesztő Ablak szolgáltató adatokhoz.
|
||||
*
|
||||
* P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők.
|
||||
* - A régi single 'street' mező helyett 3 külön mező:
|
||||
* address_street_name, address_street_type, address_house_number
|
||||
* - A payload pontosan az új, atomizált kulcsokkal megy a backend felé.
|
||||
*
|
||||
* UX OVERHAUL (2026-06-17):
|
||||
* - Tab 1: Alapadatok (Név, Cím 3 bontásban, Elérhetőségek)
|
||||
* - Tab 2: Szolgáltatások (Kategória fa + Autocomplete)
|
||||
* - Görgethető body (overflow-y-auto, max-height: 85vh)
|
||||
* - Sticky footer a Mentés/Mégse gombokkal
|
||||
* - Okos Kategória Fa: csak a kipipált Szint 0 gyerekei jelennek meg
|
||||
*
|
||||
* 4-Level Category Hybrid UI (2026-06-17):
|
||||
* ==========================================
|
||||
* BLOCK A (Fix Struktúra): Szint 0 (Járműtípus) + Szint 1 (Iparág) checkboxok
|
||||
* - Okos: Szint 1 csak akkor jelenik meg, ha a Szint 0 ki van pipálva
|
||||
* BLOCK B (Okos Szövegmező): Szint 2 + Szint 3 autocomplete + chip tags
|
||||
* - Ha a gépelt szó létezik → legördül és kiválasztható
|
||||
* - Ha nem létezik → Enter → új chip címke (new_tags)
|
||||
*
|
||||
* @emits close — modal bezárása
|
||||
* @emits saved — sikeres mentés után, payload: { id: number }
|
||||
*
|
||||
* @see backend/app/api/v1/endpoints/providers.py — PUT /providers/{id}
|
||||
* @see frontend/src/components/provider/ProviderDetailModal.vue — hívó
|
||||
*/
|
||||
import { ref, reactive, watch, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '@/api/axios'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// ── Types ──
|
||||
interface ProviderSearchResult {
|
||||
id: number
|
||||
name: string
|
||||
category: string | null
|
||||
specialization: string[]
|
||||
city: string | null
|
||||
address: string | null
|
||||
address_zip: string | null
|
||||
address_street_name: string | null
|
||||
address_street_type: string | null
|
||||
address_house_number: string | null
|
||||
contact_phone: string | null
|
||||
contact_email: string | null
|
||||
website: string | null
|
||||
tags: string[]
|
||||
source: 'verified_org' | 'staged_data' | 'crowd_added'
|
||||
is_verified: boolean
|
||||
rating: number | null
|
||||
}
|
||||
|
||||
interface CategoryTreeNode {
|
||||
id: number
|
||||
key: string
|
||||
name_hu: string | null
|
||||
name_en: string | null
|
||||
level: number
|
||||
path: string | null
|
||||
is_official: boolean
|
||||
children: CategoryTreeNode[]
|
||||
}
|
||||
|
||||
interface AutocompleteItem {
|
||||
id: number
|
||||
key: string
|
||||
name_hu: string | null
|
||||
name_en: string | null
|
||||
level: number
|
||||
path: string | null
|
||||
parent_id: number | null
|
||||
}
|
||||
|
||||
interface SelectedTag {
|
||||
id: number | null // null for user-created tags (not yet in DB)
|
||||
key: string
|
||||
name_hu: string | null
|
||||
level: number
|
||||
}
|
||||
|
||||
// ── Props ──
|
||||
const props = defineProps<{
|
||||
isOpen: boolean
|
||||
provider: ProviderSearchResult | null
|
||||
}>()
|
||||
|
||||
// ── Emits ──
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'saved', payload: { id: number }): void
|
||||
}>()
|
||||
|
||||
// ── Tab State ──
|
||||
const activeTab = ref<'basic' | 'services'>('basic')
|
||||
|
||||
// ── Form State ──
|
||||
const isSaving = ref(false)
|
||||
|
||||
// ── Gamification Toast ──
|
||||
const gamificationToast = ref<{ type: 'success' | 'penalty'; message: string } | null>(null)
|
||||
|
||||
interface FormState {
|
||||
name: string
|
||||
city: string
|
||||
zip: string
|
||||
address_street_name: string
|
||||
address_street_type: string
|
||||
address_house_number: string
|
||||
plus_code: string
|
||||
phone: string
|
||||
email: string
|
||||
website: string
|
||||
}
|
||||
|
||||
const form = reactive<FormState>({
|
||||
name: '',
|
||||
city: '',
|
||||
zip: '',
|
||||
address_street_name: '',
|
||||
address_street_type: '',
|
||||
address_house_number: '',
|
||||
plus_code: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
website: '',
|
||||
})
|
||||
|
||||
// ── Category State (4-Level) ──
|
||||
const categoryTreeLoading = ref(false)
|
||||
const categoryTree = ref<CategoryTreeNode[]>([])
|
||||
const selectedCategoryIds = ref<number[]>([])
|
||||
const selectedTags = ref<SelectedTag[]>([])
|
||||
const autocompleteQuery = ref('')
|
||||
const autocompleteResults = ref<AutocompleteItem[]>([])
|
||||
let autocompleteTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// ── Computed: Level 0 categories ──
|
||||
const level0Categories = computed(() => {
|
||||
return categoryTree.value.filter(c => c.level === 0)
|
||||
})
|
||||
|
||||
// ── Computed: Vehicle-specific Level 1 categories ──
|
||||
// Shows Level 1 children of CHECKED Level 0 parents only.
|
||||
const vehicleLevel1Categories = computed(() => {
|
||||
const checkedL0Ids = new Set(selectedCategoryIds.value)
|
||||
const result: CategoryTreeNode[] = []
|
||||
for (const l0 of level0Categories.value) {
|
||||
if (l0.children.length > 0 && checkedL0Ids.has(l0.id)) {
|
||||
for (const l1 of l0.children) {
|
||||
// Avoid duplicates if same L1 appears under multiple L0
|
||||
if (!result.some(r => r.id === l1.id)) {
|
||||
result.push(l1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
// ── Computed: Universal Level 1 categories ──
|
||||
// Level 1 categories that are root nodes (parent_id IS NULL) in the tree.
|
||||
// These are independent of any vehicle type and always visible.
|
||||
// Examples: "Üzemanyag és Töltőállomás", "Étel és Ital", "Szállás"
|
||||
const universalLevel1Categories = computed(() => {
|
||||
return categoryTree.value.filter(c => c.level === 1)
|
||||
})
|
||||
|
||||
// ── Load category tree on mount ──
|
||||
onMounted(async () => {
|
||||
await loadCategoryTree()
|
||||
})
|
||||
|
||||
// ── Watch provider changes → populate form ──
|
||||
watch(
|
||||
() => props.provider,
|
||||
(provider) => {
|
||||
if (provider) {
|
||||
form.name = provider.name || ''
|
||||
form.city = provider.city || ''
|
||||
form.zip = provider.address_zip || ''
|
||||
// P1 CRITICAL ALIGN: Atomizált címmezők kitöltése
|
||||
form.address_street_name = provider.address_street_name || ''
|
||||
form.address_street_type = provider.address_street_type || ''
|
||||
form.address_house_number = provider.address_house_number || ''
|
||||
form.phone = provider.contact_phone || ''
|
||||
form.email = provider.contact_email || ''
|
||||
form.website = provider.website || ''
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// ── Load Category Tree from API ──
|
||||
async function loadCategoryTree() {
|
||||
categoryTreeLoading.value = true
|
||||
try {
|
||||
const response = await api.get('providers/categories/tree')
|
||||
categoryTree.value = response.data || []
|
||||
} catch (err) {
|
||||
console.error('[ProviderEditModal] Failed to load category tree:', err)
|
||||
} finally {
|
||||
categoryTreeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Toggle category selection (Block A) ──
|
||||
function toggleCategory(categoryId: number) {
|
||||
const idx = selectedCategoryIds.value.indexOf(categoryId)
|
||||
if (idx >= 0) {
|
||||
selectedCategoryIds.value.splice(idx, 1)
|
||||
} else {
|
||||
selectedCategoryIds.value.push(categoryId)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Autocomplete input handler (Block B) ──
|
||||
function onAutocompleteInput() {
|
||||
if (autocompleteTimer) {
|
||||
clearTimeout(autocompleteTimer)
|
||||
}
|
||||
|
||||
const query = autocompleteQuery.value.trim()
|
||||
if (query.length < 2) {
|
||||
autocompleteResults.value = []
|
||||
return
|
||||
}
|
||||
|
||||
// Debounce: 300ms
|
||||
autocompleteTimer = setTimeout(async () => {
|
||||
try {
|
||||
const response = await api.get('providers/categories/autocomplete', {
|
||||
params: { q: query },
|
||||
})
|
||||
// Filter out already selected tags
|
||||
const selectedKeys = new Set(selectedTags.value.map(t => t.key))
|
||||
autocompleteResults.value = (response.data || []).filter(
|
||||
(item: AutocompleteItem) => !selectedKeys.has(item.key)
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('[ProviderEditModal] Autocomplete error:', err)
|
||||
autocompleteResults.value = []
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// ── Select autocomplete item ──
|
||||
function selectAutocompleteItem(item: AutocompleteItem) {
|
||||
selectedTags.value.push({
|
||||
id: item.id,
|
||||
key: item.key,
|
||||
name_hu: item.name_hu,
|
||||
level: item.level,
|
||||
})
|
||||
autocompleteQuery.value = ''
|
||||
autocompleteResults.value = []
|
||||
}
|
||||
|
||||
// ── Enter key: create new tag if not found ──
|
||||
function onAutocompleteEnter() {
|
||||
const query = autocompleteQuery.value.trim()
|
||||
if (!query) return
|
||||
|
||||
// Check if already selected
|
||||
if (selectedTags.value.some(t => t.key === query.toLowerCase().replace(/\s+/g, '_'))) {
|
||||
autocompleteQuery.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
// Check if it matches any autocomplete result
|
||||
const match = autocompleteResults.value.find(
|
||||
item => (item.name_hu || '').toLowerCase() === query.toLowerCase()
|
||||
)
|
||||
if (match) {
|
||||
selectAutocompleteItem(match)
|
||||
return
|
||||
}
|
||||
|
||||
// Create new tag (user-created, not yet in DB)
|
||||
selectedTags.value.push({
|
||||
id: null, // null = not yet in DB
|
||||
key: query.toLowerCase().replace(/\s+/g, '_'),
|
||||
name_hu: query,
|
||||
level: 3,
|
||||
})
|
||||
autocompleteQuery.value = ''
|
||||
autocompleteResults.value = []
|
||||
}
|
||||
|
||||
// ── Backspace on empty input: remove last tag ──
|
||||
function onAutocompleteBackspace() {
|
||||
if (autocompleteQuery.value === '' && selectedTags.value.length > 0) {
|
||||
selectedTags.value.pop()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Blur: close autocomplete dropdown ──
|
||||
function onAutocompleteBlur() {
|
||||
// Delay to allow click on dropdown items
|
||||
setTimeout(() => {
|
||||
autocompleteResults.value = []
|
||||
}, 200)
|
||||
}
|
||||
|
||||
// ── Remove tag ──
|
||||
function removeTag(index: number) {
|
||||
selectedTags.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// ── Save handler (async — calls backend PUT /providers/{id}) ──
|
||||
async function handleSave() {
|
||||
if (!form.name.trim() || !props.provider) return
|
||||
|
||||
isSaving.value = true
|
||||
gamificationToast.value = null
|
||||
|
||||
try {
|
||||
// Separate existing tag IDs from new tag names
|
||||
const existingCategoryIds = selectedTags.value
|
||||
.filter(t => t.id !== null)
|
||||
.map(t => t.id as number)
|
||||
|
||||
const newTagNames = selectedTags.value
|
||||
.filter(t => t.id === null)
|
||||
.map(t => t.name_hu || t.key)
|
||||
|
||||
// Combine Block A + Block B category IDs
|
||||
const allCategoryIds = [...selectedCategoryIds.value, ...existingCategoryIds]
|
||||
|
||||
// P1 CRITICAL ALIGN: Atomizált címmezők a payload-ban
|
||||
const body: Record<string, any> = {
|
||||
name: form.name.trim(),
|
||||
city: form.city.trim() || null,
|
||||
address_zip: form.zip.trim() || null,
|
||||
address_street_name: form.address_street_name.trim() || null,
|
||||
address_street_type: form.address_street_type.trim() || null,
|
||||
address_house_number: form.address_house_number.trim() || null,
|
||||
plus_code: form.plus_code.trim() || null,
|
||||
contact_phone: form.phone.trim() || null,
|
||||
contact_email: form.email.trim() || null,
|
||||
website: form.website.trim() || null,
|
||||
tags: null,
|
||||
category_ids: allCategoryIds.length > 0 ? allCategoryIds : null,
|
||||
new_tags: newTagNames.length > 0 ? newTagNames : null,
|
||||
}
|
||||
|
||||
const response = await api.put(`providers/${props.provider.id}`, body)
|
||||
const earnedPoints = response.data?.earned_points
|
||||
|
||||
// Show gamification toast if points were awarded
|
||||
if (earnedPoints !== undefined && earnedPoints !== null) {
|
||||
if (earnedPoints >= 0) {
|
||||
gamificationToast.value = {
|
||||
type: 'success',
|
||||
message: t('gamification.points_awarded', { points: earnedPoints }),
|
||||
}
|
||||
} else {
|
||||
gamificationToast.value = {
|
||||
type: 'penalty',
|
||||
message: t('gamification.penalty_applied', { points: Math.abs(earnedPoints) }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Emit success so parent can refresh search results
|
||||
emit('saved', { id: props.provider.id })
|
||||
} catch (err: any) {
|
||||
console.error('[ProviderEditModal] Save error:', err)
|
||||
// Keep modal open on error so user can retry
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
.animate-fade-in-up {
|
||||
animation: fadeInUp 0.25s ease-out forwards;
|
||||
}
|
||||
</style>
|
||||
981
frontend_app/src/components/provider/ProviderQuickAddModal.vue
Normal file
@@ -0,0 +1,981 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 z-[10001] flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
@click.self="handleClose"
|
||||
>
|
||||
<div
|
||||
class="relative w-full max-w-lg mx-4 flex flex-col rounded-2xl border border-slate-200 bg-white shadow-2xl animate-fade-in-up"
|
||||
style="max-height: 85vh;"
|
||||
>
|
||||
<!-- ── Header ── -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-200 shrink-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-sf-accent to-sf-blue text-white shadow-sm">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-slate-800">{{ t('provider.quickAddTitle') }}</h3>
|
||||
<p class="text-xs text-slate-500">{{ t('provider.quickAddSubtitle') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="handleClose"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-red-50 hover:text-red-500 cursor-pointer"
|
||||
:aria-label="t('provider.closeButton')"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Tab Bar ── -->
|
||||
<div class="flex border-b border-slate-200 px-6 shrink-0">
|
||||
<button
|
||||
@click="activeTab = 'basic'"
|
||||
class="inline-flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all cursor-pointer"
|
||||
:class="activeTab === 'basic'
|
||||
? 'border-sf-accent text-sf-accent'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700'"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
{{ t('provider.tabBasic') }}
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'services'"
|
||||
class="inline-flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all cursor-pointer"
|
||||
:class="activeTab === 'services'
|
||||
? 'border-sf-accent text-sf-accent'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700'"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
{{ t('provider.tabServices') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Scrollable Body ── -->
|
||||
<div class="overflow-y-auto p-6 space-y-5" style="max-height: calc(85vh - 140px);">
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<!-- TAB 1: Alapadatok (Basic Data) -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<template v-if="activeTab === 'basic'">
|
||||
<!-- Provider Name -->
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
|
||||
{{ t('provider.nameLabel') }} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
required
|
||||
minlength="2"
|
||||
maxlength="200"
|
||||
:placeholder="t('provider.namePlaceholder')"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── Atomizált címmezők (P1 CRITICAL ALIGN) ── -->
|
||||
<div class="border-t border-slate-100 pt-4">
|
||||
<p class="mb-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('provider.addressSection') }}</p>
|
||||
<!-- Street Name -->
|
||||
<div class="mb-3">
|
||||
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
|
||||
{{ t('common.streetName') }} <span class="text-slate-400 text-xs">({{ t('provider.streetNameOptional') }})</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.address_street_name"
|
||||
type="text"
|
||||
maxlength="150"
|
||||
:placeholder="t('provider.streetNamePlaceholder')"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Street Type + House Number row -->
|
||||
<div class="grid grid-cols-2 gap-4 mb-3">
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
|
||||
{{ t('provider.streetTypeLabel') }} <span class="text-slate-400 text-xs">({{ t('provider.streetTypeOptional') }})</span>
|
||||
</label>
|
||||
<select
|
||||
v-model="form.address_street_type"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20 appearance-none cursor-pointer"
|
||||
>
|
||||
<option value="">{{ t('provider.streetTypePlaceholder') }}</option>
|
||||
<option value="utca">utca</option>
|
||||
<option value="út">út</option>
|
||||
<option value="tér">tér</option>
|
||||
<option value="körút">körút</option>
|
||||
<option value="sugárút">sugárút</option>
|
||||
<option value="sétány">sétány</option>
|
||||
<option value="köz">köz</option>
|
||||
<option value="sor">sor</option>
|
||||
<option value="liget">liget</option>
|
||||
<option value="park">park</option>
|
||||
<option value="rakpart">rakpart</option>
|
||||
<option value="dűlő">dűlő</option>
|
||||
<option value="hegy">hegy</option>
|
||||
<option value="lakópark">lakópark</option>
|
||||
<option value="üdülőtelep">üdülőtelep</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
|
||||
{{ t('provider.houseNumberLabel') }} <span class="text-slate-400 text-xs">({{ t('provider.houseNumberOptional') }})</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.address_house_number"
|
||||
type="text"
|
||||
maxlength="20"
|
||||
:placeholder="t('provider.houseNumberPlaceholder')"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- City + ZIP row -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
|
||||
{{ t('provider.zipLabel') }} <span class="text-slate-400 text-xs">({{ t('provider.zipOptional') }})</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.address_zip"
|
||||
type="text"
|
||||
maxlength="20"
|
||||
:placeholder="t('provider.zipPlaceholder')"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
|
||||
{{ t('provider.cityLabel') }} <span class="text-slate-400 text-xs">({{ t('provider.cityOptional') }})</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.city"
|
||||
type="text"
|
||||
maxlength="100"
|
||||
:placeholder="t('provider.cityPlaceholder')"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Plus Code -->
|
||||
<div class="mt-3">
|
||||
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
|
||||
{{ t('provider.plusCodeLabel') }} <span class="text-slate-400 text-xs">({{ t('provider.plusCodeOptional') }})</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.plus_code"
|
||||
type="text"
|
||||
maxlength="20"
|
||||
:placeholder="t('provider.plusCodePlaceholder')"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Elérhetőségek (Contact) ── -->
|
||||
<div class="border-t border-slate-100 pt-4">
|
||||
<p class="mb-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('provider.contactSection') }}</p>
|
||||
<!-- Phone -->
|
||||
<div class="mb-3">
|
||||
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
|
||||
{{ t('common.phone') }} <span class="text-slate-400 text-xs">({{ t('provider.phoneOptional') }})</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.phone"
|
||||
type="tel"
|
||||
maxlength="50"
|
||||
:placeholder="t('provider.phonePlaceholder')"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div class="mb-3">
|
||||
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
|
||||
{{ t('common.email') }} <span class="text-slate-400 text-xs">({{ t('provider.emailOptional') }})</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
maxlength="255"
|
||||
:placeholder="t('provider.emailPlaceholder')"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Website -->
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
|
||||
{{ t('provider.websiteLabel') }} <span class="text-slate-400 text-xs">({{ t('provider.websiteOptional') }})</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.website"
|
||||
type="url"
|
||||
maxlength="255"
|
||||
:placeholder="t('provider.websitePlaceholder')"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<!-- TAB 2: Szolgáltatások (Services) -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<template v-if="activeTab === 'services'">
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<!-- BLOCK A: Fix Struktúra (Szint 0 + Szint 1) -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="flex h-6 w-6 items-center justify-center rounded-full bg-sf-accent/10 text-sf-accent">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-bold text-slate-700">{{ t('categories.block_a_title') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="categoryTreeLoading" class="flex items-center gap-2 text-sm text-slate-400 py-2">
|
||||
<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
|
||||
<!-- Level 0: Járműtípus (always visible) -->
|
||||
<div v-if="level0Categories.length > 0" class="mb-3">
|
||||
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">{{ t('provider.level0Label') }}</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="cat in level0Categories"
|
||||
:key="cat.id"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-medium text-slate-600 transition-all hover:border-sf-accent/40 hover:bg-sf-accent/5 cursor-pointer"
|
||||
:class="{ 'border-sf-accent bg-sf-accent/10 text-sf-accent': selectedCategoryIds.includes(cat.id) }"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedCategoryIds.includes(cat.id)"
|
||||
@change="toggleCategory(cat.id)"
|
||||
class="h-3.5 w-3.5 rounded border-slate-300 text-sf-accent focus:ring-sf-accent/30"
|
||||
/>
|
||||
{{ cat.name_hu || cat.key }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Level 1: Iparág/Főcsoport (children of CHECKED Level 0) -->
|
||||
<div v-if="vehicleLevel1Categories.length > 0" class="mb-3 ml-4 pl-3 border-l-2 border-sf-accent/20">
|
||||
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">{{ t('provider.level1Label') }}</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="l1 in vehicleLevel1Categories"
|
||||
:key="l1.id"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-medium text-slate-600 transition-all hover:border-sf-accent/40 hover:bg-sf-accent/5 cursor-pointer"
|
||||
:class="{ 'border-sf-accent bg-sf-accent/10 text-sf-accent': selectedCategoryIds.includes(l1.id) }"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedCategoryIds.includes(l1.id)"
|
||||
@change="toggleCategory(l1.id)"
|
||||
class="h-3.5 w-3.5 rounded border-slate-300 text-sf-accent focus:ring-sf-accent/30"
|
||||
/>
|
||||
{{ l1.name_hu || l1.key }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Universal Level 1: Iparágak és Univerzális Szolgáltatások (independent of Level 0) -->
|
||||
<div v-if="universalLevel1Categories.length > 0" class="mb-3 mt-4 pt-3 border-t-2 border-dashed border-amber-200">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="flex h-6 w-6 items-center justify-center rounded-full bg-amber-50 text-amber-500">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-bold text-slate-700">{{ t('categories.universal_title') }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-400 mb-2">{{ t('categories.universal_hint') }}</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="l1 in universalLevel1Categories"
|
||||
:key="l1.id"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-amber-200 px-3 py-1.5 text-xs font-medium text-slate-600 transition-all hover:border-amber-400 hover:bg-amber-50 cursor-pointer"
|
||||
:class="{ 'border-amber-500 bg-amber-50 text-amber-700 font-semibold': selectedCategoryIds.includes(l1.id) }"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedCategoryIds.includes(l1.id)"
|
||||
@change="toggleCategory(l1.id)"
|
||||
class="h-3.5 w-3.5 rounded border-amber-300 text-amber-500 focus:ring-amber-300"
|
||||
/>
|
||||
{{ l1.name_hu || l1.key }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<!-- BLOCK B: Okos Szövegmező (Szint 2 + Szint 3) -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<div class="border-t border-slate-100 pt-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="flex h-6 w-6 items-center justify-center rounded-full bg-blue-50 text-blue-500">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-bold text-slate-700">{{ t('categories.block_b_title') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Autocomplete input -->
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="autocompleteQuery"
|
||||
type="text"
|
||||
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
|
||||
:placeholder="t('categories.search_placeholder')"
|
||||
@input="onAutocompleteInput"
|
||||
@keydown.enter.prevent="onAutocompleteEnter"
|
||||
@keydown.backspace="onAutocompleteBackspace"
|
||||
@blur="onAutocompleteBlur"
|
||||
/>
|
||||
<!-- Autocomplete dropdown -->
|
||||
<div
|
||||
v-if="autocompleteQuery.length >= 2"
|
||||
class="absolute z-10 mt-1 w-full rounded-xl border border-slate-200 bg-white shadow-lg max-h-48 overflow-y-auto"
|
||||
>
|
||||
<!-- Results list -->
|
||||
<button
|
||||
v-for="item in autocompleteResults"
|
||||
:key="item.id"
|
||||
@mousedown.prevent="selectAutocompleteItem(item)"
|
||||
class="w-full px-4 py-2.5 text-left text-sm text-slate-700 transition-colors hover:bg-sf-accent/5 hover:text-sf-accent flex items-center gap-2"
|
||||
>
|
||||
<span class="text-xs text-slate-400 font-mono">L{{ item.level }}</span>
|
||||
<span>{{ item.name_hu || item.key }}</span>
|
||||
</button>
|
||||
<!-- No results message -->
|
||||
<div
|
||||
v-if="autocompleteResults.length === 0"
|
||||
class="px-4 py-3 text-sm text-slate-400 text-center"
|
||||
>
|
||||
{{ t('categories.no_results_create') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selected tags (chips) -->
|
||||
<div v-if="selectedTags.length > 0" class="mt-3 flex flex-wrap gap-1.5">
|
||||
<span
|
||||
v-for="(tag, idx) in selectedTags"
|
||||
:key="'tag-' + idx"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-600 border border-blue-100"
|
||||
>
|
||||
{{ tag.name_hu || tag.key }}
|
||||
<button
|
||||
@click="removeTag(idx)"
|
||||
class="ml-0.5 text-blue-400 hover:text-red-500 cursor-pointer"
|
||||
>×</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<hr class="border-slate-200" />
|
||||
|
||||
<!-- Info text -->
|
||||
<p class="text-xs text-slate-400 italic">
|
||||
{{ t('provider.editInfo') }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<!-- Soft-Deduplication Warning -->
|
||||
<div
|
||||
v-if="duplicateWarning"
|
||||
class="rounded-lg bg-amber-50 border border-amber-300 px-4 py-3 text-sm text-amber-800"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<svg class="h-5 w-5 mt-0.5 shrink-0 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<span>{{ duplicateWarning }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<!-- Success Toast (gamification) -->
|
||||
<div
|
||||
v-if="successMessage"
|
||||
class="rounded-xl bg-gradient-to-r from-emerald-50 to-emerald-100 border border-emerald-300 px-5 py-4 text-center animate-fade-in-up"
|
||||
>
|
||||
<div class="flex items-center justify-center gap-2 mb-1">
|
||||
<span class="text-2xl">🎉</span>
|
||||
<span class="text-lg font-bold text-emerald-800">{{ successMessage }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-emerald-600 font-medium">{{ t('provider.successSubtext') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Sticky Footer (Submit / Close after success) ── -->
|
||||
<div class="shrink-0 border-t border-slate-200 bg-white px-6 py-4 rounded-b-2xl">
|
||||
<button
|
||||
v-if="!successMessage"
|
||||
type="submit"
|
||||
:disabled="isSubmitting || !isFormValid"
|
||||
class="w-full inline-flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-sf-accent to-sf-blue px-6 py-3 text-sm font-bold text-white shadow-md transition-all hover:shadow-lg disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<svg
|
||||
v-if="isSubmitting"
|
||||
class="h-4 w-4 animate-spin"
|
||||
fill="none" viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<svg v-else class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{{ isSubmitting ? t('provider.submitting') : t('provider.submitButton') }}
|
||||
</button>
|
||||
|
||||
<!-- Close button after success -->
|
||||
<button
|
||||
v-if="successMessage"
|
||||
@click="handleCloseAfterSuccess"
|
||||
class="w-full rounded-xl bg-sf-accent px-6 py-3 text-sm font-bold text-white shadow-md transition-all hover:bg-sf-accent/90 cursor-pointer"
|
||||
>
|
||||
{{ t('provider.closeButton') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, watch, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '@/api/axios'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// ── Types ──
|
||||
interface ExpertiseCategory {
|
||||
id: number
|
||||
key: string
|
||||
name_hu: string | null
|
||||
name_en: string | null
|
||||
category: string
|
||||
}
|
||||
|
||||
interface QuickAddResponse {
|
||||
id: number
|
||||
name: string
|
||||
status: string
|
||||
message: string
|
||||
earned_points: number
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
id: number
|
||||
name: string
|
||||
city: string | null
|
||||
address: string | null
|
||||
source: string
|
||||
is_verified: boolean
|
||||
}
|
||||
|
||||
interface CategoryTreeNode {
|
||||
id: number
|
||||
key: string
|
||||
name_hu: string | null
|
||||
name_en: string | null
|
||||
level: number
|
||||
path: string | null
|
||||
is_official: boolean
|
||||
children: CategoryTreeNode[]
|
||||
}
|
||||
|
||||
interface AutocompleteItem {
|
||||
id: number
|
||||
key: string
|
||||
name_hu: string | null
|
||||
name_en: string | null
|
||||
level: number
|
||||
path: string | null
|
||||
parent_id: number | null
|
||||
}
|
||||
|
||||
interface SelectedTag {
|
||||
id: number | null
|
||||
key: string
|
||||
name_hu: string | null
|
||||
level: number
|
||||
}
|
||||
|
||||
// ── Props ──
|
||||
const props = defineProps<{
|
||||
isOpen: boolean
|
||||
}>()
|
||||
|
||||
// ── Emits ──
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'saved', provider: { id: number; name: string; category?: string | null; city?: string | null; address?: string | null }): void
|
||||
}>()
|
||||
|
||||
// ── Tab State ──
|
||||
const activeTab = ref<'basic' | 'services'>('basic')
|
||||
|
||||
// ── State ──
|
||||
const categories = ref<ExpertiseCategory[]>([])
|
||||
const isSubmitting = ref(false)
|
||||
const errorMessage = ref<string | null>(null)
|
||||
const successMessage = ref<string | null>(null)
|
||||
const duplicateWarning = ref<string | null>(null)
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
category_id: '' as string | number,
|
||||
city: '',
|
||||
address_zip: '',
|
||||
address_street_name: '',
|
||||
address_street_type: '',
|
||||
address_house_number: '',
|
||||
plus_code: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
website: '',
|
||||
tagsInput: '',
|
||||
tags: [] as string[],
|
||||
})
|
||||
|
||||
// ── Category State (4-Level) ──
|
||||
const categoryTreeLoading = ref(false)
|
||||
const categoryTree = ref<CategoryTreeNode[]>([])
|
||||
const selectedCategoryIds = ref<number[]>([])
|
||||
const selectedTags = ref<SelectedTag[]>([])
|
||||
const autocompleteQuery = ref('')
|
||||
const autocompleteResults = ref<AutocompleteItem[]>([])
|
||||
let autocompleteTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// ── Computed: Level 0 categories ──
|
||||
const level0Categories = computed(() => {
|
||||
return categoryTree.value.filter(c => c.level === 0)
|
||||
})
|
||||
|
||||
// ── Computed: Vehicle-specific Level 1 categories ──
|
||||
// Shows Level 1 children of CHECKED Level 0 parents only.
|
||||
const vehicleLevel1Categories = computed(() => {
|
||||
const checkedL0Ids = new Set(selectedCategoryIds.value)
|
||||
const result: CategoryTreeNode[] = []
|
||||
for (const l0 of level0Categories.value) {
|
||||
if (l0.children.length > 0 && checkedL0Ids.has(l0.id)) {
|
||||
for (const l1 of l0.children) {
|
||||
// Avoid duplicates if same L1 appears under multiple L0
|
||||
if (!result.some(r => r.id === l1.id)) {
|
||||
result.push(l1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
// ── Computed: Universal Level 1 categories ──
|
||||
// Level 1 categories that are root nodes (parent_id IS NULL) in the tree.
|
||||
// These are independent of any vehicle type and always visible.
|
||||
// Examples: "Üzemanyag és Töltőállomás", "Étel és Ital", "Szállás"
|
||||
const universalLevel1Categories = computed(() => {
|
||||
return categoryTree.value.filter(c => c.level === 1)
|
||||
})
|
||||
|
||||
// ── Computed ──
|
||||
const isFormValid = computed(() => {
|
||||
return form.name.trim().length >= 2
|
||||
})
|
||||
|
||||
// ── Soft-Deduplication: Debounced search on name/city/zip changes ──
|
||||
async function checkDuplicates() {
|
||||
const name = form.name.trim()
|
||||
if (name.length < 3) {
|
||||
duplicateWarning.value = null
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const params: Record<string, string | number> = { q: name, limit: 3 }
|
||||
if (form.city.trim()) params.city = form.city.trim()
|
||||
else if (form.address_zip.trim()) params.city = form.address_zip.trim()
|
||||
|
||||
const res = await api.get('providers/search', { params })
|
||||
const data = res.data as { results: SearchResult[]; total: number }
|
||||
|
||||
if (data.results && data.results.length > 0) {
|
||||
const names = data.results.map((r: SearchResult) => r.name)
|
||||
if (names.length === 1) {
|
||||
duplicateWarning.value = t('provider.duplicateWarningShort', { name: names[0] })
|
||||
} else {
|
||||
duplicateWarning.value = t('provider.duplicateWarning', {
|
||||
matches: names.slice(0, 3).join(', '),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
duplicateWarning.value = null
|
||||
}
|
||||
} catch {
|
||||
duplicateWarning.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function onFormFieldChange() {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
checkDuplicates()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// ── Fetch Categories (legacy dropdown fallback) ──
|
||||
async function fetchCategories() {
|
||||
try {
|
||||
const res = await api.get('providers/categories')
|
||||
categories.value = res.data || []
|
||||
} catch {
|
||||
categories.value = [
|
||||
{ id: 1, key: 'auto_szerelo', name_hu: 'Autószerelő', name_en: 'Car Mechanic', category: 'vehicle_service' },
|
||||
{ id: 2, key: 'motor_szerelo', name_hu: 'Motorkerékpár szerelő', name_en: 'Motorcycle Mechanic', category: 'vehicle_service' },
|
||||
{ id: 3, key: 'gumiszerviz', name_hu: 'Gumiszerviz', name_en: 'Tire Service', category: 'vehicle_service' },
|
||||
{ id: 4, key: 'karosszerialakatos', name_hu: 'Karosszérialakatos', name_en: 'Body Shop', category: 'body_paint' },
|
||||
{ id: 5, key: 'fényező', name_hu: 'Fényező', name_en: 'Painter', category: 'body_paint' },
|
||||
{ id: 6, key: 'autómentő', name_hu: 'Autómentő / Motormentő', name_en: 'Tow Truck / Roadside', category: 'roadside' },
|
||||
{ id: 7, key: 'benzinkút', name_hu: 'Benzinkút', name_en: 'Gas Station', category: 'fuel' },
|
||||
{ id: 8, key: 'alkatrész_kereskedés', name_hu: 'Alkatrész kereskedés', name_en: 'Parts Store', category: 'parts' },
|
||||
{ id: 9, key: 'vizsgaállomás', name_hu: 'Vizsgaállomás', name_en: 'Inspection Station', category: 'inspection' },
|
||||
{ id: 10, key: 'autókozmetika', name_hu: 'Autókozmetika', name_en: 'Car Detailing', category: 'detailing' },
|
||||
{ id: 11, key: 'egyéb', name_hu: 'Egyéb szolgáltató', name_en: 'Other Service', category: 'other' },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// ── Load Category Tree from API ──
|
||||
async function loadCategoryTree() {
|
||||
categoryTreeLoading.value = true
|
||||
try {
|
||||
const response = await api.get('providers/categories/tree')
|
||||
categoryTree.value = response.data || []
|
||||
} catch (err) {
|
||||
console.error('[ProviderQuickAddModal] Failed to load category tree:', err)
|
||||
} finally {
|
||||
categoryTreeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Toggle category selection (Block A) ──
|
||||
function toggleCategory(categoryId: number) {
|
||||
const idx = selectedCategoryIds.value.indexOf(categoryId)
|
||||
if (idx >= 0) {
|
||||
selectedCategoryIds.value.splice(idx, 1)
|
||||
} else {
|
||||
selectedCategoryIds.value.push(categoryId)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Autocomplete input handler (Block B) ──
|
||||
function onAutocompleteInput() {
|
||||
if (autocompleteTimer) {
|
||||
clearTimeout(autocompleteTimer)
|
||||
}
|
||||
|
||||
const query = autocompleteQuery.value.trim()
|
||||
if (query.length < 2) {
|
||||
autocompleteResults.value = []
|
||||
return
|
||||
}
|
||||
|
||||
autocompleteTimer = setTimeout(async () => {
|
||||
try {
|
||||
const response = await api.get('providers/categories/autocomplete', {
|
||||
params: { q: query },
|
||||
})
|
||||
const selectedKeys = new Set(selectedTags.value.map(t => t.key))
|
||||
autocompleteResults.value = (response.data || []).filter(
|
||||
(item: AutocompleteItem) => !selectedKeys.has(item.key)
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('[ProviderQuickAddModal] Autocomplete error:', err)
|
||||
autocompleteResults.value = []
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// ── Select autocomplete item ──
|
||||
function selectAutocompleteItem(item: AutocompleteItem) {
|
||||
selectedTags.value.push({
|
||||
id: item.id,
|
||||
key: item.key,
|
||||
name_hu: item.name_hu,
|
||||
level: item.level,
|
||||
})
|
||||
autocompleteQuery.value = ''
|
||||
autocompleteResults.value = []
|
||||
}
|
||||
|
||||
// ── Enter key: create new tag if not found ──
|
||||
function onAutocompleteEnter() {
|
||||
const query = autocompleteQuery.value.trim()
|
||||
if (!query) return
|
||||
|
||||
// Check if already selected
|
||||
if (selectedTags.value.some(t => t.key === query.toLowerCase().replace(/\s+/g, '_'))) {
|
||||
autocompleteQuery.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
// Check if it matches any autocomplete result
|
||||
const match = autocompleteResults.value.find(
|
||||
item => (item.name_hu || '').toLowerCase() === query.toLowerCase()
|
||||
)
|
||||
if (match) {
|
||||
selectAutocompleteItem(match)
|
||||
return
|
||||
}
|
||||
|
||||
// Create new tag (user-created, not yet in DB)
|
||||
selectedTags.value.push({
|
||||
id: null, // null = not yet in DB
|
||||
key: query.toLowerCase().replace(/\s+/g, '_'),
|
||||
name_hu: query,
|
||||
level: 3,
|
||||
})
|
||||
autocompleteQuery.value = ''
|
||||
autocompleteResults.value = []
|
||||
}
|
||||
|
||||
// ── Backspace on empty input: remove last tag ──
|
||||
function onAutocompleteBackspace() {
|
||||
if (autocompleteQuery.value === '' && selectedTags.value.length > 0) {
|
||||
selectedTags.value.pop()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Blur: close autocomplete dropdown ──
|
||||
function onAutocompleteBlur() {
|
||||
// Delay to allow click on dropdown items
|
||||
setTimeout(() => {
|
||||
autocompleteResults.value = []
|
||||
}, 200)
|
||||
}
|
||||
|
||||
// ── Remove tag ──
|
||||
function removeTag(index: number) {
|
||||
selectedTags.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// ── Submit handler (async — calls backend POST /providers/quick-add) ──
|
||||
async function handleSubmit() {
|
||||
if (!form.name.trim()) return
|
||||
|
||||
isSubmitting.value = true
|
||||
errorMessage.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
// Separate existing tag IDs from new tag names
|
||||
const existingCategoryIds = selectedTags.value
|
||||
.filter(t => t.id !== null)
|
||||
.map(t => t.id as number)
|
||||
|
||||
const newTagNames = selectedTags.value
|
||||
.filter(t => t.id === null)
|
||||
.map(t => t.name_hu || t.key)
|
||||
|
||||
// Combine Block A + Block B category IDs
|
||||
const allCategoryIds = [...selectedCategoryIds.value, ...existingCategoryIds]
|
||||
|
||||
// P1 CRITICAL ALIGN: Atomizált címmezők a payload-ban
|
||||
const body: Record<string, any> = {
|
||||
name: form.name.trim(),
|
||||
category_id: form.category_id || null,
|
||||
city: form.city.trim() || null,
|
||||
address_zip: form.address_zip.trim() || null,
|
||||
address_street_name: form.address_street_name.trim() || null,
|
||||
address_street_type: form.address_street_type.trim() || null,
|
||||
address_house_number: form.address_house_number.trim() || null,
|
||||
plus_code: form.plus_code.trim() || null,
|
||||
contact_phone: form.phone.trim() || null,
|
||||
contact_email: form.email.trim() || null,
|
||||
website: form.website.trim() || null,
|
||||
tags: form.tags.length > 0 ? form.tags : null,
|
||||
category_ids: allCategoryIds.length > 0 ? allCategoryIds : null,
|
||||
new_tags: newTagNames.length > 0 ? newTagNames : null,
|
||||
}
|
||||
|
||||
const response = await api.post('providers/quick-add', body)
|
||||
const data = response.data as QuickAddResponse
|
||||
|
||||
// Show gamification toast
|
||||
if (data.earned_points && data.earned_points > 0) {
|
||||
successMessage.value = t('provider.successGamification', {
|
||||
name: data.name,
|
||||
points: data.earned_points,
|
||||
})
|
||||
} else {
|
||||
successMessage.value = t('provider.successSimple', { name: data.name })
|
||||
}
|
||||
|
||||
// Emit saved event so parent can refresh
|
||||
emit('saved', {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
category: form.category_id ? String(form.category_id) : null,
|
||||
city: form.city.trim() || null,
|
||||
address: form.address_street_name.trim()
|
||||
? `${form.address_street_name.trim()} ${form.address_street_type.trim()} ${form.address_house_number.trim()}`.trim()
|
||||
: null,
|
||||
})
|
||||
} catch (err: any) {
|
||||
console.error('[ProviderQuickAddModal] Submit error:', err)
|
||||
if (err.response?.data?.detail) {
|
||||
errorMessage.value = err.response.data.detail
|
||||
} else if (err.message) {
|
||||
errorMessage.value = err.message
|
||||
} else {
|
||||
errorMessage.value = t('provider.errorGeneric')
|
||||
}
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Watch tagsInput for comma/semicolon splitting ──
|
||||
watch(
|
||||
() => form.tagsInput,
|
||||
(val: string) => {
|
||||
if (/[,;]/.test(val)) {
|
||||
const parts = val.split(/[,;]+/).map(s => s.trim()).filter(Boolean)
|
||||
if (parts.length > 0) {
|
||||
form.tags.push(...parts)
|
||||
form.tagsInput = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function removeLegacyTag(index: number) {
|
||||
form.tags.splice(index, 1)
|
||||
}
|
||||
|
||||
// ── Close handler ──
|
||||
function handleClose() {
|
||||
resetForm()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function handleCloseAfterSuccess() {
|
||||
resetForm()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// ── Reset form ──
|
||||
function resetForm() {
|
||||
form.name = ''
|
||||
form.category_id = ''
|
||||
form.city = ''
|
||||
form.address_zip = ''
|
||||
form.address_street_name = ''
|
||||
form.address_street_type = ''
|
||||
form.address_house_number = ''
|
||||
form.phone = ''
|
||||
form.email = ''
|
||||
form.website = ''
|
||||
form.tagsInput = ''
|
||||
form.tags = []
|
||||
selectedCategoryIds.value = []
|
||||
selectedTags.value = []
|
||||
autocompleteQuery.value = ''
|
||||
autocompleteResults.value = []
|
||||
activeTab.value = 'basic'
|
||||
errorMessage.value = null
|
||||
successMessage.value = null
|
||||
duplicateWarning.value = null
|
||||
isSubmitting.value = false
|
||||
}
|
||||
|
||||
// ── Watch isOpen: reset + fetch on open ──
|
||||
watch(
|
||||
() => props.isOpen,
|
||||
(open) => {
|
||||
if (open) {
|
||||
resetForm()
|
||||
fetchCategories()
|
||||
loadCategoryTree()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// ── Soft-deduplication watchers ──
|
||||
watch(() => form.name, onFormFieldChange)
|
||||
watch(() => form.city, onFormFieldChange)
|
||||
watch(() => form.address_zip, onFormFieldChange)
|
||||
|
||||
// ── Load categories on mount ──
|
||||
onMounted(() => {
|
||||
fetchCategories()
|
||||
loadCategoryTree()
|
||||
})
|
||||
|
||||
// 🔍 P0 Debug: Universal Level 1 categories count verification
|
||||
watchEffect(() => {
|
||||
console.log(`[ProviderQuickAddModal] universalLevel1Categories count: ${universalLevel1Categories.value.length}`)
|
||||
if (universalLevel1Categories.value.length > 0) {
|
||||
console.log(`[ProviderQuickAddModal] Universal categories:`, universalLevel1Categories.value.map(c => c.name_hu || c.name_en))
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
.animate-fade-in-up {
|
||||
animation: fadeInUp 0.25s ease-out forwards;
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.animate-spin {
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
</style>
|
||||
286
frontend_app/src/components/subscription/PlanDetailsModal.vue
Normal file
@@ -0,0 +1,286 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal-fade">
|
||||
<div
|
||||
v-if="visible"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
@click.self="$emit('close')"
|
||||
>
|
||||
<!-- Backdrop -->
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
|
||||
<!-- Modal Panel -->
|
||||
<div
|
||||
class="relative w-full max-w-lg bg-white rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]"
|
||||
>
|
||||
<!-- ═══ HEADER ═══ -->
|
||||
<div
|
||||
class="shrink-0 flex items-center justify-between px-6 py-4 border-b border-slate-200"
|
||||
:style="{ backgroundColor: highlightBg }"
|
||||
>
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-white">
|
||||
{{ plan?.rules?.display_name || plan?.name }}
|
||||
</h2>
|
||||
<p v-if="plan?.rules?.marketing?.subtitle" class="text-sm text-white/70 mt-0.5">
|
||||
{{ plan.rules.marketing.subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="w-8 h-8 flex items-center justify-center rounded-full bg-white/20 hover:bg-white/30 transition-colors text-white"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══ BODY (scrollable) ═══ -->
|
||||
<div class="flex-1 overflow-y-auto px-6 py-5 space-y-5">
|
||||
<!-- Price Section (from resolved_pricing) -->
|
||||
<div class="text-center">
|
||||
<div class="text-4xl font-bold text-slate-900">
|
||||
{{ isYearly ? formatPrice(resolvedYearlyPrice) : formatPrice(resolvedMonthlyPrice) }}
|
||||
</div>
|
||||
<div class="text-sm text-slate-500 mt-1">
|
||||
/ {{ isYearly ? t('subscription.year') : t('subscription.month') }}
|
||||
</div>
|
||||
<div v-if="isYearly && savingsPercent > 0" class="mt-2 inline-block px-3 py-1 rounded-full bg-emerald-100 text-emerald-700 text-xs font-semibold">
|
||||
{{ t('subscription.savePercent', { percent: savingsPercent }) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Billing Toggle -->
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<span
|
||||
class="text-sm font-medium transition-colors"
|
||||
:class="isYearly ? 'text-slate-400' : 'text-slate-900'"
|
||||
>
|
||||
{{ t('subscription.monthly') }}
|
||||
</span>
|
||||
<button
|
||||
class="relative w-12 h-6 rounded-full transition-colors"
|
||||
:class="isYearly ? 'bg-emerald-500' : 'bg-slate-300'"
|
||||
@click="isYearly = !isYearly"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform"
|
||||
:class="isYearly ? 'translate-x-6' : 'translate-x-0'"
|
||||
/>
|
||||
</button>
|
||||
<span
|
||||
class="text-sm font-medium transition-colors"
|
||||
:class="isYearly ? 'text-slate-900' : 'text-slate-400'"
|
||||
>
|
||||
{{ t('subscription.yearly') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Key Stats -->
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="bg-slate-50 rounded-xl p-3 text-center">
|
||||
<div class="text-lg font-bold text-slate-900">{{ plan?.rules?.allowances?.max_vehicles ?? '—' }}</div>
|
||||
<div class="text-xs text-slate-500 mt-0.5">{{ t('subscription.maxVehicles') }}</div>
|
||||
</div>
|
||||
<div class="bg-slate-50 rounded-xl p-3 text-center">
|
||||
<div class="text-lg font-bold text-slate-900">{{ plan?.rules?.allowances?.max_garages ?? '—' }}</div>
|
||||
<div class="text-xs text-slate-500 mt-0.5">{{ t('subscription.maxGarages') }}</div>
|
||||
</div>
|
||||
<div class="bg-slate-50 rounded-xl p-3 text-center">
|
||||
<div class="text-lg font-bold text-slate-900">{{ plan?.rules?.allowances?.monthly_free_credits ?? '—' }}</div>
|
||||
<div class="text-xs text-slate-500 mt-0.5">{{ t('subscription.freeCredits') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Included Services (Entitlements) -->
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-slate-700 mb-3 uppercase tracking-wider">
|
||||
{{ t('subscription.includedServices') }}
|
||||
</h3>
|
||||
<ul class="space-y-2">
|
||||
<!-- Ad-free -->
|
||||
<li class="flex items-start gap-3 text-sm text-slate-700">
|
||||
<span class="shrink-0 w-5 h-5 rounded-full bg-emerald-100 flex items-center justify-center">
|
||||
<svg v-if="plan?.rules?.ad_policy?.show_ads === false" class="w-3 h-3 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<svg v-else class="w-3 h-3 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</span>
|
||||
<span :class="plan?.rules?.ad_policy?.show_ads === false ? 'text-slate-900 font-medium' : 'text-slate-400'">
|
||||
{{ t('subscription.features.adFree') }}
|
||||
</span>
|
||||
</li>
|
||||
<!-- Dynamic entitlements -->
|
||||
<li
|
||||
v-for="(entitlement, idx) in plan?.rules?.entitlements || []"
|
||||
:key="idx"
|
||||
class="flex items-start gap-3 text-sm text-slate-700"
|
||||
>
|
||||
<span class="shrink-0 w-5 h-5 rounded-full bg-emerald-100 flex items-center justify-center">
|
||||
<svg class="w-3 h-3 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="text-slate-900 font-medium">{{ entitlementLabel(entitlement) }}</span>
|
||||
</li>
|
||||
<!-- Affiliate info -->
|
||||
<li
|
||||
v-if="plan?.rules?.affiliate?.commission_rate_percent"
|
||||
class="flex items-start gap-3 text-sm text-slate-700"
|
||||
>
|
||||
<span class="shrink-0 w-5 h-5 rounded-full bg-emerald-100 flex items-center justify-center">
|
||||
<svg class="w-3 h-3 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="text-slate-900 font-medium">
|
||||
{{ plan.rules.affiliate.commission_rate_percent }}% {{ t('subscription.features.affiliateCommission') || 'Partner jutalék' }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ FOOTER ═══ -->
|
||||
<div class="shrink-0 px-6 py-4 border-t border-slate-200 bg-slate-50">
|
||||
<button
|
||||
class="w-full py-3 rounded-xl font-bold text-white transition-colors text-sm"
|
||||
:class="isCurrentPlan ? 'bg-slate-400 cursor-not-allowed' : 'bg-blue-800 hover:bg-blue-900'"
|
||||
:disabled="isCurrentPlan"
|
||||
@click="handleOrder"
|
||||
>
|
||||
{{ isCurrentPlan ? t('subscription.currentPlan') : t('subscription.orderSimulation') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface PricingZone {
|
||||
monthly_price: number
|
||||
yearly_price: number
|
||||
currency: string
|
||||
credit_price?: number | null
|
||||
}
|
||||
|
||||
interface SubscriptionPlan {
|
||||
id: number
|
||||
name: string
|
||||
rules: Record<string, any> | null
|
||||
is_custom: boolean
|
||||
resolved_pricing?: PricingZone | null
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
visible: boolean
|
||||
plan: SubscriptionPlan | null
|
||||
currentPlanName?: string | null
|
||||
}>(),
|
||||
{
|
||||
visible: false,
|
||||
plan: null,
|
||||
currentPlanName: null,
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
order: [plan: SubscriptionPlan, isYearly: boolean]
|
||||
}>()
|
||||
|
||||
const isYearly = ref(false)
|
||||
|
||||
/**
|
||||
* Get resolved monthly price from plan's resolved_pricing or fallback to rules.pricing.
|
||||
*/
|
||||
const resolvedMonthlyPrice = computed(() => {
|
||||
return props.plan?.resolved_pricing?.monthly_price ?? props.plan?.rules?.pricing?.monthly_price ?? 0
|
||||
})
|
||||
|
||||
/**
|
||||
* Get resolved yearly price from plan's resolved_pricing or fallback to rules.pricing.
|
||||
*/
|
||||
const resolvedYearlyPrice = computed(() => {
|
||||
return props.plan?.resolved_pricing?.yearly_price ?? props.plan?.rules?.pricing?.yearly_price ?? 0
|
||||
})
|
||||
|
||||
/**
|
||||
* Get resolved currency from plan's resolved_pricing or fallback to rules.pricing.
|
||||
*/
|
||||
const resolvedCurrency = computed(() => {
|
||||
return props.plan?.resolved_pricing?.currency ?? props.plan?.rules?.pricing?.currency ?? 'EUR'
|
||||
})
|
||||
|
||||
const savingsPercent = computed(() => {
|
||||
const monthly = resolvedMonthlyPrice.value
|
||||
const yearly = resolvedYearlyPrice.value
|
||||
if (monthly <= 0 || yearly <= 0) return 0
|
||||
const monthlyYearly = monthly * 12
|
||||
const savings = monthlyYearly - yearly
|
||||
return Math.round((savings / monthlyYearly) * 100)
|
||||
})
|
||||
|
||||
const isCurrentPlan = computed(() => {
|
||||
return props.plan?.name === props.currentPlanName
|
||||
})
|
||||
|
||||
const highlightBg = computed(() => {
|
||||
const color = props.plan?.rules?.marketing?.highlight_color
|
||||
return color || '#306081'
|
||||
})
|
||||
|
||||
function formatPrice(price: number): string {
|
||||
if (price === 0) return t('subscription.free')
|
||||
const cur = resolvedCurrency.value
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency: cur,
|
||||
minimumFractionDigits: cur === 'HUF' ? 0 : 2,
|
||||
maximumFractionDigits: cur === 'HUF' ? 0 : 2,
|
||||
}).format(price)
|
||||
} catch {
|
||||
return `${price} ${cur}`
|
||||
}
|
||||
}
|
||||
|
||||
function entitlementLabel(code: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
SRV_DATA_EXPORT: t('subscription.features.analytics'),
|
||||
SRV_API_ACCESS: t('subscription.features.apiAccess'),
|
||||
SRV_PRIORITY_SUPPORT: t('subscription.features.prioritySupport'),
|
||||
SRV_AI_UPLOAD: 'AI dokumentumfeltöltés',
|
||||
SRV_ACCOUNTING_SYNC: 'Számviteli szinkron',
|
||||
}
|
||||
return labels[code] || code
|
||||
}
|
||||
|
||||
function handleOrder() {
|
||||
if (props.plan) {
|
||||
emit('order', props.plan, isYearly.value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-fade-enter-active,
|
||||
.modal-fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.modal-fade-enter-from,
|
||||
.modal-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,278 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal-fade">
|
||||
<div
|
||||
v-if="visible"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
@click.self="$emit('close')"
|
||||
>
|
||||
<!-- Backdrop -->
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
|
||||
<!-- Modal Panel — Dark Glassmorphism -->
|
||||
<div
|
||||
class="relative w-full max-w-md rounded-2xl border border-slate-700 bg-slate-900/70 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
|
||||
>
|
||||
<!-- ═══ HEADER ═══ -->
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-white/10">
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Crown icon -->
|
||||
<svg class="w-6 h-6 text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
|
||||
</svg>
|
||||
<h2 class="text-lg font-bold text-white">
|
||||
{{ t('subscription.subscriptionInfo') }}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
class="w-8 h-8 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 transition-colors text-white/70 hover:text-white"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══ BODY ═══ -->
|
||||
<div class="px-5 py-5 space-y-5">
|
||||
<!-- Plan Name & Status Badge -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs text-white/50 uppercase tracking-wider">{{ t('subscription.currentPlan') }}</p>
|
||||
<p class="text-xl font-bold text-white mt-0.5">
|
||||
{{ planDisplayName }}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
:class="isExpired
|
||||
? 'bg-red-500/20 text-red-400 border-red-500/30'
|
||||
: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30'"
|
||||
class="px-3 py-1 rounded-full text-xs font-semibold border"
|
||||
>
|
||||
{{ isExpired ? t('subscription.expired') : t('subscription.active') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Expiry Date -->
|
||||
<div class="rounded-xl bg-white/5 border border-white/10 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<span class="text-sm text-white/70">{{ t('subscription.expiresAt') }}</span>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-white">
|
||||
{{ formattedExpiry }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Days remaining bar -->
|
||||
<div v-if="!isExpired && daysRemaining !== null" class="mt-3">
|
||||
<div class="flex items-center justify-between text-xs mb-1">
|
||||
<span class="text-white/50">{{ t('subscription.daysLeft', { count: daysRemaining }) }}</span>
|
||||
<span :class="daysRemaining <= 7 ? 'text-red-400' : 'text-emerald-400'">
|
||||
{{ daysRemaining }} {{ t('subscription.daysLeftShort') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full h-1.5 rounded-full bg-white/10 overflow-hidden">
|
||||
<div
|
||||
:class="daysRemaining <= 7 ? 'bg-red-500' : 'bg-emerald-500'"
|
||||
class="h-full rounded-full transition-all duration-500"
|
||||
:style="{ width: daysProgressPercent + '%' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vehicle Limit Progress -->
|
||||
<div class="rounded-xl bg-white/5 border border-white/10 p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm text-white/70 flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 17h8m0 0V9m0 8l-8-8-4 4-6-6"/>
|
||||
</svg>
|
||||
{{ t('subscription.vehicleLimit') }}
|
||||
</span>
|
||||
<span class="text-sm font-semibold text-white">
|
||||
{{ vehicleCount }} / {{ maxVehicles }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full h-2 rounded-full bg-white/10 overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-500"
|
||||
:class="vehiclePercent >= 90 ? 'bg-red-500' : vehiclePercent >= 70 ? 'bg-amber-500' : 'bg-blue-500'"
|
||||
:style="{ width: vehiclePercent + '%' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Garage Limit Progress -->
|
||||
<div class="rounded-xl bg-white/5 border border-white/10 p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm text-white/70 flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
|
||||
</svg>
|
||||
{{ t('subscription.garageLimit') }}
|
||||
</span>
|
||||
<span class="text-sm font-semibold text-white">
|
||||
{{ garageCount }} / {{ maxGarages }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full h-2 rounded-full bg-white/10 overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-500"
|
||||
:class="garagePercent >= 90 ? 'bg-red-500' : garagePercent >= 70 ? 'bg-amber-500' : 'bg-blue-500'"
|
||||
:style="{ width: garagePercent + '%' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ FOOTER ═══ -->
|
||||
<div class="px-5 py-4 border-t border-white/10 flex gap-3">
|
||||
<button
|
||||
@click="$emit('close')"
|
||||
class="flex-1 px-4 py-2.5 rounded-xl border border-white/10 text-sm text-white/70 hover:text-white hover:bg-white/5 transition-all duration-150"
|
||||
>
|
||||
{{ t('common.close') }}
|
||||
</button>
|
||||
<button
|
||||
@click="goToPlans"
|
||||
class="flex-1 px-4 py-2.5 rounded-xl bg-gradient-to-r from-blue-600 to-indigo-600 text-sm font-semibold text-white hover:from-blue-500 hover:to-indigo-500 transition-all duration-150 shadow-lg shadow-blue-600/20"
|
||||
>
|
||||
{{ t('subscription.managePlan') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useVehicleStore } from '@/stores/vehicle'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
const vehicleStore = useVehicleStore()
|
||||
|
||||
// ── Computed: Plan display name ──
|
||||
const planDisplayName = computed(() => {
|
||||
const plan = authStore.user?.subscription_plan
|
||||
if (!plan || plan === 'FREE') return t('subscription.free')
|
||||
return plan
|
||||
})
|
||||
|
||||
// ── Computed: Expiry date ──
|
||||
const subscriptionExpiresAt = computed(() => {
|
||||
return authStore.user?.subscription_expires_at ?? null
|
||||
})
|
||||
|
||||
const formattedExpiry = computed(() => {
|
||||
const raw = subscriptionExpiresAt.value
|
||||
// P0: Show "Active" / "Indefinite" instead of em-dash when no expiry
|
||||
if (!raw) return t('subscription.indefinite')
|
||||
const d = new Date(raw)
|
||||
if (isNaN(d.getTime())) return t('subscription.indefinite')
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${y}.${m}.${day}`
|
||||
})
|
||||
|
||||
const isExpired = computed(() => {
|
||||
const raw = subscriptionExpiresAt.value
|
||||
if (!raw) return false
|
||||
return new Date(raw) < new Date()
|
||||
})
|
||||
|
||||
const daysRemaining = computed(() => {
|
||||
const raw = subscriptionExpiresAt.value
|
||||
if (!raw) return null
|
||||
const now = new Date()
|
||||
const expiry = new Date(raw)
|
||||
const diff = expiry.getTime() - now.getTime()
|
||||
if (diff <= 0) return 0
|
||||
return Math.ceil(diff / (1000 * 60 * 60 * 24))
|
||||
})
|
||||
|
||||
const daysProgressPercent = computed(() => {
|
||||
// Assume a 365-day cycle for the progress bar
|
||||
const days = daysRemaining.value
|
||||
if (days === null) return 0
|
||||
return Math.min(100, Math.max(0, (days / 365) * 100))
|
||||
})
|
||||
|
||||
// ── Computed: Vehicle limit progress ──
|
||||
// P0: Real vehicle count from the vehicle store
|
||||
const vehicleCount = computed(() => {
|
||||
return vehicleStore.vehicles.length
|
||||
})
|
||||
|
||||
// P0: Real max vehicles from the backend subscription tier allowances
|
||||
const maxVehicles = computed(() => {
|
||||
return authStore.user?.max_vehicles ?? 1
|
||||
})
|
||||
|
||||
const vehiclePercent = computed(() => {
|
||||
if (maxVehicles.value === 0) return 0
|
||||
return Math.min(100, Math.round((vehicleCount.value / maxVehicles.value) * 100))
|
||||
})
|
||||
|
||||
// ── Computed: Garage limit progress ──
|
||||
const garageCount = computed(() => {
|
||||
// Use the actual organizations count from the auth store
|
||||
return authStore.myOrganizations?.length ?? 0
|
||||
})
|
||||
|
||||
// P0: Real max garages from the backend subscription tier allowances
|
||||
const maxGarages = computed(() => {
|
||||
return authStore.user?.max_garages ?? 1
|
||||
})
|
||||
|
||||
const garagePercent = computed(() => {
|
||||
if (maxGarages.value === 0) return 0
|
||||
return Math.min(100, Math.round((garageCount.value / maxGarages.value) * 100))
|
||||
})
|
||||
|
||||
// ── Navigation ──
|
||||
// P0: Navigate to the correct subscription plans route
|
||||
function goToPlans() {
|
||||
emit('close')
|
||||
if (authStore.isCorporateMode && authStore.user?.active_organization_id) {
|
||||
router.push({
|
||||
name: 'org-subscription',
|
||||
params: { id: authStore.user.active_organization_id }
|
||||
})
|
||||
} else {
|
||||
router.push({ name: 'subscription' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-fade-enter-active,
|
||||
.modal-fade-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
.modal-fade-enter-from,
|
||||
.modal-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
</style>
|
||||
174
frontend_app/src/components/ui/AddressDisplay.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<div class="address-display flex items-start gap-2">
|
||||
<!-- MapPin icon (Lucide) -->
|
||||
<svg
|
||||
class="mt-0.5 h-4 w-4 flex-shrink-0 text-[#418890]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
|
||||
<!-- Address text -->
|
||||
<span class="text-sm break-words leading-relaxed" :class="textClass">
|
||||
{{ formattedAddress }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
/**
|
||||
* AddressDisplay — intelligently formats and displays a Hungarian-style address.
|
||||
*
|
||||
* Accepts address fields either as individual props or as a single `address` object.
|
||||
* Filters out null/undefined/empty values, prevents double spaces,
|
||||
* and handles missing house numbers gracefully.
|
||||
*
|
||||
* Hungarian format: [ZIP] [City], [Street name] [Street type] [House number]
|
||||
* Example: 2120 Dunakeszi, Határ út 8.
|
||||
*/
|
||||
interface AddressObject {
|
||||
zip?: string | null
|
||||
city?: string | null
|
||||
street_name?: string | null
|
||||
street_type?: string | null
|
||||
house_number?: string | null
|
||||
stairwell?: string | null
|
||||
floor?: string | null
|
||||
door?: string | null
|
||||
parcel_id?: string | null
|
||||
hrsz?: string | null
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** Address fields as a single object (alternative to individual props) */
|
||||
address?: AddressObject | null
|
||||
/** Individual address fields (take precedence over address object) */
|
||||
zip?: string | null
|
||||
city?: string | null
|
||||
street_name?: string | null
|
||||
street_type?: string | null
|
||||
house_number?: string | null
|
||||
stairwell?: string | null
|
||||
floor?: string | null
|
||||
door?: string | null
|
||||
parcel_id?: string | null
|
||||
hrsz?: string | null
|
||||
/** Optional CSS class for the text span */
|
||||
textClass?: string
|
||||
/** Show detailed address (including stairwell/floor/door) */
|
||||
detailed?: boolean
|
||||
}>(),
|
||||
{
|
||||
address: null,
|
||||
zip: null,
|
||||
city: null,
|
||||
street_name: null,
|
||||
street_type: null,
|
||||
house_number: null,
|
||||
stairwell: null,
|
||||
floor: null,
|
||||
door: null,
|
||||
parcel_id: null,
|
||||
hrsz: null,
|
||||
textClass: 'text-white/80',
|
||||
detailed: false,
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Resolve the effective address values.
|
||||
* Individual props take precedence over the address object.
|
||||
*/
|
||||
function resolveField(field: keyof AddressObject): string | null {
|
||||
const propValue = (props as any)[field]
|
||||
if (propValue !== null && propValue !== undefined && propValue !== '') {
|
||||
return propValue
|
||||
}
|
||||
if (props.address) {
|
||||
const addrValue = props.address[field]
|
||||
if (addrValue !== null && addrValue !== undefined && addrValue !== '') {
|
||||
return addrValue
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the formatted address string.
|
||||
* Hungarian format: [ZIP] [City], [Street name] [Street type] [House number]
|
||||
* Intelligently filters out null/undefined/empty values and prevents double spaces.
|
||||
*/
|
||||
const formattedAddress = computed(() => {
|
||||
const zip = resolveField('zip')
|
||||
const city = resolveField('city')
|
||||
const streetName = resolveField('street_name')
|
||||
const streetType = resolveField('street_type')
|
||||
const houseNumber = resolveField('house_number')
|
||||
|
||||
// Build the primary address line
|
||||
const parts: string[] = []
|
||||
|
||||
// ZIP + City (always together if city exists)
|
||||
if (zip || city) {
|
||||
const location = [zip, city].filter(Boolean).join(' ')
|
||||
parts.push(location)
|
||||
}
|
||||
|
||||
// Street details
|
||||
const streetParts: string[] = []
|
||||
if (streetName) streetParts.push(streetName)
|
||||
if (streetType) streetParts.push(streetType)
|
||||
if (houseNumber) streetParts.push(houseNumber)
|
||||
|
||||
if (streetParts.length > 0) {
|
||||
// If we already have a location part, add street with a comma separator
|
||||
if (parts.length > 0) {
|
||||
parts[parts.length - 1] += ','
|
||||
}
|
||||
parts.push(streetParts.join(' '))
|
||||
}
|
||||
|
||||
// Join all parts, then normalize whitespace (remove double spaces)
|
||||
let result = parts.join(' ').replace(/\s+/g, ' ').trim()
|
||||
|
||||
// If no address data at all, show a placeholder
|
||||
if (!result) {
|
||||
result = '—'
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
/**
|
||||
* Build the detailed address string (includes stairwell/floor/door).
|
||||
*/
|
||||
const { t } = useI18n()
|
||||
|
||||
const detailedAddress = computed(() => {
|
||||
if (!props.detailed) return ''
|
||||
|
||||
const stairwell = resolveField('stairwell')
|
||||
const floor = resolveField('floor')
|
||||
const door = resolveField('door')
|
||||
const hrsz = resolveField('hrsz') || resolveField('parcel_id')
|
||||
|
||||
const detailParts: string[] = []
|
||||
if (stairwell) detailParts.push(t('address.stairwell', { value: stairwell }))
|
||||
if (floor) detailParts.push(t('address.floor', { value: floor }))
|
||||
if (door) detailParts.push(t('address.door', { value: door }))
|
||||
if (hrsz) detailParts.push(t('address.hrsz', { value: hrsz }))
|
||||
|
||||
return detailParts.join(', ')
|
||||
})
|
||||
</script>
|
||||
80
frontend_app/src/components/ui/BaseCard.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div
|
||||
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col relative transition-all duration-300 ease-out"
|
||||
:class="[
|
||||
heightClass,
|
||||
hoverable ? 'cursor-pointer hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl' : '',
|
||||
clickable ? 'cursor-pointer' : '',
|
||||
customClass,
|
||||
]"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<!-- ═══ HEADER SLOT ═══ -->
|
||||
<div
|
||||
v-if="$slots.header"
|
||||
class="h-12 w-full shrink-0 flex items-center px-4"
|
||||
:class="headerColorClass"
|
||||
>
|
||||
<slot name="header" />
|
||||
</div>
|
||||
|
||||
<!-- ═══ BODY SLOT (default) ═══ -->
|
||||
<div
|
||||
class="flex-1 flex flex-col text-slate-800 overflow-hidden"
|
||||
:class="bodyPaddingClass"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- ═══ FOOTER SLOT ═══ -->
|
||||
<div v-if="$slots.footer" class="shrink-0 px-4 pb-4">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** Enable hover lift effect (translate-y and stronger shadow) */
|
||||
hoverable?: boolean
|
||||
/** Enable cursor-pointer without hover animation */
|
||||
clickable?: boolean
|
||||
/** Card height Tailwind class (default: h-[350px]) */
|
||||
height?: string
|
||||
/** Header background color Tailwind class (default: bg-slate-700) */
|
||||
headerColor?: string
|
||||
/** Body padding: 'sm' | 'md' | 'lg' | 'none' (default: 'md') */
|
||||
bodyPadding?: 'sm' | 'md' | 'lg' | 'none'
|
||||
/** Additional CSS classes to merge */
|
||||
customClass?: string
|
||||
}>(),
|
||||
{
|
||||
hoverable: false,
|
||||
clickable: false,
|
||||
height: 'h-[350px]',
|
||||
headerColor: 'bg-slate-700',
|
||||
bodyPadding: 'md',
|
||||
customClass: '',
|
||||
}
|
||||
)
|
||||
|
||||
defineEmits<{
|
||||
click: []
|
||||
}>()
|
||||
|
||||
const heightClass = computed(() => props.height)
|
||||
|
||||
const headerColorClass = computed(() => props.headerColor)
|
||||
|
||||
const bodyPaddingClass = computed(() => {
|
||||
switch (props.bodyPadding) {
|
||||
case 'sm': return 'p-3'
|
||||
case 'lg': return 'p-6'
|
||||
case 'none': return ''
|
||||
default: return 'p-4'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
77
frontend_app/src/components/ui/GlassCard.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div
|
||||
class="glass-card relative overflow-hidden rounded-2xl border border-white/[0.08] bg-white/[0.05] backdrop-blur-xl shadow-xl shadow-black/20 transition-all duration-300"
|
||||
:class="[
|
||||
variant === 'premium'
|
||||
? 'premium-gradient-border hover:shadow-2xl hover:shadow-[#00E5A0]/10'
|
||||
: 'hover:border-white/[0.15] hover:bg-white/[0.08] hover:shadow-2xl hover:shadow-black/30',
|
||||
hoverable ? 'hover:-translate-y-1 cursor-pointer' : '',
|
||||
customClass,
|
||||
]"
|
||||
>
|
||||
<!-- Premium gradient border overlay (only for premium variant) -->
|
||||
<div
|
||||
v-if="variant === 'premium'"
|
||||
class="pointer-events-none absolute inset-0 rounded-2xl p-[1px]"
|
||||
>
|
||||
<div class="h-full w-full rounded-2xl bg-gradient-to-br from-[#00E5A0]/40 via-[#418890]/20 to-transparent" />
|
||||
</div>
|
||||
|
||||
<!-- Content slot -->
|
||||
<div class="relative z-10" :class="paddingClass">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** Visual variant: 'standard' (default) or 'premium' (with gradient border) */
|
||||
variant?: 'standard' | 'premium'
|
||||
/** Enable hover lift effect (translate-y and stronger shadow) */
|
||||
hoverable?: boolean
|
||||
/** Custom padding: 'sm', 'md' (default), 'lg', or 'none' */
|
||||
padding?: 'sm' | 'md' | 'lg' | 'none'
|
||||
/** Additional CSS classes to merge */
|
||||
customClass?: string
|
||||
}>(),
|
||||
{
|
||||
variant: 'standard',
|
||||
hoverable: false,
|
||||
padding: 'md',
|
||||
customClass: '',
|
||||
}
|
||||
)
|
||||
|
||||
const paddingClass = computed(() => {
|
||||
switch (props.padding) {
|
||||
case 'sm': return 'p-4'
|
||||
case 'lg': return 'p-8'
|
||||
case 'none': return ''
|
||||
default: return 'p-6'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ── Premium gradient border using pseudo-element ── */
|
||||
.glass-card.premium-gradient-border {
|
||||
position: relative;
|
||||
border: none;
|
||||
}
|
||||
.glass-card.premium-gradient-border::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 1rem; /* matches rounded-2xl */
|
||||
padding: 1px;
|
||||
background: linear-gradient(135deg, rgba(0, 229, 160, 0.5), rgba(65, 136, 144, 0.3), transparent);
|
||||
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
42
frontend_app/src/components/vehicle/VehicleCardCompact.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center gap-3 p-2 hover:bg-slate-50 rounded-lg cursor-pointer transition-colors"
|
||||
@click="$emit('click', vehicle)"
|
||||
>
|
||||
<!-- Mini EU License Plate (clickable) with brand inside -->
|
||||
<div
|
||||
v-if="vehicle.license_plate"
|
||||
@click.stop="$emit('plate-click', vehicle)"
|
||||
>
|
||||
<VehiclePlateBadge
|
||||
:license-plate="vehicle.license_plate"
|
||||
:brand="vehicle.brand"
|
||||
:country-code="vehicle.countryCode || vehicle.country_code"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<!-- Nickname or Brand/Model text -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="text-sm font-semibold text-slate-700 truncate block">
|
||||
{{ vehicle.nickname || vehicle.brand || vehicle.license_plate }}
|
||||
</span>
|
||||
<p v-if="vehicle.nickname && vehicle.brand" class="text-[11px] text-slate-400 truncate">
|
||||
{{ vehicle.brand }}<span v-if="vehicle.model"> {{ vehicle.model }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { VehicleData } from '../../types/vehicle'
|
||||
import VehiclePlateBadge from './VehiclePlateBadge.vue'
|
||||
|
||||
defineProps<{
|
||||
vehicle: VehicleData
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
click: [vehicle: VehicleData]
|
||||
'plate-click': [vehicle: VehicleData]
|
||||
}>()
|
||||
</script>
|
||||
182
frontend_app/src/components/vehicle/VehicleCardStandard.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<div
|
||||
:id="'vehicle-card-' + vehicle.license_plate"
|
||||
class="group relative min-w-[260px] w-[280px] snap-center flex-shrink-0 rounded-2xl border border-slate-200 bg-white shadow-sm transition-all duration-200 hover:shadow-lg hover:-translate-y-1 overflow-hidden cursor-pointer"
|
||||
@click="$emit('click', vehicle)"
|
||||
>
|
||||
<!-- ── Image Area (Top 40%) ── -->
|
||||
<div class="h-36 rounded-t-2xl bg-gradient-to-br from-slate-100 to-slate-200 flex items-center justify-center overflow-hidden relative">
|
||||
<template v-if="vehicle.image_url || vehicle.image">
|
||||
<img :src="vehicle.image_url || vehicle.image" :alt="vehicle.brand" class="h-full w-full object-cover" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- Car SVG silhouette placeholder -->
|
||||
<div class="flex flex-col items-center justify-center text-slate-300">
|
||||
<svg
|
||||
class="w-16 h-16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19 17h2a1 1 0 001-1v-3.172a1 1 0 00-.293-.707l-2.828-2.828A1 1 0 0018.172 9H17l-3-3H7a2 2 0 00-2 2v2M5 17H3a1 1 0 01-1-1v-3.172a1 1 0 01.293-.707l2.828-2.828A1 1 0 016.828 9H7l3-3h5"
|
||||
/>
|
||||
<circle cx="7" cy="17" r="2" />
|
||||
<circle cx="17" cy="17" r="2" />
|
||||
</svg>
|
||||
<span class="text-xs text-slate-400 mt-1">{{ t('vehicle.noPhoto') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ── Card Body (relative for watermark) ── -->
|
||||
<div class="p-4 space-y-3 relative overflow-hidden">
|
||||
<!-- Halvány vízjel (kisebb, nem lóg ki) -->
|
||||
<div
|
||||
class="absolute inset-0 pointer-events-none select-none flex items-center justify-center overflow-hidden"
|
||||
>
|
||||
<span
|
||||
class="text-5xl font-black text-slate-900/5 w-full text-center truncate px-4 leading-none"
|
||||
>
|
||||
{{ vehicle.brand }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Nickname (if present) — fixed height to prevent layout shift -->
|
||||
<div class="min-h-[24px] mb-2">
|
||||
<p
|
||||
v-if="vehicle.nickname"
|
||||
class="text-sm italic text-slate-500"
|
||||
>
|
||||
„{{ vehicle.nickname }}”
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic EU License Plate Badge (brand inside plate box) -->
|
||||
<VehiclePlateBadge
|
||||
:license-plate="vehicle.license_plate"
|
||||
:brand="vehicle.brand"
|
||||
:country-code="vehicle.registration_country || vehicle.countryCode || vehicle.country_code"
|
||||
size="md"
|
||||
/>
|
||||
|
||||
<!-- Brand & Model -->
|
||||
<h3 class="text-base font-bold text-slate-800 relative z-10">
|
||||
{{ vehicle.brand }}
|
||||
<span class="font-normal text-slate-500">{{ vehicle.model }}</span>
|
||||
</h3>
|
||||
|
||||
<!-- Rich Data Rows (Icon + Text) -->
|
||||
<div class="space-y-1.5 text-xs text-slate-500 relative z-10">
|
||||
<!-- Year -->
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="h-3.5 w-3.5 shrink-0 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span>{{ vehicle.year_of_manufacture || vehicle.year }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Mileage -->
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="h-3.5 w-3.5 shrink-0 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||
</svg>
|
||||
<span>{{ (vehicle.current_mileage ?? vehicle.mileage)?.toLocaleString() || '-' }} km</span>
|
||||
</div>
|
||||
|
||||
<!-- Engine -->
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="h-3.5 w-3.5 shrink-0 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 10h-2m2 4h-2m-4-4h-2m2 4h-2m6-8h2a2 2 0 012 2v4a2 2 0 01-2 2h-2m-4 0H8a2 2 0 01-2-2V8a2 2 0 012-2h4m0 0V4m0 4v4" />
|
||||
</svg>
|
||||
<span>{{ vehicle.power_kw || vehicle.engine_capacity ? (vehicle.power_kw ? vehicle.power_kw + ' kW' : '') + (vehicle.engine_capacity ? ' / ' + vehicle.engine_capacity + ' cm³' : '') : vehicle.engine || vehicle.fuel_type || '—' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Color -->
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="h-3.5 w-3.5 shrink-0 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
|
||||
</svg>
|
||||
<span>{{ vehicle.individual_equipment?.color || vehicle.color || t('common.na') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Next Service (highlighted) -->
|
||||
<div
|
||||
class="flex items-center gap-2 pt-1"
|
||||
:class="serviceDueClass"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span class="font-semibold">{{ t('common.nextService') }}: {{ vehicle.nextService || t('common.noData') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Primary Vehicle Star (top-left overlay) -->
|
||||
<button
|
||||
class="absolute top-3 left-3 flex h-8 w-8 items-center justify-center rounded-full shadow-sm backdrop-blur-sm transition-all z-20"
|
||||
:class="vehicle.is_primary
|
||||
? 'bg-amber-400 text-white hover:bg-amber-500'
|
||||
: 'bg-white/60 text-slate-300 opacity-0 group-hover:opacity-100 hover:text-amber-400 hover:bg-white/90'"
|
||||
@click.stop="$emit('set-primary', vehicle)"
|
||||
:title="vehicle.is_primary ? t('vehicle.primaryVehicle') : t('vehicle.setPrimary')"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Primary Vehicle Badge Text (below star) -->
|
||||
<div
|
||||
v-if="vehicle.is_primary"
|
||||
class="absolute top-11 left-3 z-20 rounded-full bg-amber-400/90 px-2 py-0.5 text-[10px] font-semibold text-white shadow-sm backdrop-blur-sm"
|
||||
>
|
||||
{{ t('vehicle.primaryVehicle') }}
|
||||
</div>
|
||||
|
||||
<!-- Edit Button (top-right overlay) -->
|
||||
<button
|
||||
class="absolute top-3 right-3 flex h-8 w-8 items-center justify-center rounded-full bg-white/80 text-slate-400 opacity-0 shadow-sm backdrop-blur-sm transition-all hover:bg-sf-accent hover:text-white group-hover:opacity-100 z-20"
|
||||
@click.stop="$emit('edit', vehicle)"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { VehicleData } from '../../types/vehicle'
|
||||
import VehiclePlateBadge from './VehiclePlateBadge.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
vehicle: VehicleData
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
click: [vehicle: VehicleData]
|
||||
edit: [vehicle: VehicleData]
|
||||
'set-primary': [vehicle: VehicleData]
|
||||
}>()
|
||||
|
||||
const serviceDueClass = computed(() => {
|
||||
if (!props.vehicle.nextService) return 'text-slate-500'
|
||||
const match = props.vehicle.nextService.match(/([\d\s]+)/)
|
||||
if (!match) return 'text-slate-500'
|
||||
const kmStr = match[1].replace(/\s/g, '')
|
||||
const km = parseInt(kmStr, 10)
|
||||
if (isNaN(km)) return 'text-slate-500'
|
||||
if (km > 10000) return 'text-green-600'
|
||||
if (km > 3000) return 'text-orange-500'
|
||||
return 'text-red-600'
|
||||
})
|
||||
</script>
|
||||
1661
frontend_app/src/components/vehicle/VehicleDetailModal.vue
Normal file
113
frontend_app/src/components/vehicle/VehiclePlateBadge.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div
|
||||
class="inline-flex items-stretch overflow-hidden rounded border border-gray-400 shadow-sm shrink-0 transition-transform duration-200 hover:-translate-y-0.5"
|
||||
:class="containerClasses"
|
||||
>
|
||||
<!-- EU blue band -->
|
||||
<div
|
||||
class="flex flex-col items-center justify-center bg-[#003399] text-white shrink-0"
|
||||
:class="bandClasses"
|
||||
>
|
||||
<div class="flex flex-wrap justify-center gap-[1px] px-0.5">
|
||||
<span
|
||||
v-for="i in 12"
|
||||
:key="i"
|
||||
class="inline-block rounded-full bg-yellow-300"
|
||||
:class="starClasses"
|
||||
></span>
|
||||
</div>
|
||||
<span class="font-black leading-none tracking-wide" :class="codeClasses">
|
||||
{{ countryCode || 'H' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Plate content: brand + license number -->
|
||||
<div
|
||||
class="flex flex-col items-center justify-center bg-white font-mono font-bold tracking-widest text-gray-900 min-w-0"
|
||||
:class="plateClasses"
|
||||
>
|
||||
<!-- Brand text (discreet, inside the plate box) -->
|
||||
<span
|
||||
v-if="brand"
|
||||
class="text-[9px] leading-tight font-sans font-semibold text-gray-500 uppercase tracking-[0.15em] max-w-full truncate px-0.5"
|
||||
:class="brandTextClasses"
|
||||
>
|
||||
{{ brand }}
|
||||
</span>
|
||||
<!-- License plate number -->
|
||||
<span class="leading-none tracking-widest" :class="numberClasses">
|
||||
{{ licensePlate }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
licensePlate: string
|
||||
brand?: string
|
||||
countryCode?: string
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
}>(), {
|
||||
countryCode: 'H',
|
||||
size: 'md',
|
||||
})
|
||||
|
||||
const containerClasses = computed(() => {
|
||||
switch (props.size) {
|
||||
case 'sm': return 'rounded'
|
||||
case 'lg': return 'rounded-lg'
|
||||
default: return 'rounded-md'
|
||||
}
|
||||
})
|
||||
|
||||
const bandClasses = computed(() => {
|
||||
switch (props.size) {
|
||||
case 'sm': return 'w-5 py-0.5'
|
||||
case 'lg': return 'w-10 py-1'
|
||||
default: return 'w-8 py-0.5'
|
||||
}
|
||||
})
|
||||
|
||||
const starClasses = computed(() => {
|
||||
switch (props.size) {
|
||||
case 'sm': return 'h-[2px] w-[2px]'
|
||||
case 'lg': return 'h-[3px] w-[3px]'
|
||||
default: return 'h-[2px] w-[2px]'
|
||||
}
|
||||
})
|
||||
|
||||
const codeClasses = computed(() => {
|
||||
switch (props.size) {
|
||||
case 'sm': return 'text-[6px]'
|
||||
case 'lg': return 'text-xs'
|
||||
default: return 'text-[10px]'
|
||||
}
|
||||
})
|
||||
|
||||
const plateClasses = computed(() => {
|
||||
switch (props.size) {
|
||||
case 'sm': return 'h-6 px-1.5 pt-0 pb-0.5'
|
||||
case 'lg': return 'h-14 px-2 pt-1 pb-1'
|
||||
default: return 'h-10 px-1.5 pt-0.5 pb-0.5'
|
||||
}
|
||||
})
|
||||
|
||||
const brandTextClasses = computed(() => {
|
||||
switch (props.size) {
|
||||
case 'sm': return 'text-[7px]'
|
||||
case 'lg': return 'text-[11px]'
|
||||
default: return 'text-[9px]'
|
||||
}
|
||||
})
|
||||
|
||||
const numberClasses = computed(() => {
|
||||
switch (props.size) {
|
||||
case 'sm': return 'text-[10px]'
|
||||
case 'lg': return 'text-base'
|
||||
default: return 'text-sm'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
16
frontend_app/src/components/vehicles/tabs/DocumentsTab.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur-sm">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">{{ t('vehicleDetail.tabDocuments') }}</h3>
|
||||
<div class="flex flex-col items-center justify-center py-12 text-white/50">
|
||||
<svg class="mb-4 h-16 w-16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<p class="text-base">{{ t('vehicleDetail.placeholderDocuments') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
709
frontend_app/src/components/vehicles/tabs/FinancialsTab.vue
Normal file
@@ -0,0 +1,709 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- ════════════════════════════════════════════════════════════════ -->
|
||||
<!-- SECTION 1: Purchasing & Leasing -->
|
||||
<!-- ════════════════════════════════════════════════════════════════ -->
|
||||
<div class="rounded-2xl border border-slate-700 bg-slate-900/70 backdrop-blur-md p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-base font-bold text-white flex items-center gap-2">
|
||||
<span>💸</span> {{ t('finance.purchasing_section_title') || 'Beszerzés & Lízing' }}
|
||||
</h3>
|
||||
<button
|
||||
@click="openFinancialsModal"
|
||||
class="rounded-lg bg-sf-accent/20 px-3 py-1.5 text-xs font-medium text-sf-accent hover:bg-sf-accent/30 transition-colors cursor-pointer"
|
||||
>
|
||||
{{ t('common.edit') || 'Szerkesztés' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loadingFinancials" class="flex items-center justify-center py-8">
|
||||
<svg class="animate-spin h-6 w-6 text-sf-accent" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="!financials" class="flex flex-col items-center justify-center py-8 text-slate-400">
|
||||
<svg class="h-10 w-10 mb-2 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p class="text-sm">{{ t('finance.no_financials') || 'Még nincsenek pénzügyi adatok rögzítve.' }}</p>
|
||||
<button
|
||||
@click="openFinancialsModal"
|
||||
class="mt-3 rounded-lg bg-sf-accent px-4 py-2 text-xs font-medium text-white hover:bg-sf-accent/90 transition-colors cursor-pointer"
|
||||
>
|
||||
{{ t('finance.add_financials') || 'Pénzügyi adatok hozzáadása' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Data Grid -->
|
||||
<div v-else class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<div class="rounded-xl bg-slate-800/50 p-3">
|
||||
<p class="text-xs text-slate-400 mb-1">{{ t('finance.purchase_price_gross') || 'Bruttó vételár' }}</p>
|
||||
<p class="text-sm font-semibold text-white">
|
||||
{{ formatCurrency(financials.purchase_price_gross, financials.currency) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl bg-slate-800/50 p-3">
|
||||
<p class="text-xs text-slate-400 mb-1">{{ t('finance.purchase_price_net') || 'Nettó vételár' }}</p>
|
||||
<p class="text-sm font-semibold text-white">
|
||||
{{ formatCurrency(financials.purchase_price_net, financials.currency) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl bg-slate-800/50 p-3">
|
||||
<p class="text-xs text-slate-400 mb-1">{{ t('finance.down_payment') || 'Önerő' }}</p>
|
||||
<p class="text-sm font-semibold text-white">
|
||||
{{ formatCurrency(financials.down_payment, financials.currency) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl bg-slate-800/50 p-3">
|
||||
<p class="text-xs text-slate-400 mb-1">{{ t('finance.monthly_installment') || 'Havi törlesztő' }}</p>
|
||||
<p class="text-sm font-semibold text-white">
|
||||
{{ formatCurrency(financials.monthly_installment, financials.currency) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl bg-slate-800/50 p-3">
|
||||
<p class="text-xs text-slate-400 mb-1">{{ t('finance.contract_number') || 'Szerződésszám' }}</p>
|
||||
<p class="text-sm font-semibold text-white">{{ financials.contract_number || '—' }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl bg-slate-800/50 p-3">
|
||||
<p class="text-xs text-slate-400 mb-1">{{ t('finance.financing_provider') || 'Finanszírozó' }}</p>
|
||||
<p class="text-sm font-semibold text-white">{{ financials.financing_provider || '—' }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl bg-slate-800/50 p-3">
|
||||
<p class="text-xs text-slate-400 mb-1">{{ t('finance.interest_rate') || 'Kamat' }}</p>
|
||||
<p class="text-sm font-semibold text-white">{{ financials.interest_rate != null ? `${financials.interest_rate}%` : '—' }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl bg-slate-800/50 p-3">
|
||||
<p class="text-xs text-slate-400 mb-1">{{ t('finance.residual_value') || 'Maradványérték' }}</p>
|
||||
<p class="text-sm font-semibold text-white">
|
||||
{{ formatCurrency(financials.residual_value, financials.currency) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contract Dates -->
|
||||
<div v-if="financials?.contract_start_date || financials?.contract_end_date" class="mt-3 flex flex-wrap gap-4 text-xs text-slate-400">
|
||||
<span v-if="financials.contract_start_date">
|
||||
{{ t('finance.contract_start_date') || 'Szerződés kezdete' }}: <span class="text-slate-300">{{ formatDate(financials.contract_start_date) }}</span>
|
||||
</span>
|
||||
<span v-if="financials.contract_end_date">
|
||||
{{ t('finance.contract_end_date') || 'Szerződés vége' }}: <span class="text-slate-300">{{ formatDate(financials.contract_end_date) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════════ -->
|
||||
<!-- SECTION 2: Insurances & Taxes -->
|
||||
<!-- ════════════════════════════════════════════════════════════════ -->
|
||||
<div class="rounded-2xl border border-slate-700 bg-slate-900/70 backdrop-blur-md p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-base font-bold text-white flex items-center gap-2">
|
||||
<span>🛡</span> {{ t('finance.insurance_section_title') || 'Biztosítások & Adók' }}
|
||||
</h3>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="openInsuranceModal"
|
||||
class="rounded-lg bg-sf-accent/20 px-3 py-1.5 text-xs font-medium text-sf-accent hover:bg-sf-accent/30 transition-colors cursor-pointer"
|
||||
>
|
||||
{{ t('finance.add_insurance') || '+ Biztosítás' }}
|
||||
</button>
|
||||
<button
|
||||
@click="openTaxModal"
|
||||
class="rounded-lg bg-amber-500/20 px-3 py-1.5 text-xs font-medium text-amber-400 hover:bg-amber-500/30 transition-colors cursor-pointer"
|
||||
>
|
||||
{{ t('finance.add_tax') || '+ Adó' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loadingInsurance" class="flex items-center justify-center py-8">
|
||||
<svg class="animate-spin h-6 w-6 text-sf-accent" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="insurancePolicies.length === 0 && taxObligations.length === 0" class="flex flex-col items-center justify-center py-8 text-slate-400">
|
||||
<svg class="h-10 w-10 mb-2 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p class="text-sm">{{ t('finance.no_insurance_taxes') || 'Még nincsenek biztosítások vagy adók rögzítve.' }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<!-- Insurance Cards -->
|
||||
<div v-for="policy in insurancePolicies" :key="policy.id" class="rounded-xl border border-slate-700/50 bg-slate-800/30 p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="rounded-full bg-blue-500/20 px-2.5 py-0.5 text-xs font-medium text-blue-400">
|
||||
{{ policy.policy_type === 'kgfb' ? 'KGFB' : policy.policy_type === 'casco' ? 'CASCO' : policy.policy_type === 'both' ? 'KGFB + CASCO' : (policy.policy_type || t('finance.insurance') || 'Biztosítás') }}
|
||||
</span>
|
||||
<span class="text-sm font-semibold text-white">{{ policy.insurer_name || '—' }}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs">
|
||||
<div>
|
||||
<span class="text-slate-400">{{ t('finance.policy_number') || 'Kötvényszám' }}:</span>
|
||||
<span class="ml-1 text-slate-200">{{ policy.policy_number || '—' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-slate-400">{{ t('finance.premium') || 'Díj' }}:</span>
|
||||
<span class="ml-1 text-slate-200">{{ formatCurrency(policy.premium, policy.currency) }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-slate-400">{{ t('finance.valid_from') || 'Érvényes' }}:</span>
|
||||
<span class="ml-1 text-slate-200">{{ formatDate(policy.valid_from) }} — {{ formatDate(policy.valid_until) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="deleteInsurance(policy)"
|
||||
class="ml-2 rounded-lg p-1.5 text-slate-500 hover:bg-red-500/20 hover:text-red-400 transition-colors cursor-pointer"
|
||||
:title="t('common.delete') || 'Törlés'"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tax Cards -->
|
||||
<div v-for="tax in taxObligations" :key="tax.id" class="rounded-xl border border-slate-700/50 bg-slate-800/30 p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="rounded-full bg-amber-500/20 px-2.5 py-0.5 text-xs font-medium text-amber-400">
|
||||
{{ tax.tax_type || (t('finance.tax') || 'Adó') }}
|
||||
</span>
|
||||
<span class="text-sm font-semibold text-white">{{ formatCurrency(tax.amount, tax.currency) }}</span>
|
||||
<span v-if="tax.paid" class="rounded-full bg-green-500/20 px-2 py-0.5 text-xs font-medium text-green-400">
|
||||
{{ t('finance.paid') || 'Fizetve' }}
|
||||
</span>
|
||||
<span v-else class="rounded-full bg-red-500/20 px-2 py-0.5 text-xs font-medium text-red-400">
|
||||
{{ t('finance.unpaid') || 'Fizetendő' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3 text-xs">
|
||||
<div>
|
||||
<span class="text-slate-400">{{ t('finance.due_date') || 'Esedékesség' }}:</span>
|
||||
<span class="ml-1 text-slate-200">{{ formatDate(tax.due_date) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="deleteTax(tax)"
|
||||
class="ml-2 rounded-lg p-1.5 text-slate-500 hover:bg-red-500/20 hover:text-red-400 transition-colors cursor-pointer"
|
||||
:title="t('common.delete') || 'Törlés'"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════════ -->
|
||||
<!-- SECTION 3: TCO / Expenses -->
|
||||
<!-- ════════════════════════════════════════════════════════════════ -->
|
||||
<div class="rounded-2xl border border-slate-700 bg-slate-900/70 backdrop-blur-md p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-base font-bold text-white flex items-center gap-2">
|
||||
<span>📊</span> {{ t('finance.tco_section_title') || 'TCO / Költségek' }}
|
||||
</h3>
|
||||
<button
|
||||
@click="openCostModal"
|
||||
class="rounded-lg bg-sf-accent/20 px-3 py-1.5 text-xs font-medium text-sf-accent hover:bg-sf-accent/30 transition-colors cursor-pointer"
|
||||
>
|
||||
{{ t('finance.add_cost') || '+ Költség' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Placeholder for TCO / Expenses -->
|
||||
<div class="flex flex-col items-center justify-center py-8 text-slate-400">
|
||||
<svg class="h-10 w-10 mb-2 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
<p class="text-sm">{{ t('finance.tco_placeholder') || 'A TCO / költségadatok itt jelennek meg.' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════════ -->
|
||||
<!-- FINANCIALS MODAL (inline edit) -->
|
||||
<!-- ════════════════════════════════════════════════════════════════ -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showFinancialsModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4" @click.self="closeFinancialsModal">
|
||||
<div class="w-full max-w-lg rounded-2xl border border-slate-700 bg-slate-900 p-6 shadow-2xl">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<h3 class="text-lg font-bold text-white">{{ t('finance.edit_financials') || 'Beszerzés & Lízing adatok' }}</h3>
|
||||
<button @click="closeFinancialsModal" class="rounded-lg p-1.5 text-slate-400 hover:bg-slate-800 hover:text-white transition-colors cursor-pointer">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.purchase_price_net') || 'Nettó vételár' }}</label>
|
||||
<input v-model.number="financialsForm.purchase_price_net" type="number" step="0.01" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.purchase_price_gross') || 'Bruttó vételár' }}</label>
|
||||
<input v-model.number="financialsForm.purchase_price_gross" type="number" step="0.01" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.down_payment') || 'Önerő' }}</label>
|
||||
<input v-model.number="financialsForm.down_payment" type="number" step="0.01" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.monthly_installment') || 'Havi törlesztő' }}</label>
|
||||
<input v-model.number="financialsForm.monthly_installment" type="number" step="0.01" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.contract_number') || 'Szerződésszám' }}</label>
|
||||
<input v-model="financialsForm.contract_number" type="text" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.financing_provider') || 'Finanszírozó' }}</label>
|
||||
<input v-model="financialsForm.financing_provider" type="text" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.interest_rate') || 'Kamat (%)' }}</label>
|
||||
<input v-model.number="financialsForm.interest_rate" type="number" step="0.01" min="0" max="100" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.residual_value') || 'Maradványérték' }}</label>
|
||||
<input v-model.number="financialsForm.residual_value" type="number" step="0.01" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.contract_start_date') || 'Szerződés kezdete' }}</label>
|
||||
<input v-model="financialsForm.contract_start_date" type="date" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.contract_end_date') || 'Szerződés vége' }}</label>
|
||||
<input v-model="financialsForm.contract_end_date" type="date" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button @click="closeFinancialsModal" class="rounded-xl border border-slate-600 px-4 py-2 text-sm text-slate-300 hover:bg-slate-800 transition-colors cursor-pointer">
|
||||
{{ t('common.cancel') || 'Mégse' }}
|
||||
</button>
|
||||
<button @click="saveFinancials" :disabled="savingFinancials" class="rounded-xl bg-sf-accent px-4 py-2 text-sm font-medium text-white hover:bg-sf-accent/90 disabled:opacity-50 transition-colors cursor-pointer">
|
||||
{{ savingFinancials ? (t('common.saving') || 'Mentés...') : (t('common.save') || 'Mentés') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════════ -->
|
||||
<!-- INSURANCE MODAL -->
|
||||
<!-- ════════════════════════════════════════════════════════════════ -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showInsuranceModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4" @click.self="closeInsuranceModal">
|
||||
<div class="w-full max-w-md rounded-2xl border border-slate-700 bg-slate-900 p-6 shadow-2xl">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<h3 class="text-lg font-bold text-white">{{ t('finance.add_insurance') || 'Új biztosítás' }}</h3>
|
||||
<button @click="closeInsuranceModal" class="rounded-lg p-1.5 text-slate-400 hover:bg-slate-800 hover:text-white transition-colors cursor-pointer">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.insurer_name') || 'Biztosító neve' }}</label>
|
||||
<input v-model="insuranceForm.insurer_name" type="text" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.policy_number') || 'Kötvényszám' }}</label>
|
||||
<input v-model="insuranceForm.policy_number" type="text" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.policy_type') || 'Biztosítás típusa' }}</label>
|
||||
<select v-model="insuranceForm.policy_type" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20 appearance-none cursor-pointer">
|
||||
<option value="kgfb">KGFB</option>
|
||||
<option value="casco">CASCO</option>
|
||||
<option value="both">KGFB + CASCO</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.premium') || 'Díj' }}</label>
|
||||
<input v-model.number="insuranceForm.premium" type="number" step="0.01" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.currency') || 'Pénznem' }}</label>
|
||||
<select v-model="insuranceForm.currency" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20 appearance-none cursor-pointer">
|
||||
<option value="HUF">HUF</option>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="USD">USD</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.valid_from') || 'Érvényesség kezdete' }}</label>
|
||||
<input v-model="insuranceForm.valid_from" type="date" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.valid_until') || 'Érvényesség vége' }}</label>
|
||||
<input v-model="insuranceForm.valid_until" type="date" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button @click="closeInsuranceModal" class="rounded-xl border border-slate-600 px-4 py-2 text-sm text-slate-300 hover:bg-slate-800 transition-colors cursor-pointer">
|
||||
{{ t('common.cancel') || 'Mégse' }}
|
||||
</button>
|
||||
<button @click="saveInsurance" :disabled="savingInsurance" class="rounded-xl bg-sf-accent px-4 py-2 text-sm font-medium text-white hover:bg-sf-accent/90 disabled:opacity-50 transition-colors cursor-pointer">
|
||||
{{ savingInsurance ? (t('common.saving') || 'Mentés...') : (t('common.save') || 'Mentés') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════════ -->
|
||||
<!-- TAX MODAL -->
|
||||
<!-- ════════════════════════════════════════════════════════════════ -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showTaxModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4" @click.self="closeTaxModal">
|
||||
<div class="w-full max-w-md rounded-2xl border border-slate-700 bg-slate-900 p-6 shadow-2xl">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<h3 class="text-lg font-bold text-white">{{ t('finance.add_tax') || 'Új adó' }}</h3>
|
||||
<button @click="closeTaxModal" class="rounded-lg p-1.5 text-slate-400 hover:bg-slate-800 hover:text-white transition-colors cursor-pointer">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.tax_type') || 'Adó típusa' }}</label>
|
||||
<input v-model="taxForm.tax_type" type="text" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.amount') || 'Összeg' }}</label>
|
||||
<input v-model.number="taxForm.amount" type="number" step="0.01" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.currency') || 'Pénznem' }}</label>
|
||||
<select v-model="taxForm.currency" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20 appearance-none cursor-pointer">
|
||||
<option value="HUF">HUF</option>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="USD">USD</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.due_date') || 'Esedékesség' }}</label>
|
||||
<input v-model="taxForm.due_date" type="date" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input v-model="taxForm.paid" type="checkbox" id="tax-paid" class="rounded border-slate-600 bg-slate-800 text-sf-accent focus:ring-sf-accent/20 cursor-pointer" />
|
||||
<label for="tax-paid" class="text-sm text-slate-300 cursor-pointer">{{ t('finance.paid') || 'Fizetve' }}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button @click="closeTaxModal" class="rounded-xl border border-slate-600 px-4 py-2 text-sm text-slate-300 hover:bg-slate-800 transition-colors cursor-pointer">
|
||||
{{ t('common.cancel') || 'Mégse' }}
|
||||
</button>
|
||||
<button @click="saveTax" :disabled="savingTax" class="rounded-xl bg-sf-accent px-4 py-2 text-sm font-medium text-white hover:bg-sf-accent/90 disabled:opacity-50 transition-colors cursor-pointer">
|
||||
{{ savingTax ? (t('common.saving') || 'Mentés...') : (t('common.save') || 'Mentés') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useVehicleStore } from '@/stores/vehicle'
|
||||
import type { AssetFinancials, VehicleInsurancePolicy, VehicleTaxObligation } from '@/types/vehicle'
|
||||
|
||||
const { t } = useI18n()
|
||||
const vehicleStore = useVehicleStore()
|
||||
|
||||
const props = defineProps<{ vehicleId: string }>()
|
||||
|
||||
// ── Reactive State ────────────────────────────────────────────────────────
|
||||
const financials = ref<AssetFinancials | null>(null)
|
||||
const insurancePolicies = ref<VehicleInsurancePolicy[]>([])
|
||||
const taxObligations = ref<VehicleTaxObligation[]>([])
|
||||
|
||||
const loadingFinancials = ref(false)
|
||||
const loadingInsurance = ref(false)
|
||||
|
||||
const savingFinancials = ref(false)
|
||||
const savingInsurance = ref(false)
|
||||
const savingTax = ref(false)
|
||||
|
||||
const showFinancialsModal = ref(false)
|
||||
const showInsuranceModal = ref(false)
|
||||
const showTaxModal = ref(false)
|
||||
|
||||
// ── Form Objects ──────────────────────────────────────────────────────────
|
||||
const financialsForm = reactive<Partial<AssetFinancials>>({
|
||||
purchase_price_net: null,
|
||||
purchase_price_gross: null,
|
||||
down_payment: null,
|
||||
monthly_installment: null,
|
||||
contract_number: '',
|
||||
financing_provider: '',
|
||||
interest_rate: null,
|
||||
residual_value: null,
|
||||
contract_start_date: '',
|
||||
contract_end_date: '',
|
||||
})
|
||||
|
||||
const insuranceForm = reactive<Partial<VehicleInsurancePolicy>>({
|
||||
insurer_name: '',
|
||||
policy_number: '',
|
||||
policy_type: 'kgfb',
|
||||
premium: null,
|
||||
currency: 'HUF',
|
||||
valid_from: '',
|
||||
valid_until: '',
|
||||
})
|
||||
|
||||
const taxForm = reactive<Partial<VehicleTaxObligation>>({
|
||||
tax_type: '',
|
||||
amount: null,
|
||||
currency: 'HUF',
|
||||
due_date: '',
|
||||
paid: false,
|
||||
})
|
||||
|
||||
// ── Helper Methods ────────────────────────────────────────────────────────
|
||||
function formatCurrency(value: number | null | undefined, currency?: string): string {
|
||||
if (value == null) return '—'
|
||||
const cur = currency || 'HUF'
|
||||
try {
|
||||
return new Intl.NumberFormat('hu-HU', { style: 'currency', currency: cur }).format(value)
|
||||
} catch {
|
||||
return `${value.toLocaleString('hu-HU')} ${cur}`
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(date: string | null | undefined): string {
|
||||
if (!date) return '—'
|
||||
try {
|
||||
return new Intl.DateTimeFormat('hu-HU', { year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(date))
|
||||
} catch {
|
||||
return date
|
||||
}
|
||||
}
|
||||
|
||||
// ── Data Loading ──────────────────────────────────────────────────────────
|
||||
async function loadData() {
|
||||
if (!props.vehicleId) return
|
||||
|
||||
loadingFinancials.value = true
|
||||
loadingInsurance.value = true
|
||||
|
||||
try {
|
||||
const [finResult, insResult, taxResult] = await Promise.all([
|
||||
vehicleStore.fetchAssetFinancials(props.vehicleId),
|
||||
vehicleStore.fetchInsurancePolicies(props.vehicleId),
|
||||
vehicleStore.fetchTaxObligations(props.vehicleId),
|
||||
])
|
||||
financials.value = finResult
|
||||
insurancePolicies.value = insResult
|
||||
taxObligations.value = taxResult
|
||||
} catch (err) {
|
||||
console.error('[FinancialsTab] Failed to load data:', err)
|
||||
} finally {
|
||||
loadingFinancials.value = false
|
||||
loadingInsurance.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Financials Modal ──────────────────────────────────────────────────────
|
||||
function openFinancialsModal() {
|
||||
if (financials.value) {
|
||||
Object.assign(financialsForm, {
|
||||
purchase_price_net: financials.value.purchase_price_net ?? null,
|
||||
purchase_price_gross: financials.value.purchase_price_gross ?? null,
|
||||
down_payment: financials.value.down_payment ?? null,
|
||||
monthly_installment: financials.value.monthly_installment ?? null,
|
||||
contract_number: financials.value.contract_number || '',
|
||||
financing_provider: financials.value.financing_provider || '',
|
||||
interest_rate: financials.value.interest_rate ?? null,
|
||||
residual_value: financials.value.residual_value ?? null,
|
||||
contract_start_date: financials.value.contract_start_date || '',
|
||||
contract_end_date: financials.value.contract_end_date || '',
|
||||
})
|
||||
} else {
|
||||
Object.assign(financialsForm, {
|
||||
purchase_price_net: null,
|
||||
purchase_price_gross: null,
|
||||
down_payment: null,
|
||||
monthly_installment: null,
|
||||
contract_number: '',
|
||||
financing_provider: '',
|
||||
interest_rate: null,
|
||||
residual_value: null,
|
||||
contract_start_date: '',
|
||||
contract_end_date: '',
|
||||
})
|
||||
}
|
||||
showFinancialsModal.value = true
|
||||
}
|
||||
|
||||
function closeFinancialsModal() {
|
||||
showFinancialsModal.value = false
|
||||
}
|
||||
|
||||
async function saveFinancials() {
|
||||
if (!props.vehicleId) return
|
||||
savingFinancials.value = true
|
||||
try {
|
||||
const result = await vehicleStore.saveAssetFinancials(props.vehicleId, { ...financialsForm })
|
||||
if (result) {
|
||||
financials.value = result
|
||||
closeFinancialsModal()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[FinancialsTab] Failed to save financials:', err)
|
||||
} finally {
|
||||
savingFinancials.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Insurance Modal ───────────────────────────────────────────────────────
|
||||
function openInsuranceModal() {
|
||||
Object.assign(insuranceForm, {
|
||||
insurer_name: '',
|
||||
policy_number: '',
|
||||
policy_type: 'kgfb',
|
||||
premium: null,
|
||||
currency: 'HUF',
|
||||
valid_from: '',
|
||||
valid_until: '',
|
||||
})
|
||||
showInsuranceModal.value = true
|
||||
}
|
||||
|
||||
function closeInsuranceModal() {
|
||||
showInsuranceModal.value = false
|
||||
}
|
||||
|
||||
async function saveInsurance() {
|
||||
if (!props.vehicleId) return
|
||||
savingInsurance.value = true
|
||||
try {
|
||||
const result = await vehicleStore.createInsurancePolicy(props.vehicleId, { ...insuranceForm })
|
||||
if (result) {
|
||||
insurancePolicies.value.push(result)
|
||||
closeInsuranceModal()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[FinancialsTab] Failed to save insurance:', err)
|
||||
} finally {
|
||||
savingInsurance.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteInsurance(policy: VehicleInsurancePolicy) {
|
||||
if (!props.vehicleId || !policy.id) return
|
||||
if (!confirm(t('common.confirm_delete') || 'Biztosan törlöd?')) return
|
||||
try {
|
||||
const success = await vehicleStore.deleteInsurancePolicy(props.vehicleId, policy.id)
|
||||
if (success) {
|
||||
insurancePolicies.value = insurancePolicies.value.filter(p => p.id !== policy.id)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[FinancialsTab] Failed to delete insurance:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tax Modal ─────────────────────────────────────────────────────────────
|
||||
function openTaxModal() {
|
||||
Object.assign(taxForm, {
|
||||
tax_type: '',
|
||||
amount: null,
|
||||
currency: 'HUF',
|
||||
due_date: '',
|
||||
paid: false,
|
||||
})
|
||||
showTaxModal.value = true
|
||||
}
|
||||
|
||||
function closeTaxModal() {
|
||||
showTaxModal.value = false
|
||||
}
|
||||
|
||||
async function saveTax() {
|
||||
if (!props.vehicleId) return
|
||||
savingTax.value = true
|
||||
try {
|
||||
const result = await vehicleStore.createTaxObligation(props.vehicleId, { ...taxForm })
|
||||
if (result) {
|
||||
taxObligations.value.push(result)
|
||||
closeTaxModal()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[FinancialsTab] Failed to save tax:', err)
|
||||
} finally {
|
||||
savingTax.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTax(tax: VehicleTaxObligation) {
|
||||
if (!props.vehicleId || !tax.id) return
|
||||
if (!confirm(t('common.confirm_delete') || 'Biztosan törlöd?')) return
|
||||
try {
|
||||
const success = await vehicleStore.deleteTaxObligation(props.vehicleId, tax.id)
|
||||
if (success) {
|
||||
taxObligations.value = taxObligations.value.filter(t => t.id !== tax.id)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[FinancialsTab] Failed to delete tax:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── TCO / Cost Modal (placeholder) ────────────────────────────────────────
|
||||
function openCostModal() {
|
||||
// TODO: Implement cost modal when TCO module is ready
|
||||
console.warn('[FinancialsTab] TCO cost modal not yet implemented')
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* All styling is handled by Tailwind utility classes */
|
||||
</style>
|
||||
326
frontend_app/src/components/vehicles/tabs/OverviewTab.vue
Normal file
@@ -0,0 +1,326 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-12">
|
||||
<!-- ═══ LEFT: Photo ═══ -->
|
||||
<div class="lg:col-span-5">
|
||||
<!-- ── Vehicle Photo ── -->
|
||||
<div
|
||||
v-if="vehicle?.photo_url && !imageError"
|
||||
class="flex aspect-[16/9] items-center justify-center overflow-hidden rounded-2xl border border-slate-700 bg-slate-900/70 backdrop-blur-md shadow-xl lg:aspect-square"
|
||||
>
|
||||
<img
|
||||
:src="vehicle.photo_url"
|
||||
alt="Vehicle photo"
|
||||
class="h-full w-full rounded-2xl object-cover"
|
||||
@error="imageError = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── Photo Fallback (SVG Placeholder) ── -->
|
||||
<div
|
||||
v-else
|
||||
class="flex aspect-[16/9] flex-col items-center justify-center gap-3 rounded-2xl border border-slate-700 bg-slate-900/70 backdrop-blur-md shadow-xl lg:aspect-square"
|
||||
>
|
||||
<svg class="h-16 w-16 text-white/20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<p class="text-sm text-white/40">{{ t('vehicleDetail.noPhoto') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ RIGHT: Master Data Card ═══ -->
|
||||
<div class="flex flex-col rounded-2xl border border-slate-700 bg-slate-900/70 p-6 backdrop-blur-md shadow-xl lg:col-span-7">
|
||||
<!-- ── SECTION 1: Main Data (Top) ── -->
|
||||
<!-- License Plate (large, prominent) -->
|
||||
<h2 class="text-4xl font-bold tracking-wider text-white">
|
||||
{{ vehicle?.license_plate || t('common.noPlate') }}
|
||||
</h2>
|
||||
|
||||
<!-- Brand / Model / Year -->
|
||||
<p class="mt-2 text-2xl text-white/80">
|
||||
{{ vehicle?.brand || '—' }} {{ vehicle?.model || '—' }}
|
||||
</p>
|
||||
<p class="mt-1 text-lg text-white/60">
|
||||
{{ vehicle?.year_of_manufacture || '—' }}
|
||||
</p>
|
||||
|
||||
<!-- Quick stats grid: Fuel, Mileage, Monthly Cost, Monthly Mileage -->
|
||||
<div class="mt-4 grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<!-- Fuel -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs uppercase tracking-wider text-white/50">{{ t('vehicleDetail.fuelType') }}</span>
|
||||
<p class="text-base font-semibold text-white">{{ fuelLabel }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Current Mileage (only here) -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs uppercase tracking-wider text-white/50">{{ t('common.mileage') }}</span>
|
||||
<p class="text-base font-semibold text-white">{{ formattedMileage }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Monthly Cost (dynamic from Expense Store) -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs uppercase tracking-wider text-white/50">{{ t('vehicleDetail.monthlyCost') }}</span>
|
||||
<p class="flex items-center gap-1 text-base font-semibold text-white">
|
||||
<svg class="h-4 w-4 text-rose-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{{ monthlyCostLabel }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Monthly Mileage -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs uppercase tracking-wider text-white/50">{{ t('vehicleDetail.monthlyMileage') }}</span>
|
||||
<p class="flex items-center gap-1 text-base font-semibold text-white">
|
||||
<svg class="h-4 w-4 text-violet-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
{{ monthlyMileageLabel }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Divider ── -->
|
||||
<hr class="my-5 border-slate-700" />
|
||||
|
||||
<!-- ── SECTION 2: Recent Expenses (Middle) ── -->
|
||||
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wider text-white/60">{{ t('vehicleDetail.recentExpenses') }}</h3>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="expenseStore.isLoading" class="flex items-center justify-center py-6">
|
||||
<svg class="h-6 w-6 animate-spin text-white/40" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Expense list -->
|
||||
<div v-else-if="recentExpenses.length > 0" class="flex flex-col">
|
||||
<div
|
||||
v-for="(expense, index) in recentExpenses"
|
||||
:key="expense.id || index"
|
||||
class="flex items-center justify-between py-2"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Dynamic icon based on category -->
|
||||
<svg
|
||||
v-if="expense.icon === 'fuel'"
|
||||
class="h-4 w-4 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="expense.icon === 'service'"
|
||||
class="h-4 w-4 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
class="h-4 w-4 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
|
||||
</svg>
|
||||
<span class="text-sm text-white/80">{{ expense.label }}</span>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-white">{{ formatCurrency(expense.amount) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state: no expenses -->
|
||||
<div v-else class="flex flex-col items-center justify-center py-6 text-white/40">
|
||||
<svg class="mb-2 h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
<p class="text-sm">{{ t('vehicleDetail.noExpenses') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- ── Divider ── -->
|
||||
<hr class="my-5 border-slate-700" />
|
||||
|
||||
<!-- ── SECTION 3: Deadlines (Bottom) ── -->
|
||||
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wider text-white/60">{{ t('vehicleDetail.deadlines') }}</h3>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- MOT Expiry -->
|
||||
<div class="flex items-center gap-3 rounded-xl border border-slate-700 bg-slate-900/70 p-4">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-500/20">
|
||||
<svg class="h-5 w-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wider text-white/50">{{ t('vehicleDetail.motExpiry') }}</p>
|
||||
<p class="text-base font-semibold" :class="motStatusClass">
|
||||
{{ motExpiryLabel }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Next Service -->
|
||||
<div class="flex items-center gap-3 rounded-xl border border-slate-700 bg-slate-900/70 p-4">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-500/20">
|
||||
<svg class="h-5 w-5 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wider text-white/50">{{ t('common.nextService') }}</p>
|
||||
<p class="text-base font-semibold text-white">
|
||||
{{ nextServiceLabel }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useExpenseStore, type ExpenseItem } from '../../../stores/expense'
|
||||
import type { Vehicle } from '../../../stores/vehicle'
|
||||
|
||||
const { t } = useI18n()
|
||||
const expenseStore = useExpenseStore()
|
||||
|
||||
// ── Inject vehicle from parent (provided by VehicleDetailsView) ──
|
||||
const vehicle = inject<computed<Vehicle | null>>('vehicle')
|
||||
|
||||
// ── Image error state ──
|
||||
const imageError = ref(false)
|
||||
|
||||
// ── Fuel type label ──
|
||||
const fuelLabel = computed(() => {
|
||||
if (!vehicle?.value?.fuel_type) return '—'
|
||||
const key = `vehicle.fuelTypes.${vehicle.value.fuel_type}`
|
||||
const translated = t(key)
|
||||
return translated !== key ? translated : vehicle.value.fuel_type
|
||||
})
|
||||
|
||||
// ── Formatted mileage ──
|
||||
const formattedMileage = computed(() => {
|
||||
if (vehicle?.value?.current_mileage == null) return '—'
|
||||
return `${Number(vehicle.value.current_mileage).toLocaleString()} km`
|
||||
})
|
||||
|
||||
// ── Monthly Cost (dynamic from Expense Store - current month aggregate) ──
|
||||
const monthlyCostLabel = computed(() => {
|
||||
if (!vehicle?.value?.id) return t('common.noData')
|
||||
const monthlyCost = expenseStore.getMonthlyCost(vehicle.value.id)
|
||||
if (monthlyCost > 0) {
|
||||
return formatCurrency(monthlyCost)
|
||||
}
|
||||
return t('common.noData')
|
||||
})
|
||||
|
||||
// ── Monthly Mileage (dynamic from individual_equipment or fallback) ──
|
||||
const monthlyMileageLabel = computed(() => {
|
||||
const ie = vehicle?.value?.individual_equipment
|
||||
const monthlyMileage = ie?.monthly_mileage ?? ie?.monthlyMileage
|
||||
if (monthlyMileage != null && !isNaN(Number(monthlyMileage))) {
|
||||
return `${Number(monthlyMileage).toLocaleString()} km`
|
||||
}
|
||||
return t('common.noData')
|
||||
})
|
||||
|
||||
// ── Recent Expenses (from Expense Store, sorted by date desc, latest 3) ──
|
||||
interface DisplayExpense {
|
||||
id: string
|
||||
label: string
|
||||
amount: number
|
||||
icon: 'fuel' | 'service' | 'other'
|
||||
}
|
||||
|
||||
const recentExpenses = computed<DisplayExpense[]>(() => {
|
||||
if (!vehicle?.value?.id) return []
|
||||
const expenses = expenseStore.getExpensesForAsset(vehicle.value.id)
|
||||
|
||||
if (!expenses.length) return []
|
||||
|
||||
// Sort by date descending (newest first)
|
||||
const sorted = [...expenses].sort((a, b) => {
|
||||
if (!a.date && !b.date) return 0
|
||||
if (!a.date) return 1
|
||||
if (!b.date) return -1
|
||||
return new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
})
|
||||
|
||||
// Take the latest 3 entries
|
||||
return sorted.slice(0, 3).map((e: ExpenseItem) => ({
|
||||
id: e.id,
|
||||
label: e.description || e.category_name || e.category_code || t('common.noData'),
|
||||
amount: e.amount_gross ? parseFloat(e.amount_gross) : 0,
|
||||
icon: resolveIcon(e.category_code || e.category_name || ''),
|
||||
}))
|
||||
})
|
||||
|
||||
function resolveIcon(category: string): 'fuel' | 'service' | 'other' {
|
||||
const cat = (category || '').toLowerCase()
|
||||
if (cat.includes('fuel') || cat.includes('tankol') || cat.includes('üzemanyag') || cat.includes('benzin')) return 'fuel'
|
||||
if (cat.includes('service') || cat.includes('szerviz') || cat.includes('javítás') || cat.includes('repair') || cat.includes('maintenance') || cat.includes('karbantartás')) return 'service'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
// ── Currency formatter ──
|
||||
function formatCurrency(amount: number): string {
|
||||
if (amount == null || isNaN(amount)) return t('common.noData')
|
||||
return `${amount.toLocaleString()} Ft`
|
||||
}
|
||||
|
||||
// ── MOT Expiry ──
|
||||
const motExpiryLabel = computed(() => {
|
||||
const dates = vehicle?.value?.individual_equipment?.dates
|
||||
if (!dates?.mot_expiry) return t('common.noData')
|
||||
return formatDate(dates.mot_expiry)
|
||||
})
|
||||
|
||||
const motStatusClass = computed(() => {
|
||||
const dates = vehicle?.value?.individual_equipment?.dates
|
||||
if (!dates?.mot_expiry) return ''
|
||||
const expiry = new Date(dates.mot_expiry)
|
||||
const now = new Date()
|
||||
const diffDays = Math.ceil((expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
if (diffDays < 0) return 'text-red-400'
|
||||
if (diffDays < 30) return 'text-amber-400'
|
||||
return 'text-emerald-400'
|
||||
})
|
||||
|
||||
// ── Next Service ──
|
||||
const nextServiceLabel = computed(() => {
|
||||
// Placeholder — will be connected to the service book / maintenance schedule later
|
||||
return t('vehicleDetail.comingSoon')
|
||||
})
|
||||
|
||||
// ── Date formatter helper ──
|
||||
function formatDate(dateStr: string): string {
|
||||
try {
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
} catch {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fetch expenses when vehicle is available ──
|
||||
async function loadExpenses() {
|
||||
if (vehicle?.value?.id) {
|
||||
await expenseStore.fetchExpensesByAsset(vehicle.value.id)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadExpenses()
|
||||
})
|
||||
|
||||
// Watch for vehicle ID changes (e.g., when navigating between vehicles)
|
||||
watch(
|
||||
() => vehicle?.value?.id,
|
||||
(newId) => {
|
||||
if (newId) {
|
||||
loadExpenses()
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
16
frontend_app/src/components/vehicles/tabs/ServiceBookTab.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur-sm">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">{{ t('vehicleDetail.tabServiceBook') }}</h3>
|
||||
<div class="flex flex-col items-center justify-center py-12 text-white/50">
|
||||
<svg class="mb-4 h-16 w-16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p class="text-base">{{ t('vehicleDetail.placeholderServiceBook') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
278
frontend_app/src/components/vehicles/tabs/TechDataTab.vue
Normal file
@@ -0,0 +1,278 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- ═══ Section 1: Basic Specs ═══ -->
|
||||
<div
|
||||
v-if="basicSpecs.length > 0"
|
||||
class="rounded-2xl border border-slate-700 bg-slate-900/70 p-6 backdrop-blur-md shadow-xl"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-white">{{ t('vehicleDetail.sectionBasicSpecs') }}</h3>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-x-8 gap-y-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div v-for="spec in basicSpecs" :key="spec.key" class="flex flex-col">
|
||||
<span class="text-xs uppercase tracking-wider text-white/50">{{ spec.label }}</span>
|
||||
<span class="text-sm text-white/90">{{ spec.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Section 2: Engine & Drivetrain ═══ -->
|
||||
<div
|
||||
v-if="engineDrivetrainSpecs.length > 0"
|
||||
class="rounded-2xl border border-slate-700 bg-slate-900/70 p-6 backdrop-blur-md shadow-xl"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-white">{{ t('vehicleDetail.sectionEngineDrivetrain') }}</h3>
|
||||
<!-- ── LE/KW Display Toggle ── -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[10px] font-semibold text-white/50 uppercase tracking-wider">{{ t('vehicleDetail.powerUnitLabel') }}</span>
|
||||
<button
|
||||
type="button"
|
||||
@click="togglePowerUnit"
|
||||
class="relative inline-flex h-6 w-10 items-center rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-sf-accent/30"
|
||||
:class="powerUnit === 'kw' ? 'bg-sf-accent' : 'bg-amber-500'"
|
||||
>
|
||||
<span
|
||||
class="inline-flex h-4 w-4 items-center rounded-full bg-white text-[8px] font-bold shadow-sm transition-all duration-200"
|
||||
:class="powerUnit === 'kw' ? 'translate-x-0.5 justify-center' : 'translate-x-5 justify-end pr-0.5'"
|
||||
>
|
||||
{{ powerUnit === 'kw' ? 'kW' : 'LE' }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-x-8 gap-y-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div v-for="spec in engineDrivetrainSpecs" :key="spec.key" class="flex flex-col">
|
||||
<span class="text-xs uppercase tracking-wider text-white/50">{{ spec.label }}</span>
|
||||
<span class="text-sm text-white/90">{{ spec.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Section 3: Body & Dimensions ═══ -->
|
||||
<div
|
||||
v-if="bodyDimensionSpecs.length > 0"
|
||||
class="rounded-2xl border border-slate-700 bg-slate-900/70 p-6 backdrop-blur-md shadow-xl"
|
||||
>
|
||||
<h3 class="mb-4 text-lg font-semibold text-white">{{ t('vehicleDetail.sectionBodyDimensions') }}</h3>
|
||||
<div class="grid grid-cols-1 gap-x-8 gap-y-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div v-for="spec in bodyDimensionSpecs" :key="spec.key" class="flex flex-col">
|
||||
<span class="text-xs uppercase tracking-wider text-white/50">{{ spec.label }}</span>
|
||||
<span class="text-sm text-white/90">{{ spec.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Section 4: EV Specs (conditional) ═══ -->
|
||||
<div
|
||||
v-if="evSpecs.length > 0"
|
||||
class="rounded-2xl border border-slate-700 bg-slate-900/70 p-6 backdrop-blur-md shadow-xl"
|
||||
>
|
||||
<h3 class="mb-4 text-lg font-semibold text-white">{{ t('vehicleDetail.sectionEvSpecs') }}</h3>
|
||||
<div class="grid grid-cols-1 gap-x-8 gap-y-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div v-for="spec in evSpecs" :key="spec.key" class="flex flex-col">
|
||||
<span class="text-xs uppercase tracking-wider text-white/50">{{ spec.label }}</span>
|
||||
<span class="text-sm text-white/90">{{ spec.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Section 5: Features / Extras (conditional) ═══ -->
|
||||
<div
|
||||
v-if="featuresList.length > 0"
|
||||
class="rounded-2xl border border-slate-700 bg-slate-900/70 p-6 backdrop-blur-md shadow-xl"
|
||||
>
|
||||
<h3 class="mb-4 text-lg font-semibold text-white">{{ t('vehicleDetail.sectionFeatures') }}</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="feat in featuresList"
|
||||
:key="feat"
|
||||
class="px-3 py-1 text-sm bg-slate-800 border border-slate-600 rounded-full text-slate-300"
|
||||
>
|
||||
{{ getFeatureTranslation(feat) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { Vehicle } from '../../../stores/vehicle'
|
||||
|
||||
const { t, te } = useI18n()
|
||||
|
||||
// ── Inject vehicle from parent ──
|
||||
const vehicle = inject<computed<Vehicle | null>>('vehicle')
|
||||
|
||||
// ── Power Unit Toggle (LE / KW) for display ──
|
||||
const powerUnit = ref<'kw' | 'le'>('kw')
|
||||
|
||||
function togglePowerUnit() {
|
||||
powerUnit.value = powerUnit.value === 'kw' ? 'le' : 'kw'
|
||||
}
|
||||
|
||||
/** Convert kW to LE (1 kW = 1.34102 HP/LE) */
|
||||
function kwToLe(kw: number): number {
|
||||
return Math.round(kw * 1.34102)
|
||||
}
|
||||
|
||||
/** Display power value based on selected unit */
|
||||
function powerDisplay(kw: number | null | undefined): string {
|
||||
if (kw === null || kw === undefined) return '—'
|
||||
if (powerUnit.value === 'kw') return `${kw} kW`
|
||||
return `${kwToLe(kw)} LE`
|
||||
}
|
||||
|
||||
// ── Helper: display value or dash ──
|
||||
function val(value: unknown): string {
|
||||
if (value === null || value === undefined || value === '') return '—'
|
||||
return String(value)
|
||||
}
|
||||
|
||||
// ── Helper: translate fuel type ──
|
||||
function fuelLabel(fuel: string | null | undefined): string {
|
||||
if (!fuel) return '—'
|
||||
const key = `vehicle.fuelTypes.${fuel}`
|
||||
const translated = t(key)
|
||||
return translated !== key ? translated : fuel
|
||||
}
|
||||
|
||||
// ── Helper: translate body type ──
|
||||
function bodyTypeLabel(bodyType: string | null | undefined): string {
|
||||
if (!bodyType) return '—'
|
||||
const key = `vehicle.bodyTypes.${bodyType}`
|
||||
const translated = t(key)
|
||||
return translated !== key ? translated : bodyType
|
||||
}
|
||||
|
||||
// ── Helper: translate transmission type ──
|
||||
function transmissionLabel(transmission: string | null | undefined): string {
|
||||
if (!transmission) return '—'
|
||||
const key = `vehicle.transmissionTypes.${transmission}`
|
||||
const translated = t(key)
|
||||
return translated !== key ? translated : transmission
|
||||
}
|
||||
|
||||
// ── Helper: translate drive type ──
|
||||
function driveLabel(drive: string | null | undefined): string {
|
||||
if (!drive) return '—'
|
||||
const key = `vehicle.driveTypes.${drive}`
|
||||
const translated = t(key)
|
||||
return translated !== key ? translated : drive
|
||||
}
|
||||
|
||||
// ── Helper: translate cylinder layout ──
|
||||
function cylinderLabel(layout: string | null | undefined): string {
|
||||
if (!layout) return '—'
|
||||
const key = `vehicle.cylinderLayouts.${layout}`
|
||||
const translated = t(key)
|
||||
return translated !== key ? translated : layout
|
||||
}
|
||||
|
||||
// ── Helper: translate AC type ──
|
||||
function acLabel(acType: string | null | undefined): string {
|
||||
if (!acType) return '—'
|
||||
const key = `vehicle.acTypes.${acType}`
|
||||
const translated = t(key)
|
||||
return translated !== key ? translated : acType
|
||||
}
|
||||
|
||||
/**
|
||||
* i18n feature fallback: tries to translate a feature key with category-specific
|
||||
* fallback support. If neither `vehicle.features.{feat}` nor
|
||||
* `vehicle.{category}Features.{feat}` exists, returns the raw key.
|
||||
*/
|
||||
function getFeatureTranslation(feat: string): string {
|
||||
if (te(`vehicle.features.${feat}`)) return t(`vehicle.features.${feat}`)
|
||||
// Attempt category-specific fallback (e.g. vehicle.safetyFeatures.ABS)
|
||||
const category = vehicle?.value?.individual_equipment?.car_specs?.body_type || ''
|
||||
if (category && te(`vehicle.${category}Features.${feat}`)) {
|
||||
return t(`vehicle.${category}Features.${feat}`)
|
||||
}
|
||||
return feat
|
||||
}
|
||||
|
||||
// ── Section 1: Basic Specs ──
|
||||
interface SpecItem {
|
||||
key: string
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
const basicSpecs = computed<SpecItem[]>(() => {
|
||||
const v = vehicle?.value
|
||||
return [
|
||||
{ key: 'brand', label: t('vehicle.label_brand'), value: val(v?.brand) },
|
||||
{ key: 'model', label: t('vehicle.label_model'), value: val(v?.model) },
|
||||
{ key: 'year', label: t('vehicle.label_year'), value: val(v?.year_of_manufacture) },
|
||||
{ key: 'vin', label: t('common.vin'), value: val(v?.vin) },
|
||||
{ key: 'class', label: t('vehicleDetail.vehicleClass'), value: val(v?.vehicle_class) },
|
||||
{ key: 'trim', label: t('vehicleDetail.trimLevel'), value: val(v?.trim_level) },
|
||||
].filter(item => item.value && item.value !== '—' && item.value !== '-')
|
||||
})
|
||||
|
||||
// ── Section 2: Engine & Drivetrain ──
|
||||
const engineDrivetrainSpecs = computed<SpecItem[]>(() => {
|
||||
const v = vehicle?.value
|
||||
return [
|
||||
{ key: 'fuel', label: t('vehicleDetail.fuelType'), value: fuelLabel(v?.fuel_type) },
|
||||
{ key: 'capacity', label: t('vehicleDetail.engineCapacity'), value: val(v?.engine_capacity) },
|
||||
{
|
||||
key: 'power',
|
||||
label: powerUnit.value === 'kw' ? t('vehicleDetail.powerKw') : t('vehicleDetail.powerLe'),
|
||||
value: powerDisplay(v?.power_kw),
|
||||
},
|
||||
{ key: 'torque', label: t('vehicleDetail.torqueNm'), value: val(v?.torque_nm) },
|
||||
{ key: 'transmission', label: t('vehicleDetail.transmission'), value: transmissionLabel(v?.transmission_type) },
|
||||
{ key: 'drive', label: t('vehicleDetail.driveType'), value: driveLabel(v?.drive_type) },
|
||||
{ key: 'euroClass', label: t('vehicleDetail.euroClass'), value: val(v?.euro_classification) },
|
||||
].filter(item => item.value && item.value !== '—' && item.value !== '-')
|
||||
})
|
||||
|
||||
// ── Section 3: Body & Dimensions ──
|
||||
const bodyDimensionSpecs = computed<SpecItem[]>(() => {
|
||||
const v = vehicle?.value
|
||||
const carSpecs = v?.individual_equipment?.car_specs
|
||||
// Prefer top-level columns, fall back to individual_equipment.car_specs
|
||||
const bodyType = carSpecs?.body_type || v?.vehicle_class
|
||||
const doorCount = v?.door_count ?? carSpecs?.door_count
|
||||
const seatCount = v?.seat_count ?? carSpecs?.seat_count
|
||||
const cylinderLayout = v?.cylinder_layout ?? carSpecs?.cylinder_layout
|
||||
return [
|
||||
{ key: 'bodyType', label: t('vehicleDetail.bodyType'), value: bodyTypeLabel(bodyType) },
|
||||
{ key: 'doors', label: t('vehicleDetail.doors'), value: val(doorCount) },
|
||||
{ key: 'seats', label: t('vehicleDetail.seats'), value: val(seatCount) },
|
||||
{ key: 'cylinderLayout', label: t('vehicleDetail.cylinderLayout'), value: cylinderLabel(cylinderLayout) },
|
||||
{ key: 'acType', label: t('vehicleDetail.acType'), value: acLabel(carSpecs?.ac_type) },
|
||||
{ key: 'curbWeight', label: t('vehicleDetail.curbWeight'), value: val(v?.curb_weight) },
|
||||
{ key: 'maxWeight', label: t('vehicleDetail.maxWeight'), value: val(v?.max_weight) },
|
||||
{ key: 'mileage', label: t('vehicleDetail.mileage'), value: val(v?.current_mileage) },
|
||||
{ key: 'condition', label: t('vehicleDetail.conditionScore'), value: val(v?.condition_score) },
|
||||
].filter(item => item.value && item.value !== '—' && item.value !== '-')
|
||||
})
|
||||
|
||||
// ── Section 4: EV Specs (conditional) ──
|
||||
const evSpecs = computed<SpecItem[]>(() => {
|
||||
const ev = vehicle?.value?.individual_equipment?.ev_specs
|
||||
if (!ev) return []
|
||||
return [
|
||||
{ key: 'battery', label: t('vehicleDetail.batteryCapacity'), value: val(ev.battery_capacity_kwh) },
|
||||
{ key: 'range', label: t('vehicleDetail.range'), value: val(ev.range_wltp) },
|
||||
{ key: 'acConnector', label: t('vehicleDetail.acConnector'), value: val(ev.ac_connector) },
|
||||
{ key: 'dcConnector', label: t('vehicleDetail.dcConnector'), value: val(ev.dc_connector) },
|
||||
].filter(item => item.value && item.value !== '—' && item.value !== '-')
|
||||
})
|
||||
|
||||
// ── Section 5: Features / Extras ──
|
||||
const featuresList = computed<string[]>(() => {
|
||||
const v = vehicle?.value
|
||||
// Features can be at individual_equipment.features (top-level array)
|
||||
// or nested under individual_equipment.car_specs.features
|
||||
const ie = v?.individual_equipment
|
||||
const features = ie?.features || ie?.car_specs?.features
|
||||
if (!features || !Array.isArray(features) || features.length === 0) return []
|
||||
return features
|
||||
})
|
||||
</script>
|
||||
16
frontend_app/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE: string
|
||||
// more env variables...
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
83
frontend_app/src/i18n/cz.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
export default {
|
||||
menu: {
|
||||
features: 'Funkce',
|
||||
garage: 'Vozidla',
|
||||
calendar: 'Servisní kalendář',
|
||||
finance: 'Finance & Náklady',
|
||||
diagnostic: 'Živá diagnostika',
|
||||
addVehicle: '+ Přidat nové vozidlo',
|
||||
},
|
||||
header: {
|
||||
welcome: 'Vítejte',
|
||||
userMenu: 'Uživatelské menu',
|
||||
profile: 'Nastavení profilu',
|
||||
logout: 'Odhlásit se',
|
||||
subtitle: 'Váš dashboard a přehled vozidel',
|
||||
switchLanguage: 'Přepnout jazyk',
|
||||
},
|
||||
dashboard: {
|
||||
loading: 'Načítání...',
|
||||
errorRetry: 'Opakovat',
|
||||
statusActive: 'Aktivní',
|
||||
statusUnknown: 'Neznámý',
|
||||
condition: 'Stav',
|
||||
mileage: 'km',
|
||||
// Bento Box layout
|
||||
myVehicles: 'Moje Vozidla',
|
||||
vehicles: 'vozidel',
|
||||
noVehicles: 'Zatím žádná vozidla. Přidejte své první!',
|
||||
activeProcesses: 'Aktivní Procesy / Oznámení',
|
||||
noNotifications: 'Vše v pořádku — žádná oznámení',
|
||||
totalVehicles: 'Celkem Vozidel',
|
||||
addVehicle: '+ Přidat Vozidlo',
|
||||
refresh: 'Obnovit',
|
||||
statsAndPoints: 'Statistiky & Body',
|
||||
live: 'ŽIVĚ',
|
||||
monthlyScore: 'Měsíční Skóre',
|
||||
totalKm: 'Celková Vzdálenost',
|
||||
fuelCost: 'Náklady na Palivo',
|
||||
serviceCost: 'Náklady na Servis',
|
||||
co2Savings: 'Úspora CO₂',
|
||||
achievements: 'Úspěchy',
|
||||
},
|
||||
zen: {
|
||||
hide: 'Skrýt UI (Zen režim)',
|
||||
show: 'Zobrazit UI',
|
||||
},
|
||||
auth: {
|
||||
invalid_credentials: 'Neplatný email nebo heslo.',
|
||||
user_inactive: 'Váš účet ještě není aktivován. Prosím ověřte svůj email!',
|
||||
forgotPasswordMessage: 'Funkce obnovení hesla je ve vývoji. Prosím kontaktujte administrátora!',
|
||||
resendTitle: 'Znovu odeslat aktivační email',
|
||||
resendDescription: 'Zadejte svou emailovou adresu a znovu zašleme aktivační odkaz.',
|
||||
resendButton: 'Odeslat email',
|
||||
resend_cooldown: 'Odeslat znovu ({seconds}s)',
|
||||
send_email: 'Odeslat email',
|
||||
resendSuccess: 'Aktivační email byl odeslán! Zkontrolujte své složky (včetně Spamu).',
|
||||
resendLink: 'Nedostali jste aktivační email?',
|
||||
backToLogin: 'Zpět na přihlášení',
|
||||
loginTitle: 'Přihlášení do garáže',
|
||||
loginButton: 'Přihlásit se',
|
||||
loginLoading: 'Prosím čekejte...',
|
||||
forgotPassword: 'Zapomněli jste heslo?',
|
||||
rememberMe: 'Zapamatovat si mě',
|
||||
noAccount: 'Nemáte ještě garáž?',
|
||||
registerLink: 'Registrovat',
|
||||
registerTitle: 'Otevřít novou garáž',
|
||||
registerButton: 'Registrovat',
|
||||
haveAccount: 'Již máte garáž?',
|
||||
loginLink: 'Přihlásit se',
|
||||
forgotTitle: 'Požádat o nové heslo',
|
||||
forgotDescription: 'Zadejte svou emailovou adresu a zašleme vám bezpečnostní odkaz.',
|
||||
forgotSuccess: 'Odkaz pro obnovení byl odeslán na váš email!',
|
||||
forgotSendButton: 'Odeslat odkaz',
|
||||
inactiveMessage: 'Váš účet ještě není aktivován! Aktivujte jej pomocí odkazu v emailu nebo požádejte o nový aktivační email.',
|
||||
resendActivationButton: 'Znovu odeslat aktivační email',
|
||||
resendActivationSuccess: 'Aktivační email byl znovu odeslán!',
|
||||
socialDivider: 'Nebo se přihlaste pomocí sociálního účtu',
|
||||
loginFailed: 'Přihlášení selhalo. Zkontrolujte své přihlašovací údaje.',
|
||||
registerFailed: 'Registrace selhala.',
|
||||
resendFailed: 'Odeslání selhalo. Prosím zkuste to znovu později.',
|
||||
forgotFailed: 'Při odesílání požadavku došlo k chybě. Prosím zkuste to znovu.',
|
||||
},
|
||||
}
|
||||
83
frontend_app/src/i18n/de.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
export default {
|
||||
menu: {
|
||||
features: 'Funktionen',
|
||||
garage: 'Fahrzeuge',
|
||||
calendar: 'Servicekalender',
|
||||
finance: 'Finanzen & Kosten',
|
||||
diagnostic: 'Live-Diagnose',
|
||||
addVehicle: '+ Neues Fahrzeug hinzufügen',
|
||||
},
|
||||
header: {
|
||||
welcome: 'Willkommen',
|
||||
userMenu: 'Benutzermenü',
|
||||
profile: 'Profileinstellungen',
|
||||
logout: 'Abmelden',
|
||||
subtitle: 'Ihr Dashboard und Fahrzeugübersicht',
|
||||
switchLanguage: 'Sprache wechseln',
|
||||
},
|
||||
dashboard: {
|
||||
loading: 'Wird geladen...',
|
||||
errorRetry: 'Wiederholen',
|
||||
statusActive: 'Aktiv',
|
||||
statusUnknown: 'Unbekannt',
|
||||
condition: 'Zustand',
|
||||
mileage: 'km',
|
||||
// Bento Box layout
|
||||
myVehicles: 'Meine Fahrzeuge',
|
||||
vehicles: 'Fahrzeuge',
|
||||
noVehicles: 'Noch keine Fahrzeuge. Füge dein erstes hinzu!',
|
||||
activeProcesses: 'Aktive Prozesse / Benachrichtigungen',
|
||||
noNotifications: 'Alles klar — keine Benachrichtigungen',
|
||||
totalVehicles: 'Gesamtfahrzeuge',
|
||||
addVehicle: '+ Fahrzeug hinzufügen',
|
||||
refresh: 'Aktualisieren',
|
||||
statsAndPoints: 'Statistiken & Punkte',
|
||||
live: 'LIVE',
|
||||
monthlyScore: 'Monatliche Punktzahl',
|
||||
totalKm: 'Gesamtstrecke',
|
||||
fuelCost: 'Kraftstoffkosten',
|
||||
serviceCost: 'Servicekosten',
|
||||
co2Savings: 'CO₂-Einsparungen',
|
||||
achievements: 'Errungenschaften',
|
||||
},
|
||||
zen: {
|
||||
hide: 'UI ausblenden (Zen-Modus)',
|
||||
show: 'UI anzeigen',
|
||||
},
|
||||
auth: {
|
||||
invalid_credentials: 'Ungültige E-Mail oder Passwort.',
|
||||
user_inactive: 'Ihr Konto ist noch nicht aktiviert. Bitte bestätigen Sie Ihre E-Mail!',
|
||||
forgotPasswordMessage: 'Die Passwort-Zurücksetzen-Funktion befindet sich in Entwicklung. Bitte kontaktieren Sie den Administrator!',
|
||||
resendTitle: 'Aktivierungs-E-Mail erneut senden',
|
||||
resendDescription: 'Geben Sie Ihre E-Mail-Adresse ein und wir senden den Aktivierungslink erneut.',
|
||||
resendButton: 'E-Mail senden',
|
||||
resend_cooldown: 'Erneut senden ({seconds}s)',
|
||||
send_email: 'E-Mail senden',
|
||||
resendSuccess: 'Die Aktivierungs-E-Mail wurde gesendet! Überprüfen Sie Ihre Ordner (auch Spam).',
|
||||
resendLink: 'Haben Sie die Aktivierungs-E-Mail nicht erhalten?',
|
||||
backToLogin: 'Zurück zum Login',
|
||||
loginTitle: 'Anmelden zur Garage',
|
||||
loginButton: 'Anmelden',
|
||||
loginLoading: 'Bitte warten...',
|
||||
forgotPassword: 'Passwort vergessen?',
|
||||
rememberMe: 'Angemeldet bleiben',
|
||||
noAccount: 'Noch keine Garage?',
|
||||
registerLink: 'Registrieren',
|
||||
registerTitle: 'Neue Garage eröffnen',
|
||||
registerButton: 'Registrieren',
|
||||
haveAccount: 'Bereits eine Garage?',
|
||||
loginLink: 'Anmelden',
|
||||
forgotTitle: 'Neues Passwort anfordern',
|
||||
forgotDescription: 'Geben Sie Ihre E-Mail-Adresse ein und wir senden Ihnen einen Sicherheitslink.',
|
||||
forgotSuccess: 'Der Zurücksetzungslink wurde an Ihre E-Mail gesendet!',
|
||||
forgotSendButton: 'Link senden',
|
||||
inactiveMessage: 'Ihr Konto ist noch nicht aktiviert! Bitte aktivieren Sie es mit dem Link in der E-Mail oder fordern Sie eine neue Aktivierungs-E-Mail an.',
|
||||
resendActivationButton: 'Aktivierungs-E-Mail erneut senden',
|
||||
resendActivationSuccess: 'Die Aktivierungs-E-Mail wurde erneut gesendet!',
|
||||
socialDivider: 'Oder mit Social-Konto anmelden',
|
||||
loginFailed: 'Anmeldung fehlgeschlagen. Bitte überprüfen Sie Ihre Anmeldedaten.',
|
||||
registerFailed: 'Registrierung fehlgeschlagen.',
|
||||
resendFailed: 'Erneutes Senden fehlgeschlagen. Bitte versuchen Sie es später erneut.',
|
||||
forgotFailed: 'Beim Senden der Anfrage ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.',
|
||||
},
|
||||
}
|
||||
1674
frontend_app/src/i18n/en.ts
Normal file
1672
frontend_app/src/i18n/hu.ts
Normal file
83
frontend_app/src/i18n/ro.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
export default {
|
||||
menu: {
|
||||
features: 'Funcționalități',
|
||||
garage: 'Vehicule',
|
||||
calendar: 'Calendar Service',
|
||||
finance: 'Finanțe & Costuri',
|
||||
diagnostic: 'Diagnosticare Live',
|
||||
addVehicle: '+ Adăugați Vehicul Nou',
|
||||
},
|
||||
header: {
|
||||
welcome: 'Bun venit',
|
||||
userMenu: 'Meniul utilizatorului',
|
||||
profile: 'Setări Profil',
|
||||
logout: 'Deconectare',
|
||||
subtitle: 'Tabloul de bord și prezentarea vehiculelor',
|
||||
switchLanguage: 'Schimbă limba',
|
||||
},
|
||||
dashboard: {
|
||||
loading: 'Se încarcă...',
|
||||
errorRetry: 'Reîncercare',
|
||||
statusActive: 'Activ',
|
||||
statusUnknown: 'Necunoscut',
|
||||
condition: 'Stare',
|
||||
mileage: 'km',
|
||||
// Bento Box layout
|
||||
myVehicles: 'Vehiculele Mele',
|
||||
vehicles: 'vehicule',
|
||||
noVehicles: 'Încă nu aveți vehicule. Adăugați primul!',
|
||||
activeProcesses: 'Procese Active / Notificări',
|
||||
noNotifications: 'Totul în regulă — nicio notificare',
|
||||
totalVehicles: 'Total Vehicule',
|
||||
addVehicle: '+ Adăugați Vehicul',
|
||||
refresh: 'Reîmprospătare',
|
||||
statsAndPoints: 'Statistici & Puncte',
|
||||
live: 'LIVE',
|
||||
monthlyScore: 'Scor Lunar',
|
||||
totalKm: 'Distanță Totală',
|
||||
fuelCost: 'Cost Combustibil',
|
||||
serviceCost: 'Cost Service',
|
||||
co2Savings: 'Economii CO₂',
|
||||
achievements: 'Realizări',
|
||||
},
|
||||
zen: {
|
||||
hide: 'Ascunde UI (Mod Zen)',
|
||||
show: 'Arată UI',
|
||||
},
|
||||
auth: {
|
||||
invalid_credentials: 'Email sau parolă invalidă.',
|
||||
user_inactive: 'Contul dumneavoastră nu este încă activat. Vă rugăm să verificați emailul!',
|
||||
forgotPasswordMessage: 'Funcția de resetare a parolei este în dezvoltare. Vă rugăm să contactați administratorul!',
|
||||
resendTitle: 'Retrimiteți Emailul de Activare',
|
||||
resendDescription: 'Introduceți adresa de email și vom retrimite linkul de activare.',
|
||||
resendButton: 'Trimiteți Email',
|
||||
resend_cooldown: 'Retrimiteți ({seconds}s)',
|
||||
send_email: 'Trimiteți Email',
|
||||
resendSuccess: 'Emailul de activare a fost trimis! Verificați dosarele (inclusiv Spam).',
|
||||
resendLink: 'Nu ați primit emailul de activare?',
|
||||
backToLogin: 'Înapoi la Autentificare',
|
||||
loginTitle: 'Autentificare în Garaj',
|
||||
loginButton: 'Autentificare',
|
||||
loginLoading: 'Vă rugăm așteptați...',
|
||||
forgotPassword: 'Ați uitat parola?',
|
||||
rememberMe: 'Ține-mă minte',
|
||||
noAccount: 'Nu aveți încă un garaj?',
|
||||
registerLink: 'Înregistrare',
|
||||
registerTitle: 'Deschideți un Garaj Nou',
|
||||
registerButton: 'Înregistrare',
|
||||
haveAccount: 'Aveți deja un garaj?',
|
||||
loginLink: 'Autentificare',
|
||||
forgotTitle: 'Solicitați o Parolă Nouă',
|
||||
forgotDescription: 'Introduceți adresa de email și vă vom trimite un link de securitate.',
|
||||
forgotSuccess: 'Linkul de resetare a fost trimis pe email!',
|
||||
forgotSendButton: 'Trimiteți Linkul',
|
||||
inactiveMessage: 'Contul dumneavoastră nu este încă activat! Activați-l folosind linkul din email sau solicitați un nou email de activare.',
|
||||
resendActivationButton: 'Retrimiteți Emailul de Activare',
|
||||
resendActivationSuccess: 'Emailul de activare a fost retrimis!',
|
||||
socialDivider: 'Sau autentificați-vă cu un cont Social',
|
||||
loginFailed: 'Autentificare eșuată. Verificați datele de autentificare.',
|
||||
registerFailed: 'Înregistrare eșuată.',
|
||||
resendFailed: 'Retrimiterea a eșuat. Vă rugăm încercați din nou mai târziu.',
|
||||
forgotFailed: 'A apărut o eroare la trimiterea cererii. Vă rugăm încercați din nou.',
|
||||
},
|
||||
}
|
||||
83
frontend_app/src/i18n/sk.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
export default {
|
||||
menu: {
|
||||
features: 'Funkcie',
|
||||
garage: 'Vozidlá',
|
||||
calendar: 'Servisný kalendár',
|
||||
finance: 'Financie & Náklady',
|
||||
diagnostic: 'Živá diagnostika',
|
||||
addVehicle: '+ Pridať nové vozidlo',
|
||||
},
|
||||
header: {
|
||||
welcome: 'Vitajte',
|
||||
userMenu: 'Používateľské menu',
|
||||
profile: 'Nastavenia profilu',
|
||||
logout: 'Odhlásiť sa',
|
||||
subtitle: 'Váš dashboard a prehľad vozidiel',
|
||||
switchLanguage: 'Prepnúť jazyk',
|
||||
},
|
||||
dashboard: {
|
||||
loading: 'Načítava sa...',
|
||||
errorRetry: 'Opakovať',
|
||||
statusActive: 'Aktívny',
|
||||
statusUnknown: 'Neznámy',
|
||||
condition: 'Stav',
|
||||
mileage: 'km',
|
||||
// Bento Box layout
|
||||
myVehicles: 'Moje Vozidlá',
|
||||
vehicles: 'vozidiel',
|
||||
noVehicles: 'Zatiaľ žiadne vozidlá. Pridajte svoje prvé!',
|
||||
activeProcesses: 'Aktívne Procesy / Notifikácie',
|
||||
noNotifications: 'Všetko v poriadku — žiadne notifikácie',
|
||||
totalVehicles: 'Celkový Počet Vozidiel',
|
||||
addVehicle: '+ Pridať Vozidlo',
|
||||
refresh: 'Obnoviť',
|
||||
statsAndPoints: 'Štatistiky & Body',
|
||||
live: 'ŽIVĚ',
|
||||
monthlyScore: 'Mesačné Skóre',
|
||||
totalKm: 'Celková Vzdialenosť',
|
||||
fuelCost: 'Náklady na Palivo',
|
||||
serviceCost: 'Náklady na Servis',
|
||||
co2Savings: 'Úspora CO₂',
|
||||
achievements: 'Úspechy',
|
||||
},
|
||||
zen: {
|
||||
hide: 'Skryť UI (Zen režim)',
|
||||
show: 'Zobraziť UI',
|
||||
},
|
||||
auth: {
|
||||
invalid_credentials: 'Neplatný email alebo heslo.',
|
||||
user_inactive: 'Váš účet ešte nie je aktivovaný. Prosím overte svoj email!',
|
||||
forgotPasswordMessage: 'Funkcia obnovenia hesla je vo vývoji. Prosím kontaktujte administrátora!',
|
||||
resendTitle: 'Znova odoslať aktivačný email',
|
||||
resendDescription: 'Zadajte svoju emailovú adresu a znova pošleme aktivačný odkaz.',
|
||||
resendButton: 'Odoslať email',
|
||||
resend_cooldown: 'Odoslať znova ({seconds}s)',
|
||||
send_email: 'Odoslať email',
|
||||
resendSuccess: 'Aktivačný email bol odoslaný! Skontrolujte svoje priečinky (vrátane Spamu).',
|
||||
resendLink: 'Nedostali ste aktivačný email?',
|
||||
backToLogin: 'Späť na prihlásenie',
|
||||
loginTitle: 'Prihlásenie do garáže',
|
||||
loginButton: 'Prihlásiť sa',
|
||||
loginLoading: 'Prosím čakajte...',
|
||||
forgotPassword: 'Zabudli ste heslo?',
|
||||
rememberMe: 'Zapamätať si ma',
|
||||
noAccount: 'Ešte nemáte garáž?',
|
||||
registerLink: 'Registrovať',
|
||||
registerTitle: 'Otvoriť novú garáž',
|
||||
registerButton: 'Registrovať',
|
||||
haveAccount: 'Už máte garáž?',
|
||||
loginLink: 'Prihlásiť sa',
|
||||
forgotTitle: 'Požiadať o nové heslo',
|
||||
forgotDescription: 'Zadajte svoju emailovú adresu a pošleme vám bezpečnostný odkaz.',
|
||||
forgotSuccess: 'Odkaz na obnovenie bol odoslaný na váš email!',
|
||||
forgotSendButton: 'Odoslať odkaz',
|
||||
inactiveMessage: 'Váš účet ešte nie je aktivovaný! Aktivujte ho pomocou odkazu v emaile alebo požiadajte o nový aktivačný email.',
|
||||
resendActivationButton: 'Znova odoslať aktivačný email',
|
||||
resendActivationSuccess: 'Aktivačný email bol znovu odoslaný!',
|
||||
socialDivider: 'Alebo sa prihláste pomocou sociálneho účtu',
|
||||
loginFailed: 'Prihlásenie zlyhalo. Skontrolujte svoje prihlasovacie údaje.',
|
||||
registerFailed: 'Registrácia zlyhala.',
|
||||
resendFailed: 'Odoslanie zlyhalo. Prosím skúste to znova neskôr.',
|
||||
forgotFailed: 'Pri odosielaní požiadavky došlo k chybe. Prosím skúste to znova.',
|
||||
},
|
||||
}
|
||||
200
frontend_app/src/layouts/AdminLayout.vue
Normal file
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<div class="admin-layout min-h-screen bg-gray-900 text-gray-100">
|
||||
<!-- Sidebar -->
|
||||
<aside class="fixed inset-y-0 left-0 w-64 bg-gray-800 border-r border-gray-700 z-50">
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Logo -->
|
||||
<div class="p-6 border-b border-gray-700">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center">
|
||||
<span class="text-xl font-bold">SF</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xl font-bold">Service Finder</div>
|
||||
<div class="text-xs text-gray-400">Admin Panel</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex-1 p-4 space-y-2">
|
||||
<router-link
|
||||
to="/admin"
|
||||
class="flex items-center space-x-3 px-4 py-3 rounded-lg hover:bg-gray-700 transition-colors"
|
||||
:class="{ 'bg-blue-900/50 hover:bg-blue-900/70': $route.path === '/admin' }"
|
||||
>
|
||||
<span class="text-xl">📊</span>
|
||||
<span class="font-medium">Dashboard</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/admin/users"
|
||||
class="flex items-center space-x-3 px-4 py-3 rounded-lg hover:bg-gray-700 transition-colors"
|
||||
:class="{ 'bg-blue-900/50 hover:bg-blue-900/70': $route.path.startsWith('/admin/users') }"
|
||||
>
|
||||
<span class="text-xl">👥</span>
|
||||
<span class="font-medium">User Management</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/admin/packages"
|
||||
class="flex items-center space-x-3 px-4 py-3 rounded-lg hover:bg-gray-700 transition-colors"
|
||||
:class="{ 'bg-blue-900/50 hover:bg-blue-900/70': $route.path.startsWith('/admin/packages') }"
|
||||
>
|
||||
<span class="text-xl">📦</span>
|
||||
<span class="font-medium">Subscription Packages</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/admin/services"
|
||||
class="flex items-center space-x-3 px-4 py-3 rounded-lg hover:bg-gray-700 transition-colors"
|
||||
:class="{ 'bg-blue-900/50 hover:bg-blue-900/70': $route.path.startsWith('/admin/services') }"
|
||||
>
|
||||
<span class="text-xl">🛠️</span>
|
||||
<span class="font-medium">Services</span>
|
||||
</router-link>
|
||||
|
||||
<div class="pt-6 border-t border-gray-700">
|
||||
<div class="px-4 py-2 text-xs text-gray-500 uppercase tracking-wider">Navigation</div>
|
||||
<router-link
|
||||
to="/dashboard"
|
||||
class="flex items-center space-x-3 px-4 py-3 rounded-lg hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<span class="text-xl">🏠</span>
|
||||
<span class="font-medium">Back to Dashboard</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- User Profile -->
|
||||
<div class="p-4 border-t border-gray-700">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-gray-600 to-gray-800 flex items-center justify-center">
|
||||
<span class="text-lg">{{ userInitials }}</span>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium truncate">{{ userName }}</div>
|
||||
<div class="text-xs text-gray-400 capitalize">{{ userRoleLabel }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="pl-64">
|
||||
<!-- Top Bar -->
|
||||
<header class="sticky top-0 z-40 bg-gray-800/90 backdrop-blur-sm border-b border-gray-700">
|
||||
<div class="flex justify-between items-center px-8 py-4">
|
||||
<div class="flex items-center space-x-4">
|
||||
<h1 class="text-2xl font-bold">{{ pageTitle }}</h1>
|
||||
<div class="text-sm px-3 py-1 bg-gray-700 rounded-full">
|
||||
{{ currentTime }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<slot name="header-controls">
|
||||
<LanguageSwitcher />
|
||||
<ModeSwitcher />
|
||||
</slot>
|
||||
|
||||
<!-- HeaderProfile komponens: profil, admin center, kilépés -->
|
||||
<HeaderProfile />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Content Area -->
|
||||
<main class="p-8">
|
||||
<div class="bg-gray-800/50 rounded-xl border border-gray-700 p-6">
|
||||
<router-view />
|
||||
</div>
|
||||
|
||||
<!-- Footer Stats -->
|
||||
<div class="mt-8 grid grid-cols-4 gap-4">
|
||||
<div class="bg-gray-800/50 rounded-lg p-4 border border-gray-700">
|
||||
<div class="text-sm text-gray-400">System Status</div>
|
||||
<div class="text-2xl font-bold text-green-400">Operational</div>
|
||||
</div>
|
||||
<div class="bg-gray-800/50 rounded-lg p-4 border border-gray-700">
|
||||
<div class="text-sm text-gray-400">API Response</div>
|
||||
<div class="text-2xl font-bold">42ms</div>
|
||||
</div>
|
||||
<div class="bg-gray-800/50 rounded-lg p-4 border border-gray-700">
|
||||
<div class="text-sm text-gray-400">Active Sessions</div>
|
||||
<div class="text-2xl font-bold">127</div>
|
||||
</div>
|
||||
<div class="bg-gray-800/50 rounded-lg p-4 border border-gray-700">
|
||||
<div class="text-sm text-gray-400">Uptime</div>
|
||||
<div class="text-2xl font-bold">99.8%</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher.vue'
|
||||
import ModeSwitcher from '../components/ModeSwitcher.vue'
|
||||
import HeaderProfile from '../components/header/HeaderProfile.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const currentTime = ref('')
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
const name = route.name?.toString() || 'Dashboard'
|
||||
return name.charAt(0).toUpperCase() + name.slice(1)
|
||||
})
|
||||
|
||||
const userName = computed(() => {
|
||||
if (!authStore.user) return 'Admin User'
|
||||
const { first_name, last_name } = authStore.user
|
||||
if (first_name && last_name) return `${first_name} ${last_name}`
|
||||
return authStore.user.email || 'Admin User'
|
||||
})
|
||||
|
||||
const userInitials = computed(() => {
|
||||
if (!authStore.user) return 'AD'
|
||||
const { first_name, last_name } = authStore.user
|
||||
if (first_name && last_name) {
|
||||
return (first_name[0] + last_name[0]).toUpperCase()
|
||||
}
|
||||
return authStore.user.email?.[0]?.toUpperCase() || 'A'
|
||||
})
|
||||
|
||||
const userRoleLabel = computed(() => {
|
||||
return authStore.user?.role?.replace(/_/g, ' ') || 'Administrator'
|
||||
})
|
||||
|
||||
function updateTime() {
|
||||
const now = new Date()
|
||||
currentTime.value = now.toLocaleTimeString('en-US', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
let intervalId: number
|
||||
|
||||
onMounted(() => {
|
||||
updateTime()
|
||||
intervalId = setInterval(updateTime, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(intervalId)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-layout {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
</style>
|
||||
195
frontend_app/src/layouts/ConsumerLayout.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<div class="consumer-layout min-h-screen bg-sf-wall bg-sf-wall-pattern">
|
||||
<!-- Header (above decorations) -->
|
||||
<header class="sf-header-bg border-b border-sf-blue/20 shadow-lg z-20 relative">
|
||||
<div class="container mx-auto px-4 sm:px-6 py-4">
|
||||
<!-- Top row: Logo and controls -->
|
||||
<div class="flex justify-between items-center">
|
||||
<!-- Logo Section with actual logo image -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- Logo Image -->
|
||||
<div class="flex items-center">
|
||||
<div class="relative">
|
||||
<img src="/sf_logo_ok.png" alt="Service Finder Logo" class="h-12 w-auto object-contain mix-blend-multiply" />
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<div class="text-2xl font-bold">
|
||||
<span class="text-sf-blue">SERVICE</span>
|
||||
<span class="text-sf-green"> FINDER</span>
|
||||
</div>
|
||||
<!-- Slogan -->
|
||||
<div class="text-xs font-semibold text-sf-accent mt-1">
|
||||
ONLINE JÁRMŰNYILVÁNTARTÓ & SZERVIZKERESŐ
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Controls -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<slot name="header-controls">
|
||||
<LanguageSwitcher />
|
||||
<ModeSwitcher />
|
||||
</slot>
|
||||
|
||||
<!-- User Menu -->
|
||||
<div class="flex items-center space-x-3">
|
||||
<router-link to="/login" class="hidden sm:flex items-center space-x-2 px-4 py-2 bg-sf-blue text-white rounded-full hover:bg-sf-blue/90 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"></path>
|
||||
</svg>
|
||||
<span>Sign In</span>
|
||||
</router-link>
|
||||
<router-link to="/login" class="sm:hidden p-2 rounded-full bg-sf-blue text-white">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"></path>
|
||||
</svg>
|
||||
</router-link>
|
||||
|
||||
<!-- Notification Bell -->
|
||||
<button class="relative p-2 rounded-full hover:bg-gray-100 transition-colors">
|
||||
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path>
|
||||
</svg>
|
||||
<span class="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Decorative Background Elements (behind navbar, low z-index) -->
|
||||
<div class="fixed inset-0 pointer-events-none -z-10 overflow-hidden">
|
||||
<!-- Calendar moved lower on the "wall" -->
|
||||
<img src="/sf_naptar.png" alt="Calendar" class="absolute top-32 left-8 w-32 shadow-md opacity-80">
|
||||
<!-- Alarm light on right side, below navbar -->
|
||||
<img src="/sf_alarm_light_01.png" alt="Alarm Light" class="absolute top-32 right-8 w-24 pulse">
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="container mx-auto px-4 sm:px-6 py-8 relative z-10">
|
||||
<!-- Content Slot -->
|
||||
<div class="bg-white/80 backdrop-blur-sm rounded-3xl shadow-lg p-6 md:p-8 mb-8">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12">
|
||||
<div class="bg-white/90 rounded-2xl p-6 shadow-lg hover:shadow-xl transition-shadow border border-gray-100">
|
||||
<div class="w-12 h-12 bg-sf-blue/10 rounded-xl flex items-center justify-center mb-4">
|
||||
<svg class="w-6 h-6 text-sf-blue" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-2 text-gray-800">Vehicle Registration</h3>
|
||||
<p class="text-gray-600">Quickly register your vehicle and get personalized service recommendations.</p>
|
||||
</div>
|
||||
<div class="bg-white/90 rounded-2xl p-6 shadow-lg hover:shadow-xl transition-shadow border border-gray-100">
|
||||
<div class="w-12 h-12 bg-sf-green/10 rounded-xl flex items-center justify-center mb-4">
|
||||
<svg class="w-6 h-6 text-sf-green" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-2 text-gray-800">Service Booking</h3>
|
||||
<p class="text-gray-600">Book appointments with trusted service providers in your area.</p>
|
||||
</div>
|
||||
<div class="bg-white/90 rounded-2xl p-6 shadow-lg hover:shadow-xl transition-shadow border border-gray-100">
|
||||
<div class="w-12 h-12 bg-sf-teal/10 rounded-xl flex items-center justify-center mb-4">
|
||||
<svg class="w-6 h-6 text-sf-teal" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-2 text-gray-800">Cost Tracking</h3>
|
||||
<p class="text-gray-600">Track your vehicle's maintenance history and total cost of ownership.</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="mt-12 bg-white/90 backdrop-blur-sm border-t border-gray-200/50">
|
||||
<div class="container mx-auto px-4 sm:px-6 py-8">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<div>
|
||||
<div class="flex items-center space-x-3 mb-4">
|
||||
<div class="w-8 h-8 rounded-lg bg-sf-blue flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-sf-green" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-xl font-bold">
|
||||
<span class="text-sf-blue">Service</span>
|
||||
<span class="text-sf-green">Finder</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-gray-600 text-sm">Making vehicle maintenance simple, transparent, and affordable.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold mb-4 text-gray-800">For Consumers</h4>
|
||||
<ul class="space-y-2">
|
||||
<li><a href="#" class="text-gray-600 hover:text-sf-blue text-sm">Find Services</a></li>
|
||||
<li><a href="#" class="text-gray-600 hover:text-sf-blue text-sm">Vehicle Registration</a></li>
|
||||
<li><a href="#" class="text-gray-600 hover:text-sf-blue text-sm">Price Comparison</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold mb-4 text-gray-800">For Businesses</h4>
|
||||
<ul class="space-y-2">
|
||||
<li><a href="#" class="text-gray-600 hover:text-sf-green text-sm">Partner Program</a></li>
|
||||
<li><a href="#" class="text-gray-600 hover:text-sf-green text-sm">Business Dashboard</a></li>
|
||||
<li><a href="#" class="text-gray-600 hover:text-sf-green text-sm">API Access</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold mb-4 text-gray-800">Connect</h4>
|
||||
<ul class="space-y-2">
|
||||
<li><a href="#" class="text-gray-600 hover:text-sf-teal text-sm">Help Center</a></li>
|
||||
<li><a href="#" class="text-gray-600 hover:text-sf-teal text-sm">Contact Us</a></li>
|
||||
<li><a href="#" class="text-gray-600 hover:text-sf-teal text-sm">Privacy Policy</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-8 pt-8 border-t border-gray-200 text-center text-gray-500 text-sm">
|
||||
<p>© 2026 Service Finder. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher.vue'
|
||||
import ModeSwitcher from '../components/ModeSwitcher.vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.consumer-layout {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
/* Mobile background image */
|
||||
@media (max-width: 768px) {
|
||||
.consumer-layout {
|
||||
background: url('/sf_mobile_garage_1.png') no-repeat center center fixed;
|
||||
background-size: cover;
|
||||
}
|
||||
}
|
||||
|
||||
/* Pulse animation for alarm light */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.8;
|
||||
filter: drop-shadow(0 0 5px rgba(255, 0, 0, 0.5));
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
filter: drop-shadow(0 0 15px rgba(255, 0, 0, 0.9));
|
||||
}
|
||||
}
|
||||
|
||||
.pulse {
|
||||
animation: pulse 2s infinite ease-in-out;
|
||||
}
|
||||
</style>
|
||||
98
frontend_app/src/layouts/CorporateLayout.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<div class="corporate-layout min-h-screen bg-corporate-light">
|
||||
<!-- Header -->
|
||||
<header class="bg-white shadow-sm border-b border-gray-200">
|
||||
<div class="container mx-auto px-6 py-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center space-x-8">
|
||||
<div class="text-2xl font-bold text-corporate-blue">
|
||||
Service Finder <span class="text-sm font-normal text-gray-500">Corporate</span>
|
||||
</div>
|
||||
<nav class="hidden md:flex space-x-6">
|
||||
<a href="#" class="text-gray-700 hover:text-corporate-blue font-medium">Dashboard</a>
|
||||
<a href="#" class="text-gray-700 hover:text-corporate-blue font-medium">Fleet</a>
|
||||
<a href="#" class="text-gray-700 hover:text-corporate-blue font-medium">Analytics</a>
|
||||
<a href="#" class="text-gray-700 hover:text-corporate-blue font-medium">Billing</a>
|
||||
<a href="#" class="text-gray-700 hover:text-corporate-blue font-medium">Settings</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<slot name="header-controls">
|
||||
<LanguageSwitcher />
|
||||
<ModeSwitcher />
|
||||
</slot>
|
||||
<div class="w-10 h-10 rounded-full bg-corporate-blue text-white flex items-center justify-center">
|
||||
CO
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="container mx-auto px-6 py-8">
|
||||
<div class="grid grid-cols-12 gap-8">
|
||||
<!-- Sidebar -->
|
||||
<aside class="col-span-3">
|
||||
<div class="bg-white rounded-xl shadow-sm p-6">
|
||||
<h3 class="font-semibold text-gray-800 mb-4">Organization</h3>
|
||||
<ul class="space-y-2">
|
||||
<li><a href="#" class="block py-2 px-3 rounded-lg hover:bg-gray-100 text-gray-700">Overview</a></li>
|
||||
<li><a href="#" class="block py-2 px-3 rounded-lg hover:bg-gray-100 text-gray-700">Vehicles</a></li>
|
||||
<li><a href="#" class="block py-2 px-3 rounded-lg hover:bg-gray-100 text-gray-700">Drivers</a></li>
|
||||
<li><a href="#" class="block py-2 px-3 rounded-lg hover:bg-gray-100 text-gray-700">Maintenance</a></li>
|
||||
<li><a href="#" class="block py-2 px-3 rounded-lg hover:bg-gray-100 text-gray-700">Reports</a></li>
|
||||
</ul>
|
||||
<div class="mt-8 pt-6 border-t border-gray-200">
|
||||
<h3 class="font-semibold text-gray-800 mb-4">Quick Stats</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Active Vehicles</span>
|
||||
<span class="font-semibold">42</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Pending Services</span>
|
||||
<span class="font-semibold">7</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Monthly Cost</span>
|
||||
<span class="font-semibold">€12,450</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="col-span-9">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="bg-white border-t border-gray-200 mt-12">
|
||||
<div class="container mx-auto px-6 py-6">
|
||||
<div class="flex justify-between items-center text-sm text-gray-500">
|
||||
<div>© 2026 Service Finder Corporate. All rights reserved.</div>
|
||||
<div class="flex space-x-6">
|
||||
<a href="#" class="hover:text-gray-800">Privacy Policy</a>
|
||||
<a href="#" class="hover:text-gray-800">Terms of Service</a>
|
||||
<a href="#" class="hover:text-gray-800">Support</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher.vue'
|
||||
import ModeSwitcher from '../components/ModeSwitcher.vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.corporate-layout {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
</style>
|
||||
206
frontend_app/src/layouts/OrganizationLayout.vue
Normal file
@@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<div class="organization-layout min-h-screen text-white">
|
||||
<!-- Modular header with BaseHeader + Lego components -->
|
||||
<BaseHeader>
|
||||
<template #left>
|
||||
<HeaderLogo />
|
||||
</template>
|
||||
|
||||
<template #center>
|
||||
<div class="font-semibold text-lg text-white tracking-wide">{{ orgDisplayName }} garázsa</div>
|
||||
</template>
|
||||
|
||||
<template #right>
|
||||
<!-- Hamburger Menu (Company Settings) -->
|
||||
<div class="relative hamburger-container">
|
||||
<button
|
||||
@click.stop="isHamburgerOpen = !isHamburgerOpen"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-lg text-white/60 hover:text-white hover:bg-white/10 transition-all duration-200 cursor-pointer"
|
||||
:aria-label="t('company.companyDataMenu')"
|
||||
:title="t('company.companyDataMenu')"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Hamburger Dropdown -->
|
||||
<Transition
|
||||
enter-active-class="transition duration-150 ease-out"
|
||||
enter-from-class="scale-95 opacity-0 -translate-y-2"
|
||||
enter-to-class="scale-100 opacity-100 translate-y-0"
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-from-class="scale-100 opacity-100 translate-y-0"
|
||||
leave-to-class="scale-95 opacity-0 -translate-y-2"
|
||||
>
|
||||
<div
|
||||
v-if="isHamburgerOpen"
|
||||
class="absolute right-0 top-full mt-2 w-56 z-50 rounded-xl border border-white/10 bg-[#04151F]/95 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
|
||||
>
|
||||
<div class="py-1">
|
||||
<!-- Járművek (Fleet) -->
|
||||
<button
|
||||
@click="navigateToVehicles"
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<svg class="w-4 h-4 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
{{ t('menu.garage') }}
|
||||
</button>
|
||||
|
||||
<!-- Költségek (right-aligned per Architect request) -->
|
||||
<button
|
||||
@click="navigateToCosts"
|
||||
class="flex flex-row-reverse w-full items-center justify-end gap-3 px-4 py-2.5 text-sm text-right text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<svg class="w-4 h-4 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{{ t('menu.finance') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="openCompanyData"
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<svg class="w-4 h-4 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
{{ t('company.companyDataMenu') }}
|
||||
</button>
|
||||
|
||||
<!-- Előfizetésem & Csomagok -->
|
||||
<button
|
||||
@click="navigateToSubscription"
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<svg class="w-4 h-4 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
||||
</svg>
|
||||
{{ t('menu.subscription') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<HeaderCompanySwitcher />
|
||||
<HeaderProfile />
|
||||
</template>
|
||||
</BaseHeader>
|
||||
|
||||
<!-- Main content slot for child routes -->
|
||||
<main class="relative z-10">
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<!-- Company Data Modal -->
|
||||
<CompanyDataModal
|
||||
v-if="showCompanyDataModal"
|
||||
@close="showCompanyDataModal = false"
|
||||
@saved="showCompanyDataModal = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useThemeStore } from '../stores/theme'
|
||||
import BaseHeader from '../components/layout/BaseHeader.vue'
|
||||
import HeaderLogo from '../components/header/HeaderLogo.vue'
|
||||
import HeaderCompanySwitcher from '../components/header/HeaderCompanySwitcher.vue'
|
||||
import HeaderProfile from '../components/header/HeaderProfile.vue'
|
||||
import CompanyDataModal from '../components/organization/CompanyDataModal.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
// ── Hamburger Menu State ──────────────────────────────────────────
|
||||
const isHamburgerOpen = ref(false)
|
||||
const showCompanyDataModal = ref(false)
|
||||
|
||||
// ── Resolve organization display name from route params ────────────
|
||||
const orgDisplayName = computed(() => {
|
||||
const id = Number(route.params.id)
|
||||
if (!id) return t('organization.fallbackName')
|
||||
const org = authStore.myOrganizations.find((o) => o.organization_id === id)
|
||||
return org?.display_name || org?.name || t('organization.fallbackName')
|
||||
})
|
||||
|
||||
// ── Resolve current organization object ────────────────────────────
|
||||
const currentOrganization = computed(() => {
|
||||
const id = Number(route.params.id)
|
||||
if (!id) return null
|
||||
return authStore.myOrganizations.find((o) => o.organization_id === id) || null
|
||||
})
|
||||
|
||||
// ── Hamburger Menu Handlers ───────────────────────────────────────
|
||||
function openCompanyData() {
|
||||
isHamburgerOpen.value = false
|
||||
showCompanyDataModal.value = true
|
||||
}
|
||||
|
||||
// ── Navigate to Vehicles (Fleet) page ──────────────────────────────
|
||||
function navigateToVehicles() {
|
||||
isHamburgerOpen.value = false
|
||||
const orgId = route.params.id
|
||||
router.push(`/organization/${orgId}/vehicles`)
|
||||
}
|
||||
|
||||
// ── Navigate to Costs page ─────────────────────────────────────────
|
||||
function navigateToCosts() {
|
||||
isHamburgerOpen.value = false
|
||||
const orgId = route.params.id
|
||||
router.push(`/organization/${orgId}/costs`)
|
||||
}
|
||||
|
||||
// ── Navigate to Subscription Plans page ────────────────────────────
|
||||
function navigateToSubscription() {
|
||||
isHamburgerOpen.value = false
|
||||
const orgId = route.params.id
|
||||
router.push(`/organization/${orgId}/subscription`)
|
||||
}
|
||||
|
||||
// ── Close hamburger on outside click ──────────────────────────────
|
||||
function handleOutsideClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
if (isHamburgerOpen.value && !target.closest('.hamburger-container')) {
|
||||
isHamburgerOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Apply theme when organization loads or changes ────────────────
|
||||
function applyOrgTheme() {
|
||||
const org = currentOrganization.value
|
||||
if (org?.visual_settings) {
|
||||
themeStore.applyTheme(org.visual_settings)
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for organization changes (e.g. when switching orgs via switcher)
|
||||
watch(
|
||||
() => route.params.id,
|
||||
() => {
|
||||
applyOrgTheme()
|
||||
},
|
||||
)
|
||||
|
||||
// Apply theme on mount
|
||||
onMounted(() => {
|
||||
applyOrgTheme()
|
||||
document.addEventListener('mousedown', handleOutsideClick)
|
||||
})
|
||||
|
||||
// Reset theme when leaving the organization view
|
||||
onUnmounted(() => {
|
||||
themeStore.resetTheme()
|
||||
document.removeEventListener('mousedown', handleOutsideClick)
|
||||
})
|
||||
</script>
|
||||
191
frontend_app/src/layouts/PrivateLayout.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<div class="private-layout min-h-screen text-white">
|
||||
<!-- Modular header with BaseHeader + Lego components -->
|
||||
<BaseHeader>
|
||||
<template #left>
|
||||
<HeaderLogo />
|
||||
</template>
|
||||
|
||||
<template #center>
|
||||
<!-- Center slot: kept empty for breathing space -->
|
||||
</template>
|
||||
|
||||
<template #right>
|
||||
<!-- Language Switcher -->
|
||||
<LanguageSwitcher />
|
||||
|
||||
<!-- Hamburger Menu (Private Garage Navigation) -->
|
||||
<div class="relative hamburger-container">
|
||||
<button
|
||||
@click.stop="isHamburgerOpen = !isHamburgerOpen"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-lg text-white/60 hover:text-white hover:bg-white/10 transition-all duration-200 cursor-pointer"
|
||||
:aria-label="t('header.menu')"
|
||||
:title="t('header.menu')"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Hamburger Dropdown -->
|
||||
<Transition
|
||||
enter-active-class="transition duration-150 ease-out"
|
||||
enter-from-class="scale-95 opacity-0 -translate-y-2"
|
||||
enter-to-class="scale-100 opacity-100 translate-y-0"
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-from-class="scale-100 opacity-100 translate-y-0"
|
||||
leave-to-class="scale-95 opacity-0 -translate-y-2"
|
||||
>
|
||||
<div
|
||||
v-if="isHamburgerOpen"
|
||||
class="absolute right-0 top-full mt-2 w-56 z-50 rounded-xl border border-white/10 bg-[#04151F]/95 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
|
||||
>
|
||||
<div class="py-1">
|
||||
<!-- Vezérlőpult (Dashboard) -->
|
||||
<button
|
||||
@click="navigateToDashboard"
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<svg class="w-4 h-4 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
{{ t('header.dashboard') }}
|
||||
</button>
|
||||
|
||||
<!-- Járművek -->
|
||||
<button
|
||||
@click="navigateToCard('vehicles')"
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<svg class="w-4 h-4 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
{{ t('menu.garage') }}
|
||||
</button>
|
||||
|
||||
<!-- Költségek (right-aligned per Architect request - flex-row-reverse pushes icon to right edge) -->
|
||||
<button
|
||||
@click="navigateToCard('costs')"
|
||||
class="flex flex-row-reverse w-full items-center justify-end gap-3 px-4 py-2.5 text-sm text-right text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<svg class="w-4 h-4 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{{ t('menu.finance') }}
|
||||
</button>
|
||||
|
||||
<!-- Szerviz kereső (belső) -->
|
||||
<button
|
||||
@click="navigateToCard('service')"
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<svg class="w-4 h-4 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
{{ t('menu.features') }}
|
||||
</button>
|
||||
|
||||
<!-- Szervíz (külső oldal) -->
|
||||
<button
|
||||
@click="openExternalServiceFinder"
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<svg class="w-4 h-4 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
{{ t('menu.service') }}
|
||||
</button>
|
||||
|
||||
<!-- Előfizetésem & Csomagok -->
|
||||
<button
|
||||
@click="navigateToSubscription"
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<svg class="w-4 h-4 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
||||
</svg>
|
||||
{{ t('menu.subscription') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<HeaderCompanySwitcher />
|
||||
<HeaderProfile />
|
||||
</template>
|
||||
</BaseHeader>
|
||||
|
||||
<!-- Main content slot for child routes -->
|
||||
<main class="relative z-10">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import BaseHeader from '../components/layout/BaseHeader.vue'
|
||||
import HeaderLogo from '../components/header/HeaderLogo.vue'
|
||||
import HeaderCompanySwitcher from '../components/header/HeaderCompanySwitcher.vue'
|
||||
import HeaderProfile from '../components/header/HeaderProfile.vue'
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// ── Hamburger Menu State ──────────────────────────────────────────
|
||||
const isHamburgerOpen = ref(false)
|
||||
|
||||
// ── Navigate to dashboard with card query param ────────────────────
|
||||
function navigateToCard(cardId: string) {
|
||||
isHamburgerOpen.value = false
|
||||
// 'vehicles' navigates to the full FleetView page
|
||||
// 'costs' navigates to the full CostsView page
|
||||
// 'service' navigates to dashboard where its card is already visible inline
|
||||
if (cardId === 'vehicles') {
|
||||
router.push('/dashboard/vehicles')
|
||||
} else if (cardId === 'costs') {
|
||||
router.push('/dashboard/costs')
|
||||
} else if (cardId === 'service') {
|
||||
router.push('/dashboard')
|
||||
} else {
|
||||
router.push({ path: '/dashboard', query: { card: cardId } })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Navigate to Service Finder page (same tab) ─────────────────────
|
||||
function openExternalServiceFinder() {
|
||||
isHamburgerOpen.value = false
|
||||
router.push('/dashboard/service-finder')
|
||||
}
|
||||
|
||||
// ── Navigate to Dashboard homepage ─────────────────────────────────
|
||||
function navigateToDashboard() {
|
||||
isHamburgerOpen.value = false
|
||||
router.push('/dashboard')
|
||||
}
|
||||
|
||||
// ── Navigate to Subscription Plans page ────────────────────────────
|
||||
function navigateToSubscription() {
|
||||
isHamburgerOpen.value = false
|
||||
router.push('/dashboard/subscription')
|
||||
}
|
||||
|
||||
// ── Close hamburger on outside click ──────────────────────────────
|
||||
function handleOutsideClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
if (isHamburgerOpen.value && !target.closest('.hamburger-container')) {
|
||||
isHamburgerOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('mousedown', handleOutsideClick))
|
||||
onUnmounted(() => document.removeEventListener('mousedown', handleOutsideClick))
|
||||
</script>
|
||||
121
frontend_app/src/main.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import hu from './i18n/hu'
|
||||
import en from './i18n/en'
|
||||
import de from './i18n/de'
|
||||
import ro from './i18n/ro'
|
||||
import cz from './i18n/cz'
|
||||
import sk from './i18n/sk'
|
||||
import './assets/main.css'
|
||||
import 'flag-icons/css/flag-icons.css'
|
||||
|
||||
const messages = {
|
||||
hu,
|
||||
en,
|
||||
de,
|
||||
ro,
|
||||
cz,
|
||||
sk,
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the initial locale:
|
||||
* 1. If a saved locale exists in localStorage, use it.
|
||||
* 2. Otherwise, detect the browser language (navigator.language).
|
||||
* If it starts with 'hu', use 'hu'. For everything else, use 'en'.
|
||||
*/
|
||||
function detectLocale(): string {
|
||||
const saved = localStorage.getItem('user-locale')
|
||||
if (saved) return saved
|
||||
|
||||
if (typeof navigator !== 'undefined' && navigator.language) {
|
||||
const browserLang = navigator.language.toLowerCase()
|
||||
if (browserLang === 'hu' || browserLang.startsWith('hu-')) {
|
||||
return 'hu'
|
||||
}
|
||||
}
|
||||
|
||||
return 'en'
|
||||
}
|
||||
|
||||
const initialLocale = detectLocale()
|
||||
|
||||
/**
|
||||
* DateTime format configurations per locale.
|
||||
*
|
||||
* Usage in Vue templates:
|
||||
* $d(date, 'short') → "2026. 06. 08." (hu) or "06/08/2026" (en)
|
||||
* $d(date, 'long') → "2026. június 8., hétfő" (hu) or "Monday, June 8, 2026" (en)
|
||||
*/
|
||||
const datetimeFormats: Record<string, Intl.DateTimeFormatOptions> = {
|
||||
hu: {
|
||||
short: { year: 'numeric', month: '2-digit', day: '2-digit' },
|
||||
long: { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' },
|
||||
},
|
||||
en: {
|
||||
short: { year: 'numeric', month: '2-digit', day: '2-digit' },
|
||||
long: { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' },
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Number / currency format configurations per locale.
|
||||
*
|
||||
* Usage in Vue templates:
|
||||
* $n(amount, 'currency') → "1 000 Ft" (hu) or "€1,000.00" (en)
|
||||
* $n(value, 'decimal') → "1 234,56" (hu) or "1,234.56" (en)
|
||||
*/
|
||||
const numberFormats: Record<string, Intl.NumberFormatOptions> = {
|
||||
hu: {
|
||||
currency: {
|
||||
style: 'currency',
|
||||
currency: 'HUF',
|
||||
currencyDisplay: 'symbol',
|
||||
useGrouping: true,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
},
|
||||
decimal: {
|
||||
style: 'decimal',
|
||||
useGrouping: true,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
},
|
||||
},
|
||||
en: {
|
||||
currency: {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
currencyDisplay: 'symbol',
|
||||
useGrouping: true,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
},
|
||||
decimal: {
|
||||
style: 'decimal',
|
||||
useGrouping: true,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: initialLocale,
|
||||
fallbackLocale: 'en',
|
||||
messages,
|
||||
datetimeFormats,
|
||||
numberFormats,
|
||||
})
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(i18n)
|
||||
|
||||
app.mount('#app')
|
||||
210
frontend_app/src/router/index.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import LandingView from '../views/LandingView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: LandingView
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
component: () => import('../layouts/PrivateLayout.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'dashboard',
|
||||
component: () => import('../views/DashboardView.vue')
|
||||
},
|
||||
{
|
||||
path: 'service-finder',
|
||||
name: 'service-finder',
|
||||
component: () => import('../views/ServiceFinderView.vue')
|
||||
},
|
||||
{
|
||||
path: 'subscription',
|
||||
name: 'subscription',
|
||||
component: () => import('../views/SubscriptionPlansView.vue')
|
||||
},
|
||||
{
|
||||
path: 'vehicles',
|
||||
name: 'FleetView',
|
||||
component: () => import('../views/vehicles/FleetView.vue')
|
||||
},
|
||||
{
|
||||
path: 'costs',
|
||||
name: 'CostsView',
|
||||
component: () => import('../views/costs/CostsView.vue')
|
||||
},
|
||||
{
|
||||
path: 'vehicles/:id',
|
||||
name: 'VehicleDetails',
|
||||
component: () => import('../views/vehicles/VehicleDetailsView.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/organization/:org_id',
|
||||
component: () => import('../layouts/OrganizationLayout.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'organization-dashboard',
|
||||
component: () => import('../views/organization/CompanyGarageView.vue')
|
||||
},
|
||||
{
|
||||
path: 'subscription',
|
||||
name: 'org-subscription',
|
||||
component: () => import('../views/SubscriptionPlansView.vue')
|
||||
},
|
||||
{
|
||||
path: 'vehicles',
|
||||
name: 'OrgFleetView',
|
||||
component: () => import('../views/vehicles/FleetView.vue')
|
||||
},
|
||||
{
|
||||
path: 'vehicles/:vehicle_id',
|
||||
name: 'OrgVehicleDetails',
|
||||
component: () => import('../views/vehicles/VehicleDetailsView.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/complete-kyc',
|
||||
name: 'complete-kyc',
|
||||
component: () => import('../views/CompleteKycView.vue')
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
name: 'profile',
|
||||
component: () => import('../views/ProfileView.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/verify',
|
||||
name: 'verify',
|
||||
component: () => import('../views/VerifyEmailView.vue')
|
||||
},
|
||||
{
|
||||
path: '/reset-password',
|
||||
name: 'reset-password',
|
||||
component: () => import('../views/ResetPasswordView.vue')
|
||||
},
|
||||
{
|
||||
path: '/company/onboard',
|
||||
name: 'company-onboard',
|
||||
component: () => import('../views/organization/CompanyOnboardingView.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/company/garage',
|
||||
redirect: () => {
|
||||
// Redirect old /company/garage to /organization/:id
|
||||
// The auth store will be checked at runtime
|
||||
return { name: 'dashboard' }
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/login',
|
||||
name: 'admin-login',
|
||||
component: () => import('../views/admin/AdminLoginView.vue')
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
component: () => import('../layouts/AdminLayout.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'admin-dashboard',
|
||||
component: () => import('../views/admin/AdminDashboardView.vue')
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'admin-users',
|
||||
component: () => import('../views/admin/AdminUsersView.vue')
|
||||
},
|
||||
{
|
||||
path: 'packages',
|
||||
name: 'admin-packages',
|
||||
component: () => import('../views/admin/AdminPackagesView.vue')
|
||||
},
|
||||
{
|
||||
path: 'services',
|
||||
name: 'admin-services',
|
||||
component: () => import('../views/admin/AdminServicesView.vue')
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// ── Global navigation guard ──────────────────────────────────────────
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
// ── Subdomain Detection ────────────────────────────────────────────
|
||||
// If the user visits the root of the admin subdomain (admin.servicefinder.hu),
|
||||
// redirect them into the /admin path scope so Vue Router handles it correctly.
|
||||
const host = window.location.hostname
|
||||
const isAdminSubdomain = host.startsWith('admin.')
|
||||
if (isAdminSubdomain && to.path === '/') {
|
||||
return next('/admin')
|
||||
}
|
||||
|
||||
const { useAuthStore } = await import('../stores/auth')
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// CRITICAL: Wait for the auth store to finish initializing
|
||||
// (reading token from localStorage + fetching user profile).
|
||||
// Without this, the guard runs before init() completes on F5 refresh,
|
||||
// causing isAuthenticated to be falsely false -> user gets logged out.
|
||||
if (!authStore.isInitialized) {
|
||||
await authStore.init()
|
||||
}
|
||||
|
||||
// ── Admin Login Page — always public ──────────────────────────────
|
||||
// Allow access to admin login page regardless of auth state
|
||||
if (to.path === '/admin/login') {
|
||||
return next()
|
||||
}
|
||||
|
||||
// ── Admin Area Early Intercept ────────────────────────────────────
|
||||
// Intercept unauthenticated admin requests BEFORE the general
|
||||
// requiresAuth redirect, so we can send them to the dedicated
|
||||
// dark-themed admin login page instead of the B2C landing page.
|
||||
if (to.path.startsWith('/admin') && !authStore.isAuthenticated) {
|
||||
return next('/admin/login')
|
||||
}
|
||||
|
||||
// Redirect authenticated users away from landing
|
||||
if (to.path === '/') {
|
||||
if (authStore.isAuthenticated) {
|
||||
return next('/dashboard')
|
||||
}
|
||||
}
|
||||
|
||||
// Protect routes that require authentication
|
||||
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||
return next('/')
|
||||
}
|
||||
|
||||
// ── Admin RBAC Guard ──────────────────────────────────────────────
|
||||
// If the route requires admin privileges, check the user's role.
|
||||
if (to.meta.requiresAdmin) {
|
||||
const role = authStore.user?.role
|
||||
// CRITICAL: Backend sends UPPERCASE roles (SUPERADMIN, ADMIN, MODERATOR, SALES_REP, SERVICE_MGR)
|
||||
// Must use toUpperCase() to ensure case-insensitive matching
|
||||
const staffRoles = ['SUPERADMIN', 'ADMIN', 'MODERATOR', 'SALES_REP', 'SERVICE_MGR']
|
||||
if (!role || !staffRoles.includes(role.toUpperCase())) {
|
||||
// Non-staff user trying to access admin area → redirect to dashboard
|
||||
return next('/dashboard')
|
||||
}
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
18
frontend_app/src/services/dateUtils.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Returns today's date as a YYYY-MM-DD string in the **local** timezone.
|
||||
*
|
||||
* Why this exists:
|
||||
* `new Date().toISOString().split('T')[0]` returns the date in UTC.
|
||||
* In timezones ahead of UTC (e.g. CEST = UTC+2), a local time like
|
||||
* 01:00 AM is still the *previous day* in UTC, causing date-picker
|
||||
* defaults to show yesterday.
|
||||
*
|
||||
* This function compensates for the timezone offset so the returned
|
||||
* string always matches the user's local calendar date.
|
||||
*/
|
||||
export function getLocalDateString(): string {
|
||||
const d = new Date()
|
||||
// Shift by the timezone offset so toISOString reflects local date
|
||||
d.setMinutes(d.getMinutes() - d.getTimezoneOffset())
|
||||
return d.toISOString().split('T')[0]
|
||||
}
|
||||
44
frontend_app/src/services/translationService.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || '/api'
|
||||
|
||||
const translationService = {
|
||||
async fetchTranslations(lang: string): Promise<Record<string, any>> {
|
||||
try {
|
||||
const response = await axios.get(`${API_BASE}/v1/translations`, {
|
||||
params: { lang }
|
||||
})
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.warn(`Failed to fetch translations for ${lang}, using fallback`, error)
|
||||
// Return empty object as fallback
|
||||
return {}
|
||||
}
|
||||
},
|
||||
|
||||
async fetchAllTranslations(): Promise<Record<string, any>> {
|
||||
try {
|
||||
const response = await axios.get(`${API_BASE}/v1/translations/all`)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch all translations', error)
|
||||
return {}
|
||||
}
|
||||
},
|
||||
|
||||
async updateTranslation(key: string, value: string, lang: string): Promise<boolean> {
|
||||
try {
|
||||
await axios.post(`${API_BASE}/v1/translations`, {
|
||||
key,
|
||||
value,
|
||||
lang
|
||||
})
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to update translation', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default translationService
|
||||
259
frontend_app/src/stores/adminPackages.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* 📦 Admin Packages Store
|
||||
*
|
||||
* Pinia store for managing SubscriptionTier packages.
|
||||
* Mirrors the backend admin_packages.py API endpoints.
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /admin/packages/ — List all packages
|
||||
* POST /admin/packages/ — Create a new package
|
||||
* PATCH /admin/packages/{id} — Update a package
|
||||
* DELETE /admin/packages/{id} — Soft-delete (deactivate) a package
|
||||
*/
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import api from '../api/axios'
|
||||
|
||||
// ── TypeScript Interfaces (mirroring backend Pydantic schemas) ───────────────
|
||||
|
||||
export interface PricingModel {
|
||||
monthly_price: number
|
||||
yearly_price: number
|
||||
currency: string
|
||||
credit_price?: number | null
|
||||
}
|
||||
|
||||
export interface PricingZoneModel {
|
||||
monthly_price: number
|
||||
yearly_price: number
|
||||
currency: string
|
||||
credit_price?: number | null
|
||||
}
|
||||
|
||||
export interface MarketingModel {
|
||||
subtitle?: string | null
|
||||
badge?: string | null
|
||||
highlight_color?: string | null
|
||||
}
|
||||
|
||||
export interface AllowancesModel {
|
||||
max_vehicles: number
|
||||
max_garages: number
|
||||
monthly_free_credits: number
|
||||
}
|
||||
|
||||
export interface LifecycleModel {
|
||||
is_public: boolean
|
||||
available_until?: string | null
|
||||
}
|
||||
|
||||
export interface AffiliateModel {
|
||||
commission_rate_percent: number
|
||||
referral_bonus_credits: number
|
||||
}
|
||||
|
||||
export interface SubscriptionRulesModel {
|
||||
type: 'private' | 'corporate'
|
||||
display_name?: string | null
|
||||
pricing: PricingModel
|
||||
pricing_zones?: Record<string, PricingZoneModel> | null
|
||||
allowances: AllowancesModel
|
||||
entitlements: string[]
|
||||
affiliate: AffiliateModel
|
||||
lifecycle?: LifecycleModel | null
|
||||
marketing?: MarketingModel | null
|
||||
}
|
||||
|
||||
export interface SubscriptionTierItem {
|
||||
id: number
|
||||
name: string
|
||||
rules: SubscriptionRulesModel
|
||||
is_custom: boolean
|
||||
}
|
||||
|
||||
export interface SubscriptionTierListResponse {
|
||||
total: number
|
||||
tiers: SubscriptionTierItem[]
|
||||
}
|
||||
|
||||
export interface SubscriptionTierCreatePayload {
|
||||
name: string
|
||||
rules: SubscriptionRulesModel
|
||||
is_custom?: boolean
|
||||
}
|
||||
|
||||
export interface SubscriptionTierUpdatePayload {
|
||||
name?: string
|
||||
rules?: SubscriptionRulesModel
|
||||
is_custom?: boolean
|
||||
}
|
||||
|
||||
export interface DeletePackageResponse {
|
||||
status: string
|
||||
message: string
|
||||
tier_id: number
|
||||
is_public: boolean
|
||||
}
|
||||
|
||||
// ── Available Entitlements (service codes) ───────────────────────────────────
|
||||
|
||||
export const AVAILABLE_ENTITLEMENTS: string[] = [
|
||||
'SRV_DATA_EXPORT',
|
||||
'SRV_AI_UPLOAD',
|
||||
'SRV_ACCOUNTING_SYNC',
|
||||
'SRV_API_ACCESS',
|
||||
'SRV_MULTI_USER',
|
||||
'SRV_PRIORITY_SUPPORT',
|
||||
'SRV_ADVANCED_ANALYTICS',
|
||||
'SRV_CUSTOM_REPORTS',
|
||||
'SRV_GARAGE_SHARING',
|
||||
'SRV_SERVICE_HISTORY',
|
||||
'SRV_FUEL_MANAGEMENT',
|
||||
'SRV_MAINTENANCE_ALERTS',
|
||||
'SRV_ODOMETER_TRACKING',
|
||||
'SRV_DOCUMENT_STORAGE',
|
||||
'SRV_INSURANCE_INTEGRATION',
|
||||
]
|
||||
|
||||
// ── Store ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const useAdminPackagesStore = defineStore('adminPackages', () => {
|
||||
// ── State ────────────────────────────────────────────────────────────────
|
||||
const tiers = ref<SubscriptionTierItem[]>([])
|
||||
const total = ref(0)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// ── Actions ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch all subscription packages from GET /admin/packages/.
|
||||
*/
|
||||
async function fetchPackages(params: {
|
||||
skip?: number
|
||||
limit?: number
|
||||
include_hidden?: boolean
|
||||
type_filter?: string
|
||||
date_from?: string
|
||||
date_until?: string
|
||||
} = {}) {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const queryParams: Record<string, any> = {
|
||||
skip: params.skip ?? 0,
|
||||
limit: params.limit ?? 100,
|
||||
}
|
||||
if (params.include_hidden) {
|
||||
queryParams.include_hidden = true
|
||||
}
|
||||
if (params.type_filter) {
|
||||
queryParams.type_filter = params.type_filter
|
||||
}
|
||||
if (params.date_from) {
|
||||
queryParams.date_from = params.date_from
|
||||
}
|
||||
if (params.date_until) {
|
||||
queryParams.date_until = params.date_until
|
||||
}
|
||||
|
||||
const res = await api.get('/admin/packages', { params: queryParams })
|
||||
const data = res.data as SubscriptionTierListResponse
|
||||
console.log('[AdminPackages] API response:', data)
|
||||
console.log('[AdminPackages] tiers array:', data.tiers)
|
||||
console.log('[AdminPackages] tiers length:', data.tiers?.length)
|
||||
tiers.value = data.tiers
|
||||
total.value = data.total
|
||||
return data
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to fetch packages'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new subscription package via POST /admin/packages/.
|
||||
* NOTE: Do NOT send the `id` field — PostgreSQL auto-increments it.
|
||||
*/
|
||||
async function createPackage(payload: SubscriptionTierCreatePayload): Promise<SubscriptionTierItem> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const res = await api.post('/admin/packages', payload)
|
||||
const created = res.data as SubscriptionTierItem
|
||||
// Refresh the list
|
||||
await fetchPackages({ include_hidden: true })
|
||||
return created
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to create package'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing package via PATCH /admin/packages/{id}.
|
||||
*/
|
||||
async function updatePackage(
|
||||
tierId: number,
|
||||
payload: SubscriptionTierUpdatePayload,
|
||||
): Promise<SubscriptionTierItem> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const res = await api.patch(`/admin/packages/${tierId}`, payload)
|
||||
const updated = res.data as SubscriptionTierItem
|
||||
// Refresh the list
|
||||
await fetchPackages({ include_hidden: true })
|
||||
return updated
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to update package'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete (deactivate) a package via DELETE /admin/packages/{id}.
|
||||
* Sets lifecycle.is_public = false.
|
||||
*/
|
||||
async function deletePackage(tierId: number): Promise<DeletePackageResponse> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const res = await api.delete(`/admin/packages/${tierId}`)
|
||||
const result = res.data as DeletePackageResponse
|
||||
// Refresh the list
|
||||
await fetchPackages({ include_hidden: true })
|
||||
return result
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to delete package'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
tiers,
|
||||
total,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Actions
|
||||
fetchPackages,
|
||||
createPackage,
|
||||
updatePackage,
|
||||
deletePackage,
|
||||
}
|
||||
})
|
||||
142
frontend_app/src/stores/adminServices.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* 🛠️ Admin Services Store
|
||||
*
|
||||
* Pinia store for managing ServiceCatalog entries.
|
||||
* Mirrors the backend admin_services.py API endpoints.
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /admin/services/ — List all services
|
||||
* POST /admin/services/ — Create a new service
|
||||
* PATCH /admin/services/{id} — Update a service
|
||||
*/
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import api from '../api/axios'
|
||||
|
||||
// ── TypeScript Interfaces (mirroring backend Pydantic schemas) ───────────────
|
||||
|
||||
export interface ServiceCatalogItem {
|
||||
id: number
|
||||
service_code: string
|
||||
name: string
|
||||
description: string | null
|
||||
credit_cost: number
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
export interface ServiceCatalogCreatePayload {
|
||||
service_code: string
|
||||
name: string
|
||||
description?: string | null
|
||||
credit_cost?: number
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
export interface ServiceCatalogUpdatePayload {
|
||||
name?: string
|
||||
description?: string | null
|
||||
credit_cost?: number
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
// ── Store ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const useAdminServicesStore = defineStore('adminServices', () => {
|
||||
// ── State ────────────────────────────────────────────────────────────────
|
||||
const services = ref<ServiceCatalogItem[]>([])
|
||||
const total = ref(0)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// ── Actions ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch all services from GET /admin/services/.
|
||||
*/
|
||||
async function fetchServices(params: {
|
||||
skip?: number
|
||||
limit?: number
|
||||
is_active?: boolean | null
|
||||
} = {}) {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const queryParams: Record<string, any> = {
|
||||
skip: params.skip ?? 0,
|
||||
limit: params.limit ?? 200,
|
||||
}
|
||||
if (params.is_active !== undefined && params.is_active !== null) {
|
||||
queryParams.is_active = params.is_active
|
||||
}
|
||||
|
||||
const res = await api.get('/admin/services', { params: queryParams })
|
||||
const data = res.data as ServiceCatalogItem[]
|
||||
services.value = data
|
||||
total.value = data.length
|
||||
return data
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to fetch services'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new service via POST /admin/services/.
|
||||
*/
|
||||
async function createService(payload: ServiceCatalogCreatePayload): Promise<ServiceCatalogItem> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const res = await api.post('/admin/services', payload)
|
||||
const created = res.data as ServiceCatalogItem
|
||||
await fetchServices()
|
||||
return created
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to create service'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing service via PATCH /admin/services/{id}.
|
||||
*/
|
||||
async function updateService(
|
||||
serviceId: number,
|
||||
payload: ServiceCatalogUpdatePayload,
|
||||
): Promise<ServiceCatalogItem> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const res = await api.patch(`/admin/services/${serviceId}`, payload)
|
||||
const updated = res.data as ServiceCatalogItem
|
||||
await fetchServices()
|
||||
return updated
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to update service'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
services,
|
||||
total,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Actions
|
||||
fetchServices,
|
||||
createService,
|
||||
updateService,
|
||||
}
|
||||
})
|
||||
129
frontend_app/src/stores/adminUsers.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
// /opt/docker/dev/service_finder/frontend/src/stores/adminUsers.ts
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import api from '../api/axios'
|
||||
|
||||
export interface AdminUserItem {
|
||||
id: number
|
||||
email: string
|
||||
role: string
|
||||
is_active: boolean
|
||||
is_deleted: boolean
|
||||
subscription_plan: string
|
||||
region_code: string
|
||||
preferred_language: string
|
||||
created_at: string | null
|
||||
deleted_at: string | null
|
||||
// Person adatok
|
||||
first_name: string | null
|
||||
last_name: string | null
|
||||
phone: string | null
|
||||
// Címadatok
|
||||
postal_code: string | null
|
||||
city: string | null
|
||||
street: string | null
|
||||
house_number: string | null
|
||||
address: string | null
|
||||
}
|
||||
|
||||
export interface UserListResponse {
|
||||
total: number
|
||||
skip: number
|
||||
limit: number
|
||||
users: AdminUserItem[]
|
||||
}
|
||||
|
||||
export interface BulkActionResponse {
|
||||
status: string
|
||||
action: string
|
||||
affected_count: number
|
||||
total_requested: number
|
||||
missing_ids: number[] | null
|
||||
message: string
|
||||
}
|
||||
|
||||
export const useAdminUsersStore = defineStore('adminUsers', () => {
|
||||
// ────────────────────────────── State ──────────────────────────────
|
||||
const users = ref<AdminUserItem[]>([])
|
||||
const total = ref(0)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// ────────────────────────────── Actions ────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch paginated and filtered user list from GET /admin/users.
|
||||
* Supports targeted search with search_term + search_category.
|
||||
*/
|
||||
async function fetchUsers(params: {
|
||||
skip?: number
|
||||
limit?: number
|
||||
search_term?: string
|
||||
search_category?: string
|
||||
role?: string
|
||||
is_active?: boolean
|
||||
is_deleted?: boolean
|
||||
} = {}) {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const queryParams: Record<string, any> = {
|
||||
skip: params.skip ?? 0,
|
||||
limit: params.limit ?? 50,
|
||||
}
|
||||
if (params.search_term) queryParams.search_term = params.search_term
|
||||
if (params.search_category) queryParams.search_category = params.search_category
|
||||
if (params.role) queryParams.role = params.role
|
||||
if (params.is_active !== undefined) queryParams.is_active = params.is_active
|
||||
if (params.is_deleted !== undefined) queryParams.is_deleted = params.is_deleted
|
||||
|
||||
const res = await api.get('/admin/users', { params: queryParams })
|
||||
const data = res.data as UserListResponse
|
||||
users.value = data.users
|
||||
total.value = data.total
|
||||
return data
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to fetch users'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a bulk action on selected users via POST /admin/users/bulk-action.
|
||||
*/
|
||||
async function bulkAction(userIds: number[], action: string): Promise<BulkActionResponse> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const res = await api.post('/admin/users/bulk-action', {
|
||||
user_ids: userIds,
|
||||
action,
|
||||
})
|
||||
const data = res.data as BulkActionResponse
|
||||
// Refresh the user list after a successful bulk action
|
||||
await fetchUsers()
|
||||
return data
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || `Failed to execute bulk action: ${action}`
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
users,
|
||||
total,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Actions
|
||||
fetchUsers,
|
||||
bulkAction,
|
||||
}
|
||||
})
|
||||
93
frontend_app/src/stores/appModeStore.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import translationService from '../services/translationService'
|
||||
|
||||
export const useAppModeStore = defineStore('appMode', () => {
|
||||
// State
|
||||
const mode = ref<'B2B' | 'B2C'>('B2C')
|
||||
const language = ref('en')
|
||||
const translations = ref<Record<string, any>>({})
|
||||
|
||||
// Getters
|
||||
const isCorporate = computed(() => mode.value === 'B2B')
|
||||
const isConsumer = computed(() => mode.value === 'B2C')
|
||||
const currentLanguage = computed(() => language.value)
|
||||
|
||||
// Actions
|
||||
function toggleMode() {
|
||||
mode.value = mode.value === 'B2B' ? 'B2C' : 'B2B'
|
||||
// Save to localStorage
|
||||
localStorage.setItem('appMode', mode.value)
|
||||
}
|
||||
|
||||
function setMode(newMode: 'B2B' | 'B2C') {
|
||||
mode.value = newMode
|
||||
localStorage.setItem('appMode', newMode)
|
||||
}
|
||||
|
||||
async function setLanguage(lang: string) {
|
||||
const { locale } = useI18n()
|
||||
language.value = lang
|
||||
locale.value = lang
|
||||
localStorage.setItem('language', lang)
|
||||
|
||||
// Load translations from backend
|
||||
await loadTranslations(lang)
|
||||
}
|
||||
|
||||
async function loadTranslations(lang: string) {
|
||||
try {
|
||||
const data = await translationService.fetchTranslations(lang)
|
||||
translations.value = data
|
||||
} catch (error) {
|
||||
console.error('Failed to load translations:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function getTranslation(key: string, defaultValue = ''): string {
|
||||
const keys = key.split('.')
|
||||
let value: any = translations.value
|
||||
for (const k of keys) {
|
||||
if (value && typeof value === 'object' && k in value) {
|
||||
value = value[k]
|
||||
} else {
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
return typeof value === 'string' ? value : defaultValue
|
||||
}
|
||||
|
||||
// Initialize from localStorage
|
||||
function init() {
|
||||
const savedMode = localStorage.getItem('appMode') as 'B2B' | 'B2C'
|
||||
if (savedMode) {
|
||||
mode.value = savedMode
|
||||
}
|
||||
|
||||
const savedLang = localStorage.getItem('language')
|
||||
if (savedLang) {
|
||||
language.value = savedLang
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
mode,
|
||||
language,
|
||||
translations,
|
||||
|
||||
// Getters
|
||||
isCorporate,
|
||||
isConsumer,
|
||||
currentLanguage,
|
||||
|
||||
// Actions
|
||||
toggleMode,
|
||||
setMode,
|
||||
setLanguage,
|
||||
loadTranslations,
|
||||
getTranslation,
|
||||
init
|
||||
}
|
||||
})
|
||||
764
frontend_app/src/stores/auth.ts
Normal file
@@ -0,0 +1,764 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import router from '../router'
|
||||
import api from '../api/axios'
|
||||
import type { OrganizationItem } from '../types/organization'
|
||||
|
||||
/**
|
||||
* Backend now sends address fields directly on the person object
|
||||
* with address_ prefix (e.g. address_city, address_zip, address_floor).
|
||||
* This interface is kept for backward compatibility but the actual
|
||||
* data comes as flat fields on PersonData.
|
||||
*/
|
||||
export interface AddressData {
|
||||
zip: string | null
|
||||
city: string | null
|
||||
street_name: string | null
|
||||
street_type: string | null
|
||||
house_number: string | null
|
||||
stairwell: string | null
|
||||
floor: string | null
|
||||
door: string | null
|
||||
parcel_id: string | null
|
||||
full_address_text: string | null
|
||||
}
|
||||
|
||||
export interface PersonData {
|
||||
id: number
|
||||
first_name: string | null
|
||||
last_name: string | null
|
||||
phone: string | null
|
||||
mothers_last_name: string | null
|
||||
mothers_first_name: string | null
|
||||
birth_place: string | null
|
||||
birth_date: string | null
|
||||
identity_docs: Record<string, any> | null
|
||||
ice_contact: Record<string, any> | null
|
||||
is_active: boolean
|
||||
/** @deprecated Backend now sends address_* fields flat on person */
|
||||
address: AddressData | null
|
||||
// Flat address fields (new backend format)
|
||||
address_zip: string | null
|
||||
address_city: string | null
|
||||
address_street_name: string | null
|
||||
address_street_type: string | null
|
||||
address_house_number: string | null
|
||||
address_stairwell: string | null
|
||||
address_floor: string | null
|
||||
address_door: string | null
|
||||
address_hrsz: string | null
|
||||
address_parcel_id: string | null
|
||||
address_full_text: string | null
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id: number
|
||||
email: string
|
||||
first_name: string | null
|
||||
last_name: string | null
|
||||
is_active: boolean
|
||||
role: string
|
||||
region_code: string
|
||||
subscription_plan: string
|
||||
subscription_expires_at?: string | null
|
||||
/** P0: Real subscription limits from the assigned subscription_tier JSONB rules */
|
||||
max_vehicles?: number
|
||||
max_garages?: number
|
||||
scope_level: string
|
||||
scope_id: string | null
|
||||
ui_mode: string
|
||||
active_organization_id: number | null
|
||||
preferred_language: string
|
||||
// person_id is set when KYC is complete (Person record exists)
|
||||
person_id: number | null
|
||||
// Nested person data with address
|
||||
person: PersonData | null
|
||||
// RBAC Phase 3: Rendszerszintű képességek (a SYSTEM_CAPABILITIES_MATRIX-ból)
|
||||
system_capabilities?: Record<string, boolean>
|
||||
// RBAC Phase 3: Szervezeti szintű képességek (org_id -> capability -> bool)
|
||||
org_capabilities?: Record<string, Record<string, boolean>>
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
// ────────────────────────────── State ──────────────────────────────
|
||||
const user = ref<UserProfile | null>(null)
|
||||
const token = ref<string | null>(localStorage.getItem('access_token'))
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const myOrganizations = ref<OrganizationItem[]>([])
|
||||
const isInitialized = ref(false)
|
||||
|
||||
// ────────────────────────────── Getters ────────────────────────────
|
||||
const isAuthenticated = computed(() => !!token.value && !!user.value)
|
||||
const isKycComplete = computed(() => {
|
||||
// KYC is complete when the user has a linked Person record (person_id is set)
|
||||
return !!user.value?.person_id
|
||||
})
|
||||
const userRole = computed(() => user.value?.role ?? 'guest')
|
||||
|
||||
/**
|
||||
* Ellenőrzi, hogy a felhasználó rendelkezik-e admin jogosultsággal.
|
||||
* A SUPERADMIN és ADMIN (és egyéb adminisztratív) szerepköröket ismeri fel.
|
||||
*/
|
||||
const isAdmin = computed(() => {
|
||||
const role = user.value?.role
|
||||
if (!role) return false
|
||||
// CRITICAL: Backend sends UPPERCASE roles (SUPERADMIN, ADMIN, MODERATOR, SALES_REP, SERVICE_MGR)
|
||||
// Must use toUpperCase() to ensure case-insensitive matching
|
||||
const staffRoles = ['SUPERADMIN', 'ADMIN', 'MODERATOR', 'SALES_REP', 'SERVICE_MGR']
|
||||
return staffRoles.includes(role.toUpperCase())
|
||||
})
|
||||
const userName = computed(() => {
|
||||
if (!user.value) return ''
|
||||
const { first_name, last_name } = user.value
|
||||
if (first_name && last_name) return `${first_name} ${last_name}`
|
||||
return first_name || last_name || user.value.email
|
||||
})
|
||||
const userId = computed(() => user.value?.id ?? null)
|
||||
|
||||
// ── RBAC Phase 3: Capability Helpers ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Ellenőrzi, hogy a felhasználó rendelkezik-e egy adott rendszerszintű képességgel.
|
||||
* A képességek a backend /auth/me vagy /users/me végpontról érkeznek
|
||||
* a SYSTEM_CAPABILITIES_MATRIX alapján.
|
||||
* @param capability - A képesség neve (pl. 'can_manage_users')
|
||||
* @returns true, ha a képesség elérhető a user szerepköréhez
|
||||
*/
|
||||
function hasSystemCapability(capability: string): boolean {
|
||||
return user.value?.system_capabilities?.[capability] === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Ellenőrzi, hogy a felhasználó rendelkezik-e egy adott szervezeti képességgel.
|
||||
* A szervezeti képességek az OrgRole.permissions JSONB mezőből származnak.
|
||||
* @param orgId - A szervezet azonosítója (string vagy number)
|
||||
* @param capability - A képesség neve (pl. 'can_approve_expense')
|
||||
* @returns true, ha a képesség elérhető a user számára az adott szervezetben
|
||||
*/
|
||||
function hasOrgCapability(orgId: string | number, capability: string): boolean {
|
||||
const orgCaps = user.value?.org_capabilities?.[String(orgId)]
|
||||
if (!orgCaps) return false
|
||||
return orgCaps[capability] === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Visszaadja az összes szervezeti képességet egy adott szervezethez.
|
||||
* @param orgId - A szervezet azonosítója
|
||||
* @returns A képességek objektuma, vagy üres objektum, ha nincs
|
||||
*/
|
||||
function getOrgCapabilities(orgId: string | number): Record<string, boolean> {
|
||||
return user.value?.org_capabilities?.[String(orgId)] || {}
|
||||
}
|
||||
|
||||
// ────────────────────────────── Helpers ────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract the raw error code from the backend response.
|
||||
* Backend returns codes like "[AUTH.INVALID_CREDENTIALS]" or plain strings.
|
||||
* Strips brackets and returns the clean code, or null if not a code.
|
||||
*/
|
||||
function extractErrorCode(err: any): string | null {
|
||||
const detail = err.response?.data?.detail || err.response?.data?.message || ''
|
||||
if (!detail) return null
|
||||
// Strip surrounding brackets: [AUTH.INVALID_CREDENTIALS] -> AUTH.INVALID_CREDENTIALS
|
||||
const match = detail.match(/^\[?([A-Z]+\.[A-Z_]+)\]?$/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a backend error code to a user-friendly i18n key path.
|
||||
* Returns the i18n key (e.g. 'auth.invalid_credentials') or null if unmapped.
|
||||
*/
|
||||
function mapErrorCodeToI18nKey(code: string): string | null {
|
||||
const map: Record<string, string> = {
|
||||
'AUTH.INVALID_CREDENTIALS': 'auth.invalid_credentials',
|
||||
'AUTH.USER_INACTIVE': 'auth.user_inactive',
|
||||
'AUTH.USER_NOT_FOUND': 'auth.invalid_credentials',
|
||||
'AUTH.EMAIL_EXISTS': 'auth.email_exists',
|
||||
'AUTH.INVALID_TOKEN': 'auth.invalid_token',
|
||||
'AUTH.TOKEN_EXPIRED': 'auth.token_expired',
|
||||
'AUTH.RATE_LIMIT': 'auth.rate_limit',
|
||||
}
|
||||
return map[code] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly error message from an API error response.
|
||||
* Returns an i18n key path (e.g. 'auth.invalid_credentials') if the error
|
||||
* matches a known code, or the raw detail string as fallback.
|
||||
*/
|
||||
function getErrorMessage(err: any, fallback: string): string {
|
||||
const code = extractErrorCode(err)
|
||||
if (code) {
|
||||
const i18nKey = mapErrorCodeToI18nKey(code)
|
||||
if (i18nKey) return i18nKey
|
||||
}
|
||||
// Fallback to raw detail or message
|
||||
return err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
fallback
|
||||
}
|
||||
|
||||
// ────────────────────────────── Actions ────────────────────────────
|
||||
|
||||
/**
|
||||
* Login with email + password.
|
||||
* The FastAPI /auth/login endpoint uses OAuth2PasswordRequestForm,
|
||||
* which expects application/x-www-form-urlencoded with `username` and `password`.
|
||||
* Supports optional device_fingerprint for Device-Hub tracking.
|
||||
*/
|
||||
async function login(email: string, password: string, deviceFingerprint?: string) {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const body = new URLSearchParams()
|
||||
body.append('username', email) // FastAPI OAuth2 form uses `username` key
|
||||
body.append('password', password)
|
||||
if (deviceFingerprint) {
|
||||
body.append('device_fingerprint', deviceFingerprint)
|
||||
}
|
||||
|
||||
const res = await api.post('/auth/login', body, {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
|
||||
const data = res.data as {
|
||||
access_token: string
|
||||
refresh_token?: string
|
||||
token_type: string
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
// Persist token
|
||||
localStorage.setItem('access_token', data.access_token)
|
||||
if (data.refresh_token) {
|
||||
localStorage.setItem('refresh_token', data.refresh_token)
|
||||
}
|
||||
token.value = data.access_token
|
||||
|
||||
// Immediately fetch the user profile
|
||||
await fetchUser()
|
||||
|
||||
// Fetch organizations after successful login so the header can show them
|
||||
await fetchMyOrganizations()
|
||||
|
||||
// Clear pending verification email after successful login
|
||||
localStorage.removeItem('pending_verification_email')
|
||||
|
||||
// If KYC is not complete, redirect to KYC page
|
||||
if (!isKycComplete.value) {
|
||||
router.push('/complete-kyc')
|
||||
}
|
||||
} catch (err: any) {
|
||||
error.value = getErrorMessage(err, 'auth.loginFailed')
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the current user profile from /auth/me.
|
||||
*/
|
||||
async function fetchUser() {
|
||||
if (!token.value) return
|
||||
|
||||
try {
|
||||
const res = await api.get('/auth/me')
|
||||
user.value = res.data as UserProfile
|
||||
} catch (err: any) {
|
||||
// If the token is invalid, clear everything
|
||||
if (err.response?.status === 401) {
|
||||
logout()
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new user.
|
||||
* The /auth/register endpoint expects JSON with UserLiteRegister schema.
|
||||
* Default hidden values (region_code, lang, timezone) are appended here.
|
||||
*/
|
||||
async function register(data: {
|
||||
email: string
|
||||
password: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
}) {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
...data,
|
||||
region_code: 'HU',
|
||||
lang: 'hu',
|
||||
timezone: 'Europe/Budapest',
|
||||
}
|
||||
|
||||
const res = await api.post('/auth/register', payload)
|
||||
|
||||
// Save email to localStorage for verification page auto-fill
|
||||
localStorage.setItem('pending_verification_email', data.email)
|
||||
|
||||
return res.data as {
|
||||
status: string
|
||||
message: string
|
||||
user_id: number
|
||||
email: string
|
||||
}
|
||||
} catch (err: any) {
|
||||
error.value = getErrorMessage(err, 'auth.registerFailed')
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend the verification email.
|
||||
* POST /auth/resend-verification with { email }.
|
||||
*/
|
||||
async function resendVerification(email: string) {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const res = await api.post('/auth/resend-verification', { email })
|
||||
return res.data as { status: string; message: string }
|
||||
} catch (err: any) {
|
||||
error.value = getErrorMessage(err, 'auth.resendFailed')
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a password reset email.
|
||||
*/
|
||||
async function forgotPassword(email: string) {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const res = await api.post('/auth/forgot-password', { email })
|
||||
return res.data as { status: string; message: string }
|
||||
} catch (err: any) {
|
||||
const code = extractErrorCode(err)
|
||||
if (code === 'AUTH.USER_INACTIVE') {
|
||||
error.value = 'auth.user_inactive'
|
||||
} else {
|
||||
error.value = getErrorMessage(err, 'auth.forgotFailed')
|
||||
}
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset password using token from email.
|
||||
* POST /auth/reset-password with { email, token, new_password }.
|
||||
*/
|
||||
async function resetPassword(email: string, token: string, newPassword: string) {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const res = await api.post('/auth/reset-password', {
|
||||
email,
|
||||
token,
|
||||
new_password: newPassword,
|
||||
})
|
||||
return res.data as { status: string; message: string }
|
||||
} catch (err: any) {
|
||||
error.value = getErrorMessage(err, 'auth.resetPasswordFailed')
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout — clear token, user state, and localStorage.
|
||||
*/
|
||||
function logout() {
|
||||
token.value = null
|
||||
user.value = null
|
||||
error.value = null
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
// Redirect to the landing page
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the store on app load — if a token exists, fetch the user.
|
||||
*/
|
||||
async function init() {
|
||||
if (token.value) {
|
||||
try {
|
||||
await fetchUser()
|
||||
// Load organizations so the header switcher has data immediately
|
||||
await fetchMyOrganizations()
|
||||
} catch {
|
||||
// Token invalid — already cleared by fetchUser → logout
|
||||
}
|
||||
}
|
||||
// Mark initialization as complete so the router guard can await it
|
||||
isInitialized.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify email account using the token from the activation link (Magic Link).
|
||||
* POST /auth/verify-email with { token, device_fingerprint }.
|
||||
* Now returns JWT tokens on success, automatically logging the user in.
|
||||
* Supports optional device_fingerprint for Device-Hub tracking.
|
||||
*/
|
||||
async function verifyAccount(verificationToken: string, deviceFingerprint?: string) {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const payload: Record<string, any> = { token: verificationToken }
|
||||
if (deviceFingerprint) {
|
||||
payload.device_fingerprint = deviceFingerprint
|
||||
}
|
||||
const res = await api.post('/auth/verify-email', payload)
|
||||
const data = res.data as {
|
||||
access_token?: string
|
||||
refresh_token?: string
|
||||
token_type?: string
|
||||
is_active?: boolean
|
||||
status?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
// Magic Link: Ha a backend JWT tokent küld vissza, automatikus bejelentkezés
|
||||
if (data.access_token) {
|
||||
// Persist token
|
||||
localStorage.setItem('access_token', data.access_token)
|
||||
if (data.refresh_token) {
|
||||
localStorage.setItem('refresh_token', data.refresh_token)
|
||||
}
|
||||
token.value = data.access_token
|
||||
|
||||
// Immediately fetch the user profile
|
||||
await fetchUser()
|
||||
|
||||
// Clear pending verification email after successful verification + login
|
||||
localStorage.removeItem('pending_verification_email')
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (err: any) {
|
||||
error.value = getErrorMessage(err, 'auth.verificationFailed')
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete KYC (Know Your Customer) — full profile submission.
|
||||
* POSTs the KYC data to /auth/complete-kyc, then refreshes the user profile.
|
||||
* Returns true on success, throws on failure.
|
||||
*/
|
||||
async function completeKyc(kycData: Record<string, any>): Promise<boolean> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
await api.post('/auth/complete-kyc', kycData)
|
||||
// Refresh the user profile so isKycComplete getter updates
|
||||
await fetchUser()
|
||||
return true
|
||||
} catch (err: any) {
|
||||
error.value = getErrorMessage(err, 'auth.kycFailed')
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's Person record (profile data + address).
|
||||
* PUT /users/me/person with PersonUpdate schema.
|
||||
* On success, refreshes the local user state with the response.
|
||||
*/
|
||||
async function updatePerson(personData: Record<string, any>): Promise<boolean> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const res = await api.put('/users/me/person', personData)
|
||||
// Update the local user state with the fresh response
|
||||
user.value = res.data as UserProfile
|
||||
return true
|
||||
} catch (err: any) {
|
||||
error.value = getErrorMessage(err, 'auth.kycFailed')
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the current user's password.
|
||||
* PUT /users/me/password with { current_password, new_password }.
|
||||
* On success, returns { status: 'ok', message: string }.
|
||||
*/
|
||||
async function changePassword(currentPassword: string, newPassword: string): Promise<{ status: string; message: string }> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const res = await api.put('/users/me/password', {
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
})
|
||||
return res.data as { status: string; message: string }
|
||||
} catch (err: any) {
|
||||
error.value = getErrorMessage(err, 'A jelszó módosítása sikertelen.')
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the current user's account (soft delete).
|
||||
* DELETE /users/me with optional reason as query parameter.
|
||||
* On success, clears all local state and redirects to landing page.
|
||||
* If the backend returns is_last_admin=true, the response includes
|
||||
* a warning detail that the caller can display.
|
||||
*/
|
||||
async function deleteAccount(reason?: string): Promise<{ is_last_admin?: boolean }> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const params: Record<string, string> = {}
|
||||
if (reason) {
|
||||
params.reason = reason
|
||||
}
|
||||
const res = await api.delete('/users/me', { params })
|
||||
// Check if backend returned is_last_admin warning
|
||||
const isLastAdmin = res.data?.is_last_admin === true
|
||||
// Clear all local state — same as logout but without API call
|
||||
token.value = null
|
||||
user.value = null
|
||||
myOrganizations.value = []
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
router.push('/')
|
||||
return { is_last_admin: isLastAdmin }
|
||||
} catch (err: any) {
|
||||
// If the error response contains is_last_admin, pass it through
|
||||
if (err.response?.data?.is_last_admin) {
|
||||
error.value = err.response?.data?.detail || 'profile.deleteError'
|
||||
throw { ...err, is_last_admin: true }
|
||||
}
|
||||
error.value = err?.response?.data?.detail || 'profile.deleteError'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request account restoration code (Step 1 of Account Restore).
|
||||
* POST /auth/restore/request with { email }.
|
||||
* Sends a 6-digit OTP to the user's email.
|
||||
*/
|
||||
async function requestRestore(email: string): Promise<{ status: string; message: string }> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const res = await api.post('/auth/restore/request', { email })
|
||||
return res.data as { status: string; message: string }
|
||||
} catch (err: any) {
|
||||
error.value = getErrorMessage(err, 'auth.restoreError')
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify account restoration code and set new password (Step 2 of Account Restore).
|
||||
* POST /auth/restore/verify with { email, otp, new_password }.
|
||||
* On success, returns JWT tokens and auto-logs in the user.
|
||||
*/
|
||||
async function verifyRestore(email: string, otp: string, newPassword: string): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const res = await api.post('/auth/restore/verify', {
|
||||
email,
|
||||
otp,
|
||||
new_password: newPassword,
|
||||
})
|
||||
const data = res.data as {
|
||||
access_token: string
|
||||
refresh_token?: string
|
||||
token_type: string
|
||||
}
|
||||
|
||||
// Persist token
|
||||
localStorage.setItem('access_token', data.access_token)
|
||||
if (data.refresh_token) {
|
||||
localStorage.setItem('refresh_token', data.refresh_token)
|
||||
}
|
||||
token.value = data.access_token
|
||||
|
||||
// Fetch user profile and organizations
|
||||
await fetchUser()
|
||||
await fetchMyOrganizations()
|
||||
} catch (err: any) {
|
||||
error.value = getErrorMessage(err, 'auth.restoreError')
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear any stored error message.
|
||||
*/
|
||||
function clearError() {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
// ──────────────────────── Organization Actions ──────────────────────
|
||||
|
||||
/**
|
||||
* Fetch the current user's organizations from GET /api/v1/organizations/my.
|
||||
* Stores them in myOrganizations state.
|
||||
*/
|
||||
async function fetchMyOrganizations() {
|
||||
try {
|
||||
const res = await api.get('/organizations/my')
|
||||
myOrganizations.value = res.data as OrganizationItem[]
|
||||
return myOrganizations.value
|
||||
} catch (err: any) {
|
||||
console.error('Failed to fetch organizations:', err)
|
||||
myOrganizations.value = []
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's first business organization (org_type === 'business' or similar).
|
||||
* Returns the organization item or null if none found.
|
||||
*/
|
||||
const businessOrganization = computed(() => {
|
||||
return myOrganizations.value.find(
|
||||
(org) => org.org_type === 'business' || org.org_type === 'fleet_owner'
|
||||
) || null
|
||||
})
|
||||
|
||||
/**
|
||||
* Check if the user has any business organization.
|
||||
*/
|
||||
const hasBusinessOrganization = computed(() => {
|
||||
return !!businessOrganization.value
|
||||
})
|
||||
|
||||
/**
|
||||
* Check if the user is currently in corporate mode
|
||||
* (active_organization_id is set and matches a business org).
|
||||
*/
|
||||
const isCorporateMode = computed(() => {
|
||||
return !!user.value?.active_organization_id
|
||||
})
|
||||
|
||||
/**
|
||||
* Switch the user's active organization.
|
||||
* PATCH /api/v1/users/me/active-organization with { organization_id }.
|
||||
* On success, updates the token and user state, then navigates.
|
||||
*/
|
||||
async function switchOrganization(orgId: number | null) {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const res = await api.patch('/users/me/active-organization', {
|
||||
organization_id: orgId,
|
||||
})
|
||||
|
||||
const data = res.data as {
|
||||
user: UserProfile
|
||||
access_token: string
|
||||
token_type: string
|
||||
}
|
||||
|
||||
// Update the stored token
|
||||
localStorage.setItem('access_token', data.access_token)
|
||||
token.value = data.access_token
|
||||
|
||||
// Update the user state with the fresh profile
|
||||
user.value = data.user
|
||||
} catch (err: any) {
|
||||
error.value =
|
||||
err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
'Failed to switch organization'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
user,
|
||||
token,
|
||||
isLoading,
|
||||
error,
|
||||
myOrganizations,
|
||||
isInitialized,
|
||||
|
||||
// Getters
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
isKycComplete,
|
||||
userRole,
|
||||
userName,
|
||||
userId,
|
||||
businessOrganization,
|
||||
hasBusinessOrganization,
|
||||
isCorporateMode,
|
||||
|
||||
// RBAC Phase 3: Capability Helpers
|
||||
hasSystemCapability,
|
||||
hasOrgCapability,
|
||||
getOrgCapabilities,
|
||||
|
||||
// Actions
|
||||
login,
|
||||
fetchUser,
|
||||
register,
|
||||
resendVerification,
|
||||
verifyAccount,
|
||||
forgotPassword,
|
||||
resetPassword,
|
||||
completeKyc,
|
||||
updatePerson,
|
||||
changePassword,
|
||||
deleteAccount,
|
||||
requestRestore,
|
||||
verifyRestore,
|
||||
logout,
|
||||
init,
|
||||
clearError,
|
||||
fetchMyOrganizations,
|
||||
switchOrganization,
|
||||
}
|
||||
})
|
||||
157
frontend_app/src/stores/authStore.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export type UserRole = 'guest' | 'user' | 'manager' | 'admin' | 'superadmin'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
// ── State ──────────────────────────────────────────────────────────
|
||||
const isAuthenticated = ref(false)
|
||||
const userRole = ref<UserRole>('guest')
|
||||
const userName = ref('')
|
||||
const userId = ref<number | null>(null)
|
||||
|
||||
/**
|
||||
* RBAC Phase 3: Rendszerszintű képességek (a SYSTEM_CAPABILITIES_MATRIX-ból).
|
||||
* Pl. { can_manage_users: false, can_manage_own_vehicles: true, ... }
|
||||
*/
|
||||
const systemCapabilities = ref<Record<string, boolean>>({})
|
||||
|
||||
/**
|
||||
* RBAC Phase 3: Szervezeti szintű képességek.
|
||||
* Kulcs: organization_id (string), Érték: { capability_name: boolean }
|
||||
* Pl. { "5": { can_approve_expense: true, can_add_expense: true } }
|
||||
*/
|
||||
const orgCapabilities = ref<Record<string, Record<string, boolean>>>({})
|
||||
|
||||
// ── Getters ────────────────────────────────────────────────────────
|
||||
const isAdmin = computed(() => {
|
||||
// CRITICAL: Backend sends UPPERCASE roles (SUPERADMIN, ADMIN, MODERATOR)
|
||||
// Must use toUpperCase() to ensure case-insensitive matching
|
||||
const role = userRole.value.toUpperCase()
|
||||
return role === 'ADMIN' || role === 'SUPERADMIN'
|
||||
})
|
||||
|
||||
const isSuperAdmin = computed(() => userRole.value.toUpperCase() === 'SUPERADMIN')
|
||||
const isManager = computed(() => userRole.value.toUpperCase() === 'MANAGER')
|
||||
const isRegularUser = computed(() => userRole.value.toUpperCase() === 'USER')
|
||||
|
||||
// ── RBAC Phase 3: Capability Helpers ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Ellenőrzi, hogy a felhasználó rendelkezik-e egy adott rendszerszintű képességgel.
|
||||
* @param capability - A képesség neve (pl. 'can_manage_users')
|
||||
* @returns true, ha a képesség elérhető a user szerepköréhez
|
||||
*/
|
||||
function hasSystemCapability(capability: string): boolean {
|
||||
return systemCapabilities.value[capability] === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Ellenőrzi, hogy a felhasználó rendelkezik-e egy adott szervezeti képességgel.
|
||||
* @param orgId - A szervezet azonosítója (string vagy number)
|
||||
* @param capability - A képesség neve (pl. 'can_approve_expense')
|
||||
* @returns true, ha a képesség elérhető a user számára az adott szervezetben
|
||||
*/
|
||||
function hasOrgCapability(orgId: string | number, capability: string): boolean {
|
||||
const orgIdStr = String(orgId)
|
||||
const orgCaps = orgCapabilities.value[orgIdStr]
|
||||
if (!orgCaps) return false
|
||||
return orgCaps[capability] === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Visszaadja az összes szervezeti képességet egy adott szervezethez.
|
||||
* @param orgId - A szervezet azonosítója
|
||||
* @returns A képességek objektuma, vagy üres objektum, ha nincs
|
||||
*/
|
||||
function getOrgCapabilities(orgId: string | number): Record<string, boolean> {
|
||||
return orgCapabilities.value[String(orgId)] || {}
|
||||
}
|
||||
|
||||
// ── Actions ────────────────────────────────────────────────────────
|
||||
function login(role: UserRole, name: string, id: number) {
|
||||
isAuthenticated.value = true
|
||||
userRole.value = role
|
||||
userName.value = name
|
||||
userId.value = id
|
||||
localStorage.setItem('auth', JSON.stringify({ role, name, id }))
|
||||
}
|
||||
|
||||
function logout() {
|
||||
isAuthenticated.value = false
|
||||
userRole.value = 'guest'
|
||||
userName.value = ''
|
||||
userId.value = null
|
||||
systemCapabilities.value = {}
|
||||
orgCapabilities.value = {}
|
||||
localStorage.removeItem('auth')
|
||||
}
|
||||
|
||||
function init() {
|
||||
const saved = localStorage.getItem('auth')
|
||||
if (saved) {
|
||||
try {
|
||||
const { role, name, id } = JSON.parse(saved)
|
||||
isAuthenticated.value = true
|
||||
userRole.value = role
|
||||
userName.value = name
|
||||
userId.value = id
|
||||
} catch {
|
||||
logout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RBAC Phase 3: Frissíti a képességeket a backend /users/me válaszából.
|
||||
* @param systemCaps - Rendszerszintű képességek objektuma
|
||||
* @param orgCaps - Szervezeti képességek objektuma (org_id -> { capability: bool })
|
||||
*/
|
||||
function setCapabilities(
|
||||
systemCaps: Record<string, boolean>,
|
||||
orgCaps: Record<string, Record<string, boolean>>
|
||||
) {
|
||||
systemCapabilities.value = systemCaps
|
||||
orgCapabilities.value = orgCaps
|
||||
}
|
||||
|
||||
// Simulate fetching user role from backend
|
||||
async function fetchUserRole() {
|
||||
// In a real app, this would be an API call
|
||||
// For now, we'll simulate based on localStorage
|
||||
const saved = localStorage.getItem('auth')
|
||||
if (saved) {
|
||||
const { role } = JSON.parse(saved)
|
||||
userRole.value = role
|
||||
}
|
||||
return userRole.value
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
isAuthenticated,
|
||||
userRole,
|
||||
userName,
|
||||
userId,
|
||||
systemCapabilities,
|
||||
orgCapabilities,
|
||||
|
||||
// Getters
|
||||
isAdmin,
|
||||
isSuperAdmin,
|
||||
isManager,
|
||||
isRegularUser,
|
||||
|
||||
// RBAC Phase 3: Capability Helpers
|
||||
hasSystemCapability,
|
||||
hasOrgCapability,
|
||||
getOrgCapabilities,
|
||||
|
||||
// Actions
|
||||
login,
|
||||
logout,
|
||||
init,
|
||||
setCapabilities,
|
||||
fetchUserRole,
|
||||
}
|
||||
})
|
||||