feat(frontend): PWA + Backup/Restore + Upgrade Prompts + Settings

- PWA: vite-plugin-pwa with autoUpdate service worker, manifest, offline
  caching for assets + NetworkFirst for API calls
- PWA icons: 192x192 and 512x512 placeholder PNGs + favicon.svg
- index.html: theme-color, apple-touch-icon, description meta tags
- UpgradeBanner component: trial expiry warning with upgrade CTA
- SettingsView: complete settings page with:
  - Plan info display
  - Tenant profile form (firma, CUI, reg com, adresa, IBAN, banca)
  - Backup export (JSON with all tenant data from wa-sqlite)
  - Restore import (JSON file with validation and INSERT OR REPLACE)
  - User management with invite form (email + rol)
  - Logout button
- useOffline composable: shared reactive online/offline state
- DashboardView: added UpgradeBanner at top

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 17:35:06 +02:00
parent 3bdafad22a
commit 1686efeead
9 changed files with 402 additions and 1 deletions

View File

@@ -3,6 +3,9 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#111827" />
<meta name="description" content="ROA AUTO - Management Service Auto" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<title>ROA AUTO</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head>

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="20" fill="#111827"/>
<text x="50" y="38" font-family="Arial,sans-serif" font-size="22" font-weight="bold" fill="#fff" text-anchor="middle">ROA</text>
<text x="50" y="68" font-family="Arial,sans-serif" font-size="22" font-weight="bold" fill="#3b82f6" text-anchor="middle">AUTO</text>
</svg>

After

Width:  |  Height:  |  Size: 394 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,54 @@
<template>
<div
v-if="showBanner"
class="bg-amber-50 border-l-4 border-amber-400 p-4 mb-4"
>
<div class="flex items-start">
<div class="flex-1">
<h3 class="text-sm font-medium text-amber-800">
{{ isExpired ? 'Perioada de trial a expirat' : 'Trial activ' }}
</h3>
<p class="text-sm text-amber-700 mt-1">
<template v-if="isExpired">
Contul tau a depasit perioada de trial. Upgradeaza la un plan platit pentru a continua sa folosesti toate functiile.
</template>
<template v-else>
Mai ai {{ daysRemaining }} zile de trial. Upgradeaza pentru acces complet.
</template>
</p>
</div>
<a
href="mailto:contact@roaauto.ro?subject=Upgrade%20plan"
class="ml-4 px-3 py-1.5 bg-amber-600 text-white text-sm rounded-md hover:bg-amber-700 whitespace-nowrap"
>
Upgradeaza
</a>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useAuthStore } from '../../stores/auth.js'
const auth = useAuthStore()
const showBanner = computed(() => auth.plan === 'trial')
const trialExpiresAt = computed(() => {
// JWT payload may have trial info, or we check local db
// For now use a simple heuristic from plan
return null
})
const isExpired = computed(() => {
if (!trialExpiresAt.value) return false
return new Date(trialExpiresAt.value) < new Date()
})
const daysRemaining = computed(() => {
if (!trialExpiresAt.value) return 30
const diff = new Date(trialExpiresAt.value) - new Date()
return Math.max(0, Math.ceil(diff / (1000 * 60 * 60 * 24)))
})
</script>

View File

@@ -0,0 +1,21 @@
import { ref, onMounted, onUnmounted } from 'vue'
const online = ref(navigator.onLine)
function setOnline() { online.value = true }
function setOffline() { online.value = false }
let listenersAttached = false
export function useOffline() {
onMounted(() => {
if (!listenersAttached) {
window.addEventListener('online', setOnline)
window.addEventListener('offline', setOffline)
listenersAttached = true
}
online.value = navigator.onLine
})
return { online }
}

View File

@@ -2,6 +2,8 @@
<div>
<h1 class="text-2xl font-bold text-gray-900 mb-6">Dashboard</h1>
<UpgradeBanner />
<!-- Stats cards -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div class="bg-white rounded-lg shadow p-4">
@@ -70,6 +72,7 @@
import { ref, onMounted } from 'vue'
import { useOrdersStore } from '../../stores/orders.js'
import { useSync } from '../../composables/useSync.js'
import UpgradeBanner from '../../components/common/UpgradeBanner.vue'
useSync()

View File

