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