Compare commits

...

7 Commits

Author SHA1 Message Date
3ac8432360 Merge pull request 'CUB-116: Scaffold React frontend — Vite, TS, Tailwind' (#39) from agent/rex/CUB-116-scaffold-react-frontend-v2 into dev
All checks were successful
Dev Build / build-test (push) Successful in 1m34s
Reviewed-on: #39
2026-05-06 14:14:57 -04:00
f15597966f Merge branch 'dev' into agent/rex/CUB-116-scaffold-react-frontend-v2
All checks were successful
Dev Build / build-test (pull_request) Successful in 1m30s
2026-05-06 14:14:36 -04:00
a54fcdd371 CUB-116: scaffold React frontend with Vite, TypeScript, Tailwind
All checks were successful
Dev Build / build-test (pull_request) Successful in 1m26s
2026-05-06 14:02:57 -04:00
1b86d617cd Merge pull request 'CUB-111: Merge PostgreSQL schema and Go models (resolved)' (#38) from fix/CUB-111-merge into dev
Some checks failed
Dev Build / build-test (push) Failing after 1m56s
Reviewed-on: #38
2026-05-06 13:57:47 -04:00
otto-bot
fd39fff433 CUB-111: merge PostgreSQL schema and Go models into dev
Some checks failed
Dev Build / build-test (pull_request) Failing after 1m46s
2026-05-06 13:56:57 -04:00
2243859286 Merge pull request 'CUB-112: Scaffold Go backend' (#37) from agent/dex/CUB-112-go-scaffold into dev
Some checks failed
Dev Build / build-test (push) Failing after 1m49s
Reviewed-on: #37
2026-05-06 13:55:01 -04:00
dex-bot
3fe0850711 CUB-112: scaffold Go backend with Chi, pgx, health check
Some checks failed
Dev Build / build-test (pull_request) Failing after 1m39s
2026-05-06 12:20:31 -04:00
82 changed files with 2749 additions and 10757 deletions

View File

@@ -1,37 +1,25 @@
# ── Stage 1: Build ──────────────────────────────────────────
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
# Build stage
FROM golang:1.24-alpine AS builder
# Copy csproj first for layer caching — restores before copying source
COPY Extrudex.csproj .
RUN dotnet restore
# Copy the rest of the source
COPY . .
RUN dotnet publish Extrudex.csproj \
-c Release \
-o /app/publish \
--no-restore
# ── Stage 2: Runtime ────────────────────────────────────────
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
WORKDIR /app
# Install curl for health check (not included in aspnet base image)
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
# Copy go mod files first for caching
COPY go.mod go.sum ./
RUN go mod download
# Non-root user for security
RUN adduser --disabled-password --gecos "" appuser
USER appuser
# Copy source and build
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/server
# Copy published output from build stage
COPY --from=build /app/publish .
# Final stage
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
# Copy binary from builder
COPY --from=builder /app/server .
# ASP.NET Core listens on 8080 by default in .NET 8+
EXPOSE 8080
# Health check against /health endpoint
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl --fail http://localhost:8080/health || exit 1
ENTRYPOINT ["dotnet", "Extrudex.dll"]
CMD ["./server"]

View File

@@ -0,0 +1,80 @@
package main
import (
"context"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/CubeCraft-Creations/Extrudex/backend/internal/config"
"github.com/CubeCraft-Creations/Extrudex/backend/internal/db"
"github.com/CubeCraft-Creations/Extrudex/backend/internal/router"
)
func main() {
// Setup structured logging
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
})))
// Load configuration
cfg, err := config.Load()
if err != nil {
slog.Error("failed to load config", "error", err)
os.Exit(1)
}
slog.Info("config loaded", "port", cfg.Port, "cors_origin", cfg.CorsOrigin)
// Connect to database
dbPool, err := db.NewPool(cfg.DatabaseURL)
if err != nil {
slog.Error("failed to connect to database", "error", err)
os.Exit(1)
}
defer db.ClosePool(dbPool)
slog.Info("database connected")
// Create router
r := router.New(cfg, dbPool)
// Create HTTP server
server := &http.Server{
Addr: ":" + cfg.Port,
Handler: r,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
// Start server in goroutine
go func() {
slog.Info("server starting", "addr", server.Addr)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("server error", "error", err)
os.Exit(1)
}
}()
// Wait for shutdown signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
slog.Info("server shutting down")
// Graceful shutdown
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
slog.Error("server shutdown error", "error", err)
}
db.ClosePool(dbPool)
slog.Info("server stopped")
}

9
backend/go.mod Normal file
View File

@@ -0,0 +1,9 @@
module github.com/CubeCraft-Creations/Extrudex/backend
go 1.24
require (
github.com/go-chi/chi/v5 v5.2.0
github.com/jackc/pgx/v5 v5.7.4
github.com/kelseyhightower/envconfig v1.4.0
)

View File

@@ -0,0 +1,24 @@
package config
import (
"fmt"
"github.com/kelseyhightower/envconfig"
)
// Config holds all application configuration loaded from environment variables.
type Config struct {
DatabaseURL string `envconfig:"database_url" required:"true"`
Port string `envconfig:"port" default:"8080"`
CorsOrigin string `envconfig:"cors_origin" default:"*"`
LogLevel string `envconfig:"log_level" default:"info"`
}
// Load reads configuration from environment variables and returns a populated Config.
func Load() (*Config, error) {
var cfg Config
if err := envconfig.Process("", &cfg); err != nil {
return nil, fmt.Errorf("failed to load config: %w", err)
}
return &cfg, nil
}

34
backend/internal/db/db.go Normal file
View File

@@ -0,0 +1,34 @@
package db
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// NewPool creates a new pgx connection pool and verifies connectivity with a ping.
func NewPool(databaseURL string) (*pgxpool.Pool, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
return nil, fmt.Errorf("failed to create db pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("failed to ping db: %w", err)
}
return pool, nil
}
// ClosePool gracefully closes the connection pool.
func ClosePool(pool *pgxpool.Pool) {
if pool != nil {
pool.Close()
}
}

View File

@@ -0,0 +1,50 @@
package handlers
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// HealthHandler provides a health check endpoint that verifies database connectivity.
type HealthHandler struct {
dbPool *pgxpool.Pool
}
// NewHealthHandler creates a new HealthHandler with the given database pool.
func NewHealthHandler(dbPool *pgxpool.Pool) *HealthHandler {
return &HealthHandler{dbPool: dbPool}
}
// ServeHTTP handles GET /health requests.
func (h *HealthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
dbConnected := false
if h.dbPool != nil {
if err := h.dbPool.Ping(ctx); err == nil {
dbConnected = true
} else {
slog.Warn("health check db ping failed", "error", err)
}
}
resp := map[string]any{
"status": "ok",
"timestamp": time.Now().UTC().Format(time.RFC3339),
"db_connected": dbConnected,
}
w.Header().Set("Content-Type", "application/json")
if !dbConnected {
w.WriteHeader(http.StatusServiceUnavailable)
}
if err := json.NewEncoder(w).Encode(resp); err != nil {
slog.Error("failed to encode health response", "error", err)
}
}

View File

