From dc8c1bd69ada76daaa17d7fb5586b5400b4d5ac8 Mon Sep 17 00:00:00 2001 From: Luciano Milani Date: Fri, 3 Jul 2026 14:07:39 +0100 Subject: [PATCH] feat(ui): unify global visual identity, theme tokens and report filters --- .../internal/platformsettings/repository.go | 2 + .../src/components/layout/AdminLayout.tsx | 16 +- frontend/src/components/layout/AppLayout.tsx | 42 ++- frontend/src/components/ui/badge.tsx | 14 +- frontend/src/components/ui/button.tsx | 7 +- .../components/ui/confirm-action-dialog.tsx | 12 +- frontend/src/components/ui/dialog.tsx | 10 +- frontend/src/index.css | 14 + frontend/src/lib/color.ts | 55 ++++ frontend/src/lib/workOrderStatus.ts | 4 +- frontend/src/pages/admin/DashboardPage.tsx | 2 +- frontend/src/pages/admin/SettingsPage.tsx | 38 ++- frontend/src/pages/admin/TenantsPage.tsx | 2 +- frontend/src/pages/app/CatalogPage.tsx | 86 +++++- frontend/src/pages/app/ClientsPage.tsx | 55 +++- frontend/src/pages/app/DashboardPage.tsx | 11 +- frontend/src/pages/app/ExpenseReportsPage.tsx | 27 +- frontend/src/pages/app/ExpensesPage.tsx | 114 +++++--- frontend/src/pages/app/InvoicesPage.tsx | 14 +- frontend/src/pages/app/ReportsPage.tsx | 50 ++-- frontend/src/pages/app/SettingsPage.tsx | 6 +- frontend/src/pages/app/StaffPage.tsx | 80 +++++- .../src/pages/app/TechnicianReportsPage.tsx | 262 ++++++++++++++++-- .../src/pages/app/WorkOrderDetailPage.tsx | 18 +- frontend/src/pages/app/WorkOrdersPage.tsx | 70 +++-- frontend/src/pages/auth/LoginPage.tsx | 9 +- .../src/pages/public/InviteRedeemPage.tsx | 81 ++++-- frontend/tsconfig.app.tsbuildinfo | 2 +- 28 files changed, 894 insertions(+), 209 deletions(-) create mode 100644 frontend/src/lib/color.ts diff --git a/backend/internal/platformsettings/repository.go b/backend/internal/platformsettings/repository.go index f033f7f..e8521ac 100644 --- a/backend/internal/platformsettings/repository.go +++ b/backend/internal/platformsettings/repository.go @@ -13,6 +13,8 @@ var AllowedKeys = map[string]bool{ "platform_logo": true, "admin_primary_color": true, "admin_accent_color": true, + "admin_danger_color": true, + "admin_warning_color": true, } func GetSettings(ctx context.Context, pool *pgxpool.Pool) (map[string]string, error) { diff --git a/frontend/src/components/layout/AdminLayout.tsx b/frontend/src/components/layout/AdminLayout.tsx index 58e1389..c87396e 100644 --- a/frontend/src/components/layout/AdminLayout.tsx +++ b/frontend/src/components/layout/AdminLayout.tsx @@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query' import { useLogout } from '@/hooks/useAuth' import { useTheme } from '@/hooks/useTheme' import { apiFetch } from '@/lib/api' +import { darken, getContrastText, safeHex } from '@/lib/color' import type { PlatformSettings } from '@/lib/types' export default function AdminLayout() { @@ -14,17 +15,26 @@ export default function AdminLayout() { queryKey: ['admin', 'settings'], queryFn: () => apiFetch('/admin/settings'), }) - const primary = settings?.admin_primary_color || '#2563eb' - const accent = settings?.admin_accent_color || '#0ea5e9' + const primary = safeHex(settings?.admin_primary_color, '#2563eb') + const accent = safeHex(settings?.admin_accent_color, '#0ea5e9') + const danger = safeHex(settings?.admin_danger_color, '#991b1b') + const warning = safeHex(settings?.admin_warning_color, '#b45309') const isLight = theme === 'light' const shellStyle = useMemo( () => ({ backgroundColor: 'var(--ui-bg)', color: 'var(--ui-text)', ['--brand-primary' as string]: primary, + ['--brand-primary-text' as string]: getContrastText(primary), ['--brand-accent' as string]: accent, + ['--ui-danger' as string]: danger, + ['--ui-danger-hover' as string]: darken(danger), + ['--ui-danger-text' as string]: getContrastText(danger), + ['--ui-warning' as string]: warning, + ['--ui-warning-hover' as string]: darken(warning), + ['--ui-warning-text' as string]: getContrastText(warning), }), - [primary, accent] + [primary, accent, danger, warning] ) const asideStyle = useMemo( () => ({ diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index a8256d2..0c1b78f 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -24,9 +24,10 @@ import { import { useLogout } from '@/hooks/useAuth' import { useTheme } from '@/hooks/useTheme' import { apiFetch } from '@/lib/api' +import { darken, getContrastText, safeHex } from '@/lib/color' import { useAuthStore } from '@/store/authStore' import { queryClient } from '@/lib/queryClient' -import type { TenantSettings } from '@/lib/types' +import type { TenantSettings, PlatformSettings } from '@/lib/types' type MainNavItem = { to: string @@ -76,6 +77,15 @@ export default function AppLayout() { queryFn: () => apiFetch('/settings'), retry: false, }) + const { data: platform } = useQuery({ + queryKey: ['platform', 'settings', 'layout'], + queryFn: () => apiFetch('/platform/settings'), + retry: false, + }) + const primary = safeHex(platform?.admin_primary_color, '#2563eb') + const accent = safeHex(platform?.admin_accent_color, '#0ea5e9') + const danger = safeHex(platform?.admin_danger_color, '#991b1b') + const warning = safeHex(platform?.admin_warning_color, '#b45309') useLayoutEffect(() => { const initialTheme = settings?.ui_theme @@ -128,16 +138,40 @@ export default function AppLayout() { const navIconClass = sidebarCollapsed ? 'h-5 w-5 shrink-0' : 'h-4 w-4 shrink-0' return ( -
+
{previousSession && ( -
+
TechXCar Admin — a gerir: {user?.name} diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx index 5ffc3fc..70abcfe 100644 --- a/frontend/src/components/ui/badge.tsx +++ b/frontend/src/components/ui/badge.tsx @@ -3,16 +3,16 @@ import { cva, type VariantProps } from 'class-variance-authority' import { cn } from '@/lib/utils' const badgeVariants = cva( - 'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors', + 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors', { variants: { variant: { - default: 'bg-blue-100 text-blue-800', - secondary: 'bg-gray-100 text-gray-800', - destructive: 'bg-red-100 text-red-800', - success: 'bg-green-100 text-green-800', - warning: 'bg-yellow-100 text-yellow-800', - outline: 'border border-gray-300 text-gray-700', + default: 'border-[var(--brand-primary)] bg-[var(--brand-primary)] text-[var(--brand-primary-text)]', + secondary: 'border-[var(--ui-border)] bg-[var(--ui-panel-soft)] text-[var(--ui-text)]', + destructive: 'border-[var(--ui-danger)] bg-[var(--ui-danger)] text-[var(--ui-danger-text)]', + success: 'border-emerald-500 bg-emerald-600 text-white', + warning: 'border-[var(--ui-warning)] bg-[var(--ui-warning)] text-[var(--ui-warning-text)]', + outline: 'border-[var(--ui-border)] text-[var(--ui-muted)]', }, }, defaultVariants: { diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx index 0fc059c..1cc4e96 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/components/ui/button.tsx @@ -4,12 +4,13 @@ import { cva, type VariantProps } from 'class-variance-authority' import { cn } from '@/lib/utils' const buttonVariants = cva( - 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ui-bg)] disabled:pointer-events-none disabled:opacity-50', { variants: { variant: { - default: 'bg-[var(--brand-primary)] text-slate-50 shadow hover:brightness-110', - destructive: 'bg-red-600 text-slate-50 shadow-sm hover:bg-red-700', + default: 'bg-[var(--brand-primary)] text-[var(--brand-primary-text)] shadow-sm hover:brightness-110 active:brightness-95', + destructive: 'bg-[var(--ui-danger)] text-[var(--ui-danger-text)] shadow-sm hover:bg-[var(--ui-danger-hover)] active:brightness-95', + warning: 'bg-[var(--ui-warning)] text-[var(--ui-warning-text)] shadow-sm hover:bg-[var(--ui-warning-hover)] active:brightness-95', outline: 'border border-[var(--ui-border)] bg-[var(--ui-panel)] shadow-sm hover:bg-[var(--ui-hover)] text-[var(--ui-text)]', secondary: 'bg-[var(--ui-panel-soft)] text-[var(--ui-text)] shadow-sm hover:brightness-95', ghost: 'hover:bg-[var(--ui-hover)] text-[var(--ui-muted)]', diff --git a/frontend/src/components/ui/confirm-action-dialog.tsx b/frontend/src/components/ui/confirm-action-dialog.tsx index b68d8dd..105e009 100644 --- a/frontend/src/components/ui/confirm-action-dialog.tsx +++ b/frontend/src/components/ui/confirm-action-dialog.tsx @@ -1,5 +1,6 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' +import { AlertTriangle } from 'lucide-react' type ConfirmActionDialogProps = { open: boolean @@ -24,12 +25,15 @@ export function ConfirmActionDialog({ }: ConfirmActionDialogProps) { return ( - - + + +
+ +
{title}
-

{description}

-
+

{description}

+
diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx index 21f67c1..982108b 100644 --- a/frontend/src/components/ui/dialog.tsx +++ b/frontend/src/components/ui/dialog.tsx @@ -15,7 +15,7 @@ const DialogOverlay = React.forwardRef< {children} - - + + Fechar @@ -58,7 +58,7 @@ const DialogTitle = React.forwardRef< >(({ className, ...props }, ref) => ( )) diff --git a/frontend/src/index.css b/frontend/src/index.css index d7e27b3..18d6928 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -3,7 +3,14 @@ :root { --radius: 0.5rem; --brand-primary: #2563eb; + --brand-primary-text: #f8fafc; --brand-accent: #0ea5e9; + --ui-danger: #991b1b; + --ui-danger-hover: #7f1d1d; + --ui-danger-text: #fee2e2; + --ui-warning: #b45309; + --ui-warning-hover: #92400e; + --ui-warning-text: #fffbeb; --ui-bg: #030712; --ui-panel: #0b1220; --ui-panel-soft: #121a2c; @@ -20,6 +27,13 @@ :root.theme-light { color-scheme: light; + --brand-primary-text: #f8fafc; + --ui-danger: #b91c1c; + --ui-danger-hover: #991b1b; + --ui-danger-text: #fef2f2; + --ui-warning: #d97706; + --ui-warning-hover: #b45309; + --ui-warning-text: #fffbeb; --ui-bg: #f2f6fb; --ui-panel: #ffffff; --ui-panel-soft: #f8fbff; diff --git a/frontend/src/lib/color.ts b/frontend/src/lib/color.ts new file mode 100644 index 0000000..f37c024 --- /dev/null +++ b/frontend/src/lib/color.ts @@ -0,0 +1,55 @@ +function clamp(value: number, min = 0, max = 255) { + return Math.min(max, Math.max(min, value)) +} + +function normalizeHex(input: string, fallback: string) { + const value = (input || '').trim() + const short = /^#([0-9a-fA-F]{3})$/ + const long = /^#([0-9a-fA-F]{6})$/ + + if (long.test(value)) return value.toLowerCase() + const shortMatch = value.match(short) + if (shortMatch) { + const [r, g, b] = shortMatch[1].split('') + return `#${r}${r}${g}${g}${b}${b}`.toLowerCase() + } + return fallback +} + +function hexToRgb(hex: string) { + const n = normalizeHex(hex, '#000000').slice(1) + return { + r: parseInt(n.slice(0, 2), 16), + g: parseInt(n.slice(2, 4), 16), + b: parseInt(n.slice(4, 6), 16), + } +} + +function toLinear(c: number) { + const n = c / 255 + if (n <= 0.03928) return n / 12.92 + return ((n + 0.055) / 1.055) ** 2.4 +} + +function luminance(hex: string) { + const { r, g, b } = hexToRgb(hex) + return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b) +} + +export function getContrastText(bgHex: string) { + return luminance(bgHex) > 0.45 ? '#0f172a' : '#f8fafc' +} + +export function darken(hex: string, amount = 0.14) { + const safe = normalizeHex(hex, '#000000') + const { r, g, b } = hexToRgb(safe) + const ratio = 1 - amount + const rr = clamp(Math.round(r * ratio)) + const gg = clamp(Math.round(g * ratio)) + const bb = clamp(Math.round(b * ratio)) + return `#${rr.toString(16).padStart(2, '0')}${gg.toString(16).padStart(2, '0')}${bb.toString(16).padStart(2, '0')}` +} + +export function safeHex(input: string | undefined, fallback: string) { + return normalizeHex(input ?? '', fallback) +} diff --git a/frontend/src/lib/workOrderStatus.ts b/frontend/src/lib/workOrderStatus.ts index 2ecca49..35324dc 100644 --- a/frontend/src/lib/workOrderStatus.ts +++ b/frontend/src/lib/workOrderStatus.ts @@ -10,12 +10,12 @@ export const WORK_ORDER_STATUS_LABEL: Record = { } const LIGHT_STATUS_BADGE: Record = { - quote: 'border-amber-400 bg-amber-100 text-slate-800', + quote: 'border-[var(--ui-warning)] bg-[var(--ui-warning)] text-[var(--ui-warning-text)]', open: 'border-sky-400 bg-sky-100 text-slate-800', in_progress: 'border-indigo-400 bg-indigo-100 text-slate-800', completed: 'border-emerald-400 bg-emerald-100 text-slate-800', invoiced: 'border-teal-400 bg-teal-100 text-slate-800', - cancelled: 'border-rose-400 bg-rose-100 text-slate-800', + cancelled: 'border-[var(--ui-danger)] bg-[var(--ui-danger)] text-[var(--ui-danger-text)]', } export function workOrderStatusBadgeClass(status: WorkOrder['status']) { diff --git a/frontend/src/pages/admin/DashboardPage.tsx b/frontend/src/pages/admin/DashboardPage.tsx index 42ab76c..a6bd3f6 100644 --- a/frontend/src/pages/admin/DashboardPage.tsx +++ b/frontend/src/pages/admin/DashboardPage.tsx @@ -90,7 +90,7 @@ export default function AdminDashboardPage() {

)} {isError && !isLoading && ( -

+

Não foi possível carregar os dados globais neste momento.

)} diff --git a/frontend/src/pages/admin/SettingsPage.tsx b/frontend/src/pages/admin/SettingsPage.tsx index 0ffded3..1b39309 100644 --- a/frontend/src/pages/admin/SettingsPage.tsx +++ b/frontend/src/pages/admin/SettingsPage.tsx @@ -13,6 +13,8 @@ type FormData = { platform_logo: string admin_primary_color: string admin_accent_color: string + admin_danger_color: string + admin_warning_color: string } const defaultValues: FormData = { @@ -21,6 +23,8 @@ const defaultValues: FormData = { platform_logo: '', admin_primary_color: '#0f3b47', admin_accent_color: '#06b6d4', + admin_danger_color: '#991b1b', + admin_warning_color: '#b45309', } export default function AdminSettingsPage() { @@ -44,6 +48,8 @@ export default function AdminSettingsPage() { platform_logo: settings.platform_logo ?? '', admin_primary_color: settings.admin_primary_color ?? defaultValues.admin_primary_color, admin_accent_color: settings.admin_accent_color ?? defaultValues.admin_accent_color, + admin_danger_color: settings.admin_danger_color ?? defaultValues.admin_danger_color, + admin_warning_color: settings.admin_warning_color ?? defaultValues.admin_warning_color, }) }, [settings, reset]) @@ -93,10 +99,10 @@ export default function AdminSettingsPage() {
)} {previewBroken && ( -

Não foi possível carregar este logo por URL. Tenta outro URL ou usa `data:image/...`.

+

Não foi possível carregar este logo por URL. Tenta outro URL ou usa `data:image/...`.

)}
-
+
@@ -111,21 +117,41 @@ export default function AdminSettingsPage() {
+
+ +
+ + +
+
+
+ +
+ + +
+

Prévia de aplicação de cor

-
+
- Botão primário + Cor primária - Destaque + Cor de destaque + +
- {save.error &&

{(save.error as Error).message}

} + {save.error &&

{(save.error as Error).message}

} {save.isSuccess &&

Definições globais guardadas.

}
diff --git a/frontend/src/pages/admin/TenantsPage.tsx b/frontend/src/pages/admin/TenantsPage.tsx index d8f91c9..346feb3 100644 --- a/frontend/src/pages/admin/TenantsPage.tsx +++ b/frontend/src/pages/admin/TenantsPage.tsx @@ -166,7 +166,7 @@ export default function TenantsPage() { required />
- {createError &&

{createError}

} + {createError &&

{createError}

}
+
+
+ + setSearch(e.target.value)} + placeholder="Código, nome ou categoria" + className="bg-slate-900 border-slate-600 text-white" + /> +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ {items.length} total + {filteredItems.length} no filtro atual +
+ {showForm && (

@@ -153,7 +231,7 @@ export default function CatalogPage() {

{save.error && ( -

{save.error.message}

+

{save.error.message}

)}
+
+
+ + setSearch(e.target.value)} + placeholder="Nome, NIF, telefone ou email" + className="bg-slate-900 border-slate-600 text-white" + /> +
+
+ + +
+
+ +
+ {clients.length} total + {filteredClients.length} no filtro atual +
+ {showForm && (

@@ -336,7 +381,7 @@ export default function ClientsPage() {

{save.error && ( -

{(save.error as Error).message}

+

{(save.error as Error).message}

)}
+
+
+ {expenses.length} total + {filtered.length} no filtro atual +
+
{[ ['Total de despesas', currency(kpis.total)], diff --git a/frontend/src/pages/app/ExpensesPage.tsx b/frontend/src/pages/app/ExpensesPage.tsx index 52f490e..07b6e75 100644 --- a/frontend/src/pages/app/ExpensesPage.tsx +++ b/frontend/src/pages/app/ExpensesPage.tsx @@ -1,9 +1,11 @@ -import { useState } from 'react' +import { useMemo, useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useForm, type Resolver } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' +import { FilterX } from 'lucide-react' import { apiFetch } from '@/lib/api' +import { useTheme } from '@/hooks/useTheme' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { ConfirmActionDialog } from '@/components/ui/confirm-action-dialog' @@ -12,6 +14,7 @@ import { Label } from '@/components/ui/label' import type { Expense } from '@/lib/types' const EXPENSE_TYPES = ['fuel', 'parts', 'tools', 'other'] as const +type ExpenseType = '' | (typeof EXPENSE_TYPES)[number] const TYPE_LABELS: Record = { fuel: 'Combustível', parts: 'Peças', tools: 'Ferramentas', other: 'Outros', } @@ -29,15 +32,22 @@ const today = new Date().toISOString().split('T')[0] const emptyExpense: FormData = { type: 'fuel', amount: 0, description: '', date: today, vehicle_id: '' } export default function ExpensesPage() { + const { theme } = useTheme('ui_theme', 'dark') + const isLight = theme === 'light' const qc = useQueryClient() - const [typeFilter, setTypeFilter] = useState('') + const [typeFilter, setTypeFilter] = useState('') const [showForm, setShowForm] = useState(false) const [pendingDeleteId, setPendingDeleteId] = useState(null) const { data: expenses = [], isLoading } = useQuery({ - queryKey: ['expenses', typeFilter], - queryFn: () => apiFetch(`/expenses${typeFilter ? `?type=${typeFilter}` : ''}`), + queryKey: ['expenses'], + queryFn: () => apiFetch('/expenses'), }) + const filteredExpenses = useMemo( + () => (typeFilter ? expenses.filter((e) => e.type === typeFilter) : expenses), + [expenses, typeFilter] + ) + const hasFilters = !!typeFilter const { register, handleSubmit, reset, formState: { errors } } = useForm({ resolver: zodResolver(schema) as Resolver, @@ -59,46 +69,68 @@ export default function ExpensesPage() { onSuccess: () => qc.invalidateQueries({ queryKey: ['expenses'] }), }) - const total = expenses.reduce((s, e) => s + e.amount, 0) + const totalFiltered = filteredExpenses.reduce((s, e) => s + e.amount, 0) return (
-

Despesas

-

- {expenses.length} registos — total: {total.toFixed(2)} € +

Despesas

+

+ {filteredExpenses.length} registos — total: {totalFiltered.toFixed(2)} €

-
- {(['', ...EXPENSE_TYPES] as string[]).map((t) => ( - +
+
+ +
+ {expenses.length} total + {filteredExpenses.length} no filtro atual
{showForm && ( -
-

Nova Despesa

+
+

Nova Despesa

create.mutate(d))} className="grid grid-cols-2 gap-4">
+ className={isLight ? 'bg-white border-slate-300 text-slate-900' : 'bg-slate-900 border-slate-600 text-white'} /> {errors.amount &&

{errors.amount.message}

}
+ className={isLight ? 'bg-white border-slate-300 text-slate-900' : 'bg-slate-900 border-slate-600 text-white'} /> {errors.date &&

{errors.date.message}

}
+ className={isLight ? 'bg-white border-slate-300 text-slate-900' : 'bg-slate-900 border-slate-600 text-white'} />
- {create.error &&

{(create.error as Error).message}

} + {create.error &&

{(create.error as Error).message}

}