feat: refine OT flow, reports, theming and sidebar UX

This commit is contained in:
Luciano Milani
2026-07-02 22:19:03 +01:00
parent 8231bba3f9
commit 9bf176b385
48 changed files with 3469 additions and 599 deletions
+10
View File
@@ -13,8 +13,13 @@ import StaffPage from '@/pages/app/StaffPage'
import ExpensesPage from '@/pages/app/ExpensesPage'
import SettingsPage from '@/pages/app/SettingsPage'
import InvoicesPage from '@/pages/app/InvoicesPage'
import ReportsPage from '@/pages/app/ReportsPage'
import ExpenseReportsPage from '@/pages/app/ExpenseReportsPage'
import TechnicianReportsPage from '@/pages/app/TechnicianReportsPage'
import HelpPage from '@/pages/app/HelpPage'
import AdminDashboardPage from '@/pages/admin/DashboardPage'
import TenantsPage from '@/pages/admin/TenantsPage'
import AdminSettingsPage from '@/pages/admin/SettingsPage'
import InviteRedeemPage from '@/pages/public/InviteRedeemPage'
import AppLayout from '@/components/layout/AppLayout'
import AdminLayout from '@/components/layout/AdminLayout'
@@ -56,6 +61,7 @@ export default function App() {
>
<Route index element={<AdminDashboardPage />} />
<Route path="tenants" element={<TenantsPage />} />
<Route path="settings" element={<AdminSettingsPage />} />
</Route>
<Route
@@ -74,7 +80,11 @@ export default function App() {
<Route path="staff" element={<StaffPage />} />
<Route path="expenses" element={<ExpensesPage />} />
<Route path="invoices" element={<InvoicesPage />} />
<Route path="reports" element={<ReportsPage />} />
<Route path="reports/expenses" element={<ExpenseReportsPage />} />
<Route path="reports/technicians" element={<TechnicianReportsPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="help" element={<HelpPage />} />
</Route>
<Route path="/" element={<Navigate to="/app" replace />} />
+80 -18
View File
@@ -1,53 +1,115 @@
import { useMemo, useState } from 'react'
import { Outlet, NavLink } from 'react-router'
import { useQuery } from '@tanstack/react-query'
import { useLogout } from '@/hooks/useAuth'
import { useTheme } from '@/hooks/useTheme'
import { apiFetch } from '@/lib/api'
import type { PlatformSettings } from '@/lib/types'
export default function AdminLayout() {
const { mutate: logout } = useLogout()
const { theme, toggleTheme } = useTheme('ui_theme', 'dark')
const [logoBroken, setLogoBroken] = useState(false)
const { data: settings } = useQuery<PlatformSettings>({
queryKey: ['admin', 'settings'],
queryFn: () => apiFetch<PlatformSettings>('/admin/settings'),
})
const primary = settings?.admin_primary_color || '#2563eb'
const accent = settings?.admin_accent_color || '#0ea5e9'
const isLight = theme === 'light'
const shellStyle = useMemo(
() => ({
backgroundColor: 'var(--ui-bg)',
color: 'var(--ui-text)',
['--brand-primary' as string]: primary,
['--brand-accent' as string]: accent,
}),
[primary, accent]
)
const asideStyle = useMemo(
() => ({
backgroundColor: 'var(--ui-sidebar)',
borderColor: 'var(--ui-border)',
}),
[]
)
const mainStyle = useMemo(
() => ({
background: isLight
? 'linear-gradient(180deg, #f8fbff 0%, #eef4fb 100%)'
: 'linear-gradient(180deg, #0b1530 0%, #050b18 100%)',
}),
[isLight]
)
return (
<div className="flex h-screen bg-slate-950">
<aside className="w-64 bg-slate-900 border-r border-slate-800 flex flex-col">
<div className="p-4 border-b border-slate-800">
<h1 className="text-lg font-bold text-white">TechXCar</h1>
<p className="text-xs text-slate-400 mt-0.5">Administração</p>
<div className="flex h-screen" style={shellStyle}>
<aside className="w-64 border-r flex flex-col" style={asideStyle}>
<div className="p-4 border-b" style={{ borderColor: 'var(--ui-border)' }}>
{settings?.platform_logo && !logoBroken ? (
<div className="mb-2 flex h-14 w-14 items-center justify-center rounded-md border bg-white p-1" style={{ borderColor: 'var(--ui-border)' }}>
<img
src={settings.platform_logo}
alt=""
className="h-full w-full object-contain"
onError={() => setLogoBroken(true)}
referrerPolicy="no-referrer"
/>
</div>
) : (
<div className="mb-2 h-14 w-14 rounded-md flex items-center justify-center text-sm font-semibold" style={{ backgroundColor: `${accent}22`, color: primary }}>
TX
</div>
)}
<h1 className="text-lg font-bold text-[var(--ui-text)]">{settings?.platform_name || 'TechXCar'}</h1>
<p className="text-xs mt-0.5 text-[var(--ui-muted)]">{settings?.platform_subtitle || 'Administração Global'}</p>
</div>
<nav className="flex-1 p-3 space-y-1">
<NavLink
to="/admin"
end
className={({ isActive }) =>
`flex items-center px-3 py-2 rounded-md text-sm transition-colors ${
isActive
? 'bg-slate-700 text-white'
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
}`
`flex items-center px-3 py-2 rounded-md text-sm transition-colors ${isActive ? 'text-white' : 'text-[var(--ui-muted)] hover:text-[var(--ui-text)]'}`
}
style={({ isActive }) => (isActive ? { backgroundColor: primary } : { backgroundColor: 'transparent' })}
>
Dashboard
</NavLink>
<NavLink
to="/admin/tenants"
className={({ isActive }) =>
`flex items-center px-3 py-2 rounded-md text-sm transition-colors ${
isActive
? 'bg-slate-700 text-white'
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
}`
`flex items-center px-3 py-2 rounded-md text-sm transition-colors ${isActive ? 'text-white' : 'text-[var(--ui-muted)] hover:text-[var(--ui-text)]'}`
}
style={({ isActive }) => (isActive ? { backgroundColor: primary } : { backgroundColor: 'transparent' })}
>
Oficinas
</NavLink>
<NavLink
to="/admin/settings"
className={({ isActive }) =>
`flex items-center px-3 py-2 rounded-md text-sm transition-colors ${isActive ? 'text-white' : 'text-[var(--ui-muted)] hover:text-[var(--ui-text)]'}`
}
style={({ isActive }) => (isActive ? { backgroundColor: primary } : { backgroundColor: 'transparent' })}
>
Definições Globais
</NavLink>
</nav>
<div className="p-3 border-t border-slate-800">
<div className="p-3 border-t" style={{ borderColor: 'var(--ui-border)' }}>
<button
onClick={toggleTheme}
className="mb-2 w-full text-left px-3 py-2 text-sm rounded-md transition-colors text-[var(--ui-muted)] hover:text-[var(--ui-text)] hover:bg-[var(--ui-hover)]"
>
Tema: {theme === 'dark' ? 'Dark' : 'Light'}
</button>
<button
onClick={() => logout()}
className="w-full text-left px-3 py-2 text-sm text-slate-400 hover:text-white rounded-md hover:bg-slate-800 transition-colors"
className="w-full text-left px-3 py-2 text-sm rounded-md transition-colors text-[var(--ui-muted)] hover:text-[var(--ui-text)] hover:bg-[var(--ui-hover)]"
>
Terminar sessão
</button>
</div>
</aside>
<main className="flex-1 overflow-auto p-6 text-white">
<main className="flex-1 overflow-auto p-6 text-[var(--ui-text)]" style={mainStyle}>
<Outlet />
</main>
</div>
+234 -45
View File
@@ -1,32 +1,107 @@
import { useLayoutEffect } from 'react'
import { useLayoutEffect, useMemo, useState, type ComponentType } from 'react'
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router'
import { useQuery } from '@tanstack/react-query'
import {
BarChart3,
CircleHelp,
ChevronDown,
ChevronRight,
ClipboardList,
FileText,
Fuel,
Gauge,
Home,
LogOut,
Moon,
Package,
PanelLeftClose,
PanelLeftOpen,
Settings,
Sun,
Users,
Wrench,
} from 'lucide-react'
import { useLogout } from '@/hooks/useAuth'
import { useTheme } from '@/hooks/useTheme'
import { apiFetch } from '@/lib/api'
import { useAuthStore } from '@/store/authStore'
import { queryClient } from '@/lib/queryClient'
import type { TenantSettings } from '@/lib/types'
const nav = [
{ to: '/app', label: 'Dashboard', end: true },
{ to: '/app/work-orders', label: 'Ordens de Trabalho' },
{ to: '/app/clients', label: 'Clientes' },
{ to: '/app/catalog', label: 'Catálogo' },
{ to: '/app/staff', label: 'Técnicos' },
{ to: '/app/expenses', label: 'Despesas' },
{ to: '/app/invoices', label: 'Transações' },
{ to: '/app/settings', label: 'Definições' },
type MainNavItem = {
to: string
label: string
end?: boolean
icon: ComponentType<{ className?: string }>
}
type ReportNavItem = {
to: string
label: string
icon: ComponentType<{ className?: string }>
}
const MAIN_NAV: MainNavItem[] = [
{ to: '/app', label: 'Dashboard', end: true, icon: Home },
{ to: '/app/work-orders', label: 'Ordens de Trabalho', icon: ClipboardList },
{ to: '/app/clients', label: 'Clientes', icon: Users },
{ to: '/app/catalog', label: 'Catálogo', icon: Package },
{ to: '/app/staff', label: 'Técnicos', icon: Wrench },
{ to: '/app/expenses', label: 'Despesas', icon: Fuel },
{ to: '/app/invoices', label: 'Transações', icon: FileText },
]
const REPORTS_NAV: ReportNavItem[] = [
{ to: '/app/reports', label: 'OTs Cliente/Viatura', icon: BarChart3 },
{ to: '/app/reports/expenses', label: 'Relatório Despesas', icon: Fuel },
{ to: '/app/reports/technicians', label: 'Desempenho Técnicos', icon: Gauge },
]
const SETTINGS_NAV: MainNavItem = { to: '/app/settings', label: 'Definições', icon: Settings }
const HELP_NAV: MainNavItem = { to: '/app/help', label: 'Ajuda', icon: CircleHelp }
export default function AppLayout() {
const { mutate: logout } = useLogout()
const { previousSession, restoreSession, user, impersonateTenant } = useAuthStore()
const { theme, setTheme, toggleTheme } = useTheme('ui_theme', 'dark')
const isLight = theme === 'light'
const [logoBroken, setLogoBroken] = useState(false)
const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(() => window.localStorage.getItem('app_sidebar_collapsed') === '1')
const navigate = useNavigate()
const location = useLocation()
const reportsActive = location.pathname.startsWith('/app/reports')
const [reportsOpen, setReportsOpen] = useState(reportsActive)
const { data: settings } = useQuery<TenantSettings>({
queryKey: ['settings', 'layout'],
queryFn: () => apiFetch<TenantSettings>('/settings'),
retry: false,
})
useLayoutEffect(() => {
const initialTheme = settings?.ui_theme
if (initialTheme === 'dark' || initialTheme === 'light') {
const hasLocal = window.localStorage.getItem('ui_theme')
if (!hasLocal) setTheme(initialTheme)
}
}, [settings?.ui_theme, setTheme])
useLayoutEffect(() => {
setLogoBroken(false)
}, [settings?.company_logo])
useLayoutEffect(() => {
if (reportsActive) setReportsOpen(true)
}, [reportsActive])
useLayoutEffect(() => {
window.localStorage.setItem('app_sidebar_collapsed', sidebarCollapsed ? '1' : '0')
}, [sidebarCollapsed])
useLayoutEffect(() => {
const imp = location.state?.impersonation
if (imp) {
impersonateTenant(imp.token, imp.user)
queryClient.clear()
// Remove impersonation payload from history state so back/forward doesn't re-apply it
navigate(location.pathname, { replace: true, state: {} })
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
@@ -36,55 +111,169 @@ export default function AppLayout() {
navigate('/admin')
}
const navItemClass = useMemo(() => {
return (active: boolean) =>
`flex items-center rounded-md text-sm transition-colors ${
sidebarCollapsed ? 'justify-center px-2 py-2.5' : 'gap-2 px-3 py-2'
} ${
active
? isLight
? 'bg-sky-700 text-slate-50'
: 'bg-slate-700 text-slate-100'
: isLight
? 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
}`
}, [isLight, sidebarCollapsed])
const navIconClass = sidebarCollapsed ? 'h-5 w-5 shrink-0' : 'h-4 w-4 shrink-0'
return (
<div className="flex flex-col h-screen bg-slate-950">
<div className={`app-shell flex h-screen flex-col ${isLight ? 'bg-slate-100' : 'bg-slate-950'}`}>
{previousSession && (
<div className="flex items-center justify-between px-4 py-2 bg-amber-500 text-amber-950 text-sm font-medium shrink-0">
<span>
TechXCar Admin a gerir: <strong>{user?.name}</strong>
</span>
<button
onClick={handleRestore}
className="px-3 py-1 rounded bg-amber-950 text-amber-100 hover:bg-amber-900 text-xs font-semibold transition-colors"
>
Voltar ao painel
</button>
<div className="shrink-0 bg-amber-500 px-4 py-2 text-sm font-medium text-amber-950">
<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"
>
Voltar ao painel
</button>
</div>
</div>
)}
<div className="flex flex-1 overflow-hidden">
<aside className="w-64 bg-slate-900 border-r border-slate-800 flex flex-col">
<div className="p-4 border-b border-slate-800">
<h1 className="text-lg font-bold text-white">TechXCar</h1>
<p className="text-xs text-slate-400 mt-0.5">Gestão de Oficina</p>
<aside className={`${sidebarCollapsed ? 'w-20' : 'w-72'} flex flex-col border-r transition-all duration-200 ${isLight ? 'border-slate-200 bg-white' : 'border-slate-800 bg-slate-900'}`}>
<div className={`border-b p-4 ${isLight ? 'border-slate-200' : 'border-slate-800'}`}>
<div className={`mb-2 flex ${sidebarCollapsed ? 'justify-center' : 'items-start gap-2'}`}>
{settings?.company_logo && !logoBroken ? (
<div className="h-12 w-12 rounded-md bg-white/90 p-1">
<img
src={settings.company_logo}
alt=""
className="h-full w-full object-contain"
onError={() => setLogoBroken(true)}
referrerPolicy="no-referrer"
/>
</div>
) : (
<div className={`h-12 w-12 rounded-md text-sm font-semibold ${isLight ? 'bg-slate-200 text-slate-700' : 'bg-slate-700 text-white'} flex items-center justify-center`}>
TX
</div>
)}
</div>
{!sidebarCollapsed ? (
<>
<h1 className={`text-lg font-bold ${isLight ? 'text-slate-900' : 'text-white'}`}>TechXCar</h1>
<p className={`mt-0.5 text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>{settings?.company_name || 'Gestão de Oficina'}</p>
</>
) : null}
</div>
<nav className="flex-1 p-3 space-y-1">
{nav.map(({ to, label, end }) => (
<NavLink
key={to}
to={to}
end={end}
className={({ isActive }) =>
`flex items-center px-3 py-2 rounded-md text-sm transition-colors ${
isActive
? 'bg-slate-700 text-white'
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
}`
}
>
{label}
<nav className="flex-1 space-y-1 p-3">
{MAIN_NAV.map(({ to, label, icon: Icon, end }) => (
<NavLink key={to} to={to} end={end} className={({ isActive }) => navItemClass(isActive)} title={sidebarCollapsed ? label : undefined}>
<Icon className={navIconClass} />
{!sidebarCollapsed && <span>{label}</span>}
</NavLink>
))}
<div className="pt-1">
<button
onClick={() => {
if (sidebarCollapsed) {
setSidebarCollapsed(false)
setReportsOpen(true)
return
}
setReportsOpen((v) => !v)
}}
className={`${navItemClass(reportsActive)} w-full`}
title={sidebarCollapsed ? 'Relatórios' : undefined}
>
<BarChart3 className={navIconClass} />
{!sidebarCollapsed && (
<>
<span className="flex-1 text-left">Relatórios</span>
{reportsOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</>
)}
</button>
{!sidebarCollapsed && reportsOpen && (
<div className="mt-1 space-y-1 pl-2">
{REPORTS_NAV.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
end={to === '/app/reports'}
className={({ isActive }) =>
`flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors ${
isActive
? isLight
? 'bg-sky-700/15 text-sky-800'
: 'bg-slate-700 text-slate-100'
: isLight
? 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
}`
}
>
<Icon className={navIconClass} />
<span>{label}</span>
</NavLink>
))}
</div>
)}
</div>
<NavLink
to={SETTINGS_NAV.to}
className={({ isActive }) => navItemClass(isActive)}
title={sidebarCollapsed ? SETTINGS_NAV.label : undefined}
>
<SETTINGS_NAV.icon className={navIconClass} />
{!sidebarCollapsed && <span>{SETTINGS_NAV.label}</span>}
</NavLink>
</nav>
<div className="p-3 border-t border-slate-800">
<div className={`border-t p-3 ${isLight ? 'border-slate-200' : 'border-slate-800'}`}>
<button
onClick={() => setSidebarCollapsed((v) => !v)}
className={`${navItemClass(false)} w-full`}
title={sidebarCollapsed ? 'Expandir menu' : 'Comprimir menu'}
>
{sidebarCollapsed ? <PanelLeftOpen className={navIconClass} /> : <PanelLeftClose className={navIconClass} />}
{!sidebarCollapsed && <span>{sidebarCollapsed ? 'Expandir menu' : 'Comprimir menu'}</span>}
</button>
<NavLink
to={HELP_NAV.to}
className={({ isActive }) => navItemClass(isActive)}
title={sidebarCollapsed ? HELP_NAV.label : undefined}
>
<HELP_NAV.icon className={navIconClass} />
{!sidebarCollapsed && <span>{HELP_NAV.label}</span>}
</NavLink>
<button
onClick={toggleTheme}
className={`${navItemClass(false)} w-full`}
title={sidebarCollapsed ? `Tema: ${theme === 'dark' ? 'Dark' : 'Light'}` : undefined}
>
{theme === 'dark' ? <Moon className={navIconClass} /> : <Sun className={navIconClass} />}
{!sidebarCollapsed && <span>Tema: {theme === 'dark' ? 'Dark' : 'Light'}</span>}
</button>
<button
onClick={() => logout()}
className="w-full text-left px-3 py-2 text-sm text-slate-400 hover:text-white rounded-md hover:bg-slate-800 transition-colors"
className={`${navItemClass(false)} w-full`}
title={sidebarCollapsed ? 'Terminar sessão' : undefined}
>
Terminar sessão
<LogOut className={navIconClass} />
{!sidebarCollapsed && <span>Terminar sessão</span>}
</button>
</div>
</aside>
<main className="flex-1 overflow-auto p-6 text-white">
<main className={`flex-1 overflow-auto p-6 ${isLight ? 'text-slate-900' : 'text-white'}`}>
<Outlet />
</main>
</div>
+6 -6
View File
@@ -8,12 +8,12 @@ const buttonVariants = cva(
{
variants: {
variant: {
default: 'bg-blue-600 text-white shadow hover:bg-blue-700',
destructive: 'bg-red-600 text-white shadow-sm hover:bg-red-700',
outline: 'border border-gray-300 bg-white shadow-sm hover:bg-gray-50 text-gray-900',
secondary: 'bg-gray-100 text-gray-900 shadow-sm hover:bg-gray-200',
ghost: 'hover:bg-gray-100 text-gray-700',
link: 'text-blue-600 underline-offset-4 hover:underline',
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',
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)]',
link: 'text-[var(--ui-link)] underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
@@ -0,0 +1,43 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
type ConfirmActionDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description: string
confirmLabel?: string
cancelLabel?: string
pending?: boolean
onConfirm: () => void
}
export function ConfirmActionDialog({
open,
onOpenChange,
title,
description,
confirmLabel = 'Confirmar',
cancelLabel = 'Cancelar',
pending = false,
onConfirm,
}: 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>
<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">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
{cancelLabel}
</Button>
<Button type="button" variant="destructive" onClick={onConfirm} disabled={pending}>
{pending ? 'A processar...' : confirmLabel}
</Button>
</div>
</DialogContent>
</Dialog>
)
}
+1 -1
View File
@@ -9,7 +9,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-gray-300 bg-white px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50',
'flex h-9 w-full rounded-md border border-[var(--ui-input-border)] bg-[var(--ui-input-bg)] px-3 py-1 text-sm text-[var(--ui-input-text)] shadow-sm transition-colors placeholder:text-[var(--ui-muted)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand-accent)] disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
+26
View File
@@ -0,0 +1,26 @@
import { useEffect, useMemo, useState } from 'react'
export type UITheme = 'dark' | 'light'
function readTheme(storageKey: string, fallback: UITheme): UITheme {
if (typeof window === 'undefined') return fallback
const v = window.localStorage.getItem(storageKey)
return v === 'light' || v === 'dark' ? v : fallback
}
export function useTheme(storageKey = 'ui_theme', fallback: UITheme = 'dark') {
const [theme, setTheme] = useState<UITheme>(() => readTheme(storageKey, fallback))
useEffect(() => {
if (typeof window === 'undefined') return
window.localStorage.setItem(storageKey, theme)
document.documentElement.classList.toggle('theme-light', theme === 'light')
}, [storageKey, theme])
const toggleTheme = useMemo(
() => () => setTheme((t) => (t === 'dark' ? 'light' : 'dark')),
[]
)
return { theme, setTheme, toggleTheme }
}
+80
View File
@@ -2,4 +2,84 @@
:root {
--radius: 0.5rem;
--brand-primary: #2563eb;
--brand-accent: #0ea5e9;
--ui-bg: #030712;
--ui-panel: #0b1220;
--ui-panel-soft: #121a2c;
--ui-border: #263248;
--ui-text: #e2e8f0;
--ui-muted: #94a3b8;
--ui-sidebar: #0b1326;
--ui-hover: #1d2940;
--ui-input-bg: #0a1326;
--ui-input-border: #31415f;
--ui-input-text: #e2e8f0;
--ui-link: #7dd3fc;
}
:root.theme-light {
color-scheme: light;
--ui-bg: #f2f6fb;
--ui-panel: #ffffff;
--ui-panel-soft: #f8fbff;
--ui-border: #d7e0ee;
--ui-text: #0f172a;
--ui-muted: #475569;
--ui-sidebar: #eef3fa;
--ui-hover: #e6edf8;
--ui-input-bg: #ffffff;
--ui-input-border: #cfd8e6;
--ui-input-text: #0f172a;
--ui-link: #0369a1;
}
body {
background-color: var(--ui-bg);
color: var(--ui-text);
}
/* App (tenant) light mode compatibility for legacy slate utility usage */
:root.theme-light .app-shell .bg-slate-950 {
background-color: #f2f6fb !important;
}
:root.theme-light .app-shell .bg-slate-900,
:root.theme-light .app-shell .bg-slate-900\/50,
:root.theme-light .app-shell .bg-slate-900\/60,
:root.theme-light .app-shell .bg-slate-900\/65 {
background-color: #ffffff !important;
}
:root.theme-light .app-shell .bg-slate-800,
:root.theme-light .app-shell .bg-slate-800\/50,
:root.theme-light .app-shell .bg-slate-800\/80 {
background-color: #edf3fa !important;
}
:root.theme-light .app-shell .text-white {
color: #0f172a !important;
}
:root.theme-light .app-shell .text-slate-400,
:root.theme-light .app-shell .text-slate-500 {
color: #475569 !important;
}
:root.theme-light .app-shell .text-slate-300,
:root.theme-light .app-shell .text-slate-200 {
color: #1e293b !important;
}
:root.theme-light .app-shell .border-slate-800,
:root.theme-light .app-shell .border-slate-700,
:root.theme-light .app-shell .border-slate-700\/70,
:root.theme-light .app-shell .border-slate-700\/60,
:root.theme-light .app-shell .border-slate-600 {
border-color: #d6deea !important;
}
:root.theme-light .app-shell .hover\:bg-slate-800:hover,
:root.theme-light .app-shell .hover\:bg-slate-800\/50:hover {
background-color: #e6edf8 !important;
}
+6
View File
@@ -62,6 +62,10 @@ export interface WorkOrder {
status: 'quote' | 'open' | 'in_progress' | 'completed' | 'invoiced' | 'cancelled'
internal_notes: string
client_notes: string
eta_days: number
real_deadline: string | null
payment_method: string
payment_date: string | null
created_by: string | null
created_at: string
updated_at: string
@@ -72,6 +76,7 @@ export interface WOItem {
work_order_id: string
catalog_item_id: string | null
description: string
change_justification: string
qty: number
unit_price: number
discount_pct: number
@@ -115,6 +120,7 @@ export interface Expense {
}
export type TenantSettings = Record<string, string>
export type PlatformSettings = Record<string, string>
export interface Invoice {
id: string
+50
View File
@@ -0,0 +1,50 @@
export const OTHER_BRAND = 'Outra Marca'
export const VEHICLE_BRANDS: string[] = [
'Abarth',
'Alfa Romeo',
'Audi',
'BMW',
'BYD',
'Chevrolet',
'Citroen',
'Cupra',
'Dacia',
'DS',
'Fiat',
'Ford',
'Honda',
'Hyundai',
'Jeep',
'Kia',
'Lancia',
'Land Rover',
'Lexus',
'Mazda',
'Mercedes-Benz',
'MG',
'Mini',
'Mitsubishi',
'Nissan',
'Opel',
'Peugeot',
'Porsche',
'Renault',
'SEAT',
'Skoda',
'Smart',
'Subaru',
'Suzuki',
'Tesla',
'Toyota',
'Volkswagen',
'Volvo',
OTHER_BRAND,
]
export function normalizeBrand(input: string) {
const v = (input || '').trim()
if (!v) return ''
if (VEHICLE_BRANDS.includes(v)) return v
return OTHER_BRAND
}
+23
View File
@@ -0,0 +1,23 @@
import type { WorkOrder } from '@/lib/types'
export const WORK_ORDER_STATUS_LABEL: Record<WorkOrder['status'], string> = {
quote: 'Orçamento',
open: 'Orçamento Aprovado',
in_progress: 'Em Curso',
completed: 'Concluída',
invoiced: 'Faturada',
cancelled: 'Cancelada',
}
const LIGHT_STATUS_BADGE: Record<WorkOrder['status'], string> = {
quote: 'border-amber-400 bg-amber-100 text-slate-800',
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',
}
export function workOrderStatusBadgeClass(status: WorkOrder['status']) {
return `border ${LIGHT_STATUS_BADGE[status]}`
}
+157 -5
View File
@@ -1,8 +1,160 @@
export default function AdminDashboardPage() {
import { useMemo } from 'react'
import { Link } from 'react-router'
import { useQuery } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api'
import { useTheme } from '@/hooks/useTheme'
import type { Tenant } from '@/lib/types'
function monthKey(d: Date) {
return `${d.getFullYear()}-${d.getMonth()}`
}
function AdminCard({
title,
value,
subtitle,
}: {
title: string
value: string
subtitle: string
}) {
return (
<div>
<h1 className="text-2xl font-bold">Painel de Administração</h1>
<p className="text-slate-400 mt-1 text-sm">Gestão da plataforma implementado no Plano 2</p>
</div>
<article className="rounded-2xl border border-[var(--ui-border)] bg-[var(--ui-panel)] p-4">
<p className="text-[11px] uppercase tracking-[0.14em] text-[var(--ui-muted)]">{title}</p>
<p className="mt-2 text-2xl font-semibold text-[var(--ui-text)]">{value}</p>
<p className="mt-1 text-xs text-[var(--ui-muted)]">{subtitle}</p>
</article>
)
}
export default function AdminDashboardPage() {
const { theme } = useTheme('ui_theme', 'dark')
const currentYear = new Date().getFullYear()
const { data: tenants = [], isLoading, isError } = useQuery<Tenant[]>({
queryKey: ['admin', 'tenants'],
queryFn: () => apiFetch<Tenant[]>('/admin/tenants'),
})
const metrics = useMemo(() => {
const now = new Date()
const thisMonth = monthKey(now)
const thisYear = now.getFullYear()
const start30d = new Date(now)
start30d.setDate(now.getDate() - 30)
const byStatus = {
active: tenants.filter((t) => t.status === 'active').length,
suspended: tenants.filter((t) => t.status === 'suspended').length,
pending: tenants.filter((t) => t.status === 'pending').length,
}
const created30d = tenants.filter((t) => new Date(t.created_at) >= start30d).length
const createdMonth = tenants.filter((t) => monthKey(new Date(t.created_at)) === thisMonth).length
const monthly = Array.from({ length: 6 }).map((_, i) => {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
const key = monthKey(d)
const count = tenants.filter((t) => monthKey(new Date(t.created_at)) === key).length
return {
label: new Intl.DateTimeFormat('pt-PT', { month: 'short' }).format(d),
count,
}
}).reverse()
const yearToDate = tenants.filter((t) => new Date(t.created_at).getFullYear() === thisYear).length
return {
total: tenants.length,
byStatus,
created30d,
createdMonth,
yearToDate,
monthly,
}
}, [tenants])
const maxMonthly = Math.max(1, ...metrics.monthly.map((m) => m.count))
return (
<section className="space-y-6">
<header className={`rounded-2xl border p-6 border-[var(--ui-border)] ${theme === 'light' ? 'bg-[var(--ui-panel)]' : 'bg-gradient-to-br from-[#101b33] via-[#0e162c] to-[#0a1222]'}`}>
<p className="text-xs uppercase tracking-[0.2em] text-[var(--ui-muted)]">Admin Global</p>
<h1 className="mt-1 text-3xl font-semibold text-[var(--ui-text)]">Dashboard da Plataforma</h1>
<p className="mt-2 text-sm text-[var(--ui-muted)]">
Métricas globais por oficina, sem exposição de dados operacionais internos.
</p>
</header>
{isLoading && (
<p className="rounded-xl border border-[var(--ui-border)] bg-[var(--ui-panel)] px-4 py-3 text-sm text-[var(--ui-muted)]">
A carregar métricas...
</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">
Não foi possível carregar os dados globais neste momento.
</p>
)}
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<AdminCard title="Oficinas Totais" value={String(metrics.total)} subtitle="Total registado na plataforma" />
<AdminCard title="Ativas" value={String(metrics.byStatus.active)} subtitle="Oficinas em operação" />
<AdminCard title="Últimos 30 dias" value={String(metrics.created30d)} subtitle="Novas adesões recentes" />
<AdminCard title="Este ano" value={String(metrics.yearToDate)} subtitle={`Oficinas criadas em ${currentYear}`} />
</div>
<div className="grid gap-4 xl:grid-cols-2">
<article className="rounded-2xl border border-[var(--ui-border)] bg-[var(--ui-panel)] p-5">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-semibold text-[var(--ui-text)]">Estado das Oficinas</h2>
<Link to="/admin/tenants" className="text-xs text-[var(--ui-link)] hover:brightness-110">
Ver lista
</Link>
</div>
<div className="space-y-3">
{[
{ label: 'Ativas', value: metrics.byStatus.active, color: 'from-emerald-500 to-teal-400' },
{ label: 'Suspensas', value: metrics.byStatus.suspended, color: 'from-rose-500 to-orange-400' },
{ label: 'Pendentes', value: metrics.byStatus.pending, color: 'from-amber-500 to-yellow-400' },
].map((row) => {
const width = metrics.total > 0 ? (row.value / metrics.total) * 100 : 0
return (
<div key={row.label}>
<div className="mb-1 flex justify-between text-xs">
<span className="text-[var(--ui-muted)]">{row.label}</span>
<span className="font-mono text-[var(--ui-muted)]">{row.value}</span>
</div>
<div className="h-2 rounded-full bg-[var(--ui-hover)]">
<div className={`h-2 rounded-full bg-gradient-to-r ${row.color}`} style={{ width: `${width}%` }} />
</div>
</div>
)
})}
</div>
</article>
<article className="rounded-2xl border border-[var(--ui-border)] bg-[var(--ui-panel)] p-5">
<h2 className="text-base font-semibold text-[var(--ui-text)]">Evolução de Novas Oficinas (6 meses)</h2>
<div className="mt-4 space-y-2">
{metrics.monthly.map((m) => {
const width = (m.count / maxMonthly) * 100
return (
<div key={m.label}>
<div className="mb-1 flex justify-between text-xs">
<span className="text-[var(--ui-muted)] capitalize">{m.label}</span>
<span className="font-mono text-[var(--ui-muted)]">{m.count}</span>
</div>
<div className="h-2 rounded-full bg-[var(--ui-hover)]">
<div className="h-2 rounded-full bg-gradient-to-r from-sky-500 to-indigo-400" style={{ width: `${width}%` }} />
</div>
</div>
)
})}
</div>
<p className="mt-4 text-xs text-[var(--ui-muted)]">
No mês atual foram criadas {metrics.createdMonth} oficina(s).
</p>
</article>
</div>
</section>
)
}
+139
View File
@@ -0,0 +1,139 @@
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import type { PlatformSettings } from '@/lib/types'
type FormData = {
platform_name: string
platform_subtitle: string
platform_logo: string
admin_primary_color: string
admin_accent_color: string
}
const defaultValues: FormData = {
platform_name: 'TechXCar',
platform_subtitle: 'Administração',
platform_logo: '',
admin_primary_color: '#0f3b47',
admin_accent_color: '#06b6d4',
}
export default function AdminSettingsPage() {
const [previewBroken, setPreviewBroken] = useState(false)
const qc = useQueryClient()
const { data: settings, isLoading } = useQuery<PlatformSettings>({
queryKey: ['admin', 'settings'],
queryFn: () => apiFetch<PlatformSettings>('/admin/settings'),
})
const { register, handleSubmit, reset, watch } = useForm<FormData>({ defaultValues })
const selectedPrimary = watch('admin_primary_color') || defaultValues.admin_primary_color
const selectedAccent = watch('admin_accent_color') || defaultValues.admin_accent_color
useEffect(() => {
if (!settings) return
setPreviewBroken(false)
reset({
platform_name: settings.platform_name ?? defaultValues.platform_name,
platform_subtitle: settings.platform_subtitle ?? defaultValues.platform_subtitle,
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,
})
}, [settings, reset])
const save = useMutation({
mutationFn: (data: FormData) =>
apiFetch<PlatformSettings>('/admin/settings', { method: 'PUT', body: JSON.stringify(data) }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'settings'] })
qc.invalidateQueries({ queryKey: ['platform', 'settings'] })
},
})
if (isLoading) return <p className="text-slate-300">A carregar...</p>
return (
<div className="max-w-2xl">
<div className="mb-6">
<h1 className="text-2xl font-bold text-[var(--ui-text)]">Definições Globais</h1>
<p className="text-[var(--ui-muted)] text-sm mt-0.5">Branding aplicado ao login e painel de administração global</p>
</div>
<form
onSubmit={handleSubmit((d) => save.mutate(d))}
className="rounded-lg border border-[var(--ui-border)] bg-[var(--ui-panel)] p-6 space-y-4"
>
<div className="space-y-1">
<Label htmlFor="platform_name">Nome da plataforma</Label>
<Input id="platform_name" {...register('platform_name')} />
</div>
<div className="space-y-1">
<Label htmlFor="platform_subtitle">Subtítulo</Label>
<Input id="platform_subtitle" {...register('platform_subtitle')} />
</div>
<div className="space-y-1">
<Label htmlFor="platform_logo">Logotipo global</Label>
<Input id="platform_logo" {...register('platform_logo')} placeholder="https://.../logo.png ou data:image/png;base64,..." />
{settings?.platform_logo && !previewBroken && (
<div className="mt-2 rounded-md border border-[var(--ui-border)] bg-[var(--ui-panel-soft)] p-3">
<p className="text-xs mb-2 text-[var(--ui-muted)]">Pré-visualização:</p>
<img
src={settings.platform_logo}
alt=""
className="h-12 object-contain bg-white p-1 rounded"
onError={() => setPreviewBroken(true)}
referrerPolicy="no-referrer"
/>
</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>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label htmlFor="admin_primary_color">Cor primária admin</Label>
<div className="flex gap-2">
<Input id="admin_primary_color" {...register('admin_primary_color')} />
<Input type="color" {...register('admin_primary_color')} className="w-14 p-1 h-10 cursor-pointer" />
</div>
</div>
<div className="space-y-1">
<Label htmlFor="admin_accent_color">Cor de destaque</Label>
<div className="flex gap-2">
<Input id="admin_accent_color" {...register('admin_accent_color')} />
<Input type="color" {...register('admin_accent_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">
<span className="inline-flex rounded-md px-3 py-1 text-xs font-semibold text-white" style={{ backgroundColor: selectedPrimary }}>
Botão primário
</span>
<span className="inline-flex rounded-md px-3 py-1 text-xs font-semibold text-white" style={{ backgroundColor: selectedAccent }}>
Destaque
</span>
</div>
</div>
{save.error && <p className="text-red-300 text-sm">{(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">
<Button type="submit" disabled={save.isPending}>
{save.isPending ? 'A guardar...' : 'Guardar Definições'}
</Button>
</div>
</form>
</div>
)
}
+28 -26
View File
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useTheme } from '@/hooks/useTheme'
import type { Tenant, Invite } from '@/lib/types'
function toSlug(name: string) {
@@ -19,6 +20,7 @@ function toSlug(name: string) {
}
export default function TenantsPage() {
useTheme('ui_theme', 'dark')
const navigate = useNavigate()
const queryClient = useQueryClient()
@@ -91,8 +93,8 @@ export default function TenantsPage() {
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-white">Oficinas</h1>
<p className="text-slate-400 text-sm mt-0.5">{tenants.length} oficinas registadas</p>
<h1 className="text-2xl font-bold text-[var(--ui-text)]">Oficinas</h1>
<p className="text-[var(--ui-muted)] text-sm mt-0.5">{tenants.length} oficinas registadas</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => generateInvite.mutate()} disabled={generateInvite.isPending}>
@@ -105,8 +107,8 @@ export default function TenantsPage() {
</div>
{showCreate && (
<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 Oficina</h2>
<div className="mb-6 rounded-lg border border-[var(--ui-border)] bg-[var(--ui-panel)] p-6">
<h2 className="text-lg font-semibold text-[var(--ui-text)] mb-4">Nova Oficina</h2>
<form
onSubmit={(e) => {
e.preventDefault()
@@ -122,7 +124,7 @@ export default function TenantsPage() {
onChange={(e) => setCreateForm(f => ({
...f, name: e.target.value, slug: toSlug(e.target.value),
}))}
className="bg-slate-900 border-slate-600 text-white"
className="font-medium"
required
/>
</div>
@@ -131,7 +133,7 @@ export default function TenantsPage() {
<Input
value={createForm.slug}
onChange={(e) => setCreateForm(f => ({ ...f, slug: e.target.value }))}
className="bg-slate-900 border-slate-600 text-white font-mono text-sm"
className="font-mono text-sm"
required
/>
</div>
@@ -140,7 +142,7 @@ export default function TenantsPage() {
<Input
value={createForm.admin_name}
onChange={(e) => setCreateForm(f => ({ ...f, admin_name: e.target.value }))}
className="bg-slate-900 border-slate-600 text-white"
className="font-medium"
required
/>
</div>
@@ -150,7 +152,7 @@ export default function TenantsPage() {
type="email"
value={createForm.admin_email}
onChange={(e) => setCreateForm(f => ({ ...f, admin_email: e.target.value }))}
className="bg-slate-900 border-slate-600 text-white"
className="font-medium"
required
/>
</div>
@@ -160,7 +162,7 @@ export default function TenantsPage() {
type="password"
value={createForm.admin_password}
onChange={(e) => setCreateForm(f => ({ ...f, admin_password: e.target.value }))}
className="bg-slate-900 border-slate-600 text-white"
className="font-medium"
required
/>
</div>
@@ -178,10 +180,10 @@ export default function TenantsPage() {
)}
{inviteUrl && (
<div className="mb-6 p-4 bg-slate-800 rounded-lg border border-slate-700">
<p className="text-slate-300 text-sm font-medium mb-2">Link de convite (válido 72h):</p>
<div className="mb-6 p-4 rounded-lg border border-[var(--ui-border)] bg-[var(--ui-panel)]">
<p className="text-[var(--ui-text)] text-sm font-medium mb-2">Link de convite (válido 72h):</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs text-green-400 bg-slate-900 px-3 py-2 rounded break-all">
<code className="flex-1 text-xs text-emerald-600 dark:text-emerald-400 bg-[var(--ui-panel-soft)] px-3 py-2 rounded break-all">
{inviteUrl}
</code>
<Button
@@ -194,7 +196,7 @@ export default function TenantsPage() {
</div>
<button
onClick={() => setInviteToken(null)}
className="text-xs text-slate-500 hover:text-slate-400 mt-2"
className="text-xs text-[var(--ui-muted)] hover:text-[var(--ui-text)] mt-2"
>
Fechar
</button>
@@ -202,26 +204,26 @@ export default function TenantsPage() {
)}
{isLoading ? (
<p className="text-slate-400">A carregar...</p>
<p className="text-[var(--ui-muted)]">A carregar...</p>
) : tenants.length === 0 ? (
<p className="text-slate-500 text-sm">Nenhuma oficina registada.</p>
<p className="text-[var(--ui-muted)] text-sm">Nenhuma oficina registada.</p>
) : (
<div className="rounded-lg border border-slate-700 overflow-hidden">
<div className="rounded-lg border border-[var(--ui-border)] overflow-hidden bg-[var(--ui-panel)]">
<table className="w-full text-sm">
<thead className="bg-slate-800">
<thead className="bg-[var(--ui-panel-soft)]">
<tr>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Nome</th>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Slug</th>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Estado</th>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Criada</th>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Ações</th>
<th className="text-left px-4 py-3 text-[var(--ui-muted)] font-medium">Nome</th>
<th className="text-left px-4 py-3 text-[var(--ui-muted)] font-medium">Slug</th>
<th className="text-left px-4 py-3 text-[var(--ui-muted)] font-medium">Estado</th>
<th className="text-left px-4 py-3 text-[var(--ui-muted)] font-medium">Criada</th>
<th className="text-left px-4 py-3 text-[var(--ui-muted)] font-medium">Ações</th>
</tr>
</thead>
<tbody>
{tenants.map((t) => (
<tr key={t.id} className="border-t border-slate-700 hover:bg-slate-800/50">
<td className="px-4 py-3 text-white font-medium">{t.name}</td>
<td className="px-4 py-3 text-slate-400 font-mono text-xs">{t.slug}</td>
<tr key={t.id} className="border-t border-[var(--ui-border)] hover:bg-[var(--ui-hover)] transition-colors">
<td className="px-4 py-3 text-[var(--ui-text)] font-medium">{t.name}</td>
<td className="px-4 py-3 text-[var(--ui-muted)] font-mono text-xs">{t.slug}</td>
<td className="px-4 py-3">
<Badge
variant={
@@ -235,7 +237,7 @@ export default function TenantsPage() {
{t.status}
</Badge>
</td>
<td className="px-4 py-3 text-slate-400">
<td className="px-4 py-3 text-[var(--ui-muted)]">
{new Intl.DateTimeFormat('pt-PT').format(new Date(t.created_at))}
</td>
<td className="px-4 py-3">
+17 -1
View File
@@ -6,6 +6,7 @@ import { z } from 'zod'
import { apiFetch } from '@/lib/api'
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'
import { Label } from '@/components/ui/label'
import type { CatalogItem } from '@/lib/types'
@@ -51,6 +52,7 @@ export default function CatalogPage() {
const qc = useQueryClient()
const [editing, setEditing] = useState<CatalogItem | null>(null)
const [showForm, setShowForm] = useState(false)
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
const { data: items = [], isLoading } = useQuery<CatalogItem[]>({
queryKey: ['catalog'],
@@ -201,7 +203,7 @@ export default function CatalogPage() {
<Button
size="sm"
variant="destructive"
onClick={() => { if (confirm('Eliminar item?')) remove.mutate(item.id) }}
onClick={() => setPendingDeleteId(item.id)}
>
Eliminar
</Button>
@@ -212,6 +214,20 @@ export default function CatalogPage() {
</table>
</div>
)}
<ConfirmActionDialog
open={!!pendingDeleteId}
onOpenChange={(open) => !open && setPendingDeleteId(null)}
title="Eliminar item do catálogo"
description="Esta ação remove o artigo do catálogo de forma permanente."
confirmLabel="Eliminar"
pending={remove.isPending}
onConfirm={() => {
if (!pendingDeleteId) return
remove.mutate(pendingDeleteId, {
onSettled: () => setPendingDeleteId(null),
})
}}
/>
</div>
)
}
+29 -4
View File
@@ -4,7 +4,9 @@ import { useForm, type Resolver } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { apiFetch } from '@/lib/api'
import { VEHICLE_BRANDS, normalizeBrand } from '@/lib/vehicleBrands'
import { Button } from '@/components/ui/button'
import { ConfirmActionDialog } from '@/components/ui/confirm-action-dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import type { Client, Vehicle } from '@/lib/types'
@@ -74,6 +76,7 @@ function ClientVehicles({ clientId }: { clientId: string }) {
mutationFn: (data: VehicleForm) => {
const body = {
...data,
brand: normalizeBrand(data.brand),
year: data.year || null,
mileage: data.mileage || null,
}
@@ -99,7 +102,7 @@ function ClientVehicles({ clientId }: { clientId: string }) {
setEditingVehicle(v)
reset({
plate: v.plate,
brand: v.brand,
brand: normalizeBrand(v.brand),
model: v.model,
year: v.year ?? 0,
vin: v.vin,
@@ -133,8 +136,15 @@ function ClientVehicles({ clientId }: { clientId: string }) {
</div>
<div className="space-y-1">
<Label className="text-xs">Marca</Label>
<Input {...register('brand')} placeholder="Toyota"
className="bg-slate-900 border-slate-600 text-white text-sm h-8" />
<select
{...register('brand')}
className="w-full rounded-md border border-slate-600 bg-slate-900 text-white px-3 text-sm h-8"
>
<option value=""> Marca </option>
{VEHICLE_BRANDS.map((b) => (
<option key={b} value={b}>{b}</option>
))}
</select>
</div>
<div className="space-y-1">
<Label className="text-xs">Modelo</Label>
@@ -237,6 +247,7 @@ export default function ClientsPage() {
const [editing, setEditing] = useState<Client | null>(null)
const [showForm, setShowForm] = useState(false)
const [expandedId, setExpandedId] = useState<string | null>(null)
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
const { data: clients = [], isLoading } = useQuery<Client[]>({
queryKey: ['clients'],
@@ -375,7 +386,7 @@ export default function ClientsPage() {
<Button
size="sm"
variant="destructive"
onClick={() => { if (confirm('Eliminar cliente?')) remove.mutate(c.id) }}
onClick={() => setPendingDeleteId(c.id)}
>
Eliminar
</Button>
@@ -388,6 +399,20 @@ export default function ClientsPage() {
</table>
</div>
)}
<ConfirmActionDialog
open={!!pendingDeleteId}
onOpenChange={(open) => !open && setPendingDeleteId(null)}
title="Eliminar cliente"
description="Esta ação remove o cliente e não pode ser desfeita."
confirmLabel="Eliminar"
pending={remove.isPending}
onConfirm={() => {
if (!pendingDeleteId) return
remove.mutate(pendingDeleteId, {
onSettled: () => setPendingDeleteId(null),
})
}}
/>
</div>
)
}
+87 -71
View File
@@ -1,12 +1,13 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router'
import { useQueries, useQuery } from '@tanstack/react-query'
import { useTheme } from '@/hooks/useTheme'
import { apiFetch } from '@/lib/api'
import type { Client, Expense, Invoice, Staff, WorkOrder, WorkOrderDetail } from '@/lib/types'
const STATUS_LABEL: Record<WorkOrder['status'], string> = {
quote: 'Orçamento',
open: 'Aberta',
open: 'Orçamento Aprovado',
in_progress: 'Em Curso',
completed: 'Concluída',
invoiced: 'Faturada',
@@ -54,23 +55,27 @@ function DashboardCard({
title,
value,
subtitle,
isLight,
}: {
title: string
value: string
subtitle: string
isLight: boolean
}) {
return (
<article className="rounded-2xl border border-slate-700/70 bg-slate-900/65 p-4 transition-colors hover:border-slate-500/80">
<p className="text-[11px] uppercase tracking-[0.14em] text-slate-400">{title}</p>
<p className="mt-2 text-2xl font-semibold text-white [font-family:'Space_Grotesk',ui-sans-serif,sans-serif]">
<article className={`rounded-2xl border p-4 transition-colors ${isLight ? 'border-slate-300 bg-white hover:border-slate-400' : 'border-slate-700/70 bg-slate-900/65 hover:border-slate-500/80'}`}>
<p className={`text-[11px] uppercase tracking-[0.14em] ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>{title}</p>
<p className={`mt-2 text-2xl font-semibold [font-family:'Space_Grotesk',ui-sans-serif,sans-serif] ${isLight ? 'text-slate-900' : 'text-white'}`}>
{value}
</p>
<p className="mt-1 text-xs text-slate-400">{subtitle}</p>
<p className={`mt-1 text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>{subtitle}</p>
</article>
)
}
export default function DashboardPage() {
const { theme } = useTheme('ui_theme', 'dark')
const isLight = theme === 'light'
const [period, setPeriod] = useState<PeriodKey>('month')
const workOrdersQ = useQuery<WorkOrder[]>({
@@ -160,8 +165,8 @@ export default function DashboardPage() {
.sort((a, b) => +new Date(b.updated_at) - +new Date(a.updated_at))
.slice(0, 6)
const recentDocs = [...invoicesPeriod]
.sort((a, b) => +new Date(b.issued_at) - +new Date(a.issued_at))
const recentExpenses = [...expensesPeriod]
.sort((a, b) => +new Date(b.date) - +new Date(a.date))
.slice(0, 6)
return {
@@ -174,7 +179,7 @@ export default function DashboardPage() {
monthDocs,
activeStaff,
recentOrders,
recentDocs,
recentExpenses,
periodOrdersCount: workOrdersPeriod.length,
periodDocsCount: invoicesPeriod.length,
}
@@ -186,14 +191,14 @@ export default function DashboardPage() {
return (
<section className="space-y-6 [font-family:'Sora',ui-sans-serif,sans-serif]">
<header className="relative overflow-hidden rounded-2xl border border-slate-700/60 bg-gradient-to-br from-slate-900 via-slate-900 to-sky-950/35 p-6">
<header className={`relative overflow-hidden rounded-2xl border p-6 ${isLight ? 'border-slate-300 bg-gradient-to-br from-white via-slate-50 to-sky-50' : 'border-slate-700/60 bg-gradient-to-br from-slate-900 via-slate-900 to-sky-950/35'}`}>
<div className="absolute -right-16 -top-16 h-44 w-44 rounded-full bg-cyan-600/10 blur-2xl" />
<div className="absolute -left-12 bottom-0 h-32 w-32 rounded-full bg-amber-500/10 blur-2xl" />
<p className="text-xs uppercase tracking-[0.2em] text-slate-400">Painel Operacional</p>
<h1 className="mt-1 text-3xl font-semibold text-white [font-family:'Space_Grotesk',ui-sans-serif,sans-serif]">
<p className={`text-xs uppercase tracking-[0.2em] ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>Painel Operacional</p>
<h1 className={`mt-1 text-3xl font-semibold [font-family:'Space_Grotesk',ui-sans-serif,sans-serif] ${isLight ? 'text-slate-900' : 'text-white'}`}>
Dashboard da Oficina
</h1>
<p className="mt-2 max-w-2xl text-sm text-slate-300">
<p className={`mt-2 max-w-2xl text-sm ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>
Visão rápida do trabalho em curso, faturação e despesas para apoiar as decisões do dia.
</p>
<div className="mt-4 flex flex-wrap gap-2">
@@ -203,8 +208,12 @@ export default function DashboardPage() {
onClick={() => setPeriod(opt.key)}
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
period === opt.key
? 'border-cyan-400/80 bg-cyan-500/20 text-cyan-100'
: 'border-slate-700 bg-slate-900/80 text-slate-300 hover:border-slate-500 hover:text-white'
? isLight
? 'border-sky-300 bg-sky-100 text-sky-900'
: 'border-cyan-400/80 bg-cyan-500/20 text-cyan-100'
: isLight
? 'border-slate-300 bg-white text-slate-700 hover:border-slate-400 hover:text-slate-900'
: 'border-slate-700 bg-slate-900/80 text-slate-300 hover:border-slate-500 hover:text-white'
}`}
>
{opt.label}
@@ -214,7 +223,7 @@ export default function DashboardPage() {
</header>
{isLoading && (
<p className="rounded-xl border border-slate-700 bg-slate-900/60 px-4 py-3 text-sm text-slate-300">
<p className={`rounded-xl border px-4 py-3 text-sm ${isLight ? 'border-slate-300 bg-white text-slate-600' : 'border-slate-700 bg-slate-900/60 text-slate-300'}`}>
A carregar métricas do dashboard...
</p>
)}
@@ -230,78 +239,82 @@ export default function DashboardPage() {
title="OTs Ativas"
value={String(metrics.activeOrders)}
subtitle={`No período (${metrics.periodOrdersCount} OTs)`}
isLight={isLight}
/>
<DashboardCard
title="Clientes"
value={String(clients.length)}
subtitle="Base total de clientes"
isLight={isLight}
/>
<DashboardCard
title="Técnicos Ativos"
value={`${metrics.activeStaff}/${staff.length}`}
subtitle="Recursos disponíveis"
isLight={isLight}
/>
<DashboardCard
title="Despesas do Período"
value={currency(metrics.periodExpenses)}
subtitle="Somatório no filtro selecionado"
isLight={isLight}
/>
</div>
<article className="rounded-2xl border border-slate-700/70 bg-slate-900/65 p-5">
<article className={`rounded-2xl border p-5 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700/70 bg-slate-900/65'}`}>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-semibold text-white">Entradas Recebidas vs Despesas</h2>
<span className="text-xs text-slate-400">
<h2 className={`text-base font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Entradas Recebidas vs Despesas</h2>
<span className={`text-xs ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>
{PERIOD_OPTIONS.find((p) => p.key === period)?.label}
</span>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-lg border border-emerald-800/60 bg-emerald-950/20 p-3">
<p className="text-xs uppercase tracking-wide text-emerald-300">Entradas (faturas)</p>
<p className="mt-1 text-2xl font-semibold text-white">{currency(metrics.periodEntries)}</p>
<div className={`rounded-lg border p-3 ${isLight ? 'border-emerald-200 bg-emerald-50' : 'border-emerald-800/60 bg-emerald-950/20'}`}>
<p className={`text-xs uppercase tracking-wide ${isLight ? 'text-emerald-700' : 'text-emerald-300'}`}>Entradas (faturas)</p>
<p className={`mt-1 text-2xl font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>{currency(metrics.periodEntries)}</p>
</div>
<div className="rounded-lg border border-rose-800/60 bg-rose-950/20 p-3">
<p className="text-xs uppercase tracking-wide text-rose-300">Despesas</p>
<p className="mt-1 text-2xl font-semibold text-white">{currency(metrics.periodExpenses)}</p>
<div className={`rounded-lg border p-3 ${isLight ? 'border-rose-200 bg-rose-50' : 'border-rose-800/60 bg-rose-950/20'}`}>
<p className={`text-xs uppercase tracking-wide ${isLight ? 'text-rose-700' : 'text-rose-300'}`}>Despesas</p>
<p className={`mt-1 text-2xl font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>{currency(metrics.periodExpenses)}</p>
</div>
</div>
<div className="mt-4 space-y-3">
<div>
<div className="mb-1 flex justify-between text-xs">
<span className="text-slate-300">Entradas recebidas</span>
<span className="font-mono text-emerald-300">{currency(metrics.periodEntries)}</span>
<span className={isLight ? 'text-slate-700' : 'text-slate-300'}>Entradas recebidas</span>
<span className={`font-mono ${isLight ? 'text-emerald-700' : 'text-emerald-300'}`}>{currency(metrics.periodEntries)}</span>
</div>
<div className="h-3 rounded-full bg-slate-800">
<div className={`h-3 rounded-full ${isLight ? 'bg-slate-200' : 'bg-slate-800'}`}>
<div className="h-3 rounded-full bg-gradient-to-r from-emerald-500 to-teal-400" style={{ width: `${entriesBarWidth}%` }} />
</div>
</div>
<div>
<div className="mb-1 flex justify-between text-xs">
<span className="text-slate-300">Despesas registadas</span>
<span className="font-mono text-rose-300">{currency(metrics.periodExpenses)}</span>
<span className={isLight ? 'text-slate-700' : 'text-slate-300'}>Despesas registadas</span>
<span className={`font-mono ${isLight ? 'text-rose-700' : 'text-rose-300'}`}>{currency(metrics.periodExpenses)}</span>
</div>
<div className="h-3 rounded-full bg-slate-800">
<div className={`h-3 rounded-full ${isLight ? 'bg-slate-200' : 'bg-slate-800'}`}>
<div className="h-3 rounded-full bg-gradient-to-r from-rose-500 to-orange-400" style={{ width: `${expensesBarWidth}%` }} />
</div>
</div>
</div>
<p className="mt-4 text-sm text-slate-300">
Saldo estimado: <span className={metrics.periodEntries - metrics.periodExpenses >= 0 ? 'text-emerald-300' : 'text-rose-300'}>
<p className={`mt-4 text-sm ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>
Saldo estimado: <span className={metrics.periodEntries - metrics.periodExpenses >= 0 ? (isLight ? 'text-emerald-700' : 'text-emerald-300') : (isLight ? 'text-rose-700' : 'text-rose-300')}>
{currency(metrics.periodEntries - metrics.periodExpenses)}
</span>
</p>
{(entriesLoading || entriesError) && (
<p className="mt-2 text-xs text-slate-400">
<p className={`mt-2 text-xs ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>
{entriesLoading ? 'A calcular totais de entradas...' : 'Algumas entradas não puderam ser calculadas.'}
</p>
)}
</article>
<div className="grid items-stretch gap-4 xl:grid-cols-2">
<article className="h-full rounded-2xl border border-slate-700/70 bg-slate-900/65 p-5">
<article className={`h-full rounded-2xl border p-5 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700/70 bg-slate-900/65'}`}>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-semibold text-white">Fluxo das Ordens de Trabalho</h2>
<Link to="/app/work-orders" className="text-xs text-cyan-300 hover:text-cyan-200">
<h2 className={`text-base font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Fluxo das Ordens de Trabalho</h2>
<Link to="/app/work-orders" className={`text-xs ${isLight ? 'text-sky-700 hover:text-sky-800' : 'text-cyan-300 hover:text-cyan-200'}`}>
Ver todas
</Link>
</div>
@@ -313,10 +326,10 @@ export default function DashboardPage() {
return (
<div key={status}>
<div className="mb-1 flex justify-between text-xs">
<span className="text-slate-300">{STATUS_LABEL[status]}</span>
<span className="font-mono text-slate-400">{count}</span>
<span className={isLight ? 'text-slate-700' : 'text-slate-300'}>{STATUS_LABEL[status]}</span>
<span className={`font-mono ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>{count}</span>
</div>
<div className="h-2 rounded-full bg-slate-800">
<div className={`h-2 rounded-full ${isLight ? 'bg-slate-200' : 'bg-slate-800'}`}>
<div className="h-2 rounded-full bg-gradient-to-r from-cyan-500 to-emerald-500" style={{ width: `${width}%` }} />
</div>
</div>
@@ -325,14 +338,14 @@ export default function DashboardPage() {
</div>
</article>
<article className="h-full rounded-2xl border border-slate-700/70 bg-slate-900/65 p-5">
<h2 className="text-base font-semibold text-white">Faturação e Prioridades</h2>
<article className={`h-full rounded-2xl border p-5 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700/70 bg-slate-900/65'}`}>
<h2 className={`text-base font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Faturação e Prioridades</h2>
<div className="mt-4 space-y-3 text-sm">
<div className="rounded-lg border border-slate-700 bg-slate-900/70 px-3 py-2">
<p className="text-slate-400">Documentos emitidos no período</p>
<p className="text-xl font-semibold text-white">{metrics.periodDocsCount}</p>
<div className={`rounded-lg border px-3 py-2 ${isLight ? 'border-slate-200 bg-slate-100' : 'border-slate-700 bg-slate-900/70'}`}>
<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 border-amber-800/70 bg-amber-950/30 px-3 py-2 text-amber-100">
<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'}`}>
<p className="text-xs uppercase tracking-wide">Ação recomendada</p>
<p className="mt-1">
{metrics.byStatus.completed > 0
@@ -340,7 +353,7 @@ export default function DashboardPage() {
: 'Sem OTs concluídas pendentes de faturação.'}
</p>
</div>
<div className="rounded-lg border border-cyan-900/70 bg-cyan-950/25 px-3 py-2 text-cyan-100">
<div className={`rounded-lg border px-3 py-2 ${isLight ? 'border-cyan-200 bg-cyan-50 text-cyan-900' : 'border-cyan-900/70 bg-cyan-950/25 text-cyan-100'}`}>
<p className="text-xs uppercase tracking-wide">Em orçamento</p>
<p className="mt-1">
{metrics.byStatus.quote > 0
@@ -348,9 +361,9 @@ export default function DashboardPage() {
: 'Não existem OTs pendentes em orçamento.'}
</p>
</div>
<div className="rounded-lg border border-slate-700 bg-slate-900/70 px-3 py-2">
<p className="text-slate-400">Contexto mensal</p>
<p className="text-slate-200 mt-1 text-xs">
<div className={`rounded-lg border px-3 py-2 ${isLight ? 'border-slate-200 bg-slate-100' : 'border-slate-700 bg-slate-900/70'}`}>
<p className={isLight ? 'text-slate-600' : 'text-slate-400'}>Contexto mensal</p>
<p className={`mt-1 text-xs ${isLight ? 'text-slate-700' : 'text-slate-200'}`}>
Entradas no mês: {currency(metrics.monthEntries)} | Despesas no mês: {currency(metrics.monthExpenses)} | Documentos: {metrics.monthDocs}
</p>
</div>
@@ -359,24 +372,24 @@ export default function DashboardPage() {
</div>
<div className="grid gap-4 xl:grid-cols-2">
<article className="rounded-2xl border border-slate-700/70 bg-slate-900/65 p-5">
<article className={`rounded-2xl border p-5 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700/70 bg-slate-900/65'}`}>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-base font-semibold text-white">Atividade Recente de OTs</h2>
<Link to="/app/work-orders" className="text-xs text-cyan-300 hover:text-cyan-200">
<h2 className={`text-base font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Atividade Recente de OTs</h2>
<Link to="/app/work-orders" className={`text-xs ${isLight ? 'text-sky-700 hover:text-sky-800' : 'text-cyan-300 hover:text-cyan-200'}`}>
Abrir OTs
</Link>
</div>
{metrics.recentOrders.length === 0 ? (
<p className="text-sm text-slate-400">Ainda não existem ordens de trabalho.</p>
<p className={`text-sm ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Ainda não existem ordens de trabalho.</p>
) : (
<ul className="divide-y divide-slate-800">
<ul className={`divide-y ${isLight ? 'divide-slate-200' : 'divide-slate-800'}`}>
{metrics.recentOrders.map((wo) => (
<li key={wo.id} className="flex items-center justify-between py-2">
<div>
<p className="text-sm font-medium text-white">OT #{wo.number}</p>
<p className="text-xs text-slate-400">{STATUS_LABEL[wo.status]}</p>
<p className={`text-sm font-medium ${isLight ? 'text-slate-900' : 'text-white'}`}>OT #{wo.number}</p>
<p className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>{STATUS_LABEL[wo.status]}</p>
</div>
<span className="text-xs text-slate-500">
<span className={`text-xs ${isLight ? 'text-slate-500' : 'text-slate-500'}`}>
{new Intl.DateTimeFormat('pt-PT', { dateStyle: 'short', timeStyle: 'short' }).format(new Date(wo.updated_at))}
</span>
</li>
@@ -385,27 +398,30 @@ export default function DashboardPage() {
)}
</article>
<article className="rounded-2xl border border-slate-700/70 bg-slate-900/65 p-5">
<article className={`rounded-2xl border p-5 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700/70 bg-slate-900/65'}`}>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-base font-semibold text-white">Documentos Recentes</h2>
<Link to="/app/invoices" className="text-xs text-cyan-300 hover:text-cyan-200">
Abrir Faturação
<h2 className={`text-base font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Despesas Recentes</h2>
<Link to="/app/expenses" className={`text-xs ${isLight ? 'text-sky-700 hover:text-sky-800' : 'text-cyan-300 hover:text-cyan-200'}`}>
Abrir Despesas
</Link>
</div>
{metrics.recentDocs.length === 0 ? (
<p className="text-sm text-slate-400">Sem documentos emitidos.</p>
{metrics.recentExpenses.length === 0 ? (
<p className={`text-sm ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Sem despesas no período selecionado.</p>
) : (
<ul className="divide-y divide-slate-800">
{metrics.recentDocs.map((doc) => (
<li key={doc.id} className="flex items-center justify-between py-2">
<ul className={`divide-y ${isLight ? 'divide-slate-200' : 'divide-slate-800'}`}>
{metrics.recentExpenses.map((exp) => (
<li key={exp.id} className="flex items-center justify-between py-2">
<div>
<p className="text-sm font-medium text-white">
{doc.type === 'invoice' ? 'Fatura' : 'Orçamento'} #{doc.number}
<p className={`text-sm font-medium ${isLight ? 'text-slate-900' : 'text-white'}`}>
{currency(exp.amount)}
</p>
<p className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>
{exp.type === 'fuel' ? 'Combustível' : exp.type === 'parts' ? 'Peças' : exp.type === 'tools' ? 'Ferramentas' : 'Outros'}
{exp.description ? ` · ${exp.description}` : ''}
</p>
<p className="text-xs text-slate-400 font-mono">OT {doc.work_order_id.slice(0, 8)}...</p>
</div>
<span className="text-xs text-slate-500">
{new Intl.DateTimeFormat('pt-PT', { dateStyle: 'short' }).format(new Date(doc.issued_at))}
<span className={`text-xs ${isLight ? 'text-slate-500' : 'text-slate-500'}`}>
{new Intl.DateTimeFormat('pt-PT', { dateStyle: 'short' }).format(new Date(exp.date))}
</span>
</li>
))}
@@ -0,0 +1,275 @@
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api'
import { useTheme } from '@/hooks/useTheme'
import type { Expense } from '@/lib/types'
type PeriodKey = 'month' | '30d' | '90d' | 'year' | 'all'
type ExpenseType = '' | 'fuel' | 'parts' | 'tools' | 'other'
const PERIOD_OPTIONS: { key: PeriodKey; label: string }[] = [
{ key: 'month', label: 'Mês atual' },
{ key: '30d', label: '30 dias' },
{ key: '90d', label: '90 dias' },
{ key: 'year', label: '12 meses' },
{ key: 'all', label: 'Todo o histórico' },
]
const TYPE_LABEL: Record<Exclude<ExpenseType, ''>, string> = {
fuel: 'Combustível',
parts: 'Peças',
tools: 'Ferramentas',
other: 'Outros',
}
function periodStart(period: PeriodKey) {
const now = new Date()
if (period === 'all') return null
const start = new Date(now)
if (period === 'month') {
start.setDate(1)
start.setHours(0, 0, 0, 0)
return start
}
if (period === '30d') start.setDate(now.getDate() - 30)
if (period === '90d') start.setDate(now.getDate() - 90)
if (period === 'year') start.setMonth(now.getMonth() - 12)
return start
}
function currency(v: number) {
return new Intl.NumberFormat('pt-PT', { style: 'currency', currency: 'EUR' }).format(v)
}
function monthKey(d: Date) {
return `${d.getFullYear()}-${d.getMonth()}`
}
export default function ExpenseReportsPage() {
const { theme } = useTheme('ui_theme', 'dark')
const isLight = theme === 'light'
const [period, setPeriod] = useState<PeriodKey>('90d')
const [typeFilter, setTypeFilter] = useState<ExpenseType>('')
const expensesQ = useQuery<Expense[]>({
queryKey: ['expenses', 'reports'],
queryFn: () => apiFetch<Expense[]>('/expenses'),
})
const expenses = expensesQ.data ?? []
const filtered = useMemo(() => {
const start = periodStart(period)
return expenses.filter((e) => {
if (typeFilter && e.type !== typeFilter) return false
if (start && new Date(e.date) < start) return false
return true
})
}, [expenses, period, typeFilter])
const kpis = useMemo(() => {
const total = filtered.reduce((sum, e) => sum + e.amount, 0)
const count = filtered.length
const avg = count > 0 ? total / count : 0
const maxExpense = filtered.reduce((mx, e) => (e.amount > mx.amount ? e : mx), { amount: 0, type: 'other' as Expense['type'] })
const byType = {
fuel: 0,
parts: 0,
tools: 0,
other: 0,
}
for (const e of filtered) byType[e.type] += e.amount
const topType = (Object.entries(byType) as [keyof typeof byType, number][])
.sort((a, b) => b[1] - a[1])[0]
return {
total,
count,
avg,
maxValue: maxExpense.amount,
maxType: maxExpense.type,
topType: topType?.[0] ?? 'other',
topTypeAmount: topType?.[1] ?? 0,
}
}, [filtered])
const byTypeRows = useMemo(() => {
const totals = {
fuel: 0,
parts: 0,
tools: 0,
other: 0,
}
for (const e of filtered) totals[e.type] += e.amount
return (Object.entries(totals) as [keyof typeof totals, number][])
.map(([type, total]) => ({ type, total, pct: kpis.total > 0 ? (total / kpis.total) * 100 : 0 }))
.sort((a, b) => b.total - a.total)
}, [filtered, kpis.total])
const monthly = useMemo(() => {
const now = new Date()
const buckets = Array.from({ length: 6 }).map((_, i) => {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
return {
key: monthKey(d),
label: new Intl.DateTimeFormat('pt-PT', { month: 'short' }).format(d),
total: 0,
count: 0,
}
}).reverse()
const index = new Map(buckets.map((b, i) => [b.key, i]))
for (const e of filtered) {
const idx = index.get(monthKey(new Date(e.date)))
if (idx === undefined) continue
buckets[idx].total += e.amount
buckets[idx].count += 1
}
return buckets
}, [filtered])
const maxMonthly = Math.max(1, ...monthly.map((m) => m.total))
const topExpenses = [...filtered]
.sort((a, b) => b.amount - a.amount)
.slice(0, 7)
return (
<section className="space-y-6">
<header className={`rounded-2xl border p-6 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<p className={`text-xs uppercase tracking-[0.2em] ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>
Relatórios Financeiros
</p>
<h1 className={`mt-1 text-3xl font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>
Relatório de Despesas
</h1>
<p className={`mt-2 text-sm ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>
Acompanhamento de custos por período e categoria para suportar decisões operacionais.
</p>
</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">
<label className="text-sm">
<span className={`mb-1 block ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>Período</span>
<select
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'}`}
value={period}
onChange={(e) => setPeriod(e.target.value as PeriodKey)}
>
{PERIOD_OPTIONS.map((o) => (
<option key={o.key} value={o.key}>{o.label}</option>
))}
</select>
</label>
<label className="text-sm">
<span className={`mb-1 block ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>Categoria</span>
<select
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'}`}
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value as ExpenseType)}
>
<option value="">Todas</option>
<option value="fuel">Combustível</option>
<option value="parts">Peças</option>
<option value="tools">Ferramentas</option>
<option value="other">Outros</option>
</select>
</label>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{[
['Total de despesas', currency(kpis.total)],
['Registos', String(kpis.count)],
['Média por registo', currency(kpis.avg)],
['Maior despesa', currency(kpis.maxValue)],
].map(([label, value]) => (
<article key={label} className={`rounded-xl border p-4 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<p className={`text-xs uppercase tracking-wide ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>{label}</p>
<p className={`mt-1 text-2xl font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>{value}</p>
</article>
))}
</div>
{expensesQ.isLoading && (
<p className={isLight ? 'text-slate-600 text-sm' : 'text-slate-400 text-sm'}>A calcular relatório de despesas...</p>
)}
<div className="grid gap-4 xl:grid-cols-2">
<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'}`}>Distribuição por Categoria</h2>
<p className={`mt-1 text-sm ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>
Categoria com maior peso: {TYPE_LABEL[kpis.topType as keyof typeof TYPE_LABEL]} ({currency(kpis.topTypeAmount)})
</p>
<div className="mt-4 space-y-3">
{byTypeRows.map((row) => (
<div key={row.type}>
<div className="mb-1 flex items-center justify-between gap-2 text-sm">
<span className={isLight ? 'text-slate-800' : 'text-slate-200'}>{TYPE_LABEL[row.type]}</span>
<span className={isLight ? 'text-slate-600' : 'text-slate-400'}>
{currency(row.total)} ({row.pct.toFixed(1)}%)
</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-cyan-500 to-blue-500" style={{ width: `${row.pct}%` }} />
</div>
</div>
))}
</div>
</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 (Despesas)</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.count} reg. | {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-violet-500 to-fuchsia-500"
style={{ width: `${(m.total / maxMonthly) * 100}%` }}
/>
</div>
</div>
))}
</div>
</article>
<article className={`rounded-2xl border p-5 xl:col-span-2 ${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'}`}>Top Despesas do Período</h2>
{topExpenses.length === 0 ? (
<p className={`mt-3 text-sm ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Sem despesas para o filtro atual.</p>
) : (
<div className="mt-4 overflow-hidden rounded-lg border border-slate-300/70 dark:border-slate-700">
<table className="w-full text-sm">
<thead className={isLight ? 'bg-slate-100' : 'bg-slate-800'}>
<tr>
<th className={`px-3 py-2 text-left ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>Data</th>
<th className={`px-3 py-2 text-left ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>Categoria</th>
<th className={`px-3 py-2 text-left ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>Descrição</th>
<th className={`px-3 py-2 text-right ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>Valor</th>
</tr>
</thead>
<tbody>
{topExpenses.map((e) => (
<tr key={e.id} className={`border-t ${isLight ? 'border-slate-200' : 'border-slate-700'}`}>
<td className={`px-3 py-2 ${isLight ? 'text-slate-800' : 'text-slate-200'}`}>
{new Intl.DateTimeFormat('pt-PT').format(new Date(e.date))}
</td>
<td className={`px-3 py-2 ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>{TYPE_LABEL[e.type]}</td>
<td className={`px-3 py-2 ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>{e.description || '—'}</td>
<td className={`px-3 py-2 text-right font-medium ${isLight ? 'text-slate-900' : 'text-white'}`}>{currency(e.amount)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</article>
</div>
</section>
)
}
+17 -1
View File
@@ -6,6 +6,7 @@ import { z } from 'zod'
import { apiFetch } from '@/lib/api'
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'
import { Label } from '@/components/ui/label'
import type { Expense } from '@/lib/types'
@@ -31,6 +32,7 @@ export default function ExpensesPage() {
const qc = useQueryClient()
const [typeFilter, setTypeFilter] = useState('')
const [showForm, setShowForm] = useState(false)
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
const { data: expenses = [], isLoading } = useQuery<Expense[]>({
queryKey: ['expenses', typeFilter],
@@ -162,7 +164,7 @@ export default function ExpensesPage() {
<Button
size="sm"
variant="destructive"
onClick={() => { if (confirm('Eliminar despesa?')) remove.mutate(e.id) }}
onClick={() => setPendingDeleteId(e.id)}
>
Eliminar
</Button>
@@ -173,6 +175,20 @@ export default function ExpensesPage() {
</table>
</div>
)}
<ConfirmActionDialog
open={!!pendingDeleteId}
onOpenChange={(open) => !open && setPendingDeleteId(null)}
title="Eliminar despesa"
description="Esta ação remove a despesa de forma permanente."
confirmLabel="Eliminar"
pending={remove.isPending}
onConfirm={() => {
if (!pendingDeleteId) return
remove.mutate(pendingDeleteId, {
onSettled: () => setPendingDeleteId(null),
})
}}
/>
</div>
)
}
+13
View File
@@ -0,0 +1,13 @@
export default function HelpPage() {
return (
<div className="space-y-4">
<section className="rounded-2xl border border-slate-200/80 bg-white/90 p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900/60">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400">Ajuda</p>
<h1 className="mt-2 text-3xl font-bold text-slate-900 dark:text-white">Centro de ajuda</h1>
<p className="mt-2 text-sm text-slate-600 dark:text-slate-300">
Em breve vamos disponibilizar documentação de uso, boas práticas e dicas rápidas para a equipa.
</p>
</section>
</div>
)
}
+203 -158
View File
@@ -1,56 +1,109 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { Link } from 'react-router'
import { useQuery, useQueries } from '@tanstack/react-query'
import { apiFetch, apiFetchBlob } from '@/lib/api'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import type { Invoice, WorkOrder } from '@/lib/types'
const STATUS_LABELS: Record<string, string> = {
quote: 'Orçamento',
open: 'Aberta',
in_progress: 'Em Curso',
completed: 'Concluída',
invoiced: 'Faturada',
cancelled: 'Cancelada',
import type { Invoice, WorkOrder, Client, Vehicle } from '@/lib/types'
type PeriodKey = 'month' | '30d' | '90d' | 'year' | 'all'
type DocFilter = 'all' | 'quote' | 'invoice'
const PERIOD_OPTIONS: { key: PeriodKey; label: string }[] = [
{ key: 'month', label: 'Mês atual' },
{ key: '30d', label: '30 dias' },
{ key: '90d', label: '90 dias' },
{ key: 'year', label: '12 meses' },
{ key: 'all', label: 'Todo o período' },
]
function getPeriodStart(period: PeriodKey, now = new Date()) {
const start = new Date(now)
if (period === 'all') return null
if (period === 'month') {
start.setDate(1)
start.setHours(0, 0, 0, 0)
return start
}
if (period === '30d') {
start.setDate(now.getDate() - 30)
return start
}
if (period === '90d') {
start.setDate(now.getDate() - 90)
return start
}
start.setMonth(now.getMonth() - 12)
return start
}
export default function InvoicesPage() {
const qc = useQueryClient()
const [showGenerate, setShowGenerate] = useState(false)
const [selectedWO, setSelectedWO] = useState('')
const [docType, setDocType] = useState<'quote' | 'invoice'>('quote')
const [viewError, setViewError] = useState('')
const [period, setPeriod] = useState<PeriodKey>('month')
const [clientFilter, setClientFilter] = useState('all')
const [docFilter, setDocFilter] = useState<DocFilter>('all')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
queryKey: ['invoices'],
queryFn: () => apiFetch<Invoice[]>('/invoices'),
})
const quoteDocs = invoices.filter((i) => i.type === 'quote')
const invoiceDocs = invoices.filter((i) => i.type === 'invoice')
const { data: workOrders = [] } = useQuery<WorkOrder[]>({
queryKey: ['work-orders-for-invoice'],
queryKey: ['work-orders-for-transactions'],
queryFn: () => apiFetch<WorkOrder[]>('/work-orders'),
enabled: showGenerate,
})
const { data: clients = [] } = useQuery<Client[]>({
queryKey: ['clients'],
queryFn: () => apiFetch<Client[]>('/clients'),
})
const eligibleWOs = workOrders.filter((wo) =>
wo.status === 'quote' || wo.status === 'open' || wo.status === 'in_progress' || wo.status === 'completed'
const clientNameById = Object.fromEntries(clients.map((c) => [c.id, c.name]))
const woById = Object.fromEntries(workOrders.map((wo) => [wo.id, wo]))
const uniqueClientIds = Array.from(
new Set(workOrders.map((wo) => wo.client_id).filter((id): id is string => !!id))
)
const eligibleSorted = [...eligibleWOs].sort((a, b) => b.number - a.number)
const generate = useMutation({
mutationFn: () =>
apiFetch<Invoice>('/invoices', {
method: 'POST',
body: JSON.stringify({ work_order_id: selectedWO, type: docType }),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['invoices'] })
qc.invalidateQueries({ queryKey: ['work-orders'] })
setShowGenerate(false)
setSelectedWO('')
},
const vehiclesByClientQ = useQueries({
queries: uniqueClientIds.map((clientId) => ({
queryKey: ['vehicles', clientId, 'transactions'],
queryFn: () => apiFetch<Vehicle[]>(`/clients/${clientId}/vehicles`),
staleTime: 5 * 60 * 1000,
})),
})
const vehicleById = useMemo(() => {
const map: Record<string, Vehicle> = {}
for (const q of vehiclesByClientQ) {
for (const v of (q.data ?? [])) map[v.id] = v
}
return map
}, [vehiclesByClientQ])
const filtered = useMemo(() => {
const start = getPeriodStart(period)
return invoices.filter((inv) => {
if (docFilter !== 'all' && inv.type !== docFilter) return false
const wo = woById[inv.work_order_id]
if (clientFilter !== 'all' && wo?.client_id !== clientFilter) return false
const issued = new Date(inv.issued_at)
if (start && issued < start) return false
if (dateFrom) {
const from = new Date(`${dateFrom}T00:00:00`)
if (issued < from) return false
}
if (dateTo) {
const to = new Date(`${dateTo}T23:59:59`)
if (issued > to) return false
}
return true
})
}, [invoices, woById, period, clientFilter, docFilter, dateFrom, dateTo])
const sorted = [...filtered].sort(
(a, b) => new Date(b.issued_at).getTime() - new Date(a.issued_at).getTime()
)
const totalQuotes = sorted.filter((d) => d.type === 'quote').length
const totalInvoices = sorted.filter((d) => d.type === 'invoice').length
async function openPDF(inv: Invoice) {
setViewError('')
@@ -66,143 +119,135 @@ export default function InvoicesPage() {
return (
<div>
<div className="flex items-center justify-between mb-6">
<div className="mb-6 flex items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-white">Transações</h1>
<p className="text-slate-400 text-sm mt-0.5">
{invoices.length} documentos emitidos (orçamentos e faturas)
<p className="mt-0.5 text-sm text-slate-400">
Consulta e impressão de documentos gerados pelo fluxo das OTs.
</p>
</div>
<Button onClick={() => setShowGenerate(true)}>Nova Transação</Button>
<Link to="/app/work-orders" className="text-xs text-cyan-300 hover:text-cyan-200">
Ir para Ordens de Trabalho
</Link>
</div>
{showGenerate && (
<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">Gerar Documento</h2>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-sm text-slate-300">Tipo</label>
<select
value={docType}
onChange={(e) => setDocType(e.target.value as 'quote' | 'invoice')}
className="w-full rounded-md border border-slate-600 bg-slate-900 text-white px-3 py-2 text-sm"
>
<option value="quote">Orçamento</option>
<option value="invoice">Fatura</option>
</select>
</div>
<div className="space-y-1">
<label className="text-sm text-slate-300">Ordem de Trabalho</label>
<select
value={selectedWO}
onChange={(e) => setSelectedWO(e.target.value)}
className="w-full rounded-md border border-slate-600 bg-slate-900 text-white px-3 py-2 text-sm"
>
<option value=""> Seleccionar OT </option>
{eligibleSorted.map((wo) => (
<option key={wo.id} value={wo.id}>
#{wo.number} ({STATUS_LABELS[wo.status] ?? wo.status})
</option>
))}
</select>
</div>
</div>
{generate.error && (
<p className="text-red-400 text-sm mt-3">{(generate.error as Error).message}</p>
)}
<div className="flex gap-2 justify-end mt-4">
<Button variant="outline" onClick={() => { setShowGenerate(false); setSelectedWO('') }}>
Cancelar
</Button>
<Button
onClick={() => generate.mutate()}
disabled={!selectedWO || generate.isPending}
>
{generate.isPending ? 'A gerar...' : 'Gerar PDF'}
</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">
<label className="text-xs text-slate-400">Período</label>
<select
value={period}
onChange={(e) => setPeriod(e.target.value as PeriodKey)}
className="w-full rounded-md border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-white"
>
{PERIOD_OPTIONS.map((opt) => (
<option key={opt.key} value={opt.key}>{opt.label}</option>
))}
</select>
</div>
)}
<div className="space-y-1">
<label className="text-xs text-slate-400">Cliente</label>
<select
value={clientFilter}
onChange={(e) => setClientFilter(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="all">Todos os clientes</option>
{clients.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div className="space-y-1">
<label className="text-xs text-slate-400">Tipo de documento</label>
<select
value={docFilter}
onChange={(e) => setDocFilter(e.target.value as DocFilter)}
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="quote">Orçamentos</option>
<option value="invoice">Faturas</option>
</select>
</div>
<div className="space-y-1">
<label className="text-xs text-slate-400">Data início</label>
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="w-full rounded-md border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-white"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-slate-400">Data fim</label>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="w-full rounded-md border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-white"
/>
</div>
</div>
{viewError && (
<p className="mb-4 text-red-400 text-sm">{viewError}</p>
)}
<div className="mb-4 flex items-center gap-3 text-sm">
<Badge variant="secondary">{totalQuotes} orçamentos</Badge>
<Badge>{totalInvoices} faturas</Badge>
<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>}
{isLoading ? (
<p className="text-slate-400">A carregar...</p>
) : invoices.length === 0 ? (
<p className="text-slate-500 text-sm">Nenhum documento gerado.</p>
) : sorted.length === 0 ? (
<p className="text-sm text-slate-500">Nenhum documento encontrado com os filtros selecionados.</p>
) : (
<div className="grid gap-6 xl:grid-cols-2">
<div className="rounded-lg border border-slate-700 overflow-hidden">
<div className="bg-slate-800 px-4 py-3 flex items-center justify-between">
<h2 className="text-sm font-semibold text-white">Orçamentos</h2>
<Badge variant="secondary">{quoteDocs.length}</Badge>
</div>
{quoteDocs.length === 0 ? (
<p className="px-4 py-5 text-slate-500 text-sm">Sem orçamentos gerados.</p>
) : (
<table className="w-full text-sm">
<thead className="bg-slate-900/80">
<tr>
<th className="text-left px-4 py-2 text-slate-400 font-medium"></th>
<th className="text-left px-4 py-2 text-slate-400 font-medium">OT</th>
<th className="text-left px-4 py-2 text-slate-400 font-medium">Emitida</th>
<th className="px-4 py-2"></th>
<div className="overflow-hidden rounded-lg border border-slate-700">
<table className="w-full text-sm">
<thead className="bg-slate-800">
<tr>
<th className="px-4 py-2 text-left font-medium text-slate-400">Tipo</th>
<th className="px-4 py-2 text-left font-medium text-slate-400"></th>
<th className="px-4 py-2 text-left font-medium text-slate-400">OT / Cliente / Viatura</th>
<th className="px-4 py-2 text-left font-medium text-slate-400">Emitida</th>
<th className="px-4 py-2"></th>
</tr>
</thead>
<tbody>
{sorted.map((inv) => {
const wo = woById[inv.work_order_id]
const clientName = wo?.client_id ? (clientNameById[wo.client_id] ?? '—') : '—'
const vehiclePlate = wo?.vehicle_id ? (vehicleById[wo.vehicle_id]?.plate ?? '—') : '—'
return (
<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>
) : (
<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>
)}
</td>
<td className="px-4 py-2 font-mono text-white">#{inv.number}</td>
<td className="px-4 py-2 text-xs text-slate-400">
<div className="font-mono text-slate-300">#{wo?.number ?? '—'}</div>
<div>{clientName}</div>
<div>{vehiclePlate}</div>
</td>
<td className="px-4 py-2 text-slate-400">
{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>
</td>
</tr>
</thead>
<tbody>
{quoteDocs.map((inv) => (
<tr key={inv.id} className="border-t border-slate-700 hover:bg-slate-800/50">
<td className="px-4 py-2 text-white font-mono">#{inv.number}</td>
<td className="px-4 py-2 text-slate-400 font-mono text-xs">{inv.work_order_id.slice(0, 8)}</td>
<td className="px-4 py-2 text-slate-400">
{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>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="rounded-lg border border-slate-700 overflow-hidden">
<div className="bg-slate-800 px-4 py-3 flex items-center justify-between">
<h2 className="text-sm font-semibold text-white">Faturas</h2>
<Badge>{invoiceDocs.length}</Badge>
</div>
{invoiceDocs.length === 0 ? (
<p className="px-4 py-5 text-slate-500 text-sm">Sem faturas geradas.</p>
) : (
<table className="w-full text-sm">
<thead className="bg-slate-900/80">
<tr>
<th className="text-left px-4 py-2 text-slate-400 font-medium"></th>
<th className="text-left px-4 py-2 text-slate-400 font-medium">OT</th>
<th className="text-left px-4 py-2 text-slate-400 font-medium">Emitida</th>
<th className="px-4 py-2"></th>
</tr>
</thead>
<tbody>
{invoiceDocs.map((inv) => (
<tr key={inv.id} className="border-t border-slate-700 hover:bg-slate-800/50">
<td className="px-4 py-2 text-white font-mono">#{inv.number}</td>
<td className="px-4 py-2 text-slate-400 font-mono text-xs">{inv.work_order_id.slice(0, 8)}</td>
<td className="px-4 py-2 text-slate-400">
{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>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)
})}
</tbody>
</table>
</div>
)}
</div>
+571
View File
@@ -0,0 +1,571 @@
import { useMemo, useState } from 'react'
import { useQueries, useQuery } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api'
import { useTheme } from '@/hooks/useTheme'
import type { Client, Vehicle, WorkOrder, WorkOrderDetail } from '@/lib/types'
type PeriodKey = '30d' | '90d' | 'year' | 'all'
const PERIOD_OPTIONS: { key: PeriodKey; label: string }[] = [
{ key: '30d', label: '30 dias' },
{ key: '90d', label: '90 dias' },
{ key: 'year', label: '12 meses' },
{ key: 'all', label: 'Todo o histórico' },
]
function getStartDate(period: PeriodKey) {
const now = new Date()
if (period === 'all') return null
const start = new Date(now)
if (period === '30d') start.setDate(now.getDate() - 30)
if (period === '90d') start.setDate(now.getDate() - 90)
if (period === 'year') start.setMonth(now.getMonth() - 12)
return start
}
function currency(v: number) {
return new Intl.NumberFormat('pt-PT', { style: 'currency', currency: 'EUR' }).format(v)
}
function monthKey(d: Date) {
return `${d.getFullYear()}-${d.getMonth()}`
}
function csvEscape(v: string | number) {
const s = String(v ?? '')
if (s.includes(';') || s.includes('"') || s.includes('\n')) {
return `"${s.replace(/"/g, '""')}"`
}
return s
}
const PIE_COLORS = ['#0ea5e9', '#6366f1', '#14b8a6', '#f59e0b', '#f43f5e', '#22c55e']
export default function ReportsPage() {
const { theme } = useTheme('ui_theme', 'dark')
const isLight = theme === 'light'
const [period, setPeriod] = useState<PeriodKey>('90d')
const [clientFilter, setClientFilter] = useState('')
const [vehicleFilter, setVehicleFilter] = useState('')
const clientsQ = useQuery<Client[]>({
queryKey: ['clients'],
queryFn: () => apiFetch<Client[]>('/clients'),
})
const ordersQ = useQuery<WorkOrder[]>({
queryKey: ['work-orders', 'reports'],
queryFn: () => apiFetch<WorkOrder[]>('/work-orders'),
})
const vehiclesQ = useQuery<Vehicle[]>({
queryKey: ['vehicles', 'reports', clientFilter || 'all'],
queryFn: async () => {
if (!clientFilter) {
const clients = await apiFetch<Client[]>('/clients')
const all = await Promise.all(
clients.map((c) =>
apiFetch<Vehicle[]>(`/clients/${c.id}/vehicles`).catch(() => [] as Vehicle[])
)
)
return all.flat()
}
return apiFetch<Vehicle[]>(`/clients/${clientFilter}/vehicles`)
},
})
const clients = clientsQ.data ?? []
const orders = ordersQ.data ?? []
const vehicles = vehiclesQ.data ?? []
const filteredOrders = useMemo(() => {
const start = getStartDate(period)
return orders.filter((o) => {
if (start && new Date(o.created_at) < start) return false
if (clientFilter && o.client_id !== clientFilter) return false
if (vehicleFilter && o.vehicle_id !== vehicleFilter) return false
return true
})
}, [orders, period, clientFilter, vehicleFilter])
const detailsQ = useQueries({
queries: filteredOrders.map((o) => ({
queryKey: ['work-order-detail', o.id, 'reports'],
queryFn: () => apiFetch<WorkOrderDetail>(`/work-orders/${o.id}`),
staleTime: 5 * 60 * 1000,
})),
})
const isLoading = clientsQ.isLoading || ordersQ.isLoading || vehiclesQ.isLoading
const detailsLoading = detailsQ.some((q) => q.isLoading)
const detailByOrderId = useMemo(() => {
const map = new Map<string, WorkOrderDetail>()
for (const q of detailsQ) {
if (q.data) map.set(q.data.id, q.data)
}
return map
}, [detailsQ])
const byClient = useMemo(() => {
const totals = new Map<
string,
{ clientName: string; ots: number; invoiced: number; completed: number; amount: number }
>()
const orderById = new Map(filteredOrders.map((o) => [o.id, o]))
const clientNameById = new Map(clients.map((c) => [c.id, c.name]))
for (let i = 0; i < detailsQ.length; i++) {
const detail = detailsQ[i].data
if (!detail?.client_id) continue
const order = orderById.get(detail.id)
if (!order) continue
const amount =
detail.items.reduce((s, it) => s + it.total, 0) +
detail.staff_hours.reduce((s, h) => s + h.total, 0)
const row = totals.get(detail.client_id) ?? {
clientName: clientNameById.get(detail.client_id) ?? 'Cliente',
ots: 0,
invoiced: 0,
completed: 0,
amount: 0,
}
row.ots += 1
row.amount += amount
if (order.status === 'invoiced') row.invoiced += 1
if (order.status === 'completed' || order.status === 'invoiced') row.completed += 1
totals.set(detail.client_id, row)
}
return [...totals.values()]
.map((r) => ({ ...r, avgTicket: r.ots > 0 ? r.amount / r.ots : 0 }))
.sort((a, b) => b.amount - a.amount)
}, [detailsQ, filteredOrders, clients])
const byClientVehicle = useMemo(() => {
const totals = new Map<
string,
{
key: string
clientName: string
vehicleLabel: string
ots: number
invoiced: number
amount: number
}
>()
const orderById = new Map(filteredOrders.map((o) => [o.id, o]))
const clientNameById = new Map(clients.map((c) => [c.id, c.name]))
const vehicleLabelById = new Map(
vehicles.map((v) => [v.id, `${v.plate}${v.brand} ${v.model}`])
)
for (let i = 0; i < detailsQ.length; i++) {
const detail = detailsQ[i].data
if (!detail?.client_id || !detail.vehicle_id) continue
const order = orderById.get(detail.id)
if (!order) continue
const amount =
detail.items.reduce((s, it) => s + it.total, 0) +
detail.staff_hours.reduce((s, h) => s + h.total, 0)
const key = `${detail.client_id}:${detail.vehicle_id}`
const row = totals.get(key) ?? {
key,
clientName: clientNameById.get(detail.client_id) ?? 'Cliente',
vehicleLabel: vehicleLabelById.get(detail.vehicle_id) ?? 'Viatura',
ots: 0,
invoiced: 0,
amount: 0,
}
row.ots += 1
row.amount += amount
if (order.status === 'invoiced') row.invoiced += 1
totals.set(key, row)
}
return [...totals.values()].sort((a, b) => b.amount - a.amount)
}, [detailsQ, filteredOrders, clients, vehicles])
const kpis = useMemo(() => {
const total = filteredOrders.length
const inProgress = filteredOrders.filter((o) => o.status === 'in_progress').length
const completed = filteredOrders.filter((o) => o.status === 'completed').length
const invoiced = filteredOrders.filter((o) => o.status === 'invoiced').length
const amount = byClient.reduce((sum, c) => sum + c.amount, 0)
const withVehicle = filteredOrders.filter((o) => !!o.vehicle_id).length
const invoiceRate = total > 0 ? (invoiced / total) * 100 : 0
const avgTicket = total > 0 ? amount / total : 0
return { total, inProgress, completed, invoiced, amount, withVehicle, invoiceRate, avgTicket }
}, [filteredOrders, byClient])
const topClientAmount = byClient[0]?.amount ?? 1
const brandPie = useMemo(() => {
const vehicleByID = new Map(vehicles.map((v) => [v.id, v]))
const totals = new Map<string, number>()
for (const o of filteredOrders) {
if (!o.vehicle_id) continue
const v = vehicleByID.get(o.vehicle_id)
if (!v || !v.brand) continue
totals.set(v.brand, (totals.get(v.brand) ?? 0) + 1)
}
const rows = [...totals.entries()]
.map(([brand, count]) => ({ brand, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 5)
const total = rows.reduce((s, r) => s + r.count, 0) || 1
let acc = 0
const segments = rows.map((r, idx) => {
const start = (acc / total) * 360
acc += r.count
const end = (acc / total) * 360
return { ...r, color: PIE_COLORS[idx % PIE_COLORS.length], start, end, pct: (r.count / total) * 100 }
})
const css = segments.length
? `conic-gradient(${segments
.map((s) => `${s.color} ${s.start}deg ${s.end}deg`)
.join(', ')})`
: 'conic-gradient(#334155 0deg 360deg)'
return { segments, css }
}, [filteredOrders, vehicles])
const monthly = useMemo(() => {
const now = new Date()
const buckets = Array.from({ length: 6 }).map((_, i) => {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
return {
key: monthKey(d),
label: new Intl.DateTimeFormat('pt-PT', { month: 'short' }).format(d),
ots: 0,
amount: 0,
}
}).reverse()
const index = new Map(buckets.map((b, i) => [b.key, i]))
for (const o of filteredOrders) {
const key = monthKey(new Date(o.created_at))
const idx = index.get(key)
if (idx === undefined) continue
buckets[idx].ots += 1
const detail = detailByOrderId.get(o.id)
if (detail) {
buckets[idx].amount +=
detail.items.reduce((s, it) => s + it.total, 0) +
detail.staff_hours.reduce((s, h) => s + h.total, 0)
}
}
return buckets
}, [filteredOrders, detailByOrderId])
const maxMonthlyAmount = Math.max(1, ...monthly.map((m) => m.amount))
function downloadCsv() {
const lines: string[] = []
lines.push('Tipo;Cliente;Viatura;OTs;Faturadas;Concluidas;Resultado;TicketMedio')
byClient.forEach((row) => {
lines.push(
[
'Cliente',
csvEscape(row.clientName),
'',
row.ots,
row.invoiced,
row.completed,
row.amount.toFixed(2),
row.avgTicket.toFixed(2),
].join(';')
)
})
byClientVehicle.forEach((row) => {
lines.push(
[
'ClienteViatura',
csvEscape(row.clientName),
csvEscape(row.vehicleLabel),
row.ots,
row.invoiced,
'',
row.amount.toFixed(2),
'',
].join(';')
)
})
const blob = new Blob([`\uFEFF${lines.join('\n')}`], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `relatorio-operacional-${new Date().toISOString().slice(0, 10)}.csv`
a.click()
URL.revokeObjectURL(url)
}
function printPdfView() {
const rows = byClient
.slice(0, 12)
.map(
(r) =>
`<tr><td>${r.clientName}</td><td>${r.ots}</td><td>${r.invoiced}</td><td>${currency(r.avgTicket)}</td><td>${currency(r.amount)}</td></tr>`
)
.join('')
const w = window.open('', '_blank')
if (!w) return
w.document.write(`
<html><head><title>Relatório Operacional</title>
<style>
body{font-family:Arial,sans-serif;padding:24px;color:#0f172a}
h1{margin:0 0 6px 0} p{margin:0 0 16px 0;color:#475569}
.kpi{display:inline-block;margin-right:20px;margin-bottom:12px}
table{width:100%;border-collapse:collapse;margin-top:12px}
th,td{border:1px solid #cbd5e1;padding:8px;text-align:left;font-size:13px}
th{background:#f1f5f9}
</style></head><body>
<h1>Relatório Operacional de OTs</h1>
<p>Período: ${PERIOD_OPTIONS.find((p) => p.key === period)?.label ?? period}</p>
<div class="kpi"><strong>OTs:</strong> ${kpis.total}</div>
<div class="kpi"><strong>Em curso:</strong> ${kpis.inProgress}</div>
<div class="kpi"><strong>Concluídas:</strong> ${kpis.completed}</div>
<div class="kpi"><strong>Faturadas:</strong> ${kpis.invoiced}</div>
<div class="kpi"><strong>Resultado:</strong> ${currency(kpis.amount)}</div>
<h3>Top Clientes</h3>
<table><thead><tr><th>Cliente</th><th>OTs</th><th>Faturadas</th><th>Ticket Médio</th><th>Resultado</th></tr></thead><tbody>${rows}</tbody></table>
</body></html>
`)
w.document.close()
w.focus()
w.print()
}
return (
<section className="space-y-6">
<header className={`rounded-2xl border p-6 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<p className={`text-xs uppercase tracking-[0.2em] ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>
Relatórios Operacionais
</p>
<h1 className={`mt-1 text-3xl font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>
Resultados de OTs por Cliente e Viatura
</h1>
<p className={`mt-2 text-sm ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>
Visão executiva do desempenho operacional e financeiro das ordens de trabalho.
</p>
</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]">
<label className="text-sm">
<span className={`mb-1 block ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>Período</span>
<select
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'}`}
value={period}
onChange={(e) => setPeriod(e.target.value as PeriodKey)}
>
{PERIOD_OPTIONS.map((o) => (
<option key={o.key} value={o.key}>{o.label}</option>
))}
</select>
</label>
<label className="text-sm">
<span className={`mb-1 block ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>Cliente</span>
<select
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'}`}
value={clientFilter}
onChange={(e) => {
setClientFilter(e.target.value)
setVehicleFilter('')
}}
>
<option value="">Todos</option>
{clients.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</label>
<label className="text-sm">
<span className={`mb-1 block ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>Viatura</span>
<select
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'}`}
value={vehicleFilter}
onChange={(e) => setVehicleFilter(e.target.value)}
>
<option value="">Todas</option>
{vehicles.map((v) => (
<option key={v.id} value={v.id}>{v.plate} {v.brand} {v.model}</option>
))}
</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'
}`}
>
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'
}`}
>
Imprimir PDF
</button>
</div>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
{[
['OTs no período', String(kpis.total)],
['Em curso', String(kpis.inProgress)],
['Concluídas', String(kpis.completed)],
['Faturadas', String(kpis.invoiced)],
['Resultado total', currency(kpis.amount)],
].map(([label, value]) => (
<article key={label} className={`rounded-xl border p-4 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<p className={`text-xs uppercase tracking-wide ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>{label}</p>
<p className={`mt-1 text-2xl font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>{value}</p>
</article>
))}
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{[
['Taxa de faturação', `${kpis.invoiceRate.toFixed(1)}%`],
['Ticket médio por OT', currency(kpis.avgTicket)],
['OTs com viatura', `${kpis.withVehicle}/${kpis.total}`],
].map(([label, value]) => (
<article key={label} className={`rounded-xl border p-4 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<p className={`text-xs uppercase tracking-wide ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>{label}</p>
<p className={`mt-1 text-xl font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>{value}</p>
</article>
))}
</div>
{(isLoading || detailsLoading) && (
<p className={isLight ? 'text-slate-600 text-sm' : 'text-slate-400 text-sm'}>A calcular relatórios...</p>
)}
<div className="grid gap-4 xl:grid-cols-2">
<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'}`}>Desempenho por Cliente</h2>
{byClient.length === 0 ? (
<p className={`mt-3 text-sm ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Sem dados para este filtro.</p>
) : (
<div className="mt-4 space-y-3">
{byClient.slice(0, 7).map((row) => {
const width = (row.amount / topClientAmount) * 100
return (
<div key={row.clientName}>
<div className="mb-1 flex items-center justify-between gap-2 text-sm">
<span className={isLight ? 'text-slate-800' : 'text-slate-200'}>{row.clientName}</span>
<span className={isLight ? 'text-slate-600' : 'text-slate-400'}>
{row.ots} OTs | {currency(row.amount)}
</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-sky-500 to-cyan-400" style={{ width: `${width}%` }} />
</div>
<p className={`mt-1 text-xs ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>
Ticket médio: {currency(row.avgTicket)} | Faturadas: {row.invoiced}
</p>
</div>
)
})}
</div>
)}
</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'}`}>Desempenho por Cliente / Viatura</h2>
{byClientVehicle.length === 0 ? (
<p className={`mt-3 text-sm ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Sem dados para este filtro.</p>
) : (
<div className="mt-4 overflow-hidden rounded-lg border border-slate-300/70 dark:border-slate-700">
<table className="w-full text-sm">
<thead className={isLight ? 'bg-slate-100' : 'bg-slate-800'}>
<tr>
<th className={`px-3 py-2 text-left ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>Cliente</th>
<th className={`px-3 py-2 text-left ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>Viatura</th>
<th className={`px-3 py-2 text-right ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>OTs</th>
<th className={`px-3 py-2 text-right ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>Faturadas</th>
<th className={`px-3 py-2 text-right ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>Resultado</th>
</tr>
</thead>
<tbody>
{byClientVehicle.slice(0, 7).map((row) => (
<tr key={row.key} className={`border-t ${isLight ? 'border-slate-200' : 'border-slate-700'}`}>
<td className={`px-3 py-2 ${isLight ? 'text-slate-800' : 'text-slate-200'}`}>{row.clientName}</td>
<td className={`px-3 py-2 ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>{row.vehicleLabel}</td>
<td className={`px-3 py-2 text-right ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>{row.ots}</td>
<td className={`px-3 py-2 text-right ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>{row.invoiced}</td>
<td className={`px-3 py-2 text-right font-medium ${isLight ? 'text-slate-900' : 'text-white'}`}>{currency(row.amount)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</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 (OTs e Resultado)</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.ots} OTs | {currency(m.amount)}
</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.amount / maxMonthlyAmount) * 100}%` }}
/>
</div>
</div>
))}
</div>
</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'}`}>Principais Marcas (OTs)</h2>
<div className="mt-4 grid gap-4 md:grid-cols-[180px_1fr]">
<div className="mx-auto relative">
<div
className="h-40 w-40 rounded-full border border-slate-300/70 dark:border-slate-700 shadow-[inset_0_0_30px_rgba(0,0,0,0.08)]"
style={{ background: brandPie.css }}
/>
<div className={`absolute inset-0 m-auto h-16 w-16 rounded-full flex items-center justify-center ${isLight ? 'bg-white' : 'bg-slate-900'}`}>
<span className={`text-xs font-semibold ${isLight ? 'text-slate-700' : 'text-slate-200'}`}>
{brandPie.segments.reduce((s, x) => s + x.count, 0)}
</span>
</div>
</div>
<div className="space-y-2">
{brandPie.segments.length === 0 ? (
<p className={`text-sm ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Sem viaturas para este filtro.</p>
) : (
brandPie.segments.map((s) => (
<div key={s.brand} className="rounded-md border border-slate-200/70 dark:border-slate-700 px-3 py-2">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<span className="inline-block h-2.5 w-2.5 rounded-full" style={{ backgroundColor: s.color }} />
<span className={isLight ? 'text-slate-800' : 'text-slate-200'}>{s.brand}</span>
</div>
<span className={isLight ? 'text-slate-600' : 'text-slate-400'}>
{s.count} OTs ({s.pct.toFixed(1)}%)
</span>
</div>
<div className={`mt-2 h-1.5 rounded-full ${isLight ? 'bg-slate-200' : 'bg-slate-800'}`}>
<div className="h-1.5 rounded-full" style={{ width: `${s.pct}%`, backgroundColor: s.color }} />
</div>
</div>
))
)}
</div>
</div>
</article>
</div>
</section>
)
}
+43 -5
View File
@@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useForm } from 'react-hook-form'
import { useEffect } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { apiFetch } from '@/lib/api'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -15,6 +15,7 @@ type FormData = {
company_phone: string
company_email: string
company_logo: string
ui_theme: string
}
const defaultValues: FormData = {
@@ -25,9 +26,11 @@ const defaultValues: FormData = {
company_phone: '',
company_email: '',
company_logo: '',
ui_theme: 'dark',
}
export default function SettingsPage() {
const [previewBroken, setPreviewBroken] = useState(false)
const qc = useQueryClient()
const { data: settings, isLoading } = useQuery<TenantSettings>({
@@ -35,7 +38,18 @@ export default function SettingsPage() {
queryFn: () => apiFetch<TenantSettings>('/settings'),
})
const { register, handleSubmit, reset } = useForm<FormData>({ defaultValues })
const { register, handleSubmit, reset, watch } = useForm<FormData>({ defaultValues })
const companyLogo = watch('company_logo')
const logoPreview = (companyLogo || '').trim()
const logoLooksInvalid = useMemo(() => {
if (!logoPreview) return false
const normalized = logoPreview.toLowerCase()
return normalized.endsWith('.pn') || (!normalized.startsWith('http') && !normalized.startsWith('data:image/'))
}, [logoPreview])
useEffect(() => {
setPreviewBroken(false)
}, [logoPreview])
useEffect(() => {
if (settings) {
@@ -47,6 +61,7 @@ export default function SettingsPage() {
company_phone: settings['company_phone'] ?? '',
company_email: settings['company_email'] ?? '',
company_logo: settings['company_logo'] ?? '',
ui_theme: settings['ui_theme'] ?? 'dark',
})
}
}, [settings, reset])
@@ -112,16 +127,39 @@ export default function SettingsPage() {
<p className="text-xs text-slate-500">
Este logotipo será impresso em Orçamentos e Faturas.
</p>
{settings?.company_logo && (
{logoLooksInvalid && (
<p className="text-xs text-amber-400">
O URL do logotipo parece incompleto. Confirma se termina com extensão correta (ex.: `.png`).
</p>
)}
{logoPreview && !previewBroken && (
<div className="mt-2 rounded-md border border-slate-700 p-3 bg-slate-900/50">
<p className="text-xs text-slate-400 mb-2">Pré-visualização:</p>
<img
src={settings.company_logo}
alt="Logotipo da oficina"
src={logoPreview}
alt=""
className="h-12 object-contain bg-white/90 p-1 rounded"
onError={() => setPreviewBroken(true)}
referrerPolicy="no-referrer"
/>
</div>
)}
{logoPreview && previewBroken && (
<p className="text-xs text-amber-400">
Não foi possível carregar o logotipo. Tenta outro URL público ou usa `data:image/...`.
</p>
)}
</div>
<div className="space-y-1">
<Label htmlFor="ui_theme">Tema da interface (padrão)</Label>
<select
id="ui_theme"
{...register('ui_theme')}
className="w-full rounded-md border border-slate-600 bg-slate-900 text-white px-3 py-2 text-sm"
>
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
</div>
{save.error && (
+17 -1
View File
@@ -6,6 +6,7 @@ import { z } from 'zod'
import { apiFetch } from '@/lib/api'
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'
import { Label } from '@/components/ui/label'
import type { Staff } from '@/lib/types'
@@ -28,6 +29,7 @@ export default function StaffPage() {
const qc = useQueryClient()
const [editing, setEditing] = useState<Staff | null>(null)
const [showForm, setShowForm] = useState(false)
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
const { data: staff = [], isLoading } = useQuery<Staff[]>({
queryKey: ['staff'],
@@ -165,7 +167,7 @@ export default function StaffPage() {
<Button
size="sm"
variant="destructive"
onClick={() => { if (confirm('Eliminar técnico?')) remove.mutate(s.id) }}
onClick={() => setPendingDeleteId(s.id)}
>
Eliminar
</Button>
@@ -176,6 +178,20 @@ export default function StaffPage() {
</table>
</div>
)}
<ConfirmActionDialog
open={!!pendingDeleteId}
onOpenChange={(open) => !open && setPendingDeleteId(null)}
title="Eliminar técnico"
description="Esta ação remove o técnico de forma permanente."
confirmLabel="Eliminar"
pending={remove.isPending}
onConfirm={() => {
if (!pendingDeleteId) return
remove.mutate(pendingDeleteId, {
onSettled: () => setPendingDeleteId(null),
})
}}
/>
</div>
)
}
@@ -0,0 +1,258 @@
import { useMemo, useState } from 'react'
import { useQueries, useQuery } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api'
import { useTheme } from '@/hooks/useTheme'
import type { Staff, WorkOrder, WorkOrderDetail } from '@/lib/types'
type PeriodKey = '30d' | '90d' | 'year' | 'all'
const PERIOD_OPTIONS: { key: PeriodKey; label: string }[] = [
{ key: '30d', label: '30 dias' },
{ key: '90d', label: '90 dias' },
{ key: 'year', label: '12 meses' },
{ key: 'all', label: 'Todo o histórico' },
]
function getStartDate(period: PeriodKey) {
const now = new Date()
if (period === 'all') return null
const start = new Date(now)
if (period === '30d') start.setDate(now.getDate() - 30)
if (period === '90d') start.setDate(now.getDate() - 90)
if (period === 'year') start.setMonth(now.getMonth() - 12)
return start
}
function currency(v: number) {
return new Intl.NumberFormat('pt-PT', { style: 'currency', currency: 'EUR' }).format(v)
}
function monthKey(d: Date) {
return `${d.getFullYear()}-${d.getMonth()}`
}
export default function TechnicianReportsPage() {
const { theme } = useTheme('ui_theme', 'dark')
const isLight = theme === 'light'
const [period, setPeriod] = useState<PeriodKey>('90d')
const [staffFilter, setStaffFilter] = useState('')
const staffQ = useQuery<Staff[]>({
queryKey: ['staff'],
queryFn: () => apiFetch<Staff[]>('/staff'),
})
const workOrdersQ = useQuery<WorkOrder[]>({
queryKey: ['work-orders', 'reports', 'technicians'],
queryFn: () => apiFetch<WorkOrder[]>('/work-orders'),
})
const staff = staffQ.data ?? []
const workOrders = workOrdersQ.data ?? []
const filteredOrders = useMemo(() => {
const start = getStartDate(period)
return workOrders.filter((wo) => !start || new Date(wo.created_at) >= start)
}, [workOrders, period])
const detailQ = useQueries({
queries: filteredOrders.map((o) => ({
queryKey: ['work-order-detail', o.id, 'tech-report'],
queryFn: () => apiFetch<WorkOrderDetail>(`/work-orders/${o.id}`),
staleTime: 5 * 60 * 1000,
})),
})
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 byTechnician = useMemo(() => {
const map = new Map<
string,
{ id: string; name: string; hours: number; total: number; ots: Set<string>; invoiced: number }
>()
for (const q of detailQ) {
const d = q.data
if (!d) continue
const wo = orderByID.get(d.id)
if (!wo) continue
for (const sh of d.staff_hours) {
if (staffFilter && sh.staff_id !== staffFilter) continue
const row = map.get(sh.staff_id) ?? {
id: sh.staff_id,
name: staffNameByID.get(sh.staff_id) ?? 'Técnico',
hours: 0,
total: 0,
ots: new Set<string>(),
invoiced: 0,
}
row.hours += sh.hours
row.total += sh.total
if (!row.ots.has(d.id) && wo.status === 'invoiced') row.invoiced += 1
row.ots.add(d.id)
map.set(sh.staff_id, row)
}
}
return [...map.values()]
.map((r) => ({ ...r, otsCount: r.ots.size, avgHour: r.hours > 0 ? r.total / r.hours : 0 }))
.sort((a, b) => b.total - a.total)
}, [detailQ, orderByID, staffNameByID, staffFilter])
const kpis = useMemo(() => {
const totalHours = byTechnician.reduce((s, r) => s + r.hours, 0)
const totalCost = byTechnician.reduce((s, r) => s + r.total, 0)
const otsCount = byTechnician.reduce((s, r) => s + r.otsCount, 0)
const avgHourCost = totalHours > 0 ? totalCost / totalHours : 0
return {
activeTech: byTechnician.length,
totalHours,
totalCost,
otsCount,
avgHourCost,
}
}, [byTechnician])
const monthly = useMemo(() => {
const now = new Date()
const buckets = Array.from({ length: 6 }).map((_, i) => {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
return {
key: monthKey(d),
label: new Intl.DateTimeFormat('pt-PT', { month: 'short' }).format(d),
hours: 0,
total: 0,
}
}).reverse()
const index = new Map(buckets.map((b, i) => [b.key, i]))
for (const q of detailQ) {
const d = q.data
if (!d) continue
const wo = orderByID.get(d.id)
if (!wo) continue
const idx = index.get(monthKey(new Date(wo.created_at)))
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
}
}
return buckets
}, [detailQ, orderByID, staffFilter])
const maxMonthly = Math.max(1, ...monthly.map((m) => m.total))
const topValue = byTechnician[0]?.total ?? 1
return (
<section className="space-y-6">
<header className={`rounded-2xl border p-6 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<p className={`text-xs uppercase tracking-[0.2em] ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>
Relatórios Operacionais
</p>
<h1 className={`mt-1 text-3xl font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>
Desempenho por Técnicos
</h1>
<p className={`mt-2 text-sm ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>
Visão de horas, custo de mão de obra e produtividade por técnico no período.
</p>
</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">
<label className="text-sm">
<span className={`mb-1 block ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>Período</span>
<select
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'}`}
value={period}
onChange={(e) => setPeriod(e.target.value as PeriodKey)}
>
{PERIOD_OPTIONS.map((o) => (
<option key={o.key} value={o.key}>{o.label}</option>
))}
</select>
</label>
<label className="text-sm">
<span className={`mb-1 block ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>Técnico</span>
<select
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'}`}
value={staffFilter}
onChange={(e) => setStaffFilter(e.target.value)}
>
<option value="">Todos</option>
{staff.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</label>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
{[
['Técnicos no período', String(kpis.activeTech)],
['OTs cobertas', String(kpis.otsCount)],
['Horas totais', kpis.totalHours.toFixed(2)],
['Custo mão de obra', currency(kpis.totalCost)],
['Média €/hora', currency(kpis.avgHourCost)],
].map(([label, value]) => (
<article key={label} className={`rounded-xl border p-4 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<p className={`text-xs uppercase tracking-wide ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>{label}</p>
<p className={`mt-1 text-2xl font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>{value}</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>
)}
<div className="grid gap-4 xl:grid-cols-2">
<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'}`}>Ranking de Técnicos</h2>
{byTechnician.length === 0 ? (
<p className={`mt-3 text-sm ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Sem dados para este filtro.</p>
) : (
<div className="mt-4 space-y-3">
{byTechnician.slice(0, 7).map((row) => (
<div key={row.id}>
<div className="mb-1 flex items-center justify-between gap-2 text-sm">
<span className={isLight ? 'text-slate-800' : 'text-slate-200'}>{row.name}</span>
<span className={isLight ? 'text-slate-600' : 'text-slate-400'}>
{row.hours.toFixed(2)}h | {currency(row.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-sky-500 to-cyan-400" style={{ width: `${(row.total / topValue) * 100}%` }} />
</div>
<p className={`mt-1 text-xs ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>
OTs: {row.otsCount} | Faturadas: {row.invoiced} | Média: {currency(row.avgHour)}/h
</p>
</div>
))}
</div>
)}
</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>
</div>
))}
</div>
</article>
</div>
</section>
)
}
+412 -37
View File
@@ -1,15 +1,18 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useParams, Link } from 'react-router'
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 { apiFetch } from '@/lib/api'
import { apiFetch, apiFetchBlob } from '@/lib/api'
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'
import { Label } from '@/components/ui/label'
import type { WorkOrderDetail, CatalogItem, Staff } from '@/lib/types'
import { useTheme } from '@/hooks/useTheme'
import { WORK_ORDER_STATUS_LABEL, workOrderStatusBadgeClass } from '@/lib/workOrderStatus'
import type { WorkOrderDetail, CatalogItem, Staff, Client, Vehicle } from '@/lib/types'
const CATEGORY_LABEL: Record<string, string> = {
mao_de_obra: 'Mão de Obra',
@@ -26,15 +29,6 @@ const CATEGORY_LABEL: Record<string, string> = {
outro: 'Outro',
}
const STATUS_LABELS: Record<string, string> = {
quote: 'Orçamento',
open: 'Aberta',
in_progress: 'Em Curso',
completed: 'Concluída',
invoiced: 'Faturada',
cancelled: 'Cancelada',
}
const TRANSITIONS: Record<string, string[]> = {
quote: ['open', 'cancelled'],
open: ['in_progress', 'cancelled'],
@@ -43,18 +37,61 @@ const TRANSITIONS: Record<string, string[]> = {
invoiced: [],
cancelled: [],
}
const ETA_LABEL: Record<number, string> = {
1: '1 dia',
2: '2 dias',
3: '3 dias',
7: '7 dias',
15: '15 dias',
30: '30 dias',
31: '+ de 30 dias',
}
const ETA_OPTIONS = [1, 2, 3, 7, 15, 30, 31]
const PAYMENT_METHOD_OPTIONS = [
{ value: 'numerario', label: 'Numerário' },
{ value: 'multibanco', label: 'Multibanco' },
{ value: 'transferencia_bancaria', label: 'Transferência Bancária' },
{ value: 'mb_way', label: 'MB WAY' },
{ value: 'cartao_debito', label: 'Cartão de Débito' },
{ value: 'cartao_credito', label: 'Cartão de Crédito' },
{ value: 'cheque', label: 'Cheque' },
{ value: 'outro', label: 'Outro' },
]
const PAYMENT_METHOD_LABEL: Record<string, string> = Object.fromEntries(PAYMENT_METHOD_OPTIONS.map((o) => [o.value, o.label]))
function formatDateSafe(value?: string | null, withTime = false): string {
if (!value) return '—'
const d = new Date(value)
if (Number.isNaN(d.getTime())) return '—'
return withTime
? new Intl.DateTimeFormat('pt-PT', { dateStyle: 'short', timeStyle: 'short' }).format(d)
: new Intl.DateTimeFormat('pt-PT').format(d)
}
const metaSchema = z.object({
client_id: z.string(),
vehicle_id: z.string(),
internal_notes: z.string(),
client_notes: z.string(),
eta_days: z.coerce.number().int(),
real_deadline: z.string(),
payment_method: z.string(),
payment_date: z.string(),
})
type MetaForm = z.infer<typeof metaSchema>
// ─── Item form ────────────────────────────────────────────────────────────────
const itemSchema = z.object({
catalog_item_id: z.string(),
description: z.string().min(1, 'Descrição obrigatória'),
change_justification: z.string(),
qty: z.coerce.number().positive('Quantidade deve ser positiva'),
unit_price: z.coerce.number().min(0),
discount_pct: z.coerce.number().min(0).max(100),
})
type ItemForm = z.infer<typeof itemSchema>
const emptyItem: ItemForm = { catalog_item_id: '', description: '', qty: 1, unit_price: 0, discount_pct: 0 }
const emptyItem: ItemForm = { catalog_item_id: '', description: '', change_justification: '', qty: 1, unit_price: 0, discount_pct: 0 }
// ─── Staff hours form ─────────────────────────────────────────────────────────
@@ -70,11 +107,15 @@ const emptySH: SHForm = { staff_id: '', hours: 1, cost_per_hour: 0 }
export default function WorkOrderDetailPage() {
const { id } = useParams<{ id: string }>()
const { theme } = useTheme('ui_theme', 'dark')
const isLight = theme === 'light'
const qc = useQueryClient()
const [showAddItem, setShowAddItem] = useState(false)
const [showAddStaff, setShowAddStaff] = useState(false)
const [showMetaEdit, setShowMetaEdit] = useState(false)
const [confirmAction, setConfirmAction] = useState<null | { type: 'cancel' | 'remove_item' | 'remove_staff'; id?: string }>(null)
const { data: detail, isLoading } = useQuery<WorkOrderDetail>({
const { data: detail, isLoading, error: detailError } = useQuery<WorkOrderDetail>({
queryKey: ['work-order', id],
queryFn: () => apiFetch<WorkOrderDetail>(`/work-orders/${id}`),
})
@@ -88,6 +129,39 @@ export default function WorkOrderDetailPage() {
queryKey: ['staff'],
queryFn: () => apiFetch<Staff[]>('/staff'),
})
const { data: invoices = [] } = useQuery<{ id: string; work_order_id: string; type: 'quote' | 'invoice'; issued_at: string }[]>({
queryKey: ['invoices', 'work-order-detail'],
queryFn: () => apiFetch('/invoices'),
})
const { data: clients = [] } = useQuery<Client[]>({
queryKey: ['clients'],
queryFn: () => apiFetch<Client[]>('/clients'),
})
const {
register: regMeta,
handleSubmit: handleMeta,
watch: watchMeta,
reset: resetMeta,
formState: { errors: metaErrors },
} = useForm<MetaForm>({
resolver: zodResolver(metaSchema) as Resolver<MetaForm>,
defaultValues: {
client_id: '',
vehicle_id: '',
internal_notes: '',
client_notes: '',
eta_days: 1,
real_deadline: '',
payment_method: '',
payment_date: '',
},
})
const selectedMetaClient = watchMeta('client_id')
const { data: vehicles = [] } = useQuery<Vehicle[]>({
queryKey: ['vehicles', selectedMetaClient || detail?.client_id || '', 'wo-meta'],
queryFn: () => apiFetch<Vehicle[]>(`/clients/${selectedMetaClient || detail?.client_id}/vehicles`),
enabled: !!(selectedMetaClient || detail?.client_id),
})
// lookup maps
const catalogMap = Object.fromEntries(catalogItems.map(c => [c.id, c]))
@@ -96,9 +170,44 @@ export default function WorkOrderDetailPage() {
// ── Transitions ──────────────────────────────────────────────────────────────
const transition = useMutation({
mutationFn: (status: string) =>
apiFetch(`/work-orders/${id}/transition`, { method: 'POST', body: JSON.stringify({ status }) }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['work-order', id] }),
mutationFn: (status: string) => {
if (status === 'invoiced') {
return apiFetch('/invoices', {
method: 'POST',
body: JSON.stringify({ work_order_id: id, type: 'invoice' }),
})
}
return apiFetch(`/work-orders/${id}/transition`, {
method: 'POST',
body: JSON.stringify({
status,
payment_method: '',
payment_date: '',
}),
})
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['work-order', id] })
qc.invalidateQueries({ queryKey: ['work-orders'] })
qc.invalidateQueries({ queryKey: ['invoices'] })
},
})
const updateMeta = useMutation({
mutationFn: (data: MetaForm) =>
apiFetch(`/work-orders/${id}`, {
method: 'PUT',
body: JSON.stringify({
...data,
real_deadline: detail?.status === 'quote' ? '' : data.real_deadline,
payment_method: data.payment_method,
payment_date: data.payment_date,
}),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['work-order', id] })
qc.invalidateQueries({ queryKey: ['work-orders'] })
setShowMetaEdit(false)
},
})
// ── Items ─────────────────────────────────────────────────────────────────────
@@ -180,33 +289,236 @@ export default function WorkOrderDetailPage() {
onSuccess: () => qc.invalidateQueries({ queryKey: ['work-order', id] }),
})
useEffect(() => {
if (!detail || showMetaEdit) return
resetMeta({
client_id: detail.client_id ?? '',
vehicle_id: detail.vehicle_id ?? '',
internal_notes: detail.internal_notes ?? '',
client_notes: detail.client_notes ?? '',
eta_days: detail.eta_days ?? 1,
real_deadline: detail.real_deadline ? detail.real_deadline.split('T')[0] : '',
payment_method: detail.payment_method ?? '',
payment_date: detail.payment_date ? detail.payment_date.split('T')[0] : '',
})
}, [detail, showMetaEdit, resetMeta])
// ── 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 (!detail) return <p className="text-slate-400">Ordem não encontrada.</p>
const itemsTotal = detail.items.reduce((s, i) => s + i.total, 0)
const hoursTotal = detail.staff_hours.reduce((s, h) => s + h.total, 0)
const items = Array.isArray(detail.items) ? detail.items : []
const staffHours = Array.isArray(detail.staff_hours) ? detail.staff_hours : []
const itemsTotal = items.reduce((s, i) => s + Number(i.total || 0), 0)
const hoursTotal = staffHours.reduce((s, h) => s + Number(h.total || 0), 0)
const nextStates = TRANSITIONS[detail.status] ?? []
const editable = detail.status !== 'invoiced' && detail.status !== 'cancelled'
const clientName = detail.client_id ? (clients.find((c) => c.id === detail.client_id)?.name ?? '—') : '—'
const woInvoices = invoices
.filter((inv) => inv.work_order_id === detail.id)
.sort((a, b) => new Date(b.issued_at).getTime() - new Date(a.issued_at).getTime())
const preferredType = detail.status === 'invoiced' ? 'invoice' : 'quote'
const selectedDoc = woInvoices.find((inv) => inv.type === preferredType) ?? woInvoices[0]
async function openWOInvoicePDF() {
if (!selectedDoc) return
try {
const blob = await apiFetchBlob(`/invoices/${selectedDoc.id}/pdf`)
const url = URL.createObjectURL(blob)
window.open(url, '_blank', 'noopener,noreferrer')
setTimeout(() => URL.revokeObjectURL(url), 60_000)
} catch {
// transition/update errors already shown elsewhere
}
}
const vehicleName = detail.vehicle_id
? (() => {
const v = vehicles.find((it) => it.id === detail.vehicle_id)
return v ? `${v.plate}${v.brand} ${v.model}` : '—'
})()
: '—'
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<Link to="/app/work-orders" className="text-slate-400 hover:text-white text-sm">
<Link
to="/app/work-orders"
className={`text-sm ${isLight ? 'text-slate-600 hover:text-slate-900' : 'text-slate-400 hover:text-white'}`}
>
Ordens
</Link>
<span className="text-slate-600">/</span>
<h1 className="text-2xl font-bold text-white">Ordem #{detail.number}</h1>
<Badge variant={detail.status === 'cancelled' ? 'destructive' : detail.status === 'completed' || detail.status === 'invoiced' ? 'secondary' : 'default'}>
{STATUS_LABELS[detail.status]}
<span className={isLight ? 'text-slate-400' : 'text-slate-600'}>/</span>
<h1 className={`text-2xl font-bold ${isLight ? 'text-slate-900' : 'text-white'}`}>Ordem #{detail.number}</h1>
<Badge variant="outline" className={workOrderStatusBadgeClass(detail.status)}>
{WORK_ORDER_STATUS_LABEL[detail.status]}
</Badge>
{selectedDoc && (
<Button size="sm" variant="outline" onClick={openWOInvoicePDF}>
Ver PDF
</Button>
)}
</div>
<section className={`rounded-lg border p-4 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/50'}`}>
<div className="grid gap-4 lg:grid-cols-3">
<div className={`lg:col-span-2 rounded-md border p-3 ${isLight ? 'border-slate-300 bg-slate-50' : 'border-slate-800 bg-slate-950/40'}`}>
<h2 className={`mb-3 text-base font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Dados da OT</h2>
<div className="grid gap-3 text-sm grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
<div>
<p className={isLight ? 'text-slate-500' : 'text-slate-500'}>Cliente</p>
<p className={isLight ? 'text-slate-800' : 'text-slate-200'}>{clientName}</p>
</div>
<div>
<p className={isLight ? 'text-slate-500' : 'text-slate-500'}>Viatura</p>
<p className={isLight ? 'text-slate-800' : 'text-slate-200'}>{vehicleName}</p>
</div>
<div>
<p className={isLight ? 'text-slate-500' : 'text-slate-500'}>Criada em</p>
<p className={isLight ? 'text-slate-800' : 'text-slate-200'}>{formatDateSafe(detail.created_at, true)}</p>
</div>
<div>
<p className={isLight ? 'text-slate-500' : 'text-slate-500'}>Previsão de conclusão</p>
<p className={isLight ? 'text-slate-800' : 'text-slate-200'}>{ETA_LABEL[detail.eta_days] ?? `${detail.eta_days} dias`}</p>
</div>
{detail.status !== 'quote' && (
<div>
<p className={isLight ? 'text-slate-500' : 'text-slate-500'}>Prazo real (deadline)</p>
<p className={isLight ? 'text-slate-800' : 'text-slate-200'}>{formatDateSafe(detail.real_deadline)}</p>
</div>
)}
{(detail.status === 'completed' || detail.status === 'invoiced') && (
<div>
<p className={isLight ? 'text-slate-500' : 'text-slate-500'}>Meio de pagamento</p>
<p className={isLight ? 'text-slate-800' : 'text-slate-200'}>{PAYMENT_METHOD_LABEL[detail.payment_method] ?? '—'}</p>
</div>
)}
{(detail.status === 'completed' || detail.status === 'invoiced') && (
<div>
<p className={isLight ? 'text-slate-500' : 'text-slate-500'}>Data de pagamento</p>
<p className={isLight ? 'text-slate-800' : 'text-slate-200'}>{formatDateSafe(detail.payment_date)}</p>
</div>
)}
<div>
<p className={isLight ? 'text-slate-500' : 'text-slate-500'}>Observações</p>
<p className={isLight ? 'text-slate-800' : 'text-slate-200'}>{detail.client_notes || '—'}</p>
</div>
<div>
<p className={isLight ? 'text-slate-500' : 'text-slate-500'}>Notas internas</p>
<p className={isLight ? 'text-slate-800' : 'text-slate-200'}>{detail.internal_notes || '—'}</p>
</div>
</div>
</div>
<div className={`rounded-md border p-3 ${isLight ? 'border-slate-300 bg-slate-50' : 'border-slate-800 bg-slate-950/40'}`}>
<div className="mb-3 flex items-center justify-between">
<h3 className={`text-base font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Editar Dados da OT</h3>
{editable && !showMetaEdit && (
<Button size="sm" onClick={() => setShowMetaEdit(true)}>Editar</Button>
)}
</div>
{!showMetaEdit && editable && (
<p className={`text-sm ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Clique em editar para atualizar cliente, viatura, prazos e observações.</p>
)}
{!editable && (
<p className={`text-sm ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>OT faturada/cancelada não permite edição.</p>
)}
{editable && showMetaEdit && (
<form onSubmit={handleMeta((d) => updateMeta.mutate(d))} className="space-y-3">
<div className="space-y-1">
<Label>Cliente</Label>
<select
{...regMeta('client_id')}
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'}`}
>
<option value=""> Sem cliente </option>
{clients.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="space-y-1">
<Label>Viatura</Label>
<select
{...regMeta('vehicle_id')}
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'}`}
>
<option value=""> Sem viatura </option>
{vehicles.map((v) => <option key={v.id} value={v.id}>{v.plate} {v.brand} {v.model}</option>)}
</select>
</div>
<div className="space-y-1">
<Label>Previsão de conclusão</Label>
<select
{...regMeta('eta_days')}
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'}`}
>
{ETA_OPTIONS.map((o) => <option key={o} value={o}>{ETA_LABEL[o]}</option>)}
</select>
</div>
{detail.status !== 'quote' && (
<div className="space-y-1">
<Label>Prazo real (deadline)</Label>
<Input
type="date"
{...regMeta('real_deadline')}
className={isLight ? 'bg-white border-slate-300 text-slate-900' : 'bg-slate-900 border-slate-600 text-white'}
/>
</div>
)}
{(detail.status === 'completed' || detail.status === 'invoiced') && (
<div className="space-y-1">
<Label>Meio de pagamento</Label>
<select
{...regMeta('payment_method')}
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'}`}
>
<option value=""> Selecionar </option>
{PAYMENT_METHOD_OPTIONS.map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
</select>
</div>
)}
{(detail.status === 'completed' || detail.status === 'invoiced') && (
<div className="space-y-1">
<Label>Data de pagamento</Label>
<Input
type="date"
{...regMeta('payment_date')}
className={isLight ? 'bg-white border-slate-300 text-slate-900' : 'bg-slate-900 border-slate-600 text-white'}
/>
</div>
)}
<div className="space-y-1">
<Label>Observações</Label>
<Input
{...regMeta('client_notes')}
className={isLight ? 'bg-white border-slate-300 text-slate-900' : 'bg-slate-900 border-slate-600 text-white'}
/>
</div>
<div className="space-y-1">
<Label>Notas internas</Label>
<Input
{...regMeta('internal_notes')}
className={isLight ? 'bg-white border-slate-300 text-slate-900' : 'bg-slate-900 border-slate-600 text-white'}
/>
</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>}
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setShowMetaEdit(false)}>Cancelar</Button>
<Button type="submit" disabled={updateMeta.isPending}>
{updateMeta.isPending ? 'A guardar...' : 'Guardar alterações'}
</Button>
</div>
</form>
)}
</div>
</div>
</section>
{/* Transitions */}
{nextStates.length > 0 && (
<div className="flex gap-2">
<div className="space-y-2">
<div className="flex gap-2">
<span className="text-slate-400 text-sm self-center">Transição:</span>
{nextStates.map((s) => (
<Button
@@ -214,14 +526,21 @@ export default function WorkOrderDetailPage() {
size="sm"
variant={s === 'cancelled' ? 'destructive' : 'default'}
onClick={() => {
if (s === 'cancelled' && !confirm('Cancelar esta ordem?')) return
if (s === 'cancelled') {
setConfirmAction({ type: 'cancel' })
return
}
transition.mutate(s)
}}
disabled={transition.isPending}
>
{STATUS_LABELS[s]}
{WORK_ORDER_STATUS_LABEL[s as keyof typeof WORK_ORDER_STATUS_LABEL]}
</Button>
))}
</div>
{transition.error && (
<p className="text-sm text-red-400">{(transition.error as Error).message}</p>
)}
</div>
)}
@@ -259,6 +578,17 @@ export default function WorkOrderDetailPage() {
<Input {...regItem('description')} className="bg-slate-900 border-slate-600 text-white" />
{itemErrors.description && <p className="text-red-400 text-xs">{itemErrors.description.message}</p>}
</div>
{detail.status === 'open' && (
<div className="col-span-3 space-y-1">
<Label>Justificação da alteração *</Label>
<Input
{...regItem('change_justification')}
placeholder="Ex: Pedido do cliente para incluir peça adicional"
className="bg-slate-900 border-slate-600 text-white"
/>
<p className="text-xs text-slate-500">Obrigatório em Orçamento Aprovado para garantir transparência.</p>
</div>
)}
<div className="space-y-1">
<Label>Qtd. *</Label>
<Input type="number" step="0.001" {...regItem('qty')} className="bg-slate-900 border-slate-600 text-white" />
@@ -283,7 +613,7 @@ export default function WorkOrderDetailPage() {
</div>
)}
{detail.items.length === 0 ? (
{items.length === 0 ? (
<p className="text-slate-500 text-sm">Sem itens.</p>
) : (
<div className="rounded-lg border border-slate-700 overflow-hidden">
@@ -299,7 +629,7 @@ export default function WorkOrderDetailPage() {
</tr>
</thead>
<tbody>
{detail.items.map((item) => {
{items.map((item) => {
const cat = item.catalog_item_id ? catalogMap[item.catalog_item_id] : null
return (
<tr key={item.id} className="border-t border-slate-700">
@@ -310,18 +640,23 @@ export default function WorkOrderDetailPage() {
{CATEGORY_LABEL[cat.category] ?? cat.category}
</div>
)}
{item.change_justification && (
<div className="text-xs text-amber-300/90 mt-1">
Justificação: {item.change_justification}
</div>
)}
</td>
<td className="px-4 py-2 text-slate-300 text-right">{item.qty}</td>
<td className="px-4 py-2 text-slate-300 text-right">{item.unit_price.toFixed(2)} </td>
<td className="px-4 py-2 text-slate-300 text-right">{Number(item.unit_price || 0).toFixed(2)} </td>
<td className="px-4 py-2 text-slate-300 text-right">{item.discount_pct}%</td>
<td className="px-4 py-2 text-white font-medium text-right">{item.total.toFixed(2)} </td>
<td className="px-4 py-2 text-white font-medium text-right">{Number(item.total || 0).toFixed(2)} </td>
<td className="px-4 py-2 text-right">
{editable && (
<Button
size="sm"
variant="ghost"
className="text-red-400 hover:text-red-300 h-7 px-2"
onClick={() => removeItem.mutate(item.id)}
onClick={() => setConfirmAction({ type: 'remove_item', id: item.id })}
disabled={removeItem.isPending}
>
@@ -411,7 +746,7 @@ export default function WorkOrderDetailPage() {
</div>
)}
{detail.staff_hours.length === 0 ? (
{staffHours.length === 0 ? (
<p className="text-slate-500 text-sm">Sem técnicos registados nesta ordem.</p>
) : (
<div className="rounded-lg border border-slate-700 overflow-hidden">
@@ -426,7 +761,7 @@ export default function WorkOrderDetailPage() {
</tr>
</thead>
<tbody>
{detail.staff_hours.map((sh) => {
{staffHours.map((sh) => {
const staff = staffMap[sh.staff_id]
return (
<tr key={sh.id} className="border-t border-slate-700">
@@ -439,15 +774,15 @@ export default function WorkOrderDetailPage() {
)}
</td>
<td className="px-4 py-2 text-white text-right">{sh.hours}</td>
<td className="px-4 py-2 text-slate-300 text-right">{sh.cost_per_hour.toFixed(2)} </td>
<td className="px-4 py-2 text-white font-medium text-right">{sh.total.toFixed(2)} </td>
<td className="px-4 py-2 text-slate-300 text-right">{Number(sh.cost_per_hour || 0).toFixed(2)} </td>
<td className="px-4 py-2 text-white font-medium text-right">{Number(sh.total || 0).toFixed(2)} </td>
<td className="px-4 py-2 text-right">
{editable && (
<Button
size="sm"
variant="ghost"
className="text-red-400 hover:text-red-300 h-7 px-2"
onClick={() => removeSH.mutate(sh.id)}
onClick={() => setConfirmAction({ type: 'remove_staff', id: sh.id })}
disabled={removeSH.isPending}
>
@@ -462,6 +797,46 @@ export default function WorkOrderDetailPage() {
</div>
)}
</section>
<ConfirmActionDialog
open={!!confirmAction}
onOpenChange={(open) => !open && setConfirmAction(null)}
title={
confirmAction?.type === 'cancel'
? 'Cancelar ordem de trabalho'
: confirmAction?.type === 'remove_item'
? 'Eliminar item'
: 'Eliminar técnico'
}
description={
confirmAction?.type === 'cancel'
? 'A ordem será marcada como cancelada e deixará de ser editável.'
: confirmAction?.type === 'remove_item'
? 'Este item será removido da OT.'
: 'Este registo de horas será removido da OT.'
}
confirmLabel={confirmAction?.type === 'cancel' ? 'Cancelar OT' : 'Eliminar'}
pending={transition.isPending || removeItem.isPending || removeSH.isPending}
onConfirm={() => {
if (!confirmAction) return
if (confirmAction.type === 'cancel') {
transition.mutate('cancelled', {
onSettled: () => setConfirmAction(null),
})
return
}
if (confirmAction.type === 'remove_item' && confirmAction.id) {
removeItem.mutate(confirmAction.id, {
onSettled: () => setConfirmAction(null),
})
return
}
if (confirmAction.type === 'remove_staff' && confirmAction.id) {
removeSH.mutate(confirmAction.id, {
onSettled: () => setConfirmAction(null),
})
}
}}
/>
</div>
)
}
+107 -45
View File
@@ -1,47 +1,46 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router'
import { useForm } from 'react-hook-form'
import { useForm, type Resolver } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { apiFetch } from '@/lib/api'
import { useTheme } from '@/hooks/useTheme'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { WORK_ORDER_STATUS_LABEL, workOrderStatusBadgeClass } from '@/lib/workOrderStatus'
import type { WorkOrder, Client, Vehicle } from '@/lib/types'
const STATUS_LABELS: Record<string, string> = {
quote: 'Orçamento',
open: 'Aberta',
in_progress: 'Em Curso',
completed: 'Concluída',
invoiced: 'Faturada',
cancelled: 'Cancelada',
}
const STATUS_VARIANT: Record<string, 'default' | 'secondary' | 'destructive'> = {
quote: 'secondary',
open: 'secondary',
in_progress: 'default',
completed: 'default',
invoiced: 'secondary',
cancelled: 'destructive',
}
const schema = z.object({
client_id: z.string(),
vehicle_id: z.string(),
internal_notes: z.string(),
client_notes: z.string(),
eta_days: z.coerce.number().int(),
})
type FormData = z.infer<typeof schema>
const emptyOrder: FormData = { client_id: '', vehicle_id: '', internal_notes: '' }
const emptyOrder: FormData = { client_id: '', vehicle_id: '', internal_notes: '', client_notes: '', eta_days: 1 }
const ETA_OPTIONS = [
{ value: 1, label: '1 dia' },
{ value: 2, label: '2 dias' },
{ value: 3, label: '3 dias' },
{ value: 7, label: '7 dias' },
{ value: 15, label: '15 dias' },
{ value: 30, label: '30 dias' },
{ value: 31, label: '+ de 30 dias' },
]
const ETA_LABEL: Record<number, string> = Object.fromEntries(ETA_OPTIONS.map((o) => [o.value, o.label]))
export default function WorkOrdersPage() {
const { theme } = useTheme('ui_theme', 'dark')
const isLight = theme === 'light'
const qc = useQueryClient()
const [statusFilter, setStatusFilter] = useState('')
const [showForm, setShowForm] = useState(false)
const [docError, setDocError] = useState('')
const { data: orders = [], isLoading } = useQuery<WorkOrder[]>({
queryKey: ['work-orders', statusFilter],
@@ -52,9 +51,10 @@ export default function WorkOrdersPage() {
queryKey: ['clients'],
queryFn: () => apiFetch<Client[]>('/clients'),
})
const clientNameById = Object.fromEntries(clients.map((c) => [c.id, c.name]))
const { register, handleSubmit, watch, reset } = useForm<FormData>({
resolver: zodResolver(schema),
resolver: zodResolver(schema) as Resolver<FormData>,
defaultValues: emptyOrder,
})
@@ -69,8 +69,18 @@ export default function WorkOrdersPage() {
const create = useMutation({
mutationFn: (data: FormData) =>
apiFetch<WorkOrder>('/work-orders', { method: 'POST', body: JSON.stringify(data) }),
onSuccess: () => {
onSuccess: async (wo) => {
setDocError('')
try {
await apiFetch('/invoices', {
method: 'POST',
body: JSON.stringify({ work_order_id: wo.id, type: 'quote' }),
})
} catch (err) {
setDocError(`OT criada, mas falhou gerar orçamento PDF: ${(err as Error).message}`)
}
qc.invalidateQueries({ queryKey: ['work-orders'] })
qc.invalidateQueries({ queryKey: ['invoices'] })
setShowForm(false)
reset()
},
@@ -80,8 +90,8 @@ export default function WorkOrdersPage() {
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-white">Ordens de Trabalho</h1>
<p className="text-slate-400 text-sm mt-0.5">{orders.length} ordens</p>
<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>
</div>
<Button onClick={() => { reset(emptyOrder); setShowForm(true) }}>Nova Ordem</Button>
</div>
@@ -93,25 +103,31 @@ export default function WorkOrdersPage() {
onClick={() => setStatusFilter(s)}
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
statusFilter === s
? 'bg-slate-600 text-white'
: 'text-slate-400 hover:text-white hover:bg-slate-800'
? 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'
}`}
>
{s === '' ? 'Todas' : STATUS_LABELS[s]}
{s === '' ? 'Todas' : WORK_ORDER_STATUS_LABEL[s as WorkOrder['status']]}
</button>
))}
</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 Ordem de Trabalho</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 Ordem de Trabalho</h2>
<form onSubmit={handleSubmit((d) => create.mutate(d))} className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label htmlFor="client_id">Cliente</Label>
<select
id="client_id"
{...register('client_id')}
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'
}`}
>
<option value=""> Sem cliente </option>
{clients.map((c) => (
@@ -124,7 +140,9 @@ export default function WorkOrdersPage() {
<select
id="vehicle_id"
{...register('vehicle_id')}
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'
}`}
disabled={!selectedClientId}
>
<option value=""> Sem veículo </option>
@@ -135,11 +153,40 @@ export default function WorkOrdersPage() {
</div>
<div className="col-span-2 space-y-1">
<Label htmlFor="internal_notes">Notas internas</Label>
<Input id="internal_notes" {...register('internal_notes')} className="bg-slate-900 border-slate-600 text-white" />
<Input
id="internal_notes"
{...register('internal_notes')}
className={isLight ? 'bg-white border-slate-300 text-slate-900' : 'bg-slate-900 border-slate-600 text-white'}
/>
</div>
<div className="col-span-2 space-y-1">
<Label htmlFor="client_notes">Observações</Label>
<Input
id="client_notes"
{...register('client_notes')}
className={isLight ? 'bg-white border-slate-300 text-slate-900' : 'bg-slate-900 border-slate-600 text-white'}
/>
</div>
<div className="space-y-1">
<Label htmlFor="eta_days">Previsão de conclusão</Label>
<select
id="eta_days"
{...register('eta_days')}
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'
}`}
>
{ETA_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
{create.error && (
<p className="col-span-2 text-red-400 text-sm">{create.error.message}</p>
)}
{docError && (
<p className="col-span-2 text-amber-300 text-sm">{docError}</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}>
@@ -151,36 +198,51 @@ export default function WorkOrdersPage() {
)}
{isLoading ? (
<p className="text-slate-400">A carregar...</p>
<p className={isLight ? 'text-slate-700' : 'text-slate-400'}>A carregar...</p>
) : orders.length === 0 ? (
<p className="text-slate-500 text-sm">Nenhuma ordem de trabalho.</p>
<p className={`text-sm ${isLight ? 'text-slate-700' : 'text-slate-500'}`}>Nenhuma ordem de trabalho.</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"></th>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Estado</th>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Criada</th>
<th className={`text-left px-4 py-3 font-medium ${isLight ? 'text-slate-800' : 'text-slate-400'}`}></th>
<th className={`text-left px-4 py-3 font-medium ${isLight ? 'text-slate-800' : 'text-slate-400'}`}>Cliente</th>
<th className={`text-left px-4 py-3 font-medium ${isLight ? 'text-slate-800' : 'text-slate-400'}`}>Estado</th>
<th className={`text-left px-4 py-3 font-medium ${isLight ? 'text-slate-800' : 'text-slate-400'}`}>Previsão</th>
<th className={`text-left px-4 py-3 font-medium ${isLight ? 'text-slate-800' : 'text-slate-400'}`}>Prazo real</th>
<th className={`text-left px-4 py-3 font-medium ${isLight ? 'text-slate-800' : 'text-slate-400'}`}>Criada</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{orders.map((o) => (
<tr key={o.id} className="border-t border-slate-700 hover:bg-slate-800/50">
<td className="px-4 py-3 text-white font-mono">#{o.number}</td>
<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'}`}
>
<td className={`px-4 py-3 font-mono ${isLight ? 'text-slate-900' : 'text-white'}`}>#{o.number}</td>
<td className={`px-4 py-3 ${isLight ? 'text-slate-900' : 'text-slate-300'}`}>
{o.client_id ? (clientNameById[o.client_id] ?? '—') : '—'}
</td>
<td className="px-4 py-3">
<Badge variant={STATUS_VARIANT[o.status]}>
{STATUS_LABELS[o.status]}
<Badge variant="outline" className={workOrderStatusBadgeClass(o.status)}>
{WORK_ORDER_STATUS_LABEL[o.status]}
</Badge>
</td>
<td className="px-4 py-3 text-slate-400">
<td className={`px-4 py-3 ${isLight ? 'text-slate-800' : 'text-slate-300'}`}>{ETA_LABEL[o.eta_days] ?? `${o.eta_days} dias`}</td>
<td className={`px-4 py-3 ${isLight ? 'text-slate-800' : 'text-slate-300'}`}>
{o.status !== 'quote' && o.real_deadline
? new Intl.DateTimeFormat('pt-PT').format(new Date(o.real_deadline))
: '—'}
</td>
<td className={`px-4 py-3 ${isLight ? 'text-slate-800' : 'text-slate-400'}`}>
{new Intl.DateTimeFormat('pt-PT').format(new Date(o.created_at))}
</td>
<td className="px-4 py-3 text-right">
<Link
to={`/app/work-orders/${o.id}`}
className="text-xs text-blue-400 hover:text-blue-300"
className={`text-xs ${isLight ? 'text-blue-700 hover:text-blue-800' : 'text-blue-400 hover:text-blue-300'}`}
>
Ver detalhe
</Link>
+38 -10
View File
@@ -2,10 +2,13 @@ import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useLogin } from '@/hooks/useAuth'
import { apiFetch } from '@/lib/api'
import type { PlatformSettings } from '@/lib/types'
const schema = z.object({
email: z.string().email('Email inválido'),
@@ -16,7 +19,13 @@ type FormData = z.infer<typeof schema>
export default function LoginPage() {
const [showSlug, setShowSlug] = useState(false)
const [logoBroken, setLogoBroken] = useState(false)
const { mutate: login, isPending, error } = useLogin()
const { data: platform } = useQuery<PlatformSettings>({
queryKey: ['platform', 'settings'],
queryFn: () => apiFetch<PlatformSettings>('/platform/settings'),
retry: false,
})
const {
register,
@@ -31,58 +40,77 @@ export default function LoginPage() {
tenant_slug: data.tenant_slug || undefined,
})
}
const primary = platform?.admin_primary_color || '#0f3b47'
const accent = platform?.admin_accent_color || '#06b6d4'
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-sm">
<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">Gestão de 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 h-14 w-14 rounded-md flex items-center justify-center bg-gray-200 text-gray-700 font-semibold">
TX
</div>
)}
<h1 className="text-3xl font-bold text-slate-100">{platform?.platform_name || 'TechXCar'}</h1>
<p className="text-slate-300 mt-1 text-sm">{platform?.platform_subtitle || 'Gestão de Oficina'}</p>
</div>
<form
onSubmit={handleSubmit(onSubmit)}
className="bg-white p-8 rounded-lg shadow-sm border border-gray-200 space-y-4"
className="bg-slate-50 p-8 rounded-xl shadow-xl border border-slate-200/90 space-y-4"
>
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" autoComplete="email" {...register('email')} />
<Label htmlFor="email" className="text-slate-700">Email</Label>
<Input id="email" type="email" autoComplete="email" {...register('email')} className="border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" />
{errors.email && <p className="text-red-500 text-xs">{errors.email.message}</p>}
</div>
<div className="space-y-1.5">
<Label htmlFor="password">Password</Label>
<Label htmlFor="password" className="text-slate-700">Password</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
{...register('password')}
className="border-slate-300 bg-white text-slate-900 placeholder:text-slate-400"
/>
{errors.password && <p className="text-red-500 text-xs">{errors.password.message}</p>}
</div>
{showSlug && (
<div className="space-y-1.5">
<Label htmlFor="tenant_slug">Workspace (slug da oficina)</Label>
<Label htmlFor="tenant_slug" className="text-slate-700">Workspace (slug da oficina)</Label>
<Input
id="tenant_slug"
type="text"
placeholder="minha-oficina"
{...register('tenant_slug')}
className="border-slate-300 bg-white text-slate-900 placeholder:text-slate-400"
/>
</div>
)}
{error && <p className="text-red-500 text-sm text-center">{error.message}</p>}
<Button type="submit" className="w-full" disabled={isPending}>
<Button type="submit" className="w-full" disabled={isPending} style={{ backgroundColor: primary, color: '#e6fffb' }}>
{isPending ? 'A entrar...' : 'Entrar'}
</Button>
<button
type="button"
onClick={() => setShowSlug((v) => !v)}
className="w-full text-xs text-gray-400 hover:text-gray-600 text-center"
className="w-full text-xs text-slate-500 hover:text-slate-700 text-center"
style={{ color: accent }}
>
{showSlug ? 'Ocultar campo workspace' : 'Entrar numa oficina específica'}
</button>
+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/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/lib/api.ts","./src/lib/queryClient.ts","./src/lib/types.ts","./src/lib/utils.ts","./src/pages/admin/DashboardPage.tsx","./src/pages/admin/TenantsPage.tsx","./src/pages/app/CatalogPage.tsx","./src/pages/app/ClientsPage.tsx","./src/pages/app/DashboardPage.tsx","./src/pages/app/ExpensesPage.tsx","./src/pages/app/InvoicesPage.tsx","./src/pages/app/SettingsPage.tsx","./src/pages/app/StaffPage.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/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"}