feat(ui): unify global visual identity, theme tokens and report filters

This commit is contained in:
Luciano Milani
2026-07-03 14:07:39 +01:00
parent 9bf176b385
commit dc8c1bd69a
28 changed files with 894 additions and 209 deletions
+13 -3
View File
@@ -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<PlatformSettings>('/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(
() => ({
+38 -4
View File
@@ -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<TenantSettings>('/settings'),
retry: false,
})
const { data: platform } = useQuery<PlatformSettings>({
queryKey: ['platform', 'settings', 'layout'],
queryFn: () => apiFetch<PlatformSettings>('/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 (
<div className={`app-shell flex h-screen flex-col ${isLight ? 'bg-slate-100' : 'bg-slate-950'}`}>
<div
className={`app-shell flex h-screen flex-col ${isLight ? 'bg-slate-100' : 'bg-slate-950'}`}
style={{
['--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),
}}
>
{previousSession && (
<div className="shrink-0 bg-amber-500 px-4 py-2 text-sm font-medium text-amber-950">
<div
className="shrink-0 border-b px-4 py-2 text-sm font-medium"
style={{
borderColor: 'color-mix(in srgb, var(--ui-warning) 45%, var(--ui-border))',
backgroundColor: 'color-mix(in srgb, var(--ui-warning) 22%, transparent)',
color: 'var(--ui-warning)',
}}
>
<div className="flex items-center justify-between">
<span>
TechXCar Admin a gerir: <strong>{user?.name}</strong>
</span>
<button
onClick={handleRestore}
className="rounded bg-amber-950 px-3 py-1 text-xs font-semibold text-amber-100 transition-colors hover:bg-amber-900"
className="rounded px-3 py-1 text-xs font-semibold transition-colors"
style={{
backgroundColor: 'var(--ui-warning)',
color: 'var(--ui-warning-text)',
}}
>
Voltar ao painel
</button>
+7 -7
View File
@@ -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: {
+4 -3
View File
@@ -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)]',
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="left-auto right-4 top-4 w-[calc(100%-2rem)] max-w-md translate-x-0 translate-y-0 border-[var(--ui-border)] bg-[var(--ui-panel)]">
<DialogHeader>
<DialogContent className="w-[calc(100%-2rem)] max-w-md">
<DialogHeader className="pr-8">
<div className="mb-2 flex h-10 w-10 items-center justify-center rounded-full border border-[var(--ui-danger)]/30 bg-[var(--ui-danger)]/15">
<AlertTriangle className="h-5 w-5 text-[var(--ui-danger)]" />
</div>
<DialogTitle className="text-[var(--ui-text)]">{title}</DialogTitle>
</DialogHeader>
<p className="text-sm text-[var(--ui-muted)]">{description}</p>
<div className="flex justify-end gap-2">
<p className="text-sm leading-relaxed text-[var(--ui-muted)]">{description}</p>
<div className="mt-2 flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
{cancelLabel}
</Button>
+5 -5
View File
@@ -15,7 +15,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'fixed inset-0 z-50 bg-slate-950/60 backdrop-blur-[2px] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
@@ -32,14 +32,14 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 bg-slate-900 border border-slate-700 p-6 shadow-lg rounded-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-xl border border-[var(--ui-border)] bg-[var(--ui-panel)]/95 p-6 shadow-2xl shadow-slate-900/25 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4 text-slate-400" />
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-md border border-[var(--ui-border)] bg-[var(--ui-panel-soft)] p-1 text-[var(--ui-muted)] transition-colors hover:bg-[var(--ui-hover)] hover:text-[var(--ui-text)] focus:outline-none focus:ring-2 focus:ring-[var(--brand-accent)] focus:ring-offset-2 focus:ring-offset-[var(--ui-panel)] disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Fechar</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
@@ -58,7 +58,7 @@ const DialogTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold text-white', className)}
className={cn('text-lg font-semibold text-[var(--ui-text)]', className)}
{...props}
/>
))
+14
View File
@@ -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;
+55
View File
@@ -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)
}
+2 -2
View File
@@ -10,12 +10,12 @@ export const WORK_ORDER_STATUS_LABEL: Record<WorkOrder['status'], string> = {
}
const LIGHT_STATUS_BADGE: Record<WorkOrder['status'], string> = {
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']) {
+1 -1
View File
@@ -90,7 +90,7 @@ export default function AdminDashboardPage() {
</p>
)}
{isError && !isLoading && (
<p className="rounded-xl border border-red-900/80 bg-red-950/40 px-4 py-3 text-sm text-red-200">
<p className="rounded-xl border px-4 py-3 text-sm text-[var(--ui-danger)]" style={{ borderColor: 'color-mix(in srgb, var(--ui-danger) 55%, var(--ui-border))', backgroundColor: 'color-mix(in srgb, var(--ui-danger) 12%, transparent)' }}>
Não foi possível carregar os dados globais neste momento.
</p>
)}
+32 -6
View File
@@ -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() {
</div>
)}
{previewBroken && (
<p className="text-amber-500 text-xs">Não foi possível carregar este logo por URL. Tenta outro URL ou usa `data:image/...`.</p>
<p className="text-xs text-[var(--ui-warning)]">Não foi possível carregar este logo por URL. Tenta outro URL ou usa `data:image/...`.</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-1">
<Label htmlFor="admin_primary_color">Cor primária admin</Label>
<div className="flex gap-2">
@@ -111,21 +117,41 @@ export default function AdminSettingsPage() {
<Input type="color" {...register('admin_accent_color')} className="w-14 p-1 h-10 cursor-pointer" />
</div>
</div>
<div className="space-y-1">
<Label htmlFor="admin_danger_color">Cor de perigo (excluir)</Label>
<div className="flex gap-2">
<Input id="admin_danger_color" {...register('admin_danger_color')} />
<Input type="color" {...register('admin_danger_color')} className="w-14 p-1 h-10 cursor-pointer" />
</div>
</div>
<div className="space-y-1">
<Label htmlFor="admin_warning_color">Cor de atenção e avisos</Label>
<div className="flex gap-2">
<Input id="admin_warning_color" {...register('admin_warning_color')} />
<Input type="color" {...register('admin_warning_color')} className="w-14 p-1 h-10 cursor-pointer" />
</div>
</div>
</div>
<div className="rounded-md border border-[var(--ui-border)] bg-[var(--ui-panel-soft)] p-3">
<p className="mb-2 text-xs font-medium text-[var(--ui-muted)]">Prévia de aplicação de cor</p>
<div className="flex items-center gap-3">
<div className="flex flex-wrap items-center gap-3">
<span className="inline-flex rounded-md px-3 py-1 text-xs font-semibold text-white" style={{ backgroundColor: selectedPrimary }}>
Botão primário
Cor primária
</span>
<span className="inline-flex rounded-md px-3 py-1 text-xs font-semibold text-white" style={{ backgroundColor: selectedAccent }}>
Destaque
Cor de destaque
</span>
<Button type="button" size="sm" variant="destructive">
Perigo / Excluir
</Button>
<Button type="button" size="sm" variant="warning">
Atenção / Aviso
</Button>
</div>
</div>
{save.error && <p className="text-red-300 text-sm">{(save.error as Error).message}</p>}
{save.error && <p className="text-sm text-[var(--ui-danger)]">{(save.error as Error).message}</p>}
{save.isSuccess && <p className="text-emerald-300 text-sm">Definições globais guardadas.</p>}
<div className="flex justify-end">
+1 -1
View File
@@ -166,7 +166,7 @@ export default function TenantsPage() {
required
/>
</div>
{createError && <p className="col-span-2 text-red-400 text-sm">{createError}</p>}
{createError && <p className="col-span-2 text-sm text-[var(--ui-danger)]">{createError}</p>}
<div className="col-span-2 flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={() => setShowCreate(false)}>
Cancelar
+82 -4
View File
@@ -1,8 +1,9 @@
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 { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@@ -53,6 +54,9 @@ export default function CatalogPage() {
const [editing, setEditing] = useState<CatalogItem | null>(null)
const [showForm, setShowForm] = useState(false)
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
const [search, setSearch] = useState('')
const [categoryFilter, setCategoryFilter] = useState('')
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all')
const { data: items = [], isLoading } = useQuery<CatalogItem[]>({
queryKey: ['catalog'],
@@ -82,6 +86,21 @@ export default function CatalogPage() {
onSuccess: () => qc.invalidateQueries({ queryKey: ['catalog'] }),
})
const filteredItems = useMemo(() => {
const q = search.trim().toLowerCase()
return items.filter((item) => {
if (categoryFilter && item.category !== categoryFilter) return false
if (statusFilter === 'active' && !item.active) return false
if (statusFilter === 'inactive' && item.active) return false
if (!q) return true
return [item.code, item.name, CATEGORY_LABEL[item.category] ?? item.category]
.join(' ')
.toLowerCase()
.includes(q)
})
}, [items, categoryFilter, statusFilter, search])
const hasFilters = Boolean(search.trim() || categoryFilter || statusFilter !== 'all')
function openNew() {
setEditing(null)
reset(emptyItem)
@@ -104,6 +123,65 @@ export default function CatalogPage() {
<Button onClick={openNew}>Novo Item</Button>
</div>
<div className="mb-4 grid gap-3 rounded-lg border border-slate-700 bg-slate-900/50 p-4 md:grid-cols-5">
<div className="space-y-1 md:col-span-2">
<label className="text-xs text-slate-400">Pesquisar item</label>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Código, nome ou categoria"
className="bg-slate-900 border-slate-600 text-white"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-slate-400">Categoria</label>
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="w-full rounded-md border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-white"
>
<option value="">Todas</option>
{CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
</div>
<div className="space-y-1">
<label className="text-xs text-slate-400">Estado</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as 'all' | 'active' | 'inactive')}
className="w-full rounded-md border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-white"
>
<option value="all">Todos</option>
<option value="active">Ativos</option>
<option value="inactive">Inativos</option>
</select>
</div>
<div className="space-y-1">
<label className="text-xs text-slate-400">Ações rápidas</label>
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => {
setSearch('')
setCategoryFilter('')
setStatusFilter('all')
}}
disabled={!hasFilters}
>
<FilterX className="h-4 w-4" />
Limpar todos os filtros
</Button>
</div>
</div>
<div className="mb-4 flex items-center gap-3 text-sm">
<Badge variant="secondary">{items.length} total</Badge>
<Badge>{filteredItems.length} no filtro atual</Badge>
</div>
{showForm && (
<div className="mb-6 bg-slate-800 rounded-lg border border-slate-700 p-6">
<h2 className="text-lg font-semibold text-white mb-4">
@@ -153,7 +231,7 @@ export default function CatalogPage() {
<Label htmlFor="active">Activo</Label>
</div>
{save.error && (
<p className="col-span-2 text-red-400 text-sm">{save.error.message}</p>
<p className="col-span-2 text-sm text-[var(--ui-danger)]">{save.error.message}</p>
)}
<div className="col-span-2 flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={() => { setShowForm(false); setEditing(null) }}>
@@ -169,7 +247,7 @@ export default function CatalogPage() {
{isLoading ? (
<p className="text-slate-400">A carregar...</p>
) : items.length === 0 ? (
) : filteredItems.length === 0 ? (
<p className="text-slate-500 text-sm">Nenhum item no catálogo.</p>
) : (
<div className="rounded-lg border border-slate-700 overflow-hidden">
@@ -186,7 +264,7 @@ export default function CatalogPage() {
</tr>
</thead>
<tbody>
{items.map((item) => (
{filteredItems.map((item) => (
<tr key={item.id} className="border-t border-slate-700 hover:bg-slate-800/50">
<td className="px-4 py-3 text-slate-300 font-mono text-xs">{item.code}</td>
<td className="px-4 py-3 text-white">{item.name}</td>
+50 -5
View File
@@ -1,10 +1,12 @@
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 { VEHICLE_BRANDS, normalizeBrand } from '@/lib/vehicleBrands'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { ConfirmActionDialog } from '@/components/ui/confirm-action-dialog'
import { Input } from '@/components/ui/input'
@@ -181,7 +183,7 @@ function ClientVehicles({ clientId }: { clientId: string }) {
className="bg-slate-900 border-slate-600 text-white text-sm h-8" />
</div>
{saveVehicle.error && (
<p className="col-span-3 text-red-400 text-xs">{(saveVehicle.error as Error).message}</p>
<p className="col-span-3 text-xs text-[var(--ui-danger)]">{(saveVehicle.error as Error).message}</p>
)}
<div className="col-span-3 flex gap-2 justify-end">
<Button type="button" size="sm" variant="outline"
@@ -248,6 +250,7 @@ export default function ClientsPage() {
const [showForm, setShowForm] = useState(false)
const [expandedId, setExpandedId] = useState<string | null>(null)
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
const [search, setSearch] = useState('')
const { data: clients = [], isLoading } = useQuery<Client[]>({
queryKey: ['clients'],
@@ -277,6 +280,18 @@ export default function ClientsPage() {
onSuccess: () => qc.invalidateQueries({ queryKey: ['clients'] }),
})
const filteredClients = useMemo(() => {
const q = search.trim().toLowerCase()
if (!q) return clients
return clients.filter((c) =>
[c.name, c.nif, c.phone, c.email]
.join(' ')
.toLowerCase()
.includes(q)
)
}, [clients, search])
const hasFilters = search.trim().length > 0
function openNew() {
setEditing(null)
reset(emptyClient)
@@ -303,6 +318,36 @@ export default function ClientsPage() {
<Button onClick={openNew}>Novo Cliente</Button>
</div>
<div className="mb-4 grid gap-3 rounded-lg border border-slate-700 bg-slate-900/50 p-4 md:grid-cols-3">
<div className="space-y-1 md:col-span-2">
<label className="text-xs text-slate-400">Pesquisar cliente</label>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Nome, NIF, telefone ou email"
className="bg-slate-900 border-slate-600 text-white"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-slate-400">Ações rápidas</label>
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => setSearch('')}
disabled={!hasFilters}
>
<FilterX className="h-4 w-4" />
Limpar todos os filtros
</Button>
</div>
</div>
<div className="mb-4 flex items-center gap-3 text-sm">
<Badge variant="secondary">{clients.length} total</Badge>
<Badge>{filteredClients.length} no filtro atual</Badge>
</div>
{showForm && (
<div className="mb-6 bg-slate-800 rounded-lg border border-slate-700 p-6">
<h2 className="text-lg font-semibold text-white mb-4">
@@ -336,7 +381,7 @@ export default function ClientsPage() {
<Input id="notes" {...register('notes')} className="bg-slate-900 border-slate-600 text-white" />
</div>
{save.error && (
<p className="col-span-2 text-red-400 text-sm">{(save.error as Error).message}</p>
<p className="col-span-2 text-sm text-[var(--ui-danger)]">{(save.error as Error).message}</p>
)}
<div className="col-span-2 flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={() => { setShowForm(false); setEditing(null) }}>
@@ -352,7 +397,7 @@ export default function ClientsPage() {
{isLoading ? (
<p className="text-slate-400">A carregar...</p>
) : clients.length === 0 ? (
) : filteredClients.length === 0 ? (
<p className="text-slate-500 text-sm">Nenhum cliente registado.</p>
) : (
<div className="rounded-lg border border-slate-700 overflow-hidden">
@@ -367,7 +412,7 @@ export default function ClientsPage() {
</tr>
</thead>
<tbody>
{clients.map((c) => (
{filteredClients.map((c) => (
<>
<tr key={c.id} className="border-t border-slate-700 hover:bg-slate-800/50">
<td className="px-4 py-3 text-white font-medium">{c.name}</td>
+9 -2
View File
@@ -229,7 +229,7 @@ export default function DashboardPage() {
)}
{hasError && !isLoading && (
<p className="rounded-xl border border-red-900/80 bg-red-950/40 px-4 py-3 text-sm text-red-200">
<p className="rounded-xl border px-4 py-3 text-sm text-[var(--ui-danger)]" style={{ borderColor: 'color-mix(in srgb, var(--ui-danger) 55%, var(--ui-border))', backgroundColor: 'color-mix(in srgb, var(--ui-danger) 12%, transparent)' }}>
Alguns dados não foram carregados. As métricas visíveis podem estar incompletas.
</p>
)}
@@ -345,7 +345,14 @@ export default function DashboardPage() {
<p className={isLight ? 'text-slate-600' : 'text-slate-400'}>Documentos emitidos no período</p>
<p className={`text-xl font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>{metrics.periodDocsCount}</p>
</div>
<div className={`rounded-lg border px-3 py-2 ${isLight ? 'border-amber-200 bg-amber-50 text-amber-900' : 'border-amber-800/70 bg-amber-950/30 text-amber-100'}`}>
<div
className="rounded-lg border px-3 py-2"
style={{
borderColor: 'color-mix(in srgb, var(--ui-warning) 55%, var(--ui-border))',
backgroundColor: 'color-mix(in srgb, var(--ui-warning) 12%, transparent)',
color: 'var(--ui-warning)',
}}
>
<p className="text-xs uppercase tracking-wide">Ação recomendada</p>
<p className="mt-1">
{metrics.byStatus.completed > 0
+26 -1
View File
@@ -1,7 +1,10 @@
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
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 type { Expense } from '@/lib/types'
type PeriodKey = 'month' | '30d' | '90d' | 'year' | 'all'
@@ -65,6 +68,7 @@ export default function ExpenseReportsPage() {
return true
})
}, [expenses, period, typeFilter])
const hasFilters = period !== '90d' || !!typeFilter
const kpis = useMemo(() => {
const total = filtered.reduce((sum, e) => sum + e.amount, 0)
@@ -145,7 +149,7 @@ export default function ExpenseReportsPage() {
</header>
<div className={`rounded-2xl border p-4 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<div className="grid gap-3 md:grid-cols-2">
<div className="grid gap-3 md:grid-cols-3">
<label className="text-sm">
<span className={`mb-1 block ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>Período</span>
<select
@@ -172,9 +176,30 @@ export default function ExpenseReportsPage() {
<option value="other">Outros</option>
</select>
</label>
<div className="space-y-1">
<span className={`mb-1 block text-sm ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>Ações rápidas</span>
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => {
setPeriod('90d')
setTypeFilter('')
}}
disabled={!hasFilters}
>
<FilterX className="h-4 w-4" />
Limpar todos os filtros
</Button>
</div>
</div>
</div>
<div className="flex items-center gap-3 text-sm">
<Badge variant="secondary">{expenses.length} total</Badge>
<Badge>{filtered.length} no filtro atual</Badge>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{[
['Total de despesas', currency(kpis.total)],
+73 -41
View File
@@ -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<string, string> = {
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<ExpenseType>('')
const [showForm, setShowForm] = useState(false)
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
const { data: expenses = [], isLoading } = useQuery<Expense[]>({
queryKey: ['expenses', typeFilter],
queryFn: () => apiFetch<Expense[]>(`/expenses${typeFilter ? `?type=${typeFilter}` : ''}`),
queryKey: ['expenses'],
queryFn: () => apiFetch<Expense[]>('/expenses'),
})
const filteredExpenses = useMemo(
() => (typeFilter ? expenses.filter((e) => e.type === typeFilter) : expenses),
[expenses, typeFilter]
)
const hasFilters = !!typeFilter
const { register, handleSubmit, reset, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema) as Resolver<FormData>,
@@ -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 (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-white">Despesas</h1>
<p className="text-slate-400 text-sm mt-0.5">
{expenses.length} registos total: {total.toFixed(2)}
<h1 className={`text-2xl font-bold ${isLight ? 'text-slate-900' : 'text-white'}`}>Despesas</h1>
<p className={`text-sm mt-0.5 ${isLight ? 'text-slate-700' : 'text-slate-400'}`}>
{filteredExpenses.length} registos total: {totalFiltered.toFixed(2)}
</p>
</div>
<Button onClick={() => { reset(emptyExpense); setShowForm(true) }}>Nova Despesa</Button>
</div>
<div className="flex gap-2 mb-4">
{(['', ...EXPENSE_TYPES] as string[]).map((t) => (
<button
key={t}
onClick={() => setTypeFilter(t)}
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
typeFilter === t
? 'bg-slate-600 text-white'
: 'text-slate-400 hover:text-white hover:bg-slate-800'
<div className={`mb-4 grid gap-3 rounded-lg border p-4 md:grid-cols-3 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/50'}`}>
<div className="space-y-1 md:col-span-2">
<label className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Tipo de despesa</label>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value as ExpenseType)}
className={`w-full rounded-md border px-3 py-2 text-sm ${
isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'
}`}
>
{t === '' ? 'Todas' : TYPE_LABELS[t]}
</button>
))}
<option value="">Todas as categorias</option>
{EXPENSE_TYPES.map((t) => (
<option key={t} value={t}>{TYPE_LABELS[t]}</option>
))}
</select>
</div>
<div className="space-y-1">
<label className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Ações rápidas</label>
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => setTypeFilter('')}
disabled={!hasFilters}
>
<FilterX className="h-4 w-4" />
Limpar todos os filtros
</Button>
</div>
</div>
<div className="mb-4 flex items-center gap-3 text-sm">
<Badge variant="secondary">{expenses.length} total</Badge>
<Badge>{filteredExpenses.length} no filtro atual</Badge>
</div>
{showForm && (
<div className="mb-6 bg-slate-800 rounded-lg border border-slate-700 p-6">
<h2 className="text-lg font-semibold text-white mb-4">Nova Despesa</h2>
<div className={`mb-6 rounded-lg border p-6 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-800'}`}>
<h2 className={`text-lg font-semibold mb-4 ${isLight ? 'text-slate-900' : 'text-white'}`}>Nova Despesa</h2>
<form onSubmit={handleSubmit((d) => create.mutate(d))} className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label htmlFor="type">Tipo</Label>
<select
id="type"
{...register('type')}
className="w-full rounded-md border border-slate-600 bg-slate-900 text-white px-3 py-2 text-sm"
className={`w-full rounded-md border px-3 py-2 text-sm ${
isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'
}`}
>
{EXPENSE_TYPES.map((t) => (
<option key={t} value={t}>{TYPE_LABELS[t]}</option>
@@ -108,21 +140,21 @@ export default function ExpensesPage() {
<div className="space-y-1">
<Label htmlFor="amount">Valor () *</Label>
<Input id="amount" type="number" step="0.01" {...register('amount')}
className="bg-slate-900 border-slate-600 text-white" />
className={isLight ? 'bg-white border-slate-300 text-slate-900' : 'bg-slate-900 border-slate-600 text-white'} />
{errors.amount && <p className="text-red-400 text-xs">{errors.amount.message}</p>}
</div>
<div className="space-y-1">
<Label htmlFor="date">Data *</Label>
<Input id="date" type="date" {...register('date')}
className="bg-slate-900 border-slate-600 text-white" />
className={isLight ? 'bg-white border-slate-300 text-slate-900' : 'bg-slate-900 border-slate-600 text-white'} />
{errors.date && <p className="text-red-400 text-xs">{errors.date.message}</p>}
</div>
<div className="space-y-1">
<Label htmlFor="description">Descrição</Label>
<Input id="description" {...register('description')}
className="bg-slate-900 border-slate-600 text-white" />
className={isLight ? 'bg-white border-slate-300 text-slate-900' : 'bg-slate-900 border-slate-600 text-white'} />
</div>
{create.error && <p className="col-span-2 text-red-400 text-sm">{(create.error as Error).message}</p>}
{create.error && <p className="col-span-2 text-sm text-[var(--ui-danger)]">{(create.error as Error).message}</p>}
<div className="col-span-2 flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={() => setShowForm(false)}>Cancelar</Button>
<Button type="submit" disabled={create.isPending}>
@@ -134,32 +166,32 @@ export default function ExpensesPage() {
)}
{isLoading ? (
<p className="text-slate-400">A carregar...</p>
) : expenses.length === 0 ? (
<p className="text-slate-500 text-sm">Nenhuma despesa registada.</p>
<p className={isLight ? 'text-slate-700' : 'text-slate-400'}>A carregar...</p>
) : filteredExpenses.length === 0 ? (
<p className={`text-sm ${isLight ? 'text-slate-700' : 'text-slate-500'}`}>Nenhuma despesa registada.</p>
) : (
<div className="rounded-lg border border-slate-700 overflow-hidden">
<div className={`rounded-lg border overflow-hidden ${isLight ? 'border-slate-300' : 'border-slate-700'}`}>
<table className="w-full text-sm">
<thead className="bg-slate-800">
<thead className={isLight ? 'bg-slate-200' : 'bg-slate-800'}>
<tr>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Data</th>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Tipo</th>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Descrição</th>
<th className="text-right px-4 py-3 text-slate-400 font-medium">Valor</th>
<th className={`text-left px-4 py-3 font-medium ${isLight ? 'text-slate-800' : 'text-slate-400'}`}>Data</th>
<th className={`text-left px-4 py-3 font-medium ${isLight ? 'text-slate-800' : 'text-slate-400'}`}>Tipo</th>
<th className={`text-left px-4 py-3 font-medium ${isLight ? 'text-slate-800' : 'text-slate-400'}`}>Descrição</th>
<th className={`text-right px-4 py-3 font-medium ${isLight ? 'text-slate-800' : 'text-slate-400'}`}>Valor</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{expenses.map((e) => (
<tr key={e.id} className="border-t border-slate-700 hover:bg-slate-800/50">
<td className="px-4 py-3 text-slate-400">
{filteredExpenses.map((e) => (
<tr key={e.id} className={`${isLight ? 'border-t border-slate-300 hover:bg-slate-100' : 'border-t border-slate-700 hover:bg-slate-800/50'}`}>
<td className={`px-4 py-3 ${isLight ? 'text-slate-800' : 'text-slate-400'}`}>
{new Intl.DateTimeFormat('pt-PT').format(new Date(e.date))}
</td>
<td className="px-4 py-3">
<Badge variant="secondary">{TYPE_LABELS[e.type]}</Badge>
</td>
<td className="px-4 py-3 text-slate-300">{e.description || '—'}</td>
<td className="px-4 py-3 text-white font-medium text-right">{e.amount.toFixed(2)} </td>
<td className={`px-4 py-3 ${isLight ? 'text-slate-800' : 'text-slate-300'}`}>{e.description || '—'}</td>
<td className={`px-4 py-3 font-medium text-right ${isLight ? 'text-slate-900' : 'text-white'}`}>{e.amount.toFixed(2)} </td>
<td className="px-4 py-3 text-right">
<Button
size="sm"
+6 -8
View File
@@ -195,7 +195,7 @@ export default function InvoicesPage() {
<span className="text-slate-400">{sorted.length} documento(s) no filtro atual</span>
</div>
{viewError && <p className="mb-4 text-sm text-red-400">{viewError}</p>}
{viewError && <p className="mb-4 text-sm text-[var(--ui-danger)]">{viewError}</p>}
{isLoading ? (
<p className="text-slate-400">A carregar...</p>
@@ -222,13 +222,9 @@ export default function InvoicesPage() {
<tr key={inv.id} className="border-t border-slate-700 hover:bg-slate-800/50">
<td className="px-4 py-2">
{inv.type === 'quote' ? (
<span className="inline-flex rounded-full border border-amber-400 bg-amber-100 px-2 py-0.5 text-xs font-medium text-slate-800">
Orçamento
</span>
<Badge variant="warning">Orçamento</Badge>
) : (
<span className="inline-flex rounded-full border border-emerald-400 bg-emerald-100 px-2 py-0.5 text-xs font-medium text-slate-800">
Fatura
</span>
<Badge variant="success">Fatura</Badge>
)}
</td>
<td className="px-4 py-2 font-mono text-white">#{inv.number}</td>
@@ -241,7 +237,9 @@ export default function InvoicesPage() {
{new Intl.DateTimeFormat('pt-PT').format(new Date(inv.issued_at))}
</td>
<td className="px-4 py-2 text-right">
<Button size="sm" variant="outline" onClick={() => openPDF(inv)}>Ver PDF</Button>
<Button size="sm" onClick={() => openPDF(inv)}>
Ver PDF
</Button>
</td>
</tr>
)
+31 -19
View File
@@ -1,7 +1,10 @@
import { useMemo, useState } from 'react'
import { useQueries, useQuery } from '@tanstack/react-query'
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 type { Client, Vehicle, WorkOrder, WorkOrderDetail } from '@/lib/types'
type PeriodKey = '30d' | '90d' | 'year' | 'all'
@@ -75,6 +78,7 @@ export default function ReportsPage() {
const clients = clientsQ.data ?? []
const orders = ordersQ.data ?? []
const vehicles = vehiclesQ.data ?? []
const hasFilters = period !== '90d' || !!clientFilter || !!vehicleFilter
const filteredOrders = useMemo(() => {
const start = getStartDate(period)
@@ -345,7 +349,7 @@ export default function ReportsPage() {
</header>
<div className={`rounded-2xl border p-4 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-[1fr_1fr_1fr_auto]">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-[minmax(160px,0.85fr)_minmax(220px,1fr)_minmax(220px,1fr)_minmax(220px,1fr)_auto]">
<label className="text-sm">
<span className={`mb-1 block ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>Período</span>
<select
@@ -387,31 +391,39 @@ export default function ReportsPage() {
))}
</select>
</label>
<div className="flex items-end justify-start gap-2 xl:justify-end">
<button
onClick={downloadCsv}
className={`rounded-md border px-3 py-1.5 text-sm font-medium transition-colors ${
isLight
? 'border-slate-900 bg-slate-900 text-white hover:bg-black'
: 'border-slate-100 bg-slate-100 text-slate-900 hover:bg-white'
}`}
<div className="space-y-1">
<span className={`mb-1 block text-sm ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>Ações rápidas</span>
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => {
setPeriod('90d')
setClientFilter('')
setVehicleFilter('')
}}
disabled={!hasFilters}
>
<FilterX className="h-4 w-4" />
Limpar todos os filtros
</Button>
</div>
<div className="flex items-end justify-start gap-2 xl:justify-end xl:self-end">
<Button type="button" variant="secondary" onClick={downloadCsv}>
Exportar CSV
</button>
<button
onClick={printPdfView}
className={`rounded-md border px-3 py-1.5 text-sm font-medium transition-colors ${
isLight
? 'border-sky-700 bg-sky-700 text-white hover:bg-sky-600'
: 'border-sky-300 bg-sky-300 text-slate-900 hover:bg-sky-200'
}`}
>
</Button>
<Button type="button" onClick={printPdfView}>
Imprimir PDF
</button>
</Button>
</div>
</div>
</div>
<div className="flex items-center gap-3 text-sm">
<Badge variant="secondary">{orders.length} total</Badge>
<Badge>{filteredOrders.length} no filtro atual</Badge>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
{[
['OTs no período', String(kpis.total)],
+3 -3
View File
@@ -128,7 +128,7 @@ export default function SettingsPage() {
Este logotipo será impresso em Orçamentos e Faturas.
</p>
{logoLooksInvalid && (
<p className="text-xs text-amber-400">
<p className="text-xs text-[var(--ui-warning)]">
O URL do logotipo parece incompleto. Confirma se termina com extensão correta (ex.: `.png`).
</p>
)}
@@ -145,7 +145,7 @@ export default function SettingsPage() {
</div>
)}
{logoPreview && previewBroken && (
<p className="text-xs text-amber-400">
<p className="text-xs text-[var(--ui-warning)]">
Não foi possível carregar o logotipo. Tenta outro URL público ou usa `data:image/...`.
</p>
)}
@@ -163,7 +163,7 @@ export default function SettingsPage() {
</div>
{save.error && (
<p className="text-red-400 text-sm">{(save.error as Error).message}</p>
<p className="text-sm text-[var(--ui-danger)]">{(save.error as Error).message}</p>
)}
{save.isSuccess && (
<p className="text-green-400 text-sm">Definições guardadas.</p>
+77 -3
View File
@@ -1,8 +1,9 @@
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 { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@@ -30,6 +31,9 @@ export default function StaffPage() {
const [editing, setEditing] = useState<Staff | null>(null)
const [showForm, setShowForm] = useState(false)
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
const [search, setSearch] = useState('')
const [typeFilter, setTypeFilter] = useState<'all' | 'internal' | 'external'>('all')
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all')
const { data: staff = [], isLoading } = useQuery<Staff[]>({
queryKey: ['staff'],
@@ -59,6 +63,18 @@ export default function StaffPage() {
onSuccess: () => qc.invalidateQueries({ queryKey: ['staff'] }),
})
const filteredStaff = useMemo(() => {
const q = search.trim().toLowerCase()
return staff.filter((s) => {
if (typeFilter !== 'all' && s.type !== typeFilter) return false
if (statusFilter === 'active' && !s.active) return false
if (statusFilter === 'inactive' && s.active) return false
if (!q) return true
return [s.name, s.email, s.phone].join(' ').toLowerCase().includes(q)
})
}, [staff, typeFilter, statusFilter, search])
const hasFilters = Boolean(search.trim() || typeFilter !== 'all' || statusFilter !== 'all')
function openNew() {
setEditing(null)
reset(emptyStaff)
@@ -81,6 +97,64 @@ export default function StaffPage() {
<Button onClick={openNew}>Novo Técnico</Button>
</div>
<div className="mb-4 grid gap-3 rounded-lg border border-slate-700 bg-slate-900/50 p-4 md:grid-cols-5">
<div className="space-y-1 md:col-span-2">
<label className="text-xs text-slate-400">Pesquisar técnico</label>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Nome, email ou telefone"
className="bg-slate-900 border-slate-600 text-white"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-slate-400">Tipo</label>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value as 'all' | 'internal' | 'external')}
className="w-full rounded-md border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-white"
>
<option value="all">Todos</option>
<option value="internal">Internos</option>
<option value="external">Externos</option>
</select>
</div>
<div className="space-y-1">
<label className="text-xs text-slate-400">Estado</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as 'all' | 'active' | 'inactive')}
className="w-full rounded-md border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-white"
>
<option value="all">Todos</option>
<option value="active">Ativos</option>
<option value="inactive">Inativos</option>
</select>
</div>
<div className="space-y-1">
<label className="text-xs text-slate-400">Ações rápidas</label>
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => {
setSearch('')
setTypeFilter('all')
setStatusFilter('all')
}}
disabled={!hasFilters}
>
<FilterX className="h-4 w-4" />
Limpar todos os filtros
</Button>
</div>
</div>
<div className="mb-4 flex items-center gap-3 text-sm">
<Badge variant="secondary">{staff.length} total</Badge>
<Badge>{filteredStaff.length} no filtro atual</Badge>
</div>
{showForm && (
<div className="mb-6 bg-slate-800 rounded-lg border border-slate-700 p-6">
<h2 className="text-lg font-semibold text-white mb-4">
@@ -135,7 +209,7 @@ export default function StaffPage() {
{isLoading ? (
<p className="text-slate-400">A carregar...</p>
) : staff.length === 0 ? (
) : filteredStaff.length === 0 ? (
<p className="text-slate-500 text-sm">Nenhum técnico registado.</p>
) : (
<div className="rounded-lg border border-slate-700 overflow-hidden">
@@ -151,7 +225,7 @@ export default function StaffPage() {
</tr>
</thead>
<tbody>
{staff.map((s) => (
{filteredStaff.map((s) => (
<tr key={s.id} className="border-t border-slate-700 hover:bg-slate-800/50">
<td className="px-4 py-3 text-white font-medium">{s.name}</td>
<td className="px-4 py-3 text-slate-400">{s.type === 'internal' ? 'Interno' : 'Externo'}</td>
+239 -23
View File
@@ -1,10 +1,14 @@
import { useMemo, useState } from 'react'
import { useQueries, useQuery } from '@tanstack/react-query'
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 type { Staff, WorkOrder, WorkOrderDetail } from '@/lib/types'
type PeriodKey = '30d' | '90d' | 'year' | 'all'
type StaffMixKey = 'internal' | 'external'
const PERIOD_OPTIONS: { key: PeriodKey; label: string }[] = [
{ key: '30d', label: '30 dias' },
@@ -31,6 +35,31 @@ function monthKey(d: Date) {
return `${d.getFullYear()}-${d.getMonth()}`
}
function toPercent(value: number) {
return `${(value * 100).toFixed(1)}%`
}
function buildCurvePath(points: Array<{ x: number; y: number }>) {
if (points.length === 0) return ''
if (points.length === 1) return `M ${points[0].x} ${points[0].y}`
let d = `M ${points[0].x} ${points[0].y}`
for (let i = 0; i < points.length - 1; i += 1) {
const p1 = points[i]
const p2 = points[i + 1]
const cx = (p1.x + p2.x) / 2
d += ` C ${cx} ${p1.y}, ${cx} ${p2.y}, ${p2.x} ${p2.y}`
}
return d
}
function buildAreaPath(points: Array<{ x: number; y: number }>, baselineY: number) {
if (points.length === 0) return ''
const curve = buildCurvePath(points)
const first = points[0]
const last = points[points.length - 1]
return `${curve} L ${last.x} ${baselineY} L ${first.x} ${baselineY} Z`
}
export default function TechnicianReportsPage() {
const { theme } = useTheme('ui_theme', 'dark')
const isLight = theme === 'light'
@@ -52,6 +81,7 @@ export default function TechnicianReportsPage() {
const start = getStartDate(period)
return workOrders.filter((wo) => !start || new Date(wo.created_at) >= start)
}, [workOrders, period])
const hasFilters = period !== '90d' || !!staffFilter
const detailQ = useQueries({
queries: filteredOrders.map((o) => ({
@@ -64,6 +94,7 @@ export default function TechnicianReportsPage() {
const isLoading = staffQ.isLoading || workOrdersQ.isLoading || detailQ.some((q) => q.isLoading)
const orderByID = useMemo(() => new Map(filteredOrders.map((wo) => [wo.id, wo])), [filteredOrders])
const staffNameByID = useMemo(() => new Map(staff.map((s) => [s.id, s.name])), [staff])
const staffTypeByID = useMemo(() => new Map(staff.map((s) => [s.id, s.type])), [staff])
const byTechnician = useMemo(() => {
const map = new Map<
@@ -85,8 +116,8 @@ export default function TechnicianReportsPage() {
ots: new Set<string>(),
invoiced: 0,
}
row.hours += sh.hours
row.total += sh.total
row.hours += Number(sh.hours || 0)
row.total += Number(sh.total || 0)
if (!row.ots.has(d.id) && wo.status === 'invoiced') row.invoiced += 1
row.ots.add(d.id)
map.set(sh.staff_id, row)
@@ -111,6 +142,46 @@ export default function TechnicianReportsPage() {
}
}, [byTechnician])
const staffMix = useMemo(() => {
const mix: Record<StaffMixKey, { tech: Set<string>; ots: Set<string>; hours: number; total: number }> = {
internal: { tech: new Set(), ots: new Set(), hours: 0, total: 0 },
external: { tech: new Set(), ots: new Set(), hours: 0, total: 0 },
}
for (const q of detailQ) {
const d = q.data
if (!d) continue
if (!orderByID.get(d.id)) continue
for (const sh of d.staff_hours) {
if (staffFilter && sh.staff_id !== staffFilter) continue
const bucket: StaffMixKey = staffTypeByID.get(sh.staff_id) === 'external' ? 'external' : 'internal'
mix[bucket].tech.add(sh.staff_id)
mix[bucket].ots.add(d.id)
mix[bucket].hours += Number(sh.hours || 0)
mix[bucket].total += Number(sh.total || 0)
}
}
const totalCost = Math.max(1, mix.internal.total + mix.external.total)
const totalHours = Math.max(1, mix.internal.hours + mix.external.hours)
return {
internal: {
techCount: mix.internal.tech.size,
otsCount: mix.internal.ots.size,
hours: mix.internal.hours,
total: mix.internal.total,
shareCost: mix.internal.total / totalCost,
shareHours: mix.internal.hours / totalHours,
},
external: {
techCount: mix.external.tech.size,
otsCount: mix.external.ots.size,
hours: mix.external.hours,
total: mix.external.total,
shareCost: mix.external.total / totalCost,
shareHours: mix.external.hours / totalHours,
},
}
}, [detailQ, orderByID, staffTypeByID, staffFilter])
const monthly = useMemo(() => {
const now = new Date()
const buckets = Array.from({ length: 6 }).map((_, i) => {
@@ -132,13 +203,50 @@ export default function TechnicianReportsPage() {
if (idx === undefined) continue
for (const sh of d.staff_hours) {
if (staffFilter && sh.staff_id !== staffFilter) continue
buckets[idx].hours += sh.hours
buckets[idx].total += sh.total
buckets[idx].hours += Number(sh.hours || 0)
buckets[idx].total += Number(sh.total || 0)
}
}
return buckets
}, [detailQ, orderByID, staffFilter])
const maxMonthly = Math.max(1, ...monthly.map((m) => m.total))
const chart = useMemo(() => {
const width = 640
const height = 260
const pad = { top: 18, right: 16, bottom: 36, left: 16 }
const usableWidth = width - pad.left - pad.right
const usableHeight = height - pad.top - pad.bottom
const maxTotal = Math.max(1, ...monthly.map((m) => m.total))
const maxHours = Math.max(1, ...monthly.map((m) => m.hours))
const stepX = monthly.length > 1 ? usableWidth / (monthly.length - 1) : usableWidth
const costPoints = monthly.map((m, i) => ({
x: pad.left + i * stepX,
y: pad.top + (1 - m.total / maxTotal) * usableHeight,
value: m.total,
label: m.label,
}))
const hourPoints = monthly.map((m, i) => ({
x: pad.left + i * stepX,
y: pad.top + (1 - m.hours / maxHours) * usableHeight,
value: m.hours,
label: m.label,
}))
return {
width,
height,
pad,
costPoints,
hourPoints,
costPath: buildCurvePath(costPoints),
costAreaPath: buildAreaPath(costPoints, height - pad.bottom),
hoursPath: buildCurvePath(hourPoints),
maxTotal,
trend: costPoints.length > 1 ? costPoints[costPoints.length - 1].value - costPoints[0].value : 0,
}
}, [monthly])
const topValue = byTechnician[0]?.total ?? 1
return (
@@ -156,7 +264,7 @@ export default function TechnicianReportsPage() {
</header>
<div className={`rounded-2xl border p-4 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<div className="grid gap-3 md:grid-cols-2">
<div className="grid gap-3 md:grid-cols-3">
<label className="text-sm">
<span className={`mb-1 block ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>Período</span>
<select
@@ -182,9 +290,30 @@ export default function TechnicianReportsPage() {
))}
</select>
</label>
<div className="space-y-1">
<span className={`mb-1 block text-sm ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>Ações rápidas</span>
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => {
setPeriod('90d')
setStaffFilter('')
}}
disabled={!hasFilters}
>
<FilterX className="h-4 w-4" />
Limpar todos os filtros
</Button>
</div>
</div>
</div>
<div className="flex items-center gap-3 text-sm">
<Badge variant="secondary">{workOrders.length} total</Badge>
<Badge>{filteredOrders.length} no filtro atual</Badge>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
{[
['Técnicos no período', String(kpis.activeTech)],
@@ -200,6 +329,34 @@ export default function TechnicianReportsPage() {
))}
</div>
<div className="grid gap-4 md:grid-cols-2">
<article className={`rounded-2xl border p-5 ${isLight ? 'border-cyan-200 bg-cyan-50/70' : 'border-cyan-500/30 bg-cyan-500/10'}`}>
<p className={`text-xs uppercase tracking-[0.2em] ${isLight ? 'text-cyan-700' : 'text-cyan-300'}`}>Equipa Interna</p>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<p className={isLight ? 'text-slate-700' : 'text-slate-200'}>Técnicos: <strong>{staffMix.internal.techCount}</strong></p>
<p className={isLight ? 'text-slate-700' : 'text-slate-200'}>OTs: <strong>{staffMix.internal.otsCount}</strong></p>
<p className={isLight ? 'text-slate-700' : 'text-slate-200'}>Horas: <strong>{staffMix.internal.hours.toFixed(2)}h</strong></p>
<p className={isLight ? 'text-slate-700' : 'text-slate-200'}>Custo: <strong>{currency(staffMix.internal.total)}</strong></p>
</div>
<p className={`mt-3 text-sm ${isLight ? 'text-cyan-800' : 'text-cyan-200'}`}>
Participação: {toPercent(staffMix.internal.shareHours)} em horas | {toPercent(staffMix.internal.shareCost)} em custo.
</p>
</article>
<article className={`rounded-2xl border p-5 ${isLight ? 'border-violet-200 bg-violet-50/70' : 'border-violet-500/30 bg-violet-500/10'}`}>
<p className={`text-xs uppercase tracking-[0.2em] ${isLight ? 'text-violet-700' : 'text-violet-300'}`}>Equipa Externa</p>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<p className={isLight ? 'text-slate-700' : 'text-slate-200'}>Técnicos: <strong>{staffMix.external.techCount}</strong></p>
<p className={isLight ? 'text-slate-700' : 'text-slate-200'}>OTs: <strong>{staffMix.external.otsCount}</strong></p>
<p className={isLight ? 'text-slate-700' : 'text-slate-200'}>Horas: <strong>{staffMix.external.hours.toFixed(2)}h</strong></p>
<p className={isLight ? 'text-slate-700' : 'text-slate-200'}>Custo: <strong>{currency(staffMix.external.total)}</strong></p>
</div>
<p className={`mt-3 text-sm ${isLight ? 'text-violet-800' : 'text-violet-200'}`}>
Participação: {toPercent(staffMix.external.shareHours)} em horas | {toPercent(staffMix.external.shareCost)} em custo.
</p>
</article>
</div>
{isLoading && (
<p className={isLight ? 'text-slate-600 text-sm' : 'text-slate-400 text-sm'}>A calcular relatório de técnicos...</p>
)}
@@ -232,24 +389,83 @@ export default function TechnicianReportsPage() {
</article>
<article className={`rounded-2xl border p-5 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<h2 className={`text-lg font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Evolução Mensal (Horas e Custo)</h2>
<div className="mt-4 space-y-3">
{monthly.map((m) => (
<div key={m.key}>
<div className="mb-1 flex justify-between text-xs">
<span className={isLight ? 'text-slate-700 capitalize' : 'text-slate-300 capitalize'}>{m.label}</span>
<span className={isLight ? 'text-slate-600' : 'text-slate-400'}>
{m.hours.toFixed(2)}h | {currency(m.total)}
</span>
</div>
<div className={`h-2 rounded-full ${isLight ? 'bg-slate-200' : 'bg-slate-800'}`}>
<div
className="h-2 rounded-full bg-gradient-to-r from-indigo-500 to-sky-400"
style={{ width: `${(m.total / maxMonthly) * 100}%` }}
<div className="flex items-start justify-between gap-3">
<div>
<h2 className={`text-lg font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Curva de Desempenho</h2>
<p className={`mt-1 text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>
Linha contínua: custo | Linha tracejada: horas
</p>
</div>
<p className={`rounded-full px-3 py-1 text-xs font-medium ${chart.trend >= 0
? (isLight ? 'bg-emerald-100 text-emerald-700' : 'bg-emerald-500/15 text-emerald-200')
: (isLight ? 'bg-rose-100 text-rose-700' : 'bg-rose-500/15 text-rose-200')}`}
>
Tendência: {chart.trend >= 0 ? 'Alta' : 'Queda'}
</p>
</div>
<div className="mt-4 overflow-x-auto">
<svg viewBox={`0 0 ${chart.width} ${chart.height}`} className="h-64 w-full min-w-[520px]" role="img" aria-label="Curva mensal de desempenho de técnicos">
<defs>
<linearGradient id="curve-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={isLight ? '#0ea5e9' : '#22d3ee'} stopOpacity="0.35" />
<stop offset="100%" stopColor={isLight ? '#0ea5e9' : '#22d3ee'} stopOpacity="0.03" />
</linearGradient>
</defs>
{[0, 1, 2, 3].map((tick) => {
const y = chart.pad.top + ((chart.height - chart.pad.top - chart.pad.bottom) / 3) * tick
return (
<line
key={tick}
x1={chart.pad.left}
x2={chart.width - chart.pad.right}
y1={y}
y2={y}
stroke={isLight ? '#cbd5e1' : '#334155'}
strokeDasharray="4 6"
strokeWidth="1"
/>
</div>
</div>
))}
)
})}
<path d={chart.costAreaPath} fill="url(#curve-fill)" />
<path d={chart.costPath} fill="none" stroke={isLight ? '#0284c7' : '#22d3ee'} strokeWidth="3" strokeLinecap="round" />
<path d={chart.hoursPath} fill="none" stroke={isLight ? '#7c3aed' : '#a78bfa'} strokeWidth="2" strokeLinecap="round" strokeDasharray="8 6" />
{chart.costPoints.map((point) => (
<g key={point.label}>
<circle cx={point.x} cy={point.y} r="3.5" fill={isLight ? '#0284c7' : '#22d3ee'} />
<text
x={point.x}
y={chart.height - 12}
textAnchor="middle"
className={isLight ? 'fill-slate-600 text-[11px] capitalize' : 'fill-slate-400 text-[11px] capitalize'}
>
{point.label}
</text>
</g>
))}
</svg>
</div>
<div className="mt-3 grid gap-2 sm:grid-cols-3">
<div className={`rounded-lg border p-3 ${isLight ? 'border-slate-200 bg-slate-50' : 'border-slate-700 bg-slate-900/60'}`}>
<p className={`text-xs ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>Pico mensal</p>
<p className={isLight ? 'text-slate-900 font-semibold' : 'text-white font-semibold'}>{currency(chart.maxTotal)}</p>
</div>
<div className={`rounded-lg border p-3 ${isLight ? 'border-slate-200 bg-slate-50' : 'border-slate-700 bg-slate-900/60'}`}>
<p className={`text-xs ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>Último mês</p>
<p className={isLight ? 'text-slate-900 font-semibold' : 'text-white font-semibold'}>
{currency(monthly[monthly.length - 1]?.total ?? 0)}
</p>
</div>
<div className={`rounded-lg border p-3 ${isLight ? 'border-slate-200 bg-slate-50' : 'border-slate-700 bg-slate-900/60'}`}>
<p className={`text-xs ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>Horas último mês</p>
<p className={isLight ? 'text-slate-900 font-semibold' : 'text-white font-semibold'}>
{(monthly[monthly.length - 1]?.hours ?? 0).toFixed(2)}h
</p>
</div>
</div>
</article>
</div>
@@ -306,7 +306,7 @@ export default function WorkOrderDetailPage() {
// ── Render ────────────────────────────────────────────────────────────────────
if (isLoading) return <p className="text-slate-400">A carregar...</p>
if (detailError) return <p className="text-red-400">Erro ao abrir a ordem: {(detailError as Error).message}</p>
if (detailError) return <p className="text-[var(--ui-danger)]">Erro ao abrir a ordem: {(detailError as Error).message}</p>
if (!detail) return <p className="text-slate-400">Ordem não encontrada.</p>
const items = Array.isArray(detail.items) ? detail.items : []
@@ -356,7 +356,7 @@ export default function WorkOrderDetailPage() {
{WORK_ORDER_STATUS_LABEL[detail.status]}
</Badge>
{selectedDoc && (
<Button size="sm" variant="outline" onClick={openWOInvoicePDF}>
<Button size="sm" onClick={openWOInvoicePDF}>
Ver PDF
</Button>
)}
@@ -502,7 +502,7 @@ export default function WorkOrderDetailPage() {
/>
</div>
{metaErrors.eta_days && <p className="text-red-400 text-sm">{metaErrors.eta_days.message}</p>}
{updateMeta.error && <p className="text-red-400 text-sm">{(updateMeta.error as Error).message}</p>}
{updateMeta.error && <p className="text-sm text-[var(--ui-danger)]">{(updateMeta.error as Error).message}</p>}
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setShowMetaEdit(false)}>Cancelar</Button>
<Button type="submit" disabled={updateMeta.isPending}>
@@ -539,7 +539,7 @@ export default function WorkOrderDetailPage() {
))}
</div>
{transition.error && (
<p className="text-sm text-red-400">{(transition.error as Error).message}</p>
<p className="text-sm text-[var(--ui-danger)]">{(transition.error as Error).message}</p>
)}
</div>
)}
@@ -602,7 +602,7 @@ export default function WorkOrderDetailPage() {
<Label>Desconto (%)</Label>
<Input type="number" step="0.01" {...regItem('discount_pct')} className="bg-slate-900 border-slate-600 text-white" />
</div>
{addItem.error && <p className="col-span-3 text-red-400 text-sm">{(addItem.error as Error).message}</p>}
{addItem.error && <p className="col-span-3 text-sm text-[var(--ui-danger)]">{(addItem.error as Error).message}</p>}
<div className="col-span-3 flex gap-2 justify-end">
<Button type="button" variant="outline" size="sm" onClick={() => setShowAddItem(false)}>Cancelar</Button>
<Button type="submit" size="sm" disabled={addItem.isPending}>
@@ -641,7 +641,7 @@ export default function WorkOrderDetailPage() {
</div>
)}
{item.change_justification && (
<div className="text-xs text-amber-300/90 mt-1">
<div className="mt-1 text-xs text-[var(--ui-warning)]">
Justificação: {item.change_justification}
</div>
)}
@@ -655,7 +655,7 @@ export default function WorkOrderDetailPage() {
<Button
size="sm"
variant="ghost"
className="text-red-400 hover:text-red-300 h-7 px-2"
className="h-7 px-2 text-[var(--ui-danger)] hover:text-[var(--ui-danger-hover)]"
onClick={() => setConfirmAction({ type: 'remove_item', id: item.id })}
disabled={removeItem.isPending}
>
@@ -735,7 +735,7 @@ export default function WorkOrderDetailPage() {
<div className="flex items-end pb-0.5">
<p className="text-slate-500 text-xs">Preço preenchido automaticamente<br/>do cadastro do técnico</p>
</div>
{addSH.error && <p className="col-span-3 text-red-400 text-sm">{(addSH.error as Error).message}</p>}
{addSH.error && <p className="col-span-3 text-sm text-[var(--ui-danger)]">{(addSH.error as Error).message}</p>}
<div className="col-span-3 flex gap-2 justify-end">
<Button type="button" variant="outline" size="sm" onClick={() => setShowAddStaff(false)}>Cancelar</Button>
<Button type="submit" size="sm" disabled={addSH.isPending}>
@@ -781,7 +781,7 @@ export default function WorkOrderDetailPage() {
<Button
size="sm"
variant="ghost"
className="text-red-400 hover:text-red-300 h-7 px-2"
className="h-7 px-2 text-[var(--ui-danger)] hover:text-[var(--ui-danger-hover)]"
onClick={() => setConfirmAction({ type: 'remove_staff', id: sh.id })}
disabled={removeSH.isPending}
>
+46 -24
View File
@@ -1,9 +1,10 @@
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router'
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'
@@ -43,9 +44,14 @@ export default function WorkOrdersPage() {
const [docError, setDocError] = useState('')
const { data: orders = [], isLoading } = useQuery<WorkOrder[]>({
queryKey: ['work-orders', statusFilter],
queryFn: () => apiFetch<WorkOrder[]>(`/work-orders${statusFilter ? `?status=${statusFilter}` : ''}`),
queryKey: ['work-orders'],
queryFn: () => apiFetch<WorkOrder[]>('/work-orders'),
})
const filteredOrders = useMemo(
() => (statusFilter ? orders.filter((o) => o.status === statusFilter) : orders),
[orders, statusFilter]
)
const hasFilters = statusFilter.length > 0
const { data: clients = [] } = useQuery<Client[]>({
queryKey: ['clients'],
@@ -91,29 +97,45 @@ export default function WorkOrdersPage() {
<div className="flex items-center justify-between mb-6">
<div>
<h1 className={`text-2xl font-bold ${isLight ? 'text-slate-900' : 'text-white'}`}>Ordens de Trabalho</h1>
<p className={`text-sm mt-0.5 ${isLight ? 'text-slate-700' : 'text-slate-400'}`}>{orders.length} ordens</p>
<p className={`text-sm mt-0.5 ${isLight ? 'text-slate-700' : 'text-slate-400'}`}>{filteredOrders.length} ordens</p>
</div>
<Button onClick={() => { reset(emptyOrder); setShowForm(true) }}>Nova Ordem</Button>
</div>
<div className="flex gap-2 mb-4">
{['', 'quote', 'open', 'in_progress', 'completed', 'invoiced', 'cancelled'].map((s) => (
<button
key={s}
onClick={() => setStatusFilter(s)}
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
statusFilter === s
? isLight
? 'bg-sky-700 text-slate-50'
: 'bg-slate-600 text-slate-50'
: isLight
? 'text-slate-600 hover:text-slate-900 hover:bg-slate-200'
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800'
<div className={`mb-4 grid gap-3 rounded-lg border p-4 md:grid-cols-3 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/50'}`}>
<div className="space-y-1 md:col-span-2">
<label className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Estado da OT</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className={`w-full rounded-md border px-3 py-2 text-sm ${
isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'
}`}
>
{s === '' ? 'Todas' : WORK_ORDER_STATUS_LABEL[s as WorkOrder['status']]}
</button>
))}
<option value="">Todos os estados</option>
{(['quote', 'open', 'in_progress', 'completed', 'invoiced', 'cancelled'] as WorkOrder['status'][]).map((s) => (
<option key={s} value={s}>{WORK_ORDER_STATUS_LABEL[s]}</option>
))}
</select>
</div>
<div className="space-y-1">
<label className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Ações rápidas</label>
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => setStatusFilter('')}
disabled={!hasFilters}
>
<FilterX className="h-4 w-4" />
Limpar todos os filtros
</Button>
</div>
</div>
<div className="mb-4 flex items-center gap-3 text-sm">
<Badge variant="secondary">{orders.length} total</Badge>
<Badge>{filteredOrders.length} no filtro atual</Badge>
</div>
{showForm && (
@@ -182,10 +204,10 @@ export default function WorkOrdersPage() {
</select>
</div>
{create.error && (
<p className="col-span-2 text-red-400 text-sm">{create.error.message}</p>
<p className="col-span-2 text-sm text-[var(--ui-danger)]">{create.error.message}</p>
)}
{docError && (
<p className="col-span-2 text-amber-300 text-sm">{docError}</p>
<p className="col-span-2 text-sm text-[var(--ui-warning)]">{docError}</p>
)}
<div className="col-span-2 flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={() => setShowForm(false)}>Cancelar</Button>
@@ -199,7 +221,7 @@ export default function WorkOrdersPage() {
{isLoading ? (
<p className={isLight ? 'text-slate-700' : 'text-slate-400'}>A carregar...</p>
) : orders.length === 0 ? (
) : filteredOrders.length === 0 ? (
<p className={`text-sm ${isLight ? 'text-slate-700' : 'text-slate-500'}`}>Nenhuma ordem de trabalho.</p>
) : (
<div className={`rounded-lg border overflow-hidden ${isLight ? 'border-slate-300' : 'border-slate-700'}`}>
@@ -216,7 +238,7 @@ export default function WorkOrdersPage() {
</tr>
</thead>
<tbody>
{orders.map((o) => (
{filteredOrders.map((o) => (
<tr
key={o.id}
className={`${isLight ? 'border-t border-slate-300 hover:bg-slate-100' : 'border-t border-slate-700 hover:bg-slate-800/50'}`}
+5 -4
View File
@@ -8,6 +8,7 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useLogin } from '@/hooks/useAuth'
import { apiFetch } from '@/lib/api'
import { getContrastText, safeHex } from '@/lib/color'
import type { PlatformSettings } from '@/lib/types'
const schema = z.object({
@@ -40,8 +41,8 @@ export default function LoginPage() {
tenant_slug: data.tenant_slug || undefined,
})
}
const primary = platform?.admin_primary_color || '#0f3b47'
const accent = platform?.admin_accent_color || '#06b6d4'
const primary = safeHex(platform?.admin_primary_color, '#0f3b47')
const accent = safeHex(platform?.admin_accent_color, '#06b6d4')
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-600 via-slate-700 to-slate-800 px-4">
@@ -100,9 +101,9 @@ export default function LoginPage() {
</div>
)}
{error && <p className="text-red-500 text-sm text-center">{error.message}</p>}
{error && <p className="text-sm text-center text-[var(--ui-danger)]">{error.message}</p>}
<Button type="submit" className="w-full" disabled={isPending} style={{ backgroundColor: primary, color: '#e6fffb' }}>
<Button type="submit" className="w-full" disabled={isPending} style={{ backgroundColor: primary, color: getContrastText(primary) }}>
{isPending ? 'A entrar...' : 'Entrar'}
</Button>
+55 -26
View File
@@ -8,7 +8,9 @@ import { useAuthStore } from '@/store/authStore'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import type { Invite } from '@/lib/types'
import type { Invite, PlatformSettings } from '@/lib/types'
import { useState } from 'react'
import { getContrastText, safeHex } from '@/lib/color'
const schema = z.object({
tenant_name: z.string().min(2, 'Nome obrigatório (mínimo 2 caracteres)'),
@@ -31,6 +33,13 @@ export default function InviteRedeemPage() {
const { token } = useParams<{ token: string }>()
const navigate = useNavigate()
const { setAuth } = useAuthStore()
const [logoBroken, setLogoBroken] = useState(false)
const { data: platform } = useQuery<PlatformSettings>({
queryKey: ['platform', 'settings'],
queryFn: () => apiFetch<PlatformSettings>('/platform/settings'),
retry: false,
})
const {
data: invite,
@@ -63,20 +72,23 @@ export default function InviteRedeemPage() {
},
})
const primary = safeHex(platform?.admin_primary_color, '#0f3b47')
const accent = safeHex(platform?.admin_accent_color, '#06b6d4')
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<p className="text-gray-500">A validar convite...</p>
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-600 via-slate-700 to-slate-800 px-4">
<p className="text-slate-200">A validar convite...</p>
</div>
)
}
if (inviteError || !invite) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900">Convite inválido</h1>
<p className="text-gray-500 mt-2 text-sm">
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-600 via-slate-700 to-slate-800 px-4">
<div className="rounded-xl border border-slate-200/90 bg-slate-50 p-8 text-center shadow-xl">
<h1 className="text-2xl font-bold text-slate-900">Convite inválido</h1>
<p className="mt-2 text-sm text-slate-500">
Este convite não existe, expirou ou foi utilizado.
</p>
</div>
@@ -85,32 +97,46 @@ export default function InviteRedeemPage() {
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-600 via-slate-700 to-slate-800 px-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900">TechXCar</h1>
<p className="text-gray-500 mt-1 text-sm">Criar conta da oficina</p>
{platform?.platform_logo && !logoBroken && (
<img
src={platform.platform_logo}
alt=""
className="mx-auto mb-3 h-14 object-contain"
onError={() => setLogoBroken(true)}
referrerPolicy="no-referrer"
/>
)}
{logoBroken && (
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-md bg-gray-200 font-semibold text-gray-700">
TX
</div>
)}
<h1 className="text-3xl font-bold text-slate-100">{platform?.platform_name || 'TechXCar'}</h1>
<p className="mt-1 text-sm text-slate-300">{platform?.platform_subtitle || 'Criar conta da oficina'}</p>
</div>
<form
onSubmit={handleSubmit((data) => redeem.mutate(data))}
className="bg-white p-8 rounded-lg shadow-sm border border-gray-200 space-y-4"
className="space-y-4 rounded-xl border border-slate-200/90 bg-slate-50 p-8 shadow-xl"
>
<fieldset className="space-y-3">
<legend className="text-sm font-semibold text-gray-700 pb-1 border-b border-gray-100 w-full">
<legend className="w-full border-b border-slate-200 pb-1 text-sm font-semibold text-slate-700">
Dados da oficina
</legend>
<div className="space-y-1.5">
<Label htmlFor="tenant_name">Nome da oficina</Label>
<Input id="tenant_name" {...register('tenant_name')} placeholder="Oficina XYZ" />
<Label htmlFor="tenant_name" className="text-slate-700">Nome da oficina</Label>
<Input id="tenant_name" {...register('tenant_name')} placeholder="Oficina XYZ" className="border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" />
{errors.tenant_name && (
<p className="text-red-500 text-xs">{errors.tenant_name.message}</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="tenant_slug">Identificador (slug)</Label>
<Input id="tenant_slug" {...register('tenant_slug')} placeholder="oficina-xyz" />
<p className="text-gray-400 text-xs">
<Label htmlFor="tenant_slug" className="text-slate-700">Identificador (slug)</Label>
<Input id="tenant_slug" {...register('tenant_slug')} placeholder="oficina-xyz" className="border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" />
<p className="text-xs text-slate-400">
Usado no URL apenas letras minúsculas, números e hífens.
</p>
{errors.tenant_slug && (
@@ -120,26 +146,26 @@ export default function InviteRedeemPage() {
</fieldset>
<fieldset className="space-y-3">
<legend className="text-sm font-semibold text-gray-700 pb-1 border-b border-gray-100 w-full">
<legend className="w-full border-b border-slate-200 pb-1 text-sm font-semibold text-slate-700">
Conta de administrador
</legend>
<div className="space-y-1.5">
<Label htmlFor="admin_name">Nome</Label>
<Input id="admin_name" {...register('admin_name')} placeholder="João Silva" />
<Label htmlFor="admin_name" className="text-slate-700">Nome</Label>
<Input id="admin_name" {...register('admin_name')} placeholder="João Silva" className="border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" />
{errors.admin_name && (
<p className="text-red-500 text-xs">{errors.admin_name.message}</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="admin_email">Email</Label>
<Input id="admin_email" type="email" {...register('admin_email')} />
<Label htmlFor="admin_email" className="text-slate-700">Email</Label>
<Input id="admin_email" type="email" {...register('admin_email')} className="border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" />
{errors.admin_email && (
<p className="text-red-500 text-xs">{errors.admin_email.message}</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="admin_password">Password</Label>
<Input id="admin_password" type="password" {...register('admin_password')} />
<Label htmlFor="admin_password" className="text-slate-700">Password</Label>
<Input id="admin_password" type="password" {...register('admin_password')} className="border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" />
{errors.admin_password && (
<p className="text-red-500 text-xs">{errors.admin_password.message}</p>
)}
@@ -147,12 +173,15 @@ export default function InviteRedeemPage() {
</fieldset>
{redeem.error && (
<p className="text-red-500 text-sm text-center">{redeem.error.message}</p>
<p className="text-sm text-center text-[var(--ui-danger)]">{redeem.error.message}</p>
)}
<Button type="submit" className="w-full" disabled={redeem.isPending}>
<Button type="submit" className="w-full" disabled={redeem.isPending} style={{ backgroundColor: primary, color: getContrastText(primary) }}>
{redeem.isPending ? 'A criar conta...' : 'Criar conta'}
</Button>
<p className="text-center text-xs" style={{ color: accent }}>
Convite ativo para criação da oficina
</p>
</form>
</div>
</div>
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/layout/AdminLayout.tsx","./src/components/layout/AppLayout.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/confirm-action-dialog.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/hooks/useAuth.ts","./src/hooks/useClients.test.ts","./src/hooks/useClients.ts","./src/hooks/useTheme.ts","./src/lib/api.ts","./src/lib/queryClient.ts","./src/lib/types.ts","./src/lib/utils.ts","./src/lib/vehicleBrands.ts","./src/lib/workOrderStatus.ts","./src/pages/admin/DashboardPage.tsx","./src/pages/admin/SettingsPage.tsx","./src/pages/admin/TenantsPage.tsx","./src/pages/app/CatalogPage.tsx","./src/pages/app/ClientsPage.tsx","./src/pages/app/DashboardPage.tsx","./src/pages/app/ExpenseReportsPage.tsx","./src/pages/app/ExpensesPage.tsx","./src/pages/app/HelpPage.tsx","./src/pages/app/InvoicesPage.tsx","./src/pages/app/ReportsPage.tsx","./src/pages/app/SettingsPage.tsx","./src/pages/app/StaffPage.tsx","./src/pages/app/TechnicianReportsPage.tsx","./src/pages/app/WorkOrderDetailPage.tsx","./src/pages/app/WorkOrdersPage.tsx","./src/pages/auth/LoginPage.tsx","./src/pages/public/InviteRedeemPage.tsx","./src/store/authStore.test.ts","./src/store/authStore.ts","./src/test/setup.ts"],"version":"6.0.3"}
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/layout/AdminLayout.tsx","./src/components/layout/AppLayout.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/confirm-action-dialog.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/hooks/useAuth.ts","./src/hooks/useClients.test.ts","./src/hooks/useClients.ts","./src/hooks/useTheme.ts","./src/lib/api.ts","./src/lib/color.ts","./src/lib/queryClient.ts","./src/lib/types.ts","./src/lib/utils.ts","./src/lib/vehicleBrands.ts","./src/lib/workOrderStatus.ts","./src/pages/admin/DashboardPage.tsx","./src/pages/admin/SettingsPage.tsx","./src/pages/admin/TenantsPage.tsx","./src/pages/app/CatalogPage.tsx","./src/pages/app/ClientsPage.tsx","./src/pages/app/DashboardPage.tsx","./src/pages/app/ExpenseReportsPage.tsx","./src/pages/app/ExpensesPage.tsx","./src/pages/app/HelpPage.tsx","./src/pages/app/InvoicesPage.tsx","./src/pages/app/ReportsPage.tsx","./src/pages/app/SettingsPage.tsx","./src/pages/app/StaffPage.tsx","./src/pages/app/TechnicianReportsPage.tsx","./src/pages/app/WorkOrderDetailPage.tsx","./src/pages/app/WorkOrdersPage.tsx","./src/pages/auth/LoginPage.tsx","./src/pages/public/InviteRedeemPage.tsx","./src/store/authStore.test.ts","./src/store/authStore.ts","./src/test/setup.ts"],"version":"6.0.3"}