feat(frontend): Vue 3 + wa-sqlite + sync engine + auth + layouts
- package.json with Vue 3, Pinia, vue-router, wa-sqlite, Tailwind CSS 4, Vite - wa-sqlite database layer with IDBBatchAtomicVFS (offline-first) - Full schema mirroring backend tables (vehicles, orders, invoices, etc.) - SyncEngine: fullSync, incrementalSync, pushQueue for offline queue - Auth store with JWT parsing, login/register, plan tier detection - Router with all routes and auth navigation guards - AppLayout (sidebar desktop / bottom nav mobile) + AuthLayout - Login/Register views connected to API contract - SyncIndicator component (online/offline status) - Reactive SQL query composable (useSqlQuery) - Placeholder views for dashboard, orders, vehicles, appointments, catalog, settings Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ro">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>ROA AUTO</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
6861
frontend/package-lock.json
generated
Normal file
6861
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
frontend/package.json
Normal file
23
frontend/package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "roaauto-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5",
|
||||||
|
"vue-router": "^4.4",
|
||||||
|
"pinia": "^2.2",
|
||||||
|
"@journeyapps/wa-sqlite": "^1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2",
|
||||||
|
"vite": "^6.0",
|
||||||
|
"tailwindcss": "^4.0",
|
||||||
|
"@tailwindcss/vite": "^4.0",
|
||||||
|
"vite-plugin-pwa": "^0.21"
|
||||||
|
}
|
||||||
|
}
|
||||||
17
frontend/src/App.vue
Normal file
17
frontend/src/App.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<template>
|
||||||
|
<component :is="layout">
|
||||||
|
<router-view />
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import AppLayout from './layouts/AppLayout.vue'
|
||||||
|
import AuthLayout from './layouts/AuthLayout.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const layouts = { app: AppLayout, auth: AuthLayout }
|
||||||
|
const layout = computed(() => layouts[route.meta.layout] || AppLayout)
|
||||||
|
</script>
|
||||||
1
frontend/src/assets/css/main.css
Normal file
1
frontend/src/assets/css/main.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
29
frontend/src/components/common/SyncIndicator.vue
Normal file
29
frontend/src/components/common/SyncIndicator.vue
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex items-center gap-1.5 text-xs">
|
||||||
|
<span
|
||||||
|
class="inline-block w-2 h-2 rounded-full"
|
||||||
|
:class="online ? 'bg-green-400' : 'bg-red-400'"
|
||||||
|
/>
|
||||||
|
<span :class="online ? 'text-green-300' : 'text-red-300'">
|
||||||
|
{{ online ? 'Online' : 'Offline' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const online = ref(navigator.onLine)
|
||||||
|
|
||||||
|
function setOnline() { online.value = true }
|
||||||
|
function setOffline() { online.value = false }
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('online', setOnline)
|
||||||
|
window.addEventListener('offline', setOffline)
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('online', setOnline)
|
||||||
|
window.removeEventListener('offline', setOffline)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
22
frontend/src/composables/useSqlQuery.js
Normal file
22
frontend/src/composables/useSqlQuery.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { execSQL, onTableChange } from '../db/database.js'
|
||||||
|
|
||||||
|
export function useSqlQuery(sql, params = [], watchTables = []) {
|
||||||
|
const rows = ref([])
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
loading.value = true
|
||||||
|
try { rows.value = await execSQL(sql, params) }
|
||||||
|
finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubs = []
|
||||||
|
onMounted(() => {
|
||||||
|
refresh()
|
||||||
|
watchTables.forEach(t => unsubs.push(onTableChange(t, refresh)))
|
||||||
|
})
|
||||||
|
onUnmounted(() => unsubs.forEach(fn => fn()))
|
||||||
|
|
||||||
|
return { rows, loading, refresh }
|
||||||
|
}
|
||||||
43
frontend/src/db/database.js
Normal file
43
frontend/src/db/database.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import SQLiteESMFactory from '@journeyapps/wa-sqlite/dist/wa-sqlite-async.mjs'
|
||||||
|
import { IDBBatchAtomicVFS } from '@journeyapps/wa-sqlite/src/examples/IDBBatchAtomicVFS.js'
|
||||||
|
import * as SQLite from '@journeyapps/wa-sqlite'
|
||||||
|
import { SCHEMA_SQL } from './schema.js'
|
||||||
|
|
||||||
|
let db = null
|
||||||
|
let sqlite3 = null
|
||||||
|
const tableListeners = new Map()
|
||||||
|
|
||||||
|
export async function initDatabase() {
|
||||||
|
if (db) return db
|
||||||
|
const module = await SQLiteESMFactory()
|
||||||
|
sqlite3 = SQLite.Factory(module)
|
||||||
|
const vfs = await IDBBatchAtomicVFS.create('roaauto', module)
|
||||||
|
sqlite3.vfs_register(vfs, true)
|
||||||
|
db = await sqlite3.open_v2('roaauto.db',
|
||||||
|
SQLite.SQLITE_OPEN_READWRITE | SQLite.SQLITE_OPEN_CREATE, 'roaauto')
|
||||||
|
for (const sql of SCHEMA_SQL.split(';').filter(s => s.trim())) {
|
||||||
|
await sqlite3.exec(db, sql)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyTableChanged(table) {
|
||||||
|
tableListeners.get(table)?.forEach(cb => cb())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onTableChange(table, cb) {
|
||||||
|
if (!tableListeners.has(table)) tableListeners.set(table, new Set())
|
||||||
|
tableListeners.get(table).add(cb)
|
||||||
|
return () => tableListeners.get(table).delete(cb)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function execSQL(sql, params = []) {
|
||||||
|
if (!db) throw new Error('DB not initialized')
|
||||||
|
const results = []
|
||||||
|
await sqlite3.exec(db, sql, (row, cols) => {
|
||||||
|
const obj = {}
|
||||||
|
cols.forEach((c, i) => { obj[c] = row[i] })
|
||||||
|
results.push(obj)
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
}
|
||||||
88
frontend/src/db/schema.js
Normal file
88
frontend/src/db/schema.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
export const SCHEMA_SQL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS tenants (
|
||||||
|
id TEXT PRIMARY KEY, tenant_id TEXT, nume TEXT, cui TEXT, reg_com TEXT,
|
||||||
|
adresa TEXT, telefon TEXT, email TEXT, iban TEXT, banca TEXT,
|
||||||
|
plan TEXT DEFAULT 'free', trial_expires_at TEXT, created_at TEXT, updated_at TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS vehicles (
|
||||||
|
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL,
|
||||||
|
client_nume TEXT, client_telefon TEXT, client_email TEXT,
|
||||||
|
client_cod_fiscal TEXT, client_adresa TEXT,
|
||||||
|
nr_inmatriculare TEXT, marca_id TEXT, model_id TEXT,
|
||||||
|
an_fabricatie INTEGER, serie_sasiu TEXT, tip_motor_id TEXT,
|
||||||
|
oracle_id INTEGER, created_at TEXT, updated_at TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS orders (
|
||||||
|
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL,
|
||||||
|
nr_comanda TEXT, data_comanda TEXT, vehicle_id TEXT,
|
||||||
|
tip_deviz_id TEXT, status TEXT DEFAULT 'DRAFT',
|
||||||
|
km_intrare INTEGER, observatii TEXT,
|
||||||
|
client_nume TEXT, client_telefon TEXT, nr_auto TEXT,
|
||||||
|
marca_denumire TEXT, model_denumire TEXT,
|
||||||
|
total_manopera REAL DEFAULT 0, total_materiale REAL DEFAULT 0, total_general REAL DEFAULT 0,
|
||||||
|
token_client TEXT, created_by TEXT, oracle_id INTEGER, created_at TEXT, updated_at TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS order_lines (
|
||||||
|
id TEXT PRIMARY KEY, order_id TEXT NOT NULL, tenant_id TEXT NOT NULL,
|
||||||
|
tip TEXT, descriere TEXT,
|
||||||
|
norma_id TEXT, ore REAL, pret_ora REAL,
|
||||||
|
um TEXT, cantitate REAL, pret_unitar REAL,
|
||||||
|
total REAL, mecanic_id TEXT, ordine INTEGER,
|
||||||
|
oracle_id INTEGER, created_at TEXT, updated_at TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS invoices (
|
||||||
|
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, order_id TEXT,
|
||||||
|
nr_factura TEXT, serie_factura TEXT, data_factura TEXT,
|
||||||
|
modalitate_plata TEXT, client_nume TEXT, client_cod_fiscal TEXT, nr_auto TEXT,
|
||||||
|
total_fara_tva REAL, tva REAL, total_general REAL,
|
||||||
|
oracle_id INTEGER, created_at TEXT, updated_at TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS appointments (
|
||||||
|
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, vehicle_id TEXT,
|
||||||
|
client_nume TEXT, client_telefon TEXT, data_ora TEXT,
|
||||||
|
durata_minute INTEGER DEFAULT 60, observatii TEXT,
|
||||||
|
status TEXT DEFAULT 'PROGRAMAT', order_id TEXT,
|
||||||
|
oracle_id INTEGER, created_at TEXT, updated_at TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS catalog_marci (
|
||||||
|
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, denumire TEXT, activ INTEGER DEFAULT 1,
|
||||||
|
oracle_id INTEGER, updated_at TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS catalog_modele (
|
||||||
|
id TEXT PRIMARY KEY, marca_id TEXT, denumire TEXT, oracle_id INTEGER, updated_at TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS catalog_ansamble (
|
||||||
|
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, denumire TEXT, oracle_id INTEGER, updated_at TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS catalog_norme (
|
||||||
|
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, cod TEXT, denumire TEXT,
|
||||||
|
ore_normate REAL, ansamblu_id TEXT, oracle_id INTEGER, updated_at TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS catalog_preturi (
|
||||||
|
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, denumire TEXT, pret REAL, um TEXT,
|
||||||
|
oracle_id INTEGER, updated_at TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS catalog_tipuri_deviz (
|
||||||
|
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, denumire TEXT, oracle_id INTEGER, updated_at TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS catalog_tipuri_motoare (
|
||||||
|
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, denumire TEXT, oracle_id INTEGER, updated_at TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS mecanici (
|
||||||
|
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, user_id TEXT,
|
||||||
|
nume TEXT, prenume TEXT, activ INTEGER DEFAULT 1, oracle_id INTEGER, updated_at TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS _sync_queue (
|
||||||
|
id TEXT PRIMARY KEY, table_name TEXT, row_id TEXT,
|
||||||
|
operation TEXT, data_json TEXT, created_at TEXT, synced_at TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS _sync_state (table_name TEXT PRIMARY KEY, last_sync_at TEXT);
|
||||||
|
CREATE TABLE IF NOT EXISTS _local_settings (key TEXT PRIMARY KEY, value TEXT);
|
||||||
|
PRAGMA journal_mode=WAL;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SYNC_TABLES = [
|
||||||
|
'vehicles', 'orders', 'order_lines', 'invoices', 'appointments',
|
||||||
|
'catalog_marci', 'catalog_modele', 'catalog_ansamble', 'catalog_norme',
|
||||||
|
'catalog_preturi', 'catalog_tipuri_deviz', 'catalog_tipuri_motoare', 'mecanici'
|
||||||
|
];
|
||||||
88
frontend/src/db/sync.js
Normal file
88
frontend/src/db/sync.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { execSQL, notifyTableChanged } from './database.js'
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || '/api'
|
||||||
|
|
||||||
|
export class SyncEngine {
|
||||||
|
constructor() {
|
||||||
|
this.syncing = false
|
||||||
|
this.online = navigator.onLine
|
||||||
|
window.addEventListener('online', () => { this.online = true; this.pushQueue() })
|
||||||
|
window.addEventListener('offline', () => { this.online = false })
|
||||||
|
}
|
||||||
|
|
||||||
|
getToken() { return localStorage.getItem('token') }
|
||||||
|
|
||||||
|
async fullSync() {
|
||||||
|
const token = this.getToken()
|
||||||
|
if (!token) return
|
||||||
|
const res = await fetch(`${API_URL}/sync/full`, { headers: { Authorization: `Bearer ${token}` } })
|
||||||
|
if (!res.ok) return
|
||||||
|
const { tables, synced_at } = await res.json()
|
||||||
|
for (const [tableName, rows] of Object.entries(tables)) {
|
||||||
|
for (const row of rows) {
|
||||||
|
const cols = Object.keys(row).join(', ')
|
||||||
|
const ph = Object.keys(row).map(() => '?').join(', ')
|
||||||
|
await execSQL(`INSERT OR REPLACE INTO ${tableName} (${cols}) VALUES (${ph})`, Object.values(row))
|
||||||
|
}
|
||||||
|
notifyTableChanged(tableName)
|
||||||
|
await execSQL(`INSERT OR REPLACE INTO _sync_state VALUES (?, ?)`, [tableName, synced_at])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async incrementalSync() {
|
||||||
|
const token = this.getToken()
|
||||||
|
if (!token || !this.online) return
|
||||||
|
const [state] = await execSQL(`SELECT MIN(last_sync_at) as since FROM _sync_state`)
|
||||||
|
if (!state?.since) return this.fullSync()
|
||||||
|
const res = await fetch(`${API_URL}/sync/changes?since=${encodeURIComponent(state.since)}`,
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } })
|
||||||
|
if (!res.ok) return
|
||||||
|
const { tables, synced_at } = await res.json()
|
||||||
|
for (const [tableName, rows] of Object.entries(tables)) {
|
||||||
|
for (const row of rows) {
|
||||||
|
const cols = Object.keys(row).join(', ')
|
||||||
|
const ph = Object.keys(row).map(() => '?').join(', ')
|
||||||
|
await execSQL(`INSERT OR REPLACE INTO ${tableName} (${cols}) VALUES (${ph})`, Object.values(row))
|
||||||
|
}
|
||||||
|
if (rows.length) notifyTableChanged(tableName)
|
||||||
|
await execSQL(`INSERT OR REPLACE INTO _sync_state VALUES (?, ?)`, [tableName, synced_at])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addToQueue(tableName, rowId, operation, data) {
|
||||||
|
const id = crypto.randomUUID()
|
||||||
|
await execSQL(
|
||||||
|
`INSERT INTO _sync_queue (id, table_name, row_id, operation, data_json, created_at) VALUES (?,?,?,?,?,?)`,
|
||||||
|
[id, tableName, rowId, operation, JSON.stringify(data), new Date().toISOString()]
|
||||||
|
)
|
||||||
|
if (this.online) this.pushQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
async pushQueue() {
|
||||||
|
if (this.syncing) return
|
||||||
|
const token = this.getToken()
|
||||||
|
if (!token) return
|
||||||
|
this.syncing = true
|
||||||
|
try {
|
||||||
|
const queue = await execSQL(`SELECT * FROM _sync_queue WHERE synced_at IS NULL ORDER BY created_at`)
|
||||||
|
if (!queue.length) return
|
||||||
|
const res = await fetch(`${API_URL}/sync/push`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ operations: queue.map(q => ({
|
||||||
|
table: q.table_name, id: q.row_id, operation: q.operation,
|
||||||
|
data: JSON.parse(q.data_json), timestamp: q.created_at
|
||||||
|
})) })
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const ids = queue.map(() => '?').join(',')
|
||||||
|
await execSQL(`UPDATE _sync_queue SET synced_at=? WHERE id IN (${ids})`,
|
||||||
|
[new Date().toISOString(), ...queue.map(q => q.id)])
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.syncing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const syncEngine = new SyncEngine()
|
||||||
89
frontend/src/layouts/AppLayout.vue
Normal file
89
frontend/src/layouts/AppLayout.vue
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-gray-50">
|
||||||
|
<!-- Desktop sidebar -->
|
||||||
|
<aside class="hidden md:fixed md:inset-y-0 md:flex md:w-56 md:flex-col">
|
||||||
|
<div class="flex flex-col flex-grow bg-gray-900 overflow-y-auto">
|
||||||
|
<div class="px-4 py-5">
|
||||||
|
<h1 class="text-xl font-bold text-white">ROA AUTO</h1>
|
||||||
|
</div>
|
||||||
|
<nav class="flex-1 px-2 space-y-1">
|
||||||
|
<router-link
|
||||||
|
v-for="item in navItems"
|
||||||
|
:key="item.path"
|
||||||
|
:to="item.path"
|
||||||
|
class="flex items-center px-3 py-2 text-sm font-medium rounded-md text-gray-300 hover:bg-gray-800 hover:text-white"
|
||||||
|
active-class="!bg-gray-800 !text-white"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</router-link>
|
||||||
|
</nav>
|
||||||
|
<div class="px-4 py-3 border-t border-gray-700">
|
||||||
|
<SyncIndicator />
|
||||||
|
<button
|
||||||
|
@click="logout"
|
||||||
|
class="mt-2 w-full text-left text-sm text-gray-400 hover:text-white"
|
||||||
|
>
|
||||||
|
Deconectare
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<div class="md:pl-56">
|
||||||
|
<!-- Mobile header -->
|
||||||
|
<header class="md:hidden bg-white border-b border-gray-200 px-4 py-3 flex items-center justify-between">
|
||||||
|
<h1 class="text-lg font-bold text-gray-900">ROA AUTO</h1>
|
||||||
|
<SyncIndicator />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="p-4 md:p-6">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Mobile bottom nav -->
|
||||||
|
<nav class="md:hidden fixed bottom-0 inset-x-0 bg-white border-t border-gray-200 flex">
|
||||||
|
<router-link
|
||||||
|
v-for="item in mobileNavItems"
|
||||||
|
:key="item.path"
|
||||||
|
:to="item.path"
|
||||||
|
class="flex-1 flex flex-col items-center py-2 text-xs text-gray-500"
|
||||||
|
active-class="!text-blue-600"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</router-link>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '../stores/auth.js'
|
||||||
|
import SyncIndicator from '../components/common/SyncIndicator.vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ path: '/dashboard', label: 'Dashboard' },
|
||||||
|
{ path: '/orders', label: 'Comenzi' },
|
||||||
|
{ path: '/vehicles', label: 'Vehicule' },
|
||||||
|
{ path: '/appointments', label: 'Programari' },
|
||||||
|
{ path: '/catalog', label: 'Catalog' },
|
||||||
|
{ path: '/settings', label: 'Setari' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const mobileNavItems = [
|
||||||
|
{ path: '/dashboard', label: 'Acasa' },
|
||||||
|
{ path: '/orders', label: 'Comenzi' },
|
||||||
|
{ path: '/vehicles', label: 'Vehicule' },
|
||||||
|
{ path: '/appointments', label: 'Programari' },
|
||||||
|
{ path: '/settings', label: 'Setari' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
auth.logout()
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
11
frontend/src/layouts/AuthLayout.vue
Normal file
11
frontend/src/layouts/AuthLayout.vue
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-gray-100 flex items-center justify-center p-4">
|
||||||
|
<div class="w-full max-w-md">
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">ROA AUTO</h1>
|
||||||
|
<p class="text-gray-500 mt-1">Management Service Auto</p>
|
||||||
|
</div>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
21
frontend/src/main.js
Normal file
21
frontend/src/main.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router/index.js'
|
||||||
|
import { initDatabase } from './db/database.js'
|
||||||
|
import { syncEngine } from './db/sync.js'
|
||||||
|
import { useAuthStore } from './stores/auth.js'
|
||||||
|
import './assets/css/main.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
const pinia = createPinia()
|
||||||
|
|
||||||
|
app.use(pinia)
|
||||||
|
app.use(router)
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
if (auth.isAuthenticated) {
|
||||||
|
initDatabase().then(() => syncEngine.fullSync())
|
||||||
|
}
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
27
frontend/src/router/index.js
Normal file
27
frontend/src/router/index.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import { useAuthStore } from '../stores/auth.js'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/login', component: () => import('../views/auth/LoginView.vue'), meta: { layout: 'auth' } },
|
||||||
|
{ path: '/register', component: () => import('../views/auth/RegisterView.vue'), meta: { layout: 'auth' } },
|
||||||
|
{ path: '/dashboard', component: () => import('../views/dashboard/DashboardView.vue'), meta: { requiresAuth: true } },
|
||||||
|
{ path: '/orders', component: () => import('../views/orders/OrdersListView.vue'), meta: { requiresAuth: true } },
|
||||||
|
{ path: '/orders/new', component: () => import('../views/orders/OrderCreateView.vue'), meta: { requiresAuth: true } },
|
||||||
|
{ path: '/orders/:id', component: () => import('../views/orders/OrderDetailView.vue'), meta: { requiresAuth: true } },
|
||||||
|
{ path: '/vehicles', component: () => import('../views/vehicles/VehiclesListView.vue'), meta: { requiresAuth: true } },
|
||||||
|
{ path: '/appointments', component: () => import('../views/appointments/AppointmentsView.vue'), meta: { requiresAuth: true } },
|
||||||
|
{ path: '/catalog', component: () => import('../views/catalog/CatalogView.vue'), meta: { requiresAuth: true } },
|
||||||
|
{ path: '/settings', component: () => import('../views/settings/SettingsView.vue'), meta: { requiresAuth: true } },
|
||||||
|
{ path: '/p/:token', component: () => import('../views/client/DevizPublicView.vue') },
|
||||||
|
{ path: '/', redirect: '/dashboard' },
|
||||||
|
],
|
||||||
|
scrollBehavior: () => ({ top: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to) => {
|
||||||
|
if (to.meta.requiresAuth && !useAuthStore().isAuthenticated) return '/login'
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
43
frontend/src/stores/auth.js
Normal file
43
frontend/src/stores/auth.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || '/api'
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
const token = ref(localStorage.getItem('token'))
|
||||||
|
const payload = computed(() => {
|
||||||
|
if (!token.value) return null
|
||||||
|
try { return JSON.parse(atob(token.value.split('.')[1])) } catch { return null }
|
||||||
|
})
|
||||||
|
const isAuthenticated = computed(() => !!token.value && payload.value?.exp * 1000 > Date.now())
|
||||||
|
const tenantId = computed(() => payload.value?.tenant_id)
|
||||||
|
const plan = computed(() => payload.value?.plan || 'free')
|
||||||
|
|
||||||
|
async function login(email, password) {
|
||||||
|
const res = await fetch(`${API_URL}/auth/login`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password })
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error('Credentiale invalide')
|
||||||
|
const data = await res.json()
|
||||||
|
token.value = data.access_token
|
||||||
|
localStorage.setItem('token', data.access_token)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function register(email, password, tenant_name, telefon) {
|
||||||
|
const res = await fetch(`${API_URL}/auth/register`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password, tenant_name, telefon })
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error('Inregistrare esuata')
|
||||||
|
const data = await res.json()
|
||||||
|
token.value = data.access_token
|
||||||
|
localStorage.setItem('token', data.access_token)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() { token.value = null; localStorage.removeItem('token') }
|
||||||
|
|
||||||
|
return { token, isAuthenticated, tenantId, plan, login, register, logout }
|
||||||
|
})
|
||||||
6
frontend/src/views/appointments/AppointmentsView.vue
Normal file
6
frontend/src/views/appointments/AppointmentsView.vue
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 mb-6">Programari</h1>
|
||||||
|
<p class="text-gray-500">Programari - va fi implementat in TASK-006.</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
70
frontend/src/views/auth/LoginView.vue
Normal file
70
frontend/src/views/auth/LoginView.vue
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-6">Autentificare</h2>
|
||||||
|
<form @submit.prevent="handleLogin" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||||
|
<input
|
||||||
|
v-model="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="email@exemplu.ro"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Parola</label>
|
||||||
|
<input
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Parola"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="loading"
|
||||||
|
class="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ loading ? 'Se autentifica...' : 'Autentificare' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="mt-4 text-center text-sm text-gray-500">
|
||||||
|
Nu ai cont?
|
||||||
|
<router-link to="/register" class="text-blue-600 hover:underline">Inregistreaza-te</router-link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '../../stores/auth.js'
|
||||||
|
import { initDatabase } from '../../db/database.js'
|
||||||
|
import { syncEngine } from '../../db/sync.js'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
const email = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
error.value = ''
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await auth.login(email.value, password.value)
|
||||||
|
await initDatabase()
|
||||||
|
syncEngine.fullSync()
|
||||||
|
router.push('/dashboard')
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
93
frontend/src/views/auth/RegisterView.vue
Normal file
93
frontend/src/views/auth/RegisterView.vue
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-6">Inregistrare</h2>
|
||||||
|
<form @submit.prevent="handleRegister" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Numele service-ului</label>
|
||||||
|
<input
|
||||||
|
v-model="tenantName"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Service Auto Ionescu"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Telefon</label>
|
||||||
|
<input
|
||||||
|
v-model="telefon"
|
||||||
|
type="tel"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="0722 000 000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||||
|
<input
|
||||||
|
v-model="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="email@exemplu.ro"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Parola</label>
|
||||||
|
<input
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minlength="6"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Minim 6 caractere"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="loading"
|
||||||
|
class="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ loading ? 'Se creeaza contul...' : 'Creeaza cont (trial 30 zile)' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="mt-4 text-center text-sm text-gray-500">
|
||||||
|
Ai deja cont?
|
||||||
|
<router-link to="/login" class="text-blue-600 hover:underline">Autentificare</router-link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '../../stores/auth.js'
|
||||||
|
import { initDatabase } from '../../db/database.js'
|
||||||
|
import { syncEngine } from '../../db/sync.js'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
const tenantName = ref('')
|
||||||
|
const telefon = ref('')
|
||||||
|
const email = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function handleRegister() {
|
||||||
|
error.value = ''
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await auth.register(email.value, password.value, tenantName.value, telefon.value)
|
||||||
|
await initDatabase()
|
||||||
|
syncEngine.fullSync()
|
||||||
|
router.push('/dashboard')
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
6
frontend/src/views/catalog/CatalogView.vue
Normal file
6
frontend/src/views/catalog/CatalogView.vue
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 mb-6">Catalog</h1>
|
||||||
|
<p class="text-gray-500">Catalog - va fi implementat in TASK-006.</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
6
frontend/src/views/client/DevizPublicView.vue
Normal file
6
frontend/src/views/client/DevizPublicView.vue
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 mb-6">Deviz</h1>
|
||||||
|
<p class="text-gray-500">Portal public client - va fi implementat in TASK-008.</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
6
frontend/src/views/dashboard/DashboardView.vue
Normal file
6
frontend/src/views/dashboard/DashboardView.vue
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 mb-6">Dashboard</h1>
|
||||||
|
<p class="text-gray-500">Dashboard - va fi implementat in TASK-006.</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
6
frontend/src/views/orders/OrderCreateView.vue
Normal file
6
frontend/src/views/orders/OrderCreateView.vue
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 mb-6">Comanda noua</h1>
|
||||||
|
<p class="text-gray-500">Creare comanda - va fi implementat in TASK-006.</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
6
frontend/src/views/orders/OrderDetailView.vue
Normal file
6
frontend/src/views/orders/OrderDetailView.vue
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 mb-6">Detalii comanda</h1>
|
||||||
|
<p class="text-gray-500">Detalii comanda - va fi implementat in TASK-008.</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
6
frontend/src/views/orders/OrdersListView.vue
Normal file
6
frontend/src/views/orders/OrdersListView.vue
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 mb-6">Comenzi</h1>
|
||||||
|
<p class="text-gray-500">Lista comenzi - va fi implementat in TASK-006.</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
6
frontend/src/views/settings/SettingsView.vue
Normal file
6
frontend/src/views/settings/SettingsView.vue
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 mb-6">Setari</h1>
|
||||||
|
<p class="text-gray-500">Setari - va fi implementat in TASK-011.</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
6
frontend/src/views/vehicles/VehiclesListView.vue
Normal file
6
frontend/src/views/vehicles/VehiclesListView.vue
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 mb-6">Vehicule</h1>
|
||||||
|
<p class="text-gray-500">Lista vehicule - va fi implementat in TASK-006.</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
21
frontend/vite.config.js
Normal file
21
frontend/vite.config.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
tailwindcss(),
|
||||||
|
],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
exclude: ['@journeyapps/wa-sqlite'],
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user