feat: refine OT flow, reports, theming and sidebar UX
This commit is contained in:
@@ -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,9 +86,87 @@ func createInvoiceH() fiber.Handler {
|
||||
return fiber.NewError(404, "ordem de trabalho não encontrada")
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return fiber.NewError(500, "erro ao criar fatura")
|
||||
}
|
||||
|
||||
if b.Type == "invoice" {
|
||||
paymentMethod := detail.PaymentMethod
|
||||
paymentDate := ""
|
||||
if detail.PaymentDate != nil {
|
||||
paymentDate = detail.PaymentDate.Format("2006-01-02")
|
||||
}
|
||||
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})
|
||||
}
|
||||
}
|
||||
|
||||
func downloadPDFH() fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
conn := auth.GetConn(c)
|
||||
inv, err := GetInvoice(c.Context(), conn, id)
|
||||
if err != nil {
|
||||
return fiber.NewError(500, "erro interno")
|
||||
}
|
||||
if inv == nil {
|
||||
return fiber.NewError(404, "fatura não encontrada")
|
||||
}
|
||||
|
||||
// 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(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(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 fiber.NewError(500, "erro ao obter definições")
|
||||
return "", fmt.Errorf("erro ao obter definições: %w", err)
|
||||
}
|
||||
|
||||
var clientName, clientNIF, vehiclePlate string
|
||||
@@ -100,20 +181,14 @@ func createInvoiceH() fiber.Handler {
|
||||
*detail.VehicleID).Scan(&vehiclePlate)
|
||||
}
|
||||
|
||||
inv, err := CreateInvoice(c.Context(), conn, b.WorkOrderID, b.Type)
|
||||
if err != nil {
|
||||
return fiber.NewError(500, "erro ao criar fatura")
|
||||
}
|
||||
|
||||
docType := "Orçamento"
|
||||
prefix := "ORC"
|
||||
if b.Type == "invoice" {
|
||||
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"],
|
||||
@@ -146,42 +221,10 @@ func createInvoiceH() fiber.Handler {
|
||||
}
|
||||
|
||||
if err := pdf.Generate(meta, lineItems, staffTotal, outPath); err != nil {
|
||||
return fiber.NewError(500, "erro ao gerar PDF")
|
||||
return "", err
|
||||
}
|
||||
|
||||
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 {
|
||||
return fiber.NewError(500, "erro ao atualizar estado da ordem")
|
||||
}
|
||||
}
|
||||
|
||||
return c.Status(201).JSON(fiber.Map{"data": inv, "error": nil})
|
||||
}
|
||||
}
|
||||
|
||||
func downloadPDFH() fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
conn := auth.GetConn(c)
|
||||
inv, err := GetInvoice(c.Context(), conn, id)
|
||||
if err != nil {
|
||||
return fiber.NewError(500, "erro interno")
|
||||
}
|
||||
if inv == nil {
|
||||
return fiber.NewError(404, "fatura não encontrada")
|
||||
}
|
||||
if inv.PDFPath == "" {
|
||||
return fiber.NewError(404, "PDF não disponível")
|
||||
}
|
||||
if _, err := os.Stat(inv.PDFPath); 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 "", 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -111,17 +172,35 @@ func transitionWOH() fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
var body struct {
|
||||
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())
|
||||
}
|
||||
@@ -132,6 +211,7 @@ func transitionWOH() fiber.Handler {
|
||||
type woItemBody struct {
|
||||
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"`
|
||||
@@ -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})
|
||||
|
||||
@@ -16,6 +16,10 @@ type WorkOrder struct {
|
||||
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"`
|
||||
@@ -26,6 +30,7 @@ type WOItem struct {
|
||||
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"`
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
<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="px-3 py-1 rounded bg-amber-950 text-amber-100 hover:bg-amber-900 text-xs font-semibold transition-colors"
|
||||
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>
|
||||
<nav className="flex-1 p-3 space-y-1">
|
||||
{nav.map(({ to, label, end }) => (
|
||||
) : (
|
||||
<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 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={end}
|
||||
end={to === '/app/reports'}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center px-3 py-2 rounded-md text-sm transition-colors ${
|
||||
`flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors ${
|
||||
isActive
|
||||
? 'bg-slate-700 text-white'
|
||||
? 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'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{label}
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]}`
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,7 +208,11 @@ 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'
|
||||
? 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'
|
||||
}`}
|
||||
>
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,96 +119,124 @@ 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="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-sm text-slate-300">Tipo</label>
|
||||
<label className="text-xs text-slate-400">Período</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"
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
{PERIOD_OPTIONS.map((opt) => (
|
||||
<option key={opt.key} value={opt.key}>{opt.label}</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}
|
||||
<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"
|
||||
>
|
||||
{generate.isPending ? 'A gerar...' : 'Gerar PDF'}
|
||||
</Button>
|
||||
<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>
|
||||
) : (
|
||||
<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>
|
||||
) : sorted.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">Nenhum documento encontrado com os filtros selecionados.</p>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-slate-700">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-900/80">
|
||||
<thead className="bg-slate-800">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2 text-slate-400 font-medium">Nº</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 text-left font-medium text-slate-400">Tipo</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-slate-400">Nº</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>
|
||||
{quoteDocs.map((inv) => (
|
||||
{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 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">
|
||||
{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>
|
||||
@@ -163,46 +244,10 @@ export default function InvoicesPage() {
|
||||
<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">Nº</th>
|
||||
<th className="text-left px-4 py-2 text-slate-400 font-medium">OT</th>
|
||||
<th className="text-left px-4 py-2 text-slate-400 font-medium">Emitida</th>
|
||||
<th className="px-4 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invoiceDocs.map((inv) => (
|
||||
<tr key={inv.id} className="border-t border-slate-700 hover:bg-slate-800/50">
|
||||
<td className="px-4 py-2 text-white font-mono">#{inv.number}</td>
|
||||
<td className="px-4 py-2 text-slate-400 font-mono text-xs">{inv.work_order_id.slice(0, 8)}…</td>
|
||||
<td className="px-4 py-2 text-slate-400">
|
||||
{new Intl.DateTimeFormat('pt-PT').format(new Date(inv.issued_at))}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<Button size="sm" variant="outline" onClick={() => openPDF(inv)}>Ver PDF</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,32 +289,235 @@ 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="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<span className="text-slate-400 text-sm self-center">Transição:</span>
|
||||
{nextStates.map((s) => (
|
||||
@@ -214,15 +526,22 @@ 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>
|
||||
)}
|
||||
|
||||
{/* ── Items ─────────────────────────────────────────────────────────────── */}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">Nº</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'}`}>Nº</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>
|
||||
|
||||
@@ -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 @@
|
||||
{"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"}
|
||||
Reference in New Issue
Block a user