This commit is contained in:
Luciano Milani
2026-07-02 12:47:55 +01:00
commit 5de37bb512
132 changed files with 28495 additions and 0 deletions
+184
View File
@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+85
View File
@@ -0,0 +1,85 @@
import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router'
import { QueryClientProvider } from '@tanstack/react-query'
import { queryClient } from '@/lib/queryClient'
import { useAuthStore } from '@/store/authStore'
import type { UserRole } from '@/store/authStore'
import LoginPage from '@/pages/auth/LoginPage'
import AppDashboardPage from '@/pages/app/DashboardPage'
import ClientsPage from '@/pages/app/ClientsPage'
import CatalogPage from '@/pages/app/CatalogPage'
import WorkOrdersPage from '@/pages/app/WorkOrdersPage'
import WorkOrderDetailPage from '@/pages/app/WorkOrderDetailPage'
import StaffPage from '@/pages/app/StaffPage'
import ExpensesPage from '@/pages/app/ExpensesPage'
import SettingsPage from '@/pages/app/SettingsPage'
import InvoicesPage from '@/pages/app/InvoicesPage'
import AdminDashboardPage from '@/pages/admin/DashboardPage'
import TenantsPage from '@/pages/admin/TenantsPage'
import InviteRedeemPage from '@/pages/public/InviteRedeemPage'
import AppLayout from '@/components/layout/AppLayout'
import AdminLayout from '@/components/layout/AdminLayout'
function RequireAuth({
children,
allowedRoles,
}: {
children: React.ReactNode
allowedRoles: UserRole[]
}) {
const { isAuthenticated, user } = useAuthStore()
const location = useLocation()
if (!isAuthenticated) return <Navigate to="/login" replace />
// Allow super_admin carrying an impersonation payload through to /app —
// AppLayout applies the role switch on mount via useLayoutEffect.
const hasImpersonation = !!location.state?.impersonation
if (user && !allowedRoles.includes(user.role) && !hasImpersonation) {
return <Navigate to="/login" replace />
}
return <>{children}</>
}
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/invite/:token" element={<InviteRedeemPage />} />
<Route
path="/admin"
element={
<RequireAuth allowedRoles={['super_admin']}>
<AdminLayout />
</RequireAuth>
}
>
<Route index element={<AdminDashboardPage />} />
<Route path="tenants" element={<TenantsPage />} />
</Route>
<Route
path="/app"
element={
<RequireAuth allowedRoles={['tenant_admin', 'manager', 'technician']}>
<AppLayout />
</RequireAuth>
}
>
<Route index element={<AppDashboardPage />} />
<Route path="clients" element={<ClientsPage />} />
<Route path="catalog" element={<CatalogPage />} />
<Route path="work-orders" element={<WorkOrdersPage />} />
<Route path="work-orders/:id" element={<WorkOrderDetailPage />} />
<Route path="staff" element={<StaffPage />} />
<Route path="expenses" element={<ExpensesPage />} />
<Route path="invoices" element={<InvoicesPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
<Route path="/" element={<Navigate to="/app" replace />} />
</Routes>
</BrowserRouter>
</QueryClientProvider>
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -0,0 +1,55 @@
import { Outlet, NavLink } from 'react-router'
import { useLogout } from '@/hooks/useAuth'
export default function AdminLayout() {
const { mutate: logout } = useLogout()
return (
<div className="flex h-screen bg-slate-950">
<aside className="w-64 bg-slate-900 border-r border-slate-800 flex flex-col">
<div className="p-4 border-b border-slate-800">
<h1 className="text-lg font-bold text-white">TechXCar</h1>
<p className="text-xs text-slate-400 mt-0.5">Administração</p>
</div>
<nav className="flex-1 p-3 space-y-1">
<NavLink
to="/admin"
end
className={({ isActive }) =>
`flex items-center px-3 py-2 rounded-md text-sm transition-colors ${
isActive
? 'bg-slate-700 text-white'
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
}`
}
>
Dashboard
</NavLink>
<NavLink
to="/admin/tenants"
className={({ isActive }) =>
`flex items-center px-3 py-2 rounded-md text-sm transition-colors ${
isActive
? 'bg-slate-700 text-white'
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
}`
}
>
Oficinas
</NavLink>
</nav>
<div className="p-3 border-t border-slate-800">
<button
onClick={() => logout()}
className="w-full text-left px-3 py-2 text-sm text-slate-400 hover:text-white rounded-md hover:bg-slate-800 transition-colors"
>
Terminar sessão
</button>
</div>
</aside>
<main className="flex-1 overflow-auto p-6 text-white">
<Outlet />
</main>
</div>
)
}
@@ -0,0 +1,93 @@
import { useLayoutEffect } from 'react'
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router'
import { useLogout } from '@/hooks/useAuth'
import { useAuthStore } from '@/store/authStore'
import { queryClient } from '@/lib/queryClient'
const nav = [
{ to: '/app', label: 'Dashboard', end: true },
{ to: '/app/work-orders', label: 'Ordens de Trabalho' },
{ to: '/app/clients', label: 'Clientes' },
{ to: '/app/catalog', label: 'Catálogo' },
{ to: '/app/staff', label: 'Técnicos' },
{ to: '/app/expenses', label: 'Despesas' },
{ to: '/app/invoices', label: 'Faturação' },
{ to: '/app/settings', label: 'Definições' },
]
export default function AppLayout() {
const { mutate: logout } = useLogout()
const { previousSession, restoreSession, user, impersonateTenant } = useAuthStore()
const navigate = useNavigate()
const location = useLocation()
useLayoutEffect(() => {
const imp = location.state?.impersonation
if (imp) {
impersonateTenant(imp.token, imp.user)
queryClient.clear()
// Remove impersonation payload from history state so back/forward doesn't re-apply it
navigate(location.pathname, { replace: true, state: {} })
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
function handleRestore() {
restoreSession()
navigate('/admin')
}
return (
<div className="flex flex-col h-screen bg-slate-950">
{previousSession && (
<div className="flex items-center justify-between px-4 py-2 bg-amber-500 text-amber-950 text-sm font-medium shrink-0">
<span>
TechXCar Admin a gerir: <strong>{user?.name}</strong>
</span>
<button
onClick={handleRestore}
className="px-3 py-1 rounded bg-amber-950 text-amber-100 hover:bg-amber-900 text-xs font-semibold transition-colors"
>
Voltar ao painel
</button>
</div>
)}
<div className="flex flex-1 overflow-hidden">
<aside className="w-64 bg-slate-900 border-r border-slate-800 flex flex-col">
<div className="p-4 border-b border-slate-800">
<h1 className="text-lg font-bold text-white">TechXCar</h1>
<p className="text-xs text-slate-400 mt-0.5">Gestão de Oficina</p>
</div>
<nav className="flex-1 p-3 space-y-1">
{nav.map(({ to, label, end }) => (
<NavLink
key={to}
to={to}
end={end}
className={({ isActive }) =>
`flex items-center px-3 py-2 rounded-md text-sm transition-colors ${
isActive
? 'bg-slate-700 text-white'
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
}`
}
>
{label}
</NavLink>
))}
</nav>
<div className="p-3 border-t border-slate-800">
<button
onClick={() => logout()}
className="w-full text-left px-3 py-2 text-sm text-slate-400 hover:text-white rounded-md hover:bg-slate-800 transition-colors"
>
Terminar sessão
</button>
</div>
</aside>
<main className="flex-1 overflow-auto p-6 text-white">
<Outlet />
</main>
</div>
</div>
)
}
+32
View File
@@ -0,0 +1,32 @@
import * as React from 'react'
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',
{
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',
},
},
defaultVariants: {
variant: 'default',
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}
export { Badge, badgeVariants }
+52
View File
@@ -0,0 +1,52 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
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',
{
variants: {
variant: {
default: 'bg-blue-600 text-white shadow hover:bg-blue-700',
destructive: 'bg-red-600 text-white shadow-sm hover:bg-red-700',
outline: 'border border-gray-300 bg-white shadow-sm hover:bg-gray-50 text-gray-900',
secondary: 'bg-gray-100 text-gray-900 shadow-sm hover:bg-gray-200',
ghost: 'hover:bg-gray-100 text-gray-700',
link: 'text-blue-600 underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }
+76
View File
@@ -0,0 +1,76 @@
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<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',
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<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%]',
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" />
<span className="sr-only">Fechar</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-1.5', className)} {...props} />
)
DialogHeader.displayName = 'DialogHeader'
const DialogTitle = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold text-white', className)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
export {
Dialog,
DialogTrigger,
DialogPortal,
DialogOverlay,
DialogClose,
DialogContent,
DialogHeader,
DialogTitle,
}
+23
View File
@@ -0,0 +1,23 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-gray-300 bg-white px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = 'Input'
export { Input }
+17
View File
@@ -0,0 +1,17 @@
import * as React from 'react'
import * as LabelPrimitive from '@radix-ui/react-label'
import { cn } from '@/lib/utils'
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn('text-sm font-medium text-gray-700 leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
+49
View File
@@ -0,0 +1,49 @@
import { useMutation } from '@tanstack/react-query'
import { useNavigate } from 'react-router'
import { apiFetch } from '@/lib/api'
import { useAuthStore, type AuthUser } from '@/store/authStore'
interface LoginRequest {
email: string
password: string
tenant_slug?: string
}
interface LoginResponse {
access_token: string
user: AuthUser
}
export function useLogin() {
const { setAuth } = useAuthStore()
const navigate = useNavigate()
return useMutation({
mutationFn: (data: LoginRequest) =>
apiFetch<LoginResponse>('/auth/login', {
method: 'POST',
body: JSON.stringify(data),
}),
onSuccess: (data) => {
setAuth(data.user, data.access_token)
if (data.user.role === 'super_admin') {
navigate('/admin', { replace: true })
} else {
navigate('/app', { replace: true })
}
},
})
}
export function useLogout() {
const { clearAuth } = useAuthStore()
const navigate = useNavigate()
return useMutation({
mutationFn: () => apiFetch('/auth/logout', { method: 'POST' }),
onSettled: () => {
clearAuth()
navigate('/login', { replace: true })
},
})
}
+45
View File
@@ -0,0 +1,45 @@
import { vi, describe, it, expect, beforeEach } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import React from 'react'
import { useClients, useClientVehicles } from './useClients'
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }))
vi.mock('@/lib/queryClient', () => ({ queryClient: new QueryClient() }))
import { apiFetch } from '@/lib/api'
const mockFetch = apiFetch as ReturnType<typeof vi.fn>
function makeWrapper() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const Wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: qc }, children)
return Wrapper
}
describe('useClients', () => {
beforeEach(() => vi.clearAllMocks())
it('calls GET /clients', async () => {
mockFetch.mockResolvedValue([])
const { result } = renderHook(() => useClients(), { wrapper: makeWrapper() })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(mockFetch).toHaveBeenCalledWith('/clients')
})
})
describe('useClientVehicles', () => {
beforeEach(() => vi.clearAllMocks())
it('calls GET /clients/:id/vehicles when clientId provided', async () => {
mockFetch.mockResolvedValue([])
const { result } = renderHook(() => useClientVehicles('abc-123'), { wrapper: makeWrapper() })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(mockFetch).toHaveBeenCalledWith('/clients/abc-123/vehicles')
})
it('does not fetch when clientId is empty', () => {
const { result } = renderHook(() => useClientVehicles(''), { wrapper: makeWrapper() })
expect(result.current.fetchStatus).toBe('idle')
})
})
+80
View File
@@ -0,0 +1,80 @@
import { useQuery, useMutation } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api'
import { queryClient } from '@/lib/queryClient'
import type { Client, Vehicle } from '@/lib/types'
export type ClientPayload = {
name: string; nif: string; phone: string; email: string; address: string; notes: string
}
export type ClientUpdatePayload = ClientPayload & { id: string }
export type VehiclePayload = {
plate: string; brand: string; model: string; year: number; vin: string; mileage: number; notes: string
}
export type VehicleUpdatePayload = VehiclePayload & { id: string }
export function useClients() {
return useQuery<Client[]>({
queryKey: ['clients'],
queryFn: () => apiFetch<Client[]>('/clients'),
})
}
export function useClient(id: string) {
return useQuery<Client>({
queryKey: ['clients', id],
queryFn: () => apiFetch<Client>(`/clients/${id}`),
enabled: !!id,
})
}
export function useCreateClient() {
return useMutation({
mutationFn: (data: ClientPayload) =>
apiFetch<Client>('/clients', { method: 'POST', body: JSON.stringify(data) }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['clients'] }),
})
}
export function useUpdateClient() {
return useMutation({
mutationFn: ({ id, ...data }: ClientUpdatePayload) =>
apiFetch<Client>(`/clients/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
onSuccess: (_result, { id }) => {
queryClient.invalidateQueries({ queryKey: ['clients'] })
queryClient.invalidateQueries({ queryKey: ['clients', id] })
},
})
}
export function useDeleteClient() {
return useMutation({
mutationFn: (id: string) => apiFetch(`/clients/${id}`, { method: 'DELETE' }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['clients'] }),
})
}
export function useClientVehicles(clientId: string) {
return useQuery<Vehicle[]>({
queryKey: ['clients', clientId, 'vehicles'],
queryFn: () => apiFetch<Vehicle[]>(`/clients/${clientId}/vehicles`),
enabled: !!clientId,
})
}
export function useCreateVehicle(clientId: string) {
return useMutation({
mutationFn: (data: VehiclePayload) =>
apiFetch<Vehicle>(`/clients/${clientId}/vehicles`, { method: 'POST', body: JSON.stringify(data) }),
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ['clients', clientId, 'vehicles'] }),
})
}
export function useUpdateVehicle(clientId: string) {
return useMutation({
mutationFn: ({ id, ...data }: VehicleUpdatePayload) =>
apiFetch<Vehicle>(`/vehicles/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ['clients', clientId, 'vehicles'] }),
})
}
+5
View File
@@ -0,0 +1,5 @@
@import "tailwindcss";
:root {
--radius: 0.5rem;
}
+77
View File
@@ -0,0 +1,77 @@
import { useAuthStore } from '@/store/authStore'
const BASE_URL = '/api/v1'
export class ApiError extends Error {
constructor(
public status: number,
message: string
) {
super(message)
this.name = 'ApiError'
}
}
async function refreshAccessToken(): Promise<string | null> {
try {
const res = await fetch(`${BASE_URL}/auth/refresh`, {
method: 'POST',
credentials: 'include',
})
if (!res.ok) return null
const data = await res.json()
useAuthStore.getState().updateToken(data.data.access_token)
return data.data.access_token
} catch {
return null
}
}
export async function apiFetch<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
const { accessToken, clearAuth } = useAuthStore.getState()
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
}
if (accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`
}
let res = await fetch(`${BASE_URL}${path}`, {
...options,
headers,
credentials: 'include',
})
if (res.status === 401 && accessToken) {
const newToken = await refreshAccessToken()
if (newToken) {
headers['Authorization'] = `Bearer ${newToken}`
res = await fetch(`${BASE_URL}${path}`, {
...options,
headers,
credentials: 'include',
})
} else {
clearAuth()
throw new ApiError(401, 'Sessão expirada. Por favor inicie sessão novamente.')
}
}
if (res.status === 204) {
return undefined as T
}
const json = await res.json()
if (!res.ok) {
throw new ApiError(res.status, json.error ?? 'Erro desconhecido')
}
return json.data as T
}
+17
View File
@@ -0,0 +1,17 @@
import { QueryClient } from '@tanstack/react-query'
import { ApiError } from './api'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5,
retry: (failureCount, error) => {
if (error instanceof ApiError && error.status < 500) return false
return failureCount < 2
},
},
mutations: {
retry: false,
},
},
})
+127
View File
@@ -0,0 +1,127 @@
export interface Tenant {
id: string
slug: string
name: string
status: 'active' | 'suspended' | 'pending'
created_at: string
}
export interface Invite {
id: string
token: string
tenant_id: string | null
expires_at: string
used_at: string | null
created_at: string
}
export interface Client {
id: string
name: string
nif: string
phone: string
email: string
address: string
notes: string
created_at: string
updated_at: string
}
export interface Vehicle {
id: string
client_id: string | null
plate: string
brand: string
model: string
year: number | null
vin: string
mileage: number | null
fuel_type: string
notes: string
created_at: string
updated_at: string
}
export interface CatalogItem {
id: string
code: string
name: string
category: string
unit: 'un' | 'hora' | 'litro' | 'kg'
base_price: number
active: boolean
created_at: string
updated_at: string
}
export interface WorkOrder {
id: string
number: number
client_id: string | null
vehicle_id: string | null
status: 'open' | 'in_progress' | 'completed' | 'invoiced' | 'cancelled'
internal_notes: string
client_notes: string
created_by: string | null
created_at: string
updated_at: string
}
export interface WOItem {
id: string
work_order_id: string
catalog_item_id: string | null
description: string
qty: number
unit_price: number
discount_pct: number
total: number
}
export interface WOStaffHours {
id: string
work_order_id: string
staff_id: string
hours: number
cost_per_hour: number
total: number
}
export interface WorkOrderDetail extends WorkOrder {
items: WOItem[]
staff_hours: WOStaffHours[]
}
export interface Staff {
id: string
user_id: string | null
name: string
email: string
phone: string
type: 'internal' | 'external'
hourly_rate: number
active: boolean
created_at: string
}
export interface Expense {
id: string
vehicle_id: string | null
type: 'fuel' | 'parts' | 'tools' | 'other'
amount: number
description: string
date: string
created_at: string
}
export type TenantSettings = Record<string, string>
export interface Invoice {
id: string
work_order_id: string
type: 'quote' | 'invoice'
number: number
pdf_path: string
issued_at: string
created_at: string
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
@@ -0,0 +1,8 @@
export default function AdminDashboardPage() {
return (
<div>
<h1 className="text-2xl font-bold">Painel de Administração</h1>
<p className="text-slate-400 mt-1 text-sm">Gestão da plataforma implementado no Plano 2</p>
</div>
)
}
+259
View File
@@ -0,0 +1,259 @@
import { useState } from 'react'
import { useNavigate } from 'react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api'
import type { AuthUser } from '@/store/authStore'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import type { Tenant, Invite } from '@/lib/types'
function toSlug(name: string) {
return name
.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
export default function TenantsPage() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const [inviteToken, setInviteToken] = useState<string | null>(null)
const [showCreate, setShowCreate] = useState(false)
const [createForm, setCreateForm] = useState({
name: '', slug: '', admin_name: '', admin_email: '', admin_password: '',
})
const [createError, setCreateError] = useState('')
const { data: tenants = [], isLoading } = useQuery<Tenant[]>({
queryKey: ['admin', 'tenants'],
queryFn: () => apiFetch<Tenant[]>('/admin/tenants'),
})
const generateInvite = useMutation({
mutationFn: () => apiFetch<Invite>('/admin/invites', { method: 'POST' }),
onSuccess: (invite) => {
setInviteToken(invite.token)
queryClient.invalidateQueries({ queryKey: ['admin', 'tenants'] })
},
})
const accessTenant = useMutation({
mutationFn: (tenantId: string) =>
apiFetch<{ access_token: string; tenant: { id: string; name: string; slug: string } }>(
`/admin/tenants/${tenantId}/access`,
{ method: 'POST' }
),
onSuccess: (data) => {
// Navigate first — auth state change happens in AppLayout after mount.
// If impersonateTenant were called here, the admin RequireAuth would
// see role='tenant_admin', reject it, and redirect to /login before
// navigate('/app') takes effect.
navigate('/app', {
state: {
impersonation: {
token: data.access_token,
user: {
id: data.tenant.id,
email: '',
name: data.tenant.name,
role: 'tenant_admin' as const,
tenantId: data.tenant.id,
} satisfies AuthUser,
},
},
})
},
onError: (err: Error) => {
console.error('Erro ao aceder à oficina:', err.message)
},
})
const createTenant = useMutation({
mutationFn: (body: typeof createForm) =>
apiFetch<Tenant>('/admin/tenants', { method: 'POST', body: JSON.stringify(body) }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'tenants'] })
setShowCreate(false)
setCreateForm({ name: '', slug: '', admin_name: '', admin_email: '', admin_password: '' })
setCreateError('')
},
onError: (err: Error) => setCreateError(err.message),
})
const inviteUrl = inviteToken ? `${window.location.origin}/invite/${inviteToken}` : null
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-white">Oficinas</h1>
<p className="text-slate-400 text-sm mt-0.5">{tenants.length} oficinas registadas</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => generateInvite.mutate()} disabled={generateInvite.isPending}>
{generateInvite.isPending ? 'A gerar...' : 'Gerar Convite'}
</Button>
<Button onClick={() => { setShowCreate(true); setCreateError('') }}>
Criar Oficina
</Button>
</div>
</div>
{showCreate && (
<div className="mb-6 bg-slate-800 rounded-lg border border-slate-700 p-6">
<h2 className="text-lg font-semibold text-white mb-4">Nova Oficina</h2>
<form
onSubmit={(e) => {
e.preventDefault()
setCreateError('')
createTenant.mutate(createForm)
}}
className="grid grid-cols-2 gap-4"
>
<div className="space-y-1">
<Label>Nome da oficina *</Label>
<Input
value={createForm.name}
onChange={(e) => setCreateForm(f => ({
...f, name: e.target.value, slug: toSlug(e.target.value),
}))}
className="bg-slate-900 border-slate-600 text-white"
required
/>
</div>
<div className="space-y-1">
<Label>Slug *</Label>
<Input
value={createForm.slug}
onChange={(e) => setCreateForm(f => ({ ...f, slug: e.target.value }))}
className="bg-slate-900 border-slate-600 text-white font-mono text-sm"
required
/>
</div>
<div className="space-y-1">
<Label>Nome do admin *</Label>
<Input
value={createForm.admin_name}
onChange={(e) => setCreateForm(f => ({ ...f, admin_name: e.target.value }))}
className="bg-slate-900 border-slate-600 text-white"
required
/>
</div>
<div className="space-y-1">
<Label>Email do admin *</Label>
<Input
type="email"
value={createForm.admin_email}
onChange={(e) => setCreateForm(f => ({ ...f, admin_email: e.target.value }))}
className="bg-slate-900 border-slate-600 text-white"
required
/>
</div>
<div className="col-span-2 space-y-1">
<Label>Password do admin *</Label>
<Input
type="password"
value={createForm.admin_password}
onChange={(e) => setCreateForm(f => ({ ...f, admin_password: e.target.value }))}
className="bg-slate-900 border-slate-600 text-white"
required
/>
</div>
{createError && <p className="col-span-2 text-red-400 text-sm">{createError}</p>}
<div className="col-span-2 flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={() => setShowCreate(false)}>
Cancelar
</Button>
<Button type="submit" disabled={createTenant.isPending}>
{createTenant.isPending ? 'A criar...' : 'Criar Oficina'}
</Button>
</div>
</form>
</div>
)}
{inviteUrl && (
<div className="mb-6 p-4 bg-slate-800 rounded-lg border border-slate-700">
<p className="text-slate-300 text-sm font-medium mb-2">Link de convite (válido 72h):</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs text-green-400 bg-slate-900 px-3 py-2 rounded break-all">
{inviteUrl}
</code>
<Button
size="sm"
variant="outline"
onClick={() => navigator.clipboard.writeText(inviteUrl)}
>
Copiar
</Button>
</div>
<button
onClick={() => setInviteToken(null)}
className="text-xs text-slate-500 hover:text-slate-400 mt-2"
>
Fechar
</button>
</div>
)}
{isLoading ? (
<p className="text-slate-400">A carregar...</p>
) : tenants.length === 0 ? (
<p className="text-slate-500 text-sm">Nenhuma oficina registada.</p>
) : (
<div className="rounded-lg border border-slate-700 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-800">
<tr>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Nome</th>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Slug</th>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Estado</th>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Criada</th>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Ações</th>
</tr>
</thead>
<tbody>
{tenants.map((t) => (
<tr key={t.id} className="border-t border-slate-700 hover:bg-slate-800/50">
<td className="px-4 py-3 text-white font-medium">{t.name}</td>
<td className="px-4 py-3 text-slate-400 font-mono text-xs">{t.slug}</td>
<td className="px-4 py-3">
<Badge
variant={
t.status === 'active'
? 'default'
: t.status === 'suspended'
? 'destructive'
: 'secondary'
}
>
{t.status}
</Badge>
</td>
<td className="px-4 py-3 text-slate-400">
{new Intl.DateTimeFormat('pt-PT').format(new Date(t.created_at))}
</td>
<td className="px-4 py-3">
<Button
size="sm"
variant="outline"
onClick={() => accessTenant.mutate(t.id)}
disabled={accessTenant.isPending}
>
{accessTenant.isPending ? '...' : 'Gerir'}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
+217
View File
@@ -0,0 +1,217 @@
import { 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 { apiFetch } from '@/lib/api'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import type { CatalogItem } from '@/lib/types'
const UNITS = ['un', 'hora', 'litro', 'kg'] as const
const CATEGORIES = [
{ value: 'mao_de_obra', label: 'Mão de Obra' },
{ value: 'diagnostico', label: 'Diagnóstico' },
{ value: 'manutencao', label: 'Manutenção' },
{ value: 'pecas_mecanicas', label: 'Peças Mecânicas' },
{ value: 'pneus_jantes', label: 'Pneus / Jantes' },
{ value: 'eletrica', label: 'Elétrica / Eletrónica' },
{ value: 'fluidos', label: 'Fluidos / Óleos' },
{ value: 'ar_condicionado', label: 'Ar Condicionado' },
{ value: 'carrocaria', label: 'Carroçaria / Pintura' },
{ value: 'consumiveis', label: 'Consumíveis' },
{ value: 'acessorios', label: 'Acessórios' },
{ value: 'outro', label: 'Outro' },
] as const
const CATEGORY_LABEL: Record<string, string> = Object.fromEntries(
CATEGORIES.map(c => [c.value, c.label])
)
const UNITS_LABEL: Record<string, string> = {
un: 'un', hora: 'hora', litro: 'L', kg: 'kg',
}
const schema = z.object({
code: z.string().min(1, 'Código obrigatório'),
name: z.string().min(1, 'Nome obrigatório'),
category: z.string().min(1, 'Categoria obrigatória'),
unit: z.enum(UNITS),
base_price: z.coerce.number().min(0),
active: z.boolean(),
})
type FormData = z.infer<typeof schema>
const emptyItem: FormData = { code: '', name: '', category: 'mao_de_obra', unit: 'un', base_price: 0, active: true }
export default function CatalogPage() {
const qc = useQueryClient()
const [editing, setEditing] = useState<CatalogItem | null>(null)
const [showForm, setShowForm] = useState(false)
const { data: items = [], isLoading } = useQuery<CatalogItem[]>({
queryKey: ['catalog'],
queryFn: () => apiFetch<CatalogItem[]>('/catalog'),
})
const { register, handleSubmit, reset, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema) as Resolver<FormData>,
defaultValues: emptyItem,
})
const save = useMutation({
mutationFn: (data: FormData) =>
editing
? apiFetch<CatalogItem>(`/catalog/${editing.id}`, { method: 'PUT', body: JSON.stringify(data) })
: apiFetch<CatalogItem>('/catalog', { method: 'POST', body: JSON.stringify(data) }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['catalog'] })
setShowForm(false)
setEditing(null)
reset()
},
})
const remove = useMutation({
mutationFn: (id: string) => apiFetch(`/catalog/${id}`, { method: 'DELETE' }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['catalog'] }),
})
function openNew() {
setEditing(null)
reset(emptyItem)
setShowForm(true)
}
function openEdit(item: CatalogItem) {
setEditing(item)
reset({ code: item.code, name: item.name, category: item.category, unit: item.unit, base_price: item.base_price, active: item.active })
setShowForm(true)
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-white">Catálogo</h1>
<p className="text-slate-400 text-sm mt-0.5">{items.length} itens</p>
</div>
<Button onClick={openNew}>Novo Item</Button>
</div>
{showForm && (
<div className="mb-6 bg-slate-800 rounded-lg border border-slate-700 p-6">
<h2 className="text-lg font-semibold text-white mb-4">
{editing ? 'Editar Item' : 'Novo Item'}
</h2>
<form onSubmit={handleSubmit((d) => save.mutate(d))} className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label htmlFor="code">Código *</Label>
<Input id="code" {...register('code')} className="bg-slate-900 border-slate-600 text-white" />
{errors.code && <p className="text-red-400 text-xs">{errors.code.message}</p>}
</div>
<div className="space-y-1">
<Label htmlFor="name">Nome *</Label>
<Input id="name" {...register('name')} className="bg-slate-900 border-slate-600 text-white" />
{errors.name && <p className="text-red-400 text-xs">{errors.name.message}</p>}
</div>
<div className="space-y-1">
<Label htmlFor="category">Categoria *</Label>
<select
id="category"
{...register('category')}
className="w-full rounded-md border border-slate-600 bg-slate-900 text-white px-3 py-2 text-sm"
>
{CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
</select>
{errors.category && <p className="text-red-400 text-xs">{errors.category.message}</p>}
</div>
<div className="space-y-1">
<Label htmlFor="unit">Unidade *</Label>
<select
id="unit"
{...register('unit')}
className="w-full rounded-md border border-slate-600 bg-slate-900 text-white px-3 py-2 text-sm"
>
<option value="un">Unidade (un)</option>
<option value="hora">Hora</option>
<option value="litro">Litro (L)</option>
<option value="kg">Quilograma (kg)</option>
</select>
</div>
<div className="space-y-1">
<Label htmlFor="base_price">Preço Base ()</Label>
<Input id="base_price" type="number" step="0.01" {...register('base_price')} className="bg-slate-900 border-slate-600 text-white" />
</div>
<div className="flex items-center gap-2 pt-6">
<input type="checkbox" id="active" {...register('active')} className="h-4 w-4" />
<Label htmlFor="active">Activo</Label>
</div>
{save.error && (
<p className="col-span-2 text-red-400 text-sm">{save.error.message}</p>
)}
<div className="col-span-2 flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={() => { setShowForm(false); setEditing(null) }}>
Cancelar
</Button>
<Button type="submit" disabled={save.isPending}>
{save.isPending ? 'A guardar...' : 'Guardar'}
</Button>
</div>
</form>
</div>
)}
{isLoading ? (
<p className="text-slate-400">A carregar...</p>
) : items.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">
<table className="w-full text-sm">
<thead className="bg-slate-800">
<tr>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Código</th>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Nome</th>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Categoria</th>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Un.</th>
<th className="text-right px-4 py-3 text-slate-400 font-medium">Preço</th>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Estado</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{items.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>
<td className="px-4 py-3 text-slate-400">{CATEGORY_LABEL[item.category] ?? item.category}</td>
<td className="px-4 py-3 text-slate-400">{UNITS_LABEL[item.unit] ?? item.unit}</td>
<td className="px-4 py-3 text-white text-right">{item.base_price.toFixed(2)} </td>
<td className="px-4 py-3">
<Badge variant={item.active ? 'default' : 'secondary'}>
{item.active ? 'Activo' : 'Inactivo'}
</Badge>
</td>
<td className="px-4 py-3 flex gap-2 justify-end">
<Button size="sm" variant="outline" onClick={() => openEdit(item)}>Editar</Button>
<Button
size="sm"
variant="destructive"
onClick={() => { if (confirm('Eliminar item?')) remove.mutate(item.id) }}
>
Eliminar
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
+393
View File
@@ -0,0 +1,393 @@
import { 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 { apiFetch } from '@/lib/api'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import type { Client, Vehicle } from '@/lib/types'
// ─── Client form ─────────────────────────────────────────────────────────────
const clientSchema = z.object({
name: z.string().min(1, 'Nome obrigatório'),
nif: z.string(),
phone: z.string(),
email: z.union([z.string().email('Email inválido'), z.literal('')]),
address: z.string(),
notes: z.string(),
})
type ClientForm = z.infer<typeof clientSchema>
const emptyClient: ClientForm = { name: '', nif: '', phone: '', email: '', address: '', notes: '' }
// ─── Vehicle form ─────────────────────────────────────────────────────────────
const FUEL_TYPES = [
{ value: '', label: '— Combustível —' },
{ value: 'gasoline', label: 'Gasolina' },
{ value: 'diesel', label: 'Diesel' },
{ value: 'electric', label: 'Eléctrico' },
{ value: 'hybrid', label: 'Híbrido' },
{ value: 'hybrid_plug', label: 'Híbrido Plug-in' },
{ value: 'lpg', label: 'GPL' },
{ value: 'hydrogen', label: 'Hidrogénio' },
{ value: 'bifuel', label: 'Bi-Fuel' },
]
const FUEL_LABEL: Record<string, string> = Object.fromEntries(
FUEL_TYPES.filter(f => f.value).map(f => [f.value, f.label])
)
const vehicleSchema = z.object({
plate: z.string().min(1, 'Matrícula obrigatória'),
brand: z.string(),
model: z.string(),
year: z.coerce.number().int().min(1900).max(2100).or(z.literal(0)),
vin: z.string(),
fuel_type: z.string(),
mileage: z.coerce.number().int().min(0).or(z.literal(0)),
notes: z.string(),
})
type VehicleForm = z.infer<typeof vehicleSchema>
const emptyVehicle: VehicleForm = { plate: '', brand: '', model: '', year: 0, vin: '', fuel_type: '', mileage: 0, notes: '' }
// ─── ClientVehicles sub-component ────────────────────────────────────────────
function ClientVehicles({ clientId }: { clientId: string }) {
const qc = useQueryClient()
const [editingVehicle, setEditingVehicle] = useState<Vehicle | null>(null)
const [showVehicleForm, setShowVehicleForm] = useState(false)
const { data: vehicles = [], isLoading } = useQuery<Vehicle[]>({
queryKey: ['vehicles', clientId],
queryFn: () => apiFetch<Vehicle[]>(`/clients/${clientId}/vehicles`),
})
const { register, handleSubmit, reset, formState: { errors } } = useForm<VehicleForm>({
resolver: zodResolver(vehicleSchema) as Resolver<VehicleForm>,
defaultValues: emptyVehicle,
})
const saveVehicle = useMutation({
mutationFn: (data: VehicleForm) => {
const body = {
...data,
year: data.year || null,
mileage: data.mileage || null,
}
return editingVehicle
? apiFetch<Vehicle>(`/vehicles/${editingVehicle.id}`, { method: 'PUT', body: JSON.stringify(body) })
: apiFetch<Vehicle>(`/clients/${clientId}/vehicles`, { method: 'POST', body: JSON.stringify(body) })
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['vehicles', clientId] })
setShowVehicleForm(false)
setEditingVehicle(null)
reset(emptyVehicle)
},
})
function openNewVehicle() {
setEditingVehicle(null)
reset(emptyVehicle)
setShowVehicleForm(true)
}
function openEditVehicle(v: Vehicle) {
setEditingVehicle(v)
reset({
plate: v.plate,
brand: v.brand,
model: v.model,
year: v.year ?? 0,
vin: v.vin,
fuel_type: v.fuel_type,
mileage: v.mileage ?? 0,
notes: v.notes,
})
setShowVehicleForm(true)
}
return (
<tr>
<td colSpan={5} className="bg-slate-900/60 border-t border-slate-700 px-4 py-4">
<div className="flex items-center justify-between mb-3">
<span className="text-slate-300 text-xs font-semibold uppercase tracking-wide">
Viaturas {!isLoading && `(${vehicles.length})`}
</span>
<Button size="sm" onClick={openNewVehicle}>+ Viatura</Button>
</div>
{showVehicleForm && (
<form
onSubmit={handleSubmit((d) => saveVehicle.mutate(d))}
className="grid grid-cols-3 gap-3 mb-4 p-4 bg-slate-800 rounded-lg border border-slate-600"
>
<div className="space-y-1">
<Label className="text-xs">Matrícula *</Label>
<Input {...register('plate')} placeholder="AA-00-BB"
className="bg-slate-900 border-slate-600 text-white text-sm h-8" />
{errors.plate && <p className="text-red-400 text-xs">{errors.plate.message}</p>}
</div>
<div className="space-y-1">
<Label className="text-xs">Marca</Label>
<Input {...register('brand')} placeholder="Toyota"
className="bg-slate-900 border-slate-600 text-white text-sm h-8" />
</div>
<div className="space-y-1">
<Label className="text-xs">Modelo</Label>
<Input {...register('model')} placeholder="Corolla"
className="bg-slate-900 border-slate-600 text-white text-sm h-8" />
</div>
<div className="space-y-1">
<Label className="text-xs">Ano</Label>
<Input type="number" {...register('year')} placeholder="2020"
className="bg-slate-900 border-slate-600 text-white text-sm h-8" />
</div>
<div className="space-y-1">
<Label className="text-xs">Km actuais</Label>
<Input type="number" {...register('mileage')} placeholder="50000"
className="bg-slate-900 border-slate-600 text-white text-sm h-8" />
</div>
<div className="space-y-1">
<Label className="text-xs">VIN / Chassi</Label>
<Input {...register('vin')}
className="bg-slate-900 border-slate-600 text-white text-sm h-8" />
</div>
<div className="space-y-1">
<Label className="text-xs">Combustível</Label>
<select {...register('fuel_type')}
className="w-full rounded-md border border-slate-600 bg-slate-900 text-white px-3 text-sm h-8">
{FUEL_TYPES.map(f => (
<option key={f.value} value={f.value}>{f.label}</option>
))}
</select>
</div>
<div className="col-span-3 space-y-1">
<Label className="text-xs">Notas</Label>
<Input {...register('notes')}
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>
)}
<div className="col-span-3 flex gap-2 justify-end">
<Button type="button" size="sm" variant="outline"
onClick={() => { setShowVehicleForm(false); setEditingVehicle(null) }}>
Cancelar
</Button>
<Button type="submit" size="sm" disabled={saveVehicle.isPending}>
{saveVehicle.isPending ? 'A guardar...' : editingVehicle ? 'Actualizar' : 'Adicionar'}
</Button>
</div>
</form>
)}
{isLoading ? (
<p className="text-slate-500 text-xs">A carregar...</p>
) : vehicles.length === 0 && !showVehicleForm ? (
<p className="text-slate-500 text-xs">Sem viaturas. Clique em + Viatura para adicionar.</p>
) : vehicles.length > 0 ? (
<table className="w-full text-xs">
<thead>
<tr className="text-slate-500">
<th className="text-left py-1 pr-4 font-medium">Matrícula</th>
<th className="text-left py-1 pr-4 font-medium">Marca / Modelo</th>
<th className="text-left py-1 pr-4 font-medium">Ano</th>
<th className="text-left py-1 pr-4 font-medium">Combustível</th>
<th className="text-right py-1 pr-4 font-medium">Km</th>
<th className="py-1"></th>
</tr>
</thead>
<tbody>
{vehicles.map((v) => (
<tr key={v.id} className="border-t border-slate-800">
<td className="py-2 pr-4 text-white font-mono font-semibold">{v.plate}</td>
<td className="py-2 pr-4 text-slate-300">
{[v.brand, v.model].filter(Boolean).join(' ') || '—'}
</td>
<td className="py-2 pr-4 text-slate-400">{v.year ?? '—'}</td>
<td className="py-2 pr-4 text-slate-400">{FUEL_LABEL[v.fuel_type] ?? '—'}</td>
<td className="py-2 pr-4 text-slate-400 text-right">
{v.mileage != null ? v.mileage.toLocaleString('pt-PT') + ' km' : '—'}
</td>
<td className="py-2 text-right">
<Button size="sm" variant="outline"
className="h-6 text-xs px-2"
onClick={() => openEditVehicle(v)}>
Editar
</Button>
</td>
</tr>
))}
</tbody>
</table>
) : null}
</td>
</tr>
)
}
// ─── Main page ────────────────────────────────────────────────────────────────
export default function ClientsPage() {
const qc = useQueryClient()
const [editing, setEditing] = useState<Client | null>(null)
const [showForm, setShowForm] = useState(false)
const [expandedId, setExpandedId] = useState<string | null>(null)
const { data: clients = [], isLoading } = useQuery<Client[]>({
queryKey: ['clients'],
queryFn: () => apiFetch<Client[]>('/clients'),
})
const { register, handleSubmit, reset, formState: { errors } } = useForm<ClientForm>({
resolver: zodResolver(clientSchema),
defaultValues: emptyClient,
})
const save = useMutation({
mutationFn: (data: ClientForm) =>
editing
? apiFetch<Client>(`/clients/${editing.id}`, { method: 'PUT', body: JSON.stringify(data) })
: apiFetch<Client>('/clients', { method: 'POST', body: JSON.stringify(data) }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['clients'] })
setShowForm(false)
setEditing(null)
reset(emptyClient)
},
})
const remove = useMutation({
mutationFn: (id: string) => apiFetch(`/clients/${id}`, { method: 'DELETE' }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['clients'] }),
})
function openNew() {
setEditing(null)
reset(emptyClient)
setShowForm(true)
}
function openEdit(c: Client) {
setEditing(c)
reset({ name: c.name, nif: c.nif, phone: c.phone, email: c.email, address: c.address, notes: c.notes })
setShowForm(true)
}
function toggleVehicles(clientId: string) {
setExpandedId(prev => prev === clientId ? null : clientId)
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-white">Clientes</h1>
<p className="text-slate-400 text-sm mt-0.5">{clients.length} clientes</p>
</div>
<Button onClick={openNew}>Novo Cliente</Button>
</div>
{showForm && (
<div className="mb-6 bg-slate-800 rounded-lg border border-slate-700 p-6">
<h2 className="text-lg font-semibold text-white mb-4">
{editing ? 'Editar Cliente' : 'Novo Cliente'}
</h2>
<form onSubmit={handleSubmit((d) => save.mutate(d))} className="grid grid-cols-2 gap-4">
<div className="col-span-2 space-y-1">
<Label htmlFor="name">Nome *</Label>
<Input id="name" {...register('name')} className="bg-slate-900 border-slate-600 text-white" />
{errors.name && <p className="text-red-400 text-xs">{errors.name.message}</p>}
</div>
<div className="space-y-1">
<Label htmlFor="nif">NIF</Label>
<Input id="nif" {...register('nif')} className="bg-slate-900 border-slate-600 text-white" />
</div>
<div className="space-y-1">
<Label htmlFor="phone">Telefone</Label>
<Input id="phone" {...register('phone')} className="bg-slate-900 border-slate-600 text-white" />
</div>
<div className="space-y-1">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" {...register('email')} className="bg-slate-900 border-slate-600 text-white" />
{errors.email && <p className="text-red-400 text-xs">{errors.email.message}</p>}
</div>
<div className="space-y-1">
<Label htmlFor="address">Morada</Label>
<Input id="address" {...register('address')} className="bg-slate-900 border-slate-600 text-white" />
</div>
<div className="col-span-2 space-y-1">
<Label htmlFor="notes">Notas</Label>
<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>
)}
<div className="col-span-2 flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={() => { setShowForm(false); setEditing(null) }}>
Cancelar
</Button>
<Button type="submit" disabled={save.isPending}>
{save.isPending ? 'A guardar...' : 'Guardar'}
</Button>
</div>
</form>
</div>
)}
{isLoading ? (
<p className="text-slate-400">A carregar...</p>
) : clients.length === 0 ? (
<p className="text-slate-500 text-sm">Nenhum cliente registado.</p>
) : (
<div className="rounded-lg border border-slate-700 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-800">
<tr>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Nome</th>
<th className="text-left px-4 py-3 text-slate-400 font-medium">NIF</th>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Telefone</th>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Email</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{clients.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>
<td className="px-4 py-3 text-slate-400 font-mono text-xs">{c.nif || '—'}</td>
<td className="px-4 py-3 text-slate-400">{c.phone || '—'}</td>
<td className="px-4 py-3 text-slate-400">{c.email || '—'}</td>
<td className="px-4 py-3 flex gap-2 justify-end">
<Button
size="sm"
variant={expandedId === c.id ? 'secondary' : 'outline'}
onClick={() => toggleVehicles(c.id)}
>
🚗 Viaturas
</Button>
<Button size="sm" variant="outline" onClick={() => openEdit(c)}>Editar</Button>
<Button
size="sm"
variant="destructive"
onClick={() => { if (confirm('Eliminar cliente?')) remove.mutate(c.id) }}
>
Eliminar
</Button>
</td>
</tr>
{expandedId === c.id && <ClientVehicles key={`v-${c.id}`} clientId={c.id} />}
</>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
+8
View File
@@ -0,0 +1,8 @@
export default function DashboardPage() {
return (
<div>
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
<p className="text-gray-500 mt-1 text-sm">Bem-vindo ao TechXCar implementado no Plano 5</p>
</div>
)
}
+178
View File
@@ -0,0 +1,178 @@
import { 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 { apiFetch } from '@/lib/api'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import type { Expense } from '@/lib/types'
const EXPENSE_TYPES = ['fuel', 'parts', 'tools', 'other'] as const
const TYPE_LABELS: Record<string, string> = {
fuel: 'Combustível', parts: 'Peças', tools: 'Ferramentas', other: 'Outros',
}
const schema = z.object({
type: z.enum(EXPENSE_TYPES),
amount: z.coerce.number().positive('Valor deve ser positivo'),
description: z.string(),
date: z.string().min(1, 'Data obrigatória'),
vehicle_id: z.string(),
})
type FormData = z.infer<typeof schema>
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 qc = useQueryClient()
const [typeFilter, setTypeFilter] = useState('')
const [showForm, setShowForm] = useState(false)
const { data: expenses = [], isLoading } = useQuery<Expense[]>({
queryKey: ['expenses', typeFilter],
queryFn: () => apiFetch<Expense[]>(`/expenses${typeFilter ? `?type=${typeFilter}` : ''}`),
})
const { register, handleSubmit, reset, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema) as Resolver<FormData>,
defaultValues: emptyExpense,
})
const create = useMutation({
mutationFn: (data: FormData) =>
apiFetch<Expense>('/expenses', { method: 'POST', body: JSON.stringify(data) }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['expenses'] })
setShowForm(false)
reset(emptyExpense)
},
})
const remove = useMutation({
mutationFn: (id: string) => apiFetch(`/expenses/${id}`, { method: 'DELETE' }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['expenses'] }),
})
const total = expenses.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)}
</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'
}`}
>
{t === '' ? 'Todas' : TYPE_LABELS[t]}
</button>
))}
</div>
{showForm && (
<div className="mb-6 bg-slate-800 rounded-lg border border-slate-700 p-6">
<h2 className="text-lg font-semibold text-white mb-4">Nova 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"
>
{EXPENSE_TYPES.map((t) => (
<option key={t} value={t}>{TYPE_LABELS[t]}</option>
))}
</select>
</div>
<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" />
{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" />
{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" />
</div>
{create.error && <p className="col-span-2 text-red-400 text-sm">{(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}>
{create.isPending ? 'A guardar...' : 'Guardar'}
</Button>
</div>
</form>
</div>
)}
{isLoading ? (
<p className="text-slate-400">A carregar...</p>
) : expenses.length === 0 ? (
<p className="text-slate-500 text-sm">Nenhuma despesa registada.</p>
) : (
<div className="rounded-lg border border-slate-700 overflow-hidden">
<table className="w-full text-sm">
<thead className="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="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">
{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 text-right">
<Button
size="sm"
variant="destructive"
onClick={() => { if (confirm('Eliminar despesa?')) remove.mutate(e.id) }}
>
Eliminar
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
+151
View File
@@ -0,0 +1,151 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import type { Invoice, WorkOrder } from '@/lib/types'
const TYPE_LABELS: Record<string, string> = { quote: 'Orçamento', invoice: 'Fatura' }
export default function InvoicesPage() {
const qc = useQueryClient()
const [showGenerate, setShowGenerate] = useState(false)
const [selectedWO, setSelectedWO] = useState('')
const [docType, setDocType] = useState<'quote' | 'invoice'>('quote')
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
queryKey: ['invoices'],
queryFn: () => apiFetch<Invoice[]>('/invoices'),
})
const { data: workOrders = [] } = useQuery<WorkOrder[]>({
queryKey: ['work-orders-for-invoice'],
queryFn: () => apiFetch<WorkOrder[]>('/work-orders'),
enabled: showGenerate,
})
const eligibleWOs = workOrders.filter((wo) =>
wo.status === 'open' || wo.status === 'in_progress' || wo.status === 'completed'
)
const generate = useMutation({
mutationFn: () =>
apiFetch<Invoice>('/invoices', {
method: 'POST',
body: JSON.stringify({ work_order_id: selectedWO, type: docType }),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['invoices'] })
qc.invalidateQueries({ queryKey: ['work-orders'] })
setShowGenerate(false)
setSelectedWO('')
},
})
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-white">Faturação</h1>
<p className="text-slate-400 text-sm mt-0.5">{invoices.length} documentos</p>
</div>
<Button onClick={() => setShowGenerate(true)}>Gerar Documento</Button>
</div>
{showGenerate && (
<div className="mb-6 bg-slate-800 rounded-lg border border-slate-700 p-6">
<h2 className="text-lg font-semibold text-white mb-4">Gerar Documento</h2>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-sm text-slate-300">Tipo</label>
<select
value={docType}
onChange={(e) => setDocType(e.target.value as 'quote' | 'invoice')}
className="w-full rounded-md border border-slate-600 bg-slate-900 text-white px-3 py-2 text-sm"
>
<option value="quote">Orçamento</option>
<option value="invoice">Fatura</option>
</select>
</div>
<div className="space-y-1">
<label className="text-sm text-slate-300">Ordem de Trabalho</label>
<select
value={selectedWO}
onChange={(e) => setSelectedWO(e.target.value)}
className="w-full rounded-md border border-slate-600 bg-slate-900 text-white px-3 py-2 text-sm"
>
<option value=""> Seleccionar OT </option>
{eligibleWOs.map((wo) => (
<option key={wo.id} value={wo.id}>
#{wo.number} ({wo.status})
</option>
))}
</select>
</div>
</div>
{generate.error && (
<p className="text-red-400 text-sm mt-3">{(generate.error as Error).message}</p>
)}
<div className="flex gap-2 justify-end mt-4">
<Button variant="outline" onClick={() => { setShowGenerate(false); setSelectedWO('') }}>
Cancelar
</Button>
<Button
onClick={() => generate.mutate()}
disabled={!selectedWO || generate.isPending}
>
{generate.isPending ? 'A gerar...' : 'Gerar PDF'}
</Button>
</div>
</div>
)}
{isLoading ? (
<p className="text-slate-400">A carregar...</p>
) : invoices.length === 0 ? (
<p className="text-slate-500 text-sm">Nenhum documento gerado.</p>
) : (
<div className="rounded-lg border border-slate-700 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-800">
<tr>
<th className="text-left px-4 py-3 text-slate-400 font-medium"></th>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Tipo</th>
<th className="text-left px-4 py-3 text-slate-400 font-medium">OT</th>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Emitida</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{invoices.map((inv) => (
<tr key={inv.id} className="border-t border-slate-700 hover:bg-slate-800/50">
<td className="px-4 py-3 text-white font-mono">#{inv.number}</td>
<td className="px-4 py-3">
<Badge variant={inv.type === 'invoice' ? 'default' : 'secondary'}>
{TYPE_LABELS[inv.type]}
</Badge>
</td>
<td className="px-4 py-3 text-slate-400 font-mono text-xs">
{inv.work_order_id.slice(0, 8)}
</td>
<td className="px-4 py-3 text-slate-400">
{new Intl.DateTimeFormat('pt-PT').format(new Date(inv.issued_at))}
</td>
<td className="px-4 py-3 text-right">
<Button
size="sm"
variant="outline"
onClick={() => window.open(`/api/v1/invoices/${inv.id}/pdf`, '_blank')}
>
Descarregar PDF
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
+120
View File
@@ -0,0 +1,120 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useForm } from 'react-hook-form'
import { useEffect } from 'react'
import { apiFetch } from '@/lib/api'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import type { TenantSettings } from '@/lib/types'
type FormData = {
company_name: string
company_nif: string
company_address: string
company_iban: string
company_phone: string
company_email: string
}
const defaultValues: FormData = {
company_name: '',
company_nif: '',
company_address: '',
company_iban: '',
company_phone: '',
company_email: '',
}
export default function SettingsPage() {
const qc = useQueryClient()
const { data: settings, isLoading } = useQuery<TenantSettings>({
queryKey: ['settings'],
queryFn: () => apiFetch<TenantSettings>('/settings'),
})
const { register, handleSubmit, reset } = useForm<FormData>({ defaultValues })
useEffect(() => {
if (settings) {
reset({
company_name: settings['company_name'] ?? '',
company_nif: settings['company_nif'] ?? '',
company_address: settings['company_address'] ?? '',
company_iban: settings['company_iban'] ?? '',
company_phone: settings['company_phone'] ?? '',
company_email: settings['company_email'] ?? '',
})
}
}, [settings, reset])
const save = useMutation({
mutationFn: (data: FormData) =>
apiFetch<TenantSettings>('/settings', { method: 'PUT', body: JSON.stringify(data) }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['settings'] }),
})
if (isLoading) return <p className="text-slate-400">A carregar...</p>
return (
<div className="max-w-2xl">
<div className="mb-6">
<h1 className="text-2xl font-bold text-white">Definições</h1>
<p className="text-slate-400 text-sm mt-0.5">Dados da oficina utilizados nos documentos PDF</p>
</div>
<form
onSubmit={handleSubmit((d) => save.mutate(d))}
className="bg-slate-800 rounded-lg border border-slate-700 p-6 space-y-4"
>
<div className="space-y-1">
<Label htmlFor="company_name">Nome da oficina</Label>
<Input id="company_name" {...register('company_name')}
className="bg-slate-900 border-slate-600 text-white" />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label htmlFor="company_nif">NIF</Label>
<Input id="company_nif" {...register('company_nif')}
className="bg-slate-900 border-slate-600 text-white" />
</div>
<div className="space-y-1">
<Label htmlFor="company_phone">Telefone</Label>
<Input id="company_phone" {...register('company_phone')}
className="bg-slate-900 border-slate-600 text-white" />
</div>
</div>
<div className="space-y-1">
<Label htmlFor="company_address">Morada</Label>
<Input id="company_address" {...register('company_address')}
className="bg-slate-900 border-slate-600 text-white" />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label htmlFor="company_email">Email</Label>
<Input id="company_email" type="email" {...register('company_email')}
className="bg-slate-900 border-slate-600 text-white" />
</div>
<div className="space-y-1">
<Label htmlFor="company_iban">IBAN</Label>
<Input id="company_iban" {...register('company_iban')} placeholder="PT50..."
className="bg-slate-900 border-slate-600 text-white" />
</div>
</div>
{save.error && (
<p className="text-red-400 text-sm">{(save.error as Error).message}</p>
)}
{save.isSuccess && (
<p className="text-green-400 text-sm">Definições guardadas.</p>
)}
<div className="flex justify-end pt-2">
<Button type="submit" disabled={save.isPending}>
{save.isPending ? 'A guardar...' : 'Guardar Definições'}
</Button>
</div>
</form>
</div>
)
}
+181
View File
@@ -0,0 +1,181 @@
import { 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 { apiFetch } from '@/lib/api'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import type { Staff } from '@/lib/types'
const STAFF_TYPES = ['internal', 'external'] as const
const schema = z.object({
name: z.string().min(1, 'Nome obrigatório'),
email: z.string(),
phone: z.string(),
type: z.enum(STAFF_TYPES),
hourly_rate: z.coerce.number().min(0),
active: z.boolean(),
})
type FormData = z.infer<typeof schema>
const emptyStaff: FormData = { name: '', email: '', phone: '', type: 'internal', hourly_rate: 0, active: true }
export default function StaffPage() {
const qc = useQueryClient()
const [editing, setEditing] = useState<Staff | null>(null)
const [showForm, setShowForm] = useState(false)
const { data: staff = [], isLoading } = useQuery<Staff[]>({
queryKey: ['staff'],
queryFn: () => apiFetch<Staff[]>('/staff'),
})
const { register, handleSubmit, reset, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema) as Resolver<FormData>,
defaultValues: emptyStaff,
})
const save = useMutation({
mutationFn: (data: FormData) =>
editing
? apiFetch<Staff>(`/staff/${editing.id}`, { method: 'PUT', body: JSON.stringify(data) })
: apiFetch<Staff>('/staff', { method: 'POST', body: JSON.stringify(data) }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['staff'] })
setShowForm(false)
setEditing(null)
reset(emptyStaff)
},
})
const remove = useMutation({
mutationFn: (id: string) => apiFetch(`/staff/${id}`, { method: 'DELETE' }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['staff'] }),
})
function openNew() {
setEditing(null)
reset(emptyStaff)
setShowForm(true)
}
function openEdit(s: Staff) {
setEditing(s)
reset({ name: s.name, email: s.email, phone: s.phone, type: s.type, hourly_rate: s.hourly_rate, active: s.active })
setShowForm(true)
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-white">Técnicos</h1>
<p className="text-slate-400 text-sm mt-0.5">{staff.length} técnicos</p>
</div>
<Button onClick={openNew}>Novo Técnico</Button>
</div>
{showForm && (
<div className="mb-6 bg-slate-800 rounded-lg border border-slate-700 p-6">
<h2 className="text-lg font-semibold text-white mb-4">
{editing ? 'Editar Técnico' : 'Novo Técnico'}
</h2>
<form onSubmit={handleSubmit((d) => save.mutate(d))} className="grid grid-cols-2 gap-4">
<div className="col-span-2 space-y-1">
<Label htmlFor="name">Nome *</Label>
<Input id="name" {...register('name')} className="bg-slate-900 border-slate-600 text-white" />
{errors.name && <p className="text-red-400 text-xs">{errors.name.message}</p>}
</div>
<div className="space-y-1">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" {...register('email')} className="bg-slate-900 border-slate-600 text-white" />
</div>
<div className="space-y-1">
<Label htmlFor="phone">Telefone</Label>
<Input id="phone" {...register('phone')} className="bg-slate-900 border-slate-600 text-white" />
</div>
<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"
>
<option value="internal">Interno</option>
<option value="external">Externo</option>
</select>
</div>
<div className="space-y-1">
<Label htmlFor="hourly_rate">Custo/hora ()</Label>
<Input id="hourly_rate" type="number" step="0.01" {...register('hourly_rate')}
className="bg-slate-900 border-slate-600 text-white" />
</div>
<div className="flex items-center gap-2 pt-6">
<input type="checkbox" id="active" {...register('active')} className="h-4 w-4" />
<Label htmlFor="active">Activo</Label>
</div>
{save.error && <p className="col-span-2 text-red-400 text-sm">{(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) }}>
Cancelar
</Button>
<Button type="submit" disabled={save.isPending}>
{save.isPending ? 'A guardar...' : 'Guardar'}
</Button>
</div>
</form>
</div>
)}
{isLoading ? (
<p className="text-slate-400">A carregar...</p>
) : staff.length === 0 ? (
<p className="text-slate-500 text-sm">Nenhum técnico registado.</p>
) : (
<div className="rounded-lg border border-slate-700 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-800">
<tr>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Nome</th>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Tipo</th>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Email</th>
<th className="text-right px-4 py-3 text-slate-400 font-medium">/hora</th>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Estado</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{staff.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>
<td className="px-4 py-3 text-slate-400">{s.email || '—'}</td>
<td className="px-4 py-3 text-white text-right">{s.hourly_rate.toFixed(2)} </td>
<td className="px-4 py-3">
<Badge variant={s.active ? 'default' : 'secondary'}>
{s.active ? 'Activo' : 'Inactivo'}
</Badge>
</td>
<td className="px-4 py-3 flex gap-2 justify-end">
<Button size="sm" variant="outline" onClick={() => openEdit(s)}>Editar</Button>
<Button
size="sm"
variant="destructive"
onClick={() => { if (confirm('Eliminar técnico?')) remove.mutate(s.id) }}
>
Eliminar
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
@@ -0,0 +1,465 @@
import { useState } from 'react'
import { useParams, Link } from 'react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useForm, type Resolver } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { apiFetch } from '@/lib/api'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import type { WorkOrderDetail, CatalogItem, Staff } from '@/lib/types'
const CATEGORY_LABEL: Record<string, string> = {
mao_de_obra: 'Mão de Obra',
diagnostico: 'Diagnóstico',
manutencao: 'Manutenção',
pecas_mecanicas: 'Peças Mecânicas',
pneus_jantes: 'Pneus / Jantes',
eletrica: 'Elétrica / Eletrónica',
fluidos: 'Fluidos / Óleos',
ar_condicionado: 'Ar Condicionado',
carrocaria: 'Carroçaria / Pintura',
consumiveis: 'Consumíveis',
acessorios: 'Acessórios',
outro: 'Outro',
}
const STATUS_LABELS: Record<string, string> = {
open: 'Aberta',
in_progress: 'Em Curso',
completed: 'Concluída',
invoiced: 'Faturada',
cancelled: 'Cancelada',
}
const TRANSITIONS: Record<string, string[]> = {
open: ['in_progress', 'cancelled'],
in_progress: ['completed', 'cancelled'],
completed: ['invoiced', 'cancelled'],
invoiced: [],
cancelled: [],
}
// ─── Item form ────────────────────────────────────────────────────────────────
const itemSchema = z.object({
catalog_item_id: z.string(),
description: z.string().min(1, 'Descrição obrigatória'),
qty: z.coerce.number().positive('Quantidade deve ser positiva'),
unit_price: z.coerce.number().min(0),
discount_pct: z.coerce.number().min(0).max(100),
})
type ItemForm = z.infer<typeof itemSchema>
const emptyItem: ItemForm = { catalog_item_id: '', description: '', qty: 1, unit_price: 0, discount_pct: 0 }
// ─── Staff hours form ─────────────────────────────────────────────────────────
const shSchema = z.object({
staff_id: z.string().min(1, 'Seleccione um técnico'),
hours: z.coerce.number().positive('Horas deve ser positivo'),
cost_per_hour: z.coerce.number().min(0),
})
type SHForm = z.infer<typeof shSchema>
const emptySH: SHForm = { staff_id: '', hours: 1, cost_per_hour: 0 }
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function WorkOrderDetailPage() {
const { id } = useParams<{ id: string }>()
const qc = useQueryClient()
const [showAddItem, setShowAddItem] = useState(false)
const [showAddStaff, setShowAddStaff] = useState(false)
const { data: detail, isLoading } = useQuery<WorkOrderDetail>({
queryKey: ['work-order', id],
queryFn: () => apiFetch<WorkOrderDetail>(`/work-orders/${id}`),
})
const { data: catalogItems = [] } = useQuery<CatalogItem[]>({
queryKey: ['catalog'],
queryFn: () => apiFetch<CatalogItem[]>('/catalog'),
})
const { data: staffList = [] } = useQuery<Staff[]>({
queryKey: ['staff'],
queryFn: () => apiFetch<Staff[]>('/staff'),
})
// lookup maps
const catalogMap = Object.fromEntries(catalogItems.map(c => [c.id, c]))
const staffMap = Object.fromEntries(staffList.map(s => [s.id, s]))
// ── Transitions ──────────────────────────────────────────────────────────────
const transition = useMutation({
mutationFn: (status: string) =>
apiFetch(`/work-orders/${id}/transition`, { method: 'POST', body: JSON.stringify({ status }) }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['work-order', id] }),
})
// ── Items ─────────────────────────────────────────────────────────────────────
const {
register: regItem,
handleSubmit: handleItem,
setValue: setItemVal,
reset: resetItem,
formState: { errors: itemErrors },
} = useForm<ItemForm>({
resolver: zodResolver(itemSchema) as Resolver<ItemForm>,
defaultValues: emptyItem,
})
function onCatalogSelect(e: React.ChangeEvent<HTMLSelectElement>) {
const cid = e.target.value
setItemVal('catalog_item_id', cid)
if (cid) {
const found = catalogMap[cid]
if (found) {
setItemVal('description', found.name)
setItemVal('unit_price', found.base_price)
}
}
}
const addItem = useMutation({
mutationFn: (data: ItemForm) =>
apiFetch(`/work-orders/${id}/items`, { method: 'POST', body: JSON.stringify(data) }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['work-order', id] })
setShowAddItem(false)
resetItem(emptyItem)
},
})
const removeItem = useMutation({
mutationFn: (itemId: string) =>
apiFetch(`/work-orders/${id}/items/${itemId}`, { method: 'DELETE' }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['work-order', id] }),
})
// ── Staff hours ───────────────────────────────────────────────────────────────
const {
register: regSH,
handleSubmit: handleSH,
setValue: setSHVal,
reset: resetSH,
formState: { errors: shErrors },
} = useForm<SHForm>({
resolver: zodResolver(shSchema) as Resolver<SHForm>,
defaultValues: emptySH,
})
function onStaffSelect(e: React.ChangeEvent<HTMLSelectElement>) {
const sid = e.target.value
setSHVal('staff_id', sid)
if (sid) {
const found = staffMap[sid]
if (found) setSHVal('cost_per_hour', found.hourly_rate)
}
}
const addSH = useMutation({
mutationFn: (data: SHForm) =>
apiFetch(`/work-orders/${id}/staff-hours`, { method: 'POST', body: JSON.stringify(data) }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['work-order', id] })
setShowAddStaff(false)
resetSH(emptySH)
},
})
const removeSH = useMutation({
mutationFn: (shId: string) =>
apiFetch(`/work-orders/${id}/staff-hours/${shId}`, { method: 'DELETE' }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['work-order', id] }),
})
// ── Render ────────────────────────────────────────────────────────────────────
if (isLoading) return <p className="text-slate-400">A carregar...</p>
if (!detail) return <p className="text-slate-400">Ordem não encontrada.</p>
const itemsTotal = detail.items.reduce((s, i) => s + i.total, 0)
const hoursTotal = detail.staff_hours.reduce((s, h) => s + h.total, 0)
const nextStates = TRANSITIONS[detail.status] ?? []
const editable = detail.status !== 'invoiced' && detail.status !== 'cancelled'
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<Link to="/app/work-orders" className="text-slate-400 hover:text-white text-sm">
Ordens
</Link>
<span className="text-slate-600">/</span>
<h1 className="text-2xl font-bold text-white">Ordem #{detail.number}</h1>
<Badge variant={detail.status === 'cancelled' ? 'destructive' : detail.status === 'completed' || detail.status === 'invoiced' ? 'secondary' : 'default'}>
{STATUS_LABELS[detail.status]}
</Badge>
</div>
{/* Transitions */}
{nextStates.length > 0 && (
<div className="flex gap-2">
<span className="text-slate-400 text-sm self-center">Transição:</span>
{nextStates.map((s) => (
<Button
key={s}
size="sm"
variant={s === 'cancelled' ? 'destructive' : 'default'}
onClick={() => {
if (s === 'cancelled' && !confirm('Cancelar esta ordem?')) return
transition.mutate(s)
}}
disabled={transition.isPending}
>
{STATUS_LABELS[s]}
</Button>
))}
</div>
)}
{/* ── Items ─────────────────────────────────────────────────────────────── */}
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-white">Itens</h2>
{editable && (
<Button size="sm" onClick={() => { resetItem(emptyItem); setShowAddItem(true) }}>
Adicionar Item
</Button>
)}
</div>
{showAddItem && (
<div className="mb-4 bg-slate-800 rounded-lg border border-slate-700 p-4">
<form onSubmit={handleItem((d) => addItem.mutate(d))} className="grid grid-cols-3 gap-3">
<div className="col-span-3 space-y-1">
<Label>Catálogo (opcional)</Label>
<select
{...regItem('catalog_item_id')}
onChange={onCatalogSelect}
className="w-full rounded-md border border-slate-600 bg-slate-900 text-white px-3 py-2 text-sm"
>
<option value=""> Seleccionar do catálogo </option>
{catalogItems.filter(i => i.active).map(i => (
<option key={i.id} value={i.id}>
{i.code} {i.name}
</option>
))}
</select>
</div>
<div className="col-span-3 space-y-1">
<Label>Descrição *</Label>
<Input {...regItem('description')} className="bg-slate-900 border-slate-600 text-white" />
{itemErrors.description && <p className="text-red-400 text-xs">{itemErrors.description.message}</p>}
</div>
<div className="space-y-1">
<Label>Qtd. *</Label>
<Input type="number" step="0.001" {...regItem('qty')} className="bg-slate-900 border-slate-600 text-white" />
{itemErrors.qty && <p className="text-red-400 text-xs">{itemErrors.qty.message}</p>}
</div>
<div className="space-y-1">
<Label>Preço Unit. ()</Label>
<Input type="number" step="0.01" {...regItem('unit_price')} className="bg-slate-900 border-slate-600 text-white" />
</div>
<div className="space-y-1">
<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>}
<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}>
{addItem.isPending ? 'A adicionar...' : 'Adicionar'}
</Button>
</div>
</form>
</div>
)}
{detail.items.length === 0 ? (
<p className="text-slate-500 text-sm">Sem itens.</p>
) : (
<div className="rounded-lg border border-slate-700 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-800">
<tr>
<th className="text-left px-4 py-2 text-slate-400 font-medium">Artigo</th>
<th className="text-right px-4 py-2 text-slate-400 font-medium">Qtd.</th>
<th className="text-right px-4 py-2 text-slate-400 font-medium">Preço Unit.</th>
<th className="text-right px-4 py-2 text-slate-400 font-medium">Desc.%</th>
<th className="text-right px-4 py-2 text-slate-400 font-medium">Total</th>
<th className="px-4 py-2"></th>
</tr>
</thead>
<tbody>
{detail.items.map((item) => {
const cat = item.catalog_item_id ? catalogMap[item.catalog_item_id] : null
return (
<tr key={item.id} className="border-t border-slate-700">
<td className="px-4 py-2">
<div className="text-white">{item.description}</div>
{cat && (
<div className="text-xs text-slate-500 mt-0.5">
{CATEGORY_LABEL[cat.category] ?? cat.category}
</div>
)}
</td>
<td className="px-4 py-2 text-slate-300 text-right">{item.qty}</td>
<td className="px-4 py-2 text-slate-300 text-right">{item.unit_price.toFixed(2)} </td>
<td className="px-4 py-2 text-slate-300 text-right">{item.discount_pct}%</td>
<td className="px-4 py-2 text-white font-medium text-right">{item.total.toFixed(2)} </td>
<td className="px-4 py-2 text-right">
{editable && (
<Button
size="sm"
variant="ghost"
className="text-red-400 hover:text-red-300 h-7 px-2"
onClick={() => removeItem.mutate(item.id)}
disabled={removeItem.isPending}
>
</Button>
)}
</td>
</tr>
)
})}
</tbody>
<tfoot className="bg-slate-800/50">
<tr>
<td colSpan={4} className="px-4 py-2 text-slate-400 text-right font-medium">Subtotal itens</td>
<td className="px-4 py-2 text-white font-bold text-right">{itemsTotal.toFixed(2)} </td>
<td />
</tr>
{hoursTotal > 0 && (
<tr>
<td colSpan={4} className="px-4 py-2 text-slate-400 text-right font-medium">Mão de obra</td>
<td className="px-4 py-2 text-white font-bold text-right">{hoursTotal.toFixed(2)} </td>
<td />
</tr>
)}
<tr>
<td colSpan={4} className="px-4 py-2 text-slate-300 text-right font-bold">Total</td>
<td className="px-4 py-2 text-blue-400 font-bold text-right text-base">
{(itemsTotal + hoursTotal).toFixed(2)}
</td>
<td />
</tr>
</tfoot>
</table>
</div>
)}
</section>
{/* ── Staff hours ───────────────────────────────────────────────────────── */}
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-white">Técnicos</h2>
{editable && (
<Button size="sm" onClick={() => { resetSH(emptySH); setShowAddStaff(true) }}>
Adicionar Técnico
</Button>
)}
</div>
{showAddStaff && (
<div className="mb-4 bg-slate-800 rounded-lg border border-slate-700 p-4">
<form onSubmit={handleSH((d) => addSH.mutate(d))} className="grid grid-cols-3 gap-3">
<div className="col-span-3 space-y-1">
<Label>Técnico *</Label>
<select
{...regSH('staff_id')}
onChange={onStaffSelect}
className="w-full rounded-md border border-slate-600 bg-slate-900 text-white px-3 py-2 text-sm"
>
<option value=""> Seleccionar técnico </option>
{staffList.filter(s => s.active).map(s => (
<option key={s.id} value={s.id}>
{s.name} ({s.type === 'internal' ? 'Interno' : 'Externo'})
</option>
))}
</select>
{shErrors.staff_id && <p className="text-red-400 text-xs">{shErrors.staff_id.message}</p>}
</div>
<div className="space-y-1">
<Label>Horas *</Label>
<Input type="number" step="0.25" {...regSH('hours')} className="bg-slate-900 border-slate-600 text-white" />
{shErrors.hours && <p className="text-red-400 text-xs">{shErrors.hours.message}</p>}
</div>
<div className="space-y-1">
<Label>/hora</Label>
<Input type="number" step="0.01" {...regSH('cost_per_hour')} className="bg-slate-900 border-slate-600 text-white" />
</div>
<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>}
<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}>
{addSH.isPending ? 'A adicionar...' : 'Adicionar'}
</Button>
</div>
</form>
</div>
)}
{detail.staff_hours.length === 0 ? (
<p className="text-slate-500 text-sm">Sem técnicos registados nesta ordem.</p>
) : (
<div className="rounded-lg border border-slate-700 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-800">
<tr>
<th className="text-left px-4 py-2 text-slate-400 font-medium">Técnico</th>
<th className="text-right px-4 py-2 text-slate-400 font-medium">Horas</th>
<th className="text-right px-4 py-2 text-slate-400 font-medium">/hora</th>
<th className="text-right px-4 py-2 text-slate-400 font-medium">Total</th>
<th className="px-4 py-2"></th>
</tr>
</thead>
<tbody>
{detail.staff_hours.map((sh) => {
const staff = staffMap[sh.staff_id]
return (
<tr key={sh.id} className="border-t border-slate-700">
<td className="px-4 py-2">
<div className="text-white">{staff?.name ?? '—'}</div>
{staff && (
<div className="text-xs text-slate-500 mt-0.5">
{staff.type === 'internal' ? 'Interno' : 'Externo'}
</div>
)}
</td>
<td className="px-4 py-2 text-white text-right">{sh.hours}</td>
<td className="px-4 py-2 text-slate-300 text-right">{sh.cost_per_hour.toFixed(2)} </td>
<td className="px-4 py-2 text-white font-medium text-right">{sh.total.toFixed(2)} </td>
<td className="px-4 py-2 text-right">
{editable && (
<Button
size="sm"
variant="ghost"
className="text-red-400 hover:text-red-300 h-7 px-2"
onClick={() => removeSH.mutate(sh.id)}
disabled={removeSH.isPending}
>
</Button>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</section>
</div>
)
}
+194
View File
@@ -0,0 +1,194 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { apiFetch } from '@/lib/api'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import type { WorkOrder, Client, Vehicle } from '@/lib/types'
const STATUS_LABELS: Record<string, string> = {
open: 'Aberta',
in_progress: 'Em Curso',
completed: 'Concluída',
invoiced: 'Faturada',
cancelled: 'Cancelada',
}
const STATUS_VARIANT: Record<string, 'default' | 'secondary' | 'destructive'> = {
open: 'secondary',
in_progress: 'default',
completed: 'default',
invoiced: 'secondary',
cancelled: 'destructive',
}
const schema = z.object({
client_id: z.string(),
vehicle_id: z.string(),
internal_notes: z.string(),
})
type FormData = z.infer<typeof schema>
const emptyOrder: FormData = { client_id: '', vehicle_id: '', internal_notes: '' }
export default function WorkOrdersPage() {
const qc = useQueryClient()
const [statusFilter, setStatusFilter] = useState('')
const [showForm, setShowForm] = useState(false)
const { data: orders = [], isLoading } = useQuery<WorkOrder[]>({
queryKey: ['work-orders', statusFilter],
queryFn: () => apiFetch<WorkOrder[]>(`/work-orders${statusFilter ? `?status=${statusFilter}` : ''}`),
})
const { data: clients = [] } = useQuery<Client[]>({
queryKey: ['clients'],
queryFn: () => apiFetch<Client[]>('/clients'),
})
const { register, handleSubmit, watch, reset } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: emptyOrder,
})
const selectedClientId = watch('client_id')
const { data: vehicles = [] } = useQuery<Vehicle[]>({
queryKey: ['vehicles', selectedClientId],
queryFn: () => apiFetch<Vehicle[]>(`/clients/${selectedClientId}/vehicles`),
enabled: !!selectedClientId,
})
const create = useMutation({
mutationFn: (data: FormData) =>
apiFetch<WorkOrder>('/work-orders', { method: 'POST', body: JSON.stringify(data) }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['work-orders'] })
setShowForm(false)
reset()
},
})
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-white">Ordens de Trabalho</h1>
<p className="text-slate-400 text-sm mt-0.5">{orders.length} ordens</p>
</div>
<Button onClick={() => { reset(emptyOrder); setShowForm(true) }}>Nova Ordem</Button>
</div>
<div className="flex gap-2 mb-4">
{['', '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
? 'bg-slate-600 text-white'
: 'text-slate-400 hover:text-white hover:bg-slate-800'
}`}
>
{s === '' ? 'Todas' : STATUS_LABELS[s]}
</button>
))}
</div>
{showForm && (
<div className="mb-6 bg-slate-800 rounded-lg border border-slate-700 p-6">
<h2 className="text-lg font-semibold text-white mb-4">Nova Ordem de Trabalho</h2>
<form onSubmit={handleSubmit((d) => create.mutate(d))} className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label htmlFor="client_id">Cliente</Label>
<select
id="client_id"
{...register('client_id')}
className="w-full rounded-md border border-slate-600 bg-slate-900 text-white px-3 py-2 text-sm"
>
<option value=""> Sem cliente </option>
{clients.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div className="space-y-1">
<Label htmlFor="vehicle_id">Veículo</Label>
<select
id="vehicle_id"
{...register('vehicle_id')}
className="w-full rounded-md border border-slate-600 bg-slate-900 text-white px-3 py-2 text-sm"
disabled={!selectedClientId}
>
<option value=""> Sem veículo </option>
{vehicles.map((v) => (
<option key={v.id} value={v.id}>{v.plate} {v.brand} {v.model}</option>
))}
</select>
</div>
<div className="col-span-2 space-y-1">
<Label htmlFor="internal_notes">Notas internas</Label>
<Input id="internal_notes" {...register('internal_notes')} className="bg-slate-900 border-slate-600 text-white" />
</div>
{create.error && (
<p className="col-span-2 text-red-400 text-sm">{create.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}>
{create.isPending ? 'A criar...' : 'Criar Ordem'}
</Button>
</div>
</form>
</div>
)}
{isLoading ? (
<p className="text-slate-400">A carregar...</p>
) : orders.length === 0 ? (
<p className="text-slate-500 text-sm">Nenhuma ordem de trabalho.</p>
) : (
<div className="rounded-lg border border-slate-700 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-800">
<tr>
<th className="text-left px-4 py-3 text-slate-400 font-medium"></th>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Estado</th>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Criada</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{orders.map((o) => (
<tr key={o.id} className="border-t border-slate-700 hover:bg-slate-800/50">
<td className="px-4 py-3 text-white font-mono">#{o.number}</td>
<td className="px-4 py-3">
<Badge variant={STATUS_VARIANT[o.status]}>
{STATUS_LABELS[o.status]}
</Badge>
</td>
<td className="px-4 py-3 text-slate-400">
{new Intl.DateTimeFormat('pt-PT').format(new Date(o.created_at))}
</td>
<td className="px-4 py-3 text-right">
<Link
to={`/app/work-orders/${o.id}`}
className="text-xs text-blue-400 hover:text-blue-300"
>
Ver detalhe
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
+93
View File
@@ -0,0 +1,93 @@
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useLogin } from '@/hooks/useAuth'
const schema = z.object({
email: z.string().email('Email inválido'),
password: z.string().min(1, 'Password obrigatória'),
tenant_slug: z.string().optional(),
})
type FormData = z.infer<typeof schema>
export default function LoginPage() {
const [showSlug, setShowSlug] = useState(false)
const { mutate: login, isPending, error } = useLogin()
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>({ resolver: zodResolver(schema) })
const onSubmit = (data: FormData) => {
login({
email: data.email,
password: data.password,
tenant_slug: data.tenant_slug || undefined,
})
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900">TechXCar</h1>
<p className="text-gray-500 mt-1 text-sm">Gestão de Oficina</p>
</div>
<form
onSubmit={handleSubmit(onSubmit)}
className="bg-white p-8 rounded-lg shadow-sm border border-gray-200 space-y-4"
>
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" autoComplete="email" {...register('email')} />
{errors.email && <p className="text-red-500 text-xs">{errors.email.message}</p>}
</div>
<div className="space-y-1.5">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
{...register('password')}
/>
{errors.password && <p className="text-red-500 text-xs">{errors.password.message}</p>}
</div>
{showSlug && (
<div className="space-y-1.5">
<Label htmlFor="tenant_slug">Workspace (slug da oficina)</Label>
<Input
id="tenant_slug"
type="text"
placeholder="minha-oficina"
{...register('tenant_slug')}
/>
</div>
)}
{error && <p className="text-red-500 text-sm text-center">{error.message}</p>}
<Button type="submit" className="w-full" disabled={isPending}>
{isPending ? 'A entrar...' : 'Entrar'}
</Button>
<button
type="button"
onClick={() => setShowSlug((v) => !v)}
className="w-full text-xs text-gray-400 hover:text-gray-600 text-center"
>
{showSlug ? 'Ocultar campo workspace' : 'Entrar numa oficina específica'}
</button>
</form>
</div>
</div>
)
}
@@ -0,0 +1,160 @@
import { useParams, useNavigate } from 'react-router'
import { useQuery, useMutation } from '@tanstack/react-query'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { apiFetch } from '@/lib/api'
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'
const schema = z.object({
tenant_name: z.string().min(2, 'Nome obrigatório (mínimo 2 caracteres)'),
tenant_slug: z
.string()
.min(2, 'Slug obrigatório')
.regex(/^[a-z0-9-]+$/, 'Apenas letras minúsculas, números e hífens'),
admin_email: z.string().email('Email inválido'),
admin_password: z.string().min(8, 'Mínimo 8 caracteres'),
admin_name: z.string().min(2, 'Nome obrigatório'),
})
type FormData = z.infer<typeof schema>
interface RedeemResponse {
access_token: string
tenant_slug: string
}
export default function InviteRedeemPage() {
const { token } = useParams<{ token: string }>()
const navigate = useNavigate()
const { setAuth } = useAuthStore()
const {
data: invite,
isLoading,
error: inviteError,
} = useQuery<Invite>({
queryKey: ['invite', token],
queryFn: () => apiFetch<Invite>(`/invites/${token}`),
retry: false,
})
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>({ resolver: zodResolver(schema) })
const redeem = useMutation({
mutationFn: (data: FormData) =>
apiFetch<RedeemResponse>(`/invites/${token}/redeem`, {
method: 'POST',
body: JSON.stringify(data),
}),
onSuccess: (data) => {
setAuth(
{ id: '', email: '', name: '', role: 'tenant_admin', tenantId: undefined },
data.access_token,
)
navigate('/app', { replace: true })
},
})
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>
)
}
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">
Este convite não existe, expirou ou foi utilizado.
</p>
</div>
</div>
)
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<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>
</div>
<form
onSubmit={handleSubmit((data) => redeem.mutate(data))}
className="bg-white p-8 rounded-lg shadow-sm border border-gray-200 space-y-4"
>
<fieldset className="space-y-3">
<legend className="text-sm font-semibold text-gray-700 pb-1 border-b border-gray-100 w-full">
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" />
{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">
Usado no URL apenas letras minúsculas, números e hífens.
</p>
{errors.tenant_slug && (
<p className="text-red-500 text-xs">{errors.tenant_slug.message}</p>
)}
</div>
</fieldset>
<fieldset className="space-y-3">
<legend className="text-sm font-semibold text-gray-700 pb-1 border-b border-gray-100 w-full">
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" />
{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')} />
{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')} />
{errors.admin_password && (
<p className="text-red-500 text-xs">{errors.admin_password.message}</p>
)}
</div>
</fieldset>
{redeem.error && (
<p className="text-red-500 text-sm text-center">{redeem.error.message}</p>
)}
<Button type="submit" className="w-full" disabled={redeem.isPending}>
{redeem.isPending ? 'A criar conta...' : 'Criar conta'}
</Button>
</form>
</div>
</div>
)
}
+103
View File
@@ -0,0 +1,103 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { useAuthStore } from './authStore'
describe('authStore', () => {
beforeEach(() => {
useAuthStore.setState({
user: null,
accessToken: null,
isAuthenticated: false,
previousSession: null,
})
})
it('starts unauthenticated', () => {
const state = useAuthStore.getState()
expect(state.isAuthenticated).toBe(false)
expect(state.user).toBeNull()
expect(state.accessToken).toBeNull()
})
it('setAuth stores user and token', () => {
const user = { id: '1', email: 'a@a.com', name: 'Test', role: 'tenant_admin' as const }
useAuthStore.getState().setAuth(user, 'tok123')
const state = useAuthStore.getState()
expect(state.isAuthenticated).toBe(true)
expect(state.user).toEqual(user)
expect(state.accessToken).toBe('tok123')
})
it('clearAuth resets to unauthenticated', () => {
const user = { id: '1', email: 'a@a.com', name: 'Test', role: 'tenant_admin' as const }
useAuthStore.getState().setAuth(user, 'tok123')
useAuthStore.getState().clearAuth()
const state = useAuthStore.getState()
expect(state.isAuthenticated).toBe(false)
expect(state.user).toBeNull()
expect(state.accessToken).toBeNull()
})
it('updateToken replaces only the token', () => {
const user = { id: '1', email: 'a@a.com', name: 'Test', role: 'tenant_admin' as const }
useAuthStore.getState().setAuth(user, 'old-token')
useAuthStore.getState().updateToken('new-token')
const state = useAuthStore.getState()
expect(state.accessToken).toBe('new-token')
expect(state.user).toEqual(user)
})
it('impersonateTenant saves previous session and sets new auth', () => {
const originalUser = { id: '1', email: 'admin@a.com', name: 'Admin', role: 'super_admin' as const }
const tenantUser = { id: '2', email: 'tenant@a.com', name: 'Tenant User', role: 'tenant_admin' as const, tenantId: 'tenant-123' }
// Setup original session
useAuthStore.getState().setAuth(originalUser, 'admin-token')
// Impersonate tenant
useAuthStore.getState().impersonateTenant('tenant-token', tenantUser)
const state = useAuthStore.getState()
expect(state.user).toEqual(tenantUser)
expect(state.accessToken).toBe('tenant-token')
expect(state.isAuthenticated).toBe(true)
expect(state.previousSession).toEqual({
token: 'admin-token',
user: originalUser,
})
})
it('restoreSession restores previous session and clears previousSession', () => {
const originalUser = { id: '1', email: 'admin@a.com', name: 'Admin', role: 'super_admin' as const }
const tenantUser = { id: '2', email: 'tenant@a.com', name: 'Tenant User', role: 'tenant_admin' as const, tenantId: 'tenant-123' }
// Setup original session and impersonate
useAuthStore.getState().setAuth(originalUser, 'admin-token')
useAuthStore.getState().impersonateTenant('tenant-token', tenantUser)
// Restore
useAuthStore.getState().restoreSession()
const state = useAuthStore.getState()
expect(state.user).toEqual(originalUser)
expect(state.accessToken).toBe('admin-token')
expect(state.isAuthenticated).toBe(true)
expect(state.previousSession).toBeNull()
})
it('restoreSession when no previousSession is a no-op', () => {
const user = { id: '1', email: 'a@a.com', name: 'Test', role: 'tenant_admin' as const }
useAuthStore.getState().setAuth(user, 'tok123')
const stateBefore = useAuthStore.getState()
useAuthStore.getState().restoreSession()
const stateAfter = useAuthStore.getState()
expect(stateAfter.user).toEqual(stateBefore.user)
expect(stateAfter.accessToken).toEqual(stateBefore.accessToken)
expect(stateAfter.isAuthenticated).toBe(true)
expect(stateAfter.previousSession).toBeNull()
})
})
+76
View File
@@ -0,0 +1,76 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export type UserRole = 'super_admin' | 'tenant_admin' | 'manager' | 'technician'
export interface AuthUser {
id: string
email: string
name: string
role: UserRole
tenantId?: string
}
interface PreviousSession {
token: string
user: AuthUser
}
interface AuthState {
user: AuthUser | null
accessToken: string | null
isAuthenticated: boolean
previousSession: PreviousSession | null
setAuth: (user: AuthUser, accessToken: string) => void
clearAuth: () => void
updateToken: (accessToken: string) => void
impersonateTenant: (token: string, user: AuthUser) => void
restoreSession: () => void
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: null,
accessToken: null,
isAuthenticated: false,
previousSession: null,
setAuth: (user, accessToken) =>
set({ user, accessToken, isAuthenticated: true, previousSession: null }),
clearAuth: () =>
set({ user: null, accessToken: null, isAuthenticated: false, previousSession: null }),
updateToken: (accessToken) =>
set({ accessToken }),
impersonateTenant: (token, user) => {
const { accessToken, user: currentUser } = get()
set({
accessToken: token,
user,
isAuthenticated: true,
previousSession: currentUser && accessToken
? { token: accessToken, user: currentUser }
: null,
})
},
restoreSession: () => {
const { previousSession } = get()
if (!previousSession) return
set({
accessToken: previousSession.token,
user: previousSession.user,
isAuthenticated: true,
previousSession: null,
})
},
}),
{
name: 'techxcar-auth',
partialize: (state) => ({
user: state.user,
accessToken: state.accessToken,
isAuthenticated: state.isAuthenticated,
// previousSession intentionally excluded — impersonation does not survive refresh
}),
}
)
)
+15
View File
@@ -0,0 +1,15 @@
import '@testing-library/jest-dom'
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: (key: string) => store[key] ?? null,
setItem: (key: string, value: string) => { store[key] = value },
removeItem: (key: string) => { delete store[key] },
clear: () => { store = {} },
get length() { return Object.keys(store).length },
key: (index: number) => Object.keys(store)[index] ?? null,
}
})()
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock })
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />