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

This commit is contained in:
Luciano Milani
2026-07-02 22:19:03 +01:00
parent 8231bba3f9
commit 9bf176b385
48 changed files with 3469 additions and 599 deletions
+111 -68
View File
@@ -5,8 +5,11 @@ import (
"os"
"path/filepath"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/techxcar/backend/internal/auth"
"github.com/techxcar/backend/internal/settings"
"github.com/techxcar/backend/internal/workorder"
@@ -83,21 +86,18 @@ func createInvoiceH() fiber.Handler {
return fiber.NewError(404, "ordem de trabalho não encontrada")
}
sett, err := settings.GetSettings(c.Context(), conn)
if err != nil {
return fiber.NewError(500, "erro ao obter definições")
}
var clientName, clientNIF, vehiclePlate string
if detail.ClientID != nil {
_ = conn.QueryRow(c.Context(),
`SELECT name, COALESCE(nif,'') FROM clients WHERE id = $1`,
*detail.ClientID).Scan(&clientName, &clientNIF)
}
if detail.VehicleID != nil {
_ = conn.QueryRow(c.Context(),
`SELECT plate FROM vehicles WHERE id = $1`,
*detail.VehicleID).Scan(&vehiclePlate)
if b.Type == "invoice" {
if detail.PaymentMethod == "" {
return fiber.NewError(400, "defina o meio de pagamento na OT antes de faturar")
}
var existingID string
if err := conn.QueryRow(c.Context(),
`SELECT id FROM invoices WHERE work_order_id=$1 AND type='invoice' ORDER BY issued_at DESC LIMIT 1`,
b.WorkOrderID).Scan(&existingID); err == nil && existingID != "" {
return fiber.NewError(409, "já existe uma fatura para esta OT")
} else if err != nil && err != pgx.ErrNoRows {
return fiber.NewError(500, "erro ao validar faturação existente")
}
}
inv, err := CreateInvoice(c.Context(), conn, b.WorkOrderID, b.Type)
@@ -105,61 +105,27 @@ func createInvoiceH() fiber.Handler {
return fiber.NewError(500, "erro ao criar fatura")
}
docType := "Orçamento"
prefix := "ORC"
if b.Type == "invoice" {
docType = "Fatura"
prefix = "FAT"
}
outPath := filepath.Join(storageRoot, tenantDir(c), fmt.Sprintf("inv_%s.pdf", inv.ID))
meta := pdf.DocMeta{
CompanyName: sett["company_name"],
CompanyNIF: sett["company_nif"],
CompanyAddress: sett["company_address"],
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"),
ClientName: clientName,
ClientNIF: clientNIF,
VehiclePlate: vehiclePlate,
}
lineItems := make([]pdf.LineItem, len(detail.Items))
for i, item := range detail.Items {
lineItems[i] = pdf.LineItem{
Description: item.Description,
Qty: item.Qty,
UnitPrice: item.UnitPrice,
DiscountPct: item.DiscountPct,
Total: item.Total,
paymentMethod := detail.PaymentMethod
paymentDate := ""
if detail.PaymentDate != nil {
paymentDate = detail.PaymentDate.Format("2006-01-02")
}
}
var staffTotal float64
for _, sh := range detail.StaffHours {
staffTotal += sh.Total
}
if err := pdf.Generate(meta, lineItems, staffTotal, outPath); err != nil {
return fiber.NewError(500, "erro ao gerar PDF")
}
if err := SetPDFPath(c.Context(), conn, inv.ID, outPath); err != nil {
return fiber.NewError(500, "erro ao registar caminho do PDF")
}
inv.PDFPath = outPath
if b.Type == "invoice" {
if _, err := workorder.TransitionStatus(c.Context(), conn, b.WorkOrderID, "invoiced", ""); err != nil {
if paymentDate == "" {
paymentDate = time.Now().Format("2006-01-02")
}
if _, err := workorder.TransitionStatus(c.Context(), conn, b.WorkOrderID, "invoiced", paymentMethod, paymentDate, ""); err != nil {
_, _ = conn.Exec(c.Context(), `DELETE FROM invoices WHERE id=$1`, inv.ID)
return fiber.NewError(500, "erro ao atualizar estado da ordem")
}
}
outPath, err := regeneratePDF(c, conn, inv)
if err != nil {
return fiber.NewError(500, "erro ao gerar PDF")
}
inv.PDFPath = outPath
return c.Status(201).JSON(fiber.Map{"data": inv, "error": nil})
}
}
@@ -175,13 +141,90 @@ func downloadPDFH() fiber.Handler {
if inv == nil {
return fiber.NewError(404, "fatura não encontrada")
}
if inv.PDFPath == "" {
return fiber.NewError(404, "PDF não disponível")
// Regenerate on download so layout/branding fixes are reflected on existing docs.
outPath, err := regeneratePDF(c, conn, inv)
if err != nil {
return fiber.NewError(500, "erro ao gerar PDF")
}
if _, err := os.Stat(inv.PDFPath); os.IsNotExist(err) {
if _, err := os.Stat(outPath); os.IsNotExist(err) {
return fiber.NewError(404, "ficheiro PDF não encontrado")
}
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="invoice_%d.pdf"`, inv.Number))
return c.SendFile(inv.PDFPath)
return c.SendFile(outPath)
}
}
func regeneratePDF(c *fiber.Ctx, conn *pgxpool.Conn, inv *Invoice) (string, error) {
detail, err := workorder.GetWorkOrderDetail(c.Context(), conn, inv.WorkOrderID)
if err != nil {
return "", fmt.Errorf("erro ao obter ordem de trabalho: %w", err)
}
if detail == nil {
return "", fmt.Errorf("ordem de trabalho não encontrada")
}
sett, err := settings.GetSettings(c.Context(), conn)
if err != nil {
return "", fmt.Errorf("erro ao obter definições: %w", err)
}
var clientName, clientNIF, vehiclePlate string
if detail.ClientID != nil {
_ = conn.QueryRow(c.Context(),
`SELECT name, COALESCE(nif,'') FROM clients WHERE id = $1`,
*detail.ClientID).Scan(&clientName, &clientNIF)
}
if detail.VehicleID != nil {
_ = conn.QueryRow(c.Context(),
`SELECT plate FROM vehicles WHERE id = $1`,
*detail.VehicleID).Scan(&vehiclePlate)
}
docType := "Orçamento"
prefix := "ORC"
if inv.Type == "invoice" {
docType = "Fatura"
prefix = "FAT"
}
outPath := filepath.Join(storageRoot, tenantDir(c), fmt.Sprintf("inv_%s.pdf", inv.ID))
meta := pdf.DocMeta{
CompanyName: sett["company_name"],
CompanyNIF: sett["company_nif"],
CompanyAddress: sett["company_address"],
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"),
ClientName: clientName,
ClientNIF: clientNIF,
VehiclePlate: vehiclePlate,
}
lineItems := make([]pdf.LineItem, len(detail.Items))
for i, item := range detail.Items {
lineItems[i] = pdf.LineItem{
Description: item.Description,
Qty: item.Qty,
UnitPrice: item.UnitPrice,
DiscountPct: item.DiscountPct,
Total: item.Total,
}
}
var staffTotal float64
for _, sh := range detail.StaffHours {
staffTotal += sh.Total
}
if err := pdf.Generate(meta, lineItems, staffTotal, outPath); err != nil {
return "", err
}
if err := SetPDFPath(c.Context(), conn, inv.ID, outPath); err != nil {
return "", err
}
return outPath, nil
}
@@ -0,0 +1,60 @@
package platformsettings
import (
"github.com/gofiber/fiber/v2"
"github.com/techxcar/backend/internal/auth"
"github.com/techxcar/backend/pkg/database"
)
func RegisterRoutes(app *fiber.App, db *database.DB, secret string) {
app.Get("/api/v1/platform/settings", getPublicSettingsH(db))
admin := []fiber.Handler{
auth.RequireAuth(secret),
auth.RequireRole("super_admin"),
}
app.Get("/api/v1/admin/settings", append(admin, getAdminSettingsH(db))...)
app.Put("/api/v1/admin/settings", append(admin, updateAdminSettingsH(db))...)
}
func getPublicSettingsH(db *database.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
s, err := GetSettings(c.Context(), db.Pool)
if err != nil {
return fiber.NewError(500, "erro ao obter definições globais")
}
return c.JSON(fiber.Map{"data": s, "error": nil})
}
}
func getAdminSettingsH(db *database.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
s, err := GetSettings(c.Context(), db.Pool)
if err != nil {
return fiber.NewError(500, "erro ao obter definições globais")
}
return c.JSON(fiber.Map{"data": s, "error": nil})
}
}
func updateAdminSettingsH(db *database.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
var body map[string]string
if err := c.BodyParser(&body); err != nil {
return fiber.NewError(400, "corpo do pedido inválido")
}
for k, v := range body {
if !AllowedKeys[k] {
return fiber.NewError(400, "chave inválida: "+k)
}
if err := SetSetting(c.Context(), db.Pool, k, v); err != nil {
return fiber.NewError(500, "erro ao guardar definição: "+k)
}
}
s, err := GetSettings(c.Context(), db.Pool)
if err != nil {
return fiber.NewError(500, "erro ao obter definições globais")
}
return c.JSON(fiber.Map{"data": s, "error": nil})
}
}
@@ -0,0 +1,44 @@
package platformsettings
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
var AllowedKeys = map[string]bool{
"platform_name": true,
"platform_subtitle": true,
"platform_logo": true,
"admin_primary_color": true,
"admin_accent_color": true,
}
func GetSettings(ctx context.Context, pool *pgxpool.Pool) (map[string]string, error) {
rows, err := pool.Query(ctx, `SELECT key, value FROM public.platform_settings`)
if err != nil {
return nil, fmt.Errorf("platform settings: get: %w", err)
}
defer rows.Close()
result := map[string]string{}
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return nil, fmt.Errorf("platform settings: scan: %w", err)
}
result[k] = v
}
return result, rows.Err()
}
func SetSetting(ctx context.Context, pool *pgxpool.Pool, key, value string) error {
_, err := pool.Exec(ctx,
`INSERT INTO public.platform_settings (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`,
key, value)
if err != nil {
return fmt.Errorf("platform settings: set %s: %w", key, err)
}
return nil
}
+2
View File
@@ -13,6 +13,7 @@ import (
"github.com/techxcar/backend/internal/config"
"github.com/techxcar/backend/internal/expense"
"github.com/techxcar/backend/internal/invoice"
"github.com/techxcar/backend/internal/platformsettings"
"github.com/techxcar/backend/internal/settings"
"github.com/techxcar/backend/internal/staff"
"github.com/techxcar/backend/internal/tenant"
@@ -55,6 +56,7 @@ func New(deps Deps) *fiber.App {
staff.RegisterRoutes(app, deps.DB, deps.Config.JWTSecret)
expense.RegisterRoutes(app, deps.DB, deps.Config.JWTSecret)
settings.RegisterRoutes(app, deps.DB, deps.Config.JWTSecret)
platformsettings.RegisterRoutes(app, deps.DB, deps.Config.JWTSecret)
invoice.RegisterRoutes(app, deps.DB, deps.Config.JWTSecret)
}
+1 -1
View File
@@ -9,7 +9,7 @@ import (
func RegisterRoutes(app *fiber.App, db *database.DB, secret string) {
read := []fiber.Handler{
auth.RequireAuth(secret),
auth.RequireRole("tenant_admin", "manager"),
auth.RequireRole("tenant_admin", "manager", "technician"),
auth.TenantMiddleware(db),
}
admin := []fiber.Handler{
+1
View File
@@ -15,6 +15,7 @@ var AllowedKeys = map[string]bool{
"company_phone": true,
"company_email": true,
"company_logo": true,
"ui_theme": true,
}
func GetSettings(ctx context.Context, conn *pgxpool.Conn) (map[string]string, error) {
+107 -11
View File
@@ -2,6 +2,7 @@ package workorder
import (
"errors"
"time"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5"
@@ -53,6 +54,28 @@ type woBody struct {
VehicleID string `json:"vehicle_id"`
InternalNotes string `json:"internal_notes"`
ClientNotes string `json:"client_notes"`
ETADays int `json:"eta_days"`
RealDeadline string `json:"real_deadline"`
PaymentMethod string `json:"payment_method"`
PaymentDate string `json:"payment_date"`
}
func isValidETADays(v int) bool {
switch v {
case 1, 2, 3, 7, 15, 30, 31:
return true
default:
return false
}
}
func isValidPaymentMethod(v string) bool {
switch v {
case "", "numerario", "multibanco", "transferencia_bancaria", "mb_way", "cartao_debito", "cartao_credito", "cheque", "outro":
return true
default:
return false
}
}
func createWOH() fiber.Handler {
@@ -61,13 +84,32 @@ func createWOH() fiber.Handler {
if err := c.BodyParser(&b); err != nil {
return fiber.NewError(400, "corpo inválido")
}
if b.ETADays == 0 {
b.ETADays = 1
}
if !isValidETADays(b.ETADays) {
return fiber.NewError(400, "eta_days inválido")
}
if b.RealDeadline != "" {
if _, err := time.Parse("2006-01-02", b.RealDeadline); err != nil {
return fiber.NewError(400, "real_deadline inválido (YYYY-MM-DD)")
}
}
if !isValidPaymentMethod(b.PaymentMethod) {
return fiber.NewError(400, "payment_method inválido")
}
if b.PaymentDate != "" {
if _, err := time.Parse("2006-01-02", b.PaymentDate); err != nil {
return fiber.NewError(400, "payment_date inválido (YYYY-MM-DD)")
}
}
claims, _ := c.Locals("claims").(*auth.Claims)
createdBy := ""
if claims != nil {
createdBy = claims.UserID
}
conn := auth.GetConn(c)
wo, err := CreateWorkOrder(c.Context(), conn, b.ClientID, b.VehicleID, b.InternalNotes, createdBy)
wo, err := CreateWorkOrder(c.Context(), conn, b.ClientID, b.VehicleID, b.InternalNotes, b.ClientNotes, b.ETADays, b.RealDeadline, createdBy)
if err != nil {
return fiber.NewError(500, "erro ao criar ordem")
}
@@ -95,11 +137,30 @@ func updateWOH() fiber.Handler {
if err := c.BodyParser(&b); err != nil {
return fiber.NewError(400, "corpo inválido")
}
if b.ETADays == 0 {
b.ETADays = 1
}
if !isValidETADays(b.ETADays) {
return fiber.NewError(400, "eta_days inválido")
}
if b.RealDeadline != "" {
if _, err := time.Parse("2006-01-02", b.RealDeadline); err != nil {
return fiber.NewError(400, "real_deadline inválido (YYYY-MM-DD)")
}
}
if !isValidPaymentMethod(b.PaymentMethod) {
return fiber.NewError(400, "payment_method inválido")
}
if b.PaymentDate != "" {
if _, err := time.Parse("2006-01-02", b.PaymentDate); err != nil {
return fiber.NewError(400, "payment_date inválido (YYYY-MM-DD)")
}
}
conn := auth.GetConn(c)
wo, err := UpdateWorkOrder(c.Context(), conn, c.Params("id"), b.ClientID, b.VehicleID, b.InternalNotes, b.ClientNotes)
wo, err := UpdateWorkOrder(c.Context(), conn, c.Params("id"), b.ClientID, b.VehicleID, b.InternalNotes, b.ClientNotes, b.ETADays, b.RealDeadline, b.PaymentMethod, b.PaymentDate)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return fiber.NewError(404, "ordem não encontrada")
return fiber.NewError(400, "ordem não editável (cancelada/faturada) ou não encontrada")
}
return fiber.NewError(500, "erro ao actualizar ordem")
}
@@ -110,18 +171,36 @@ func updateWOH() fiber.Handler {
func transitionWOH() fiber.Handler {
return func(c *fiber.Ctx) error {
var body struct {
Status string `json:"status"`
Status string `json:"status"`
PaymentMethod string `json:"payment_method"`
PaymentDate string `json:"payment_date"`
}
if err := c.BodyParser(&body); err != nil || body.Status == "" {
return fiber.NewError(400, "status é obrigatório")
}
if !isValidPaymentMethod(body.PaymentMethod) {
return fiber.NewError(400, "payment_method inválido")
}
if body.PaymentDate != "" {
if _, err := time.Parse("2006-01-02", body.PaymentDate); err != nil {
return fiber.NewError(400, "payment_date inválido (YYYY-MM-DD)")
}
}
if body.Status == "invoiced" {
if body.PaymentMethod == "" {
return fiber.NewError(400, "meio de pagamento é obrigatório para faturar")
}
if body.PaymentDate == "" {
body.PaymentDate = time.Now().Format("2006-01-02")
}
}
claims, _ := c.Locals("claims").(*auth.Claims)
changedBy := ""
if claims != nil {
changedBy = claims.UserID
}
conn := auth.GetConn(c)
wo, err := TransitionStatus(c.Context(), conn, c.Params("id"), body.Status, changedBy)
wo, err := TransitionStatus(c.Context(), conn, c.Params("id"), body.Status, body.PaymentMethod, body.PaymentDate, changedBy)
if err != nil {
return fiber.NewError(400, err.Error())
}
@@ -130,11 +209,12 @@ func transitionWOH() fiber.Handler {
}
type woItemBody struct {
CatalogItemID string `json:"catalog_item_id"`
Description string `json:"description"`
Qty float64 `json:"qty"`
UnitPrice float64 `json:"unit_price"`
DiscountPct float64 `json:"discount_pct"`
CatalogItemID string `json:"catalog_item_id"`
Description string `json:"description"`
ChangeJustification string `json:"change_justification"`
Qty float64 `json:"qty"`
UnitPrice float64 `json:"unit_price"`
DiscountPct float64 `json:"discount_pct"`
}
func addItemH() fiber.Handler {
@@ -147,8 +227,21 @@ func addItemH() fiber.Handler {
return fiber.NewError(400, "descrição, quantidade e preço unitário são obrigatórios")
}
conn := auth.GetConn(c)
item, err := AddItem(c.Context(), conn, c.Params("id"), b.CatalogItemID, b.Description, b.Qty, b.UnitPrice, b.DiscountPct)
var status string
if err := conn.QueryRow(c.Context(), `SELECT status FROM work_orders WHERE id=$1`, c.Params("id")).Scan(&status); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return fiber.NewError(404, "ordem não encontrada")
}
return fiber.NewError(500, "erro ao validar ordem")
}
if status == "open" && b.ChangeJustification == "" {
return fiber.NewError(400, "justificação da alteração é obrigatória em orçamento aprovado")
}
item, err := AddItem(c.Context(), conn, c.Params("id"), b.CatalogItemID, b.Description, b.ChangeJustification, b.Qty, b.UnitPrice, b.DiscountPct)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return fiber.NewError(400, "ordem não editável (cancelada/faturada) ou dados inválidos")
}
return fiber.NewError(500, "erro ao adicionar item")
}
return c.Status(201).JSON(fiber.Map{"data": item, "error": nil})
@@ -183,6 +276,9 @@ func addStaffHoursH() fiber.Handler {
conn := auth.GetConn(c)
sh, err := AddStaffHours(c.Context(), conn, c.Params("id"), b.StaffID, b.Hours, b.CostPerHour)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return fiber.NewError(400, "ordem não editável (cancelada/faturada) ou não encontrada")
}
return fiber.NewError(500, "erro ao adicionar horas")
}
return c.Status(201).JSON(fiber.Map{"data": sh, "error": nil})
+88 -50
View File
@@ -9,27 +9,32 @@ import (
)
type WorkOrder struct {
ID string `json:"id"`
Number int `json:"number"`
ClientID *string `json:"client_id"`
VehicleID *string `json:"vehicle_id"`
Status string `json:"status"`
InternalNotes string `json:"internal_notes"`
ClientNotes string `json:"client_notes"`
CreatedBy *string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"`
Number int `json:"number"`
ClientID *string `json:"client_id"`
VehicleID *string `json:"vehicle_id"`
Status string `json:"status"`
InternalNotes string `json:"internal_notes"`
ClientNotes string `json:"client_notes"`
ETADays int `json:"eta_days"`
RealDeadline *time.Time `json:"real_deadline"`
PaymentMethod string `json:"payment_method"`
PaymentDate *time.Time `json:"payment_date"`
CreatedBy *string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type WOItem struct {
ID string `json:"id"`
WorkOrderID string `json:"work_order_id"`
CatalogItemID *string `json:"catalog_item_id"`
Description string `json:"description"`
Qty float64 `json:"qty"`
UnitPrice float64 `json:"unit_price"`
DiscountPct float64 `json:"discount_pct"`
Total float64 `json:"total"`
ID string `json:"id"`
WorkOrderID string `json:"work_order_id"`
CatalogItemID *string `json:"catalog_item_id"`
Description string `json:"description"`
ChangeJustification string `json:"change_justification"`
Qty float64 `json:"qty"`
UnitPrice float64 `json:"unit_price"`
DiscountPct float64 `json:"discount_pct"`
Total float64 `json:"total"`
}
type WOStaffHours struct {
@@ -71,7 +76,8 @@ func ValidateTransition(from, to string) error {
func ListWorkOrders(ctx context.Context, conn *pgxpool.Conn, status string) ([]*WorkOrder, error) {
q := `SELECT id, number, client_id, vehicle_id, status,
COALESCE(internal_notes,''), COALESCE(client_notes,''), created_by, created_at, updated_at
COALESCE(internal_notes,''), COALESCE(client_notes,''), COALESCE(eta_days,1), real_deadline,
COALESCE(payment_method,''), payment_date, created_by, created_at, updated_at
FROM work_orders`
args := []any{}
if status != "" {
@@ -88,7 +94,8 @@ func ListWorkOrders(ctx context.Context, conn *pgxpool.Conn, status string) ([]*
for rows.Next() {
var wo WorkOrder
if err := rows.Scan(&wo.ID, &wo.Number, &wo.ClientID, &wo.VehicleID,
&wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt); err != nil {
&wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.ETADays, &wo.RealDeadline,
&wo.PaymentMethod, &wo.PaymentDate, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt); err != nil {
return nil, err
}
list = append(list, &wo)
@@ -96,39 +103,47 @@ func ListWorkOrders(ctx context.Context, conn *pgxpool.Conn, status string) ([]*
return list, rows.Err()
}
func CreateWorkOrder(ctx context.Context, conn *pgxpool.Conn, clientID, vehicleID, internalNotes, createdBy string) (*WorkOrder, error) {
func CreateWorkOrder(ctx context.Context, conn *pgxpool.Conn, clientID, vehicleID, internalNotes, clientNotes string, etaDays int, realDeadline, createdBy string) (*WorkOrder, error) {
var wo WorkOrder
err := conn.QueryRow(ctx, `
INSERT INTO work_orders (client_id, vehicle_id, internal_notes, created_by)
INSERT INTO work_orders (client_id, vehicle_id, internal_notes, client_notes, eta_days, real_deadline, created_by)
VALUES (
NULLIF($1,'')::uuid,
NULLIF($2,'')::uuid,
NULLIF($3,''),
(SELECT id FROM users WHERE id = NULLIF($4,'')::uuid LIMIT 1)
NULLIF($4,''),
$5,
NULLIF($6,'')::date,
(SELECT id FROM users WHERE id = NULLIF($7,'')::uuid LIMIT 1)
)
RETURNING id, number, client_id, vehicle_id, status,
COALESCE(internal_notes,''), COALESCE(client_notes,''), created_by, created_at, updated_at`,
clientID, vehicleID, internalNotes, createdBy).
COALESCE(internal_notes,''), COALESCE(client_notes,''), COALESCE(eta_days,1), real_deadline,
COALESCE(payment_method,''), payment_date, created_by, created_at, updated_at`,
clientID, vehicleID, internalNotes, clientNotes, etaDays, realDeadline, createdBy).
Scan(&wo.ID, &wo.Number, &wo.ClientID, &wo.VehicleID,
&wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt)
&wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.ETADays, &wo.RealDeadline,
&wo.PaymentMethod, &wo.PaymentDate, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt)
return &wo, err
}
func UpdateWorkOrder(ctx context.Context, conn *pgxpool.Conn, id, clientID, vehicleID, internalNotes, clientNotes string) (*WorkOrder, error) {
func UpdateWorkOrder(ctx context.Context, conn *pgxpool.Conn, id, clientID, vehicleID, internalNotes, clientNotes string, etaDays int, realDeadline, paymentMethod, paymentDate string) (*WorkOrder, error) {
var wo WorkOrder
err := conn.QueryRow(ctx, `
UPDATE work_orders SET client_id=NULLIF($2,'')::uuid, vehicle_id=NULLIF($3,'')::uuid,
internal_notes=NULLIF($4,''), client_notes=NULLIF($5,''), updated_at=NOW()
WHERE id=$1
internal_notes=NULLIF($4,''), client_notes=NULLIF($5,''), eta_days=$6, real_deadline=NULLIF($7,'')::date,
payment_method=NULLIF($8,''), payment_date=NULLIF($9,'')::date, updated_at=NOW()
WHERE id=$1 AND status NOT IN ('cancelled','invoiced')
RETURNING id, number, client_id, vehicle_id, status,
COALESCE(internal_notes,''), COALESCE(client_notes,''), created_by, created_at, updated_at`,
id, clientID, vehicleID, internalNotes, clientNotes).
COALESCE(internal_notes,''), COALESCE(client_notes,''), COALESCE(eta_days,1), real_deadline,
COALESCE(payment_method,''), payment_date, created_by, created_at, updated_at`,
id, clientID, vehicleID, internalNotes, clientNotes, etaDays, realDeadline, paymentMethod, paymentDate).
Scan(&wo.ID, &wo.Number, &wo.ClientID, &wo.VehicleID,
&wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt)
&wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.ETADays, &wo.RealDeadline,
&wo.PaymentMethod, &wo.PaymentDate, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt)
return &wo, err
}
func TransitionStatus(ctx context.Context, conn *pgxpool.Conn, id, toStatus, changedBy string) (*WorkOrder, error) {
func TransitionStatus(ctx context.Context, conn *pgxpool.Conn, id, toStatus, paymentMethod, paymentDate, changedBy string) (*WorkOrder, error) {
var fromStatus string
if err := conn.QueryRow(ctx, `SELECT status FROM work_orders WHERE id=$1`, id).Scan(&fromStatus); err != nil {
return nil, errors.New("ordem não encontrada")
@@ -138,12 +153,19 @@ func TransitionStatus(ctx context.Context, conn *pgxpool.Conn, id, toStatus, cha
}
var wo WorkOrder
err := conn.QueryRow(ctx, `
UPDATE work_orders SET status=$2, updated_at=NOW() WHERE id=$1
UPDATE work_orders
SET status=$2,
payment_method=CASE WHEN $2='invoiced' THEN NULLIF($3,'') ELSE payment_method END,
payment_date=CASE WHEN $2='invoiced' THEN NULLIF($4,'')::date ELSE payment_date END,
updated_at=NOW()
WHERE id=$1
RETURNING id, number, client_id, vehicle_id, status,
COALESCE(internal_notes,''), COALESCE(client_notes,''), created_by, created_at, updated_at`,
id, toStatus).
COALESCE(internal_notes,''), COALESCE(client_notes,''), COALESCE(eta_days,1), real_deadline,
COALESCE(payment_method,''), payment_date, created_by, created_at, updated_at`,
id, toStatus, paymentMethod, paymentDate).
Scan(&wo.ID, &wo.Number, &wo.ClientID, &wo.VehicleID,
&wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt)
&wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.ETADays, &wo.RealDeadline,
&wo.PaymentMethod, &wo.PaymentDate, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt)
if err != nil {
return nil, err
}
@@ -158,10 +180,12 @@ func GetWorkOrderDetail(ctx context.Context, conn *pgxpool.Conn, id string) (*Wo
var wo WorkOrder
err := conn.QueryRow(ctx, `
SELECT id, number, client_id, vehicle_id, status,
COALESCE(internal_notes,''), COALESCE(client_notes,''), created_by, created_at, updated_at
COALESCE(internal_notes,''), COALESCE(client_notes,''), COALESCE(eta_days,1), real_deadline,
COALESCE(payment_method,''), payment_date, created_by, created_at, updated_at
FROM work_orders WHERE id=$1`, id).
Scan(&wo.ID, &wo.Number, &wo.ClientID, &wo.VehicleID,
&wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt)
&wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.ETADays, &wo.RealDeadline,
&wo.PaymentMethod, &wo.PaymentDate, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt)
if err != nil {
return nil, err
}
@@ -169,7 +193,7 @@ func GetWorkOrderDetail(ctx context.Context, conn *pgxpool.Conn, id string) (*Wo
detail := &WorkOrderDetail{WorkOrder: wo, Items: []*WOItem{}, StaffHours: []*WOStaffHours{}}
rows, err := conn.Query(ctx, `
SELECT id, work_order_id, catalog_item_id, description, qty, unit_price, discount_pct, total
SELECT id, work_order_id, catalog_item_id, description, COALESCE(change_justification,''), qty, unit_price, discount_pct, total
FROM wo_items WHERE work_order_id=$1`, id)
if err != nil {
return nil, err
@@ -177,7 +201,7 @@ func GetWorkOrderDetail(ctx context.Context, conn *pgxpool.Conn, id string) (*Wo
defer rows.Close()
for rows.Next() {
var i WOItem
if err := rows.Scan(&i.ID, &i.WorkOrderID, &i.CatalogItemID, &i.Description,
if err := rows.Scan(&i.ID, &i.WorkOrderID, &i.CatalogItemID, &i.Description, &i.ChangeJustification,
&i.Qty, &i.UnitPrice, &i.DiscountPct, &i.Total); err != nil {
return nil, err
}
@@ -204,19 +228,27 @@ func GetWorkOrderDetail(ctx context.Context, conn *pgxpool.Conn, id string) (*Wo
return detail, shRows.Err()
}
func AddItem(ctx context.Context, conn *pgxpool.Conn, woID, catalogItemID, description string, qty, unitPrice, discountPct float64) (*WOItem, error) {
func AddItem(ctx context.Context, conn *pgxpool.Conn, woID, catalogItemID, description, changeJustification string, qty, unitPrice, discountPct float64) (*WOItem, error) {
var i WOItem
err := conn.QueryRow(ctx, `
INSERT INTO wo_items (work_order_id, catalog_item_id, description, qty, unit_price, discount_pct)
VALUES ($1, NULLIF($2,'')::uuid, $3, $4, $5, $6)
RETURNING id, work_order_id, catalog_item_id, description, qty, unit_price, discount_pct, total`,
woID, catalogItemID, description, qty, unitPrice, discountPct).
Scan(&i.ID, &i.WorkOrderID, &i.CatalogItemID, &i.Description, &i.Qty, &i.UnitPrice, &i.DiscountPct, &i.Total)
INSERT INTO wo_items (work_order_id, catalog_item_id, description, change_justification, qty, unit_price, discount_pct)
SELECT id, NULLIF($2,'')::uuid, $3, NULLIF($4,''), $5, $6, $7
FROM work_orders
WHERE id=$1
AND status NOT IN ('cancelled','invoiced')
AND (status <> 'open' OR NULLIF($4,'') IS NOT NULL)
RETURNING id, work_order_id, catalog_item_id, description, COALESCE(change_justification,''), qty, unit_price, discount_pct, total`,
woID, catalogItemID, description, changeJustification, qty, unitPrice, discountPct).
Scan(&i.ID, &i.WorkOrderID, &i.CatalogItemID, &i.Description, &i.ChangeJustification, &i.Qty, &i.UnitPrice, &i.DiscountPct, &i.Total)
return &i, err
}
func RemoveItem(ctx context.Context, conn *pgxpool.Conn, woID, itemID string) error {
_, err := conn.Exec(ctx, `DELETE FROM wo_items WHERE id=$1 AND work_order_id=$2`, itemID, woID)
_, err := conn.Exec(ctx, `
DELETE FROM wo_items i
USING work_orders w
WHERE i.id=$1 AND i.work_order_id=$2 AND w.id=i.work_order_id AND w.status NOT IN ('cancelled','invoiced')`,
itemID, woID)
return err
}
@@ -224,7 +256,9 @@ func AddStaffHours(ctx context.Context, conn *pgxpool.Conn, woID, staffID string
var sh WOStaffHours
err := conn.QueryRow(ctx, `
INSERT INTO wo_staff_hours (work_order_id, staff_id, hours, cost_per_hour)
VALUES ($1, $2, $3, $4)
SELECT id, $2, $3, $4
FROM work_orders
WHERE id=$1 AND status NOT IN ('cancelled','invoiced')
RETURNING id, work_order_id, staff_id, hours, cost_per_hour, total`,
woID, staffID, hours, costPerHour).
Scan(&sh.ID, &sh.WorkOrderID, &sh.StaffID, &sh.Hours, &sh.CostPerHour, &sh.Total)
@@ -232,6 +266,10 @@ func AddStaffHours(ctx context.Context, conn *pgxpool.Conn, woID, staffID string
}
func RemoveStaffHours(ctx context.Context, conn *pgxpool.Conn, woID, shID string) error {
_, err := conn.Exec(ctx, `DELETE FROM wo_staff_hours WHERE id=$1 AND work_order_id=$2`, shID, woID)
_, err := conn.Exec(ctx, `
DELETE FROM wo_staff_hours s
USING work_orders w
WHERE s.id=$1 AND s.work_order_id=$2 AND w.id=s.work_order_id AND w.status NOT IN ('cancelled','invoiced')`,
shID, woID)
return err
}
@@ -32,7 +32,7 @@ func TestWorkOrderCRUD(t *testing.T) {
conn := getTestConn(t)
ctx := context.Background()
wo, err := workorder.CreateWorkOrder(ctx, conn, "", "", "", "")
wo, err := workorder.CreateWorkOrder(ctx, conn, "", "", "", "", 1, "", "")
require.NoError(t, err)
assert.NotEmpty(t, wo.ID)
assert.Equal(t, "quote", wo.Status)
@@ -52,14 +52,14 @@ func TestWorkOrderTransition(t *testing.T) {
conn := getTestConn(t)
ctx := context.Background()
wo, err := workorder.CreateWorkOrder(ctx, conn, "", "", "", "")
wo, err := workorder.CreateWorkOrder(ctx, conn, "", "", "", "", 1, "", "")
require.NoError(t, err)
wo2, err := workorder.TransitionStatus(ctx, conn, wo.ID, "open", "")
wo2, err := workorder.TransitionStatus(ctx, conn, wo.ID, "open", "", "", "")
require.NoError(t, err)
assert.Equal(t, "open", wo2.Status)
_, err = workorder.TransitionStatus(ctx, conn, wo.ID, "cancelled_invalid", "")
_, err = workorder.TransitionStatus(ctx, conn, wo.ID, "cancelled_invalid", "", "", "")
assert.Error(t, err, "invalid transition should error")
}
@@ -66,6 +66,10 @@ CREATE TABLE IF NOT EXISTS work_orders (
status TEXT NOT NULL DEFAULT 'quote' CHECK (status IN ('quote', 'open', 'in_progress', 'completed', 'invoiced', 'cancelled')),
internal_notes TEXT,
client_notes TEXT,
eta_days INT NOT NULL DEFAULT 1 CHECK (eta_days IN (1, 2, 3, 7, 15, 30, 31)),
real_deadline DATE,
payment_method TEXT,
payment_date DATE,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
@@ -76,6 +80,7 @@ CREATE TABLE IF NOT EXISTS wo_items (
work_order_id UUID NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
catalog_item_id UUID REFERENCES catalog_items(id) ON DELETE SET NULL,
description TEXT NOT NULL,
change_justification TEXT,
qty NUMERIC(10,3) NOT NULL DEFAULT 1,
unit_price NUMERIC(10,2) NOT NULL,
discount_pct NUMERIC(5,2) NOT NULL DEFAULT 0,
@@ -0,0 +1,5 @@
ALTER TABLE work_orders
DROP CONSTRAINT IF EXISTS work_orders_eta_days_check;
ALTER TABLE work_orders
DROP COLUMN IF EXISTS eta_days;
@@ -0,0 +1,9 @@
ALTER TABLE work_orders
ADD COLUMN IF NOT EXISTS eta_days INT NOT NULL DEFAULT 1;
ALTER TABLE work_orders
DROP CONSTRAINT IF EXISTS work_orders_eta_days_check;
ALTER TABLE work_orders
ADD CONSTRAINT work_orders_eta_days_check
CHECK (eta_days IN (1, 2, 3, 7, 15, 30, 31));
@@ -0,0 +1,2 @@
ALTER TABLE work_orders
DROP COLUMN IF EXISTS real_deadline;
@@ -0,0 +1,2 @@
ALTER TABLE work_orders
ADD COLUMN IF NOT EXISTS real_deadline DATE;
@@ -0,0 +1,5 @@
ALTER TABLE work_orders
DROP COLUMN IF EXISTS payment_date;
ALTER TABLE work_orders
DROP COLUMN IF EXISTS payment_method;
@@ -0,0 +1,5 @@
ALTER TABLE work_orders
ADD COLUMN IF NOT EXISTS payment_method TEXT;
ALTER TABLE work_orders
ADD COLUMN IF NOT EXISTS payment_date DATE;
@@ -0,0 +1,2 @@
ALTER TABLE wo_items
DROP COLUMN IF EXISTS change_justification;
@@ -0,0 +1,2 @@
ALTER TABLE wo_items
ADD COLUMN IF NOT EXISTS change_justification TEXT;
+43 -30
View File
@@ -48,37 +48,53 @@ func Generate(meta DocMeta, items []LineItem, staffTotal float64, outPath string
tr := f.UnicodeTranslatorFromDescriptor("")
t := func(s string) string { return tr(s) }
hasLogo := addLogo(f, meta.CompanyLogo)
headerTop := 15.0
leftX := 15.0
leftW := 120.0
if hasLogo {
leftW = 100.0
rightX := 135.0
rightW := 60.0
// Header (left): logo always reserves its own vertical space.
leftY := headerTop
const logoSize = 24.0
if addLogo(f, meta.CompanyLogo, leftX, leftY, logoSize, logoSize) {
leftY += logoSize + 3
}
// Header
f.SetXY(leftX, leftY)
f.SetFont("Helvetica", "B", 18)
f.CellFormat(leftW, 10, t(meta.CompanyName), "", 0, "L", false, 0, "")
f.SetFont("Helvetica", "B", 14)
f.CellFormat(60, 10, t(meta.DocType), "", 1, "R", false, 0, "")
f.CellFormat(leftW, 10, t(meta.CompanyName), "", 1, "L", false, 0, "")
leftY = f.GetY()
f.SetFont("Helvetica", "", 9)
if meta.CompanyNIF != "" {
f.CellFormat(leftW, 5, t("NIF: ")+meta.CompanyNIF, "", 0, "L", false, 0, "")
} else {
f.CellFormat(leftW, 5, "", "", 0, "L", false, 0, "")
f.SetXY(leftX, leftY)
f.CellFormat(leftW, 5, t("NIF: ")+meta.CompanyNIF, "", 1, "L", false, 0, "")
leftY = f.GetY()
}
f.SetFont("Helvetica", "", 11)
f.CellFormat(60, 5, t(meta.DocNumber), "", 1, "R", false, 0, "")
f.SetFont("Helvetica", "", 9)
if meta.CompanyAddress != "" {
f.SetXY(leftX, leftY)
f.MultiCell(leftW, 5, t(meta.CompanyAddress), "", "L", false)
leftY = f.GetY()
}
f.Ln(3)
curY := f.GetY()
f.SetXY(135, curY-3)
f.CellFormat(60, 5, t("Data: ")+meta.IssuedAt, "", 1, "R", false, 0, "")
f.SetY(curY + 3)
f.Ln(3)
// Header (right): document meta.
f.SetXY(rightX, headerTop)
f.SetFont("Helvetica", "B", 14)
f.CellFormat(rightW, 10, t(meta.DocType), "", 1, "R", false, 0, "")
f.SetFont("Helvetica", "", 11)
f.SetXY(rightX, headerTop+15)
f.CellFormat(rightW, 6, t(meta.DocNumber), "", 1, "R", false, 0, "")
f.SetXY(rightX, headerTop+26)
f.CellFormat(rightW, 6, t("Data: ")+meta.IssuedAt, "", 1, "R", false, 0, "")
// Continue after whichever side ended lower.
rightBottom := headerTop + 32
if leftY < rightBottom {
leftY = rightBottom
}
f.SetY(leftY + 4)
// Client block
if meta.ClientName != "" {
@@ -148,7 +164,7 @@ func Generate(meta DocMeta, items []LineItem, staffTotal float64, outPath string
return f.OutputFileAndClose(outPath)
}
func addLogo(f *fpdf.Fpdf, logo string) bool {
func addLogo(f *fpdf.Fpdf, logo string, x, y, w, h float64) bool {
logo = strings.TrimSpace(logo)
if logo == "" {
return false
@@ -172,10 +188,9 @@ func addLogo(f *fpdf.Fpdf, logo string) bool {
if strings.Contains(meta, "image/jpeg") || strings.Contains(meta, "image/jpg") {
imgType = "JPG"
}
opts := fpdf.ImageOptions{ImageType: imgType, ReadDpi: true}
opts := fpdf.ImageOptions{ImageType: imgType, ReadDpi: false}
f.RegisterImageOptionsReader("company_logo", opts, bytes.NewReader(raw))
f.ImageOptions("company_logo", 15, 14, 26, 0, false, opts, 0, "")
f.SetX(45)
f.ImageOptions("company_logo", x, y, w, h, false, opts, 0, "")
return true
}
@@ -198,17 +213,15 @@ func addLogo(f *fpdf.Fpdf, logo string) bool {
if strings.Contains(ct, "jpeg") || strings.Contains(ct, "jpg") {
imgType = "JPG"
}
opts := fpdf.ImageOptions{ImageType: imgType, ReadDpi: true}
opts := fpdf.ImageOptions{ImageType: imgType, ReadDpi: false}
f.RegisterImageOptionsReader("company_logo", opts, bytes.NewReader(buf.Bytes()))
f.ImageOptions("company_logo", 15, 14, 26, 0, false, opts, 0, "")
f.SetX(45)
f.ImageOptions("company_logo", x, y, w, h, false, opts, 0, "")
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)
opts := fpdf.ImageOptions{ReadDpi: false}
f.ImageOptions(logo, x, y, w, h, false, opts, 0, "")
return true
}
+10
View File
@@ -13,8 +13,13 @@ import StaffPage from '@/pages/app/StaffPage'
import ExpensesPage from '@/pages/app/ExpensesPage'
import SettingsPage from '@/pages/app/SettingsPage'
import InvoicesPage from '@/pages/app/InvoicesPage'
import ReportsPage from '@/pages/app/ReportsPage'
import ExpenseReportsPage from '@/pages/app/ExpenseReportsPage'
import TechnicianReportsPage from '@/pages/app/TechnicianReportsPage'
import HelpPage from '@/pages/app/HelpPage'
import AdminDashboardPage from '@/pages/admin/DashboardPage'
import TenantsPage from '@/pages/admin/TenantsPage'
import AdminSettingsPage from '@/pages/admin/SettingsPage'
import InviteRedeemPage from '@/pages/public/InviteRedeemPage'
import AppLayout from '@/components/layout/AppLayout'
import AdminLayout from '@/components/layout/AdminLayout'
@@ -56,6 +61,7 @@ export default function App() {
>
<Route index element={<AdminDashboardPage />} />
<Route path="tenants" element={<TenantsPage />} />
<Route path="settings" element={<AdminSettingsPage />} />
</Route>
<Route
@@ -74,7 +80,11 @@ export default function App() {
<Route path="staff" element={<StaffPage />} />
<Route path="expenses" element={<ExpensesPage />} />
<Route path="invoices" element={<InvoicesPage />} />
<Route path="reports" element={<ReportsPage />} />
<Route path="reports/expenses" element={<ExpenseReportsPage />} />
<Route path="reports/technicians" element={<TechnicianReportsPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="help" element={<HelpPage />} />
</Route>
<Route path="/" element={<Navigate to="/app" replace />} />
+80 -18
View File
@@ -1,53 +1,115 @@
import { useMemo, useState } from 'react'
import { Outlet, NavLink } from 'react-router'
import { useQuery } from '@tanstack/react-query'
import { useLogout } from '@/hooks/useAuth'
import { useTheme } from '@/hooks/useTheme'
import { apiFetch } from '@/lib/api'
import type { PlatformSettings } from '@/lib/types'
export default function AdminLayout() {
const { mutate: logout } = useLogout()
const { theme, toggleTheme } = useTheme('ui_theme', 'dark')
const [logoBroken, setLogoBroken] = useState(false)
const { data: settings } = useQuery<PlatformSettings>({
queryKey: ['admin', 'settings'],
queryFn: () => apiFetch<PlatformSettings>('/admin/settings'),
})
const primary = settings?.admin_primary_color || '#2563eb'
const accent = settings?.admin_accent_color || '#0ea5e9'
const isLight = theme === 'light'
const shellStyle = useMemo(
() => ({
backgroundColor: 'var(--ui-bg)',
color: 'var(--ui-text)',
['--brand-primary' as string]: primary,
['--brand-accent' as string]: accent,
}),
[primary, accent]
)
const asideStyle = useMemo(
() => ({
backgroundColor: 'var(--ui-sidebar)',
borderColor: 'var(--ui-border)',
}),
[]
)
const mainStyle = useMemo(
() => ({
background: isLight
? 'linear-gradient(180deg, #f8fbff 0%, #eef4fb 100%)'
: 'linear-gradient(180deg, #0b1530 0%, #050b18 100%)',
}),
[isLight]
)
return (
<div className="flex h-screen bg-slate-950">
<aside className="w-64 bg-slate-900 border-r border-slate-800 flex flex-col">
<div className="p-4 border-b border-slate-800">
<h1 className="text-lg font-bold text-white">TechXCar</h1>
<p className="text-xs text-slate-400 mt-0.5">Administração</p>
<div className="flex h-screen" style={shellStyle}>
<aside className="w-64 border-r flex flex-col" style={asideStyle}>
<div className="p-4 border-b" style={{ borderColor: 'var(--ui-border)' }}>
{settings?.platform_logo && !logoBroken ? (
<div className="mb-2 flex h-14 w-14 items-center justify-center rounded-md border bg-white p-1" style={{ borderColor: 'var(--ui-border)' }}>
<img
src={settings.platform_logo}
alt=""
className="h-full w-full object-contain"
onError={() => setLogoBroken(true)}
referrerPolicy="no-referrer"
/>
</div>
) : (
<div className="mb-2 h-14 w-14 rounded-md flex items-center justify-center text-sm font-semibold" style={{ backgroundColor: `${accent}22`, color: primary }}>
TX
</div>
)}
<h1 className="text-lg font-bold text-[var(--ui-text)]">{settings?.platform_name || 'TechXCar'}</h1>
<p className="text-xs mt-0.5 text-[var(--ui-muted)]">{settings?.platform_subtitle || 'Administração Global'}</p>
</div>
<nav className="flex-1 p-3 space-y-1">
<NavLink
to="/admin"
end
className={({ isActive }) =>
`flex items-center px-3 py-2 rounded-md text-sm transition-colors ${
isActive
? 'bg-slate-700 text-white'
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
}`
`flex items-center px-3 py-2 rounded-md text-sm transition-colors ${isActive ? 'text-white' : 'text-[var(--ui-muted)] hover:text-[var(--ui-text)]'}`
}
style={({ isActive }) => (isActive ? { backgroundColor: primary } : { backgroundColor: 'transparent' })}
>
Dashboard
</NavLink>
<NavLink
to="/admin/tenants"
className={({ isActive }) =>
`flex items-center px-3 py-2 rounded-md text-sm transition-colors ${
isActive
? 'bg-slate-700 text-white'
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
}`
`flex items-center px-3 py-2 rounded-md text-sm transition-colors ${isActive ? 'text-white' : 'text-[var(--ui-muted)] hover:text-[var(--ui-text)]'}`
}
style={({ isActive }) => (isActive ? { backgroundColor: primary } : { backgroundColor: 'transparent' })}
>
Oficinas
</NavLink>
<NavLink
to="/admin/settings"
className={({ isActive }) =>
`flex items-center px-3 py-2 rounded-md text-sm transition-colors ${isActive ? 'text-white' : 'text-[var(--ui-muted)] hover:text-[var(--ui-text)]'}`
}
style={({ isActive }) => (isActive ? { backgroundColor: primary } : { backgroundColor: 'transparent' })}
>
Definições Globais
</NavLink>
</nav>
<div className="p-3 border-t border-slate-800">
<div className="p-3 border-t" style={{ borderColor: 'var(--ui-border)' }}>
<button
onClick={toggleTheme}
className="mb-2 w-full text-left px-3 py-2 text-sm rounded-md transition-colors text-[var(--ui-muted)] hover:text-[var(--ui-text)] hover:bg-[var(--ui-hover)]"
>
Tema: {theme === 'dark' ? 'Dark' : 'Light'}
</button>
<button
onClick={() => logout()}
className="w-full text-left px-3 py-2 text-sm text-slate-400 hover:text-white rounded-md hover:bg-slate-800 transition-colors"
className="w-full text-left px-3 py-2 text-sm rounded-md transition-colors text-[var(--ui-muted)] hover:text-[var(--ui-text)] hover:bg-[var(--ui-hover)]"
>
Terminar sessão
</button>
</div>
</aside>
<main className="flex-1 overflow-auto p-6 text-white">
<main className="flex-1 overflow-auto p-6 text-[var(--ui-text)]" style={mainStyle}>
<Outlet />
</main>
</div>
+234 -45
View File
@@ -1,32 +1,107 @@
import { useLayoutEffect } from 'react'
import { useLayoutEffect, useMemo, useState, type ComponentType } from 'react'
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router'
import { useQuery } from '@tanstack/react-query'
import {
BarChart3,
CircleHelp,
ChevronDown,
ChevronRight,
ClipboardList,
FileText,
Fuel,
Gauge,
Home,
LogOut,
Moon,
Package,
PanelLeftClose,
PanelLeftOpen,
Settings,
Sun,
Users,
Wrench,
} from 'lucide-react'
import { useLogout } from '@/hooks/useAuth'
import { useTheme } from '@/hooks/useTheme'
import { apiFetch } from '@/lib/api'
import { useAuthStore } from '@/store/authStore'
import { queryClient } from '@/lib/queryClient'
import type { TenantSettings } from '@/lib/types'
const nav = [
{ to: '/app', label: 'Dashboard', end: true },
{ to: '/app/work-orders', label: 'Ordens de Trabalho' },
{ to: '/app/clients', label: 'Clientes' },
{ to: '/app/catalog', label: 'Catálogo' },
{ to: '/app/staff', label: 'Técnicos' },
{ to: '/app/expenses', label: 'Despesas' },
{ to: '/app/invoices', label: 'Transações' },
{ to: '/app/settings', label: 'Definições' },
type MainNavItem = {
to: string
label: string
end?: boolean
icon: ComponentType<{ className?: string }>
}
type ReportNavItem = {
to: string
label: string
icon: ComponentType<{ className?: string }>
}
const MAIN_NAV: MainNavItem[] = [
{ to: '/app', label: 'Dashboard', end: true, icon: Home },
{ to: '/app/work-orders', label: 'Ordens de Trabalho', icon: ClipboardList },
{ to: '/app/clients', label: 'Clientes', icon: Users },
{ to: '/app/catalog', label: 'Catálogo', icon: Package },
{ to: '/app/staff', label: 'Técnicos', icon: Wrench },
{ to: '/app/expenses', label: 'Despesas', icon: Fuel },
{ to: '/app/invoices', label: 'Transações', icon: FileText },
]
const REPORTS_NAV: ReportNavItem[] = [
{ to: '/app/reports', label: 'OTs Cliente/Viatura', icon: BarChart3 },
{ to: '/app/reports/expenses', label: 'Relatório Despesas', icon: Fuel },
{ to: '/app/reports/technicians', label: 'Desempenho Técnicos', icon: Gauge },
]
const SETTINGS_NAV: MainNavItem = { to: '/app/settings', label: 'Definições', icon: Settings }
const HELP_NAV: MainNavItem = { to: '/app/help', label: 'Ajuda', icon: CircleHelp }
export default function AppLayout() {
const { mutate: logout } = useLogout()
const { previousSession, restoreSession, user, impersonateTenant } = useAuthStore()
const { theme, setTheme, toggleTheme } = useTheme('ui_theme', 'dark')
const isLight = theme === 'light'
const [logoBroken, setLogoBroken] = useState(false)
const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(() => window.localStorage.getItem('app_sidebar_collapsed') === '1')
const navigate = useNavigate()
const location = useLocation()
const reportsActive = location.pathname.startsWith('/app/reports')
const [reportsOpen, setReportsOpen] = useState(reportsActive)
const { data: settings } = useQuery<TenantSettings>({
queryKey: ['settings', 'layout'],
queryFn: () => apiFetch<TenantSettings>('/settings'),
retry: false,
})
useLayoutEffect(() => {
const initialTheme = settings?.ui_theme
if (initialTheme === 'dark' || initialTheme === 'light') {
const hasLocal = window.localStorage.getItem('ui_theme')
if (!hasLocal) setTheme(initialTheme)
}
}, [settings?.ui_theme, setTheme])
useLayoutEffect(() => {
setLogoBroken(false)
}, [settings?.company_logo])
useLayoutEffect(() => {
if (reportsActive) setReportsOpen(true)
}, [reportsActive])
useLayoutEffect(() => {
window.localStorage.setItem('app_sidebar_collapsed', sidebarCollapsed ? '1' : '0')
}, [sidebarCollapsed])
useLayoutEffect(() => {
const imp = location.state?.impersonation
if (imp) {
impersonateTenant(imp.token, imp.user)
queryClient.clear()
// Remove impersonation payload from history state so back/forward doesn't re-apply it
navigate(location.pathname, { replace: true, state: {} })
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
@@ -36,55 +111,169 @@ export default function AppLayout() {
navigate('/admin')
}
const navItemClass = useMemo(() => {
return (active: boolean) =>
`flex items-center rounded-md text-sm transition-colors ${
sidebarCollapsed ? 'justify-center px-2 py-2.5' : 'gap-2 px-3 py-2'
} ${
active
? isLight
? 'bg-sky-700 text-slate-50'
: 'bg-slate-700 text-slate-100'
: isLight
? 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
}`
}, [isLight, sidebarCollapsed])
const navIconClass = sidebarCollapsed ? 'h-5 w-5 shrink-0' : 'h-4 w-4 shrink-0'
return (
<div className="flex flex-col h-screen bg-slate-950">
<div className={`app-shell flex h-screen flex-col ${isLight ? 'bg-slate-100' : 'bg-slate-950'}`}>
{previousSession && (
<div className="flex items-center justify-between px-4 py-2 bg-amber-500 text-amber-950 text-sm font-medium shrink-0">
<span>
TechXCar Admin a gerir: <strong>{user?.name}</strong>
</span>
<button
onClick={handleRestore}
className="px-3 py-1 rounded bg-amber-950 text-amber-100 hover:bg-amber-900 text-xs font-semibold transition-colors"
>
Voltar ao painel
</button>
<div className="shrink-0 bg-amber-500 px-4 py-2 text-sm font-medium text-amber-950">
<div className="flex items-center justify-between">
<span>
TechXCar Admin a gerir: <strong>{user?.name}</strong>
</span>
<button
onClick={handleRestore}
className="rounded bg-amber-950 px-3 py-1 text-xs font-semibold text-amber-100 transition-colors hover:bg-amber-900"
>
Voltar ao painel
</button>
</div>
</div>
)}
<div className="flex flex-1 overflow-hidden">
<aside className="w-64 bg-slate-900 border-r border-slate-800 flex flex-col">
<div className="p-4 border-b border-slate-800">
<h1 className="text-lg font-bold text-white">TechXCar</h1>
<p className="text-xs text-slate-400 mt-0.5">Gestão de Oficina</p>
<aside className={`${sidebarCollapsed ? 'w-20' : 'w-72'} flex flex-col border-r transition-all duration-200 ${isLight ? 'border-slate-200 bg-white' : 'border-slate-800 bg-slate-900'}`}>
<div className={`border-b p-4 ${isLight ? 'border-slate-200' : 'border-slate-800'}`}>
<div className={`mb-2 flex ${sidebarCollapsed ? 'justify-center' : 'items-start gap-2'}`}>
{settings?.company_logo && !logoBroken ? (
<div className="h-12 w-12 rounded-md bg-white/90 p-1">
<img
src={settings.company_logo}
alt=""
className="h-full w-full object-contain"
onError={() => setLogoBroken(true)}
referrerPolicy="no-referrer"
/>
</div>
) : (
<div className={`h-12 w-12 rounded-md text-sm font-semibold ${isLight ? 'bg-slate-200 text-slate-700' : 'bg-slate-700 text-white'} flex items-center justify-center`}>
TX
</div>
)}
</div>
{!sidebarCollapsed ? (
<>
<h1 className={`text-lg font-bold ${isLight ? 'text-slate-900' : 'text-white'}`}>TechXCar</h1>
<p className={`mt-0.5 text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>{settings?.company_name || 'Gestão de Oficina'}</p>
</>
) : null}
</div>
<nav className="flex-1 p-3 space-y-1">
{nav.map(({ to, label, end }) => (
<NavLink
key={to}
to={to}
end={end}
className={({ isActive }) =>
`flex items-center px-3 py-2 rounded-md text-sm transition-colors ${
isActive
? 'bg-slate-700 text-white'
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
}`
}
>
{label}
<nav className="flex-1 space-y-1 p-3">
{MAIN_NAV.map(({ to, label, icon: Icon, end }) => (
<NavLink key={to} to={to} end={end} className={({ isActive }) => navItemClass(isActive)} title={sidebarCollapsed ? label : undefined}>
<Icon className={navIconClass} />
{!sidebarCollapsed && <span>{label}</span>}
</NavLink>
))}
<div className="pt-1">
<button
onClick={() => {
if (sidebarCollapsed) {
setSidebarCollapsed(false)
setReportsOpen(true)
return
}
setReportsOpen((v) => !v)
}}
className={`${navItemClass(reportsActive)} w-full`}
title={sidebarCollapsed ? 'Relatórios' : undefined}
>
<BarChart3 className={navIconClass} />
{!sidebarCollapsed && (
<>
<span className="flex-1 text-left">Relatórios</span>
{reportsOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</>
)}
</button>
{!sidebarCollapsed && reportsOpen && (
<div className="mt-1 space-y-1 pl-2">
{REPORTS_NAV.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
end={to === '/app/reports'}
className={({ isActive }) =>
`flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors ${
isActive
? isLight
? 'bg-sky-700/15 text-sky-800'
: 'bg-slate-700 text-slate-100'
: isLight
? 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
}`
}
>
<Icon className={navIconClass} />
<span>{label}</span>
</NavLink>
))}
</div>
)}
</div>
<NavLink
to={SETTINGS_NAV.to}
className={({ isActive }) => navItemClass(isActive)}
title={sidebarCollapsed ? SETTINGS_NAV.label : undefined}
>
<SETTINGS_NAV.icon className={navIconClass} />
{!sidebarCollapsed && <span>{SETTINGS_NAV.label}</span>}
</NavLink>
</nav>
<div className="p-3 border-t border-slate-800">
<div className={`border-t p-3 ${isLight ? 'border-slate-200' : 'border-slate-800'}`}>
<button
onClick={() => setSidebarCollapsed((v) => !v)}
className={`${navItemClass(false)} w-full`}
title={sidebarCollapsed ? 'Expandir menu' : 'Comprimir menu'}
>
{sidebarCollapsed ? <PanelLeftOpen className={navIconClass} /> : <PanelLeftClose className={navIconClass} />}
{!sidebarCollapsed && <span>{sidebarCollapsed ? 'Expandir menu' : 'Comprimir menu'}</span>}
</button>
<NavLink
to={HELP_NAV.to}
className={({ isActive }) => navItemClass(isActive)}
title={sidebarCollapsed ? HELP_NAV.label : undefined}
>
<HELP_NAV.icon className={navIconClass} />
{!sidebarCollapsed && <span>{HELP_NAV.label}</span>}
</NavLink>
<button
onClick={toggleTheme}
className={`${navItemClass(false)} w-full`}
title={sidebarCollapsed ? `Tema: ${theme === 'dark' ? 'Dark' : 'Light'}` : undefined}
>
{theme === 'dark' ? <Moon className={navIconClass} /> : <Sun className={navIconClass} />}
{!sidebarCollapsed && <span>Tema: {theme === 'dark' ? 'Dark' : 'Light'}</span>}
</button>
<button
onClick={() => logout()}
className="w-full text-left px-3 py-2 text-sm text-slate-400 hover:text-white rounded-md hover:bg-slate-800 transition-colors"
className={`${navItemClass(false)} w-full`}
title={sidebarCollapsed ? 'Terminar sessão' : undefined}
>
Terminar sessão
<LogOut className={navIconClass} />
{!sidebarCollapsed && <span>Terminar sessão</span>}
</button>
</div>
</aside>
<main className="flex-1 overflow-auto p-6 text-white">
<main className={`flex-1 overflow-auto p-6 ${isLight ? 'text-slate-900' : 'text-white'}`}>
<Outlet />
</main>
</div>
+6 -6
View File
@@ -8,12 +8,12 @@ const buttonVariants = cva(
{
variants: {
variant: {
default: 'bg-blue-600 text-white shadow hover:bg-blue-700',
destructive: 'bg-red-600 text-white shadow-sm hover:bg-red-700',
outline: 'border border-gray-300 bg-white shadow-sm hover:bg-gray-50 text-gray-900',
secondary: 'bg-gray-100 text-gray-900 shadow-sm hover:bg-gray-200',
ghost: 'hover:bg-gray-100 text-gray-700',
link: 'text-blue-600 underline-offset-4 hover:underline',
default: 'bg-[var(--brand-primary)] text-slate-50 shadow hover:brightness-110',
destructive: 'bg-red-600 text-slate-50 shadow-sm hover:bg-red-700',
outline: 'border border-[var(--ui-border)] bg-[var(--ui-panel)] shadow-sm hover:bg-[var(--ui-hover)] text-[var(--ui-text)]',
secondary: 'bg-[var(--ui-panel-soft)] text-[var(--ui-text)] shadow-sm hover:brightness-95',
ghost: 'hover:bg-[var(--ui-hover)] text-[var(--ui-muted)]',
link: 'text-[var(--ui-link)] underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
@@ -0,0 +1,43 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
type ConfirmActionDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description: string
confirmLabel?: string
cancelLabel?: string
pending?: boolean
onConfirm: () => void
}
export function ConfirmActionDialog({
open,
onOpenChange,
title,
description,
confirmLabel = 'Confirmar',
cancelLabel = 'Cancelar',
pending = false,
onConfirm,
}: ConfirmActionDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="left-auto right-4 top-4 w-[calc(100%-2rem)] max-w-md translate-x-0 translate-y-0 border-[var(--ui-border)] bg-[var(--ui-panel)]">
<DialogHeader>
<DialogTitle className="text-[var(--ui-text)]">{title}</DialogTitle>
</DialogHeader>
<p className="text-sm text-[var(--ui-muted)]">{description}</p>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
{cancelLabel}
</Button>
<Button type="button" variant="destructive" onClick={onConfirm} disabled={pending}>
{pending ? 'A processar...' : confirmLabel}
</Button>
</div>
</DialogContent>
</Dialog>
)
}
+1 -1
View File
@@ -9,7 +9,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-gray-300 bg-white px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50',
'flex h-9 w-full rounded-md border border-[var(--ui-input-border)] bg-[var(--ui-input-bg)] px-3 py-1 text-sm text-[var(--ui-input-text)] shadow-sm transition-colors placeholder:text-[var(--ui-muted)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand-accent)] disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
+26
View File
@@ -0,0 +1,26 @@
import { useEffect, useMemo, useState } from 'react'
export type UITheme = 'dark' | 'light'
function readTheme(storageKey: string, fallback: UITheme): UITheme {
if (typeof window === 'undefined') return fallback
const v = window.localStorage.getItem(storageKey)
return v === 'light' || v === 'dark' ? v : fallback
}
export function useTheme(storageKey = 'ui_theme', fallback: UITheme = 'dark') {
const [theme, setTheme] = useState<UITheme>(() => readTheme(storageKey, fallback))
useEffect(() => {
if (typeof window === 'undefined') return
window.localStorage.setItem(storageKey, theme)
document.documentElement.classList.toggle('theme-light', theme === 'light')
}, [storageKey, theme])
const toggleTheme = useMemo(
() => () => setTheme((t) => (t === 'dark' ? 'light' : 'dark')),
[]
)
return { theme, setTheme, toggleTheme }
}
+80
View File
@@ -2,4 +2,84 @@
:root {
--radius: 0.5rem;
--brand-primary: #2563eb;
--brand-accent: #0ea5e9;
--ui-bg: #030712;
--ui-panel: #0b1220;
--ui-panel-soft: #121a2c;
--ui-border: #263248;
--ui-text: #e2e8f0;
--ui-muted: #94a3b8;
--ui-sidebar: #0b1326;
--ui-hover: #1d2940;
--ui-input-bg: #0a1326;
--ui-input-border: #31415f;
--ui-input-text: #e2e8f0;
--ui-link: #7dd3fc;
}
:root.theme-light {
color-scheme: light;
--ui-bg: #f2f6fb;
--ui-panel: #ffffff;
--ui-panel-soft: #f8fbff;
--ui-border: #d7e0ee;
--ui-text: #0f172a;
--ui-muted: #475569;
--ui-sidebar: #eef3fa;
--ui-hover: #e6edf8;
--ui-input-bg: #ffffff;
--ui-input-border: #cfd8e6;
--ui-input-text: #0f172a;
--ui-link: #0369a1;
}
body {
background-color: var(--ui-bg);
color: var(--ui-text);
}
/* App (tenant) light mode compatibility for legacy slate utility usage */
:root.theme-light .app-shell .bg-slate-950 {
background-color: #f2f6fb !important;
}
:root.theme-light .app-shell .bg-slate-900,
:root.theme-light .app-shell .bg-slate-900\/50,
:root.theme-light .app-shell .bg-slate-900\/60,
:root.theme-light .app-shell .bg-slate-900\/65 {
background-color: #ffffff !important;
}
:root.theme-light .app-shell .bg-slate-800,
:root.theme-light .app-shell .bg-slate-800\/50,
:root.theme-light .app-shell .bg-slate-800\/80 {
background-color: #edf3fa !important;
}
:root.theme-light .app-shell .text-white {
color: #0f172a !important;
}
:root.theme-light .app-shell .text-slate-400,
:root.theme-light .app-shell .text-slate-500 {
color: #475569 !important;
}
:root.theme-light .app-shell .text-slate-300,
:root.theme-light .app-shell .text-slate-200 {
color: #1e293b !important;
}
:root.theme-light .app-shell .border-slate-800,
:root.theme-light .app-shell .border-slate-700,
:root.theme-light .app-shell .border-slate-700\/70,
:root.theme-light .app-shell .border-slate-700\/60,
:root.theme-light .app-shell .border-slate-600 {
border-color: #d6deea !important;
}
:root.theme-light .app-shell .hover\:bg-slate-800:hover,
:root.theme-light .app-shell .hover\:bg-slate-800\/50:hover {
background-color: #e6edf8 !important;
}
+6
View File
@@ -62,6 +62,10 @@ export interface WorkOrder {
status: 'quote' | 'open' | 'in_progress' | 'completed' | 'invoiced' | 'cancelled'
internal_notes: string
client_notes: string
eta_days: number
real_deadline: string | null
payment_method: string
payment_date: string | null
created_by: string | null
created_at: string
updated_at: string
@@ -72,6 +76,7 @@ export interface WOItem {
work_order_id: string
catalog_item_id: string | null
description: string
change_justification: string
qty: number
unit_price: number
discount_pct: number
@@ -115,6 +120,7 @@ export interface Expense {
}
export type TenantSettings = Record<string, string>
export type PlatformSettings = Record<string, string>
export interface Invoice {
id: string
+50
View File
@@ -0,0 +1,50 @@
export const OTHER_BRAND = 'Outra Marca'
export const VEHICLE_BRANDS: string[] = [
'Abarth',
'Alfa Romeo',
'Audi',
'BMW',
'BYD',
'Chevrolet',
'Citroen',
'Cupra',
'Dacia',
'DS',
'Fiat',
'Ford',
'Honda',
'Hyundai',
'Jeep',
'Kia',
'Lancia',
'Land Rover',
'Lexus',
'Mazda',
'Mercedes-Benz',
'MG',
'Mini',
'Mitsubishi',
'Nissan',
'Opel',
'Peugeot',
'Porsche',
'Renault',
'SEAT',
'Skoda',
'Smart',
'Subaru',
'Suzuki',
'Tesla',
'Toyota',
'Volkswagen',
'Volvo',
OTHER_BRAND,
]
export function normalizeBrand(input: string) {
const v = (input || '').trim()
if (!v) return ''
if (VEHICLE_BRANDS.includes(v)) return v
return OTHER_BRAND
}
+23
View File
@@ -0,0 +1,23 @@
import type { WorkOrder } from '@/lib/types'
export const WORK_ORDER_STATUS_LABEL: Record<WorkOrder['status'], string> = {
quote: 'Orçamento',
open: 'Orçamento Aprovado',
in_progress: 'Em Curso',
completed: 'Concluída',
invoiced: 'Faturada',
cancelled: 'Cancelada',
}
const LIGHT_STATUS_BADGE: Record<WorkOrder['status'], string> = {
quote: 'border-amber-400 bg-amber-100 text-slate-800',
open: 'border-sky-400 bg-sky-100 text-slate-800',
in_progress: 'border-indigo-400 bg-indigo-100 text-slate-800',
completed: 'border-emerald-400 bg-emerald-100 text-slate-800',
invoiced: 'border-teal-400 bg-teal-100 text-slate-800',
cancelled: 'border-rose-400 bg-rose-100 text-slate-800',
}
export function workOrderStatusBadgeClass(status: WorkOrder['status']) {
return `border ${LIGHT_STATUS_BADGE[status]}`
}
+157 -5
View File
@@ -1,8 +1,160 @@
export default function AdminDashboardPage() {
import { useMemo } from 'react'
import { Link } from 'react-router'
import { useQuery } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api'
import { useTheme } from '@/hooks/useTheme'
import type { Tenant } from '@/lib/types'
function monthKey(d: Date) {
return `${d.getFullYear()}-${d.getMonth()}`
}
function AdminCard({
title,
value,
subtitle,
}: {
title: string
value: string
subtitle: string
}) {
return (
<div>
<h1 className="text-2xl font-bold">Painel de Administração</h1>
<p className="text-slate-400 mt-1 text-sm">Gestão da plataforma implementado no Plano 2</p>
</div>
<article className="rounded-2xl border border-[var(--ui-border)] bg-[var(--ui-panel)] p-4">
<p className="text-[11px] uppercase tracking-[0.14em] text-[var(--ui-muted)]">{title}</p>
<p className="mt-2 text-2xl font-semibold text-[var(--ui-text)]">{value}</p>
<p className="mt-1 text-xs text-[var(--ui-muted)]">{subtitle}</p>
</article>
)
}
export default function AdminDashboardPage() {
const { theme } = useTheme('ui_theme', 'dark')
const currentYear = new Date().getFullYear()
const { data: tenants = [], isLoading, isError } = useQuery<Tenant[]>({
queryKey: ['admin', 'tenants'],
queryFn: () => apiFetch<Tenant[]>('/admin/tenants'),
})
const metrics = useMemo(() => {
const now = new Date()
const thisMonth = monthKey(now)
const thisYear = now.getFullYear()
const start30d = new Date(now)
start30d.setDate(now.getDate() - 30)
const byStatus = {
active: tenants.filter((t) => t.status === 'active').length,
suspended: tenants.filter((t) => t.status === 'suspended').length,
pending: tenants.filter((t) => t.status === 'pending').length,
}
const created30d = tenants.filter((t) => new Date(t.created_at) >= start30d).length
const createdMonth = tenants.filter((t) => monthKey(new Date(t.created_at)) === thisMonth).length
const monthly = Array.from({ length: 6 }).map((_, i) => {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
const key = monthKey(d)
const count = tenants.filter((t) => monthKey(new Date(t.created_at)) === key).length
return {
label: new Intl.DateTimeFormat('pt-PT', { month: 'short' }).format(d),
count,
}
}).reverse()
const yearToDate = tenants.filter((t) => new Date(t.created_at).getFullYear() === thisYear).length
return {
total: tenants.length,
byStatus,
created30d,
createdMonth,
yearToDate,
monthly,
}
}, [tenants])
const maxMonthly = Math.max(1, ...metrics.monthly.map((m) => m.count))
return (
<section className="space-y-6">
<header className={`rounded-2xl border p-6 border-[var(--ui-border)] ${theme === 'light' ? 'bg-[var(--ui-panel)]' : 'bg-gradient-to-br from-[#101b33] via-[#0e162c] to-[#0a1222]'}`}>
<p className="text-xs uppercase tracking-[0.2em] text-[var(--ui-muted)]">Admin Global</p>
<h1 className="mt-1 text-3xl font-semibold text-[var(--ui-text)]">Dashboard da Plataforma</h1>
<p className="mt-2 text-sm text-[var(--ui-muted)]">
Métricas globais por oficina, sem exposição de dados operacionais internos.
</p>
</header>
{isLoading && (
<p className="rounded-xl border border-[var(--ui-border)] bg-[var(--ui-panel)] px-4 py-3 text-sm text-[var(--ui-muted)]">
A carregar métricas...
</p>
)}
{isError && !isLoading && (
<p className="rounded-xl border border-red-900/80 bg-red-950/40 px-4 py-3 text-sm text-red-200">
Não foi possível carregar os dados globais neste momento.
</p>
)}
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<AdminCard title="Oficinas Totais" value={String(metrics.total)} subtitle="Total registado na plataforma" />
<AdminCard title="Ativas" value={String(metrics.byStatus.active)} subtitle="Oficinas em operação" />
<AdminCard title="Últimos 30 dias" value={String(metrics.created30d)} subtitle="Novas adesões recentes" />
<AdminCard title="Este ano" value={String(metrics.yearToDate)} subtitle={`Oficinas criadas em ${currentYear}`} />
</div>
<div className="grid gap-4 xl:grid-cols-2">
<article className="rounded-2xl border border-[var(--ui-border)] bg-[var(--ui-panel)] p-5">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-semibold text-[var(--ui-text)]">Estado das Oficinas</h2>
<Link to="/admin/tenants" className="text-xs text-[var(--ui-link)] hover:brightness-110">
Ver lista
</Link>
</div>
<div className="space-y-3">
{[
{ label: 'Ativas', value: metrics.byStatus.active, color: 'from-emerald-500 to-teal-400' },
{ label: 'Suspensas', value: metrics.byStatus.suspended, color: 'from-rose-500 to-orange-400' },
{ label: 'Pendentes', value: metrics.byStatus.pending, color: 'from-amber-500 to-yellow-400' },
].map((row) => {
const width = metrics.total > 0 ? (row.value / metrics.total) * 100 : 0
return (
<div key={row.label}>
<div className="mb-1 flex justify-between text-xs">
<span className="text-[var(--ui-muted)]">{row.label}</span>
<span className="font-mono text-[var(--ui-muted)]">{row.value}</span>
</div>
<div className="h-2 rounded-full bg-[var(--ui-hover)]">
<div className={`h-2 rounded-full bg-gradient-to-r ${row.color}`} style={{ width: `${width}%` }} />
</div>
</div>
)
})}
</div>
</article>
<article className="rounded-2xl border border-[var(--ui-border)] bg-[var(--ui-panel)] p-5">
<h2 className="text-base font-semibold text-[var(--ui-text)]">Evolução de Novas Oficinas (6 meses)</h2>
<div className="mt-4 space-y-2">
{metrics.monthly.map((m) => {
const width = (m.count / maxMonthly) * 100
return (
<div key={m.label}>
<div className="mb-1 flex justify-between text-xs">
<span className="text-[var(--ui-muted)] capitalize">{m.label}</span>
<span className="font-mono text-[var(--ui-muted)]">{m.count}</span>
</div>
<div className="h-2 rounded-full bg-[var(--ui-hover)]">
<div className="h-2 rounded-full bg-gradient-to-r from-sky-500 to-indigo-400" style={{ width: `${width}%` }} />
</div>
</div>
)
})}
</div>
<p className="mt-4 text-xs text-[var(--ui-muted)]">
No mês atual foram criadas {metrics.createdMonth} oficina(s).
</p>
</article>
</div>
</section>
)
}
+139
View File
@@ -0,0 +1,139 @@
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import type { PlatformSettings } from '@/lib/types'
type FormData = {
platform_name: string
platform_subtitle: string
platform_logo: string
admin_primary_color: string
admin_accent_color: string
}
const defaultValues: FormData = {
platform_name: 'TechXCar',
platform_subtitle: 'Administração',
platform_logo: '',
admin_primary_color: '#0f3b47',
admin_accent_color: '#06b6d4',
}
export default function AdminSettingsPage() {
const [previewBroken, setPreviewBroken] = useState(false)
const qc = useQueryClient()
const { data: settings, isLoading } = useQuery<PlatformSettings>({
queryKey: ['admin', 'settings'],
queryFn: () => apiFetch<PlatformSettings>('/admin/settings'),
})
const { register, handleSubmit, reset, watch } = useForm<FormData>({ defaultValues })
const selectedPrimary = watch('admin_primary_color') || defaultValues.admin_primary_color
const selectedAccent = watch('admin_accent_color') || defaultValues.admin_accent_color
useEffect(() => {
if (!settings) return
setPreviewBroken(false)
reset({
platform_name: settings.platform_name ?? defaultValues.platform_name,
platform_subtitle: settings.platform_subtitle ?? defaultValues.platform_subtitle,
platform_logo: settings.platform_logo ?? '',
admin_primary_color: settings.admin_primary_color ?? defaultValues.admin_primary_color,
admin_accent_color: settings.admin_accent_color ?? defaultValues.admin_accent_color,
})
}, [settings, reset])
const save = useMutation({
mutationFn: (data: FormData) =>
apiFetch<PlatformSettings>('/admin/settings', { method: 'PUT', body: JSON.stringify(data) }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'settings'] })
qc.invalidateQueries({ queryKey: ['platform', 'settings'] })
},
})
if (isLoading) return <p className="text-slate-300">A carregar...</p>
return (
<div className="max-w-2xl">
<div className="mb-6">
<h1 className="text-2xl font-bold text-[var(--ui-text)]">Definições Globais</h1>
<p className="text-[var(--ui-muted)] text-sm mt-0.5">Branding aplicado ao login e painel de administração global</p>
</div>
<form
onSubmit={handleSubmit((d) => save.mutate(d))}
className="rounded-lg border border-[var(--ui-border)] bg-[var(--ui-panel)] p-6 space-y-4"
>
<div className="space-y-1">
<Label htmlFor="platform_name">Nome da plataforma</Label>
<Input id="platform_name" {...register('platform_name')} />
</div>
<div className="space-y-1">
<Label htmlFor="platform_subtitle">Subtítulo</Label>
<Input id="platform_subtitle" {...register('platform_subtitle')} />
</div>
<div className="space-y-1">
<Label htmlFor="platform_logo">Logotipo global</Label>
<Input id="platform_logo" {...register('platform_logo')} placeholder="https://.../logo.png ou data:image/png;base64,..." />
{settings?.platform_logo && !previewBroken && (
<div className="mt-2 rounded-md border border-[var(--ui-border)] bg-[var(--ui-panel-soft)] p-3">
<p className="text-xs mb-2 text-[var(--ui-muted)]">Pré-visualização:</p>
<img
src={settings.platform_logo}
alt=""
className="h-12 object-contain bg-white p-1 rounded"
onError={() => setPreviewBroken(true)}
referrerPolicy="no-referrer"
/>
</div>
)}
{previewBroken && (
<p className="text-amber-500 text-xs">Não foi possível carregar este logo por URL. Tenta outro URL ou usa `data:image/...`.</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label htmlFor="admin_primary_color">Cor primária admin</Label>
<div className="flex gap-2">
<Input id="admin_primary_color" {...register('admin_primary_color')} />
<Input type="color" {...register('admin_primary_color')} className="w-14 p-1 h-10 cursor-pointer" />
</div>
</div>
<div className="space-y-1">
<Label htmlFor="admin_accent_color">Cor de destaque</Label>
<div className="flex gap-2">
<Input id="admin_accent_color" {...register('admin_accent_color')} />
<Input type="color" {...register('admin_accent_color')} className="w-14 p-1 h-10 cursor-pointer" />
</div>
</div>
</div>
<div className="rounded-md border border-[var(--ui-border)] bg-[var(--ui-panel-soft)] p-3">
<p className="mb-2 text-xs font-medium text-[var(--ui-muted)]">Prévia de aplicação de cor</p>
<div className="flex items-center gap-3">
<span className="inline-flex rounded-md px-3 py-1 text-xs font-semibold text-white" style={{ backgroundColor: selectedPrimary }}>
Botão primário
</span>
<span className="inline-flex rounded-md px-3 py-1 text-xs font-semibold text-white" style={{ backgroundColor: selectedAccent }}>
Destaque
</span>
</div>
</div>
{save.error && <p className="text-red-300 text-sm">{(save.error as Error).message}</p>}
{save.isSuccess && <p className="text-emerald-300 text-sm">Definições globais guardadas.</p>}
<div className="flex justify-end">
<Button type="submit" disabled={save.isPending}>
{save.isPending ? 'A guardar...' : 'Guardar Definições'}
</Button>
</div>
</form>
</div>
)
}
+28 -26
View File
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useTheme } from '@/hooks/useTheme'
import type { Tenant, Invite } from '@/lib/types'
function toSlug(name: string) {
@@ -19,6 +20,7 @@ function toSlug(name: string) {
}
export default function TenantsPage() {
useTheme('ui_theme', 'dark')
const navigate = useNavigate()
const queryClient = useQueryClient()
@@ -91,8 +93,8 @@ export default function TenantsPage() {
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-white">Oficinas</h1>
<p className="text-slate-400 text-sm mt-0.5">{tenants.length} oficinas registadas</p>
<h1 className="text-2xl font-bold text-[var(--ui-text)]">Oficinas</h1>
<p className="text-[var(--ui-muted)] text-sm mt-0.5">{tenants.length} oficinas registadas</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => generateInvite.mutate()} disabled={generateInvite.isPending}>
@@ -105,8 +107,8 @@ export default function TenantsPage() {
</div>
{showCreate && (
<div className="mb-6 bg-slate-800 rounded-lg border border-slate-700 p-6">
<h2 className="text-lg font-semibold text-white mb-4">Nova Oficina</h2>
<div className="mb-6 rounded-lg border border-[var(--ui-border)] bg-[var(--ui-panel)] p-6">
<h2 className="text-lg font-semibold text-[var(--ui-text)] mb-4">Nova Oficina</h2>
<form
onSubmit={(e) => {
e.preventDefault()
@@ -122,7 +124,7 @@ export default function TenantsPage() {
onChange={(e) => setCreateForm(f => ({
...f, name: e.target.value, slug: toSlug(e.target.value),
}))}
className="bg-slate-900 border-slate-600 text-white"
className="font-medium"
required
/>
</div>
@@ -131,7 +133,7 @@ export default function TenantsPage() {
<Input
value={createForm.slug}
onChange={(e) => setCreateForm(f => ({ ...f, slug: e.target.value }))}
className="bg-slate-900 border-slate-600 text-white font-mono text-sm"
className="font-mono text-sm"
required
/>
</div>
@@ -140,7 +142,7 @@ export default function TenantsPage() {
<Input
value={createForm.admin_name}
onChange={(e) => setCreateForm(f => ({ ...f, admin_name: e.target.value }))}
className="bg-slate-900 border-slate-600 text-white"
className="font-medium"
required
/>
</div>
@@ -150,7 +152,7 @@ export default function TenantsPage() {
type="email"
value={createForm.admin_email}
onChange={(e) => setCreateForm(f => ({ ...f, admin_email: e.target.value }))}
className="bg-slate-900 border-slate-600 text-white"
className="font-medium"
required
/>
</div>
@@ -160,7 +162,7 @@ export default function TenantsPage() {
type="password"
value={createForm.admin_password}
onChange={(e) => setCreateForm(f => ({ ...f, admin_password: e.target.value }))}
className="bg-slate-900 border-slate-600 text-white"
className="font-medium"
required
/>
</div>
@@ -178,10 +180,10 @@ export default function TenantsPage() {
)}
{inviteUrl && (
<div className="mb-6 p-4 bg-slate-800 rounded-lg border border-slate-700">
<p className="text-slate-300 text-sm font-medium mb-2">Link de convite (válido 72h):</p>
<div className="mb-6 p-4 rounded-lg border border-[var(--ui-border)] bg-[var(--ui-panel)]">
<p className="text-[var(--ui-text)] text-sm font-medium mb-2">Link de convite (válido 72h):</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs text-green-400 bg-slate-900 px-3 py-2 rounded break-all">
<code className="flex-1 text-xs text-emerald-600 dark:text-emerald-400 bg-[var(--ui-panel-soft)] px-3 py-2 rounded break-all">
{inviteUrl}
</code>
<Button
@@ -194,7 +196,7 @@ export default function TenantsPage() {
</div>
<button
onClick={() => setInviteToken(null)}
className="text-xs text-slate-500 hover:text-slate-400 mt-2"
className="text-xs text-[var(--ui-muted)] hover:text-[var(--ui-text)] mt-2"
>
Fechar
</button>
@@ -202,26 +204,26 @@ export default function TenantsPage() {
)}
{isLoading ? (
<p className="text-slate-400">A carregar...</p>
<p className="text-[var(--ui-muted)]">A carregar...</p>
) : tenants.length === 0 ? (
<p className="text-slate-500 text-sm">Nenhuma oficina registada.</p>
<p className="text-[var(--ui-muted)] text-sm">Nenhuma oficina registada.</p>
) : (
<div className="rounded-lg border border-slate-700 overflow-hidden">
<div className="rounded-lg border border-[var(--ui-border)] overflow-hidden bg-[var(--ui-panel)]">
<table className="w-full text-sm">
<thead className="bg-slate-800">
<thead className="bg-[var(--ui-panel-soft)]">
<tr>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Nome</th>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Slug</th>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Estado</th>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Criada</th>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Ações</th>
<th className="text-left px-4 py-3 text-[var(--ui-muted)] font-medium">Nome</th>
<th className="text-left px-4 py-3 text-[var(--ui-muted)] font-medium">Slug</th>
<th className="text-left px-4 py-3 text-[var(--ui-muted)] font-medium">Estado</th>
<th className="text-left px-4 py-3 text-[var(--ui-muted)] font-medium">Criada</th>
<th className="text-left px-4 py-3 text-[var(--ui-muted)] font-medium">Ações</th>
</tr>
</thead>
<tbody>
{tenants.map((t) => (
<tr key={t.id} className="border-t border-slate-700 hover:bg-slate-800/50">
<td className="px-4 py-3 text-white font-medium">{t.name}</td>
<td className="px-4 py-3 text-slate-400 font-mono text-xs">{t.slug}</td>
<tr key={t.id} className="border-t border-[var(--ui-border)] hover:bg-[var(--ui-hover)] transition-colors">
<td className="px-4 py-3 text-[var(--ui-text)] font-medium">{t.name}</td>
<td className="px-4 py-3 text-[var(--ui-muted)] font-mono text-xs">{t.slug}</td>
<td className="px-4 py-3">
<Badge
variant={
@@ -235,7 +237,7 @@ export default function TenantsPage() {
{t.status}
</Badge>
</td>
<td className="px-4 py-3 text-slate-400">
<td className="px-4 py-3 text-[var(--ui-muted)]">
{new Intl.DateTimeFormat('pt-PT').format(new Date(t.created_at))}
</td>
<td className="px-4 py-3">
+17 -1
View File
@@ -6,6 +6,7 @@ import { z } from 'zod'
import { apiFetch } from '@/lib/api'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { ConfirmActionDialog } from '@/components/ui/confirm-action-dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import type { CatalogItem } from '@/lib/types'
@@ -51,6 +52,7 @@ export default function CatalogPage() {
const qc = useQueryClient()
const [editing, setEditing] = useState<CatalogItem | null>(null)
const [showForm, setShowForm] = useState(false)
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
const { data: items = [], isLoading } = useQuery<CatalogItem[]>({
queryKey: ['catalog'],
@@ -201,7 +203,7 @@ export default function CatalogPage() {
<Button
size="sm"
variant="destructive"
onClick={() => { if (confirm('Eliminar item?')) remove.mutate(item.id) }}
onClick={() => setPendingDeleteId(item.id)}
>
Eliminar
</Button>
@@ -212,6 +214,20 @@ export default function CatalogPage() {
</table>
</div>
)}
<ConfirmActionDialog
open={!!pendingDeleteId}
onOpenChange={(open) => !open && setPendingDeleteId(null)}
title="Eliminar item do catálogo"
description="Esta ação remove o artigo do catálogo de forma permanente."
confirmLabel="Eliminar"
pending={remove.isPending}
onConfirm={() => {
if (!pendingDeleteId) return
remove.mutate(pendingDeleteId, {
onSettled: () => setPendingDeleteId(null),
})
}}
/>
</div>
)
}
+29 -4
View File
@@ -4,7 +4,9 @@ import { useForm, type Resolver } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { apiFetch } from '@/lib/api'
import { VEHICLE_BRANDS, normalizeBrand } from '@/lib/vehicleBrands'
import { Button } from '@/components/ui/button'
import { ConfirmActionDialog } from '@/components/ui/confirm-action-dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import type { Client, Vehicle } from '@/lib/types'
@@ -74,6 +76,7 @@ function ClientVehicles({ clientId }: { clientId: string }) {
mutationFn: (data: VehicleForm) => {
const body = {
...data,
brand: normalizeBrand(data.brand),
year: data.year || null,
mileage: data.mileage || null,
}
@@ -99,7 +102,7 @@ function ClientVehicles({ clientId }: { clientId: string }) {
setEditingVehicle(v)
reset({
plate: v.plate,
brand: v.brand,
brand: normalizeBrand(v.brand),
model: v.model,
year: v.year ?? 0,
vin: v.vin,
@@ -133,8 +136,15 @@ function ClientVehicles({ clientId }: { clientId: string }) {
</div>
<div className="space-y-1">
<Label className="text-xs">Marca</Label>
<Input {...register('brand')} placeholder="Toyota"
className="bg-slate-900 border-slate-600 text-white text-sm h-8" />
<select
{...register('brand')}
className="w-full rounded-md border border-slate-600 bg-slate-900 text-white px-3 text-sm h-8"
>
<option value=""> Marca </option>
{VEHICLE_BRANDS.map((b) => (
<option key={b} value={b}>{b}</option>
))}
</select>
</div>
<div className="space-y-1">
<Label className="text-xs">Modelo</Label>
@@ -237,6 +247,7 @@ export default function ClientsPage() {
const [editing, setEditing] = useState<Client | null>(null)
const [showForm, setShowForm] = useState(false)
const [expandedId, setExpandedId] = useState<string | null>(null)
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
const { data: clients = [], isLoading } = useQuery<Client[]>({
queryKey: ['clients'],
@@ -375,7 +386,7 @@ export default function ClientsPage() {
<Button
size="sm"
variant="destructive"
onClick={() => { if (confirm('Eliminar cliente?')) remove.mutate(c.id) }}
onClick={() => setPendingDeleteId(c.id)}
>
Eliminar
</Button>
@@ -388,6 +399,20 @@ export default function ClientsPage() {
</table>
</div>
)}
<ConfirmActionDialog
open={!!pendingDeleteId}
onOpenChange={(open) => !open && setPendingDeleteId(null)}
title="Eliminar cliente"
description="Esta ação remove o cliente e não pode ser desfeita."
confirmLabel="Eliminar"
pending={remove.isPending}
onConfirm={() => {
if (!pendingDeleteId) return
remove.mutate(pendingDeleteId, {
onSettled: () => setPendingDeleteId(null),
})
}}
/>
</div>
)
}
+87 -71
View File
@@ -1,12 +1,13 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router'
import { useQueries, useQuery } from '@tanstack/react-query'
import { useTheme } from '@/hooks/useTheme'
import { apiFetch } from '@/lib/api'
import type { Client, Expense, Invoice, Staff, WorkOrder, WorkOrderDetail } from '@/lib/types'
const STATUS_LABEL: Record<WorkOrder['status'], string> = {
quote: 'Orçamento',
open: 'Aberta',
open: 'Orçamento Aprovado',
in_progress: 'Em Curso',
completed: 'Concluída',
invoiced: 'Faturada',
@@ -54,23 +55,27 @@ function DashboardCard({
title,
value,
subtitle,
isLight,
}: {
title: string
value: string
subtitle: string
isLight: boolean
}) {
return (
<article className="rounded-2xl border border-slate-700/70 bg-slate-900/65 p-4 transition-colors hover:border-slate-500/80">
<p className="text-[11px] uppercase tracking-[0.14em] text-slate-400">{title}</p>
<p className="mt-2 text-2xl font-semibold text-white [font-family:'Space_Grotesk',ui-sans-serif,sans-serif]">
<article className={`rounded-2xl border p-4 transition-colors ${isLight ? 'border-slate-300 bg-white hover:border-slate-400' : 'border-slate-700/70 bg-slate-900/65 hover:border-slate-500/80'}`}>
<p className={`text-[11px] uppercase tracking-[0.14em] ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>{title}</p>
<p className={`mt-2 text-2xl font-semibold [font-family:'Space_Grotesk',ui-sans-serif,sans-serif] ${isLight ? 'text-slate-900' : 'text-white'}`}>
{value}
</p>
<p className="mt-1 text-xs text-slate-400">{subtitle}</p>
<p className={`mt-1 text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>{subtitle}</p>
</article>
)
}
export default function DashboardPage() {
const { theme } = useTheme('ui_theme', 'dark')
const isLight = theme === 'light'
const [period, setPeriod] = useState<PeriodKey>('month')
const workOrdersQ = useQuery<WorkOrder[]>({
@@ -160,8 +165,8 @@ export default function DashboardPage() {
.sort((a, b) => +new Date(b.updated_at) - +new Date(a.updated_at))
.slice(0, 6)
const recentDocs = [...invoicesPeriod]
.sort((a, b) => +new Date(b.issued_at) - +new Date(a.issued_at))
const recentExpenses = [...expensesPeriod]
.sort((a, b) => +new Date(b.date) - +new Date(a.date))
.slice(0, 6)
return {
@@ -174,7 +179,7 @@ export default function DashboardPage() {
monthDocs,
activeStaff,
recentOrders,
recentDocs,
recentExpenses,
periodOrdersCount: workOrdersPeriod.length,
periodDocsCount: invoicesPeriod.length,
}
@@ -186,14 +191,14 @@ export default function DashboardPage() {
return (
<section className="space-y-6 [font-family:'Sora',ui-sans-serif,sans-serif]">
<header className="relative overflow-hidden rounded-2xl border border-slate-700/60 bg-gradient-to-br from-slate-900 via-slate-900 to-sky-950/35 p-6">
<header className={`relative overflow-hidden rounded-2xl border p-6 ${isLight ? 'border-slate-300 bg-gradient-to-br from-white via-slate-50 to-sky-50' : 'border-slate-700/60 bg-gradient-to-br from-slate-900 via-slate-900 to-sky-950/35'}`}>
<div className="absolute -right-16 -top-16 h-44 w-44 rounded-full bg-cyan-600/10 blur-2xl" />
<div className="absolute -left-12 bottom-0 h-32 w-32 rounded-full bg-amber-500/10 blur-2xl" />
<p className="text-xs uppercase tracking-[0.2em] text-slate-400">Painel Operacional</p>
<h1 className="mt-1 text-3xl font-semibold text-white [font-family:'Space_Grotesk',ui-sans-serif,sans-serif]">
<p className={`text-xs uppercase tracking-[0.2em] ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>Painel Operacional</p>
<h1 className={`mt-1 text-3xl font-semibold [font-family:'Space_Grotesk',ui-sans-serif,sans-serif] ${isLight ? 'text-slate-900' : 'text-white'}`}>
Dashboard da Oficina
</h1>
<p className="mt-2 max-w-2xl text-sm text-slate-300">
<p className={`mt-2 max-w-2xl text-sm ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>
Visão rápida do trabalho em curso, faturação e despesas para apoiar as decisões do dia.
</p>
<div className="mt-4 flex flex-wrap gap-2">
@@ -203,8 +208,12 @@ export default function DashboardPage() {
onClick={() => setPeriod(opt.key)}
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
period === opt.key
? 'border-cyan-400/80 bg-cyan-500/20 text-cyan-100'
: 'border-slate-700 bg-slate-900/80 text-slate-300 hover:border-slate-500 hover:text-white'
? isLight
? 'border-sky-300 bg-sky-100 text-sky-900'
: 'border-cyan-400/80 bg-cyan-500/20 text-cyan-100'
: isLight
? 'border-slate-300 bg-white text-slate-700 hover:border-slate-400 hover:text-slate-900'
: 'border-slate-700 bg-slate-900/80 text-slate-300 hover:border-slate-500 hover:text-white'
}`}
>
{opt.label}
@@ -214,7 +223,7 @@ export default function DashboardPage() {
</header>
{isLoading && (
<p className="rounded-xl border border-slate-700 bg-slate-900/60 px-4 py-3 text-sm text-slate-300">
<p className={`rounded-xl border px-4 py-3 text-sm ${isLight ? 'border-slate-300 bg-white text-slate-600' : 'border-slate-700 bg-slate-900/60 text-slate-300'}`}>
A carregar métricas do dashboard...
</p>
)}
@@ -230,78 +239,82 @@ export default function DashboardPage() {
title="OTs Ativas"
value={String(metrics.activeOrders)}
subtitle={`No período (${metrics.periodOrdersCount} OTs)`}
isLight={isLight}
/>
<DashboardCard
title="Clientes"
value={String(clients.length)}
subtitle="Base total de clientes"
isLight={isLight}
/>
<DashboardCard
title="Técnicos Ativos"
value={`${metrics.activeStaff}/${staff.length}`}
subtitle="Recursos disponíveis"
isLight={isLight}
/>
<DashboardCard
title="Despesas do Período"
value={currency(metrics.periodExpenses)}
subtitle="Somatório no filtro selecionado"
isLight={isLight}
/>
</div>
<article className="rounded-2xl border border-slate-700/70 bg-slate-900/65 p-5">
<article className={`rounded-2xl border p-5 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700/70 bg-slate-900/65'}`}>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-semibold text-white">Entradas Recebidas vs Despesas</h2>
<span className="text-xs text-slate-400">
<h2 className={`text-base font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Entradas Recebidas vs Despesas</h2>
<span className={`text-xs ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>
{PERIOD_OPTIONS.find((p) => p.key === period)?.label}
</span>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-lg border border-emerald-800/60 bg-emerald-950/20 p-3">
<p className="text-xs uppercase tracking-wide text-emerald-300">Entradas (faturas)</p>
<p className="mt-1 text-2xl font-semibold text-white">{currency(metrics.periodEntries)}</p>
<div className={`rounded-lg border p-3 ${isLight ? 'border-emerald-200 bg-emerald-50' : 'border-emerald-800/60 bg-emerald-950/20'}`}>
<p className={`text-xs uppercase tracking-wide ${isLight ? 'text-emerald-700' : 'text-emerald-300'}`}>Entradas (faturas)</p>
<p className={`mt-1 text-2xl font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>{currency(metrics.periodEntries)}</p>
</div>
<div className="rounded-lg border border-rose-800/60 bg-rose-950/20 p-3">
<p className="text-xs uppercase tracking-wide text-rose-300">Despesas</p>
<p className="mt-1 text-2xl font-semibold text-white">{currency(metrics.periodExpenses)}</p>
<div className={`rounded-lg border p-3 ${isLight ? 'border-rose-200 bg-rose-50' : 'border-rose-800/60 bg-rose-950/20'}`}>
<p className={`text-xs uppercase tracking-wide ${isLight ? 'text-rose-700' : 'text-rose-300'}`}>Despesas</p>
<p className={`mt-1 text-2xl font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>{currency(metrics.periodExpenses)}</p>
</div>
</div>
<div className="mt-4 space-y-3">
<div>
<div className="mb-1 flex justify-between text-xs">
<span className="text-slate-300">Entradas recebidas</span>
<span className="font-mono text-emerald-300">{currency(metrics.periodEntries)}</span>
<span className={isLight ? 'text-slate-700' : 'text-slate-300'}>Entradas recebidas</span>
<span className={`font-mono ${isLight ? 'text-emerald-700' : 'text-emerald-300'}`}>{currency(metrics.periodEntries)}</span>
</div>
<div className="h-3 rounded-full bg-slate-800">
<div className={`h-3 rounded-full ${isLight ? 'bg-slate-200' : 'bg-slate-800'}`}>
<div className="h-3 rounded-full bg-gradient-to-r from-emerald-500 to-teal-400" style={{ width: `${entriesBarWidth}%` }} />
</div>
</div>
<div>
<div className="mb-1 flex justify-between text-xs">
<span className="text-slate-300">Despesas registadas</span>
<span className="font-mono text-rose-300">{currency(metrics.periodExpenses)}</span>
<span className={isLight ? 'text-slate-700' : 'text-slate-300'}>Despesas registadas</span>
<span className={`font-mono ${isLight ? 'text-rose-700' : 'text-rose-300'}`}>{currency(metrics.periodExpenses)}</span>
</div>
<div className="h-3 rounded-full bg-slate-800">
<div className={`h-3 rounded-full ${isLight ? 'bg-slate-200' : 'bg-slate-800'}`}>
<div className="h-3 rounded-full bg-gradient-to-r from-rose-500 to-orange-400" style={{ width: `${expensesBarWidth}%` }} />
</div>
</div>
</div>
<p className="mt-4 text-sm text-slate-300">
Saldo estimado: <span className={metrics.periodEntries - metrics.periodExpenses >= 0 ? 'text-emerald-300' : 'text-rose-300'}>
<p className={`mt-4 text-sm ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>
Saldo estimado: <span className={metrics.periodEntries - metrics.periodExpenses >= 0 ? (isLight ? 'text-emerald-700' : 'text-emerald-300') : (isLight ? 'text-rose-700' : 'text-rose-300')}>
{currency(metrics.periodEntries - metrics.periodExpenses)}
</span>
</p>
{(entriesLoading || entriesError) && (
<p className="mt-2 text-xs text-slate-400">
<p className={`mt-2 text-xs ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>
{entriesLoading ? 'A calcular totais de entradas...' : 'Algumas entradas não puderam ser calculadas.'}
</p>
)}
</article>
<div className="grid items-stretch gap-4 xl:grid-cols-2">
<article className="h-full rounded-2xl border border-slate-700/70 bg-slate-900/65 p-5">
<article className={`h-full rounded-2xl border p-5 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700/70 bg-slate-900/65'}`}>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-semibold text-white">Fluxo das Ordens de Trabalho</h2>
<Link to="/app/work-orders" className="text-xs text-cyan-300 hover:text-cyan-200">
<h2 className={`text-base font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Fluxo das Ordens de Trabalho</h2>
<Link to="/app/work-orders" className={`text-xs ${isLight ? 'text-sky-700 hover:text-sky-800' : 'text-cyan-300 hover:text-cyan-200'}`}>
Ver todas
</Link>
</div>
@@ -313,10 +326,10 @@ export default function DashboardPage() {
return (
<div key={status}>
<div className="mb-1 flex justify-between text-xs">
<span className="text-slate-300">{STATUS_LABEL[status]}</span>
<span className="font-mono text-slate-400">{count}</span>
<span className={isLight ? 'text-slate-700' : 'text-slate-300'}>{STATUS_LABEL[status]}</span>
<span className={`font-mono ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>{count}</span>
</div>
<div className="h-2 rounded-full bg-slate-800">
<div className={`h-2 rounded-full ${isLight ? 'bg-slate-200' : 'bg-slate-800'}`}>
<div className="h-2 rounded-full bg-gradient-to-r from-cyan-500 to-emerald-500" style={{ width: `${width}%` }} />
</div>
</div>
@@ -325,14 +338,14 @@ export default function DashboardPage() {
</div>
</article>
<article className="h-full rounded-2xl border border-slate-700/70 bg-slate-900/65 p-5">
<h2 className="text-base font-semibold text-white">Faturação e Prioridades</h2>
<article className={`h-full rounded-2xl border p-5 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700/70 bg-slate-900/65'}`}>
<h2 className={`text-base font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Faturação e Prioridades</h2>
<div className="mt-4 space-y-3 text-sm">
<div className="rounded-lg border border-slate-700 bg-slate-900/70 px-3 py-2">
<p className="text-slate-400">Documentos emitidos no período</p>
<p className="text-xl font-semibold text-white">{metrics.periodDocsCount}</p>
<div className={`rounded-lg border px-3 py-2 ${isLight ? 'border-slate-200 bg-slate-100' : 'border-slate-700 bg-slate-900/70'}`}>
<p className={isLight ? 'text-slate-600' : 'text-slate-400'}>Documentos emitidos no período</p>
<p className={`text-xl font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>{metrics.periodDocsCount}</p>
</div>
<div className="rounded-lg border border-amber-800/70 bg-amber-950/30 px-3 py-2 text-amber-100">
<div className={`rounded-lg border px-3 py-2 ${isLight ? 'border-amber-200 bg-amber-50 text-amber-900' : 'border-amber-800/70 bg-amber-950/30 text-amber-100'}`}>
<p className="text-xs uppercase tracking-wide">Ação recomendada</p>
<p className="mt-1">
{metrics.byStatus.completed > 0
@@ -340,7 +353,7 @@ export default function DashboardPage() {
: 'Sem OTs concluídas pendentes de faturação.'}
</p>
</div>
<div className="rounded-lg border border-cyan-900/70 bg-cyan-950/25 px-3 py-2 text-cyan-100">
<div className={`rounded-lg border px-3 py-2 ${isLight ? 'border-cyan-200 bg-cyan-50 text-cyan-900' : 'border-cyan-900/70 bg-cyan-950/25 text-cyan-100'}`}>
<p className="text-xs uppercase tracking-wide">Em orçamento</p>
<p className="mt-1">
{metrics.byStatus.quote > 0
@@ -348,9 +361,9 @@ export default function DashboardPage() {
: 'Não existem OTs pendentes em orçamento.'}
</p>
</div>
<div className="rounded-lg border border-slate-700 bg-slate-900/70 px-3 py-2">
<p className="text-slate-400">Contexto mensal</p>
<p className="text-slate-200 mt-1 text-xs">
<div className={`rounded-lg border px-3 py-2 ${isLight ? 'border-slate-200 bg-slate-100' : 'border-slate-700 bg-slate-900/70'}`}>
<p className={isLight ? 'text-slate-600' : 'text-slate-400'}>Contexto mensal</p>
<p className={`mt-1 text-xs ${isLight ? 'text-slate-700' : 'text-slate-200'}`}>
Entradas no mês: {currency(metrics.monthEntries)} | Despesas no mês: {currency(metrics.monthExpenses)} | Documentos: {metrics.monthDocs}
</p>
</div>
@@ -359,24 +372,24 @@ export default function DashboardPage() {
</div>
<div className="grid gap-4 xl:grid-cols-2">
<article className="rounded-2xl border border-slate-700/70 bg-slate-900/65 p-5">
<article className={`rounded-2xl border p-5 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700/70 bg-slate-900/65'}`}>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-base font-semibold text-white">Atividade Recente de OTs</h2>
<Link to="/app/work-orders" className="text-xs text-cyan-300 hover:text-cyan-200">
<h2 className={`text-base font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Atividade Recente de OTs</h2>
<Link to="/app/work-orders" className={`text-xs ${isLight ? 'text-sky-700 hover:text-sky-800' : 'text-cyan-300 hover:text-cyan-200'}`}>
Abrir OTs
</Link>
</div>
{metrics.recentOrders.length === 0 ? (
<p className="text-sm text-slate-400">Ainda não existem ordens de trabalho.</p>
<p className={`text-sm ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Ainda não existem ordens de trabalho.</p>
) : (
<ul className="divide-y divide-slate-800">
<ul className={`divide-y ${isLight ? 'divide-slate-200' : 'divide-slate-800'}`}>
{metrics.recentOrders.map((wo) => (
<li key={wo.id} className="flex items-center justify-between py-2">
<div>
<p className="text-sm font-medium text-white">OT #{wo.number}</p>
<p className="text-xs text-slate-400">{STATUS_LABEL[wo.status]}</p>
<p className={`text-sm font-medium ${isLight ? 'text-slate-900' : 'text-white'}`}>OT #{wo.number}</p>
<p className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>{STATUS_LABEL[wo.status]}</p>
</div>
<span className="text-xs text-slate-500">
<span className={`text-xs ${isLight ? 'text-slate-500' : 'text-slate-500'}`}>
{new Intl.DateTimeFormat('pt-PT', { dateStyle: 'short', timeStyle: 'short' }).format(new Date(wo.updated_at))}
</span>
</li>
@@ -385,27 +398,30 @@ export default function DashboardPage() {
)}
</article>
<article className="rounded-2xl border border-slate-700/70 bg-slate-900/65 p-5">
<article className={`rounded-2xl border p-5 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700/70 bg-slate-900/65'}`}>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-base font-semibold text-white">Documentos Recentes</h2>
<Link to="/app/invoices" className="text-xs text-cyan-300 hover:text-cyan-200">
Abrir Faturação
<h2 className={`text-base font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Despesas Recentes</h2>
<Link to="/app/expenses" className={`text-xs ${isLight ? 'text-sky-700 hover:text-sky-800' : 'text-cyan-300 hover:text-cyan-200'}`}>
Abrir Despesas
</Link>
</div>
{metrics.recentDocs.length === 0 ? (
<p className="text-sm text-slate-400">Sem documentos emitidos.</p>
{metrics.recentExpenses.length === 0 ? (
<p className={`text-sm ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Sem despesas no período selecionado.</p>
) : (
<ul className="divide-y divide-slate-800">
{metrics.recentDocs.map((doc) => (
<li key={doc.id} className="flex items-center justify-between py-2">
<ul className={`divide-y ${isLight ? 'divide-slate-200' : 'divide-slate-800'}`}>
{metrics.recentExpenses.map((exp) => (
<li key={exp.id} className="flex items-center justify-between py-2">
<div>
<p className="text-sm font-medium text-white">
{doc.type === 'invoice' ? 'Fatura' : 'Orçamento'} #{doc.number}
<p className={`text-sm font-medium ${isLight ? 'text-slate-900' : 'text-white'}`}>
{currency(exp.amount)}
</p>
<p className={`text-xs ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>
{exp.type === 'fuel' ? 'Combustível' : exp.type === 'parts' ? 'Peças' : exp.type === 'tools' ? 'Ferramentas' : 'Outros'}
{exp.description ? ` · ${exp.description}` : ''}
</p>
<p className="text-xs text-slate-400 font-mono">OT {doc.work_order_id.slice(0, 8)}...</p>
</div>
<span className="text-xs text-slate-500">
{new Intl.DateTimeFormat('pt-PT', { dateStyle: 'short' }).format(new Date(doc.issued_at))}
<span className={`text-xs ${isLight ? 'text-slate-500' : 'text-slate-500'}`}>
{new Intl.DateTimeFormat('pt-PT', { dateStyle: 'short' }).format(new Date(exp.date))}
</span>
</li>
))}
@@ -0,0 +1,275 @@
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api'
import { useTheme } from '@/hooks/useTheme'
import type { Expense } from '@/lib/types'
type PeriodKey = 'month' | '30d' | '90d' | 'year' | 'all'
type ExpenseType = '' | 'fuel' | 'parts' | 'tools' | 'other'
const PERIOD_OPTIONS: { key: PeriodKey; label: string }[] = [
{ key: 'month', label: 'Mês atual' },
{ key: '30d', label: '30 dias' },
{ key: '90d', label: '90 dias' },
{ key: 'year', label: '12 meses' },
{ key: 'all', label: 'Todo o histórico' },
]
const TYPE_LABEL: Record<Exclude<ExpenseType, ''>, string> = {
fuel: 'Combustível',
parts: 'Peças',
tools: 'Ferramentas',
other: 'Outros',
}
function periodStart(period: PeriodKey) {
const now = new Date()
if (period === 'all') return null
const start = new Date(now)
if (period === 'month') {
start.setDate(1)
start.setHours(0, 0, 0, 0)
return start
}
if (period === '30d') start.setDate(now.getDate() - 30)
if (period === '90d') start.setDate(now.getDate() - 90)
if (period === 'year') start.setMonth(now.getMonth() - 12)
return start
}
function currency(v: number) {
return new Intl.NumberFormat('pt-PT', { style: 'currency', currency: 'EUR' }).format(v)
}
function monthKey(d: Date) {
return `${d.getFullYear()}-${d.getMonth()}`
}
export default function ExpenseReportsPage() {
const { theme } = useTheme('ui_theme', 'dark')
const isLight = theme === 'light'
const [period, setPeriod] = useState<PeriodKey>('90d')
const [typeFilter, setTypeFilter] = useState<ExpenseType>('')
const expensesQ = useQuery<Expense[]>({
queryKey: ['expenses', 'reports'],
queryFn: () => apiFetch<Expense[]>('/expenses'),
})
const expenses = expensesQ.data ?? []
const filtered = useMemo(() => {
const start = periodStart(period)
return expenses.filter((e) => {
if (typeFilter && e.type !== typeFilter) return false
if (start && new Date(e.date) < start) return false
return true
})
}, [expenses, period, typeFilter])
const kpis = useMemo(() => {
const total = filtered.reduce((sum, e) => sum + e.amount, 0)
const count = filtered.length
const avg = count > 0 ? total / count : 0
const maxExpense = filtered.reduce((mx, e) => (e.amount > mx.amount ? e : mx), { amount: 0, type: 'other' as Expense['type'] })
const byType = {
fuel: 0,
parts: 0,
tools: 0,
other: 0,
}
for (const e of filtered) byType[e.type] += e.amount
const topType = (Object.entries(byType) as [keyof typeof byType, number][])
.sort((a, b) => b[1] - a[1])[0]
return {
total,
count,
avg,
maxValue: maxExpense.amount,
maxType: maxExpense.type,
topType: topType?.[0] ?? 'other',
topTypeAmount: topType?.[1] ?? 0,
}
}, [filtered])
const byTypeRows = useMemo(() => {
const totals = {
fuel: 0,
parts: 0,
tools: 0,
other: 0,
}
for (const e of filtered) totals[e.type] += e.amount
return (Object.entries(totals) as [keyof typeof totals, number][])
.map(([type, total]) => ({ type, total, pct: kpis.total > 0 ? (total / kpis.total) * 100 : 0 }))
.sort((a, b) => b.total - a.total)
}, [filtered, kpis.total])
const monthly = useMemo(() => {
const now = new Date()
const buckets = Array.from({ length: 6 }).map((_, i) => {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
return {
key: monthKey(d),
label: new Intl.DateTimeFormat('pt-PT', { month: 'short' }).format(d),
total: 0,
count: 0,
}
}).reverse()
const index = new Map(buckets.map((b, i) => [b.key, i]))
for (const e of filtered) {
const idx = index.get(monthKey(new Date(e.date)))
if (idx === undefined) continue
buckets[idx].total += e.amount
buckets[idx].count += 1
}
return buckets
}, [filtered])
const maxMonthly = Math.max(1, ...monthly.map((m) => m.total))
const topExpenses = [...filtered]
.sort((a, b) => b.amount - a.amount)
.slice(0, 7)
return (
<section className="space-y-6">
<header className={`rounded-2xl border p-6 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<p className={`text-xs uppercase tracking-[0.2em] ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>
Relatórios Financeiros
</p>
<h1 className={`mt-1 text-3xl font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>
Relatório de Despesas
</h1>
<p className={`mt-2 text-sm ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>
Acompanhamento de custos por período e categoria para suportar decisões operacionais.
</p>
</header>
<div className={`rounded-2xl border p-4 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<div className="grid gap-3 md:grid-cols-2">
<label className="text-sm">
<span className={`mb-1 block ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>Período</span>
<select
className={`w-full rounded-md border px-3 py-2 text-sm ${isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'}`}
value={period}
onChange={(e) => setPeriod(e.target.value as PeriodKey)}
>
{PERIOD_OPTIONS.map((o) => (
<option key={o.key} value={o.key}>{o.label}</option>
))}
</select>
</label>
<label className="text-sm">
<span className={`mb-1 block ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>Categoria</span>
<select
className={`w-full rounded-md border px-3 py-2 text-sm ${isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'}`}
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value as ExpenseType)}
>
<option value="">Todas</option>
<option value="fuel">Combustível</option>
<option value="parts">Peças</option>
<option value="tools">Ferramentas</option>
<option value="other">Outros</option>
</select>
</label>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{[
['Total de despesas', currency(kpis.total)],
['Registos', String(kpis.count)],
['Média por registo', currency(kpis.avg)],
['Maior despesa', currency(kpis.maxValue)],
].map(([label, value]) => (
<article key={label} className={`rounded-xl border p-4 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<p className={`text-xs uppercase tracking-wide ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>{label}</p>
<p className={`mt-1 text-2xl font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>{value}</p>
</article>
))}
</div>
{expensesQ.isLoading && (
<p className={isLight ? 'text-slate-600 text-sm' : 'text-slate-400 text-sm'}>A calcular relatório de despesas...</p>
)}
<div className="grid gap-4 xl:grid-cols-2">
<article className={`rounded-2xl border p-5 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<h2 className={`text-lg font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Distribuição por Categoria</h2>
<p className={`mt-1 text-sm ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>
Categoria com maior peso: {TYPE_LABEL[kpis.topType as keyof typeof TYPE_LABEL]} ({currency(kpis.topTypeAmount)})
</p>
<div className="mt-4 space-y-3">
{byTypeRows.map((row) => (
<div key={row.type}>
<div className="mb-1 flex items-center justify-between gap-2 text-sm">
<span className={isLight ? 'text-slate-800' : 'text-slate-200'}>{TYPE_LABEL[row.type]}</span>
<span className={isLight ? 'text-slate-600' : 'text-slate-400'}>
{currency(row.total)} ({row.pct.toFixed(1)}%)
</span>
</div>
<div className={`h-2 rounded-full ${isLight ? 'bg-slate-200' : 'bg-slate-800'}`}>
<div className="h-2 rounded-full bg-gradient-to-r from-cyan-500 to-blue-500" style={{ width: `${row.pct}%` }} />
</div>
</div>
))}
</div>
</article>
<article className={`rounded-2xl border p-5 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<h2 className={`text-lg font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Evolução Mensal (Despesas)</h2>
<div className="mt-4 space-y-3">
{monthly.map((m) => (
<div key={m.key}>
<div className="mb-1 flex justify-between text-xs">
<span className={isLight ? 'text-slate-700 capitalize' : 'text-slate-300 capitalize'}>{m.label}</span>
<span className={isLight ? 'text-slate-600' : 'text-slate-400'}>
{m.count} reg. | {currency(m.total)}
</span>
</div>
<div className={`h-2 rounded-full ${isLight ? 'bg-slate-200' : 'bg-slate-800'}`}>
<div
className="h-2 rounded-full bg-gradient-to-r from-violet-500 to-fuchsia-500"
style={{ width: `${(m.total / maxMonthly) * 100}%` }}
/>
</div>
</div>
))}
</div>
</article>
<article className={`rounded-2xl border p-5 xl:col-span-2 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<h2 className={`text-lg font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Top Despesas do Período</h2>
{topExpenses.length === 0 ? (
<p className={`mt-3 text-sm ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Sem despesas para o filtro atual.</p>
) : (
<div className="mt-4 overflow-hidden rounded-lg border border-slate-300/70 dark:border-slate-700">
<table className="w-full text-sm">
<thead className={isLight ? 'bg-slate-100' : 'bg-slate-800'}>
<tr>
<th className={`px-3 py-2 text-left ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>Data</th>
<th className={`px-3 py-2 text-left ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>Categoria</th>
<th className={`px-3 py-2 text-left ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>Descrição</th>
<th className={`px-3 py-2 text-right ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>Valor</th>
</tr>
</thead>
<tbody>
{topExpenses.map((e) => (
<tr key={e.id} className={`border-t ${isLight ? 'border-slate-200' : 'border-slate-700'}`}>
<td className={`px-3 py-2 ${isLight ? 'text-slate-800' : 'text-slate-200'}`}>
{new Intl.DateTimeFormat('pt-PT').format(new Date(e.date))}
</td>
<td className={`px-3 py-2 ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>{TYPE_LABEL[e.type]}</td>
<td className={`px-3 py-2 ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>{e.description || '—'}</td>
<td className={`px-3 py-2 text-right font-medium ${isLight ? 'text-slate-900' : 'text-white'}`}>{currency(e.amount)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</article>
</div>
</section>
)
}
+17 -1
View File
@@ -6,6 +6,7 @@ import { z } from 'zod'
import { apiFetch } from '@/lib/api'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { ConfirmActionDialog } from '@/components/ui/confirm-action-dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import type { Expense } from '@/lib/types'
@@ -31,6 +32,7 @@ export default function ExpensesPage() {
const qc = useQueryClient()
const [typeFilter, setTypeFilter] = useState('')
const [showForm, setShowForm] = useState(false)
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
const { data: expenses = [], isLoading } = useQuery<Expense[]>({
queryKey: ['expenses', typeFilter],
@@ -162,7 +164,7 @@ export default function ExpensesPage() {
<Button
size="sm"
variant="destructive"
onClick={() => { if (confirm('Eliminar despesa?')) remove.mutate(e.id) }}
onClick={() => setPendingDeleteId(e.id)}
>
Eliminar
</Button>
@@ -173,6 +175,20 @@ export default function ExpensesPage() {
</table>
</div>
)}
<ConfirmActionDialog
open={!!pendingDeleteId}
onOpenChange={(open) => !open && setPendingDeleteId(null)}
title="Eliminar despesa"
description="Esta ação remove a despesa de forma permanente."
confirmLabel="Eliminar"
pending={remove.isPending}
onConfirm={() => {
if (!pendingDeleteId) return
remove.mutate(pendingDeleteId, {
onSettled: () => setPendingDeleteId(null),
})
}}
/>
</div>
)
}
+13
View File
@@ -0,0 +1,13 @@
export default function HelpPage() {
return (
<div className="space-y-4">
<section className="rounded-2xl border border-slate-200/80 bg-white/90 p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900/60">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400">Ajuda</p>
<h1 className="mt-2 text-3xl font-bold text-slate-900 dark:text-white">Centro de ajuda</h1>
<p className="mt-2 text-sm text-slate-600 dark:text-slate-300">
Em breve vamos disponibilizar documentação de uso, boas práticas e dicas rápidas para a equipa.
</p>
</section>
</div>
)
}
+203 -158
View File
@@ -1,56 +1,109 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { Link } from 'react-router'
import { useQuery, useQueries } from '@tanstack/react-query'
import { apiFetch, apiFetchBlob } from '@/lib/api'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import type { Invoice, WorkOrder } from '@/lib/types'
const STATUS_LABELS: Record<string, string> = {
quote: 'Orçamento',
open: 'Aberta',
in_progress: 'Em Curso',
completed: 'Concluída',
invoiced: 'Faturada',
cancelled: 'Cancelada',
import type { Invoice, WorkOrder, Client, Vehicle } from '@/lib/types'
type PeriodKey = 'month' | '30d' | '90d' | 'year' | 'all'
type DocFilter = 'all' | 'quote' | 'invoice'
const PERIOD_OPTIONS: { key: PeriodKey; label: string }[] = [
{ key: 'month', label: 'Mês atual' },
{ key: '30d', label: '30 dias' },
{ key: '90d', label: '90 dias' },
{ key: 'year', label: '12 meses' },
{ key: 'all', label: 'Todo o período' },
]
function getPeriodStart(period: PeriodKey, now = new Date()) {
const start = new Date(now)
if (period === 'all') return null
if (period === 'month') {
start.setDate(1)
start.setHours(0, 0, 0, 0)
return start
}
if (period === '30d') {
start.setDate(now.getDate() - 30)
return start
}
if (period === '90d') {
start.setDate(now.getDate() - 90)
return start
}
start.setMonth(now.getMonth() - 12)
return start
}
export default function InvoicesPage() {
const qc = useQueryClient()
const [showGenerate, setShowGenerate] = useState(false)
const [selectedWO, setSelectedWO] = useState('')
const [docType, setDocType] = useState<'quote' | 'invoice'>('quote')
const [viewError, setViewError] = useState('')
const [period, setPeriod] = useState<PeriodKey>('month')
const [clientFilter, setClientFilter] = useState('all')
const [docFilter, setDocFilter] = useState<DocFilter>('all')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
queryKey: ['invoices'],
queryFn: () => apiFetch<Invoice[]>('/invoices'),
})
const quoteDocs = invoices.filter((i) => i.type === 'quote')
const invoiceDocs = invoices.filter((i) => i.type === 'invoice')
const { data: workOrders = [] } = useQuery<WorkOrder[]>({
queryKey: ['work-orders-for-invoice'],
queryKey: ['work-orders-for-transactions'],
queryFn: () => apiFetch<WorkOrder[]>('/work-orders'),
enabled: showGenerate,
})
const { data: clients = [] } = useQuery<Client[]>({
queryKey: ['clients'],
queryFn: () => apiFetch<Client[]>('/clients'),
})
const eligibleWOs = workOrders.filter((wo) =>
wo.status === 'quote' || wo.status === 'open' || wo.status === 'in_progress' || wo.status === 'completed'
const clientNameById = Object.fromEntries(clients.map((c) => [c.id, c.name]))
const woById = Object.fromEntries(workOrders.map((wo) => [wo.id, wo]))
const uniqueClientIds = Array.from(
new Set(workOrders.map((wo) => wo.client_id).filter((id): id is string => !!id))
)
const eligibleSorted = [...eligibleWOs].sort((a, b) => b.number - a.number)
const generate = useMutation({
mutationFn: () =>
apiFetch<Invoice>('/invoices', {
method: 'POST',
body: JSON.stringify({ work_order_id: selectedWO, type: docType }),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['invoices'] })
qc.invalidateQueries({ queryKey: ['work-orders'] })
setShowGenerate(false)
setSelectedWO('')
},
const vehiclesByClientQ = useQueries({
queries: uniqueClientIds.map((clientId) => ({
queryKey: ['vehicles', clientId, 'transactions'],
queryFn: () => apiFetch<Vehicle[]>(`/clients/${clientId}/vehicles`),
staleTime: 5 * 60 * 1000,
})),
})
const vehicleById = useMemo(() => {
const map: Record<string, Vehicle> = {}
for (const q of vehiclesByClientQ) {
for (const v of (q.data ?? [])) map[v.id] = v
}
return map
}, [vehiclesByClientQ])
const filtered = useMemo(() => {
const start = getPeriodStart(period)
return invoices.filter((inv) => {
if (docFilter !== 'all' && inv.type !== docFilter) return false
const wo = woById[inv.work_order_id]
if (clientFilter !== 'all' && wo?.client_id !== clientFilter) return false
const issued = new Date(inv.issued_at)
if (start && issued < start) return false
if (dateFrom) {
const from = new Date(`${dateFrom}T00:00:00`)
if (issued < from) return false
}
if (dateTo) {
const to = new Date(`${dateTo}T23:59:59`)
if (issued > to) return false
}
return true
})
}, [invoices, woById, period, clientFilter, docFilter, dateFrom, dateTo])
const sorted = [...filtered].sort(
(a, b) => new Date(b.issued_at).getTime() - new Date(a.issued_at).getTime()
)
const totalQuotes = sorted.filter((d) => d.type === 'quote').length
const totalInvoices = sorted.filter((d) => d.type === 'invoice').length
async function openPDF(inv: Invoice) {
setViewError('')
@@ -66,143 +119,135 @@ export default function InvoicesPage() {
return (
<div>
<div className="flex items-center justify-between mb-6">
<div className="mb-6 flex items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-white">Transações</h1>
<p className="text-slate-400 text-sm mt-0.5">
{invoices.length} documentos emitidos (orçamentos e faturas)
<p className="mt-0.5 text-sm text-slate-400">
Consulta e impressão de documentos gerados pelo fluxo das OTs.
</p>
</div>
<Button onClick={() => setShowGenerate(true)}>Nova Transação</Button>
<Link to="/app/work-orders" className="text-xs text-cyan-300 hover:text-cyan-200">
Ir para Ordens de Trabalho
</Link>
</div>
{showGenerate && (
<div className="mb-6 bg-slate-800 rounded-lg border border-slate-700 p-6">
<h2 className="text-lg font-semibold text-white mb-4">Gerar Documento</h2>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-sm text-slate-300">Tipo</label>
<select
value={docType}
onChange={(e) => setDocType(e.target.value as 'quote' | 'invoice')}
className="w-full rounded-md border border-slate-600 bg-slate-900 text-white px-3 py-2 text-sm"
>
<option value="quote">Orçamento</option>
<option value="invoice">Fatura</option>
</select>
</div>
<div className="space-y-1">
<label className="text-sm text-slate-300">Ordem de Trabalho</label>
<select
value={selectedWO}
onChange={(e) => setSelectedWO(e.target.value)}
className="w-full rounded-md border border-slate-600 bg-slate-900 text-white px-3 py-2 text-sm"
>
<option value=""> Seleccionar OT </option>
{eligibleSorted.map((wo) => (
<option key={wo.id} value={wo.id}>
#{wo.number} ({STATUS_LABELS[wo.status] ?? wo.status})
</option>
))}
</select>
</div>
</div>
{generate.error && (
<p className="text-red-400 text-sm mt-3">{(generate.error as Error).message}</p>
)}
<div className="flex gap-2 justify-end mt-4">
<Button variant="outline" onClick={() => { setShowGenerate(false); setSelectedWO('') }}>
Cancelar
</Button>
<Button
onClick={() => generate.mutate()}
disabled={!selectedWO || generate.isPending}
>
{generate.isPending ? 'A gerar...' : 'Gerar PDF'}
</Button>
</div>
<div className="mb-4 grid gap-3 rounded-lg border border-slate-700 bg-slate-900/50 p-4 md:grid-cols-5">
<div className="space-y-1">
<label className="text-xs text-slate-400">Período</label>
<select
value={period}
onChange={(e) => setPeriod(e.target.value as PeriodKey)}
className="w-full rounded-md border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-white"
>
{PERIOD_OPTIONS.map((opt) => (
<option key={opt.key} value={opt.key}>{opt.label}</option>
))}
</select>
</div>
)}
<div className="space-y-1">
<label className="text-xs text-slate-400">Cliente</label>
<select
value={clientFilter}
onChange={(e) => setClientFilter(e.target.value)}
className="w-full rounded-md border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-white"
>
<option value="all">Todos os clientes</option>
{clients.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div className="space-y-1">
<label className="text-xs text-slate-400">Tipo de documento</label>
<select
value={docFilter}
onChange={(e) => setDocFilter(e.target.value as DocFilter)}
className="w-full rounded-md border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-white"
>
<option value="all">Todos</option>
<option value="quote">Orçamentos</option>
<option value="invoice">Faturas</option>
</select>
</div>
<div className="space-y-1">
<label className="text-xs text-slate-400">Data início</label>
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="w-full rounded-md border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-white"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-slate-400">Data fim</label>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="w-full rounded-md border border-slate-600 bg-slate-900 px-3 py-2 text-sm text-white"
/>
</div>
</div>
{viewError && (
<p className="mb-4 text-red-400 text-sm">{viewError}</p>
)}
<div className="mb-4 flex items-center gap-3 text-sm">
<Badge variant="secondary">{totalQuotes} orçamentos</Badge>
<Badge>{totalInvoices} faturas</Badge>
<span className="text-slate-400">{sorted.length} documento(s) no filtro atual</span>
</div>
{viewError && <p className="mb-4 text-sm text-red-400">{viewError}</p>}
{isLoading ? (
<p className="text-slate-400">A carregar...</p>
) : invoices.length === 0 ? (
<p className="text-slate-500 text-sm">Nenhum documento gerado.</p>
) : sorted.length === 0 ? (
<p className="text-sm text-slate-500">Nenhum documento encontrado com os filtros selecionados.</p>
) : (
<div className="grid gap-6 xl:grid-cols-2">
<div className="rounded-lg border border-slate-700 overflow-hidden">
<div className="bg-slate-800 px-4 py-3 flex items-center justify-between">
<h2 className="text-sm font-semibold text-white">Orçamentos</h2>
<Badge variant="secondary">{quoteDocs.length}</Badge>
</div>
{quoteDocs.length === 0 ? (
<p className="px-4 py-5 text-slate-500 text-sm">Sem orçamentos gerados.</p>
) : (
<table className="w-full text-sm">
<thead className="bg-slate-900/80">
<tr>
<th className="text-left px-4 py-2 text-slate-400 font-medium"></th>
<th className="text-left px-4 py-2 text-slate-400 font-medium">OT</th>
<th className="text-left px-4 py-2 text-slate-400 font-medium">Emitida</th>
<th className="px-4 py-2"></th>
<div className="overflow-hidden rounded-lg border border-slate-700">
<table className="w-full text-sm">
<thead className="bg-slate-800">
<tr>
<th className="px-4 py-2 text-left font-medium text-slate-400">Tipo</th>
<th className="px-4 py-2 text-left font-medium text-slate-400"></th>
<th className="px-4 py-2 text-left font-medium text-slate-400">OT / Cliente / Viatura</th>
<th className="px-4 py-2 text-left font-medium text-slate-400">Emitida</th>
<th className="px-4 py-2"></th>
</tr>
</thead>
<tbody>
{sorted.map((inv) => {
const wo = woById[inv.work_order_id]
const clientName = wo?.client_id ? (clientNameById[wo.client_id] ?? '—') : '—'
const vehiclePlate = wo?.vehicle_id ? (vehicleById[wo.vehicle_id]?.plate ?? '—') : '—'
return (
<tr key={inv.id} className="border-t border-slate-700 hover:bg-slate-800/50">
<td className="px-4 py-2">
{inv.type === 'quote' ? (
<span className="inline-flex rounded-full border border-amber-400 bg-amber-100 px-2 py-0.5 text-xs font-medium text-slate-800">
Orçamento
</span>
) : (
<span className="inline-flex rounded-full border border-emerald-400 bg-emerald-100 px-2 py-0.5 text-xs font-medium text-slate-800">
Fatura
</span>
)}
</td>
<td className="px-4 py-2 font-mono text-white">#{inv.number}</td>
<td className="px-4 py-2 text-xs text-slate-400">
<div className="font-mono text-slate-300">#{wo?.number ?? '—'}</div>
<div>{clientName}</div>
<div>{vehiclePlate}</div>
</td>
<td className="px-4 py-2 text-slate-400">
{new Intl.DateTimeFormat('pt-PT').format(new Date(inv.issued_at))}
</td>
<td className="px-4 py-2 text-right">
<Button size="sm" variant="outline" onClick={() => openPDF(inv)}>Ver PDF</Button>
</td>
</tr>
</thead>
<tbody>
{quoteDocs.map((inv) => (
<tr key={inv.id} className="border-t border-slate-700 hover:bg-slate-800/50">
<td className="px-4 py-2 text-white font-mono">#{inv.number}</td>
<td className="px-4 py-2 text-slate-400 font-mono text-xs">{inv.work_order_id.slice(0, 8)}</td>
<td className="px-4 py-2 text-slate-400">
{new Intl.DateTimeFormat('pt-PT').format(new Date(inv.issued_at))}
</td>
<td className="px-4 py-2 text-right">
<Button size="sm" variant="outline" onClick={() => openPDF(inv)}>Ver PDF</Button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="rounded-lg border border-slate-700 overflow-hidden">
<div className="bg-slate-800 px-4 py-3 flex items-center justify-between">
<h2 className="text-sm font-semibold text-white">Faturas</h2>
<Badge>{invoiceDocs.length}</Badge>
</div>
{invoiceDocs.length === 0 ? (
<p className="px-4 py-5 text-slate-500 text-sm">Sem faturas geradas.</p>
) : (
<table className="w-full text-sm">
<thead className="bg-slate-900/80">
<tr>
<th className="text-left px-4 py-2 text-slate-400 font-medium"></th>
<th className="text-left px-4 py-2 text-slate-400 font-medium">OT</th>
<th className="text-left px-4 py-2 text-slate-400 font-medium">Emitida</th>
<th className="px-4 py-2"></th>
</tr>
</thead>
<tbody>
{invoiceDocs.map((inv) => (
<tr key={inv.id} className="border-t border-slate-700 hover:bg-slate-800/50">
<td className="px-4 py-2 text-white font-mono">#{inv.number}</td>
<td className="px-4 py-2 text-slate-400 font-mono text-xs">{inv.work_order_id.slice(0, 8)}</td>
<td className="px-4 py-2 text-slate-400">
{new Intl.DateTimeFormat('pt-PT').format(new Date(inv.issued_at))}
</td>
<td className="px-4 py-2 text-right">
<Button size="sm" variant="outline" onClick={() => openPDF(inv)}>Ver PDF</Button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)
})}
</tbody>
</table>
</div>
)}
</div>
+571
View File
@@ -0,0 +1,571 @@
import { useMemo, useState } from 'react'
import { useQueries, useQuery } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api'
import { useTheme } from '@/hooks/useTheme'
import type { Client, Vehicle, WorkOrder, WorkOrderDetail } from '@/lib/types'
type PeriodKey = '30d' | '90d' | 'year' | 'all'
const PERIOD_OPTIONS: { key: PeriodKey; label: string }[] = [
{ key: '30d', label: '30 dias' },
{ key: '90d', label: '90 dias' },
{ key: 'year', label: '12 meses' },
{ key: 'all', label: 'Todo o histórico' },
]
function getStartDate(period: PeriodKey) {
const now = new Date()
if (period === 'all') return null
const start = new Date(now)
if (period === '30d') start.setDate(now.getDate() - 30)
if (period === '90d') start.setDate(now.getDate() - 90)
if (period === 'year') start.setMonth(now.getMonth() - 12)
return start
}
function currency(v: number) {
return new Intl.NumberFormat('pt-PT', { style: 'currency', currency: 'EUR' }).format(v)
}
function monthKey(d: Date) {
return `${d.getFullYear()}-${d.getMonth()}`
}
function csvEscape(v: string | number) {
const s = String(v ?? '')
if (s.includes(';') || s.includes('"') || s.includes('\n')) {
return `"${s.replace(/"/g, '""')}"`
}
return s
}
const PIE_COLORS = ['#0ea5e9', '#6366f1', '#14b8a6', '#f59e0b', '#f43f5e', '#22c55e']
export default function ReportsPage() {
const { theme } = useTheme('ui_theme', 'dark')
const isLight = theme === 'light'
const [period, setPeriod] = useState<PeriodKey>('90d')
const [clientFilter, setClientFilter] = useState('')
const [vehicleFilter, setVehicleFilter] = useState('')
const clientsQ = useQuery<Client[]>({
queryKey: ['clients'],
queryFn: () => apiFetch<Client[]>('/clients'),
})
const ordersQ = useQuery<WorkOrder[]>({
queryKey: ['work-orders', 'reports'],
queryFn: () => apiFetch<WorkOrder[]>('/work-orders'),
})
const vehiclesQ = useQuery<Vehicle[]>({
queryKey: ['vehicles', 'reports', clientFilter || 'all'],
queryFn: async () => {
if (!clientFilter) {
const clients = await apiFetch<Client[]>('/clients')
const all = await Promise.all(
clients.map((c) =>
apiFetch<Vehicle[]>(`/clients/${c.id}/vehicles`).catch(() => [] as Vehicle[])
)
)
return all.flat()
}
return apiFetch<Vehicle[]>(`/clients/${clientFilter}/vehicles`)
},
})
const clients = clientsQ.data ?? []
const orders = ordersQ.data ?? []
const vehicles = vehiclesQ.data ?? []
const filteredOrders = useMemo(() => {
const start = getStartDate(period)
return orders.filter((o) => {
if (start && new Date(o.created_at) < start) return false
if (clientFilter && o.client_id !== clientFilter) return false
if (vehicleFilter && o.vehicle_id !== vehicleFilter) return false
return true
})
}, [orders, period, clientFilter, vehicleFilter])
const detailsQ = useQueries({
queries: filteredOrders.map((o) => ({
queryKey: ['work-order-detail', o.id, 'reports'],
queryFn: () => apiFetch<WorkOrderDetail>(`/work-orders/${o.id}`),
staleTime: 5 * 60 * 1000,
})),
})
const isLoading = clientsQ.isLoading || ordersQ.isLoading || vehiclesQ.isLoading
const detailsLoading = detailsQ.some((q) => q.isLoading)
const detailByOrderId = useMemo(() => {
const map = new Map<string, WorkOrderDetail>()
for (const q of detailsQ) {
if (q.data) map.set(q.data.id, q.data)
}
return map
}, [detailsQ])
const byClient = useMemo(() => {
const totals = new Map<
string,
{ clientName: string; ots: number; invoiced: number; completed: number; amount: number }
>()
const orderById = new Map(filteredOrders.map((o) => [o.id, o]))
const clientNameById = new Map(clients.map((c) => [c.id, c.name]))
for (let i = 0; i < detailsQ.length; i++) {
const detail = detailsQ[i].data
if (!detail?.client_id) continue
const order = orderById.get(detail.id)
if (!order) continue
const amount =
detail.items.reduce((s, it) => s + it.total, 0) +
detail.staff_hours.reduce((s, h) => s + h.total, 0)
const row = totals.get(detail.client_id) ?? {
clientName: clientNameById.get(detail.client_id) ?? 'Cliente',
ots: 0,
invoiced: 0,
completed: 0,
amount: 0,
}
row.ots += 1
row.amount += amount
if (order.status === 'invoiced') row.invoiced += 1
if (order.status === 'completed' || order.status === 'invoiced') row.completed += 1
totals.set(detail.client_id, row)
}
return [...totals.values()]
.map((r) => ({ ...r, avgTicket: r.ots > 0 ? r.amount / r.ots : 0 }))
.sort((a, b) => b.amount - a.amount)
}, [detailsQ, filteredOrders, clients])
const byClientVehicle = useMemo(() => {
const totals = new Map<
string,
{
key: string
clientName: string
vehicleLabel: string
ots: number
invoiced: number
amount: number
}
>()
const orderById = new Map(filteredOrders.map((o) => [o.id, o]))
const clientNameById = new Map(clients.map((c) => [c.id, c.name]))
const vehicleLabelById = new Map(
vehicles.map((v) => [v.id, `${v.plate}${v.brand} ${v.model}`])
)
for (let i = 0; i < detailsQ.length; i++) {
const detail = detailsQ[i].data
if (!detail?.client_id || !detail.vehicle_id) continue
const order = orderById.get(detail.id)
if (!order) continue
const amount =
detail.items.reduce((s, it) => s + it.total, 0) +
detail.staff_hours.reduce((s, h) => s + h.total, 0)
const key = `${detail.client_id}:${detail.vehicle_id}`
const row = totals.get(key) ?? {
key,
clientName: clientNameById.get(detail.client_id) ?? 'Cliente',
vehicleLabel: vehicleLabelById.get(detail.vehicle_id) ?? 'Viatura',
ots: 0,
invoiced: 0,
amount: 0,
}
row.ots += 1
row.amount += amount
if (order.status === 'invoiced') row.invoiced += 1
totals.set(key, row)
}
return [...totals.values()].sort((a, b) => b.amount - a.amount)
}, [detailsQ, filteredOrders, clients, vehicles])
const kpis = useMemo(() => {
const total = filteredOrders.length
const inProgress = filteredOrders.filter((o) => o.status === 'in_progress').length
const completed = filteredOrders.filter((o) => o.status === 'completed').length
const invoiced = filteredOrders.filter((o) => o.status === 'invoiced').length
const amount = byClient.reduce((sum, c) => sum + c.amount, 0)
const withVehicle = filteredOrders.filter((o) => !!o.vehicle_id).length
const invoiceRate = total > 0 ? (invoiced / total) * 100 : 0
const avgTicket = total > 0 ? amount / total : 0
return { total, inProgress, completed, invoiced, amount, withVehicle, invoiceRate, avgTicket }
}, [filteredOrders, byClient])
const topClientAmount = byClient[0]?.amount ?? 1
const brandPie = useMemo(() => {
const vehicleByID = new Map(vehicles.map((v) => [v.id, v]))
const totals = new Map<string, number>()
for (const o of filteredOrders) {
if (!o.vehicle_id) continue
const v = vehicleByID.get(o.vehicle_id)
if (!v || !v.brand) continue
totals.set(v.brand, (totals.get(v.brand) ?? 0) + 1)
}
const rows = [...totals.entries()]
.map(([brand, count]) => ({ brand, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 5)
const total = rows.reduce((s, r) => s + r.count, 0) || 1
let acc = 0
const segments = rows.map((r, idx) => {
const start = (acc / total) * 360
acc += r.count
const end = (acc / total) * 360
return { ...r, color: PIE_COLORS[idx % PIE_COLORS.length], start, end, pct: (r.count / total) * 100 }
})
const css = segments.length
? `conic-gradient(${segments
.map((s) => `${s.color} ${s.start}deg ${s.end}deg`)
.join(', ')})`
: 'conic-gradient(#334155 0deg 360deg)'
return { segments, css }
}, [filteredOrders, vehicles])
const monthly = useMemo(() => {
const now = new Date()
const buckets = Array.from({ length: 6 }).map((_, i) => {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
return {
key: monthKey(d),
label: new Intl.DateTimeFormat('pt-PT', { month: 'short' }).format(d),
ots: 0,
amount: 0,
}
}).reverse()
const index = new Map(buckets.map((b, i) => [b.key, i]))
for (const o of filteredOrders) {
const key = monthKey(new Date(o.created_at))
const idx = index.get(key)
if (idx === undefined) continue
buckets[idx].ots += 1
const detail = detailByOrderId.get(o.id)
if (detail) {
buckets[idx].amount +=
detail.items.reduce((s, it) => s + it.total, 0) +
detail.staff_hours.reduce((s, h) => s + h.total, 0)
}
}
return buckets
}, [filteredOrders, detailByOrderId])
const maxMonthlyAmount = Math.max(1, ...monthly.map((m) => m.amount))
function downloadCsv() {
const lines: string[] = []
lines.push('Tipo;Cliente;Viatura;OTs;Faturadas;Concluidas;Resultado;TicketMedio')
byClient.forEach((row) => {
lines.push(
[
'Cliente',
csvEscape(row.clientName),
'',
row.ots,
row.invoiced,
row.completed,
row.amount.toFixed(2),
row.avgTicket.toFixed(2),
].join(';')
)
})
byClientVehicle.forEach((row) => {
lines.push(
[
'ClienteViatura',
csvEscape(row.clientName),
csvEscape(row.vehicleLabel),
row.ots,
row.invoiced,
'',
row.amount.toFixed(2),
'',
].join(';')
)
})
const blob = new Blob([`\uFEFF${lines.join('\n')}`], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `relatorio-operacional-${new Date().toISOString().slice(0, 10)}.csv`
a.click()
URL.revokeObjectURL(url)
}
function printPdfView() {
const rows = byClient
.slice(0, 12)
.map(
(r) =>
`<tr><td>${r.clientName}</td><td>${r.ots}</td><td>${r.invoiced}</td><td>${currency(r.avgTicket)}</td><td>${currency(r.amount)}</td></tr>`
)
.join('')
const w = window.open('', '_blank')
if (!w) return
w.document.write(`
<html><head><title>Relatório Operacional</title>
<style>
body{font-family:Arial,sans-serif;padding:24px;color:#0f172a}
h1{margin:0 0 6px 0} p{margin:0 0 16px 0;color:#475569}
.kpi{display:inline-block;margin-right:20px;margin-bottom:12px}
table{width:100%;border-collapse:collapse;margin-top:12px}
th,td{border:1px solid #cbd5e1;padding:8px;text-align:left;font-size:13px}
th{background:#f1f5f9}
</style></head><body>
<h1>Relatório Operacional de OTs</h1>
<p>Período: ${PERIOD_OPTIONS.find((p) => p.key === period)?.label ?? period}</p>
<div class="kpi"><strong>OTs:</strong> ${kpis.total}</div>
<div class="kpi"><strong>Em curso:</strong> ${kpis.inProgress}</div>
<div class="kpi"><strong>Concluídas:</strong> ${kpis.completed}</div>
<div class="kpi"><strong>Faturadas:</strong> ${kpis.invoiced}</div>
<div class="kpi"><strong>Resultado:</strong> ${currency(kpis.amount)}</div>
<h3>Top Clientes</h3>
<table><thead><tr><th>Cliente</th><th>OTs</th><th>Faturadas</th><th>Ticket Médio</th><th>Resultado</th></tr></thead><tbody>${rows}</tbody></table>
</body></html>
`)
w.document.close()
w.focus()
w.print()
}
return (
<section className="space-y-6">
<header className={`rounded-2xl border p-6 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<p className={`text-xs uppercase tracking-[0.2em] ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>
Relatórios Operacionais
</p>
<h1 className={`mt-1 text-3xl font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>
Resultados de OTs por Cliente e Viatura
</h1>
<p className={`mt-2 text-sm ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>
Visão executiva do desempenho operacional e financeiro das ordens de trabalho.
</p>
</header>
<div className={`rounded-2xl border p-4 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-[1fr_1fr_1fr_auto]">
<label className="text-sm">
<span className={`mb-1 block ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>Período</span>
<select
className={`w-full rounded-md border px-3 py-2 text-sm ${isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'}`}
value={period}
onChange={(e) => setPeriod(e.target.value as PeriodKey)}
>
{PERIOD_OPTIONS.map((o) => (
<option key={o.key} value={o.key}>{o.label}</option>
))}
</select>
</label>
<label className="text-sm">
<span className={`mb-1 block ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>Cliente</span>
<select
className={`w-full rounded-md border px-3 py-2 text-sm ${isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'}`}
value={clientFilter}
onChange={(e) => {
setClientFilter(e.target.value)
setVehicleFilter('')
}}
>
<option value="">Todos</option>
{clients.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</label>
<label className="text-sm">
<span className={`mb-1 block ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>Viatura</span>
<select
className={`w-full rounded-md border px-3 py-2 text-sm ${isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'}`}
value={vehicleFilter}
onChange={(e) => setVehicleFilter(e.target.value)}
>
<option value="">Todas</option>
{vehicles.map((v) => (
<option key={v.id} value={v.id}>{v.plate} {v.brand} {v.model}</option>
))}
</select>
</label>
<div className="flex items-end justify-start gap-2 xl:justify-end">
<button
onClick={downloadCsv}
className={`rounded-md border px-3 py-1.5 text-sm font-medium transition-colors ${
isLight
? 'border-slate-900 bg-slate-900 text-white hover:bg-black'
: 'border-slate-100 bg-slate-100 text-slate-900 hover:bg-white'
}`}
>
Exportar CSV
</button>
<button
onClick={printPdfView}
className={`rounded-md border px-3 py-1.5 text-sm font-medium transition-colors ${
isLight
? 'border-sky-700 bg-sky-700 text-white hover:bg-sky-600'
: 'border-sky-300 bg-sky-300 text-slate-900 hover:bg-sky-200'
}`}
>
Imprimir PDF
</button>
</div>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
{[
['OTs no período', String(kpis.total)],
['Em curso', String(kpis.inProgress)],
['Concluídas', String(kpis.completed)],
['Faturadas', String(kpis.invoiced)],
['Resultado total', currency(kpis.amount)],
].map(([label, value]) => (
<article key={label} className={`rounded-xl border p-4 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<p className={`text-xs uppercase tracking-wide ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>{label}</p>
<p className={`mt-1 text-2xl font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>{value}</p>
</article>
))}
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{[
['Taxa de faturação', `${kpis.invoiceRate.toFixed(1)}%`],
['Ticket médio por OT', currency(kpis.avgTicket)],
['OTs com viatura', `${kpis.withVehicle}/${kpis.total}`],
].map(([label, value]) => (
<article key={label} className={`rounded-xl border p-4 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<p className={`text-xs uppercase tracking-wide ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>{label}</p>
<p className={`mt-1 text-xl font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>{value}</p>
</article>
))}
</div>
{(isLoading || detailsLoading) && (
<p className={isLight ? 'text-slate-600 text-sm' : 'text-slate-400 text-sm'}>A calcular relatórios...</p>
)}
<div className="grid gap-4 xl:grid-cols-2">
<article className={`rounded-2xl border p-5 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<h2 className={`text-lg font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Desempenho por Cliente</h2>
{byClient.length === 0 ? (
<p className={`mt-3 text-sm ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Sem dados para este filtro.</p>
) : (
<div className="mt-4 space-y-3">
{byClient.slice(0, 7).map((row) => {
const width = (row.amount / topClientAmount) * 100
return (
<div key={row.clientName}>
<div className="mb-1 flex items-center justify-between gap-2 text-sm">
<span className={isLight ? 'text-slate-800' : 'text-slate-200'}>{row.clientName}</span>
<span className={isLight ? 'text-slate-600' : 'text-slate-400'}>
{row.ots} OTs | {currency(row.amount)}
</span>
</div>
<div className={`h-2 rounded-full ${isLight ? 'bg-slate-200' : 'bg-slate-800'}`}>
<div className="h-2 rounded-full bg-gradient-to-r from-sky-500 to-cyan-400" style={{ width: `${width}%` }} />
</div>
<p className={`mt-1 text-xs ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>
Ticket médio: {currency(row.avgTicket)} | Faturadas: {row.invoiced}
</p>
</div>
)
})}
</div>
)}
</article>
<article className={`rounded-2xl border p-5 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<h2 className={`text-lg font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Desempenho por Cliente / Viatura</h2>
{byClientVehicle.length === 0 ? (
<p className={`mt-3 text-sm ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Sem dados para este filtro.</p>
) : (
<div className="mt-4 overflow-hidden rounded-lg border border-slate-300/70 dark:border-slate-700">
<table className="w-full text-sm">
<thead className={isLight ? 'bg-slate-100' : 'bg-slate-800'}>
<tr>
<th className={`px-3 py-2 text-left ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>Cliente</th>
<th className={`px-3 py-2 text-left ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>Viatura</th>
<th className={`px-3 py-2 text-right ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>OTs</th>
<th className={`px-3 py-2 text-right ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>Faturadas</th>
<th className={`px-3 py-2 text-right ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>Resultado</th>
</tr>
</thead>
<tbody>
{byClientVehicle.slice(0, 7).map((row) => (
<tr key={row.key} className={`border-t ${isLight ? 'border-slate-200' : 'border-slate-700'}`}>
<td className={`px-3 py-2 ${isLight ? 'text-slate-800' : 'text-slate-200'}`}>{row.clientName}</td>
<td className={`px-3 py-2 ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>{row.vehicleLabel}</td>
<td className={`px-3 py-2 text-right ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>{row.ots}</td>
<td className={`px-3 py-2 text-right ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>{row.invoiced}</td>
<td className={`px-3 py-2 text-right font-medium ${isLight ? 'text-slate-900' : 'text-white'}`}>{currency(row.amount)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</article>
<article className={`rounded-2xl border p-5 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<h2 className={`text-lg font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Evolução Mensal (OTs e Resultado)</h2>
<div className="mt-4 space-y-3">
{monthly.map((m) => (
<div key={m.key}>
<div className="mb-1 flex justify-between text-xs">
<span className={isLight ? 'text-slate-700 capitalize' : 'text-slate-300 capitalize'}>{m.label}</span>
<span className={isLight ? 'text-slate-600' : 'text-slate-400'}>
{m.ots} OTs | {currency(m.amount)}
</span>
</div>
<div className={`h-2 rounded-full ${isLight ? 'bg-slate-200' : 'bg-slate-800'}`}>
<div
className="h-2 rounded-full bg-gradient-to-r from-indigo-500 to-sky-400"
style={{ width: `${(m.amount / maxMonthlyAmount) * 100}%` }}
/>
</div>
</div>
))}
</div>
</article>
<article className={`rounded-2xl border p-5 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<h2 className={`text-lg font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Principais Marcas (OTs)</h2>
<div className="mt-4 grid gap-4 md:grid-cols-[180px_1fr]">
<div className="mx-auto relative">
<div
className="h-40 w-40 rounded-full border border-slate-300/70 dark:border-slate-700 shadow-[inset_0_0_30px_rgba(0,0,0,0.08)]"
style={{ background: brandPie.css }}
/>
<div className={`absolute inset-0 m-auto h-16 w-16 rounded-full flex items-center justify-center ${isLight ? 'bg-white' : 'bg-slate-900'}`}>
<span className={`text-xs font-semibold ${isLight ? 'text-slate-700' : 'text-slate-200'}`}>
{brandPie.segments.reduce((s, x) => s + x.count, 0)}
</span>
</div>
</div>
<div className="space-y-2">
{brandPie.segments.length === 0 ? (
<p className={`text-sm ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Sem viaturas para este filtro.</p>
) : (
brandPie.segments.map((s) => (
<div key={s.brand} className="rounded-md border border-slate-200/70 dark:border-slate-700 px-3 py-2">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<span className="inline-block h-2.5 w-2.5 rounded-full" style={{ backgroundColor: s.color }} />
<span className={isLight ? 'text-slate-800' : 'text-slate-200'}>{s.brand}</span>
</div>
<span className={isLight ? 'text-slate-600' : 'text-slate-400'}>
{s.count} OTs ({s.pct.toFixed(1)}%)
</span>
</div>
<div className={`mt-2 h-1.5 rounded-full ${isLight ? 'bg-slate-200' : 'bg-slate-800'}`}>
<div className="h-1.5 rounded-full" style={{ width: `${s.pct}%`, backgroundColor: s.color }} />
</div>
</div>
))
)}
</div>
</div>
</article>
</div>
</section>
)
}
+43 -5
View File
@@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useForm } from 'react-hook-form'
import { useEffect } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { apiFetch } from '@/lib/api'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -15,6 +15,7 @@ type FormData = {
company_phone: string
company_email: string
company_logo: string
ui_theme: string
}
const defaultValues: FormData = {
@@ -25,9 +26,11 @@ const defaultValues: FormData = {
company_phone: '',
company_email: '',
company_logo: '',
ui_theme: 'dark',
}
export default function SettingsPage() {
const [previewBroken, setPreviewBroken] = useState(false)
const qc = useQueryClient()
const { data: settings, isLoading } = useQuery<TenantSettings>({
@@ -35,7 +38,18 @@ export default function SettingsPage() {
queryFn: () => apiFetch<TenantSettings>('/settings'),
})
const { register, handleSubmit, reset } = useForm<FormData>({ defaultValues })
const { register, handleSubmit, reset, watch } = useForm<FormData>({ defaultValues })
const companyLogo = watch('company_logo')
const logoPreview = (companyLogo || '').trim()
const logoLooksInvalid = useMemo(() => {
if (!logoPreview) return false
const normalized = logoPreview.toLowerCase()
return normalized.endsWith('.pn') || (!normalized.startsWith('http') && !normalized.startsWith('data:image/'))
}, [logoPreview])
useEffect(() => {
setPreviewBroken(false)
}, [logoPreview])
useEffect(() => {
if (settings) {
@@ -47,6 +61,7 @@ export default function SettingsPage() {
company_phone: settings['company_phone'] ?? '',
company_email: settings['company_email'] ?? '',
company_logo: settings['company_logo'] ?? '',
ui_theme: settings['ui_theme'] ?? 'dark',
})
}
}, [settings, reset])
@@ -112,16 +127,39 @@ export default function SettingsPage() {
<p className="text-xs text-slate-500">
Este logotipo será impresso em Orçamentos e Faturas.
</p>
{settings?.company_logo && (
{logoLooksInvalid && (
<p className="text-xs text-amber-400">
O URL do logotipo parece incompleto. Confirma se termina com extensão correta (ex.: `.png`).
</p>
)}
{logoPreview && !previewBroken && (
<div className="mt-2 rounded-md border border-slate-700 p-3 bg-slate-900/50">
<p className="text-xs text-slate-400 mb-2">Pré-visualização:</p>
<img
src={settings.company_logo}
alt="Logotipo da oficina"
src={logoPreview}
alt=""
className="h-12 object-contain bg-white/90 p-1 rounded"
onError={() => setPreviewBroken(true)}
referrerPolicy="no-referrer"
/>
</div>
)}
{logoPreview && previewBroken && (
<p className="text-xs text-amber-400">
Não foi possível carregar o logotipo. Tenta outro URL público ou usa `data:image/...`.
</p>
)}
</div>
<div className="space-y-1">
<Label htmlFor="ui_theme">Tema da interface (padrão)</Label>
<select
id="ui_theme"
{...register('ui_theme')}
className="w-full rounded-md border border-slate-600 bg-slate-900 text-white px-3 py-2 text-sm"
>
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
</div>
{save.error && (
+17 -1
View File
@@ -6,6 +6,7 @@ import { z } from 'zod'
import { apiFetch } from '@/lib/api'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { ConfirmActionDialog } from '@/components/ui/confirm-action-dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import type { Staff } from '@/lib/types'
@@ -28,6 +29,7 @@ export default function StaffPage() {
const qc = useQueryClient()
const [editing, setEditing] = useState<Staff | null>(null)
const [showForm, setShowForm] = useState(false)
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
const { data: staff = [], isLoading } = useQuery<Staff[]>({
queryKey: ['staff'],
@@ -165,7 +167,7 @@ export default function StaffPage() {
<Button
size="sm"
variant="destructive"
onClick={() => { if (confirm('Eliminar técnico?')) remove.mutate(s.id) }}
onClick={() => setPendingDeleteId(s.id)}
>
Eliminar
</Button>
@@ -176,6 +178,20 @@ export default function StaffPage() {
</table>
</div>
)}
<ConfirmActionDialog
open={!!pendingDeleteId}
onOpenChange={(open) => !open && setPendingDeleteId(null)}
title="Eliminar técnico"
description="Esta ação remove o técnico de forma permanente."
confirmLabel="Eliminar"
pending={remove.isPending}
onConfirm={() => {
if (!pendingDeleteId) return
remove.mutate(pendingDeleteId, {
onSettled: () => setPendingDeleteId(null),
})
}}
/>
</div>
)
}
@@ -0,0 +1,258 @@
import { useMemo, useState } from 'react'
import { useQueries, useQuery } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api'
import { useTheme } from '@/hooks/useTheme'
import type { Staff, WorkOrder, WorkOrderDetail } from '@/lib/types'
type PeriodKey = '30d' | '90d' | 'year' | 'all'
const PERIOD_OPTIONS: { key: PeriodKey; label: string }[] = [
{ key: '30d', label: '30 dias' },
{ key: '90d', label: '90 dias' },
{ key: 'year', label: '12 meses' },
{ key: 'all', label: 'Todo o histórico' },
]
function getStartDate(period: PeriodKey) {
const now = new Date()
if (period === 'all') return null
const start = new Date(now)
if (period === '30d') start.setDate(now.getDate() - 30)
if (period === '90d') start.setDate(now.getDate() - 90)
if (period === 'year') start.setMonth(now.getMonth() - 12)
return start
}
function currency(v: number) {
return new Intl.NumberFormat('pt-PT', { style: 'currency', currency: 'EUR' }).format(v)
}
function monthKey(d: Date) {
return `${d.getFullYear()}-${d.getMonth()}`
}
export default function TechnicianReportsPage() {
const { theme } = useTheme('ui_theme', 'dark')
const isLight = theme === 'light'
const [period, setPeriod] = useState<PeriodKey>('90d')
const [staffFilter, setStaffFilter] = useState('')
const staffQ = useQuery<Staff[]>({
queryKey: ['staff'],
queryFn: () => apiFetch<Staff[]>('/staff'),
})
const workOrdersQ = useQuery<WorkOrder[]>({
queryKey: ['work-orders', 'reports', 'technicians'],
queryFn: () => apiFetch<WorkOrder[]>('/work-orders'),
})
const staff = staffQ.data ?? []
const workOrders = workOrdersQ.data ?? []
const filteredOrders = useMemo(() => {
const start = getStartDate(period)
return workOrders.filter((wo) => !start || new Date(wo.created_at) >= start)
}, [workOrders, period])
const detailQ = useQueries({
queries: filteredOrders.map((o) => ({
queryKey: ['work-order-detail', o.id, 'tech-report'],
queryFn: () => apiFetch<WorkOrderDetail>(`/work-orders/${o.id}`),
staleTime: 5 * 60 * 1000,
})),
})
const isLoading = staffQ.isLoading || workOrdersQ.isLoading || detailQ.some((q) => q.isLoading)
const orderByID = useMemo(() => new Map(filteredOrders.map((wo) => [wo.id, wo])), [filteredOrders])
const staffNameByID = useMemo(() => new Map(staff.map((s) => [s.id, s.name])), [staff])
const byTechnician = useMemo(() => {
const map = new Map<
string,
{ id: string; name: string; hours: number; total: number; ots: Set<string>; invoiced: number }
>()
for (const q of detailQ) {
const d = q.data
if (!d) continue
const wo = orderByID.get(d.id)
if (!wo) continue
for (const sh of d.staff_hours) {
if (staffFilter && sh.staff_id !== staffFilter) continue
const row = map.get(sh.staff_id) ?? {
id: sh.staff_id,
name: staffNameByID.get(sh.staff_id) ?? 'Técnico',
hours: 0,
total: 0,
ots: new Set<string>(),
invoiced: 0,
}
row.hours += sh.hours
row.total += sh.total
if (!row.ots.has(d.id) && wo.status === 'invoiced') row.invoiced += 1
row.ots.add(d.id)
map.set(sh.staff_id, row)
}
}
return [...map.values()]
.map((r) => ({ ...r, otsCount: r.ots.size, avgHour: r.hours > 0 ? r.total / r.hours : 0 }))
.sort((a, b) => b.total - a.total)
}, [detailQ, orderByID, staffNameByID, staffFilter])
const kpis = useMemo(() => {
const totalHours = byTechnician.reduce((s, r) => s + r.hours, 0)
const totalCost = byTechnician.reduce((s, r) => s + r.total, 0)
const otsCount = byTechnician.reduce((s, r) => s + r.otsCount, 0)
const avgHourCost = totalHours > 0 ? totalCost / totalHours : 0
return {
activeTech: byTechnician.length,
totalHours,
totalCost,
otsCount,
avgHourCost,
}
}, [byTechnician])
const monthly = useMemo(() => {
const now = new Date()
const buckets = Array.from({ length: 6 }).map((_, i) => {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
return {
key: monthKey(d),
label: new Intl.DateTimeFormat('pt-PT', { month: 'short' }).format(d),
hours: 0,
total: 0,
}
}).reverse()
const index = new Map(buckets.map((b, i) => [b.key, i]))
for (const q of detailQ) {
const d = q.data
if (!d) continue
const wo = orderByID.get(d.id)
if (!wo) continue
const idx = index.get(monthKey(new Date(wo.created_at)))
if (idx === undefined) continue
for (const sh of d.staff_hours) {
if (staffFilter && sh.staff_id !== staffFilter) continue
buckets[idx].hours += sh.hours
buckets[idx].total += sh.total
}
}
return buckets
}, [detailQ, orderByID, staffFilter])
const maxMonthly = Math.max(1, ...monthly.map((m) => m.total))
const topValue = byTechnician[0]?.total ?? 1
return (
<section className="space-y-6">
<header className={`rounded-2xl border p-6 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<p className={`text-xs uppercase tracking-[0.2em] ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>
Relatórios Operacionais
</p>
<h1 className={`mt-1 text-3xl font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>
Desempenho por Técnicos
</h1>
<p className={`mt-2 text-sm ${isLight ? 'text-slate-600' : 'text-slate-300'}`}>
Visão de horas, custo de mão de obra e produtividade por técnico no período.
</p>
</header>
<div className={`rounded-2xl border p-4 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<div className="grid gap-3 md:grid-cols-2">
<label className="text-sm">
<span className={`mb-1 block ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>Período</span>
<select
className={`w-full rounded-md border px-3 py-2 text-sm ${isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'}`}
value={period}
onChange={(e) => setPeriod(e.target.value as PeriodKey)}
>
{PERIOD_OPTIONS.map((o) => (
<option key={o.key} value={o.key}>{o.label}</option>
))}
</select>
</label>
<label className="text-sm">
<span className={`mb-1 block ${isLight ? 'text-slate-700' : 'text-slate-300'}`}>Técnico</span>
<select
className={`w-full rounded-md border px-3 py-2 text-sm ${isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'}`}
value={staffFilter}
onChange={(e) => setStaffFilter(e.target.value)}
>
<option value="">Todos</option>
{staff.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</label>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
{[
['Técnicos no período', String(kpis.activeTech)],
['OTs cobertas', String(kpis.otsCount)],
['Horas totais', kpis.totalHours.toFixed(2)],
['Custo mão de obra', currency(kpis.totalCost)],
['Média €/hora', currency(kpis.avgHourCost)],
].map(([label, value]) => (
<article key={label} className={`rounded-xl border p-4 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<p className={`text-xs uppercase tracking-wide ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>{label}</p>
<p className={`mt-1 text-2xl font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>{value}</p>
</article>
))}
</div>
{isLoading && (
<p className={isLight ? 'text-slate-600 text-sm' : 'text-slate-400 text-sm'}>A calcular relatório de técnicos...</p>
)}
<div className="grid gap-4 xl:grid-cols-2">
<article className={`rounded-2xl border p-5 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<h2 className={`text-lg font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Ranking de Técnicos</h2>
{byTechnician.length === 0 ? (
<p className={`mt-3 text-sm ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Sem dados para este filtro.</p>
) : (
<div className="mt-4 space-y-3">
{byTechnician.slice(0, 7).map((row) => (
<div key={row.id}>
<div className="mb-1 flex items-center justify-between gap-2 text-sm">
<span className={isLight ? 'text-slate-800' : 'text-slate-200'}>{row.name}</span>
<span className={isLight ? 'text-slate-600' : 'text-slate-400'}>
{row.hours.toFixed(2)}h | {currency(row.total)}
</span>
</div>
<div className={`h-2 rounded-full ${isLight ? 'bg-slate-200' : 'bg-slate-800'}`}>
<div className="h-2 rounded-full bg-gradient-to-r from-sky-500 to-cyan-400" style={{ width: `${(row.total / topValue) * 100}%` }} />
</div>
<p className={`mt-1 text-xs ${isLight ? 'text-slate-500' : 'text-slate-400'}`}>
OTs: {row.otsCount} | Faturadas: {row.invoiced} | Média: {currency(row.avgHour)}/h
</p>
</div>
))}
</div>
)}
</article>
<article className={`rounded-2xl border p-5 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/70'}`}>
<h2 className={`text-lg font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Evolução Mensal (Horas e Custo)</h2>
<div className="mt-4 space-y-3">
{monthly.map((m) => (
<div key={m.key}>
<div className="mb-1 flex justify-between text-xs">
<span className={isLight ? 'text-slate-700 capitalize' : 'text-slate-300 capitalize'}>{m.label}</span>
<span className={isLight ? 'text-slate-600' : 'text-slate-400'}>
{m.hours.toFixed(2)}h | {currency(m.total)}
</span>
</div>
<div className={`h-2 rounded-full ${isLight ? 'bg-slate-200' : 'bg-slate-800'}`}>
<div
className="h-2 rounded-full bg-gradient-to-r from-indigo-500 to-sky-400"
style={{ width: `${(m.total / maxMonthly) * 100}%` }}
/>
</div>
</div>
))}
</div>
</article>
</div>
</section>
)
}
+412 -37
View File
@@ -1,15 +1,18 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useParams, Link } from 'react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useForm, type Resolver } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { apiFetch } from '@/lib/api'
import { apiFetch, apiFetchBlob } from '@/lib/api'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { ConfirmActionDialog } from '@/components/ui/confirm-action-dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import type { WorkOrderDetail, CatalogItem, Staff } from '@/lib/types'
import { useTheme } from '@/hooks/useTheme'
import { WORK_ORDER_STATUS_LABEL, workOrderStatusBadgeClass } from '@/lib/workOrderStatus'
import type { WorkOrderDetail, CatalogItem, Staff, Client, Vehicle } from '@/lib/types'
const CATEGORY_LABEL: Record<string, string> = {
mao_de_obra: 'Mão de Obra',
@@ -26,15 +29,6 @@ const CATEGORY_LABEL: Record<string, string> = {
outro: 'Outro',
}
const STATUS_LABELS: Record<string, string> = {
quote: 'Orçamento',
open: 'Aberta',
in_progress: 'Em Curso',
completed: 'Concluída',
invoiced: 'Faturada',
cancelled: 'Cancelada',
}
const TRANSITIONS: Record<string, string[]> = {
quote: ['open', 'cancelled'],
open: ['in_progress', 'cancelled'],
@@ -43,18 +37,61 @@ const TRANSITIONS: Record<string, string[]> = {
invoiced: [],
cancelled: [],
}
const ETA_LABEL: Record<number, string> = {
1: '1 dia',
2: '2 dias',
3: '3 dias',
7: '7 dias',
15: '15 dias',
30: '30 dias',
31: '+ de 30 dias',
}
const ETA_OPTIONS = [1, 2, 3, 7, 15, 30, 31]
const PAYMENT_METHOD_OPTIONS = [
{ value: 'numerario', label: 'Numerário' },
{ value: 'multibanco', label: 'Multibanco' },
{ value: 'transferencia_bancaria', label: 'Transferência Bancária' },
{ value: 'mb_way', label: 'MB WAY' },
{ value: 'cartao_debito', label: 'Cartão de Débito' },
{ value: 'cartao_credito', label: 'Cartão de Crédito' },
{ value: 'cheque', label: 'Cheque' },
{ value: 'outro', label: 'Outro' },
]
const PAYMENT_METHOD_LABEL: Record<string, string> = Object.fromEntries(PAYMENT_METHOD_OPTIONS.map((o) => [o.value, o.label]))
function formatDateSafe(value?: string | null, withTime = false): string {
if (!value) return '—'
const d = new Date(value)
if (Number.isNaN(d.getTime())) return '—'
return withTime
? new Intl.DateTimeFormat('pt-PT', { dateStyle: 'short', timeStyle: 'short' }).format(d)
: new Intl.DateTimeFormat('pt-PT').format(d)
}
const metaSchema = z.object({
client_id: z.string(),
vehicle_id: z.string(),
internal_notes: z.string(),
client_notes: z.string(),
eta_days: z.coerce.number().int(),
real_deadline: z.string(),
payment_method: z.string(),
payment_date: z.string(),
})
type MetaForm = z.infer<typeof metaSchema>
// ─── Item form ────────────────────────────────────────────────────────────────
const itemSchema = z.object({
catalog_item_id: z.string(),
description: z.string().min(1, 'Descrição obrigatória'),
change_justification: z.string(),
qty: z.coerce.number().positive('Quantidade deve ser positiva'),
unit_price: z.coerce.number().min(0),
discount_pct: z.coerce.number().min(0).max(100),
})
type ItemForm = z.infer<typeof itemSchema>
const emptyItem: ItemForm = { catalog_item_id: '', description: '', qty: 1, unit_price: 0, discount_pct: 0 }
const emptyItem: ItemForm = { catalog_item_id: '', description: '', change_justification: '', qty: 1, unit_price: 0, discount_pct: 0 }
// ─── Staff hours form ─────────────────────────────────────────────────────────
@@ -70,11 +107,15 @@ const emptySH: SHForm = { staff_id: '', hours: 1, cost_per_hour: 0 }
export default function WorkOrderDetailPage() {
const { id } = useParams<{ id: string }>()
const { theme } = useTheme('ui_theme', 'dark')
const isLight = theme === 'light'
const qc = useQueryClient()
const [showAddItem, setShowAddItem] = useState(false)
const [showAddStaff, setShowAddStaff] = useState(false)
const [showMetaEdit, setShowMetaEdit] = useState(false)
const [confirmAction, setConfirmAction] = useState<null | { type: 'cancel' | 'remove_item' | 'remove_staff'; id?: string }>(null)
const { data: detail, isLoading } = useQuery<WorkOrderDetail>({
const { data: detail, isLoading, error: detailError } = useQuery<WorkOrderDetail>({
queryKey: ['work-order', id],
queryFn: () => apiFetch<WorkOrderDetail>(`/work-orders/${id}`),
})
@@ -88,6 +129,39 @@ export default function WorkOrderDetailPage() {
queryKey: ['staff'],
queryFn: () => apiFetch<Staff[]>('/staff'),
})
const { data: invoices = [] } = useQuery<{ id: string; work_order_id: string; type: 'quote' | 'invoice'; issued_at: string }[]>({
queryKey: ['invoices', 'work-order-detail'],
queryFn: () => apiFetch('/invoices'),
})
const { data: clients = [] } = useQuery<Client[]>({
queryKey: ['clients'],
queryFn: () => apiFetch<Client[]>('/clients'),
})
const {
register: regMeta,
handleSubmit: handleMeta,
watch: watchMeta,
reset: resetMeta,
formState: { errors: metaErrors },
} = useForm<MetaForm>({
resolver: zodResolver(metaSchema) as Resolver<MetaForm>,
defaultValues: {
client_id: '',
vehicle_id: '',
internal_notes: '',
client_notes: '',
eta_days: 1,
real_deadline: '',
payment_method: '',
payment_date: '',
},
})
const selectedMetaClient = watchMeta('client_id')
const { data: vehicles = [] } = useQuery<Vehicle[]>({
queryKey: ['vehicles', selectedMetaClient || detail?.client_id || '', 'wo-meta'],
queryFn: () => apiFetch<Vehicle[]>(`/clients/${selectedMetaClient || detail?.client_id}/vehicles`),
enabled: !!(selectedMetaClient || detail?.client_id),
})
// lookup maps
const catalogMap = Object.fromEntries(catalogItems.map(c => [c.id, c]))
@@ -96,9 +170,44 @@ export default function WorkOrderDetailPage() {
// ── Transitions ──────────────────────────────────────────────────────────────
const transition = useMutation({
mutationFn: (status: string) =>
apiFetch(`/work-orders/${id}/transition`, { method: 'POST', body: JSON.stringify({ status }) }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['work-order', id] }),
mutationFn: (status: string) => {
if (status === 'invoiced') {
return apiFetch('/invoices', {
method: 'POST',
body: JSON.stringify({ work_order_id: id, type: 'invoice' }),
})
}
return apiFetch(`/work-orders/${id}/transition`, {
method: 'POST',
body: JSON.stringify({
status,
payment_method: '',
payment_date: '',
}),
})
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['work-order', id] })
qc.invalidateQueries({ queryKey: ['work-orders'] })
qc.invalidateQueries({ queryKey: ['invoices'] })
},
})
const updateMeta = useMutation({
mutationFn: (data: MetaForm) =>
apiFetch(`/work-orders/${id}`, {
method: 'PUT',
body: JSON.stringify({
...data,
real_deadline: detail?.status === 'quote' ? '' : data.real_deadline,
payment_method: data.payment_method,
payment_date: data.payment_date,
}),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['work-order', id] })
qc.invalidateQueries({ queryKey: ['work-orders'] })
setShowMetaEdit(false)
},
})
// ── Items ─────────────────────────────────────────────────────────────────────
@@ -180,33 +289,236 @@ export default function WorkOrderDetailPage() {
onSuccess: () => qc.invalidateQueries({ queryKey: ['work-order', id] }),
})
useEffect(() => {
if (!detail || showMetaEdit) return
resetMeta({
client_id: detail.client_id ?? '',
vehicle_id: detail.vehicle_id ?? '',
internal_notes: detail.internal_notes ?? '',
client_notes: detail.client_notes ?? '',
eta_days: detail.eta_days ?? 1,
real_deadline: detail.real_deadline ? detail.real_deadline.split('T')[0] : '',
payment_method: detail.payment_method ?? '',
payment_date: detail.payment_date ? detail.payment_date.split('T')[0] : '',
})
}, [detail, showMetaEdit, resetMeta])
// ── Render ────────────────────────────────────────────────────────────────────
if (isLoading) return <p className="text-slate-400">A carregar...</p>
if (detailError) return <p className="text-red-400">Erro ao abrir a ordem: {(detailError as Error).message}</p>
if (!detail) return <p className="text-slate-400">Ordem não encontrada.</p>
const itemsTotal = detail.items.reduce((s, i) => s + i.total, 0)
const hoursTotal = detail.staff_hours.reduce((s, h) => s + h.total, 0)
const items = Array.isArray(detail.items) ? detail.items : []
const staffHours = Array.isArray(detail.staff_hours) ? detail.staff_hours : []
const itemsTotal = items.reduce((s, i) => s + Number(i.total || 0), 0)
const hoursTotal = staffHours.reduce((s, h) => s + Number(h.total || 0), 0)
const nextStates = TRANSITIONS[detail.status] ?? []
const editable = detail.status !== 'invoiced' && detail.status !== 'cancelled'
const clientName = detail.client_id ? (clients.find((c) => c.id === detail.client_id)?.name ?? '—') : '—'
const woInvoices = invoices
.filter((inv) => inv.work_order_id === detail.id)
.sort((a, b) => new Date(b.issued_at).getTime() - new Date(a.issued_at).getTime())
const preferredType = detail.status === 'invoiced' ? 'invoice' : 'quote'
const selectedDoc = woInvoices.find((inv) => inv.type === preferredType) ?? woInvoices[0]
async function openWOInvoicePDF() {
if (!selectedDoc) return
try {
const blob = await apiFetchBlob(`/invoices/${selectedDoc.id}/pdf`)
const url = URL.createObjectURL(blob)
window.open(url, '_blank', 'noopener,noreferrer')
setTimeout(() => URL.revokeObjectURL(url), 60_000)
} catch {
// transition/update errors already shown elsewhere
}
}
const vehicleName = detail.vehicle_id
? (() => {
const v = vehicles.find((it) => it.id === detail.vehicle_id)
return v ? `${v.plate}${v.brand} ${v.model}` : '—'
})()
: '—'
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<Link to="/app/work-orders" className="text-slate-400 hover:text-white text-sm">
<Link
to="/app/work-orders"
className={`text-sm ${isLight ? 'text-slate-600 hover:text-slate-900' : 'text-slate-400 hover:text-white'}`}
>
Ordens
</Link>
<span className="text-slate-600">/</span>
<h1 className="text-2xl font-bold text-white">Ordem #{detail.number}</h1>
<Badge variant={detail.status === 'cancelled' ? 'destructive' : detail.status === 'completed' || detail.status === 'invoiced' ? 'secondary' : 'default'}>
{STATUS_LABELS[detail.status]}
<span className={isLight ? 'text-slate-400' : 'text-slate-600'}>/</span>
<h1 className={`text-2xl font-bold ${isLight ? 'text-slate-900' : 'text-white'}`}>Ordem #{detail.number}</h1>
<Badge variant="outline" className={workOrderStatusBadgeClass(detail.status)}>
{WORK_ORDER_STATUS_LABEL[detail.status]}
</Badge>
{selectedDoc && (
<Button size="sm" variant="outline" onClick={openWOInvoicePDF}>
Ver PDF
</Button>
)}
</div>
<section className={`rounded-lg border p-4 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-900/50'}`}>
<div className="grid gap-4 lg:grid-cols-3">
<div className={`lg:col-span-2 rounded-md border p-3 ${isLight ? 'border-slate-300 bg-slate-50' : 'border-slate-800 bg-slate-950/40'}`}>
<h2 className={`mb-3 text-base font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Dados da OT</h2>
<div className="grid gap-3 text-sm grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
<div>
<p className={isLight ? 'text-slate-500' : 'text-slate-500'}>Cliente</p>
<p className={isLight ? 'text-slate-800' : 'text-slate-200'}>{clientName}</p>
</div>
<div>
<p className={isLight ? 'text-slate-500' : 'text-slate-500'}>Viatura</p>
<p className={isLight ? 'text-slate-800' : 'text-slate-200'}>{vehicleName}</p>
</div>
<div>
<p className={isLight ? 'text-slate-500' : 'text-slate-500'}>Criada em</p>
<p className={isLight ? 'text-slate-800' : 'text-slate-200'}>{formatDateSafe(detail.created_at, true)}</p>
</div>
<div>
<p className={isLight ? 'text-slate-500' : 'text-slate-500'}>Previsão de conclusão</p>
<p className={isLight ? 'text-slate-800' : 'text-slate-200'}>{ETA_LABEL[detail.eta_days] ?? `${detail.eta_days} dias`}</p>
</div>
{detail.status !== 'quote' && (
<div>
<p className={isLight ? 'text-slate-500' : 'text-slate-500'}>Prazo real (deadline)</p>
<p className={isLight ? 'text-slate-800' : 'text-slate-200'}>{formatDateSafe(detail.real_deadline)}</p>
</div>
)}
{(detail.status === 'completed' || detail.status === 'invoiced') && (
<div>
<p className={isLight ? 'text-slate-500' : 'text-slate-500'}>Meio de pagamento</p>
<p className={isLight ? 'text-slate-800' : 'text-slate-200'}>{PAYMENT_METHOD_LABEL[detail.payment_method] ?? '—'}</p>
</div>
)}
{(detail.status === 'completed' || detail.status === 'invoiced') && (
<div>
<p className={isLight ? 'text-slate-500' : 'text-slate-500'}>Data de pagamento</p>
<p className={isLight ? 'text-slate-800' : 'text-slate-200'}>{formatDateSafe(detail.payment_date)}</p>
</div>
)}
<div>
<p className={isLight ? 'text-slate-500' : 'text-slate-500'}>Observações</p>
<p className={isLight ? 'text-slate-800' : 'text-slate-200'}>{detail.client_notes || '—'}</p>
</div>
<div>
<p className={isLight ? 'text-slate-500' : 'text-slate-500'}>Notas internas</p>
<p className={isLight ? 'text-slate-800' : 'text-slate-200'}>{detail.internal_notes || '—'}</p>
</div>
</div>
</div>
<div className={`rounded-md border p-3 ${isLight ? 'border-slate-300 bg-slate-50' : 'border-slate-800 bg-slate-950/40'}`}>
<div className="mb-3 flex items-center justify-between">
<h3 className={`text-base font-semibold ${isLight ? 'text-slate-900' : 'text-white'}`}>Editar Dados da OT</h3>
{editable && !showMetaEdit && (
<Button size="sm" onClick={() => setShowMetaEdit(true)}>Editar</Button>
)}
</div>
{!showMetaEdit && editable && (
<p className={`text-sm ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>Clique em editar para atualizar cliente, viatura, prazos e observações.</p>
)}
{!editable && (
<p className={`text-sm ${isLight ? 'text-slate-600' : 'text-slate-400'}`}>OT faturada/cancelada não permite edição.</p>
)}
{editable && showMetaEdit && (
<form onSubmit={handleMeta((d) => updateMeta.mutate(d))} className="space-y-3">
<div className="space-y-1">
<Label>Cliente</Label>
<select
{...regMeta('client_id')}
className={`w-full rounded-md border px-3 py-2 text-sm ${isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'}`}
>
<option value=""> Sem cliente </option>
{clients.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="space-y-1">
<Label>Viatura</Label>
<select
{...regMeta('vehicle_id')}
className={`w-full rounded-md border px-3 py-2 text-sm ${isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'}`}
>
<option value=""> Sem viatura </option>
{vehicles.map((v) => <option key={v.id} value={v.id}>{v.plate} {v.brand} {v.model}</option>)}
</select>
</div>
<div className="space-y-1">
<Label>Previsão de conclusão</Label>
<select
{...regMeta('eta_days')}
className={`w-full rounded-md border px-3 py-2 text-sm ${isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'}`}
>
{ETA_OPTIONS.map((o) => <option key={o} value={o}>{ETA_LABEL[o]}</option>)}
</select>
</div>
{detail.status !== 'quote' && (
<div className="space-y-1">
<Label>Prazo real (deadline)</Label>
<Input
type="date"
{...regMeta('real_deadline')}
className={isLight ? 'bg-white border-slate-300 text-slate-900' : 'bg-slate-900 border-slate-600 text-white'}
/>
</div>
)}
{(detail.status === 'completed' || detail.status === 'invoiced') && (
<div className="space-y-1">
<Label>Meio de pagamento</Label>
<select
{...regMeta('payment_method')}
className={`w-full rounded-md border px-3 py-2 text-sm ${isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'}`}
>
<option value=""> Selecionar </option>
{PAYMENT_METHOD_OPTIONS.map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
</select>
</div>
)}
{(detail.status === 'completed' || detail.status === 'invoiced') && (
<div className="space-y-1">
<Label>Data de pagamento</Label>
<Input
type="date"
{...regMeta('payment_date')}
className={isLight ? 'bg-white border-slate-300 text-slate-900' : 'bg-slate-900 border-slate-600 text-white'}
/>
</div>
)}
<div className="space-y-1">
<Label>Observações</Label>
<Input
{...regMeta('client_notes')}
className={isLight ? 'bg-white border-slate-300 text-slate-900' : 'bg-slate-900 border-slate-600 text-white'}
/>
</div>
<div className="space-y-1">
<Label>Notas internas</Label>
<Input
{...regMeta('internal_notes')}
className={isLight ? 'bg-white border-slate-300 text-slate-900' : 'bg-slate-900 border-slate-600 text-white'}
/>
</div>
{metaErrors.eta_days && <p className="text-red-400 text-sm">{metaErrors.eta_days.message}</p>}
{updateMeta.error && <p className="text-red-400 text-sm">{(updateMeta.error as Error).message}</p>}
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setShowMetaEdit(false)}>Cancelar</Button>
<Button type="submit" disabled={updateMeta.isPending}>
{updateMeta.isPending ? 'A guardar...' : 'Guardar alterações'}
</Button>
</div>
</form>
)}
</div>
</div>
</section>
{/* Transitions */}
{nextStates.length > 0 && (
<div className="flex gap-2">
<div className="space-y-2">
<div className="flex gap-2">
<span className="text-slate-400 text-sm self-center">Transição:</span>
{nextStates.map((s) => (
<Button
@@ -214,14 +526,21 @@ export default function WorkOrderDetailPage() {
size="sm"
variant={s === 'cancelled' ? 'destructive' : 'default'}
onClick={() => {
if (s === 'cancelled' && !confirm('Cancelar esta ordem?')) return
if (s === 'cancelled') {
setConfirmAction({ type: 'cancel' })
return
}
transition.mutate(s)
}}
disabled={transition.isPending}
>
{STATUS_LABELS[s]}
{WORK_ORDER_STATUS_LABEL[s as keyof typeof WORK_ORDER_STATUS_LABEL]}
</Button>
))}
</div>
{transition.error && (
<p className="text-sm text-red-400">{(transition.error as Error).message}</p>
)}
</div>
)}
@@ -259,6 +578,17 @@ export default function WorkOrderDetailPage() {
<Input {...regItem('description')} className="bg-slate-900 border-slate-600 text-white" />
{itemErrors.description && <p className="text-red-400 text-xs">{itemErrors.description.message}</p>}
</div>
{detail.status === 'open' && (
<div className="col-span-3 space-y-1">
<Label>Justificação da alteração *</Label>
<Input
{...regItem('change_justification')}
placeholder="Ex: Pedido do cliente para incluir peça adicional"
className="bg-slate-900 border-slate-600 text-white"
/>
<p className="text-xs text-slate-500">Obrigatório em Orçamento Aprovado para garantir transparência.</p>
</div>
)}
<div className="space-y-1">
<Label>Qtd. *</Label>
<Input type="number" step="0.001" {...regItem('qty')} className="bg-slate-900 border-slate-600 text-white" />
@@ -283,7 +613,7 @@ export default function WorkOrderDetailPage() {
</div>
)}
{detail.items.length === 0 ? (
{items.length === 0 ? (
<p className="text-slate-500 text-sm">Sem itens.</p>
) : (
<div className="rounded-lg border border-slate-700 overflow-hidden">
@@ -299,7 +629,7 @@ export default function WorkOrderDetailPage() {
</tr>
</thead>
<tbody>
{detail.items.map((item) => {
{items.map((item) => {
const cat = item.catalog_item_id ? catalogMap[item.catalog_item_id] : null
return (
<tr key={item.id} className="border-t border-slate-700">
@@ -310,18 +640,23 @@ export default function WorkOrderDetailPage() {
{CATEGORY_LABEL[cat.category] ?? cat.category}
</div>
)}
{item.change_justification && (
<div className="text-xs text-amber-300/90 mt-1">
Justificação: {item.change_justification}
</div>
)}
</td>
<td className="px-4 py-2 text-slate-300 text-right">{item.qty}</td>
<td className="px-4 py-2 text-slate-300 text-right">{item.unit_price.toFixed(2)} </td>
<td className="px-4 py-2 text-slate-300 text-right">{Number(item.unit_price || 0).toFixed(2)} </td>
<td className="px-4 py-2 text-slate-300 text-right">{item.discount_pct}%</td>
<td className="px-4 py-2 text-white font-medium text-right">{item.total.toFixed(2)} </td>
<td className="px-4 py-2 text-white font-medium text-right">{Number(item.total || 0).toFixed(2)} </td>
<td className="px-4 py-2 text-right">
{editable && (
<Button
size="sm"
variant="ghost"
className="text-red-400 hover:text-red-300 h-7 px-2"
onClick={() => removeItem.mutate(item.id)}
onClick={() => setConfirmAction({ type: 'remove_item', id: item.id })}
disabled={removeItem.isPending}
>
@@ -411,7 +746,7 @@ export default function WorkOrderDetailPage() {
</div>
)}
{detail.staff_hours.length === 0 ? (
{staffHours.length === 0 ? (
<p className="text-slate-500 text-sm">Sem técnicos registados nesta ordem.</p>
) : (
<div className="rounded-lg border border-slate-700 overflow-hidden">
@@ -426,7 +761,7 @@ export default function WorkOrderDetailPage() {
</tr>
</thead>
<tbody>
{detail.staff_hours.map((sh) => {
{staffHours.map((sh) => {
const staff = staffMap[sh.staff_id]
return (
<tr key={sh.id} className="border-t border-slate-700">
@@ -439,15 +774,15 @@ export default function WorkOrderDetailPage() {
)}
</td>
<td className="px-4 py-2 text-white text-right">{sh.hours}</td>
<td className="px-4 py-2 text-slate-300 text-right">{sh.cost_per_hour.toFixed(2)} </td>
<td className="px-4 py-2 text-white font-medium text-right">{sh.total.toFixed(2)} </td>
<td className="px-4 py-2 text-slate-300 text-right">{Number(sh.cost_per_hour || 0).toFixed(2)} </td>
<td className="px-4 py-2 text-white font-medium text-right">{Number(sh.total || 0).toFixed(2)} </td>
<td className="px-4 py-2 text-right">
{editable && (
<Button
size="sm"
variant="ghost"
className="text-red-400 hover:text-red-300 h-7 px-2"
onClick={() => removeSH.mutate(sh.id)}
onClick={() => setConfirmAction({ type: 'remove_staff', id: sh.id })}
disabled={removeSH.isPending}
>
@@ -462,6 +797,46 @@ export default function WorkOrderDetailPage() {
</div>
)}
</section>
<ConfirmActionDialog
open={!!confirmAction}
onOpenChange={(open) => !open && setConfirmAction(null)}
title={
confirmAction?.type === 'cancel'
? 'Cancelar ordem de trabalho'
: confirmAction?.type === 'remove_item'
? 'Eliminar item'
: 'Eliminar técnico'
}
description={
confirmAction?.type === 'cancel'
? 'A ordem será marcada como cancelada e deixará de ser editável.'
: confirmAction?.type === 'remove_item'
? 'Este item será removido da OT.'
: 'Este registo de horas será removido da OT.'
}
confirmLabel={confirmAction?.type === 'cancel' ? 'Cancelar OT' : 'Eliminar'}
pending={transition.isPending || removeItem.isPending || removeSH.isPending}
onConfirm={() => {
if (!confirmAction) return
if (confirmAction.type === 'cancel') {
transition.mutate('cancelled', {
onSettled: () => setConfirmAction(null),
})
return
}
if (confirmAction.type === 'remove_item' && confirmAction.id) {
removeItem.mutate(confirmAction.id, {
onSettled: () => setConfirmAction(null),
})
return
}
if (confirmAction.type === 'remove_staff' && confirmAction.id) {
removeSH.mutate(confirmAction.id, {
onSettled: () => setConfirmAction(null),
})
}
}}
/>
</div>
)
}
+107 -45
View File
@@ -1,47 +1,46 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router'
import { useForm } from 'react-hook-form'
import { useForm, type Resolver } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { apiFetch } from '@/lib/api'
import { useTheme } from '@/hooks/useTheme'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { WORK_ORDER_STATUS_LABEL, workOrderStatusBadgeClass } from '@/lib/workOrderStatus'
import type { WorkOrder, Client, Vehicle } from '@/lib/types'
const STATUS_LABELS: Record<string, string> = {
quote: 'Orçamento',
open: 'Aberta',
in_progress: 'Em Curso',
completed: 'Concluída',
invoiced: 'Faturada',
cancelled: 'Cancelada',
}
const STATUS_VARIANT: Record<string, 'default' | 'secondary' | 'destructive'> = {
quote: 'secondary',
open: 'secondary',
in_progress: 'default',
completed: 'default',
invoiced: 'secondary',
cancelled: 'destructive',
}
const schema = z.object({
client_id: z.string(),
vehicle_id: z.string(),
internal_notes: z.string(),
client_notes: z.string(),
eta_days: z.coerce.number().int(),
})
type FormData = z.infer<typeof schema>
const emptyOrder: FormData = { client_id: '', vehicle_id: '', internal_notes: '' }
const emptyOrder: FormData = { client_id: '', vehicle_id: '', internal_notes: '', client_notes: '', eta_days: 1 }
const ETA_OPTIONS = [
{ value: 1, label: '1 dia' },
{ value: 2, label: '2 dias' },
{ value: 3, label: '3 dias' },
{ value: 7, label: '7 dias' },
{ value: 15, label: '15 dias' },
{ value: 30, label: '30 dias' },
{ value: 31, label: '+ de 30 dias' },
]
const ETA_LABEL: Record<number, string> = Object.fromEntries(ETA_OPTIONS.map((o) => [o.value, o.label]))
export default function WorkOrdersPage() {
const { theme } = useTheme('ui_theme', 'dark')
const isLight = theme === 'light'
const qc = useQueryClient()
const [statusFilter, setStatusFilter] = useState('')
const [showForm, setShowForm] = useState(false)
const [docError, setDocError] = useState('')
const { data: orders = [], isLoading } = useQuery<WorkOrder[]>({
queryKey: ['work-orders', statusFilter],
@@ -52,9 +51,10 @@ export default function WorkOrdersPage() {
queryKey: ['clients'],
queryFn: () => apiFetch<Client[]>('/clients'),
})
const clientNameById = Object.fromEntries(clients.map((c) => [c.id, c.name]))
const { register, handleSubmit, watch, reset } = useForm<FormData>({
resolver: zodResolver(schema),
resolver: zodResolver(schema) as Resolver<FormData>,
defaultValues: emptyOrder,
})
@@ -69,8 +69,18 @@ export default function WorkOrdersPage() {
const create = useMutation({
mutationFn: (data: FormData) =>
apiFetch<WorkOrder>('/work-orders', { method: 'POST', body: JSON.stringify(data) }),
onSuccess: () => {
onSuccess: async (wo) => {
setDocError('')
try {
await apiFetch('/invoices', {
method: 'POST',
body: JSON.stringify({ work_order_id: wo.id, type: 'quote' }),
})
} catch (err) {
setDocError(`OT criada, mas falhou gerar orçamento PDF: ${(err as Error).message}`)
}
qc.invalidateQueries({ queryKey: ['work-orders'] })
qc.invalidateQueries({ queryKey: ['invoices'] })
setShowForm(false)
reset()
},
@@ -80,8 +90,8 @@ export default function WorkOrdersPage() {
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-white">Ordens de Trabalho</h1>
<p className="text-slate-400 text-sm mt-0.5">{orders.length} ordens</p>
<h1 className={`text-2xl font-bold ${isLight ? 'text-slate-900' : 'text-white'}`}>Ordens de Trabalho</h1>
<p className={`text-sm mt-0.5 ${isLight ? 'text-slate-700' : 'text-slate-400'}`}>{orders.length} ordens</p>
</div>
<Button onClick={() => { reset(emptyOrder); setShowForm(true) }}>Nova Ordem</Button>
</div>
@@ -93,25 +103,31 @@ export default function WorkOrdersPage() {
onClick={() => setStatusFilter(s)}
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
statusFilter === s
? 'bg-slate-600 text-white'
: 'text-slate-400 hover:text-white hover:bg-slate-800'
? isLight
? 'bg-sky-700 text-slate-50'
: 'bg-slate-600 text-slate-50'
: isLight
? 'text-slate-600 hover:text-slate-900 hover:bg-slate-200'
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800'
}`}
>
{s === '' ? 'Todas' : STATUS_LABELS[s]}
{s === '' ? 'Todas' : WORK_ORDER_STATUS_LABEL[s as WorkOrder['status']]}
</button>
))}
</div>
{showForm && (
<div className="mb-6 bg-slate-800 rounded-lg border border-slate-700 p-6">
<h2 className="text-lg font-semibold text-white mb-4">Nova Ordem de Trabalho</h2>
<div className={`mb-6 rounded-lg border p-6 ${isLight ? 'border-slate-300 bg-white' : 'border-slate-700 bg-slate-800'}`}>
<h2 className={`text-lg font-semibold mb-4 ${isLight ? 'text-slate-900' : 'text-white'}`}>Nova Ordem de Trabalho</h2>
<form onSubmit={handleSubmit((d) => create.mutate(d))} className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label htmlFor="client_id">Cliente</Label>
<select
id="client_id"
{...register('client_id')}
className="w-full rounded-md border border-slate-600 bg-slate-900 text-white px-3 py-2 text-sm"
className={`w-full rounded-md border px-3 py-2 text-sm ${
isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'
}`}
>
<option value=""> Sem cliente </option>
{clients.map((c) => (
@@ -124,7 +140,9 @@ export default function WorkOrdersPage() {
<select
id="vehicle_id"
{...register('vehicle_id')}
className="w-full rounded-md border border-slate-600 bg-slate-900 text-white px-3 py-2 text-sm"
className={`w-full rounded-md border px-3 py-2 text-sm ${
isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'
}`}
disabled={!selectedClientId}
>
<option value=""> Sem veículo </option>
@@ -135,11 +153,40 @@ export default function WorkOrdersPage() {
</div>
<div className="col-span-2 space-y-1">
<Label htmlFor="internal_notes">Notas internas</Label>
<Input id="internal_notes" {...register('internal_notes')} className="bg-slate-900 border-slate-600 text-white" />
<Input
id="internal_notes"
{...register('internal_notes')}
className={isLight ? 'bg-white border-slate-300 text-slate-900' : 'bg-slate-900 border-slate-600 text-white'}
/>
</div>
<div className="col-span-2 space-y-1">
<Label htmlFor="client_notes">Observações</Label>
<Input
id="client_notes"
{...register('client_notes')}
className={isLight ? 'bg-white border-slate-300 text-slate-900' : 'bg-slate-900 border-slate-600 text-white'}
/>
</div>
<div className="space-y-1">
<Label htmlFor="eta_days">Previsão de conclusão</Label>
<select
id="eta_days"
{...register('eta_days')}
className={`w-full rounded-md border px-3 py-2 text-sm ${
isLight ? 'border-slate-300 bg-white text-slate-900' : 'border-slate-600 bg-slate-900 text-white'
}`}
>
{ETA_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
{create.error && (
<p className="col-span-2 text-red-400 text-sm">{create.error.message}</p>
)}
{docError && (
<p className="col-span-2 text-amber-300 text-sm">{docError}</p>
)}
<div className="col-span-2 flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={() => setShowForm(false)}>Cancelar</Button>
<Button type="submit" disabled={create.isPending}>
@@ -151,36 +198,51 @@ export default function WorkOrdersPage() {
)}
{isLoading ? (
<p className="text-slate-400">A carregar...</p>
<p className={isLight ? 'text-slate-700' : 'text-slate-400'}>A carregar...</p>
) : orders.length === 0 ? (
<p className="text-slate-500 text-sm">Nenhuma ordem de trabalho.</p>
<p className={`text-sm ${isLight ? 'text-slate-700' : 'text-slate-500'}`}>Nenhuma ordem de trabalho.</p>
) : (
<div className="rounded-lg border border-slate-700 overflow-hidden">
<div className={`rounded-lg border overflow-hidden ${isLight ? 'border-slate-300' : 'border-slate-700'}`}>
<table className="w-full text-sm">
<thead className="bg-slate-800">
<thead className={isLight ? 'bg-slate-200' : 'bg-slate-800'}>
<tr>
<th className="text-left px-4 py-3 text-slate-400 font-medium"></th>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Estado</th>
<th className="text-left px-4 py-3 text-slate-400 font-medium">Criada</th>
<th className={`text-left px-4 py-3 font-medium ${isLight ? 'text-slate-800' : 'text-slate-400'}`}></th>
<th className={`text-left px-4 py-3 font-medium ${isLight ? 'text-slate-800' : 'text-slate-400'}`}>Cliente</th>
<th className={`text-left px-4 py-3 font-medium ${isLight ? 'text-slate-800' : 'text-slate-400'}`}>Estado</th>
<th className={`text-left px-4 py-3 font-medium ${isLight ? 'text-slate-800' : 'text-slate-400'}`}>Previsão</th>
<th className={`text-left px-4 py-3 font-medium ${isLight ? 'text-slate-800' : 'text-slate-400'}`}>Prazo real</th>
<th className={`text-left px-4 py-3 font-medium ${isLight ? 'text-slate-800' : 'text-slate-400'}`}>Criada</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{orders.map((o) => (
<tr key={o.id} className="border-t border-slate-700 hover:bg-slate-800/50">
<td className="px-4 py-3 text-white font-mono">#{o.number}</td>
<tr
key={o.id}
className={`${isLight ? 'border-t border-slate-300 hover:bg-slate-100' : 'border-t border-slate-700 hover:bg-slate-800/50'}`}
>
<td className={`px-4 py-3 font-mono ${isLight ? 'text-slate-900' : 'text-white'}`}>#{o.number}</td>
<td className={`px-4 py-3 ${isLight ? 'text-slate-900' : 'text-slate-300'}`}>
{o.client_id ? (clientNameById[o.client_id] ?? '—') : '—'}
</td>
<td className="px-4 py-3">
<Badge variant={STATUS_VARIANT[o.status]}>
{STATUS_LABELS[o.status]}
<Badge variant="outline" className={workOrderStatusBadgeClass(o.status)}>
{WORK_ORDER_STATUS_LABEL[o.status]}
</Badge>
</td>
<td className="px-4 py-3 text-slate-400">
<td className={`px-4 py-3 ${isLight ? 'text-slate-800' : 'text-slate-300'}`}>{ETA_LABEL[o.eta_days] ?? `${o.eta_days} dias`}</td>
<td className={`px-4 py-3 ${isLight ? 'text-slate-800' : 'text-slate-300'}`}>
{o.status !== 'quote' && o.real_deadline
? new Intl.DateTimeFormat('pt-PT').format(new Date(o.real_deadline))
: '—'}
</td>
<td className={`px-4 py-3 ${isLight ? 'text-slate-800' : 'text-slate-400'}`}>
{new Intl.DateTimeFormat('pt-PT').format(new Date(o.created_at))}
</td>
<td className="px-4 py-3 text-right">
<Link
to={`/app/work-orders/${o.id}`}
className="text-xs text-blue-400 hover:text-blue-300"
className={`text-xs ${isLight ? 'text-blue-700 hover:text-blue-800' : 'text-blue-400 hover:text-blue-300'}`}
>
Ver detalhe
</Link>
+38 -10
View File
@@ -2,10 +2,13 @@ import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useLogin } from '@/hooks/useAuth'
import { apiFetch } from '@/lib/api'
import type { PlatformSettings } from '@/lib/types'
const schema = z.object({
email: z.string().email('Email inválido'),
@@ -16,7 +19,13 @@ type FormData = z.infer<typeof schema>
export default function LoginPage() {
const [showSlug, setShowSlug] = useState(false)
const [logoBroken, setLogoBroken] = useState(false)
const { mutate: login, isPending, error } = useLogin()
const { data: platform } = useQuery<PlatformSettings>({
queryKey: ['platform', 'settings'],
queryFn: () => apiFetch<PlatformSettings>('/platform/settings'),
retry: false,
})
const {
register,
@@ -31,58 +40,77 @@ export default function LoginPage() {
tenant_slug: data.tenant_slug || undefined,
})
}
const primary = platform?.admin_primary_color || '#0f3b47'
const accent = platform?.admin_accent_color || '#06b6d4'
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-600 via-slate-700 to-slate-800 px-4">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900">TechXCar</h1>
<p className="text-gray-500 mt-1 text-sm">Gestão de Oficina</p>
{platform?.platform_logo && !logoBroken && (
<img
src={platform.platform_logo}
alt=""
className="mx-auto mb-3 h-14 object-contain"
onError={() => setLogoBroken(true)}
referrerPolicy="no-referrer"
/>
)}
{logoBroken && (
<div className="mx-auto mb-3 h-14 w-14 rounded-md flex items-center justify-center bg-gray-200 text-gray-700 font-semibold">
TX
</div>
)}
<h1 className="text-3xl font-bold text-slate-100">{platform?.platform_name || 'TechXCar'}</h1>
<p className="text-slate-300 mt-1 text-sm">{platform?.platform_subtitle || 'Gestão de Oficina'}</p>
</div>
<form
onSubmit={handleSubmit(onSubmit)}
className="bg-white p-8 rounded-lg shadow-sm border border-gray-200 space-y-4"
className="bg-slate-50 p-8 rounded-xl shadow-xl border border-slate-200/90 space-y-4"
>
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" autoComplete="email" {...register('email')} />
<Label htmlFor="email" className="text-slate-700">Email</Label>
<Input id="email" type="email" autoComplete="email" {...register('email')} className="border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" />
{errors.email && <p className="text-red-500 text-xs">{errors.email.message}</p>}
</div>
<div className="space-y-1.5">
<Label htmlFor="password">Password</Label>
<Label htmlFor="password" className="text-slate-700">Password</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
{...register('password')}
className="border-slate-300 bg-white text-slate-900 placeholder:text-slate-400"
/>
{errors.password && <p className="text-red-500 text-xs">{errors.password.message}</p>}
</div>
{showSlug && (
<div className="space-y-1.5">
<Label htmlFor="tenant_slug">Workspace (slug da oficina)</Label>
<Label htmlFor="tenant_slug" className="text-slate-700">Workspace (slug da oficina)</Label>
<Input
id="tenant_slug"
type="text"
placeholder="minha-oficina"
{...register('tenant_slug')}
className="border-slate-300 bg-white text-slate-900 placeholder:text-slate-400"
/>
</div>
)}
{error && <p className="text-red-500 text-sm text-center">{error.message}</p>}
<Button type="submit" className="w-full" disabled={isPending}>
<Button type="submit" className="w-full" disabled={isPending} style={{ backgroundColor: primary, color: '#e6fffb' }}>
{isPending ? 'A entrar...' : 'Entrar'}
</Button>
<button
type="button"
onClick={() => setShowSlug((v) => !v)}
className="w-full text-xs text-gray-400 hover:text-gray-600 text-center"
className="w-full text-xs text-slate-500 hover:text-slate-700 text-center"
style={{ color: accent }}
>
{showSlug ? 'Ocultar campo workspace' : 'Entrar numa oficina específica'}
</button>
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/layout/AdminLayout.tsx","./src/components/layout/AppLayout.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/hooks/useAuth.ts","./src/hooks/useClients.test.ts","./src/hooks/useClients.ts","./src/lib/api.ts","./src/lib/queryClient.ts","./src/lib/types.ts","./src/lib/utils.ts","./src/pages/admin/DashboardPage.tsx","./src/pages/admin/TenantsPage.tsx","./src/pages/app/CatalogPage.tsx","./src/pages/app/ClientsPage.tsx","./src/pages/app/DashboardPage.tsx","./src/pages/app/ExpensesPage.tsx","./src/pages/app/InvoicesPage.tsx","./src/pages/app/SettingsPage.tsx","./src/pages/app/StaffPage.tsx","./src/pages/app/WorkOrderDetailPage.tsx","./src/pages/app/WorkOrdersPage.tsx","./src/pages/auth/LoginPage.tsx","./src/pages/public/InviteRedeemPage.tsx","./src/store/authStore.test.ts","./src/store/authStore.ts","./src/test/setup.ts"],"version":"6.0.3"}
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/layout/AdminLayout.tsx","./src/components/layout/AppLayout.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/confirm-action-dialog.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/hooks/useAuth.ts","./src/hooks/useClients.test.ts","./src/hooks/useClients.ts","./src/hooks/useTheme.ts","./src/lib/api.ts","./src/lib/queryClient.ts","./src/lib/types.ts","./src/lib/utils.ts","./src/lib/vehicleBrands.ts","./src/lib/workOrderStatus.ts","./src/pages/admin/DashboardPage.tsx","./src/pages/admin/SettingsPage.tsx","./src/pages/admin/TenantsPage.tsx","./src/pages/app/CatalogPage.tsx","./src/pages/app/ClientsPage.tsx","./src/pages/app/DashboardPage.tsx","./src/pages/app/ExpenseReportsPage.tsx","./src/pages/app/ExpensesPage.tsx","./src/pages/app/HelpPage.tsx","./src/pages/app/InvoicesPage.tsx","./src/pages/app/ReportsPage.tsx","./src/pages/app/SettingsPage.tsx","./src/pages/app/StaffPage.tsx","./src/pages/app/TechnicianReportsPage.tsx","./src/pages/app/WorkOrderDetailPage.tsx","./src/pages/app/WorkOrdersPage.tsx","./src/pages/auth/LoginPage.tsx","./src/pages/public/InviteRedeemPage.tsx","./src/store/authStore.test.ts","./src/store/authStore.ts","./src/test/setup.ts"],"version":"6.0.3"}