diff --git a/backend/internal/invoice/handler.go b/backend/internal/invoice/handler.go index 66fe4f0..90e269d 100644 --- a/backend/internal/invoice/handler.go +++ b/backend/internal/invoice/handler.go @@ -5,8 +5,11 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" "github.com/techxcar/backend/internal/auth" "github.com/techxcar/backend/internal/settings" "github.com/techxcar/backend/internal/workorder" @@ -83,21 +86,18 @@ func createInvoiceH() fiber.Handler { return fiber.NewError(404, "ordem de trabalho não encontrada") } - sett, err := settings.GetSettings(c.Context(), conn) - if err != nil { - return fiber.NewError(500, "erro ao obter definições") - } - - var clientName, clientNIF, vehiclePlate string - if detail.ClientID != nil { - _ = conn.QueryRow(c.Context(), - `SELECT name, COALESCE(nif,'') FROM clients WHERE id = $1`, - *detail.ClientID).Scan(&clientName, &clientNIF) - } - if detail.VehicleID != nil { - _ = conn.QueryRow(c.Context(), - `SELECT plate FROM vehicles WHERE id = $1`, - *detail.VehicleID).Scan(&vehiclePlate) + if b.Type == "invoice" { + if detail.PaymentMethod == "" { + return fiber.NewError(400, "defina o meio de pagamento na OT antes de faturar") + } + var existingID string + if err := conn.QueryRow(c.Context(), + `SELECT id FROM invoices WHERE work_order_id=$1 AND type='invoice' ORDER BY issued_at DESC LIMIT 1`, + b.WorkOrderID).Scan(&existingID); err == nil && existingID != "" { + return fiber.NewError(409, "já existe uma fatura para esta OT") + } else if err != nil && err != pgx.ErrNoRows { + return fiber.NewError(500, "erro ao validar faturação existente") + } } inv, err := CreateInvoice(c.Context(), conn, b.WorkOrderID, b.Type) @@ -105,61 +105,27 @@ func createInvoiceH() fiber.Handler { return fiber.NewError(500, "erro ao criar fatura") } - docType := "Orçamento" - prefix := "ORC" if b.Type == "invoice" { - docType = "Fatura" - prefix = "FAT" - } - - outPath := filepath.Join(storageRoot, tenantDir(c), fmt.Sprintf("inv_%s.pdf", inv.ID)) - - meta := pdf.DocMeta{ - CompanyName: sett["company_name"], - CompanyNIF: sett["company_nif"], - CompanyAddress: sett["company_address"], - CompanyIBAN: sett["company_iban"], - CompanyPhone: sett["company_phone"], - CompanyEmail: sett["company_email"], - CompanyLogo: sett["company_logo"], - DocType: docType, - DocNumber: fmt.Sprintf("%s/%d/%04d", prefix, inv.IssuedAt.Year(), inv.Number), - IssuedAt: inv.IssuedAt.Format("02/01/2006"), - ClientName: clientName, - ClientNIF: clientNIF, - VehiclePlate: vehiclePlate, - } - - lineItems := make([]pdf.LineItem, len(detail.Items)) - for i, item := range detail.Items { - lineItems[i] = pdf.LineItem{ - Description: item.Description, - Qty: item.Qty, - UnitPrice: item.UnitPrice, - DiscountPct: item.DiscountPct, - Total: item.Total, + paymentMethod := detail.PaymentMethod + paymentDate := "" + if detail.PaymentDate != nil { + paymentDate = detail.PaymentDate.Format("2006-01-02") } - } - var staffTotal float64 - for _, sh := range detail.StaffHours { - staffTotal += sh.Total - } - - if err := pdf.Generate(meta, lineItems, staffTotal, outPath); err != nil { - return fiber.NewError(500, "erro ao gerar PDF") - } - - if err := SetPDFPath(c.Context(), conn, inv.ID, outPath); err != nil { - return fiber.NewError(500, "erro ao registar caminho do PDF") - } - inv.PDFPath = outPath - - if b.Type == "invoice" { - if _, err := workorder.TransitionStatus(c.Context(), conn, b.WorkOrderID, "invoiced", ""); err != nil { + if paymentDate == "" { + paymentDate = time.Now().Format("2006-01-02") + } + if _, err := workorder.TransitionStatus(c.Context(), conn, b.WorkOrderID, "invoiced", paymentMethod, paymentDate, ""); err != nil { + _, _ = conn.Exec(c.Context(), `DELETE FROM invoices WHERE id=$1`, inv.ID) return fiber.NewError(500, "erro ao atualizar estado da ordem") } } + outPath, err := regeneratePDF(c, conn, inv) + if err != nil { + return fiber.NewError(500, "erro ao gerar PDF") + } + inv.PDFPath = outPath + return c.Status(201).JSON(fiber.Map{"data": inv, "error": nil}) } } @@ -175,13 +141,90 @@ func downloadPDFH() fiber.Handler { if inv == nil { return fiber.NewError(404, "fatura não encontrada") } - if inv.PDFPath == "" { - return fiber.NewError(404, "PDF não disponível") + + // Regenerate on download so layout/branding fixes are reflected on existing docs. + outPath, err := regeneratePDF(c, conn, inv) + if err != nil { + return fiber.NewError(500, "erro ao gerar PDF") } - if _, err := os.Stat(inv.PDFPath); os.IsNotExist(err) { + if _, err := os.Stat(outPath); os.IsNotExist(err) { return fiber.NewError(404, "ficheiro PDF não encontrado") } c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="invoice_%d.pdf"`, inv.Number)) - return c.SendFile(inv.PDFPath) + return c.SendFile(outPath) } } + +func regeneratePDF(c *fiber.Ctx, conn *pgxpool.Conn, inv *Invoice) (string, error) { + detail, err := workorder.GetWorkOrderDetail(c.Context(), conn, inv.WorkOrderID) + if err != nil { + return "", fmt.Errorf("erro ao obter ordem de trabalho: %w", err) + } + if detail == nil { + return "", fmt.Errorf("ordem de trabalho não encontrada") + } + + sett, err := settings.GetSettings(c.Context(), conn) + if err != nil { + return "", fmt.Errorf("erro ao obter definições: %w", err) + } + + var clientName, clientNIF, vehiclePlate string + if detail.ClientID != nil { + _ = conn.QueryRow(c.Context(), + `SELECT name, COALESCE(nif,'') FROM clients WHERE id = $1`, + *detail.ClientID).Scan(&clientName, &clientNIF) + } + if detail.VehicleID != nil { + _ = conn.QueryRow(c.Context(), + `SELECT plate FROM vehicles WHERE id = $1`, + *detail.VehicleID).Scan(&vehiclePlate) + } + + docType := "Orçamento" + prefix := "ORC" + if inv.Type == "invoice" { + docType = "Fatura" + prefix = "FAT" + } + + outPath := filepath.Join(storageRoot, tenantDir(c), fmt.Sprintf("inv_%s.pdf", inv.ID)) + meta := pdf.DocMeta{ + CompanyName: sett["company_name"], + CompanyNIF: sett["company_nif"], + CompanyAddress: sett["company_address"], + CompanyIBAN: sett["company_iban"], + CompanyPhone: sett["company_phone"], + CompanyEmail: sett["company_email"], + CompanyLogo: sett["company_logo"], + DocType: docType, + DocNumber: fmt.Sprintf("%s/%d/%04d", prefix, inv.IssuedAt.Year(), inv.Number), + IssuedAt: inv.IssuedAt.Format("02/01/2006"), + ClientName: clientName, + ClientNIF: clientNIF, + VehiclePlate: vehiclePlate, + } + + lineItems := make([]pdf.LineItem, len(detail.Items)) + for i, item := range detail.Items { + lineItems[i] = pdf.LineItem{ + Description: item.Description, + Qty: item.Qty, + UnitPrice: item.UnitPrice, + DiscountPct: item.DiscountPct, + Total: item.Total, + } + } + var staffTotal float64 + for _, sh := range detail.StaffHours { + staffTotal += sh.Total + } + + if err := pdf.Generate(meta, lineItems, staffTotal, outPath); err != nil { + return "", err + } + if err := SetPDFPath(c.Context(), conn, inv.ID, outPath); err != nil { + return "", err + } + return outPath, nil +} diff --git a/backend/internal/platformsettings/handler.go b/backend/internal/platformsettings/handler.go new file mode 100644 index 0000000..9ce60df --- /dev/null +++ b/backend/internal/platformsettings/handler.go @@ -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}) + } +} diff --git a/backend/internal/platformsettings/repository.go b/backend/internal/platformsettings/repository.go new file mode 100644 index 0000000..f033f7f --- /dev/null +++ b/backend/internal/platformsettings/repository.go @@ -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 +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index c5e2a60..8c93354 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -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) } diff --git a/backend/internal/settings/handler.go b/backend/internal/settings/handler.go index 77c0ff5..4a669c4 100644 --- a/backend/internal/settings/handler.go +++ b/backend/internal/settings/handler.go @@ -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{ diff --git a/backend/internal/settings/repository.go b/backend/internal/settings/repository.go index 2b75459..a4eb098 100644 --- a/backend/internal/settings/repository.go +++ b/backend/internal/settings/repository.go @@ -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) { diff --git a/backend/internal/workorder/handler.go b/backend/internal/workorder/handler.go index 4fec68e..9f35ecb 100644 --- a/backend/internal/workorder/handler.go +++ b/backend/internal/workorder/handler.go @@ -2,6 +2,7 @@ package workorder import ( "errors" + "time" "github.com/gofiber/fiber/v2" "github.com/jackc/pgx/v5" @@ -53,6 +54,28 @@ type woBody struct { VehicleID string `json:"vehicle_id"` InternalNotes string `json:"internal_notes"` ClientNotes string `json:"client_notes"` + ETADays int `json:"eta_days"` + RealDeadline string `json:"real_deadline"` + PaymentMethod string `json:"payment_method"` + PaymentDate string `json:"payment_date"` +} + +func isValidETADays(v int) bool { + switch v { + case 1, 2, 3, 7, 15, 30, 31: + return true + default: + return false + } +} + +func isValidPaymentMethod(v string) bool { + switch v { + case "", "numerario", "multibanco", "transferencia_bancaria", "mb_way", "cartao_debito", "cartao_credito", "cheque", "outro": + return true + default: + return false + } } func createWOH() fiber.Handler { @@ -61,13 +84,32 @@ func createWOH() fiber.Handler { if err := c.BodyParser(&b); err != nil { return fiber.NewError(400, "corpo inválido") } + if b.ETADays == 0 { + b.ETADays = 1 + } + if !isValidETADays(b.ETADays) { + return fiber.NewError(400, "eta_days inválido") + } + if b.RealDeadline != "" { + if _, err := time.Parse("2006-01-02", b.RealDeadline); err != nil { + return fiber.NewError(400, "real_deadline inválido (YYYY-MM-DD)") + } + } + if !isValidPaymentMethod(b.PaymentMethod) { + return fiber.NewError(400, "payment_method inválido") + } + if b.PaymentDate != "" { + if _, err := time.Parse("2006-01-02", b.PaymentDate); err != nil { + return fiber.NewError(400, "payment_date inválido (YYYY-MM-DD)") + } + } claims, _ := c.Locals("claims").(*auth.Claims) createdBy := "" if claims != nil { createdBy = claims.UserID } conn := auth.GetConn(c) - wo, err := CreateWorkOrder(c.Context(), conn, b.ClientID, b.VehicleID, b.InternalNotes, createdBy) + wo, err := CreateWorkOrder(c.Context(), conn, b.ClientID, b.VehicleID, b.InternalNotes, b.ClientNotes, b.ETADays, b.RealDeadline, createdBy) if err != nil { return fiber.NewError(500, "erro ao criar ordem") } @@ -95,11 +137,30 @@ func updateWOH() fiber.Handler { if err := c.BodyParser(&b); err != nil { return fiber.NewError(400, "corpo inválido") } + if b.ETADays == 0 { + b.ETADays = 1 + } + if !isValidETADays(b.ETADays) { + return fiber.NewError(400, "eta_days inválido") + } + if b.RealDeadline != "" { + if _, err := time.Parse("2006-01-02", b.RealDeadline); err != nil { + return fiber.NewError(400, "real_deadline inválido (YYYY-MM-DD)") + } + } + if !isValidPaymentMethod(b.PaymentMethod) { + return fiber.NewError(400, "payment_method inválido") + } + if b.PaymentDate != "" { + if _, err := time.Parse("2006-01-02", b.PaymentDate); err != nil { + return fiber.NewError(400, "payment_date inválido (YYYY-MM-DD)") + } + } conn := auth.GetConn(c) - wo, err := UpdateWorkOrder(c.Context(), conn, c.Params("id"), b.ClientID, b.VehicleID, b.InternalNotes, b.ClientNotes) + wo, err := UpdateWorkOrder(c.Context(), conn, c.Params("id"), b.ClientID, b.VehicleID, b.InternalNotes, b.ClientNotes, b.ETADays, b.RealDeadline, b.PaymentMethod, b.PaymentDate) if err != nil { if errors.Is(err, pgx.ErrNoRows) { - return fiber.NewError(404, "ordem não encontrada") + return fiber.NewError(400, "ordem não editável (cancelada/faturada) ou não encontrada") } return fiber.NewError(500, "erro ao actualizar ordem") } @@ -110,18 +171,36 @@ func updateWOH() fiber.Handler { func transitionWOH() fiber.Handler { return func(c *fiber.Ctx) error { var body struct { - Status string `json:"status"` + Status string `json:"status"` + PaymentMethod string `json:"payment_method"` + PaymentDate string `json:"payment_date"` } if err := c.BodyParser(&body); err != nil || body.Status == "" { return fiber.NewError(400, "status é obrigatório") } + if !isValidPaymentMethod(body.PaymentMethod) { + return fiber.NewError(400, "payment_method inválido") + } + if body.PaymentDate != "" { + if _, err := time.Parse("2006-01-02", body.PaymentDate); err != nil { + return fiber.NewError(400, "payment_date inválido (YYYY-MM-DD)") + } + } + if body.Status == "invoiced" { + if body.PaymentMethod == "" { + return fiber.NewError(400, "meio de pagamento é obrigatório para faturar") + } + if body.PaymentDate == "" { + body.PaymentDate = time.Now().Format("2006-01-02") + } + } claims, _ := c.Locals("claims").(*auth.Claims) changedBy := "" if claims != nil { changedBy = claims.UserID } conn := auth.GetConn(c) - wo, err := TransitionStatus(c.Context(), conn, c.Params("id"), body.Status, changedBy) + wo, err := TransitionStatus(c.Context(), conn, c.Params("id"), body.Status, body.PaymentMethod, body.PaymentDate, changedBy) if err != nil { return fiber.NewError(400, err.Error()) } @@ -130,11 +209,12 @@ func transitionWOH() fiber.Handler { } type woItemBody struct { - CatalogItemID string `json:"catalog_item_id"` - Description string `json:"description"` - Qty float64 `json:"qty"` - UnitPrice float64 `json:"unit_price"` - DiscountPct float64 `json:"discount_pct"` + CatalogItemID string `json:"catalog_item_id"` + Description string `json:"description"` + ChangeJustification string `json:"change_justification"` + Qty float64 `json:"qty"` + UnitPrice float64 `json:"unit_price"` + DiscountPct float64 `json:"discount_pct"` } func addItemH() fiber.Handler { @@ -147,8 +227,21 @@ func addItemH() fiber.Handler { return fiber.NewError(400, "descrição, quantidade e preço unitário são obrigatórios") } conn := auth.GetConn(c) - item, err := AddItem(c.Context(), conn, c.Params("id"), b.CatalogItemID, b.Description, b.Qty, b.UnitPrice, b.DiscountPct) + var status string + if err := conn.QueryRow(c.Context(), `SELECT status FROM work_orders WHERE id=$1`, c.Params("id")).Scan(&status); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return fiber.NewError(404, "ordem não encontrada") + } + return fiber.NewError(500, "erro ao validar ordem") + } + if status == "open" && b.ChangeJustification == "" { + return fiber.NewError(400, "justificação da alteração é obrigatória em orçamento aprovado") + } + item, err := AddItem(c.Context(), conn, c.Params("id"), b.CatalogItemID, b.Description, b.ChangeJustification, b.Qty, b.UnitPrice, b.DiscountPct) if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return fiber.NewError(400, "ordem não editável (cancelada/faturada) ou dados inválidos") + } return fiber.NewError(500, "erro ao adicionar item") } return c.Status(201).JSON(fiber.Map{"data": item, "error": nil}) @@ -183,6 +276,9 @@ func addStaffHoursH() fiber.Handler { conn := auth.GetConn(c) sh, err := AddStaffHours(c.Context(), conn, c.Params("id"), b.StaffID, b.Hours, b.CostPerHour) if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return fiber.NewError(400, "ordem não editável (cancelada/faturada) ou não encontrada") + } return fiber.NewError(500, "erro ao adicionar horas") } return c.Status(201).JSON(fiber.Map{"data": sh, "error": nil}) diff --git a/backend/internal/workorder/repository.go b/backend/internal/workorder/repository.go index 61a1c95..1cde5da 100644 --- a/backend/internal/workorder/repository.go +++ b/backend/internal/workorder/repository.go @@ -9,27 +9,32 @@ import ( ) type WorkOrder struct { - ID string `json:"id"` - Number int `json:"number"` - ClientID *string `json:"client_id"` - VehicleID *string `json:"vehicle_id"` - Status string `json:"status"` - InternalNotes string `json:"internal_notes"` - ClientNotes string `json:"client_notes"` - CreatedBy *string `json:"created_by"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Number int `json:"number"` + ClientID *string `json:"client_id"` + VehicleID *string `json:"vehicle_id"` + Status string `json:"status"` + InternalNotes string `json:"internal_notes"` + ClientNotes string `json:"client_notes"` + ETADays int `json:"eta_days"` + RealDeadline *time.Time `json:"real_deadline"` + PaymentMethod string `json:"payment_method"` + PaymentDate *time.Time `json:"payment_date"` + CreatedBy *string `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type WOItem struct { - ID string `json:"id"` - WorkOrderID string `json:"work_order_id"` - CatalogItemID *string `json:"catalog_item_id"` - Description string `json:"description"` - Qty float64 `json:"qty"` - UnitPrice float64 `json:"unit_price"` - DiscountPct float64 `json:"discount_pct"` - Total float64 `json:"total"` + ID string `json:"id"` + WorkOrderID string `json:"work_order_id"` + CatalogItemID *string `json:"catalog_item_id"` + Description string `json:"description"` + ChangeJustification string `json:"change_justification"` + Qty float64 `json:"qty"` + UnitPrice float64 `json:"unit_price"` + DiscountPct float64 `json:"discount_pct"` + Total float64 `json:"total"` } type WOStaffHours struct { @@ -71,7 +76,8 @@ func ValidateTransition(from, to string) error { func ListWorkOrders(ctx context.Context, conn *pgxpool.Conn, status string) ([]*WorkOrder, error) { q := `SELECT id, number, client_id, vehicle_id, status, - COALESCE(internal_notes,''), COALESCE(client_notes,''), created_by, created_at, updated_at + COALESCE(internal_notes,''), COALESCE(client_notes,''), COALESCE(eta_days,1), real_deadline, + COALESCE(payment_method,''), payment_date, created_by, created_at, updated_at FROM work_orders` args := []any{} if status != "" { @@ -88,7 +94,8 @@ func ListWorkOrders(ctx context.Context, conn *pgxpool.Conn, status string) ([]* for rows.Next() { var wo WorkOrder if err := rows.Scan(&wo.ID, &wo.Number, &wo.ClientID, &wo.VehicleID, - &wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt); err != nil { + &wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.ETADays, &wo.RealDeadline, + &wo.PaymentMethod, &wo.PaymentDate, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt); err != nil { return nil, err } list = append(list, &wo) @@ -96,39 +103,47 @@ func ListWorkOrders(ctx context.Context, conn *pgxpool.Conn, status string) ([]* return list, rows.Err() } -func CreateWorkOrder(ctx context.Context, conn *pgxpool.Conn, clientID, vehicleID, internalNotes, createdBy string) (*WorkOrder, error) { +func CreateWorkOrder(ctx context.Context, conn *pgxpool.Conn, clientID, vehicleID, internalNotes, clientNotes string, etaDays int, realDeadline, createdBy string) (*WorkOrder, error) { var wo WorkOrder err := conn.QueryRow(ctx, ` - INSERT INTO work_orders (client_id, vehicle_id, internal_notes, created_by) + INSERT INTO work_orders (client_id, vehicle_id, internal_notes, client_notes, eta_days, real_deadline, created_by) VALUES ( NULLIF($1,'')::uuid, NULLIF($2,'')::uuid, NULLIF($3,''), - (SELECT id FROM users WHERE id = NULLIF($4,'')::uuid LIMIT 1) + NULLIF($4,''), + $5, + NULLIF($6,'')::date, + (SELECT id FROM users WHERE id = NULLIF($7,'')::uuid LIMIT 1) ) RETURNING id, number, client_id, vehicle_id, status, - COALESCE(internal_notes,''), COALESCE(client_notes,''), created_by, created_at, updated_at`, - clientID, vehicleID, internalNotes, createdBy). + COALESCE(internal_notes,''), COALESCE(client_notes,''), COALESCE(eta_days,1), real_deadline, + COALESCE(payment_method,''), payment_date, created_by, created_at, updated_at`, + clientID, vehicleID, internalNotes, clientNotes, etaDays, realDeadline, createdBy). Scan(&wo.ID, &wo.Number, &wo.ClientID, &wo.VehicleID, - &wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt) + &wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.ETADays, &wo.RealDeadline, + &wo.PaymentMethod, &wo.PaymentDate, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt) return &wo, err } -func UpdateWorkOrder(ctx context.Context, conn *pgxpool.Conn, id, clientID, vehicleID, internalNotes, clientNotes string) (*WorkOrder, error) { +func UpdateWorkOrder(ctx context.Context, conn *pgxpool.Conn, id, clientID, vehicleID, internalNotes, clientNotes string, etaDays int, realDeadline, paymentMethod, paymentDate string) (*WorkOrder, error) { var wo WorkOrder err := conn.QueryRow(ctx, ` UPDATE work_orders SET client_id=NULLIF($2,'')::uuid, vehicle_id=NULLIF($3,'')::uuid, - internal_notes=NULLIF($4,''), client_notes=NULLIF($5,''), updated_at=NOW() - WHERE id=$1 + internal_notes=NULLIF($4,''), client_notes=NULLIF($5,''), eta_days=$6, real_deadline=NULLIF($7,'')::date, + payment_method=NULLIF($8,''), payment_date=NULLIF($9,'')::date, updated_at=NOW() + WHERE id=$1 AND status NOT IN ('cancelled','invoiced') RETURNING id, number, client_id, vehicle_id, status, - COALESCE(internal_notes,''), COALESCE(client_notes,''), created_by, created_at, updated_at`, - id, clientID, vehicleID, internalNotes, clientNotes). + COALESCE(internal_notes,''), COALESCE(client_notes,''), COALESCE(eta_days,1), real_deadline, + COALESCE(payment_method,''), payment_date, created_by, created_at, updated_at`, + id, clientID, vehicleID, internalNotes, clientNotes, etaDays, realDeadline, paymentMethod, paymentDate). Scan(&wo.ID, &wo.Number, &wo.ClientID, &wo.VehicleID, - &wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt) + &wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.ETADays, &wo.RealDeadline, + &wo.PaymentMethod, &wo.PaymentDate, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt) return &wo, err } -func TransitionStatus(ctx context.Context, conn *pgxpool.Conn, id, toStatus, changedBy string) (*WorkOrder, error) { +func TransitionStatus(ctx context.Context, conn *pgxpool.Conn, id, toStatus, paymentMethod, paymentDate, changedBy string) (*WorkOrder, error) { var fromStatus string if err := conn.QueryRow(ctx, `SELECT status FROM work_orders WHERE id=$1`, id).Scan(&fromStatus); err != nil { return nil, errors.New("ordem não encontrada") @@ -138,12 +153,19 @@ func TransitionStatus(ctx context.Context, conn *pgxpool.Conn, id, toStatus, cha } var wo WorkOrder err := conn.QueryRow(ctx, ` - UPDATE work_orders SET status=$2, updated_at=NOW() WHERE id=$1 + UPDATE work_orders + SET status=$2, + payment_method=CASE WHEN $2='invoiced' THEN NULLIF($3,'') ELSE payment_method END, + payment_date=CASE WHEN $2='invoiced' THEN NULLIF($4,'')::date ELSE payment_date END, + updated_at=NOW() + WHERE id=$1 RETURNING id, number, client_id, vehicle_id, status, - COALESCE(internal_notes,''), COALESCE(client_notes,''), created_by, created_at, updated_at`, - id, toStatus). + COALESCE(internal_notes,''), COALESCE(client_notes,''), COALESCE(eta_days,1), real_deadline, + COALESCE(payment_method,''), payment_date, created_by, created_at, updated_at`, + id, toStatus, paymentMethod, paymentDate). Scan(&wo.ID, &wo.Number, &wo.ClientID, &wo.VehicleID, - &wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt) + &wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.ETADays, &wo.RealDeadline, + &wo.PaymentMethod, &wo.PaymentDate, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt) if err != nil { return nil, err } @@ -158,10 +180,12 @@ func GetWorkOrderDetail(ctx context.Context, conn *pgxpool.Conn, id string) (*Wo var wo WorkOrder err := conn.QueryRow(ctx, ` SELECT id, number, client_id, vehicle_id, status, - COALESCE(internal_notes,''), COALESCE(client_notes,''), created_by, created_at, updated_at + COALESCE(internal_notes,''), COALESCE(client_notes,''), COALESCE(eta_days,1), real_deadline, + COALESCE(payment_method,''), payment_date, created_by, created_at, updated_at FROM work_orders WHERE id=$1`, id). Scan(&wo.ID, &wo.Number, &wo.ClientID, &wo.VehicleID, - &wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt) + &wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.ETADays, &wo.RealDeadline, + &wo.PaymentMethod, &wo.PaymentDate, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt) if err != nil { return nil, err } @@ -169,7 +193,7 @@ func GetWorkOrderDetail(ctx context.Context, conn *pgxpool.Conn, id string) (*Wo detail := &WorkOrderDetail{WorkOrder: wo, Items: []*WOItem{}, StaffHours: []*WOStaffHours{}} rows, err := conn.Query(ctx, ` - SELECT id, work_order_id, catalog_item_id, description, qty, unit_price, discount_pct, total + SELECT id, work_order_id, catalog_item_id, description, COALESCE(change_justification,''), qty, unit_price, discount_pct, total FROM wo_items WHERE work_order_id=$1`, id) if err != nil { return nil, err @@ -177,7 +201,7 @@ func GetWorkOrderDetail(ctx context.Context, conn *pgxpool.Conn, id string) (*Wo defer rows.Close() for rows.Next() { var i WOItem - if err := rows.Scan(&i.ID, &i.WorkOrderID, &i.CatalogItemID, &i.Description, + if err := rows.Scan(&i.ID, &i.WorkOrderID, &i.CatalogItemID, &i.Description, &i.ChangeJustification, &i.Qty, &i.UnitPrice, &i.DiscountPct, &i.Total); err != nil { return nil, err } @@ -204,19 +228,27 @@ func GetWorkOrderDetail(ctx context.Context, conn *pgxpool.Conn, id string) (*Wo return detail, shRows.Err() } -func AddItem(ctx context.Context, conn *pgxpool.Conn, woID, catalogItemID, description string, qty, unitPrice, discountPct float64) (*WOItem, error) { +func AddItem(ctx context.Context, conn *pgxpool.Conn, woID, catalogItemID, description, changeJustification string, qty, unitPrice, discountPct float64) (*WOItem, error) { var i WOItem err := conn.QueryRow(ctx, ` - INSERT INTO wo_items (work_order_id, catalog_item_id, description, qty, unit_price, discount_pct) - VALUES ($1, NULLIF($2,'')::uuid, $3, $4, $5, $6) - RETURNING id, work_order_id, catalog_item_id, description, qty, unit_price, discount_pct, total`, - woID, catalogItemID, description, qty, unitPrice, discountPct). - Scan(&i.ID, &i.WorkOrderID, &i.CatalogItemID, &i.Description, &i.Qty, &i.UnitPrice, &i.DiscountPct, &i.Total) + INSERT INTO wo_items (work_order_id, catalog_item_id, description, change_justification, qty, unit_price, discount_pct) + SELECT id, NULLIF($2,'')::uuid, $3, NULLIF($4,''), $5, $6, $7 + FROM work_orders + WHERE id=$1 + AND status NOT IN ('cancelled','invoiced') + AND (status <> 'open' OR NULLIF($4,'') IS NOT NULL) + RETURNING id, work_order_id, catalog_item_id, description, COALESCE(change_justification,''), qty, unit_price, discount_pct, total`, + woID, catalogItemID, description, changeJustification, qty, unitPrice, discountPct). + Scan(&i.ID, &i.WorkOrderID, &i.CatalogItemID, &i.Description, &i.ChangeJustification, &i.Qty, &i.UnitPrice, &i.DiscountPct, &i.Total) return &i, err } func RemoveItem(ctx context.Context, conn *pgxpool.Conn, woID, itemID string) error { - _, err := conn.Exec(ctx, `DELETE FROM wo_items WHERE id=$1 AND work_order_id=$2`, itemID, woID) + _, err := conn.Exec(ctx, ` + DELETE FROM wo_items i + USING work_orders w + WHERE i.id=$1 AND i.work_order_id=$2 AND w.id=i.work_order_id AND w.status NOT IN ('cancelled','invoiced')`, + itemID, woID) return err } @@ -224,7 +256,9 @@ func AddStaffHours(ctx context.Context, conn *pgxpool.Conn, woID, staffID string var sh WOStaffHours err := conn.QueryRow(ctx, ` INSERT INTO wo_staff_hours (work_order_id, staff_id, hours, cost_per_hour) - VALUES ($1, $2, $3, $4) + SELECT id, $2, $3, $4 + FROM work_orders + WHERE id=$1 AND status NOT IN ('cancelled','invoiced') RETURNING id, work_order_id, staff_id, hours, cost_per_hour, total`, woID, staffID, hours, costPerHour). Scan(&sh.ID, &sh.WorkOrderID, &sh.StaffID, &sh.Hours, &sh.CostPerHour, &sh.Total) @@ -232,6 +266,10 @@ func AddStaffHours(ctx context.Context, conn *pgxpool.Conn, woID, staffID string } func RemoveStaffHours(ctx context.Context, conn *pgxpool.Conn, woID, shID string) error { - _, err := conn.Exec(ctx, `DELETE FROM wo_staff_hours WHERE id=$1 AND work_order_id=$2`, shID, woID) + _, err := conn.Exec(ctx, ` + DELETE FROM wo_staff_hours s + USING work_orders w + WHERE s.id=$1 AND s.work_order_id=$2 AND w.id=s.work_order_id AND w.status NOT IN ('cancelled','invoiced')`, + shID, woID) return err } diff --git a/backend/internal/workorder/repository_test.go b/backend/internal/workorder/repository_test.go index 1a8d267..8723511 100644 --- a/backend/internal/workorder/repository_test.go +++ b/backend/internal/workorder/repository_test.go @@ -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") } diff --git a/backend/migrations/tenant/000001_create_tenant_schema.up.sql b/backend/migrations/tenant/000001_create_tenant_schema.up.sql index 88c24ba..ae5e368 100644 --- a/backend/migrations/tenant/000001_create_tenant_schema.up.sql +++ b/backend/migrations/tenant/000001_create_tenant_schema.up.sql @@ -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, diff --git a/backend/migrations/tenant/000004_work_orders_eta_days.down.sql b/backend/migrations/tenant/000004_work_orders_eta_days.down.sql new file mode 100644 index 0000000..497fcc0 --- /dev/null +++ b/backend/migrations/tenant/000004_work_orders_eta_days.down.sql @@ -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; diff --git a/backend/migrations/tenant/000004_work_orders_eta_days.up.sql b/backend/migrations/tenant/000004_work_orders_eta_days.up.sql new file mode 100644 index 0000000..7683b87 --- /dev/null +++ b/backend/migrations/tenant/000004_work_orders_eta_days.up.sql @@ -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)); diff --git a/backend/migrations/tenant/000005_work_orders_real_deadline.down.sql b/backend/migrations/tenant/000005_work_orders_real_deadline.down.sql new file mode 100644 index 0000000..6871fe3 --- /dev/null +++ b/backend/migrations/tenant/000005_work_orders_real_deadline.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE work_orders + DROP COLUMN IF EXISTS real_deadline; diff --git a/backend/migrations/tenant/000005_work_orders_real_deadline.up.sql b/backend/migrations/tenant/000005_work_orders_real_deadline.up.sql new file mode 100644 index 0000000..6a7860b --- /dev/null +++ b/backend/migrations/tenant/000005_work_orders_real_deadline.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE work_orders + ADD COLUMN IF NOT EXISTS real_deadline DATE; diff --git a/backend/migrations/tenant/000006_work_orders_payment_fields.down.sql b/backend/migrations/tenant/000006_work_orders_payment_fields.down.sql new file mode 100644 index 0000000..00439fb --- /dev/null +++ b/backend/migrations/tenant/000006_work_orders_payment_fields.down.sql @@ -0,0 +1,5 @@ +ALTER TABLE work_orders + DROP COLUMN IF EXISTS payment_date; + +ALTER TABLE work_orders + DROP COLUMN IF EXISTS payment_method; diff --git a/backend/migrations/tenant/000006_work_orders_payment_fields.up.sql b/backend/migrations/tenant/000006_work_orders_payment_fields.up.sql new file mode 100644 index 0000000..59286cd --- /dev/null +++ b/backend/migrations/tenant/000006_work_orders_payment_fields.up.sql @@ -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; diff --git a/backend/migrations/tenant/000007_wo_items_change_justification.down.sql b/backend/migrations/tenant/000007_wo_items_change_justification.down.sql new file mode 100644 index 0000000..c614587 --- /dev/null +++ b/backend/migrations/tenant/000007_wo_items_change_justification.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE wo_items + DROP COLUMN IF EXISTS change_justification; diff --git a/backend/migrations/tenant/000007_wo_items_change_justification.up.sql b/backend/migrations/tenant/000007_wo_items_change_justification.up.sql new file mode 100644 index 0000000..168e85a --- /dev/null +++ b/backend/migrations/tenant/000007_wo_items_change_justification.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE wo_items + ADD COLUMN IF NOT EXISTS change_justification TEXT; diff --git a/backend/pkg/pdf/pdf.go b/backend/pkg/pdf/pdf.go index 74e4612..6434d85 100644 --- a/backend/pkg/pdf/pdf.go +++ b/backend/pkg/pdf/pdf.go @@ -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 } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3f35b84..5e6fc51 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { > } /> } /> + } /> } /> } /> } /> + } /> + } /> + } /> } /> + } /> } /> diff --git a/frontend/src/components/layout/AdminLayout.tsx b/frontend/src/components/layout/AdminLayout.tsx index fb499c1..58e1389 100644 --- a/frontend/src/components/layout/AdminLayout.tsx +++ b/frontend/src/components/layout/AdminLayout.tsx @@ -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({ + queryKey: ['admin', 'settings'], + queryFn: () => apiFetch('/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 ( -
-