admin felület különválasztva

This commit is contained in:
Roo
2026-06-24 11:29:45 +00:00
parent 71ef33bb85
commit 80a5d67f79
462 changed files with 87873 additions and 312 deletions

132
frontend_app/.gitignore vendored Normal file
View 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
View 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;"]

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

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

File diff suppressed because it is too large Load Diff

35
frontend_app/package.json Normal file
View 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"
}
}

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

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 MiB

View 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

View 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

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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 MiB

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

File diff suppressed because it is too large Load Diff

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

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

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

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

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

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

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

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

View File

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

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

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

View 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"
>&times;</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>

View 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"
>&times;</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>

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

View File

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

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

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

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

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

View 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"
>
&bdquo;{{ vehicle.nickname }}&rdquo;
</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>

File diff suppressed because it is too large Load Diff

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

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

View 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>&#128184;</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>&#128737;</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>&#128202;</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>

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

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

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

View 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.',
},
}

View 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

File diff suppressed because it is too large Load Diff

1672
frontend_app/src/i18n/hu.ts Normal file

File diff suppressed because it is too large Load Diff

View 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.',
},
}

View 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.',
},
}

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

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

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

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

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

View 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

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

View 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

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

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

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

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

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

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

Some files were not shown because too many files have changed in this diff Show More