feat: atualizar fluxo OT, dashboard, transacoes e PDFs

This commit is contained in:
Luciano Milani
2026-07-02 13:42:47 +01:00
parent 1def51e65b
commit 8231bba3f9
17 changed files with 729 additions and 91 deletions
+2 -1
View File
@@ -105,7 +105,7 @@ func createInvoiceH() fiber.Handler {
return fiber.NewError(500, "erro ao criar fatura")
}
docType := "Orcamento"
docType := "Orçamento"
prefix := "ORC"
if b.Type == "invoice" {
docType = "Fatura"
@@ -121,6 +121,7 @@ func createInvoiceH() fiber.Handler {
CompanyIBAN: sett["company_iban"],
CompanyPhone: sett["company_phone"],
CompanyEmail: sett["company_email"],
CompanyLogo: sett["company_logo"],
DocType: docType,
DocNumber: fmt.Sprintf("%s/%d/%04d", prefix, inv.IssuedAt.Year(), inv.Number),
IssuedAt: inv.IssuedAt.Format("02/01/2006"),
+3 -3
View File
@@ -21,7 +21,7 @@ type Invoice struct {
func ListInvoices(ctx context.Context, conn *pgxpool.Conn) ([]*Invoice, error) {
rows, err := conn.Query(ctx,
`SELECT id, work_order_id, type, number, COALESCE(pdf_path,''), issued_at, created_at
`SELECT id, work_order_id, type, number, COALESCE(pdf_path,''), issued_at, issued_at AS created_at
FROM invoices ORDER BY issued_at DESC`)
if err != nil {
return nil, fmt.Errorf("invoice: list: %w", err)
@@ -41,7 +41,7 @@ func ListInvoices(ctx context.Context, conn *pgxpool.Conn) ([]*Invoice, error) {
func GetInvoice(ctx context.Context, conn *pgxpool.Conn, id string) (*Invoice, error) {
row := conn.QueryRow(ctx,
`SELECT id, work_order_id, type, number, COALESCE(pdf_path,''), issued_at, created_at
`SELECT id, work_order_id, type, number, COALESCE(pdf_path,''), issued_at, issued_at AS created_at
FROM invoices WHERE id = $1`, id)
var inv Invoice
err := row.Scan(&inv.ID, &inv.WorkOrderID, &inv.Type, &inv.Number,
@@ -59,7 +59,7 @@ func CreateInvoice(ctx context.Context, conn *pgxpool.Conn, woID, docType string
row := conn.QueryRow(ctx,
`INSERT INTO invoices (work_order_id, type, issued_at)
VALUES ($1, $2, NOW())
RETURNING id, work_order_id, type, number, COALESCE(pdf_path,''), issued_at, created_at`,
RETURNING id, work_order_id, type, number, COALESCE(pdf_path,''), issued_at, issued_at AS created_at`,
woID, docType)
var inv Invoice
if err := row.Scan(&inv.ID, &inv.WorkOrderID, &inv.Type, &inv.Number,
+1
View File
@@ -14,6 +14,7 @@ var AllowedKeys = map[string]bool{
"company_iban": true,
"company_phone": true,
"company_email": true,
"company_logo": true,
}
func GetSettings(ctx context.Context, conn *pgxpool.Conn) (map[string]string, error) {
+1
View File
@@ -48,6 +48,7 @@ type WorkOrderDetail struct {
}
var allowedTransitions = map[string][]string{
"quote": {"open", "cancelled"},
"open": {"in_progress", "cancelled"},
"in_progress": {"completed", "cancelled"},
"completed": {"invoiced", "cancelled"},
@@ -35,7 +35,7 @@ func TestWorkOrderCRUD(t *testing.T) {
wo, err := workorder.CreateWorkOrder(ctx, conn, "", "", "", "")
require.NoError(t, err)
assert.NotEmpty(t, wo.ID)
assert.Equal(t, "open", wo.Status)
assert.Equal(t, "quote", wo.Status)
list, err := workorder.ListWorkOrders(ctx, conn, "")
require.NoError(t, err)
@@ -55,9 +55,9 @@ func TestWorkOrderTransition(t *testing.T) {
wo, err := workorder.CreateWorkOrder(ctx, conn, "", "", "", "")
require.NoError(t, err)
wo2, err := workorder.TransitionStatus(ctx, conn, wo.ID, "in_progress", "")
wo2, err := workorder.TransitionStatus(ctx, conn, wo.ID, "open", "")
require.NoError(t, err)
assert.Equal(t, "in_progress", wo2.Status)
assert.Equal(t, "open", wo2.Status)
_, err = workorder.TransitionStatus(ctx, conn, wo.ID, "cancelled_invalid", "")
assert.Error(t, err, "invalid transition should error")
@@ -69,6 +69,9 @@ func TestAllowedTransitions(t *testing.T) {
to string
valid bool
}{
{"quote", "open", true},
{"quote", "cancelled", true},
{"quote", "in_progress", false},
{"open", "in_progress", true},
{"open", "cancelled", true},
{"open", "completed", false},
@@ -63,7 +63,7 @@ CREATE TABLE IF NOT EXISTS work_orders (
number SERIAL UNIQUE,
client_id UUID REFERENCES clients(id) ON DELETE SET NULL,
vehicle_id UUID REFERENCES vehicles(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'in_progress', 'completed', 'invoiced', 'cancelled')),
status TEXT NOT NULL DEFAULT 'quote' CHECK (status IN ('quote', 'open', 'in_progress', 'completed', 'invoiced', 'cancelled')),
internal_notes TEXT,
client_notes TEXT,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
@@ -0,0 +1,13 @@
ALTER TABLE work_orders
ALTER COLUMN status SET DEFAULT 'open';
ALTER TABLE work_orders
DROP CONSTRAINT IF EXISTS work_orders_status_check;
ALTER TABLE work_orders
ADD CONSTRAINT work_orders_status_check
CHECK (status IN ('open', 'in_progress', 'completed', 'invoiced', 'cancelled'));
UPDATE work_orders
SET status = 'open'
WHERE status = 'quote';
@@ -0,0 +1,18 @@
ALTER TABLE work_orders
ALTER COLUMN status SET DEFAULT 'quote';
ALTER TABLE work_orders
DROP CONSTRAINT IF EXISTS work_orders_status_check;
ALTER TABLE work_orders
ADD CONSTRAINT work_orders_status_check
CHECK (status IN ('quote', 'open', 'in_progress', 'completed', 'invoiced', 'cancelled'));
UPDATE work_orders
SET status = 'quote'
WHERE status = 'open'
AND NOT EXISTS (
SELECT 1
FROM wo_status_log l
WHERE l.work_order_id = work_orders.id
);
+102 -21
View File
@@ -1,9 +1,14 @@
package pdf
import (
"bytes"
"encoding/base64"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/go-pdf/fpdf"
)
@@ -15,6 +20,7 @@ type DocMeta struct {
CompanyIBAN string
CompanyPhone string
CompanyEmail string
CompanyLogo string
DocType string // "Fatura" or "Orçamento"
DocNumber string // e.g. "FAT/2026/001"
IssuedAt string // e.g. "30/06/2026"
@@ -39,44 +45,52 @@ func Generate(meta DocMeta, items []LineItem, staffTotal float64, outPath string
f := fpdf.New("P", "mm", "A4", "")
f.AddPage()
f.SetMargins(15, 15, 15)
tr := f.UnicodeTranslatorFromDescriptor("")
t := func(s string) string { return tr(s) }
hasLogo := addLogo(f, meta.CompanyLogo)
leftW := 120.0
if hasLogo {
leftW = 100.0
}
// Header
f.SetFont("Helvetica", "B", 18)
f.CellFormat(120, 10, meta.CompanyName, "", 0, "L", false, 0, "")
f.CellFormat(leftW, 10, t(meta.CompanyName), "", 0, "L", false, 0, "")
f.SetFont("Helvetica", "B", 14)
f.CellFormat(60, 10, meta.DocType, "", 1, "R", false, 0, "")
f.CellFormat(60, 10, t(meta.DocType), "", 1, "R", false, 0, "")
f.SetFont("Helvetica", "", 9)
if meta.CompanyNIF != "" {
f.CellFormat(120, 5, "NIF: "+meta.CompanyNIF, "", 0, "L", false, 0, "")
f.CellFormat(leftW, 5, t("NIF: ")+meta.CompanyNIF, "", 0, "L", false, 0, "")
} else {
f.CellFormat(120, 5, "", "", 0, "L", false, 0, "")
f.CellFormat(leftW, 5, "", "", 0, "L", false, 0, "")
}
f.SetFont("Helvetica", "", 11)
f.CellFormat(60, 5, meta.DocNumber, "", 1, "R", false, 0, "")
f.CellFormat(60, 5, t(meta.DocNumber), "", 1, "R", false, 0, "")
f.SetFont("Helvetica", "", 9)
if meta.CompanyAddress != "" {
f.MultiCell(120, 5, meta.CompanyAddress, "", "L", false)
f.MultiCell(leftW, 5, t(meta.CompanyAddress), "", "L", false)
}
f.Ln(3)
curY := f.GetY()
f.SetXY(135, curY-3)
f.CellFormat(60, 5, "Data: "+meta.IssuedAt, "", 1, "R", false, 0, "")
f.CellFormat(60, 5, t("Data: ")+meta.IssuedAt, "", 1, "R", false, 0, "")
f.SetY(curY + 3)
f.Ln(3)
// Client block
if meta.ClientName != "" {
f.SetFont("Helvetica", "B", 9)
f.CellFormat(180, 5, "Cliente", "", 1, "L", false, 0, "")
f.CellFormat(180, 5, t("Cliente"), "", 1, "L", false, 0, "")
f.SetFont("Helvetica", "", 9)
f.CellFormat(180, 5, meta.ClientName, "", 1, "L", false, 0, "")
f.CellFormat(180, 5, t(meta.ClientName), "", 1, "L", false, 0, "")
if meta.ClientNIF != "" {
f.CellFormat(180, 5, "NIF: "+meta.ClientNIF, "", 1, "L", false, 0, "")
f.CellFormat(180, 5, t("NIF: ")+meta.ClientNIF, "", 1, "L", false, 0, "")
}
if meta.VehiclePlate != "" {
f.CellFormat(180, 5, "Matrícula: "+meta.VehiclePlate, "", 1, "L", false, 0, "")
f.CellFormat(180, 5, t("Matrícula: ")+meta.VehiclePlate, "", 1, "L", false, 0, "")
}
f.Ln(4)
}
@@ -85,11 +99,11 @@ func Generate(meta DocMeta, items []LineItem, staffTotal float64, outPath string
f.SetFillColor(50, 50, 50)
f.SetTextColor(255, 255, 255)
f.SetFont("Helvetica", "B", 9)
f.CellFormat(90, 7, "Descricao", "1", 0, "L", true, 0, "")
f.CellFormat(20, 7, "Qtd.", "1", 0, "C", true, 0, "")
f.CellFormat(25, 7, "Preco Unit.", "1", 0, "R", true, 0, "")
f.CellFormat(20, 7, "Desc.%", "1", 0, "C", true, 0, "")
f.CellFormat(25, 7, "Total", "1", 1, "R", true, 0, "")
f.CellFormat(90, 7, t("Descrição"), "1", 0, "L", true, 0, "")
f.CellFormat(20, 7, t("Qtd."), "1", 0, "C", true, 0, "")
f.CellFormat(25, 7, t("Preço Unit."), "1", 0, "R", true, 0, "")
f.CellFormat(20, 7, t("Desc.%"), "1", 0, "C", true, 0, "")
f.CellFormat(25, 7, t("Total"), "1", 1, "R", true, 0, "")
// Table rows
f.SetFillColor(245, 245, 245)
@@ -97,7 +111,7 @@ func Generate(meta DocMeta, items []LineItem, staffTotal float64, outPath string
f.SetFont("Helvetica", "", 9)
fill := false
for _, item := range items {
f.CellFormat(90, 6, item.Description, "1", 0, "L", fill, 0, "")
f.CellFormat(90, 6, t(item.Description), "1", 0, "L", fill, 0, "")
f.CellFormat(20, 6, fmt.Sprintf("%.2f", item.Qty), "1", 0, "C", fill, 0, "")
f.CellFormat(25, 6, fmt.Sprintf("%.2f EUR", item.UnitPrice), "1", 0, "R", fill, 0, "")
f.CellFormat(20, 6, fmt.Sprintf("%.0f%%", item.DiscountPct), "1", 0, "C", fill, 0, "")
@@ -115,21 +129,88 @@ func Generate(meta DocMeta, items []LineItem, staffTotal float64, outPath string
f.Ln(3)
f.SetFont("Helvetica", "", 9)
if staffTotal > 0 {
f.CellFormat(155, 6, "Subtotal pecas/servicos", "", 0, "R", false, 0, "")
f.CellFormat(155, 6, t("Subtotal peças/serviços"), "", 0, "R", false, 0, "")
f.CellFormat(25, 6, fmt.Sprintf("%.2f EUR", subtotal), "1", 1, "R", false, 0, "")
f.CellFormat(155, 6, "Mao de obra", "", 0, "R", false, 0, "")
f.CellFormat(155, 6, t("Mão de obra"), "", 0, "R", false, 0, "")
f.CellFormat(25, 6, fmt.Sprintf("%.2f EUR", staffTotal), "1", 1, "R", false, 0, "")
}
f.SetFont("Helvetica", "B", 10)
f.CellFormat(155, 7, "TOTAL", "", 0, "R", false, 0, "")
f.CellFormat(155, 7, t("TOTAL"), "", 0, "R", false, 0, "")
f.CellFormat(25, 7, fmt.Sprintf("%.2f EUR", grandTotal), "1", 1, "R", false, 0, "")
// IBAN footer
if meta.CompanyIBAN != "" {
f.Ln(8)
f.SetFont("Helvetica", "", 8)
f.CellFormat(180, 5, "IBAN: "+meta.CompanyIBAN, "", 1, "C", false, 0, "")
f.CellFormat(180, 5, t("IBAN: ")+meta.CompanyIBAN, "", 1, "C", false, 0, "")
}
return f.OutputFileAndClose(outPath)
}
func addLogo(f *fpdf.Fpdf, logo string) bool {
logo = strings.TrimSpace(logo)
if logo == "" {
return false
}
if strings.HasPrefix(logo, "data:image/") {
comma := strings.Index(logo, ",")
if comma <= 0 {
return false
}
meta := logo[:comma]
payload := logo[comma+1:]
if !strings.Contains(meta, ";base64") {
return false
}
raw, err := base64.StdEncoding.DecodeString(payload)
if err != nil {
return false
}
imgType := "PNG"
if strings.Contains(meta, "image/jpeg") || strings.Contains(meta, "image/jpg") {
imgType = "JPG"
}
opts := fpdf.ImageOptions{ImageType: imgType, ReadDpi: true}
f.RegisterImageOptionsReader("company_logo", opts, bytes.NewReader(raw))
f.ImageOptions("company_logo", 15, 14, 26, 0, false, opts, 0, "")
f.SetX(45)
return true
}
if strings.HasPrefix(logo, "http://") || strings.HasPrefix(logo, "https://") {
client := http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(logo)
if err != nil {
return false
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return false
}
buf := new(bytes.Buffer)
if _, err := buf.ReadFrom(resp.Body); err != nil {
return false
}
imgType := "PNG"
ct := strings.ToLower(resp.Header.Get("Content-Type"))
if strings.Contains(ct, "jpeg") || strings.Contains(ct, "jpg") {
imgType = "JPG"
}
opts := fpdf.ImageOptions{ImageType: imgType, ReadDpi: true}
f.RegisterImageOptionsReader("company_logo", opts, bytes.NewReader(buf.Bytes()))
f.ImageOptions("company_logo", 15, 14, 26, 0, false, opts, 0, "")
f.SetX(45)
return true
}
if _, err := os.Stat(logo); err == nil {
opts := fpdf.ImageOptions{ReadDpi: true}
f.ImageOptions(logo, 15, 14, 26, 0, false, opts, 0, "")
f.SetX(45)
return true
}
return false
}
+1 -1
View File
@@ -11,7 +11,7 @@ const nav = [
{ 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/invoices', label: 'Transações' },
{ to: '/app/settings', label: 'Definições' },
]
+31 -6
View File
@@ -27,17 +27,12 @@ async function refreshAccessToken(): Promise<string | null> {
}
}
export async function apiFetch<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
async function authorizedFetch(path: string, options: RequestInit = {}): Promise<Response> {
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}`
}
@@ -63,6 +58,21 @@ export async function apiFetch<T>(
}
}
return res
}
export async function apiFetch<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
const res = await authorizedFetch(path, {
...options,
headers: {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
},
})
if (res.status === 204) {
return undefined as T
}
@@ -75,3 +85,18 @@ export async function apiFetch<T>(
return json.data as T
}
export async function apiFetchBlob(path: string, options: RequestInit = {}): Promise<Blob> {
const res = await authorizedFetch(path, options)
if (!res.ok) {
let message = 'Erro ao obter ficheiro'
try {
const json = await res.json()
message = json.error ?? message
} catch {
// ignore non-json responses
}
throw new ApiError(res.status, message)
}
return res.blob()
}
+1 -1
View File
@@ -59,7 +59,7 @@ export interface WorkOrder {
number: number
client_id: string | null
vehicle_id: string | null
status: 'open' | 'in_progress' | 'completed' | 'invoiced' | 'cancelled'
status: 'quote' | 'open' | 'in_progress' | 'completed' | 'invoiced' | 'cancelled'
internal_notes: string
client_notes: string
created_by: string | null
+415 -5
View File
@@ -1,8 +1,418 @@
export default function DashboardPage() {
import { useMemo, useState } from 'react'
import { Link } from 'react-router'
import { useQueries, useQuery } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api'
import type { Client, Expense, Invoice, Staff, WorkOrder, WorkOrderDetail } from '@/lib/types'
const STATUS_LABEL: Record<WorkOrder['status'], string> = {
quote: 'Orçamento',
open: 'Aberta',
in_progress: 'Em Curso',
completed: 'Concluída',
invoiced: 'Faturada',
cancelled: 'Cancelada',
}
const STATUS_ORDER: WorkOrder['status'][] = ['quote', 'open', 'in_progress', 'completed', 'invoiced', 'cancelled']
type PeriodKey = 'month' | '30d' | '90d' | 'year'
const PERIOD_OPTIONS: { key: PeriodKey; label: string }[] = [
{ key: 'month', label: 'Mês atual' },
{ key: '30d', label: '30 dias' },
{ key: '90d', label: '90 dias' },
{ key: 'year', label: '12 meses' },
]
function currency(v: number) {
return new Intl.NumberFormat('pt-PT', { style: 'currency', currency: 'EUR' }).format(v)
}
function monthKey(d: Date) {
return `${d.getFullYear()}-${d.getMonth()}`
}
function getPeriodStart(period: PeriodKey, now = new Date()) {
const start = new Date(now)
if (period === 'month') {
start.setDate(1)
start.setHours(0, 0, 0, 0)
return start
}
if (period === '30d') {
start.setDate(now.getDate() - 30)
return start
}
if (period === '90d') {
start.setDate(now.getDate() - 90)
return start
}
start.setMonth(now.getMonth() - 12)
return start
}
function DashboardCard({
title,
value,
subtitle,
}: {
title: string
value: string
subtitle: string
}) {
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>
<article className="rounded-2xl border border-slate-700/70 bg-slate-900/65 p-4 transition-colors hover:border-slate-500/80">
<p className="text-[11px] uppercase tracking-[0.14em] text-slate-400">{title}</p>
<p className="mt-2 text-2xl font-semibold text-white [font-family:'Space_Grotesk',ui-sans-serif,sans-serif]">
{value}
</p>
<p className="mt-1 text-xs text-slate-400">{subtitle}</p>
</article>
)
}
export default function DashboardPage() {
const [period, setPeriod] = useState<PeriodKey>('month')
const workOrdersQ = useQuery<WorkOrder[]>({
queryKey: ['work-orders'],
queryFn: () => apiFetch<WorkOrder[]>('/work-orders'),
})
const invoicesQ = useQuery<Invoice[]>({
queryKey: ['invoices'],
queryFn: () => apiFetch<Invoice[]>('/invoices'),
})
const expensesQ = useQuery<Expense[]>({
queryKey: ['expenses'],
queryFn: () => apiFetch<Expense[]>('/expenses'),
})
const staffQ = useQuery<Staff[]>({
queryKey: ['staff'],
queryFn: () => apiFetch<Staff[]>('/staff'),
})
const clientsQ = useQuery<Client[]>({
queryKey: ['clients'],
queryFn: () => apiFetch<Client[]>('/clients'),
})
const workOrders = workOrdersQ.data ?? []
const invoices = invoicesQ.data ?? []
const expenses = expensesQ.data ?? []
const staff = staffQ.data ?? []
const clients = clientsQ.data ?? []
const invoiceDocs = invoices.filter((doc) => doc.type === 'invoice')
const invoiceTotalsQ = useQueries({
queries: invoiceDocs.map((doc) => ({
queryKey: ['work-order-detail', doc.work_order_id, 'dashboard-total'],
queryFn: () => apiFetch<WorkOrderDetail>(`/work-orders/${doc.work_order_id}`),
staleTime: 5 * 60 * 1000,
})),
})
const isLoading = [workOrdersQ, invoicesQ, expensesQ, staffQ, clientsQ].some((q) => q.isLoading)
const hasError = [workOrdersQ, invoicesQ, expensesQ, staffQ, clientsQ].some((q) => q.isError)
const entriesLoading = invoiceTotalsQ.some((q) => q.isLoading)
const entriesError = invoiceTotalsQ.some((q) => q.isError)
const workOrderTotals = useMemo(() => {
const totals: Record<string, number> = {}
for (let i = 0; i < invoiceDocs.length; i++) {
const q = invoiceTotalsQ[i]
const doc = invoiceDocs[i]
if (!q?.data || !doc) continue
const items = q.data.items.reduce((sum, item) => sum + item.total, 0)
const hours = q.data.staff_hours.reduce((sum, h) => sum + h.total, 0)
totals[doc.work_order_id] = items + hours
}
return totals
}, [invoiceDocs, invoiceTotalsQ])
const metrics = useMemo(() => {
const now = new Date()
const periodStart = getPeriodStart(period, now)
const workOrdersPeriod = workOrders.filter((wo) => new Date(wo.created_at) >= periodStart)
const invoicesPeriod = invoices.filter((inv) => new Date(inv.issued_at) >= periodStart)
const expensesPeriod = expenses.filter((exp) => new Date(exp.date) >= periodStart)
const invoicesMonth = invoices.filter((inv) => monthKey(new Date(inv.issued_at)) === monthKey(now))
const expensesMonth = expenses.filter((exp) => monthKey(new Date(exp.date)) === monthKey(now))
const byStatus = Object.fromEntries(STATUS_ORDER.map((s) => [s, 0])) as Record<WorkOrder['status'], number>
for (const wo of workOrdersPeriod) byStatus[wo.status] += 1
const activeOrders = byStatus.quote + byStatus.open + byStatus.in_progress
const periodExpenses = expensesPeriod.reduce((sum, e) => sum + e.amount, 0)
const monthExpenses = expensesMonth.reduce((sum, e) => sum + e.amount, 0)
const monthDocs = invoicesMonth.length
const activeStaff = staff.filter((s) => s.active).length
const periodEntries = invoicesPeriod
.filter((inv) => inv.type === 'invoice')
.reduce((sum, inv) => sum + (workOrderTotals[inv.work_order_id] ?? 0), 0)
const monthEntries = invoicesMonth
.filter((inv) => inv.type === 'invoice')
.reduce((sum, inv) => sum + (workOrderTotals[inv.work_order_id] ?? 0), 0)
const recentOrders = [...workOrdersPeriod]
.sort((a, b) => +new Date(b.updated_at) - +new Date(a.updated_at))
.slice(0, 6)
const recentDocs = [...invoicesPeriod]
.sort((a, b) => +new Date(b.issued_at) - +new Date(a.issued_at))
.slice(0, 6)
return {
byStatus,
activeOrders,
periodExpenses,
periodEntries,
monthEntries,
monthExpenses,
monthDocs,
activeStaff,
recentOrders,
recentDocs,
periodOrdersCount: workOrdersPeriod.length,
periodDocsCount: invoicesPeriod.length,
}
}, [period, workOrders, invoices, expenses, staff, workOrderTotals])
const entriesVsExpensesMax = Math.max(metrics.periodEntries, metrics.periodExpenses, 1)
const entriesBarWidth = (metrics.periodEntries / entriesVsExpensesMax) * 100
const expensesBarWidth = (metrics.periodExpenses / entriesVsExpensesMax) * 100
return (
<section className="space-y-6 [font-family:'Sora',ui-sans-serif,sans-serif]">
<header className="relative overflow-hidden rounded-2xl border border-slate-700/60 bg-gradient-to-br from-slate-900 via-slate-900 to-sky-950/35 p-6">
<div className="absolute -right-16 -top-16 h-44 w-44 rounded-full bg-cyan-600/10 blur-2xl" />
<div className="absolute -left-12 bottom-0 h-32 w-32 rounded-full bg-amber-500/10 blur-2xl" />
<p className="text-xs uppercase tracking-[0.2em] text-slate-400">Painel Operacional</p>
<h1 className="mt-1 text-3xl font-semibold text-white [font-family:'Space_Grotesk',ui-sans-serif,sans-serif]">
Dashboard da Oficina
</h1>
<p className="mt-2 max-w-2xl text-sm text-slate-300">
Visão rápida do trabalho em curso, faturação e despesas para apoiar as decisões do dia.
</p>
<div className="mt-4 flex flex-wrap gap-2">
{PERIOD_OPTIONS.map((opt) => (
<button
key={opt.key}
onClick={() => setPeriod(opt.key)}
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
period === opt.key
? 'border-cyan-400/80 bg-cyan-500/20 text-cyan-100'
: 'border-slate-700 bg-slate-900/80 text-slate-300 hover:border-slate-500 hover:text-white'
}`}
>
{opt.label}
</button>
))}
</div>
</header>
{isLoading && (
<p className="rounded-xl border border-slate-700 bg-slate-900/60 px-4 py-3 text-sm text-slate-300">
A carregar métricas do dashboard...
</p>
)}
{hasError && !isLoading && (
<p className="rounded-xl border border-red-900/80 bg-red-950/40 px-4 py-3 text-sm text-red-200">
Alguns dados não foram carregados. As métricas visíveis podem estar incompletas.
</p>
)}
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<DashboardCard
title="OTs Ativas"
value={String(metrics.activeOrders)}
subtitle={`No período (${metrics.periodOrdersCount} OTs)`}
/>
<DashboardCard
title="Clientes"
value={String(clients.length)}
subtitle="Base total de clientes"
/>
<DashboardCard
title="Técnicos Ativos"
value={`${metrics.activeStaff}/${staff.length}`}
subtitle="Recursos disponíveis"
/>
<DashboardCard
title="Despesas do Período"
value={currency(metrics.periodExpenses)}
subtitle="Somatório no filtro selecionado"
/>
</div>
<article className="rounded-2xl border border-slate-700/70 bg-slate-900/65 p-5">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-semibold text-white">Entradas Recebidas vs Despesas</h2>
<span className="text-xs text-slate-400">
{PERIOD_OPTIONS.find((p) => p.key === period)?.label}
</span>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-lg border border-emerald-800/60 bg-emerald-950/20 p-3">
<p className="text-xs uppercase tracking-wide text-emerald-300">Entradas (faturas)</p>
<p className="mt-1 text-2xl font-semibold text-white">{currency(metrics.periodEntries)}</p>
</div>
<div className="rounded-lg border border-rose-800/60 bg-rose-950/20 p-3">
<p className="text-xs uppercase tracking-wide text-rose-300">Despesas</p>
<p className="mt-1 text-2xl font-semibold text-white">{currency(metrics.periodExpenses)}</p>
</div>
</div>
<div className="mt-4 space-y-3">
<div>
<div className="mb-1 flex justify-between text-xs">
<span className="text-slate-300">Entradas recebidas</span>
<span className="font-mono text-emerald-300">{currency(metrics.periodEntries)}</span>
</div>
<div className="h-3 rounded-full bg-slate-800">
<div className="h-3 rounded-full bg-gradient-to-r from-emerald-500 to-teal-400" style={{ width: `${entriesBarWidth}%` }} />
</div>
</div>
<div>
<div className="mb-1 flex justify-between text-xs">
<span className="text-slate-300">Despesas registadas</span>
<span className="font-mono text-rose-300">{currency(metrics.periodExpenses)}</span>
</div>
<div className="h-3 rounded-full bg-slate-800">
<div className="h-3 rounded-full bg-gradient-to-r from-rose-500 to-orange-400" style={{ width: `${expensesBarWidth}%` }} />
</div>
</div>
</div>
<p className="mt-4 text-sm text-slate-300">
Saldo estimado: <span className={metrics.periodEntries - metrics.periodExpenses >= 0 ? 'text-emerald-300' : 'text-rose-300'}>
{currency(metrics.periodEntries - metrics.periodExpenses)}
</span>
</p>
{(entriesLoading || entriesError) && (
<p className="mt-2 text-xs text-slate-400">
{entriesLoading ? 'A calcular totais de entradas...' : 'Algumas entradas não puderam ser calculadas.'}
</p>
)}
</article>
<div className="grid items-stretch gap-4 xl:grid-cols-2">
<article className="h-full rounded-2xl border border-slate-700/70 bg-slate-900/65 p-5">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-semibold text-white">Fluxo das Ordens de Trabalho</h2>
<Link to="/app/work-orders" className="text-xs text-cyan-300 hover:text-cyan-200">
Ver todas
</Link>
</div>
<div className="space-y-3">
{STATUS_ORDER.map((status) => {
const count = metrics.byStatus[status]
const total = Math.max(metrics.periodOrdersCount, 1)
const width = Math.max((count / total) * 100, count > 0 ? 6 : 0)
return (
<div key={status}>
<div className="mb-1 flex justify-between text-xs">
<span className="text-slate-300">{STATUS_LABEL[status]}</span>
<span className="font-mono text-slate-400">{count}</span>
</div>
<div className="h-2 rounded-full bg-slate-800">
<div className="h-2 rounded-full bg-gradient-to-r from-cyan-500 to-emerald-500" style={{ width: `${width}%` }} />
</div>
</div>
)
})}
</div>
</article>
<article className="h-full rounded-2xl border border-slate-700/70 bg-slate-900/65 p-5">
<h2 className="text-base font-semibold text-white">Faturação e Prioridades</h2>
<div className="mt-4 space-y-3 text-sm">
<div className="rounded-lg border border-slate-700 bg-slate-900/70 px-3 py-2">
<p className="text-slate-400">Documentos emitidos no período</p>
<p className="text-xl font-semibold text-white">{metrics.periodDocsCount}</p>
</div>
<div className="rounded-lg border border-amber-800/70 bg-amber-950/30 px-3 py-2 text-amber-100">
<p className="text-xs uppercase tracking-wide">Ação recomendada</p>
<p className="mt-1">
{metrics.byStatus.completed > 0
? `${metrics.byStatus.completed} OT(s) concluída(s) pronta(s) para faturar.`
: 'Sem OTs concluídas pendentes de faturação.'}
</p>
</div>
<div className="rounded-lg border border-cyan-900/70 bg-cyan-950/25 px-3 py-2 text-cyan-100">
<p className="text-xs uppercase tracking-wide">Em orçamento</p>
<p className="mt-1">
{metrics.byStatus.quote > 0
? `${metrics.byStatus.quote} OT(s) em orçamento aguardam aprovação.`
: 'Não existem OTs pendentes em orçamento.'}
</p>
</div>
<div className="rounded-lg border border-slate-700 bg-slate-900/70 px-3 py-2">
<p className="text-slate-400">Contexto mensal</p>
<p className="text-slate-200 mt-1 text-xs">
Entradas no mês: {currency(metrics.monthEntries)} | Despesas no mês: {currency(metrics.monthExpenses)} | Documentos: {metrics.monthDocs}
</p>
</div>
</div>
</article>
</div>
<div className="grid gap-4 xl:grid-cols-2">
<article className="rounded-2xl border border-slate-700/70 bg-slate-900/65 p-5">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-base font-semibold text-white">Atividade Recente de OTs</h2>
<Link to="/app/work-orders" className="text-xs text-cyan-300 hover:text-cyan-200">
Abrir OTs
</Link>
</div>
{metrics.recentOrders.length === 0 ? (
<p className="text-sm text-slate-400">Ainda não existem ordens de trabalho.</p>
) : (
<ul className="divide-y divide-slate-800">
{metrics.recentOrders.map((wo) => (
<li key={wo.id} className="flex items-center justify-between py-2">
<div>
<p className="text-sm font-medium text-white">OT #{wo.number}</p>
<p className="text-xs text-slate-400">{STATUS_LABEL[wo.status]}</p>
</div>
<span className="text-xs text-slate-500">
{new Intl.DateTimeFormat('pt-PT', { dateStyle: 'short', timeStyle: 'short' }).format(new Date(wo.updated_at))}
</span>
</li>
))}
</ul>
)}
</article>
<article className="rounded-2xl border border-slate-700/70 bg-slate-900/65 p-5">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-base font-semibold text-white">Documentos Recentes</h2>
<Link to="/app/invoices" className="text-xs text-cyan-300 hover:text-cyan-200">
Abrir Faturação
</Link>
</div>
{metrics.recentDocs.length === 0 ? (
<p className="text-sm text-slate-400">Sem documentos emitidos.</p>
) : (
<ul className="divide-y divide-slate-800">
{metrics.recentDocs.map((doc) => (
<li key={doc.id} className="flex items-center justify-between py-2">
<div>
<p className="text-sm font-medium text-white">
{doc.type === 'invoice' ? 'Fatura' : 'Orçamento'} #{doc.number}
</p>
<p className="text-xs text-slate-400 font-mono">OT {doc.work_order_id.slice(0, 8)}...</p>
</div>
<span className="text-xs text-slate-500">
{new Intl.DateTimeFormat('pt-PT', { dateStyle: 'short' }).format(new Date(doc.issued_at))}
</span>
</li>
))}
</ul>
)}
</article>
</div>
</section>
)
}
+93 -34
View File
@@ -1,22 +1,31 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api'
import { apiFetch, apiFetchBlob } from '@/lib/api'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import type { Invoice, WorkOrder } from '@/lib/types'
const TYPE_LABELS: Record<string, string> = { quote: 'Orçamento', invoice: 'Fatura' }
const STATUS_LABELS: Record<string, string> = {
quote: 'Orçamento',
open: 'Aberta',
in_progress: 'Em Curso',
completed: 'Concluída',
invoiced: 'Faturada',
cancelled: 'Cancelada',
}
export default function InvoicesPage() {
const qc = useQueryClient()
const [showGenerate, setShowGenerate] = useState(false)
const [selectedWO, setSelectedWO] = useState('')
const [docType, setDocType] = useState<'quote' | 'invoice'>('quote')
const [viewError, setViewError] = useState('')
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
queryKey: ['invoices'],
queryFn: () => apiFetch<Invoice[]>('/invoices'),
})
const quoteDocs = invoices.filter((i) => i.type === 'quote')
const invoiceDocs = invoices.filter((i) => i.type === 'invoice')
const { data: workOrders = [] } = useQuery<WorkOrder[]>({
queryKey: ['work-orders-for-invoice'],
@@ -25,8 +34,9 @@ export default function InvoicesPage() {
})
const eligibleWOs = workOrders.filter((wo) =>
wo.status === 'open' || wo.status === 'in_progress' || wo.status === 'completed'
wo.status === 'quote' || wo.status === 'open' || wo.status === 'in_progress' || wo.status === 'completed'
)
const eligibleSorted = [...eligibleWOs].sort((a, b) => b.number - a.number)
const generate = useMutation({
mutationFn: () =>
@@ -42,14 +52,28 @@ export default function InvoicesPage() {
},
})
async function openPDF(inv: Invoice) {
setViewError('')
try {
const blob = await apiFetchBlob(`/invoices/${inv.id}/pdf`)
const url = URL.createObjectURL(blob)
window.open(url, '_blank', 'noopener,noreferrer')
setTimeout(() => URL.revokeObjectURL(url), 60_000)
} catch (err) {
setViewError((err as Error).message)
}
}
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>
<h1 className="text-2xl font-bold text-white">Transações</h1>
<p className="text-slate-400 text-sm mt-0.5">
{invoices.length} documentos emitidos (orçamentos e faturas)
</p>
</div>
<Button onClick={() => setShowGenerate(true)}>Gerar Documento</Button>
<Button onClick={() => setShowGenerate(true)}>Nova Transação</Button>
</div>
{showGenerate && (
@@ -75,9 +99,9 @@ export default function InvoicesPage() {
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) => (
{eligibleSorted.map((wo) => (
<option key={wo.id} value={wo.id}>
#{wo.number} ({wo.status})
#{wo.number} ({STATUS_LABELS[wo.status] ?? wo.status})
</option>
))}
</select>
@@ -100,50 +124,85 @@ export default function InvoicesPage() {
</div>
)}
{viewError && (
<p className="mb-4 text-red-400 text-sm">{viewError}</p>
)}
{isLoading ? (
<p className="text-slate-400">A carregar...</p>
) : invoices.length === 0 ? (
<p className="text-slate-500 text-sm">Nenhum documento gerado.</p>
) : (
<div className="grid gap-6 xl:grid-cols-2">
<div className="rounded-lg border border-slate-700 overflow-hidden">
<div className="bg-slate-800 px-4 py-3 flex items-center justify-between">
<h2 className="text-sm font-semibold text-white">Orçamentos</h2>
<Badge variant="secondary">{quoteDocs.length}</Badge>
</div>
{quoteDocs.length === 0 ? (
<p className="px-4 py-5 text-slate-500 text-sm">Sem orçamentos gerados.</p>
) : (
<table className="w-full text-sm">
<thead className="bg-slate-800">
<thead className="bg-slate-900/80">
<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>
<th className="text-left px-4 py-2 text-slate-400 font-medium"></th>
<th className="text-left px-4 py-2 text-slate-400 font-medium">OT</th>
<th className="text-left px-4 py-2 text-slate-400 font-medium">Emitida</th>
<th className="px-4 py-2"></th>
</tr>
</thead>
<tbody>
{invoices.map((inv) => (
{quoteDocs.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">
<td className="px-4 py-2 text-white font-mono">#{inv.number}</td>
<td className="px-4 py-2 text-slate-400 font-mono text-xs">{inv.work_order_id.slice(0, 8)}</td>
<td className="px-4 py-2 text-slate-400">
{new Intl.DateTimeFormat('pt-PT').format(new Date(inv.issued_at))}
</td>
<td className="px-4 py-3 text-right">
<Button
size="sm"
variant="outline"
onClick={() => window.open(`/api/v1/invoices/${inv.id}/pdf`, '_blank')}
>
Descarregar PDF
</Button>
<td className="px-4 py-2 text-right">
<Button size="sm" variant="outline" onClick={() => openPDF(inv)}>Ver PDF</Button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="rounded-lg border border-slate-700 overflow-hidden">
<div className="bg-slate-800 px-4 py-3 flex items-center justify-between">
<h2 className="text-sm font-semibold text-white">Faturas</h2>
<Badge>{invoiceDocs.length}</Badge>
</div>
{invoiceDocs.length === 0 ? (
<p className="px-4 py-5 text-slate-500 text-sm">Sem faturas geradas.</p>
) : (
<table className="w-full text-sm">
<thead className="bg-slate-900/80">
<tr>
<th className="text-left px-4 py-2 text-slate-400 font-medium"></th>
<th className="text-left px-4 py-2 text-slate-400 font-medium">OT</th>
<th className="text-left px-4 py-2 text-slate-400 font-medium">Emitida</th>
<th className="px-4 py-2"></th>
</tr>
</thead>
<tbody>
{invoiceDocs.map((inv) => (
<tr key={inv.id} className="border-t border-slate-700 hover:bg-slate-800/50">
<td className="px-4 py-2 text-white font-mono">#{inv.number}</td>
<td className="px-4 py-2 text-slate-400 font-mono text-xs">{inv.work_order_id.slice(0, 8)}</td>
<td className="px-4 py-2 text-slate-400">
{new Intl.DateTimeFormat('pt-PT').format(new Date(inv.issued_at))}
</td>
<td className="px-4 py-2 text-right">
<Button size="sm" variant="outline" onClick={() => openPDF(inv)}>Ver PDF</Button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
)}
</div>
+22
View File
@@ -14,6 +14,7 @@ type FormData = {
company_iban: string
company_phone: string
company_email: string
company_logo: string
}
const defaultValues: FormData = {
@@ -23,6 +24,7 @@ const defaultValues: FormData = {
company_iban: '',
company_phone: '',
company_email: '',
company_logo: '',
}
export default function SettingsPage() {
@@ -44,6 +46,7 @@ export default function SettingsPage() {
company_iban: settings['company_iban'] ?? '',
company_phone: settings['company_phone'] ?? '',
company_email: settings['company_email'] ?? '',
company_logo: settings['company_logo'] ?? '',
})
}
}, [settings, reset])
@@ -101,6 +104,25 @@ export default function SettingsPage() {
className="bg-slate-900 border-slate-600 text-white" />
</div>
</div>
<div className="space-y-1">
<Label htmlFor="company_logo">Logotipo</Label>
<Input id="company_logo" {...register('company_logo')}
placeholder="https://.../logo.png ou data:image/png;base64,..."
className="bg-slate-900 border-slate-600 text-white" />
<p className="text-xs text-slate-500">
Este logotipo será impresso em Orçamentos e Faturas.
</p>
{settings?.company_logo && (
<div className="mt-2 rounded-md border border-slate-700 p-3 bg-slate-900/50">
<p className="text-xs text-slate-400 mb-2">Pré-visualização:</p>
<img
src={settings.company_logo}
alt="Logotipo da oficina"
className="h-12 object-contain bg-white/90 p-1 rounded"
/>
</div>
)}
</div>
{save.error && (
<p className="text-red-400 text-sm">{(save.error as Error).message}</p>
@@ -27,6 +27,7 @@ const CATEGORY_LABEL: Record<string, string> = {
}
const STATUS_LABELS: Record<string, string> = {
quote: 'Orçamento',
open: 'Aberta',
in_progress: 'Em Curso',
completed: 'Concluída',
@@ -35,6 +36,7 @@ const STATUS_LABELS: Record<string, string> = {
}
const TRANSITIONS: Record<string, string[]> = {
quote: ['open', 'cancelled'],
open: ['in_progress', 'cancelled'],
in_progress: ['completed', 'cancelled'],
completed: ['invoiced', 'cancelled'],
+3 -1
View File
@@ -12,6 +12,7 @@ import { Label } from '@/components/ui/label'
import type { WorkOrder, Client, Vehicle } from '@/lib/types'
const STATUS_LABELS: Record<string, string> = {
quote: 'Orçamento',
open: 'Aberta',
in_progress: 'Em Curso',
completed: 'Concluída',
@@ -20,6 +21,7 @@ const STATUS_LABELS: Record<string, string> = {
}
const STATUS_VARIANT: Record<string, 'default' | 'secondary' | 'destructive'> = {
quote: 'secondary',
open: 'secondary',
in_progress: 'default',
completed: 'default',
@@ -85,7 +87,7 @@ export default function WorkOrdersPage() {
</div>
<div className="flex gap-2 mb-4">
{['', 'open', 'in_progress', 'completed', 'invoiced', 'cancelled'].map((s) => (
{['', 'quote', 'open', 'in_progress', 'completed', 'invoiced', 'cancelled'].map((s) => (
<button
key={s}
onClick={() => setStatusFilter(s)}