@@ -1,6 +1,287 @@
<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>
<UpgradeBanner />
<div class="space-y-6 max-w-2xl">
<!-- Plan info -->
<div class="bg-white rounded-lg shadow p-4">
<h2 class="text-lg font-semibold text-gray-900 mb-3">Plan</h2>
<div class="flex items-center gap-3">
<span
class="px-3 py-1 rounded-full text-sm font-medium"
:class="auth.plan === 'trial' ? 'bg-amber-100 text-amber-800' : 'bg-green-100 text-green-800'"
>
{{ auth.plan.toUpperCase() }}
</span>
<span class="text-sm text-gray-500">Tenant: {{ auth.tenantId }}</span>
</div>
</div>
<!-- Profil firma -->
<div class="bg-white rounded-lg shadow p-4">
<h2 class="text-lg font-semibold text-gray-900 mb-3">Profil firma</h2>
<form @submit.prevent="saveTenant" class="space-y-3">
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-500 mb-1">Nume firma</label>
<input v-model="tenant.nume" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">CUI</label>
<input v-model="tenant.cui" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-500 mb-1">Reg. Com.</label>
<input v-model="tenant.reg_com" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Telefon</label>
<input v-model="tenant.telefon" type="tel" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Adresa</label>
<input v-model="tenant.adresa" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-500 mb-1">Email</label>
<input v-model="tenant.email" type="email" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">IBAN</label>
<input v-model="tenant.iban" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Banca</label>
<input v-model="tenant.banca" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
<button type="submit" :disabled="savingTenant" class="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 disabled:opacity-50">
{{ savingTenant ? 'Se salveaza...' : 'Salveaza' }}
</button>
<span v-if="tenantSaved" class="ml-2 text-sm text-green-600">Salvat!</span>
</form>
</div>
<!-- Backup / Restore -->
<div class="bg-white rounded-lg shadow p-4">
<h2 class="text-lg font-semibold text-gray-900 mb-3">Backup / Restore</h2>
<div class="flex gap-3">
<button
@click="handleBackup"
:disabled="backingUp"
class="px-4 py-2 border border-gray-300 text-sm rounded-md hover:bg-gray-50 disabled:opacity-50"
>
{{ backingUp ? 'Se exporta...' : 'Exporta backup (.json)' }}
</button>
<label class="px-4 py-2 border border-gray-300 text-sm rounded-md hover:bg-gray-50 cursor-pointer">
Importa backup
<input type="file" accept=".json" class="hidden" @change="handleRestore" />
</label>
</div>
<p v-if="backupMessage" class="mt-2 text-sm" :class="backupError ? 'text-red-600' : 'text-green-600'">
{{ backupMessage }}
</p>
</div>
<!-- Utilizatori -->
<div class="bg-white rounded-lg shadow p-4">
<div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-semibold text-gray-900">Utilizatori</h2>
<button
@click="showInvite = !showInvite"
class="text-sm text-blue-600 hover:underline"
>
{{ showInvite ? 'Anuleaza' : '+ Invita' }}
</button>
</div>
<!-- Invite form -->
<form v-if="showInvite" @submit.prevent="handleInvite" class="mb-4 p-3 bg-gray-50 rounded-md space-y-2">
<div class="grid grid-cols-2 gap-2">
<input v-model="inviteEmail" type="email" required placeholder="Email" class="px-3 py-2 border border-gray-300 rounded-md text-sm" />
<select v-model="inviteRol" class="px-3 py-2 border border-gray-300 rounded-md text-sm">
<option value="admin">Admin</option>
<option value="mecanic">Mecanic</option>
</select>
</div>
<button type="submit" :disabled="inviting" class="px-3 py-1.5 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 disabled:opacity-50">
{{ inviting ? 'Se trimite...' : 'Trimite invitatie' }}
</button>
<span v-if="inviteMessage" class="ml-2 text-sm text-green-600">{{ inviteMessage }}</span>
</form>
<div v-if="users.length === 0" class="text-sm text-gray-500">Niciun utilizator.</div>
<ul v-else class="divide-y divide-gray-100">
<li v-for="u in users" :key="u.id" class="py-2 flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-900">{{ u.email }}</p>
<p class="text-xs text-gray-500">{{ u.rol }}</p>
</div>
</li>
</ul>
</div>
<!-- Deconectare -->
<div class="bg-white rounded-lg shadow p-4">
<button @click="handleLogout" class="px-4 py-2 bg-red-600 text-white text-sm rounded-md hover:bg-red-700">
Deconectare
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../../stores/auth.js'
import { execSQL, notifyTableChanged } from '../../db/database.js'
import { syncEngine } from '../../db/sync.js'
import { SYNC_TABLES } from '../../db/schema.js'
import UpgradeBanner from '../../components/common/UpgradeBanner.vue'
const router = useRouter()
const auth = useAuthStore()
// Tenant profile
const tenant = reactive({ nume: '', cui: '', reg_com: '', telefon: '', adresa: '', email: '', iban: '', banca: '' })
const savingTenant = ref(false)
const tenantSaved = ref(false)
// Users
const users = ref([])
const showInvite = ref(false)
const inviteEmail = ref('')
const inviteRol = ref('admin')
const inviting = ref(false)
const inviteMessage = ref('')
// Backup
const backingUp = ref(false)
const backupMessage = ref('')
const backupError = ref(false)
onMounted(async () => {
// Load tenant
const rows = await execSQL(`SELECT * FROM tenants WHERE id = ? OR tenant_id = ? LIMIT 1`, [auth.tenantId, auth.tenantId])
if (rows[0]) Object.assign(tenant, rows[0])
// Load users (from API if online)
try {
const token = localStorage.getItem('token')
if (token) {
const API_URL = import.meta.env.VITE_API_URL || '/api'
const res = await fetch(`${API_URL}/users`, { headers: { Authorization: `Bearer ${token}` } })
if (res.ok) users.value = await res.json()
}
} catch { /* offline, skip */ }
})
async function saveTenant() {
savingTenant.value = true
tenantSaved.value = false
const now = new Date().toISOString()
await execSQL(
`UPDATE tenants SET nume=?, cui=?, reg_com=?, telefon=?, adresa=?, email=?, iban=?, banca=?, updated_at=? WHERE id=? OR tenant_id=?`,
[tenant.nume, tenant.cui, tenant.reg_com, tenant.telefon, tenant.adresa, tenant.email, tenant.iban, tenant.banca, now, auth.tenantId, auth.tenantId]
)
notifyTableChanged('tenants')
await syncEngine.addToQueue('tenants', auth.tenantId, 'UPDATE', {
nume: tenant.nume, cui: tenant.cui, reg_com: tenant.reg_com, telefon: tenant.telefon,
adresa: tenant.adresa, email: tenant.email, iban: tenant.iban, banca: tenant.banca
})
savingTenant.value = false
tenantSaved.value = true
setTimeout(() => { tenantSaved.value = false }, 3000)
}
async function handleBackup() {
backingUp.value = true
backupMessage.value = ''
backupError.value = false
try {
const backup = { version: 1, date: new Date().toISOString(), tables: {} }
for (const table of SYNC_TABLES) {
backup.tables[table] = await execSQL(`SELECT * FROM ${table} WHERE tenant_id = ?`, [auth.tenantId])
}
const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `roaauto-backup-${new Date().toISOString().split('T')[0]}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
backupMessage.value = 'Backup exportat cu succes!'
} catch (e) {
backupMessage.value = 'Eroare la export: ' + e.message
backupError.value = true
} finally {
backingUp.value = false
}
}
async function handleRestore(event) {
const file = event.target.files?.[0]
if (!file) return
backupMessage.value = ''
backupError.value = false
try {
const text = await file.text()
const backup = JSON.parse(text)
if (!backup.version || !backup.tables) {
throw new Error('Format backup invalid')
}
let totalRows = 0
for (const [tableName, rows] of Object.entries(backup.tables)) {
if (!SYNC_TABLES.includes(tableName)) continue
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))
totalRows++
}
notifyTableChanged(tableName)
}
backupMessage.value = `Restore complet: ${totalRows} randuri importate din ${Object.keys(backup.tables).length} tabele.`
} catch (e) {
backupMessage.value = 'Eroare la import: ' + e.message
backupError.value = true
}
event.target.value = ''
}
async function handleInvite() {
inviting.value = true
inviteMessage.value = ''
try {
const API_URL = import.meta.env.VITE_API_URL || '/api'
const token = localStorage.getItem('token')
const res = await fetch(`${API_URL}/users/invite`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ email: inviteEmail.value, rol: inviteRol.value })
})
if (!res.ok) throw new Error('Eroare la trimitere')
inviteMessage.value = 'Invitatie trimisa!'
inviteEmail.value = ''
showInvite.value = false
} catch (e) {
inviteMessage.value = e.message
} finally {
inviting.value = false
}
}
function handleLogout() {
auth.logout()
router.push('/login')
}
</script>

View File

@@ -1,11 +1,45 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
vue(),
tailwindcss(),
VitePWA({
registerType: 'autoUpdate',
workbox: {
globPatterns: ['**/*.{js,css,html,svg,wasm}'],
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
runtimeCaching: [
{
urlPattern: /^https?:\/\/.*\/api\//,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: { maxEntries: 50, maxAgeSeconds: 300 },
},
},
],
},
manifest: {
name: 'ROA AUTO - Management Service Auto',
short_name: 'ROA AUTO',
description: 'Aplicatie de management pentru service-uri auto',
theme_color: '#111827',
background_color: '#f9fafb',
display: 'standalone',
orientation: 'portrait-primary',
start_url: '/',
scope: '/',
icons: [
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
],
},
}),
],
server: {
proxy: {