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