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:
@@ -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>
|
||||
|
||||
5
frontend/public/favicon.svg
Normal file
5
frontend/public/favicon.svg
Normal 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 |
BIN
frontend/public/icon-192.png
Normal file
BIN
frontend/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 547 B |
BIN
frontend/public/icon-512.png
Normal file
BIN
frontend/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
54
frontend/src/components/common/UpgradeBanner.vue
Normal file
54
frontend/src/components/common/UpgradeBanner.vue
Normal 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>
|
||||
21
frontend/src/composables/useOffline.js
Normal file
21
frontend/src/composables/useOffline.js
Normal 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 }
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user