@@ -0,0 +1,162 @@
// Package models defines the Extrudex domain model structs.
// These map 1:1 to PostgreSQL tables with snake_case JSON serialization.
// Nullable fields use pointer types; all timestamps are time.Time.
package models
import "time"
// ============================================================================
// Lookup Tables
// ============================================================================
// PrinterType represents a printer technology category (fdm, resin, etc.).
type PrinterType struct {
ID int `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// JobStatus represents a print job lifecycle state.
type JobStatus struct {
ID int `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// MaterialBase represents a base material type (PLA, PETG, ABS, etc.).
// Density and temperature ranges are stored here for grams-calculation and slicing guidance.
type MaterialBase struct {
ID int `json:"id"`
Name string `json:"name"`
DensityGCm3 float64 `json:"density_g_cm3"`
ExtrusionTempMin *int `json:"extrusion_temp_min,omitempty"`
ExtrusionTempMax *int `json:"extrusion_temp_max,omitempty"`
BedTempMin *int `json:"bed_temp_min,omitempty"`
BedTempMax *int `json:"bed_temp_max,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// MaterialFinish represents the visual/texture finish (Basic, Silk, Matte, etc.).
type MaterialFinish struct {
ID int `json:"id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// MaterialModifier represents an additive property (Carbon Fiber, Wood-Filled, etc.).
type MaterialModifier struct {
ID int `json:"id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ============================================================================
// Core Entity Tables
// ============================================================================
// Printer represents a 3D printer in the fleet.
type Printer struct {
ID int `json:"id"`
Name string `json:"name"`
PrinterTypeID int `json:"printer_type_id"`
PrinterType *PrinterType `json:"printer_type,omitempty"` // populated on JOIN queries
Manufacturer *string `json:"manufacturer,omitempty"`
Model *string `json:"model,omitempty"`
MoonrakerURL *string `json:"moonraker_url,omitempty"`
MoonrakerAPIKey *string `json:"moonraker_api_key,omitempty"`
MQTTBrokerHost *string `json:"mqtt_broker_host,omitempty"`
MQTTTopicPrefix *string `json:"mqtt_topic_prefix,omitempty"`
MQTTTLSEnabled bool `json:"mqtt_tls_enabled"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// FilamentSpool represents a physical filament spool in inventory.
// material_finish_id defaults to 1 ("Basic"); material_modifier_id is optional.
// Grams are always physically measured values — grams_used is derived, not stored.
type FilamentSpool struct {
ID int `json:"id"`
Name string `json:"name"`
MaterialBaseID int `json:"material_base_id"`
MaterialBase *MaterialBase `json:"material_base,omitempty"` // JOIN
MaterialFinishID int `json:"material_finish_id"`
MaterialFinish *MaterialFinish `json:"material_finish,omitempty"` // JOIN
MaterialModifierID *int `json:"material_modifier_id,omitempty"`
MaterialModifier *MaterialModifier `json:"material_modifier,omitempty"` // JOIN
ColorHex string `json:"color_hex"`
Brand *string `json:"brand,omitempty"`
DiameterMM float64 `json:"diameter_mm"`
InitialGrams int `json:"initial_grams"`
RemainingGrams int `json:"remaining_grams"`
SpoolWeightGrams *int `json:"spool_weight_grams,omitempty"`
CostUSD *float64 `json:"cost_usd,omitempty"`
LowStockThresholdGrams int `json:"low_stock_threshold_grams"`
Notes *string `json:"notes,omitempty"`
Barcode *string `json:"barcode,omitempty"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// PrintJob represents a single print on a specific printer.
// The filament_spool_id is a convenience reference; multi-spool jobs track usage in usage_logs.
type PrintJob struct {
ID int `json:"id"`
PrinterID int `json:"printer_id"`
Printer *Printer `json:"printer,omitempty"` // JOIN
FilamentSpoolID *int `json:"filament_spool_id,omitempty"`
FilamentSpool *FilamentSpool `json:"filament_spool,omitempty"` // JOIN
JobName string `json:"job_name"`
FileName *string `json:"file_name,omitempty"`
JobStatusID int `json:"job_status_id"`
JobStatus *JobStatus `json:"job_status,omitempty"` // JOIN
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
DurationSeconds *int `json:"duration_seconds,omitempty"`
EstimatedDurationSeconds *int `json:"estimated_duration_seconds,omitempty"`
TotalMMExtruded *float64 `json:"total_mm_extruded,omitempty"`
TotalGramsUsed *float64 `json:"total_grams_used,omitempty"`
TotalCostUSD *float64 `json:"total_cost_usd,omitempty"`
Notes *string `json:"notes,omitempty"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// UsageLog records filament consumption for a specific spool during a print job.
// This is the atomic unit of filament tracking — grams are derived from mm_extruded.
type UsageLog struct {
ID int `json:"id"`
PrintJobID int `json:"print_job_id"`
PrintJob *PrintJob `json:"print_job,omitempty"` // JOIN
FilamentSpoolID int `json:"filament_spool_id"`
FilamentSpool *FilamentSpool `json:"filament_spool,omitempty"` // JOIN
MMExtruded float64 `json:"mm_extruded"`
GramsUsed float64 `json:"grams_used"`
CostUSD *float64 `json:"cost_usd,omitempty"`
LoggedAt time.Time `json:"logged_at"`
CreatedAt time.Time `json:"created_at"`
}
// ============================================================================
// Application Settings
// ============================================================================
// Setting represents a key-value application configuration entry.
// The value is stored as JSONB in PostgreSQL, allowing flexible typed config.
type Setting struct {
ID int `json:"id"`
Key string `json:"key"`
Value []byte `json:"value"` // raw JSON — marshalled/unmarshalled by caller
Description *string `json:"description,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View File

@@ -0,0 +1 @@
# Repositories

View File

@@ -0,0 +1,45 @@
package router
import (
"log/slog"
"net/http"
"time"
"github.com/CubeCraft-Creations/Extrudex/backend/internal/config"
"github.com/CubeCraft-Creations/Extrudex/backend/internal/handlers"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/jackc/pgx/v5/pgxpool"
)
// New creates and configures a Chi router with all middleware and handlers mounted.
func New(cfg *config.Config, dbPool *pgxpool.Pool) chi.Router {
r := chi.NewRouter()
// Middleware
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(60 * time.Second))
// CORS
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", cfg.CorsOrigin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
})
// Health check
healthHandler := handlers.NewHealthHandler(dbPool)
r.Get("/health", healthHandler.ServeHTTP)
return r
}

View File

@@ -0,0 +1 @@
# Services

View File

@@ -0,0 +1,19 @@
-- Migration: 000001_initial_schema (rollback)
-- Description: Drop all tables and indexes created in the initial schema migration
-- Author: Hex
-- Date: 2026-05-06
BEGIN;
DROP TABLE IF EXISTS usage_logs CASCADE;
DROP TABLE IF EXISTS print_jobs CASCADE;
DROP TABLE IF EXISTS filament_spools CASCADE;
DROP TABLE IF EXISTS printers CASCADE;
DROP TABLE IF EXISTS settings CASCADE;
DROP TABLE IF EXISTS material_modifiers CASCADE;
DROP TABLE IF EXISTS material_finishes CASCADE;
DROP TABLE IF EXISTS material_bases CASCADE;
DROP TABLE IF EXISTS job_statuses CASCADE;
DROP TABLE IF EXISTS printer_types CASCADE;
COMMIT;

View File

@@ -0,0 +1,231 @@
-- Migration: 000001_initial_schema
-- Description: Create initial Extrudex schema — lookup tables, core entities, and settings
-- Author: Hex
-- Date: 2026-05-06
--
-- Design decisions:
-- - Lookup tables for material_base, material_finish, material_modifier (no free-text enums)
-- - Lookup tables for printer_type and job_status (extensible, no hard-coded enum values)
-- - FK ON DELETE: RESTRICT on critical parents (material_base, material_finish, printer),
-- SET NULL on optional parents (modifier, spool on print_jobs),
-- CASCADE for usage_logs when parent job is deleted
-- - Soft-delete (deleted_at) on spools and print_jobs for safety
-- - JSONB config column on settings for flexible app-wide configuration
-- - All identifiers snake_case per project convention
BEGIN;
-- ============================================================================
-- Lookup Tables
-- ============================================================================
-- Printer types (fdm, resin, etc.) — extensible, not a raw enum
CREATE TABLE printer_types (
id SERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Job statuses (pending, printing, paused, completed, failed, cancelled)
CREATE TABLE job_statuses (
id SERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Material base types (PLA, PETG, ABS, TPU, ASA, Nylon, PC)
CREATE TABLE material_bases (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
density_g_cm3 DECIMAL(5,3) NOT NULL,
extrusion_temp_min INT,
extrusion_temp_max INT,
bed_temp_min INT,
bed_temp_max INT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Material finishes (Basic, Silk, Matte, Glossy, Satin)
CREATE TABLE material_finishes (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Material modifiers (Wood-Filled, Carbon Fiber, Glow-in-Dark, Marble)
CREATE TABLE material_modifiers (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ============================================================================
-- Core Entity Tables
-- ============================================================================
-- 3D printers in the fleet
CREATE TABLE printers (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
printer_type_id INT NOT NULL,
manufacturer VARCHAR(255),
model VARCHAR(255),
moonraker_url VARCHAR(512),
moonraker_api_key VARCHAR(512),
mqtt_broker_host VARCHAR(255),
mqtt_topic_prefix VARCHAR(255),
mqtt_tls_enabled BOOLEAN NOT NULL DEFAULT FALSE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT fk_printers_printer_type
FOREIGN KEY (printer_type_id) REFERENCES printer_types(id)
ON DELETE RESTRICT
);
-- Filament spools — the core inventory item
CREATE TABLE filament_spools (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
material_base_id INT NOT NULL,
material_finish_id INT NOT NULL DEFAULT 1, -- "Basic" (seed data populates this first)
material_modifier_id INT,
color_hex VARCHAR(7) NOT NULL CHECK (color_hex ~ '^#[0-9A-Fa-f]{6}$'),
brand VARCHAR(255),
diameter_mm DECIMAL(4,2) NOT NULL DEFAULT 1.75,
initial_grams INT NOT NULL CHECK (initial_grams > 0),
remaining_grams INT NOT NULL CHECK (remaining_grams >= 0),
spool_weight_grams INT, -- measured empty-spool weight (tare), nullable
cost_usd DECIMAL(10,2),
low_stock_threshold_grams INT NOT NULL DEFAULT 50,
notes TEXT,
barcode VARCHAR(255) UNIQUE,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT fk_spools_material_base
FOREIGN KEY (material_base_id) REFERENCES material_bases(id)
ON DELETE RESTRICT,
CONSTRAINT fk_spools_material_finish
FOREIGN KEY (material_finish_id) REFERENCES material_finishes(id)
ON DELETE RESTRICT,
CONSTRAINT fk_spools_material_modifier
FOREIGN KEY (material_modifier_id) REFERENCES material_modifiers(id)
ON DELETE SET NULL
);
-- Print jobs — each job is one print on one printer
CREATE TABLE print_jobs (
id SERIAL PRIMARY KEY,
printer_id INT NOT NULL,
filament_spool_id INT, -- nullable: a job may use multiple spools (captured in usage_logs)
job_name VARCHAR(255) NOT NULL,
file_name VARCHAR(512),
job_status_id INT NOT NULL DEFAULT 1, -- "pending"
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
duration_seconds INT,
estimated_duration_seconds INT,
total_mm_extruded DECIMAL(12,2),
total_grams_used DECIMAL(10,2),
total_cost_usd DECIMAL(10,4),
notes TEXT,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT fk_print_jobs_printer
FOREIGN KEY (printer_id) REFERENCES printers(id)
ON DELETE RESTRICT,
CONSTRAINT fk_print_jobs_spool
FOREIGN KEY (filament_spool_id) REFERENCES filament_spools(id)
ON DELETE SET NULL,
CONSTRAINT fk_print_jobs_status
FOREIGN KEY (job_status_id) REFERENCES job_statuses(id)
ON DELETE RESTRICT
);
-- Usage logs — granular tracking of filament consumed per job, per spool
CREATE TABLE usage_logs (
id SERIAL PRIMARY KEY,
print_job_id INT NOT NULL,
filament_spool_id INT NOT NULL,
mm_extruded DECIMAL(12,2) NOT NULL CHECK (mm_extruded > 0),
grams_used DECIMAL(10,2) NOT NULL CHECK (grams_used > 0),
cost_usd DECIMAL(10,4),
logged_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT fk_usage_logs_print_job
FOREIGN KEY (print_job_id) REFERENCES print_jobs(id)
ON DELETE CASCADE,
CONSTRAINT fk_usage_logs_spool
FOREIGN KEY (filament_spool_id) REFERENCES filament_spools(id)
ON DELETE RESTRICT
);
-- ============================================================================
-- Application Settings
-- ============================================================================
CREATE TABLE settings (
id SERIAL PRIMARY KEY,
key VARCHAR(255) NOT NULL UNIQUE,
value JSONB NOT NULL,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ============================================================================
-- Indexes
-- ============================================================================
-- Filament spools — query patterns: lookup by material, low-stock scans, barcode scans
CREATE INDEX ix_spools_material_base_id ON filament_spools(material_base_id);
CREATE INDEX ix_spools_material_finish_id ON filament_spools(material_finish_id);
CREATE INDEX ix_spools_material_modifier_id ON filament_spools(material_modifier_id);
CREATE INDEX ix_spools_remaining_grams ON filament_spools(remaining_grams)
WHERE deleted_at IS NULL; -- partial index: only active spools for low-stock queries
CREATE INDEX ix_spools_barcode ON filament_spools(barcode)
WHERE barcode IS NOT NULL AND deleted_at IS NULL;
CREATE INDEX ix_spools_deleted_at ON filament_spools(deleted_at)
WHERE deleted_at IS NOT NULL; -- small index for soft-delete filtering
-- Printers
CREATE INDEX ix_printers_printer_type_id ON printers(printer_type_id);
CREATE INDEX ix_printers_is_active ON printers(is_active)
WHERE is_active = TRUE; -- partial index for fleet dashboard queries
-- Print jobs — query by printer, status, date range, and soft-delete filter
CREATE INDEX ix_print_jobs_printer_id ON print_jobs(printer_id);
CREATE INDEX ix_print_jobs_spool_id ON print_jobs(filament_spool_id)
WHERE filament_spool_id IS NOT NULL;
CREATE INDEX ix_print_jobs_status_id ON print_jobs(job_status_id);
CREATE INDEX ix_print_jobs_created_at ON print_jobs(created_at DESC);
CREATE INDEX ix_print_jobs_deleted_at ON print_jobs(deleted_at)
WHERE deleted_at IS NOT NULL;
-- Usage logs — always queried by job or spool
CREATE INDEX ix_usage_logs_print_job_id ON usage_logs(print_job_id);
CREATE INDEX ix_usage_logs_spool_id ON usage_logs(filament_spool_id);
CREATE INDEX ix_usage_logs_logged_at ON usage_logs(logged_at DESC);
-- Settings — key lookups
CREATE INDEX ix_settings_key ON settings(key);
COMMIT;

View File

@@ -0,0 +1,15 @@
-- Migration: 000002_seed_data (rollback)
-- Description: Remove seed data inserted in 000002
-- Author: Hex
-- Date: 2026-05-06
BEGIN;
DELETE FROM settings WHERE key IN ('default_low_stock_threshold_grams', 'default_diameter_mm', 'filament_cross_section_area_mm2');
DELETE FROM material_modifiers WHERE id IN (1, 2, 3, 4);
DELETE FROM material_finishes WHERE id IN (1, 2, 3, 4, 5);
DELETE FROM material_bases WHERE id IN (1, 2, 3, 4, 5, 6, 7);
DELETE FROM job_statuses WHERE id IN (1, 2, 3, 4, 5, 6);
DELETE FROM printer_types WHERE id IN (1, 2);
COMMIT;

View File

@@ -0,0 +1,95 @@
-- Seed Data: Extrudex common reference data
-- Author: Hex
-- Date: 2026-05-06
--
-- IMPORTANT: IDs are explicitly assigned to satisfy the DEFAULT constraints:
-- - filament_spools.material_finish_id DEFAULT 1 ("Basic")
-- - print_jobs.job_status_id DEFAULT 1 ("pending")
--
-- Density values sourced from common manufacturer specifications.
-- Temperature ranges are conservative/typical; users can override per-spool.
BEGIN;
-- ============================================================================
-- Printer Types
-- ============================================================================
INSERT INTO printer_types (id, name) VALUES
(1, 'fdm'),
(2, 'resin')
ON CONFLICT (id) DO NOTHING;
-- Reset the sequence so future inserts start after our explicit IDs
SELECT setval('printer_types_id_seq', GREATEST(2, (SELECT MAX(id) FROM printer_types)));
-- ============================================================================
-- Job Statuses
-- ============================================================================
INSERT INTO job_statuses (id, name) VALUES
(1, 'pending'),
(2, 'printing'),
(3, 'paused'),
(4, 'completed'),
(5, 'failed'),
(6, 'cancelled')
ON CONFLICT (id) DO NOTHING;
SELECT setval('job_statuses_id_seq', GREATEST(6, (SELECT MAX(id) FROM job_statuses)));
-- ============================================================================
-- Material Bases (common filament types)
-- ============================================================================
INSERT INTO material_bases (id, name, density_g_cm3, extrusion_temp_min, extrusion_temp_max, bed_temp_min, bed_temp_max) VALUES
(1, 'PLA', 1.24, 190, 220, 0, 60),
(2, 'PETG', 1.27, 230, 250, 70, 90),
(3, 'ABS', 1.04, 230, 260, 90, 110),
(4, 'TPU', 1.21, 220, 250, 0, 60),
(5, 'ASA', 1.07, 240, 260, 90, 110),
(6, 'Nylon', 1.14, 240, 280, 70, 100),
(7, 'PC', 1.20, 260, 310, 90, 120)
ON CONFLICT (id) DO NOTHING;
SELECT setval('material_bases_id_seq', GREATEST(7, (SELECT MAX(id) FROM material_bases)));
-- ============================================================================
-- Material Finishes
-- ============================================================================
-- ID 1 = "Basic" is the default for new spools (DEFAULT 1 constraint)
INSERT INTO material_finishes (id, name, description) VALUES
(1, 'Basic', 'Standard solid-color filament with no special finish'),
(2, 'Silk', 'Glossy silk-like sheen, often used for decorative prints'),
(3, 'Matte', 'Flat non-reflective surface finish'),
(4, 'Glossy', 'High-shine reflective surface'),
(5, 'Satin', 'Semi-gloss between matte and glossy')
ON CONFLICT (id) DO NOTHING;
SELECT setval('material_finishes_id_seq', GREATEST(5, (SELECT MAX(id) FROM material_finishes)));
-- ============================================================================
-- Material Modifiers
-- ============================================================================
INSERT INTO material_modifiers (id, name, description) VALUES
(1, 'Wood-Filled', 'Contains wood fibers for natural wood-like appearance and texture'),
(2, 'Carbon Fiber', 'Reinforced with carbon fibers for increased stiffness and strength'),
(3, 'Glow-in-Dark', 'Phosphorescent additive that glows after exposure to light'),
(4, 'Marble', 'Contains specks for a stone-like marble appearance')
ON CONFLICT (id) DO NOTHING;
SELECT setval('material_modifiers_id_seq', GREATEST(4, (SELECT MAX(id) FROM material_modifiers)));
-- ============================================================================
-- Default Application Settings
-- ============================================================================
INSERT INTO settings (key, value, description) VALUES
('default_low_stock_threshold_grams', '50', 'Default grams threshold for low-stock alerts on new spools'),
('default_diameter_mm', '1.75', 'Default filament diameter for new spools (1.75mm is the modern standard)'),
('filament_cross_section_area_mm2', '2.405', 'Cross-sectional area for 1.75mm filament: π × (1.75/2)²')
ON CONFLICT (key) DO NOTHING;
COMMIT;

View File

@@ -1,11 +0,0 @@
node_modules
dist
.git
.gitignore
.angular
.vscode
*.md
.editorconfig
.prettierrc
src/test.ts
**/*.spec.ts

View File

@@ -1,17 +0,0 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

44
frontend/.gitignore vendored
View File

@@ -1,44 +0,0 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/mcp.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store
Thumbs.db

View File

@@ -1,12 +0,0 @@
{
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}

View File

@@ -1,4 +0,0 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

View File

@@ -1,20 +0,0 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}

View File

@@ -1,9 +0,0 @@
{
// For more information, visit: https://angular.dev/ai/mcp
"servers": {
"angular-cli": {
"command": "npx",
"args": ["-y", "@angular/cli", "mcp"]
}
}
}

View File

@@ -1,42 +0,0 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
}
]
}

View File

@@ -1,28 +1,14 @@
# Stage 1: Build the Angular application
FROM node:22-alpine AS build
# Build stage
FROM node:22-alpine AS builder
WORKDIR /app
# Copy package files first for better layer caching
COPY package.json package-lock.json ./
COPY package*.json ./
RUN npm ci
# Copy source and build
COPY . .
RUN npx ng build --configuration production
RUN npm run build
# Stage 2: Serve static files with nginx
# Serve stage
FROM nginx:alpine
# Remove default nginx config
RUN rm /etc/nginx/conf.d/default.conf
# Copy custom nginx config
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy built Angular artifacts from build stage
COPY --from=build /app/dist/frontend/browser /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,59 +0,0 @@
# Frontend
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.8.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

View File

@@ -1,78 +0,0 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm"
},
"newProjectRoot": "projects",
"projects": {
"frontend": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.scss"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "frontend:build:production"
},
"development": {
"buildTarget": "frontend:build:development"
}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test"
}
}
}
}
}

28
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Extrudex</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,42 +1,23 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
gzip_min_length 256;
# Angular SPA — fallback to index.html for client-side routing
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets aggressively
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Proxy API requests to backend
# Uses resolver so nginx doesn't crash if backend isn't available at startup
resolver 127.0.0.11 valid=30s ipv6=off;
set $backend "extrudex-api:8080";
location /api/ {
proxy_pass http://$backend;
proxy_pass http://backend:8080/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Health check endpoint
location /health {
access_log off;
return 200 "ok";
add_header Content-Type text/plain;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,35 +1,35 @@
{
"name": "frontend",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"name": "extrudex-frontend",
"private": true,
"packageManager": "npm@11.11.0",
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@angular/cdk": "^21.2.8",
"@angular/common": "^21.2.0",
"@angular/compiler": "^21.2.0",
"@angular/core": "^21.2.0",
"@angular/forms": "^21.2.0",
"@angular/material": "^21.2.8",
"@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
"@tanstack/react-query": "^5.60.0",
"axios": "^1.7.0",
"lucide-react": "^0.460.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0"
},
"devDependencies": {
"@angular/build": "^21.2.8",
"@angular/cli": "^21.2.8",
"@angular/compiler-cli": "^21.2.0",
"@vitest/browser-playwright": "^4.1.5",
"jsdom": "^28.0.0",
"prettier": "^3.8.1",
"typescript": "~5.9.2",
"vitest": "^4.0.8"
"@tailwindcss/postcss": "^4.2.4",
"@tailwindcss/vite": "^4.2.4",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.20",
"eslint": "^9.15.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.14",
"postcss": "^8.4.49",
"tailwindcss": "^4.0.0",
"typescript": "~5.6.0",
"vite": "^6.0.0"
}
}

View File

@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

28
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,28 @@
import { useState, useEffect } from 'react'
function App() {
const [health, setHealth] = useState<any>(null)
useEffect(() => {
fetch('/api/health')
.then(r => r.json())
.then(setHealth)
.catch(console.error)
}, [])
return (
<div className="min-h-screen flex items-center justify-center">
<div className="p-6 rounded-lg bg-slate-800 shadow-xl max-w-md w-full">
<h1 className="text-2xl font-bold mb-4 text-emerald-400">Extrudex</h1>
<p className="text-slate-300 mb-4">React frontend scaffold</p>
{health && (
<pre className="text-xs bg-slate-900 p-3 rounded overflow-auto">
{JSON.stringify(health, null, 2)}
</pre>
)}
</div>
</div>
)
}
export default App

View File

@@ -1,13 +0,0 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideHttpClient(),
]
};

View File

@@ -1,13 +0,0 @@
<!-- Extrudex — Homepage (Main Hub) -->
<main class="main-content">
<h1 class="sr-only">Extrudex Dashboard</h1>
<!-- Status Summary Bar — fleet-wide health at a glance -->
<app-dashboard-summary></app-dashboard-summary>
<!-- Inventory Summary — filament metrics at a glance -->
<app-inventory-summary></app-inventory-summary>
<!-- Filament Inventory — routed view -->
<router-outlet />
</main>

View File

@@ -1,9 +0,0 @@
import { Routes } from '@angular/router';
import { FilamentTableComponent } from './components/filament-table/filament-table.component';
export const routes: Routes = [
{
path: '',
component: FilamentTableComponent,
},
];

View File

@@ -1,27 +0,0 @@
:host {
display: block;
min-height: 100vh;
background: #1a1a2e;
color: #e0e0e0;
font-family: 'Inter', 'Segoe UI', Roboto, sans-serif;
}
.main-content {
padding: 16px;
@media (min-width: 800px) {
padding: 24px;
}
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}

View File

@@ -1,23 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it('should render title', async () => {
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Extrudex Dashboard');
});
});

View File

@@ -1,29 +0,0 @@
import { Component, ViewChild } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { DashboardSummaryComponent } from './components/dashboard-summary/dashboard-summary.component';
import { InventorySummaryComponent } from './components/inventory-summary/inventory-summary.component';
import { AgentSummary, SystemHealth } from './models/agent.model';
@Component({
selector: 'app-root',
imports: [RouterOutlet, DashboardSummaryComponent, InventorySummaryComponent],
templateUrl: './app.html',
styleUrl: './app.scss'
})
export class App {
@ViewChild(DashboardSummaryComponent) summaryComponent!: DashboardSummaryComponent;
/** Sample data for development — will be replaced by real service data */
readonly sampleSummary: AgentSummary = {
total: 7,
active: 4,
idle: 1,
thinking: 1,
error: 1,
};
readonly sampleHealth: SystemHealth = {
connected: true,
status: 'healthy',
};
}

View File

@@ -1,63 +0,0 @@
<!-- Dashboard Summary Bar — Fleet-wide health at a glance -->
<section class="dashboard-summary" role="status" aria-label="Dashboard summary">
<!-- System Health Indicator -->
<div class="summary-item health-indicator"
[class.healthy]="health().status === 'healthy'"
[class.degraded]="isDegraded()"
[class.down]="isDown()"
[matTooltip]="statusLabel()"
matTooltipPosition="below">
<span class="connection-dot" [class.connected]="health().connected"></span>
<span class="health-label">{{ statusLabel() }}</span>
</div>
<!-- Total Active Agents -->
<div class="summary-item" matTooltip="Total active agents" matTooltipPosition="below">
<mat-icon aria-hidden="true">smart_toy</mat-icon>
<span class="metric-value">{{ summary().active }} / {{ summary().total }}</span>
<span class="metric-label">Active</span>
</div>
<!-- Status Breakdown -->
<div class="summary-item status-breakdown">
<mat-chip-set aria-label="Agent status breakdown">
<mat-chip
class="status-chip chip-active"
[class.has-count]="summary().active > 0"
matTooltip="Active agents">
<mat-icon matChipStart>check_circle</mat-icon>
<span class="chip-count">{{ summary().active }}</span>
<span class="chip-label">Active</span>
</mat-chip>
<mat-chip
class="status-chip chip-idle"
[class.has-count]="summary().idle > 0"
matTooltip="Idle agents">
<mat-icon matChipStart>pause_circle</mat-icon>
<span class="chip-count">{{ summary().idle }}</span>
<span class="chip-label">Idle</span>
</mat-chip>
<mat-chip
class="status-chip chip-thinking"
[class.has-count]="summary().thinking > 0"
matTooltip="Thinking agents">
<mat-icon matChipStart>psychology</mat-icon>
<span class="chip-count">{{ summary().thinking }}</span>
<span class="chip-label">Thinking</span>
</mat-chip>
<mat-chip
class="status-chip chip-error"
[class.has-count]="hasErrors()"
matTooltip="Agents in error">
<mat-icon matChipStart>error</mat-icon>
<span class="chip-count">{{ summary().error }}</span>
<span class="chip-label">Error</span>
</mat-chip>
</mat-chip-set>
</div>
</section>

View File

@@ -1,174 +0,0 @@
/**
* Dashboard Summary Component Styles
* Touch-optimized for kiosk (Raspberry Pi 5) and mobile PWA
* Uses Angular Material utility classes where possible
*/
// Touch-optimized sizing
$touch-target-min: 48px;
$kiosk-font-primary: 20px;
$mobile-font-primary: 16px;
$spacing-unit: 8px;
// Status colors — high contrast for workshop/bright environments
$color-active: #4ade70; // Green — printing/active
$color-idle: #94a3b8; // Gray — idle/offline
$color-thinking: #60a5fa; // Blue — thinking/processing
$color-error: #f87171; // Red — error/failed
$color-connected: #4ade70; // Green — SignalR connected
$color-disconnected: #f87171; // Red — disconnected
.dashboard-summary {
display: flex;
align-items: center;
gap: $spacing-unit * 2;
padding: $spacing-unit * 2;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
// Responsive: on mobile, allow horizontal scroll
@media (max-width: 480px) {
padding: $spacing-unit;
gap: $spacing-unit;
}
}
.summary-item {
display: flex;
align-items: center;
gap: $spacing-unit;
min-height: $touch-target-min;
white-space: nowrap;
.metric-value {
font-size: $kiosk-font-primary;
font-weight: 600;
line-height: 1.2;
@media (max-width: 480px) {
font-size: $mobile-font-primary;
}
}
.metric-label {
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
text-transform: uppercase;
letter-spacing: 0.05em;
@media (max-width: 480px) {
font-size: 10px;
}
}
}
// Health indicator
.health-indicator {
padding: $spacing-unit $spacing-unit * 2;
border-radius: 24px;
transition: background-color 0.3s ease;
&.healthy {
background-color: rgba($color-active, 0.15);
}
&.degraded {
background-color: rgba($color-thinking, 0.15);
}
&.down {
background-color: rgba($color-error, 0.15);
}
.connection-dot {
width: 12px;
height: 12px;
border-radius: 50%;
display: inline-block;
transition: background-color 0.3s ease;
&.connected {
background-color: $color-connected;
box-shadow: 0 0 6px $color-connected;
}
&:not(.connected) {
background-color: $color-disconnected;
box-shadow: 0 0 6px $color-disconnected;
}
}
.health-label {
font-size: 14px;
font-weight: 500;
@media (max-width: 480px) {
font-size: 12px;
}
}
}
// Status breakdown chips
.status-breakdown {
flex-shrink: 0;
}
.status-chip {
min-height: $touch-target-min !important;
font-size: 14px !important;
@media (max-width: 480px) {
min-height: 40px !important;
font-size: 12px !important;
padding: 0 8px !important;
}
.chip-count {
font-weight: 700;
margin: 0 4px;
}
.chip-label {
font-size: 12px;
opacity: 0.8;
}
mat-icon {
font-size: 18px !important;
width: 18px !important;
height: 18px !important;
}
}
// Status chip color variants
.chip-active {
--mdc-chip-outline-color: #{$color-active};
&.has-count {
background-color: rgba($color-active, 0.15) !important;
}
}
.chip-idle {
--mdc-chip-outline-color: #{$color-idle};
&.has-count {
background-color: rgba($color-idle, 0.15) !important;
}
}
.chip-thinking {
--mdc-chip-outline-color: #{$color-thinking};
&.has-count {
background-color: rgba($color-thinking, 0.15) !important;
}
}
.chip-error {
--mdc-chip-outline-color: #{$color-error};
&.has-count {
background-color: rgba($color-error, 0.2) !important;
}
}

View File

@@ -1,103 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DashboardSummaryComponent } from './dashboard-summary.component';
import { AgentSummary, SystemHealth } from '../../models/agent.model';
describe('DashboardSummaryComponent', () => {
let component: DashboardSummaryComponent;
let fixture: ComponentFixture<DashboardSummaryComponent>;
const mockSummary: AgentSummary = {
total: 7,
active: 4,
idle: 1,
thinking: 1,
error: 1,
};
const mockHealthy: SystemHealth = {
connected: true,
status: 'healthy',
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DashboardSummaryComponent],
}).compileComponents();
fixture = TestBed.createComponent(DashboardSummaryComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should default to zeroed summary', () => {
const summary = component.summary();
expect(summary.total).toBe(0);
expect(summary.active).toBe(0);
expect(summary.idle).toBe(0);
expect(summary.thinking).toBe(0);
expect(summary.error).toBe(0);
});
it('should default to disconnected/down health', () => {
const health = component.health();
expect(health.connected).toBe(false);
expect(health.status).toBe('down');
});
it('should update summary data', () => {
component.updateSummary(mockSummary);
expect(component.summary()).toEqual(mockSummary);
});
it('should update health data', () => {
component.updateHealth(mockHealthy);
expect(component.health()).toEqual(mockHealthy);
});
it('should compute hasErrors correctly', () => {
expect(component.hasErrors()).toBe(false);
component.updateSummary({ ...mockSummary, error: 2 });
expect(component.hasErrors()).toBe(true);
});
it('should compute connectionColor correctly', () => {
expect(component.connectionColor()).toBe('disconnected');
component.updateHealth({ connected: true, status: 'healthy' });
expect(component.connectionColor()).toBe('connected');
});
it('should compute statusLabel for each state', () => {
component.updateHealth({ connected: true, status: 'healthy' });
expect(component.statusLabel()).toBe('All Systems Go');
component.updateHealth({ connected: true, status: 'degraded' });
expect(component.statusLabel()).toBe('Degraded');
component.updateHealth({ connected: false, status: 'down' });
expect(component.statusLabel()).toBe('Offline');
});
it('should render summary values in template', () => {
component.updateSummary(mockSummary);
component.updateHealth(mockHealthy);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('4 / 7');
expect(compiled.textContent).toContain('Active');
expect(compiled.textContent).toContain('All Systems Go');
});
it('should render status breakdown chips', () => {
component.updateSummary(mockSummary);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('4'); // active count
expect(compiled.textContent).toContain('1'); // idle count (multiple)
expect(compiled.textContent).toContain('Error');
});
});

View File

@@ -1,80 +0,0 @@
import { ChangeDetectionStrategy, Component, Input, OnDestroy, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatChipsModule } from '@angular/material/chips';
import { MatTooltipModule } from '@angular/material/tooltip';
import { AgentSummary, SystemHealth } from '../../models/agent.model';
@Component({
selector: 'app-dashboard-summary',
standalone: true,
imports: [
CommonModule,
MatButtonModule,
MatIconModule,
MatChipsModule,
MatTooltipModule,
],
templateUrl: './dashboard-summary.component.html',
styleUrls: ['./dashboard-summary.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DashboardSummaryComponent implements OnDestroy {
/** Agent summary data — reactive signal, updatable via updateSummary() */
readonly summary = signal<AgentSummary>({
total: 0,
active: 0,
idle: 0,
thinking: 0,
error: 0,
});
/** System health data — reactive signal, updatable via updateHealth() */
readonly health = signal<SystemHealth>({
connected: false,
status: 'down',
});
/** Computed signal: whether there are errors to highlight */
readonly hasErrors = computed(() => this.summary().error > 0);
/** Computed signal: whether system is degraded */
readonly isDegraded = computed(() => this.health().status === 'degraded');
/** Computed signal: whether system is down */
readonly isDown = computed(() => this.health().status === 'down');
/** Computed signal: connection indicator color */
readonly connectionColor = computed(() =>
this.health().connected ? 'connected' : 'disconnected'
);
/** Computed signal: overall status label */
readonly statusLabel = computed(() => {
const h = this.health();
if (h.status === 'healthy') return 'All Systems Go';
if (h.status === 'degraded') return 'Degraded';
return 'Offline';
});
/**
* Update the agent summary. Called by the parent or a service
* when new data arrives (e.g., via SignalR).
*/
updateSummary(data: AgentSummary): void {
this.summary.set(data);
}
/**
* Update the system health. Called by the parent or a service
* when the connection state changes.
*/
updateHealth(data: SystemHealth): void {
this.health.set(data);
}
ngOnDestroy(): void {
// Cleanup handled by signals — no manual subscription teardown needed
}
}

View File

@@ -1,225 +0,0 @@
<!-- Filament Add/Edit Dialog — Angular Material Dialog -->
<mat-dialog-content class="filament-dialog-content">
<!-- Dialog Title -->
<h2 mat-dialog-title>{{ dialogTitle() }}</h2>
<!-- Loading state for lookup data -->
@if (lookupsLoading()) {
<div class="dialog-loading" role="status" aria-label="Loading material options">
<mat-spinner diameter="32"></mat-spinner>
<p>Loading material options…</p>
</div>
}
<!-- Form -->
@if (!lookupsLoading()) {
<form [formGroup]="form" class="filament-form" (ngSubmit)="save()">
<!-- Server Error Banner -->
@if (serverError()) {
<div class="error-banner" role="alert">
<mat-icon aria-hidden="true">error</mat-icon>
<span>{{ serverError() }}</span>
</div>
}
<!-- ── Material Section ──────────────────────────────── -->
<div class="form-section">
<h3 class="section-title">Material</h3>
<!-- Base Material -->
<mat-form-field appearance="outline" class="form-field full-width">
<mat-label>Base Material</mat-label>
<mat-select formControlName="materialBaseId" required aria-label="Base material">
@for (base of materialBases(); track base.id) {
<mat-option [value]="base.id">{{ base.name }}</mat-option>
}
</mat-select>
@if (form.get('materialBaseId')!.hasError('required') && form.get('materialBaseId')!.touched) {
<mat-error>Base material is required</mat-error>
}
</mat-form-field>
<!-- Finish -->
<mat-form-field appearance="outline" class="form-field full-width">
<mat-label>Finish</mat-label>
<mat-select formControlName="materialFinishId" required aria-label="Material finish">
<mat-option [value]="''" disabled>Select a base material first</mat-option>
@for (finish of filteredFinishes(); track finish.id) {
<mat-option [value]="finish.id">{{ finish.name }}</mat-option>
}
</mat-select>
@if (form.get('materialFinishId')!.hasError('required') && form.get('materialFinishId')!.touched) {
<mat-error>Finish is required</mat-error>
}
@if (filteredFinishes().length === 0 && form.get('materialBaseId')!.value) {
<mat-hint>No finishes available for this material</mat-hint>
}
</mat-form-field>
<!-- Modifier (optional) -->
<mat-form-field appearance="outline" class="form-field full-width">
<mat-label>Modifier (optional)</mat-label>
<mat-select formControlName="materialModifierId" aria-label="Material modifier">
<mat-option [value]="null">None</mat-option>
@for (modifier of filteredModifiers(); track modifier.id) {
<mat-option [value]="modifier.id">{{ modifier.name }}</mat-option>
}
</mat-select>
@if (filteredModifiers().length === 0 && form.get('materialBaseId')!.value) {
<mat-hint>No modifiers available for this material</mat-hint>
}
</mat-form-field>
</div>
<!-- ── Spool Details Section ──────────────────────────── -->
<div class="form-section">
<h3 class="section-title">Spool Details</h3>
<!-- Brand -->
<mat-form-field appearance="outline" class="form-field full-width">
<mat-label>Brand</mat-label>
<input matInput formControlName="brand" required maxlength="200"
placeholder="e.g., Bambu Lab, Polymaker" aria-label="Brand" />
@if (form.get('brand')!.hasError('required') && form.get('brand')!.touched) {
<mat-error>Brand is required</mat-error>
}
</mat-form-field>
<!-- Serial -->
<mat-form-field appearance="outline" class="form-field full-width">
<mat-label>Serial Number</mat-label>
<input matInput formControlName="spoolSerial" required maxlength="200"
placeholder="e.g., SN-001" aria-label="Serial number" />
@if (form.get('spoolSerial')!.hasError('required') && form.get('spoolSerial')!.touched) {
<mat-error>Serial number is required</mat-error>
}
</mat-form-field>
<!-- Color Name + Color Hex (side by side) -->
<div class="form-row">
<mat-form-field appearance="outline" class="form-field">
<mat-label>Color Name</mat-label>
<input matInput formControlName="colorName" required maxlength="200"
placeholder="e.g., Fire Engine Red" aria-label="Color name" />
@if (form.get('colorName')!.hasError('required') && form.get('colorName')!.touched) {
<mat-error>Color name is required</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="form-field color-hex-field">
<mat-label>Color Hex</mat-label>
<input matInput formControlName="colorHex" required
placeholder="#FF0000" maxlength="7" aria-label="Color hex code" />
<span matTextSuffix class="color-preview">
<span class="color-swatch-mini" [style.background-color]="form.get('colorHex')!.value"></span>
</span>
@if (form.get('colorHex')!.hasError('required') && form.get('colorHex')!.touched) {
<mat-error>Color hex is required</mat-error>
}
@if (form.get('colorHex')!.hasError('pattern') && form.get('colorHex')!.touched) {
<mat-error>Must be #RRGGBB format</mat-error>
}
</mat-form-field>
</div>
</div>
<!-- ── Weight & Dimensions Section ────────────────────── -->
<div class="form-section">
<h3 class="section-title">Weight &amp; Dimensions</h3>
<div class="form-row">
<!-- Diameter -->
<mat-form-field appearance="outline" class="form-field">
<mat-label>Diameter (mm)</mat-label>
<input matInput type="number" formControlName="filamentDiameterMm" required
min="0.1" max="10" step="0.01" aria-label="Filament diameter in mm" />
@if (form.get('filamentDiameterMm')!.hasError('required') && form.get('filamentDiameterMm')!.touched) {
<mat-error>Diameter is required</mat-error>
}
</mat-form-field>
</div>
<div class="form-row">
<!-- Total Weight -->
<mat-form-field appearance="outline" class="form-field">
<mat-label>Total Weight (g)</mat-label>
<input matInput type="number" formControlName="weightTotalGrams" required
min="0.01" max="100000" step="1" aria-label="Total spool weight in grams" />
<mat-hint>Full spool weight</mat-hint>
@if (form.get('weightTotalGrams')!.hasError('required') && form.get('weightTotalGrams')!.touched) {
<mat-error>Total weight is required</mat-error>
}
</mat-form-field>
<!-- Remaining Weight -->
<mat-form-field appearance="outline" class="form-field">
<mat-label>Remaining Weight (g)</mat-label>
<input matInput type="number" formControlName="weightRemainingGrams" required
min="0" max="100000" step="1" aria-label="Remaining weight in grams" />
<mat-hint>Current remaining</mat-hint>
@if (form.get('weightRemainingGrams')!.hasError('required') && form.get('weightRemainingGrams')!.touched) {
<mat-error>Remaining weight is required</mat-error>
}
@if (form.get('weightRemainingGrams')!.hasError('exceedsTotal')) {
<mat-error>Cannot exceed total weight</mat-error>
}
</mat-form-field>
</div>
</div>
<!-- ── Purchase & Status Section ──────────────────────── -->
<div class="form-section">
<h3 class="section-title">Purchase &amp; Status</h3>
<div class="form-row">
<!-- Purchase Price -->
<mat-form-field appearance="outline" class="form-field">
<mat-label>Price</mat-label>
<input matInput type="number" formControlName="purchasePrice"
min="0" max="1000000" step="0.01"
placeholder="e.g., 25.00" aria-label="Purchase price" />
<span matTextSuffix>$</span>
@if (form.get('purchasePrice')!.hasError('min') && form.get('purchasePrice')!.touched) {
<mat-error>Price must be non-negative</mat-error>
}
</mat-form-field>
<!-- Purchase Date -->
<mat-form-field appearance="outline" class="form-field">
<mat-label>Purchase Date</mat-label>
<input matInput [matDatepicker]="picker" formControlName="purchaseDate"
aria-label="Purchase date" />
<mat-datepicker-toggle matIconSuffix [for]="picker"></mat-datepicker-toggle>
<mat-datepicker #picker></mat-datepicker>
</mat-form-field>
</div>
<!-- Active Status -->
<div class="checkbox-row">
<mat-checkbox formControlName="isActive" aria-label="Active status">
Spool is active and available for use
</mat-checkbox>
</div>
</div>
</form>
}
</mat-dialog-content>
<!-- Dialog Actions -->
<mat-dialog-actions align="end">
<button mat-button type="button" (click)="cancel()" [disabled]="saving()"
aria-label="Cancel">
Cancel
</button>
<button mat-raised-button color="primary" type="button" (click)="save()"
[disabled]="saving() || form.invalid" aria-label="Save filament">
@if (saving()) {
<mat-spinner diameter="20" class="btn-spinner"></mat-spinner>
}
{{ isEditMode() ? 'Save Changes' : 'Add Filament' }}
</button>
</mat-dialog-actions>

View File

@@ -1,175 +0,0 @@
/**
* Filament Dialog Styles
* Touch-optimized for kiosk (Raspberry Pi 5) and mobile PWA
*/
$touch-target-min: 48px;
$spacing-unit: 8px;
$color-error: #ef4444;
// ── Dialog Layout ──────────────────────────────────────────
.filament-dialog-content {
overflow-y: auto;
max-height: 70vh;
padding: 0 $spacing-unit * 2;
@media (max-width: 480px) {
padding: 0 $spacing-unit;
}
}
[mat-dialog-title] {
margin: 0 0 $spacing-unit * 2 0;
padding: $spacing-unit * 2 0 0 0;
font-size: 20px;
font-weight: 600;
}
// ── Loading State ──────────────────────────────────────────
.dialog-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px $spacing-unit * 2;
color: var(--mat-sys-on-surface-variant);
p {
margin-top: $spacing-unit * 2;
font-size: 14px;
}
}
// ── Error Banner ───────────────────────────────────────────
.error-banner {
display: flex;
align-items: center;
gap: $spacing-unit;
padding: $spacing-unit * 1.5 $spacing-unit * 2;
border-radius: 8px;
margin-bottom: $spacing-unit * 2;
background-color: rgba($color-error, 0.12);
color: $color-error;
border: 1px solid rgba($color-error, 0.3);
font-size: 14px;
font-weight: 500;
mat-icon {
font-size: 20px !important;
width: 20px !important;
height: 20px !important;
}
}
// ── Form Sections ──────────────────────────────────────────
.form-section {
margin-bottom: $spacing-unit * 3;
.section-title {
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--mat-sys-on-surface-variant);
margin: 0 0 $spacing-unit * 1.5 0;
padding-bottom: $spacing-unit * 0.5;
border-bottom: 1px solid var(--mat-sys-outline-variant);
}
}
.filament-form {
display: flex;
flex-direction: column;
gap: $spacing-unit;
}
// ── Form Fields ────────────────────────────────────────────
.form-field {
width: 100%;
// Touch target sizing
.mat-mdc-form-field-subscript-wrapper {
min-height: 20px;
}
}
.form-row {
display: flex;
gap: $spacing-unit * 2;
width: 100%;
.form-field {
flex: 1;
}
@media (max-width: 480px) {
flex-direction: column;
gap: 0;
}
}
// ── Color Hex Preview ──────────────────────────────────────
.color-hex-field {
max-width: 180px;
@media (max-width: 480px) {
max-width: 100%;
}
}
.color-preview {
display: inline-flex;
align-items: center;
margin-left: 4px;
}
.color-swatch-mini {
display: inline-block;
width: 20px;
height: 20px;
border-radius: 4px;
border: 1px solid rgba(0, 0, 0, 0.12);
vertical-align: middle;
}
// ── Checkbox Row ───────────────────────────────────────────
.checkbox-row {
display: flex;
align-items: center;
padding: $spacing-unit 0;
mat-checkbox {
min-height: $touch-target-min;
display: flex;
align-items: center;
}
}
// ── Save Button Spinner ────────────────────────────────────
mat-dialog-actions {
padding: $spacing-unit $spacing-unit * 2 $spacing-unit * 2;
gap: $spacing-unit;
button {
min-height: $touch-target-min;
min-width: 100px;
}
}
.btn-spinner {
display: inline-block;
margin-right: $spacing-unit;
vertical-align: middle;
circle {
stroke: currentColor;
}
}

View File

@@ -1,277 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
inject,
signal,
computed,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatNativeDateModule } from '@angular/material/core';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { Filament } from '../../models/filament.model';
import {
MaterialBase,
MaterialFinish,
MaterialModifier,
} from '../../models/material.model';
import {
FilamentService,
CreateFilamentRequest,
UpdateFilamentRequest,
} from '../../services/filament.service';
/** Data passed into the dialog from the opener. */
export interface FilamentDialogData {
/** If provided, the dialog opens in edit mode with pre-populated fields. */
filament?: Filament;
}
@Component({
selector: 'app-filament-dialog',
standalone: true,
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatDatepickerModule,
MatNativeDateModule,
MatCheckboxModule,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule,
MatTooltipModule,
],
templateUrl: './filament-dialog.component.html',
styleUrl: './filament-dialog.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FilamentDialogComponent {
private readonly dialogRef = inject(MatDialogRef<FilamentDialogComponent>);
private readonly data = inject<FilamentDialogData>(MAT_DIALOG_DATA);
private readonly fb = inject(FormBuilder);
private readonly filamentService = inject(FilamentService);
/** Whether this dialog is in edit mode (has existing filament data). */
readonly isEditMode = computed(() => !!this.data.filament);
/** Dialog title based on mode. */
readonly dialogTitle = computed(() =>
this.isEditMode() ? 'Edit Filament' : 'Add Filament'
);
// ── Lookup data signals ──────────────────────────────────
/** All material bases for the base material dropdown. */
readonly materialBases = signal<MaterialBase[]>([]);
/** Material finishes filtered by selected base material. */
readonly filteredFinishes = signal<MaterialFinish[]>([]);
/** Material modifiers filtered by selected base material. */
readonly filteredModifiers = signal<MaterialModifier[]>([]);
/** Whether material lookups are loading. */
readonly lookupsLoading = signal(true);
/** Whether the save operation is in progress. */
readonly saving = signal(false);
/** Server error message, if any. */
readonly serverError = signal<string | null>(null);
// ── Form ─────────────────────────────────────────────────
readonly form: FormGroup = this.fb.group({
materialBaseId: ['', Validators.required],
materialFinishId: ['', Validators.required],
materialModifierId: [null],
brand: ['', [Validators.required, Validators.maxLength(200)]],
colorName: ['', [Validators.required, Validators.maxLength(200)]],
colorHex: ['#000000', [Validators.required, Validators.pattern(/^#[0-9A-Fa-f]{6}$/)]],
weightTotalGrams: [1000, [Validators.required, Validators.min(0.01), Validators.max(100000)]],
weightRemainingGrams: [1000, [Validators.required, Validators.min(0), Validators.max(100000)]],
filamentDiameterMm: [1.75, [Validators.required, Validators.min(0.1), Validators.max(10)]],
spoolSerial: ['', [Validators.required, Validators.maxLength(200)]],
purchasePrice: [null, [Validators.min(0), Validators.max(1000000)]],
purchaseDate: [null],
isActive: [true],
});
constructor() {
this.loadLookups();
this.patchFormIfEditing();
this.setupCascadingFilters();
}
// ── Data loading ─────────────────────────────────────────
/** Load material bases, finishes, and modifiers for dropdowns. */
private loadLookups(): void {
this.lookupsLoading.set(true);
this.filamentService.getMaterialBases().subscribe({
next: (bases) => {
this.materialBases.set(bases);
this.lookupsLoading.set(false);
},
error: () => {
this.lookupsLoading.set(false);
this.serverError.set('Failed to load material options. Please try again.');
},
});
}
/** Pre-populate form fields when editing an existing filament. */
private patchFormIfEditing(): void {
if (this.data.filament) {
const f = this.data.filament;
this.form.patchValue({
materialBaseId: f.materialBaseId,
materialFinishId: f.materialFinishId,
materialModifierId: f.materialModifierId,
brand: f.brand,
colorName: f.colorName,
colorHex: f.colorHex,
weightTotalGrams: f.weightTotalGrams,
weightRemainingGrams: f.weightRemainingGrams,
filamentDiameterMm: f.filamentDiameterMm,
spoolSerial: f.spoolSerial,
purchasePrice: f.purchasePrice,
purchaseDate: f.purchaseDate ? new Date(f.purchaseDate) : null,
isActive: f.isActive,
});
}
}
/** Set up cascading filter: when base material changes, reload finishes & modifiers. */
private setupCascadingFilters(): void {
this.form.get('materialBaseId')!.valueChanges.subscribe((baseId: string | null) => {
// Clear dependent selections when base changes
this.form.get('materialFinishId')!.setValue('');
this.form.get('materialModifierId')!.setValue(null);
this.filteredFinishes.set([]);
this.filteredModifiers.set([]);
if (!baseId) return;
this.filamentService.getMaterialFinishes(baseId).subscribe({
next: (finishes) => this.filteredFinishes.set(finishes),
error: () => this.filteredFinishes.set([]),
});
this.filamentService.getMaterialModifiers(baseId).subscribe({
next: (modifiers) => this.filteredModifiers.set(modifiers),
error: () => this.filteredModifiers.set([]),
});
});
// If editing, trigger the cascading load for the pre-selected base
if (this.data.filament) {
const baseId = this.data.filament.materialBaseId;
// We need to load finishes and modifiers for the pre-selected base
// but also re-select the original finish and modifier after loading
this.filamentService.getMaterialFinishes(baseId).subscribe({
next: (finishes) => {
this.filteredFinishes.set(finishes);
// Re-patch finish after load
this.form.get('materialFinishId')!.setValue(this.data.filament!.materialFinishId);
},
});
this.filamentService.getMaterialModifiers(baseId).subscribe({
next: (modifiers) => {
this.filteredModifiers.set(modifiers);
// Re-patch modifier after load
this.form.get('materialModifierId')!.setValue(this.data.filament!.materialModifierId);
},
});
}
}
// ── Actions ──────────────────────────────────────────────
/** Cancel and close the dialog without saving. */
cancel(): void {
this.dialogRef.close(false);
}
/** Submit the form — creates or updates the filament. */
save(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
// Cross-field validation: remaining weight must not exceed total weight
const total = this.form.value.weightTotalGrams;
const remaining = this.form.value.weightRemainingGrams;
if (remaining > total) {
this.form.get('weightRemainingGrams')!.setErrors({ exceedsTotal: true });
return;
}
this.saving.set(true);
this.serverError.set(null);
const formValue = this.form.value;
const request: CreateFilamentRequest | UpdateFilamentRequest = {
materialBaseId: formValue.materialBaseId,
materialFinishId: formValue.materialFinishId,
materialModifierId: formValue.materialModifierId || null,
brand: formValue.brand.trim(),
colorName: formValue.colorName.trim(),
colorHex: formValue.colorHex,
weightTotalGrams: formValue.weightTotalGrams,
weightRemainingGrams: formValue.weightRemainingGrams,
filamentDiameterMm: formValue.filamentDiameterMm,
spoolSerial: formValue.spoolSerial.trim(),
purchasePrice: formValue.purchasePrice ?? null,
purchaseDate: formValue.purchaseDate
? new Date(formValue.purchaseDate).toISOString()
: null,
isActive: formValue.isActive,
};
if (this.isEditMode()) {
const id = this.data.filament!.id;
this.filamentService.updateFilament(id, request).subscribe({
next: (updated) => {
this.saving.set(false);
this.dialogRef.close(true);
},
error: (err) => {
this.saving.set(false);
this.serverError.set(
err?.error?.error || err?.message || 'Failed to update filament. Please try again.'
);
},
});
} else {
this.filamentService.createFilament(request).subscribe({
next: (created) => {
this.saving.set(false);
this.dialogRef.close(true);
},
error: (err) => {
this.saving.set(false);
this.serverError.set(
err?.error?.error || err?.message || 'Failed to create filament. Please try again.'
);
},
});
}
}
}

View File

@@ -1,76 +0,0 @@
<!-- Filament Filter Bar — material type, color search, low stock, active-only -->
<div class="filament-filter-bar" role="search" aria-label="Filter filament inventory">
<!-- Material Type Multi-Select -->
<mat-form-field appearance="outline" class="filter-field material-filter">
<mat-label>Material</mat-label>
<mat-select multiple
[value]="selectedMaterials()"
(selectionChange)="onMaterialChange($event.value)"
aria-label="Filter by material type">
@for (material of materialOptions(); track material) {
<mat-option [value]="material">{{ material }}</mat-option>
}
</mat-select>
@if (selectedMaterials().length > 0) {
<mat-chip-set class="selected-chips" matSuffix>
@for (mat of selectedMaterials(); track mat) {
<mat-chip (removed)="removeMaterial(mat)"
class="filter-chip">
<span>{{ mat }}</span>
<mat-icon matChipRemove>cancel</mat-icon>
</mat-chip>
}
</mat-chip-set>
}
</mat-form-field>
<!-- Color Search -->
<mat-form-field appearance="outline" class="filter-field color-filter">
<mat-label>Color</mat-label>
<input matInput
type="text"
[value]="colorSearch()"
(input)="onColorSearchChange($any($event.target).value)"
placeholder="Search color..."
aria-label="Filter by color name" />
@if (colorSearch().trim()) {
<mat-icon matSuffix class="filter-active-icon">filter_list</mat-icon>
}
</mat-form-field>
<!-- Low Stock Toggle -->
<mat-checkbox [checked]="lowStockOnly()"
(change)="onLowStockToggle($event.checked)"
class="filter-checkbox"
aria-label="Show low stock only"
matTooltip="Show only spools at 25% or less remaining"
matTooltipPosition="below">
<mat-icon class="checkbox-icon" [class.active]="lowStockOnly()">warning</mat-icon>
Low Stock
</mat-checkbox>
<!-- Active Only Toggle -->
<mat-checkbox [checked]="activeOnly()"
(change)="onActiveOnlyToggle($event.checked)"
class="filter-checkbox"
aria-label="Show active spools only"
matTooltip="Show only spools currently in use"
matTooltipPosition="below">
<mat-icon class="checkbox-icon" [class.active]="activeOnly()">check_circle</mat-icon>
Active Only
</mat-checkbox>
<!-- Clear All Filters -->
@if (hasActiveFilters()) {
<button mat-button
class="clear-filters-btn"
(click)="clearAll()"
aria-label="Clear all filters"
matTooltip="Remove all filters"
matTooltipPosition="below">
<mat-icon>filter_alt_off</mat-icon>
Clear
</button>
}
</div>

View File

@@ -1,134 +0,0 @@
/**
* Filament Filter Bar Styles
* Responsive filter layout for kiosk and mobile
*/
$spacing-unit: 8px;
.filament-filter-bar {
display: flex;
align-items: center;
gap: $spacing-unit * 2;
flex-wrap: wrap;
padding: $spacing-unit * 2 0;
margin-bottom: $spacing-unit * 2;
}
// Form field sizing
.filter-field {
flex: 0 1 auto;
min-width: 160px;
&.material-filter {
min-width: 200px;
}
&.color-filter {
min-width: 180px;
}
// Reduce vertical spacing inside filter fields
.mat-mdc-form-field-subscript-wrapper {
display: none; // No hint/error text needed for filters
}
}
// Selected material chips
.selected-chips {
flex-wrap: wrap;
gap: 4px;
}
.filter-chip {
font-size: 12px !important;
min-height: 24px !important;
mat-icon {
font-size: 14px !important;
width: 14px !important;
height: 14px !important;
}
}
// Active filter icon
.filter-active-icon {
color: var(--mat-sys-primary);
font-size: 18px !important;
width: 18px !important;
height: 18px !important;
}
// Checkbox styling
.filter-checkbox {
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
user-select: none;
touch-action: manipulation; // Prevent zoom on double-tap
.checkbox-icon {
font-size: 18px !important;
width: 18px !important;
height: 18px !important;
color: var(--mat-sys-on-surface-variant);
transition: color 0.2s ease;
&.active {
color: var(--mat-sys-primary);
}
}
}
// Clear filters button
.clear-filters-btn {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
mat-icon {
font-size: 18px !important;
width: 18px !important;
height: 18px !important;
}
}
// Responsive: stack filters vertically on small screens
@media (max-width: 768px) {
.filament-filter-bar {
flex-direction: column;
align-items: stretch;
gap: $spacing-unit;
}
.filter-field {
width: 100%;
min-width: unset;
&.material-filter,
&.color-filter {
min-width: unset;
}
}
.filter-checkbox {
padding: $spacing-unit 0;
}
.clear-filters-btn {
align-self: flex-start;
}
}
// Extra-small screens (phone portrait)
@media (max-width: 480px) {
.filament-filter-bar {
padding: $spacing-unit 0;
margin-bottom: $spacing-unit;
}
.filter-checkbox {
font-size: 13px;
}
}

View File

@@ -1,158 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
Output,
computed,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { MatInputModule } from '@angular/material/input';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatIconModule } from '@angular/material/icon';
import { MatChipsModule } from '@angular/material/chips';
import { MatButtonModule } from '@angular/material/button';
import { MatTooltipModule } from '@angular/material/tooltip';
import {
Filament,
StockLevel,
classifyStockLevel,
} from '../../models/filament.model';
/** Filter state emitted by the filament filter component */
export interface FilamentFilterState {
/** Selected material base names — empty means all */
materialBaseNames: string[];
/** Color search text — empty string means all */
colorSearch: string;
/** Whether to show only low/critical stock */
lowStockOnly: boolean;
/** Whether to show only active spools */
activeOnly: boolean;
}
/**
* FilamentFilterComponent — Filter bar for the filament inventory list.
*
* Provides:
* - Material type multi-select filter
* - Color name text search
* - Low stock toggle (shows only critical/low spools)
* - Active-only toggle
* - Clear all filters action
*/
@Component({
selector: 'app-filament-filter',
standalone: true,
imports: [
CommonModule,
FormsModule,
MatFormFieldModule,
MatSelectModule,
MatInputModule,
MatCheckboxModule,
MatIconModule,
MatChipsModule,
MatButtonModule,
MatTooltipModule,
],
templateUrl: './filament-filter.component.html',
styleUrl: './filament-filter.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FilamentFilterComponent {
/** Filament data input — used to derive material options */
@Input() set filaments(value: Filament[]) {
this._filaments.set(value);
const materials = [...new Set(value.map((f) => f.materialBaseName))].sort();
this.materialOptions.set(materials);
}
get filaments(): Filament[] {
return this._filaments();
}
private readonly _filaments = signal<Filament[]>([]);
/** Available material base names derived from filament data */
readonly materialOptions = signal<string[]>([]);
/** Selected material base names */
readonly selectedMaterials = signal<string[]>([]);
/** Color search text */
readonly colorSearch = signal('');
/** Low stock only toggle */
readonly lowStockOnly = signal(false);
/** Active only toggle */
readonly activeOnly = signal(false);
/** Computed: whether any filters are active */
readonly hasActiveFilters = computed(
() =>
this.selectedMaterials().length > 0 ||
this.colorSearch().trim().length > 0 ||
this.lowStockOnly() ||
this.activeOnly()
);
/** Emits the current filter state whenever filters change */
@Output() readonly filterChange = new EventEmitter<FilamentFilterState>();
/** Handle material selection change */
onMaterialChange(selected: string[]): void {
this.selectedMaterials.set(selected);
this.emitFilterState();
}
/** Handle color search input */
onColorSearchChange(value: string): void {
this.colorSearch.set(value);
this.emitFilterState();
}
/** Handle low stock toggle */
onLowStockToggle(checked: boolean): void {
this.lowStockOnly.set(checked);
this.emitFilterState();
}
/** Handle active-only toggle */
onActiveOnlyToggle(checked: boolean): void {
this.activeOnly.set(checked);
this.emitFilterState();
}
/** Remove a single material chip */
removeMaterial(material: string): void {
const updated = this.selectedMaterials().filter((m) => m !== material);
this.selectedMaterials.set(updated);
this.emitFilterState();
}
/** Clear all filters */
clearAll(): void {
this.selectedMaterials.set([]);
this.colorSearch.set('');
this.lowStockOnly.set(false);
this.activeOnly.set(false);
this.emitFilterState();
}
/** Emit the current filter state */
private emitFilterState(): void {
this.filterChange.emit({
materialBaseNames: this.selectedMaterials(),
colorSearch: this.colorSearch().trim().toLowerCase(),
lowStockOnly: this.lowStockOnly(),
activeOnly: this.activeOnly(),
});
}
}

View File

@@ -1,166 +0,0 @@
<!-- Filament Inventory Table — with filters and low stock indicators -->
<div class="filament-table-container" role="region" aria-label="Filament inventory">
<!-- Filter Bar -->
<app-filament-filter
[filaments]="allFilaments()"
(filterChange)="onFilterChange($event)"
aria-label="Filter filament inventory" />
<!-- Low Stock Alert Banner — shown when critical or low stock spools exist -->
@if (criticalCount() > 0) {
<div class="alert-banner critical" role="alert">
<mat-icon aria-hidden="true">error</mat-icon>
<span>{{ criticalCount() }} spool{{ criticalCount() > 1 ? 's' : '' }} critically low (&le;10% remaining)</span>
</div>
} @else if (lowStockCount() > 0) {
<div class="alert-banner low" role="alert">
<mat-icon aria-hidden="true">warning</mat-icon>
<span>{{ lowStockCount() }} spool{{ lowStockCount() > 1 ? 's' : '' }} running low (&le;25% remaining)</span>
</div>
}
<!-- Filament Table -->
<table mat-table
[dataSource]="filteredFilaments()"
matSort
(matSortChange)="sortData($event)"
class="filament-table"
aria-label="Filament inventory table">
<!-- Color Column -->
<ng-container matColumnDef="color">
<th mat-header-cell *matHeaderCellDef mat-sort-header="color">Color</th>
<td mat-cell *matCellDef="let filament">
<span class="color-swatch"
[style.background-color]="filament.colorHex"
[matTooltip]="filament.colorName"
matTooltipPosition="after"
[attr.aria-label]="filament.colorName">
</span>
</td>
</ng-container>
<!-- Material Column -->
<ng-container matColumnDef="material">
<th mat-header-cell *matHeaderCellDef mat-sort-header="material">Material</th>
<td mat-cell *matCellDef="let filament">
<span class="material-name">{{ filament.materialBaseName }}</span>
@if (filament.materialModifierName) {
<span class="material-modifier"> {{ filament.materialModifierName }}</span>
}
</td>
</ng-container>
<!-- Brand Column -->
<ng-container matColumnDef="brand">
<th mat-header-cell *matHeaderCellDef mat-sort-header="brand">Brand</th>
<td mat-cell *matCellDef="let filament">{{ filament.brand }}</td>
</ng-container>
<!-- Serial Column -->
<ng-container matColumnDef="serial">
<th mat-header-cell *matHeaderCellDef mat-sort-header="serial">Serial</th>
<td mat-cell *matCellDef="let filament" class="serial-cell">{{ filament.spoolSerial }}</td>
</ng-container>
<!-- Remaining Weight Column -->
<ng-container matColumnDef="remaining">
<th mat-header-cell *matHeaderCellDef mat-sort-header="remaining">Remaining</th>
<td mat-cell *matCellDef="let filament">
<div class="remaining-cell">
<span class="remaining-text">
{{ formatWeight(filament.weightRemainingGrams) }} / {{ formatWeight(filament.weightTotalGrams) }}
</span>
<mat-progress-bar
mode="determinate"
[value]="getRemainingPercent(filament)"
[ngClass]="classifyStockLevel(filament)"
[matTooltip]="getRemainingPercent(filament).toFixed(0) + '% remaining'"
matTooltipPosition="below">
</mat-progress-bar>
</div>
</td>
</ng-container>
<!-- Cost Column -->
<ng-container matColumnDef="cost">
<th mat-header-cell *matHeaderCellDef mat-sort-header="cost">Cost</th>
<td mat-cell *matCellDef="let filament">
<div class="cost-cell">
@if (filament.purchasePrice !== null) {
<span class="cost-price">{{ formatCurrency(filament.purchasePrice) }}</span>
@let cpg = getCostPerGram(filament);
@if (cpg !== null) {
<span class="cost-per-gram">${{ cpg.toFixed(2) }}/g</span>
}
} @else {
<span class="cost-unknown">&mdash;</span>
}
</div>
</td>
</ng-container>
<!-- Usage Column -->
<ng-container matColumnDef="usage">
<th mat-header-cell *matHeaderCellDef mat-sort-header="usage">Usage</th>
<td mat-cell *matCellDef="let filament">
<div class="usage-cell">
<span class="usage-grams">{{ formatWeight(getGramsUsed(filament)) }} used</span>
<span class="usage-remaining">{{ formatWeight(filament.weightRemainingGrams) }} left</span>
</div>
</td>
</ng-container>
<!-- Stock Level Indicator Column -->
<ng-container matColumnDef="stockLevel">
<th mat-header-cell *matHeaderCellDef mat-sort-header="stockLevel">Stock</th>
<td mat-cell *matCellDef="let filament">
@let level = classifyStockLevel(filament);
<mat-chip-set aria-label="Stock level">
<mat-chip
[ngClass]="level"
[matTooltip]="stockLevelLabel(level) + ' ' + getRemainingPercent(filament).toFixed(0) + '% remaining'"
matTooltipPosition="below">
<mat-icon matChipStart [ngClass]="level">{{ stockLevelIcon(level) }}</mat-icon>
<span>{{ stockLevelLabel(level) }}</span>
</mat-chip>
</mat-chip-set>
</td>
</ng-container>
<!-- Status Column -->
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef mat-sort-header="status">Status</th>
<td mat-cell *matCellDef="let filament">
<span class="status-badge"
[class.active]="filament.isActive"
[class.inactive]="!filament.isActive">
{{ filament.isActive ? 'Active' : 'Inactive' }}
</span>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columns()"></tr>
<tr mat-row *matRowDef="let row; columns: columns();"
[class.row-critical]="classifyStockLevel(row) === 'critical'"
[class.row-low]="classifyStockLevel(row) === 'low'">
</tr>
</table>
<!-- Filtered empty state -->
@if (filteredFilaments().length === 0 && filaments().length > 0) {
<div class="empty-state" role="status">
<mat-icon aria-hidden="true">filter_alt_off</mat-icon>
<p>No filaments match the current filters</p>
</div>
}
<!-- No data empty state -->
@if (filaments().length === 0) {
<div class="empty-state" role="status">
<mat-icon aria-hidden="true">inventory_2</mat-icon>
<p>No filament spools found</p>
</div>
}
</div>

View File

@@ -1,301 +0,0 @@
/**
* Filament Table Component Styles
* Touch-optimized for kiosk (Raspberry Pi 5) and mobile PWA
* Low stock indicators use high-contrast colors for workshop visibility
*/
// Touch-optimized sizing
$touch-target-min: 48px;
$spacing-unit: 8px;
// Stock level colors — high contrast, accessible
$color-critical: #ef4444; // Red — critically low
$color-low: #f59e0b; // Amber — running low
$color-moderate: #3b82f6; // Blue — moderate
$color-healthy: #22c55e; // Green — healthy/OK
$color-active: #22c55e; // Green — active spool
$color-inactive: #94a3b8; // Gray — inactive spool
.filament-table-container {
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
// Alert banner for low stock warnings
.alert-banner {
display: flex;
align-items: center;
gap: $spacing-unit;
padding: $spacing-unit * 1.5 $spacing-unit * 2;
border-radius: 8px;
margin-bottom: $spacing-unit * 2;
font-size: 14px;
font-weight: 500;
mat-icon {
font-size: 20px !important;
width: 20px !important;
height: 20px !important;
}
&.critical {
background-color: rgba($color-critical, 0.12);
color: $color-critical;
border: 1px solid rgba($color-critical, 0.3);
}
&.low {
background-color: rgba($color-low, 0.12);
color: $color-low;
border: 1px solid rgba($color-low, 0.3);
}
}
// Table styling
.filament-table {
width: 100%;
min-width: 900px;
th {
font-weight: 600;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--mat-sys-on-surface-variant);
}
td {
font-size: 14px;
padding: 12px 16px !important;
min-height: $touch-target-min;
@media (max-width: 480px) {
padding: 8px 12px !important;
font-size: 13px;
}
}
// Row highlight for low stock
.mat-mdc-row {
transition: background-color 0.2s ease;
}
&.row-critical {
background-color: rgba($color-critical, 0.06) !important;
&:hover {
background-color: rgba($color-critical, 0.1) !important;
}
}
&.row-low {
background-color: rgba($color-low, 0.06) !important;
&:hover {
background-color: rgba($color-low, 0.1) !important;
}
}
}
// Color swatch
.color-swatch {
display: inline-block;
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid rgba(0, 0, 0, 0.12);
vertical-align: middle;
cursor: default;
@media (max-width: 480px) {
width: 24px;
height: 24px;
}
}
// Material name
.material-name {
font-weight: 500;
}
.material-modifier {
font-size: 12px;
color: var(--mat-sys-on-surface-variant);
margin-left: 4px;
}
// Serial cell — monospace
.serial-cell {
font-family: 'JetBrains Mono', 'Roboto Mono', monospace;
font-size: 13px;
letter-spacing: 0.02em;
}
// Cost cell
.cost-cell {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 80px;
.cost-price {
font-weight: 600;
color: var(--mat-sys-on-surface);
}
.cost-per-gram {
font-size: 11px;
color: var(--mat-sys-on-surface-variant);
letter-spacing: 0.02em;
}
.cost-unknown {
color: var(--mat-sys-on-surface-variant);
font-style: italic;
}
}
// Usage cell
.usage-cell {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 100px;
.usage-grams {
font-weight: 500;
color: var(--mat-sys-on-surface);
}
.usage-remaining {
font-size: 12px;
color: var(--mat-sys-on-surface-variant);
}
}
// Remaining weight cell
.remaining-cell {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 120px;
.remaining-text {
font-size: 13px;
color: var(--mat-sys-on-surface-variant);
}
}
// Progress bar stock level variants
mat-progress-bar {
&.critical {
--mat-progress-bar-active-indicator-color: #{$color-critical};
}
&.low {
--mat-progress-bar-active-indicator-color: #{$color-low};
}
&.moderate {
--mat-progress-bar-active-indicator-color: #{$color-moderate};
}
&.healthy {
--mat-progress-bar-active-indicator-color: #{$color-healthy};
}
}
// Stock level chip variants
mat-chip {
min-height: 32px !important;
font-size: 12px !important;
&.critical {
background-color: rgba($color-critical, 0.15) !important;
color: $color-critical;
mat-icon {
color: $color-critical;
}
}
&.low {
background-color: rgba($color-low, 0.15) !important;
color: $color-low;
mat-icon {
color: $color-low;
}
}
&.moderate {
background-color: rgba($color-moderate, 0.1) !important;
color: $color-moderate;
mat-icon {
color: $color-moderate;
}
}
&.healthy {
background-color: rgba($color-healthy, 0.1) !important;
color: $color-healthy;
mat-icon {
color: $color-healthy;
}
}
mat-icon {
font-size: 16px !important;
width: 16px !important;
height: 16px !important;
margin-right: 4px;
}
}
// Status badge
.status-badge {
display: inline-flex;
align-items: center;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
&.active {
background-color: rgba($color-active, 0.12);
color: $color-active;
}
&.inactive {
background-color: rgba($color-inactive, 0.12);
color: $color-inactive;
}
}
// Empty state
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px $spacing-unit * 2;
color: var(--mat-sys-on-surface-variant);
mat-icon {
font-size: 48px !important;
width: 48px !important;
height: 48px !important;
opacity: 0.4;
margin-bottom: $spacing-unit * 2;
}
p {
font-size: 16px;
margin: 0;
}
}

View File

@@ -1,88 +0,0 @@
import { describe, it, expect } from 'vitest';
import {
Filament,
StockLevel,
getRemainingPercent,
classifyStockLevel,
} from '../../models/filament.model';
/** Create a test filament with defaults — override specific fields */
function createFilament(overrides: Partial<Filament> = {}): Filament {
return {
id: '00000000-0000-0000-0000-000000000001',
materialBaseId: '10000000-0000-0000-0000-000000000001',
materialBaseName: 'PLA',
materialFinishId: '20000000-0000-0000-0000-000000000001',
materialFinishName: 'Basic',
materialModifierId: null,
materialModifierName: null,
brand: 'Bambu Lab',
colorName: 'White',
colorHex: '#FFFFFF',
weightTotalGrams: 1000,
weightRemainingGrams: 750,
filamentDiameterMm: 1.75,
spoolSerial: 'SN-001',
purchasePrice: null,
purchaseDate: null,
isActive: true,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
qrCodeUrl: '',
...overrides,
};
}
describe('getRemainingPercent', () => {
it('should return correct percentage', () => {
const filament = createFilament({ weightTotalGrams: 1000, weightRemainingGrams: 250 });
expect(getRemainingPercent(filament)).toBe(25);
});
it('should return 0 when total weight is 0', () => {
const filament = createFilament({ weightTotalGrams: 0, weightRemainingGrams: 0 });
expect(getRemainingPercent(filament)).toBe(0);
});
it('should cap at 100%', () => {
const filament = createFilament({ weightTotalGrams: 100, weightRemainingGrams: 200 });
expect(getRemainingPercent(filament)).toBe(100);
});
it('should floor at 0%', () => {
const filament = createFilament({ weightTotalGrams: 100, weightRemainingGrams: -10 });
expect(getRemainingPercent(filament)).toBe(0);
});
});
describe('classifyStockLevel', () => {
it('should classify as critical when ≤10%', () => {
const filament = createFilament({ weightTotalGrams: 1000, weightRemainingGrams: 50 });
expect(classifyStockLevel(filament)).toBe('critical');
});
it('should classify as critical at exactly 10%', () => {
const filament = createFilament({ weightTotalGrams: 1000, weightRemainingGrams: 100 });
expect(classifyStockLevel(filament)).toBe('critical');
});
it('should classify as low when ≤25%', () => {
const filament = createFilament({ weightTotalGrams: 1000, weightRemainingGrams: 200 });
expect(classifyStockLevel(filament)).toBe('low');
});
it('should classify as moderate when ≤50%', () => {
const filament = createFilament({ weightTotalGrams: 1000, weightRemainingGrams: 400 });
expect(classifyStockLevel(filament)).toBe('moderate');
});
it('should classify as healthy when >50%', () => {
const filament = createFilament({ weightTotalGrams: 1000, weightRemainingGrams: 750 });
expect(classifyStockLevel(filament)).toBe('healthy');
});
it('should classify 0 grams remaining as critical', () => {
const filament = createFilament({ weightTotalGrams: 1000, weightRemainingGrams: 0 });
expect(classifyStockLevel(filament)).toBe('critical');
});
});

View File

@@ -1,304 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
Input,
OnInit,
computed,
inject,
signal,
} from '@angular/core';
import { FilamentService } from '../../services/filament.service';
import { CommonModule } from '@angular/common';
import { MatTableModule } from '@angular/material/table';
import { MatChipsModule } from '@angular/material/chips';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatSortModule, Sort } from '@angular/material/sort';
import { FilamentFilterComponent, FilamentFilterState } from '../filament-filter/filament-filter.component';
import {
Filament,
StockLevel,
getRemainingPercent,
classifyStockLevel,
} from '../../models/filament.model';
/** Display column definitions for the filament table */
export type FilamentColumn =
| 'color'
| 'material'
| 'brand'
| 'serial'
| 'remaining'
| 'cost'
| 'usage'
| 'stockLevel'
| 'status';
@Component({
selector: 'app-filament-table',
standalone: true,
imports: [
CommonModule,
MatTableModule,
MatChipsModule,
MatIconModule,
MatProgressBarModule,
MatTooltipModule,
MatSortModule,
FilamentFilterComponent,
],
templateUrl: './filament-table.component.html',
styleUrl: './filament-table.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FilamentTableComponent implements OnInit {
private readonly filamentService = inject(FilamentService);
/** Filament data — reactive signal driven by FilamentService */
readonly filaments = this.filamentService.filaments;
/** Columns to display — defaults to all columns */
@Input()
set displayedColumns(cols: FilamentColumn[]) {
this._displayedColumns.set(cols);
}
get displayedColumns(): FilamentColumn[] {
return this._displayedColumns();
}
private readonly _displayedColumns = signal<FilamentColumn[]>([
'color',
'material',
'brand',
'serial',
'remaining',
'cost',
'usage',
'stockLevel',
'status',
]);
/** Default columns for template binding */
readonly columns = this._displayedColumns;
/** Current filter state */
readonly filterState = signal<FilamentFilterState>({
materialBaseNames: [],
colorSearch: '',
lowStockOnly: false,
activeOnly: false,
});
/** Sorted filament data */
readonly sortedFilaments = signal<Filament[]>([]);
/** Computed: filtered + sorted filament data for display */
readonly filteredFilaments = computed(() => {
const data = this.sortedFilaments();
const filters = this.filterState();
return data.filter((f) => this.matchesFilter(f, filters));
});
/** Computed: count of low/critical spools */
readonly lowStockCount = computed(() =>
this.filaments().filter(
(f) => classifyStockLevel(f) === 'low' || classifyStockLevel(f) === 'critical'
).length
);
/** Computed: count of critical spools */
readonly criticalCount = computed(() =>
this.filaments().filter((f) => classifyStockLevel(f) === 'critical').length
);
ngOnInit(): void {
// Initialize sorted data from FilamentService
this.sortedFilaments.set([...this.filaments()]);
}
/** Update filament data — called externally or from a SignalR handler */
updateFilaments(data: Filament[]): void {
this.filamentService.setFilaments(data);
this.sortedFilaments.set([...data]);
}
/** All filament data — for the filter component to derive material options */
readonly allFilaments = this.filaments;
/** Handle sort changes from MatSort */
sortData(sort: Sort): void {
const data = [...this.filaments()];
if (!sort.active || sort.direction === '') {
this.sortedFilaments.set(data);
return;
}
const sorted = data.sort((a, b) => {
const isAsc = sort.direction === 'asc';
switch (sort.active as FilamentColumn) {
case 'material':
return compare(a.materialBaseName, b.materialBaseName, isAsc);
case 'brand':
return compare(a.brand, b.brand, isAsc);
case 'serial':
return compare(a.spoolSerial, b.spoolSerial, isAsc);
case 'remaining':
return compare(
getRemainingPercent(a),
getRemainingPercent(b),
isAsc
);
case 'cost':
return compare(
a.purchasePrice ?? 0,
b.purchasePrice ?? 0,
isAsc
);
case 'usage':
return compare(
a.weightTotalGrams - a.weightRemainingGrams,
b.weightTotalGrams - b.weightRemainingGrams,
isAsc
);
case 'stockLevel':
return compare(
stockLevelOrder(classifyStockLevel(a)),
stockLevelOrder(classifyStockLevel(b)),
isAsc
);
case 'status':
return compare(
a.isActive ? 0 : 1,
b.isActive ? 0 : 1,
isAsc
);
default:
return 0;
}
});
this.sortedFilaments.set(sorted);
}
/** Handle filter changes from FilamentFilterComponent */
onFilterChange(state: FilamentFilterState): void {
this.filterState.set(state);
}
/** Check if a filament matches the current filter state */
private matchesFilter(filament: Filament, filters: FilamentFilterState): boolean {
// Material filter — empty means all
if (
filters.materialBaseNames.length > 0 &&
!filters.materialBaseNames.includes(filament.materialBaseName)
) {
return false;
}
// Color search — empty means all
if (
filters.colorSearch &&
!filament.colorName.toLowerCase().includes(filters.colorSearch) &&
!filament.colorHex.toLowerCase().includes(filters.colorSearch)
) {
return false;
}
// Low stock filter — show only critical/low
if (filters.lowStockOnly) {
const level = classifyStockLevel(filament);
if (level !== 'critical' && level !== 'low') {
return false;
}
}
// Active only filter
if (filters.activeOnly && !filament.isActive) {
return false;
}
return true;
}
/** Template helper: get remaining percent */
getRemainingPercent = getRemainingPercent;
/** Template helper: classify stock level */
classifyStockLevel = classifyStockLevel;
/** Template helper: stock level icon */
stockLevelIcon(level: StockLevel): string {
switch (level) {
case 'critical':
return 'error';
case 'low':
return 'warning';
case 'moderate':
return 'info';
case 'healthy':
return 'check_circle';
}
}
/** Template helper: stock level label */
stockLevelLabel(level: StockLevel): string {
switch (level) {
case 'critical':
return 'Critical';
case 'low':
return 'Low';
case 'moderate':
return 'Moderate';
case 'healthy':
return 'Healthy';
}
}
/** Template helper: format remaining weight */
formatWeight(grams: number): string {
if (grams >= 1000) {
return `${(grams / 1000).toFixed(1)}kg`;
}
return `${Math.round(grams)}g`;
}
/** Template helper: format currency */
formatCurrency(value: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
}
/** Template helper: compute cost per gram for a filament */
getCostPerGram(filament: Filament): number | null {
if (filament.purchasePrice === null || filament.purchasePrice === 0 || filament.weightTotalGrams <= 0) {
return null;
}
return filament.purchasePrice / filament.weightTotalGrams;
}
/** Template helper: compute grams used for a filament */
getGramsUsed(filament: Filament): number {
return filament.weightTotalGrams - filament.weightRemainingGrams;
}
}
/** Compare helper for sorting */
function compare(a: number | string, b: number | string, isAsc: boolean): number {
return (a < b ? -1 : a > b ? 1 : 0) * (isAsc ? 1 : -1);
}
/** Stock level sort order (critical=0, healthy=3) */
function stockLevelOrder(level: StockLevel): number {
switch (level) {
case 'critical':
return 0;
case 'low':
return 1;
case 'moderate':
return 2;
case 'healthy':
return 3;
}
}

View File

@@ -1,145 +0,0 @@
<!-- Inventory Dashboard Summary — filament metrics at a glance -->
<section class="inventory-summary" role="region" aria-label="Inventory summary">
<!-- Loading State -->
@if (loading()) {
<div class="summary-loading" role="status" aria-live="polite">
<mat-icon aria-hidden="true" class="spin">sync</mat-icon>
<span>Loading inventory...</span>
</div>
}
<!-- Error State -->
@else if (error()) {
<div class="summary-error" role="alert" aria-live="assertive">
<mat-icon aria-hidden="true">error_outline</mat-icon>
<span>{{ error() }}</span>
<button class="retry-btn" (click)="refresh()" aria-label="Retry loading inventory">
<mat-icon aria-hidden="true">refresh</mat-icon>
Retry
</button>
</div>
}
<!-- Loaded State -->
@else {
<!-- Health Status Indicator -->
<div class="summary-item health-status"
[class]="healthClass()"
[matTooltip]="healthLabel()"
matTooltipPosition="below">
<mat-icon aria-hidden="true">
@switch (healthClass()) {
@case ('critical') { error }
@case ('low') { warning }
@default { check_circle }
}
</mat-icon>
<span class="health-text">{{ healthLabel() }}</span>
</div>
<!-- Total Spool Count -->
<div class="summary-item metric-card"
matTooltip="Total filament spools in inventory"
matTooltipPosition="below">
<mat-icon aria-hidden="true" class="metric-icon">inventory_2</mat-icon>
<div class="metric-content">
<span class="metric-value">{{ totalCount() }}</span>
<span class="metric-label">Total Spools</span>
</div>
@if (activeCount() < totalCount()) {
<span class="metric-detail">{{ activeCount() }} active</span>
}
</div>
<!-- Low Stock Count -->
<div class="summary-item metric-card"
[class.has-alert]="hasLowStock()"
[class.has-critical]="hasCritical()"
[matTooltip]="hasCritical()
? criticalCount() + ' critical, ' + (lowStockCount() - criticalCount()) + ' low'
: hasLowStock()
? lowStockCount() + ' spools at or below 25% remaining'
: 'All spools above 25% remaining'"
matTooltipPosition="below">
<mat-icon aria-hidden="true" class="metric-icon">
@if (hasCritical()) { error }
@else if (hasLowStock()) { warning }
@else { check_circle }
</mat-icon>
<div class="metric-content">
<span class="metric-value">{{ lowStockCount() }}</span>
<span class="metric-label">Low Stock</span>
</div>
@if (hasCritical()) {
<span class="metric-detail critical-detail">{{ criticalCount() }} critical</span>
}
</div>
<!-- Estimated Total Value -->
<div class="summary-item metric-card"
matTooltip="Estimated total value of active spools"
matTooltipPosition="below">
<mat-icon aria-hidden="true" class="metric-icon">payments</mat-icon>
<div class="metric-content">
<span class="metric-value">{{ formatCurrency(totalValue()) }}</span>
<span class="metric-label">Est. Value</span>
</div>
</div>
<!-- Average Cost per Gram -->
@if (avgCostPerGram() !== null) {
<div class="summary-item metric-card"
matTooltip="Average cost per gram across priced, active spools"
matTooltipPosition="below">
<mat-icon aria-hidden="true" class="metric-icon">scale</mat-icon>
<div class="metric-content">
<span class="metric-value">${{ avgCostPerGram()!.toFixed(2) }}/g</span>
<span class="metric-label">Avg Cost/g</span>
</div>
</div>
}
<!-- Total Usage -->
<div class="summary-item metric-card"
matTooltip="Total filament used across all spools"
matTooltipPosition="below">
<mat-icon aria-hidden="true" class="metric-icon">trending_down</mat-icon>
<div class="metric-content">
<span class="metric-value">{{ formatWeight(totalGramsUsed()) }}</span>
<span class="metric-label">Total Used</span>
</div>
</div>
<!-- Estimated Used Value -->
@if (estimatedUsedValue() !== null) {
<div class="summary-item metric-card"
matTooltip="Estimated value of filament consumed"
matTooltipPosition="below">
<mat-icon aria-hidden="true" class="metric-icon">receipt_long</mat-icon>
<div class="metric-content">
<span class="metric-value">{{ formatCurrency(estimatedUsedValue()!) }}</span>
<span class="metric-label">Used Value</span>
</div>
</div>
}
<!-- Overall Remaining Stock Bar -->
<div class="summary-item metric-card stock-bar-card"
matTooltip="{{ formatWeight(totalRemainingGrams()) }} of {{ formatWeight(totalCapacityGrams()) }} remaining"
matTooltipPosition="below">
<mat-icon aria-hidden="true" class="metric-icon">line_weight</mat-icon>
<div class="metric-content stock-bar-content">
<div class="stock-bar-header">
<span class="metric-value">{{ overallRemainingPercent() }}%</span>
<span class="metric-label">Remaining</span>
</div>
<mat-progress-bar
mode="determinate"
[value]="overallRemainingPercent()"
[ngClass]="healthClass()">
</mat-progress-bar>
</div>
</div>
}
</section>

View File

@@ -1,257 +0,0 @@
/**
* Inventory Summary Component Styles
* Touch-optimized for kiosk (Raspberry Pi 5) and mobile PWA
* Matches the existing dark theme from app.scss
*/
// Touch-optimized sizing
$touch-target-min: 48px;
$kiosk-font-primary: 24px;
$mobile-font-primary: 18px;
$spacing-unit: 8px;
// Status colors — high contrast for workshop/bright environments
$color-healthy: #4ade70; // Green
$color-low: #fbbf24; // Amber/Yellow
$color-critical: #f87171; // Red
$color-bg: #1a1a2e; // Matches app.scss
$color-text: #e0e0e0;
$color-text-muted: rgba(255, 255, 255, 0.7);
$color-card-bg: rgba(255, 255, 255, 0.05);
$color-card-border: rgba(255, 255, 255, 0.1);
.inventory-summary {
display: flex;
align-items: stretch;
gap: $spacing-unit * 2;
padding: $spacing-unit * 2;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
@media (max-width: 600px) {
flex-wrap: wrap;
padding: $spacing-unit;
gap: $spacing-unit;
}
}
// Health status indicator
.health-status {
display: flex;
align-items: center;
gap: $spacing-unit;
padding: $spacing-unit $spacing-unit * 2;
border-radius: 24px;
min-height: $touch-target-min;
white-space: nowrap;
transition: background-color 0.3s ease;
&.healthy {
background-color: rgba($color-healthy, 0.15);
color: $color-healthy;
}
&.low {
background-color: rgba($color-low, 0.15);
color: $color-low;
}
&.critical {
background-color: rgba($color-critical, 0.15);
color: $color-critical;
}
.health-text {
font-size: 14px;
font-weight: 600;
letter-spacing: 0.02em;
@media (max-width: 480px) {
font-size: 12px;
}
}
mat-icon {
font-size: 20px !important;
width: 20px !important;
height: 20px !important;
}
}
// Metric card
.metric-card {
display: flex;
align-items: center;
gap: $spacing-unit;
padding: $spacing-unit $spacing-unit * 2;
background-color: $color-card-bg;
border: 1px solid $color-card-border;
border-radius: 12px;
min-height: $touch-target-min;
white-space: nowrap;
transition: border-color 0.2s ease, background-color 0.2s ease;
&:hover {
border-color: rgba(255, 255, 255, 0.2);
background-color: rgba(255, 255, 255, 0.08);
}
@media (max-width: 480px) {
padding: $spacing-unit;
}
&.has-alert {
border-color: rgba($color-low, 0.4);
}
&.has-critical {
border-color: rgba($color-critical, 0.5);
background-color: rgba($color-critical, 0.08);
}
}
.metric-icon {
color: $color-text-muted;
font-size: 22px !important;
width: 22px !important;
height: 22px !important;
.has-alert & {
color: $color-low;
}
.has-critical & {
color: $color-critical;
}
}
.metric-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.metric-value {
font-size: $kiosk-font-primary;
font-weight: 700;
line-height: 1.2;
color: $color-text;
@media (max-width: 480px) {
font-size: $mobile-font-primary;
}
}
.metric-label {
font-size: 11px;
color: $color-text-muted;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.metric-detail {
font-size: 11px;
color: $color-text-muted;
margin-left: $spacing-unit;
&.critical-detail {
color: $color-critical;
font-weight: 600;
}
}
// Stock bar card
.stock-bar-card {
flex: 1 1 200px;
min-width: 180px;
}
.stock-bar-content {
flex: 1;
min-width: 0;
}
.stock-bar-header {
display: flex;
align-items: baseline;
gap: $spacing-unit;
margin-bottom: 4px;
}
// Progress bar color classes
::ng-deep .mat-mdc-progress-bar {
&.healthy .mdc-linear-progress__bar-inner {
background-color: $color-healthy !important;
}
&.low .mdc-linear-progress__bar-inner {
background-color: $color-low !important;
}
&.critical .mdc-linear-progress__bar-inner {
background-color: $color-critical !important;
}
}
// Loading state
.summary-loading {
display: flex;
align-items: center;
gap: $spacing-unit;
padding: $spacing-unit * 2;
color: $color-text-muted;
font-size: 14px;
.spin {
animation: spin 1s linear infinite;
}
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
// Error state
.summary-error {
display: flex;
align-items: center;
gap: $spacing-unit;
padding: $spacing-unit * 2;
background-color: rgba($color-critical, 0.1);
border: 1px solid rgba($color-critical, 0.3);
border-radius: 12px;
color: $color-critical;
font-size: 14px;
.retry-btn {
display: flex;
align-items: center;
gap: 4px;
background: transparent;
border: 1px solid rgba($color-critical, 0.4);
color: $color-critical;
padding: 4px 12px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
min-height: $touch-target-min - 8px;
transition: background-color 0.2s ease;
&:hover {
background-color: rgba($color-critical, 0.15);
}
mat-icon {
font-size: 16px !important;
width: 16px !important;
height: 16px !important;
}
}
}
// Summary item base
.summary-item {
flex-shrink: 0;
}

View File

@@ -1,207 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
OnDestroy,
OnInit,
computed,
inject,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatIconModule } from '@angular/material/icon';
import { MatChipsModule } from '@angular/material/chips';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { FilamentService } from '../../services/filament.service';
import {
classifyStockLevel,
} from '../../models/filament.model';
import { Subscription } from 'rxjs';
/**
* Inventory Dashboard Summary — shows filament inventory at a glance.
*
* Displays:
* - Total filament spool count
* - Low stock count (spools ≤25% remaining, i.e. "low" or "critical")
* - Estimated total filament value (sum of purchase prices for active spools)
*
* Data is sourced from the shared FilamentService signal,
* which is loaded on init and can be refreshed via refresh().
*/
@Component({
selector: 'app-inventory-summary',
standalone: true,
imports: [
CommonModule,
MatIconModule,
MatChipsModule,
MatTooltipModule,
MatProgressBarModule,
],
templateUrl: './inventory-summary.component.html',
styleUrls: ['./inventory-summary.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InventorySummaryComponent implements OnInit, OnDestroy {
private readonly filamentService = inject(FilamentService);
private subscription: Subscription | null = null;
/** All filament data — reactive signal from shared service */
readonly filaments = this.filamentService.filaments;
/** Loading state */
readonly loading = signal<boolean>(true);
/** Error state */
readonly error = signal<string | null>(null);
/** Computed: total number of filament spools */
readonly totalCount = computed(() => this.filaments().length);
/** Computed: count of active spools */
readonly activeCount = computed(
() => this.filaments().filter((f) => f.isActive).length
);
/** Computed: count of low/critical stock spools (≤25% remaining) */
readonly lowStockCount = computed(
() =>
this.filaments().filter(
(f) =>
classifyStockLevel(f) === 'low' ||
classifyStockLevel(f) === 'critical'
).length
);
/** Computed: count of critically low spools (≤10% remaining) */
readonly criticalCount = computed(
() =>
this.filaments().filter((f) => classifyStockLevel(f) === 'critical')
.length
);
/** Computed: estimated total value of active spools */
readonly totalValue = computed(() =>
this.filaments()
.filter((f) => f.isActive && f.purchasePrice !== null)
.reduce((sum, f) => sum + (f.purchasePrice ?? 0), 0)
);
/** Computed: average cost per gram across active spools with a price */
readonly avgCostPerGram = computed(() => {
const priced = this.filaments().filter(
(f) => f.isActive && f.purchasePrice !== null && f.purchasePrice! > 0 && f.weightTotalGrams > 0
);
if (priced.length === 0) return null;
const totalCost = priced.reduce((sum, f) => sum + f.purchasePrice!, 0);
const totalWeight = priced.reduce((sum, f) => sum + f.weightTotalGrams, 0);
return totalWeight > 0 ? totalCost / totalWeight : null;
});
/** Computed: total grams used across all spools */
readonly totalGramsUsed = computed(() =>
this.filaments().reduce(
(sum, f) => sum + (f.weightTotalGrams - f.weightRemainingGrams),
0
)
);
/** Computed: total estimated value of used filament */
readonly estimatedUsedValue = computed(() => {
const priced = this.filaments().filter(
(f) => f.isActive && f.purchasePrice !== null && f.purchasePrice! > 0 && f.weightTotalGrams > 0
);
if (priced.length === 0) return null;
return priced.reduce((sum, f) => {
const usedFraction = (f.weightTotalGrams - f.weightRemainingGrams) / f.weightTotalGrams;
return sum + f.purchasePrice! * usedFraction;
}, 0);
});
/** Computed: total remaining weight across all spools in grams */
readonly totalRemainingGrams = computed(() =>
this.filaments().reduce((sum, f) => sum + f.weightRemainingGrams, 0)
);
/** Computed: total capacity weight across all spools in grams */
readonly totalCapacityGrams = computed(() =>
this.filaments().reduce((sum, f) => sum + f.weightTotalGrams, 0)
);
/** Computed: overall remaining percentage */
readonly overallRemainingPercent = computed(() => {
const capacity = this.totalCapacityGrams();
if (capacity <= 0) return 0;
return Math.round(
(this.totalRemainingGrams() / capacity) * 100
);
});
/** Computed: whether to show a low-stock alert */
readonly hasLowStock = computed(() => this.lowStockCount() > 0);
/** Computed: whether to show a critical-stock alert */
readonly hasCritical = computed(() => this.criticalCount() > 0);
/** Computed: status label for the inventory health */
readonly healthLabel = computed(() => {
if (this.hasCritical()) return 'Critical Stock';
if (this.hasLowStock()) return 'Low Stock Alert';
return 'Stock Healthy';
});
/** Computed: health status color class */
readonly healthClass = computed(() => {
if (this.hasCritical()) return 'critical';
if (this.hasLowStock()) return 'low';
return 'healthy';
});
ngOnInit(): void {
this.loadFilaments();
}
ngOnDestroy(): void {
this.subscription?.unsubscribe();
}
/** Load filament data from the API via FilamentService */
loadFilaments(): void {
this.loading.set(true);
this.error.set(null);
this.subscription = this.filamentService.getFilaments().subscribe({
next: () => {
this.loading.set(false);
},
error: (err) => {
console.error('Failed to load filaments:', err);
this.error.set('Failed to load inventory data');
this.loading.set(false);
},
});
}
/** Refresh data — called externally when data changes (e.g., SignalR notification) */
refresh(): void {
this.loadFilaments();
}
/** Format weight for display */
formatWeight(grams: number): string {
if (grams >= 1000) {
return `${(grams / 1000).toFixed(1)}kg`;
}
return `${Math.round(grams)}g`;
}
/** Format currency for display */
formatCurrency(value: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
}
}

View File

@@ -1,24 +0,0 @@
/**
* Represents the status of a single agent/printer in the system.
*/
export type AgentStatus = 'active' | 'idle' | 'thinking' | 'error';
export interface AgentSummary {
/** Total number of agents in the system */
total: number;
/** Number of currently active agents */
active: number;
/** Number of currently idle agents */
idle: number;
/** Number of currently thinking/processing agents */
thinking: number;
/** Number of agents in error state */
error: number;
}
export interface SystemHealth {
/** Whether the SignalR connection is live */
connected: boolean;
/** Overall system health: healthy, degraded, or down */
status: 'healthy' | 'degraded' | 'down';
}

View File

@@ -1,100 +0,0 @@
/**
* Filament model matching the Extrudex backend FilamentResponse DTO.
* Used for displaying spool inventory in the filament table UI.
*/
export interface Filament {
/** Unique identifier for the filament spool. */
id: string;
/** Foreign key to the base material. */
materialBaseId: string;
/** Name of the base material (e.g., "PLA", "PETG"). */
materialBaseName: string;
/** Foreign key to the material finish. */
materialFinishId: string;
/** Name of the material finish (e.g., "Basic", "Matte"). */
materialFinishName: string;
/** Foreign key to the optional material modifier. */
materialModifierId: string | null;
/** Name of the material modifier (e.g., "Carbon Fiber"). Null if none. */
materialModifierName: string | null;
/** Brand name (e.g., "Bambu Lab", "Polymaker"). */
brand: string;
/** Human-readable color name (e.g., "Fire Engine Red"). */
colorName: string;
/** Hex color code (e.g., "#FF0000"). */
colorHex: string;
/** Total spool weight in grams when full. */
weightTotalGrams: number;
/** Current remaining weight in grams. */
weightRemainingGrams: number;
/** Filament diameter in millimeters. Typically 1.75mm. */
filamentDiameterMm: number;
/** Manufacturer-assigned serial number. */
spoolSerial: string;
/** Purchase price per spool. Null if not tracked. */
purchasePrice: number | null;
/** Date the spool was purchased or received. */
purchaseDate: string | null;
/** Whether the spool is currently active and available. */
isActive: boolean;
/** Timestamp when this record was created (UTC). */
createdAt: string;
/** Timestamp when this record was last updated (UTC). */
updatedAt: string;
/** URL to the QR code image for this spool. */
qrCodeUrl: string;
}
/**
* Stock level classification for low stock indicators.
* - critical: ≤ 10% remaining
* - low: ≤ 25% remaining
* - moderate: ≤ 50% remaining
* - healthy: > 50% remaining
*/
export type StockLevel = 'critical' | 'low' | 'moderate' | 'healthy';
/**
* Compute the remaining weight percentage for a filament spool.
* Returns a value from 0 to 100.
*/
export function getRemainingPercent(filament: Filament): number {
if (filament.weightTotalGrams <= 0) return 0;
const pct = (filament.weightRemainingGrams / filament.weightTotalGrams) * 100;
return Math.min(Math.max(pct, 0), 100);
}
/**
* Classify the stock level based on remaining percentage.
* Thresholds:
* critical — ≤ 10% (nearly empty, red alert)
* low — ≤ 25% (getting low, amber warning)
* moderate — ≤ 50% (half or less, yellow info)
* healthy — > 50% (plenty left, green OK)
*/
export function classifyStockLevel(filament: Filament): StockLevel {
const pct = getRemainingPercent(filament);
if (pct <= 10) return 'critical';
if (pct <= 25) return 'low';
if (pct <= 50) return 'moderate';
return 'healthy';
}

View File

@@ -1,50 +0,0 @@
/**
* Material lookup models matching the Extrudex backend Material DTOs.
* Used for populating dropdowns in the filament add/edit form.
*/
/** Material base (e.g., PLA, PETG, ABS). */
export interface MaterialBase {
/** Unique identifier. */
id: string;
/** Human-readable name (e.g., "PLA", "PETG"). */
name: string;
/** Density in g/cm³. */
densityGperCm3: number;
/** Created timestamp (UTC). */
createdAt: string;
/** Updated timestamp (UTC). */
updatedAt: string;
}
/** Material finish (e.g., Basic, Matte, Silk). */
export interface MaterialFinish {
/** Unique identifier. */
id: string;
/** Human-readable name (e.g., "Basic", "Matte"). */
name: string;
/** Foreign key to the parent material base. */
materialBaseId: string;
/** Name of the parent material base (for display). */
materialBaseName: string;
/** Created timestamp (UTC). */
createdAt: string;
/** Updated timestamp (UTC). */
updatedAt: string;
}
/** Material modifier (e.g., Carbon Fiber, Wood Fill). Optional. */
export interface MaterialModifier {
/** Unique identifier. */
id: string;
/** Human-readable name (e.g., "Carbon Fiber"). */
name: string;
/** Foreign key to the parent material base. */
materialBaseId: string;
/** Name of the parent material base (for display). */
materialBaseName: string;
/** Created timestamp (UTC). */
createdAt: string;
/** Updated timestamp (UTC). */
updatedAt: string;
}

View File

@@ -1,13 +0,0 @@
/**
* Generic paged response wrapper matching the Extrudex backend PagedResponse<T>.
*/
export interface PagedResponse<T> {
/** The items in this page. */
items: T[];
/** Total number of items across all pages. */
totalCount: number;
/** The current page number (1-based). */
pageNumber: number;
/** The number of items per page. */
pageSize: number;
}

View File

@@ -1,52 +0,0 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, Subscription } from 'rxjs';
import { signal } from '@angular/core';
import { Filament } from '../models/filament.model';
/**
* API base URL — matches the Extrudex backend.
* TODO: Move to environment config when environments are set up.
*/
const API_BASE_URL = '/api/filaments';
/**
* Service for managing filament inventory data.
*
* Provides:
* - A reactive `filaments` signal for components to bind to
* - REST methods for GET, POST, DELETE endpoints
* - Real-time updates via SignalR should be layered on top when the hub is ready
*/
@Injectable({ providedIn: 'root' })
export class FilamentService {
private readonly http = inject(HttpClient);
/** Reactive filament data — components read from this signal */
readonly filaments = signal<Filament[]>([]);
/** Fetch all filament spools and update the signal */
getFilaments(): Observable<Filament[]> {
const req = this.http.get<Filament[]>(API_BASE_URL);
req.subscribe({
next: (data) => this.filaments.set(data),
error: (err) => console.error('Failed to load filaments:', err),
});
return req;
}
/** Fetch a single filament by ID */
getFilament(id: string): Observable<Filament> {
return this.http.get<Filament>(`${API_BASE_URL}/${id}`);
}
/** Set filament data directly — used by components or SignalR handlers */
setFilaments(data: Filament[]): void {
this.filaments.set(data);
}
/** Delete a filament spool by ID */
deleteFilament(id: string): Observable<void> {
return this.http.delete<void>(`${API_BASE_URL}/${id}`);
}
}

View File

View File

@@ -1,8 +0,0 @@
/**
* Environment configuration for the Extrudex frontend (production).
* Override API URL for deployed environments.
*/
export const environment = {
production: true,
apiBaseUrl: '/api',
};

View File

@@ -1,8 +0,0 @@
/**
* Environment configuration for the Extrudex frontend.
* Replace API URL with the actual backend endpoint in production.
*/
export const environment = {
production: false,
apiBaseUrl: 'http://localhost:5000',
};

View File

8
frontend/src/index.css Normal file
View File

@@ -0,0 +1,8 @@
@import "tailwindcss";
body {
margin: 0;
min-height: 100vh;
background-color: #0f172a; /* slate-900 */
color: #f8fafc; /* slate-50 */
}

View File

@@ -1,20 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Frontend</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap"
rel="stylesheet"
/>
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
</head>
<body>
<app-root></app-root>
</body>
</html>

View File

@@ -1,6 +0,0 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig)
.catch((err) => console.error(err));

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

View File

View File

View File

@@ -1,39 +0,0 @@
// Include theming for Angular Material with `mat.theme()`.
// This Sass mixin will define CSS variables that are used for styling Angular Material
// components according to the Material 3 design spec.
// Learn more about theming and how to use it for your application's
// custom components at https://material.angular.dev/guide/theming
@use '@angular/material' as mat;
html {
height: 100%;
@include mat.theme(
(
color: (
primary: mat.$azure-palette,
tertiary: mat.$blue-palette,
),
typography: Roboto,
density: 0,
)
);
}
body {
// Default the application to a light color theme. This can be changed to
// `dark` to enable the dark color theme, or to `light dark` to defer to the
// user's system settings.
color-scheme: light;
// Set a default background, font and text colors for the application using
// Angular Material's system-level CSS variables. Learn more about these
// variables at https://material.angular.dev/guide/system-variables
background-color: var(--mat-sys-surface);
color: var(--mat-sys-on-surface);
font: var(--mat-sys-body-medium);
// Reset the user agent margin.
margin: 0;
height: 100%;
}
/* You can add global styles to this file, and also import other style files */

View File

View File

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

View File

@@ -1,15 +1,24 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": [
"src/**/*.ts"
],
"exclude": [
"src/**/*.spec.ts"
]
"include": ["src"]
}

View File

@@ -1,33 +1,24 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
]
},
"include": ["src"]
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

View File

@@ -1,15 +0,0 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"vitest/globals"
]
},
"include": [
"src/**/*.d.ts",
"src/**/*.spec.ts"
]
}

19
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
}
}
},
build: {
outDir: 'dist',
}
})