diff --git a/backend/internal/handlers/material_finish_handler.go b/backend/internal/handlers/material_finish_handler.go new file mode 100644 index 0000000..e1c0821 --- /dev/null +++ b/backend/internal/handlers/material_finish_handler.go @@ -0,0 +1,34 @@ +package handlers + +import ( + "log/slog" + "net/http" + + "github.com/CubeCraft-Creations/Extrudex/backend/internal/dtos" + "github.com/CubeCraft-Creations/Extrudex/backend/internal/repositories" +) + +// MaterialFinishHandler handles requests for material finish lookup data. +type MaterialFinishHandler struct { + repo *repositories.MaterialFinishRepository +} + +// NewMaterialFinishHandler creates a MaterialFinishHandler with the given repository. +func NewMaterialFinishHandler(repo *repositories.MaterialFinishRepository) *MaterialFinishHandler { + return &MaterialFinishHandler{repo: repo} +} + +// List handles GET /api/finishes — returns all material finishes. +func (h *MaterialFinishHandler) List(w http.ResponseWriter, r *http.Request) { + finishes, err := h.repo.GetAll(r.Context()) + if err != nil { + slog.Error("failed to list finishes", "error", err) + writeJSON(w, http.StatusInternalServerError, dtos.ErrorResponse{ + Error: "internal server error", + Code: http.StatusInternalServerError, + }) + return + } + + writeJSON(w, http.StatusOK, dtos.SingleResponse{Data: finishes}) +} diff --git a/backend/internal/handlers/material_modifier_handler.go b/backend/internal/handlers/material_modifier_handler.go new file mode 100644 index 0000000..7b6e218 --- /dev/null +++ b/backend/internal/handlers/material_modifier_handler.go @@ -0,0 +1,34 @@ +package handlers + +import ( + "log/slog" + "net/http" + + "github.com/CubeCraft-Creations/Extrudex/backend/internal/dtos" + "github.com/CubeCraft-Creations/Extrudex/backend/internal/repositories" +) + +// MaterialModifierHandler handles requests for material modifier lookup data. +type MaterialModifierHandler struct { + repo *repositories.MaterialModifierRepository +} + +// NewMaterialModifierHandler creates a MaterialModifierHandler with the given repository. +func NewMaterialModifierHandler(repo *repositories.MaterialModifierRepository) *MaterialModifierHandler { + return &MaterialModifierHandler{repo: repo} +} + +// List handles GET /api/modifiers — returns all material modifiers. +func (h *MaterialModifierHandler) List(w http.ResponseWriter, r *http.Request) { + modifiers, err := h.repo.GetAll(r.Context()) + if err != nil { + slog.Error("failed to list modifiers", "error", err) + writeJSON(w, http.StatusInternalServerError, dtos.ErrorResponse{ + Error: "internal server error", + Code: http.StatusInternalServerError, + }) + return + } + + writeJSON(w, http.StatusOK, dtos.SingleResponse{Data: modifiers}) +} diff --git a/backend/internal/repositories/material_finish_repository.go b/backend/internal/repositories/material_finish_repository.go new file mode 100644 index 0000000..e7218f8 --- /dev/null +++ b/backend/internal/repositories/material_finish_repository.go @@ -0,0 +1,50 @@ +package repositories + +import ( + "context" + + "github.com/CubeCraft-Creations/Extrudex/backend/internal/models" + "github.com/jackc/pgx/v5/pgxpool" +) + +// MaterialFinishRepository handles database queries for material finishes. +type MaterialFinishRepository struct { + pool *pgxpool.Pool +} + +// NewMaterialFinishRepository creates a MaterialFinishRepository backed by the given pool. +func NewMaterialFinishRepository(pool *pgxpool.Pool) *MaterialFinishRepository { + return &MaterialFinishRepository{pool: pool} +} + +// GetAll returns all material finishes ordered by name. +func (r *MaterialFinishRepository) GetAll(ctx context.Context) ([]models.MaterialFinish, error) { + rows, err := r.pool.Query(ctx, ` + SELECT id, name, description, created_at, updated_at + FROM material_finishes + ORDER BY name + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var finishes []models.MaterialFinish + for rows.Next() { + var f models.MaterialFinish + if err := rows.Scan( + &f.ID, &f.Name, &f.Description, + &f.CreatedAt, &f.UpdatedAt, + ); err != nil { + return nil, err + } + finishes = append(finishes, f) + } + if err := rows.Err(); err != nil { + return nil, err + } + if finishes == nil { + finishes = []models.MaterialFinish{} + } + return finishes, nil +} diff --git a/backend/internal/repositories/material_modifier_repository.go b/backend/internal/repositories/material_modifier_repository.go new file mode 100644 index 0000000..22075dc --- /dev/null +++ b/backend/internal/repositories/material_modifier_repository.go @@ -0,0 +1,50 @@ +package repositories + +import ( + "context" + + "github.com/CubeCraft-Creations/Extrudex/backend/internal/models" + "github.com/jackc/pgx/v5/pgxpool" +) + +// MaterialModifierRepository handles database queries for material modifiers. +type MaterialModifierRepository struct { + pool *pgxpool.Pool +} + +// NewMaterialModifierRepository creates a MaterialModifierRepository backed by the given pool. +func NewMaterialModifierRepository(pool *pgxpool.Pool) *MaterialModifierRepository { + return &MaterialModifierRepository{pool: pool} +} + +// GetAll returns all material modifiers ordered by name. +func (r *MaterialModifierRepository) GetAll(ctx context.Context) ([]models.MaterialModifier, error) { + rows, err := r.pool.Query(ctx, ` + SELECT id, name, description, created_at, updated_at + FROM material_modifiers + ORDER BY name + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var modifiers []models.MaterialModifier + for rows.Next() { + var m models.MaterialModifier + if err := rows.Scan( + &m.ID, &m.Name, &m.Description, + &m.CreatedAt, &m.UpdatedAt, + ); err != nil { + return nil, err + } + modifiers = append(modifiers, m) + } + if err := rows.Err(); err != nil { + return nil, err + } + if modifiers == nil { + modifiers = []models.MaterialModifier{} + } + return modifiers, nil +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 61e429f..d80463f 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -45,6 +45,8 @@ func New(cfg *config.Config, dbPool *pgxpool.Pool, sseBC *sse.Broadcaster) chi.R // ── Repositories ────────────────────────────────────────────────────── materialRepo := repositories.NewMaterialRepository(dbPool) + finishRepo := repositories.NewMaterialFinishRepository(dbPool) + modifierRepo := repositories.NewMaterialModifierRepository(dbPool) filamentRepo := repositories.NewFilamentRepository(dbPool) printerRepo := repositories.NewPrinterRepository(dbPool) printJobRepo := repositories.NewPrintJobRepository(dbPool) @@ -57,6 +59,8 @@ func New(cfg *config.Config, dbPool *pgxpool.Pool, sseBC *sse.Broadcaster) chi.R // ── Handlers ────────────────────────────────────────────────────────── materialHandler := handlers.NewMaterialHandler(materialRepo) + finishHandler := handlers.NewMaterialFinishHandler(finishRepo) + modifierHandler := handlers.NewMaterialModifierHandler(modifierRepo) filamentHandler := handlers.NewFilamentHandler(filamentService) printerHandler := handlers.NewPrinterHandler(printerService) printJobHandler := handlers.NewPrintJobHandler(printJobService) @@ -66,6 +70,8 @@ func New(cfg *config.Config, dbPool *pgxpool.Pool, sseBC *sse.Broadcaster) chi.R r.Route("/api", func(r chi.Router) { r.Use(middleware.Timeout(60 * time.Second)) r.Get("/materials", materialHandler.List) + r.Get("/finishes", finishHandler.List) + r.Get("/modifiers", modifierHandler.List) r.Route("/filaments", func(r chi.Router) { r.Get("/", filamentHandler.List) diff --git a/frontend/src/components/FilamentForm.tsx b/frontend/src/components/FilamentForm.tsx new file mode 100644 index 0000000..c070455 --- /dev/null +++ b/frontend/src/components/FilamentForm.tsx @@ -0,0 +1,360 @@ +import { useState, useEffect, useMemo } from 'react' +import { X, Save, AlertCircle } from 'lucide-react' +import ColorSwatch from './ColorSwatch' +import { createFilament, updateFilament, fetchMaterialBases, fetchMaterialFinishes, fetchMaterialModifiers } from '../services/filamentService' +import type { FilamentSpool, MaterialBase, MaterialFinish, MaterialModifier } from '../types/filament' +import { useQuery } from '@tanstack/react-query' + +interface FilamentFormProps { + mode: 'create' | 'edit' + initialData?: FilamentSpool | null + onClose: () => void + onSuccess: () => void +} + +interface FormErrors { + [key: string]: string +} + +export default function FilamentForm({ mode, initialData, onClose, onSuccess }: FilamentFormProps) { + const [name, setName] = useState('') + const [materialBaseId, setMaterialBaseId] = useState('') + const [materialFinishId, setMaterialFinishId] = useState('1') + const [materialModifierId, setMaterialModifierId] = useState('') + const [colorHex, setColorHex] = useState('#3B82F6') + const [brand, setBrand] = useState('') + const [diameterMm, setDiameterMm] = useState('1.75') + const [initialGrams, setInitialGrams] = useState('') + const [remainingGrams, setRemainingGrams] = useState('') + const [costUsd, setCostUsd] = useState('') + const [lowStockThreshold, setLowStockThreshold] = useState('50') + const [notes, setNotes] = useState('') + const [barcode, setBarcode] = useState('') + const [errors, setErrors] = useState({}) + const [submitting, setSubmitting] = useState(false) + const [submitError, setSubmitError] = useState(null) + + const { data: materials } = useQuery({ + queryKey: ['materials'], + queryFn: fetchMaterialBases, + staleTime: Infinity, + }) + const { data: finishes } = useQuery({ + queryKey: ['finishes'], + queryFn: fetchMaterialFinishes, + staleTime: Infinity, + }) + const { data: modifiers } = useQuery({ + queryKey: ['modifiers'], + queryFn: fetchMaterialModifiers, + staleTime: Infinity, + }) + + useEffect(() => { + if (mode === 'edit' && initialData) { + setName(initialData.name ?? '') + setMaterialBaseId(String(initialData.material_base_id ?? '')) + setMaterialFinishId(String(initialData.material_finish_id ?? '1')) + setMaterialModifierId(initialData.material_modifier_id ? String(initialData.material_modifier_id) : '') + setColorHex(initialData.color_hex ?? '#3B82F6') + setBrand(initialData.brand ?? '') + setDiameterMm(String(initialData.diameter_mm ?? 1.75)) + setInitialGrams(String(initialData.initial_grams ?? '')) + setRemainingGrams(String(initialData.remaining_grams ?? '')) + setCostUsd(initialData.cost_usd != null ? String(initialData.cost_usd) : '') + setLowStockThreshold(String(initialData.low_stock_threshold_grams ?? 50)) + setNotes(initialData.notes ?? '') + setBarcode(initialData.barcode ?? '') + } + }, [mode, initialData]) + + const colorHexValid = useMemo(() => { + return /^#[0-9A-Fa-f]{6}$/.test(colorHex) + }, [colorHex]) + + function validate(): FormErrors { + const e: FormErrors = {} + if (!name.trim()) e.name = 'Name is required' + if (!materialBaseId) e.material_base_id = 'Material Base is required' + if (!materialFinishId) e.material_finish_id = 'Material Finish is required' + if (!colorHexValid) e.color_hex = 'Enter a valid hex color (e.g., #FF0000)' + if (!initialGrams || Number(initialGrams) <= 0) e.initial_grams = 'Must be > 0' + if (remainingGrams === '' || Number(remainingGrams) < 0) e.remaining_grams = 'Must be >= 0' + if (Number(remainingGrams) > Number(initialGrams)) e.remaining_grams = 'Cannot exceed Initial Grams' + if (costUsd && Number(costUsd) < 0) e.cost_usd = 'Must be >= 0' + if (!diameterMm || Number(diameterMm) <= 0) e.diameter_mm = 'Must be > 0' + if (!lowStockThreshold || Number(lowStockThreshold) < 0) e.low_stock_threshold_grams = 'Must be >= 0' + return e + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setSubmitError(null) + const validation = validate() + if (Object.keys(validation).length > 0) { + setErrors(validation) + return + } + setErrors({}) + setSubmitting(true) + + const payload: Record = { + name: name.trim(), + material_base_id: Number(materialBaseId), + material_finish_id: Number(materialFinishId), + color_hex: colorHex, + initial_grams: Number(initialGrams), + remaining_grams: Number(remainingGrams), + diameter_mm: Number(diameterMm), + low_stock_threshold_grams: Number(lowStockThreshold), + } + + if (materialModifierId) payload.material_modifier_id = Number(materialModifierId) + if (brand.trim()) payload.brand = brand.trim() + if (costUsd) payload.cost_usd = Number(costUsd) + if (notes.trim()) payload.notes = notes.trim() + if (barcode.trim()) payload.barcode = barcode.trim() + + try { + if (mode === 'edit' && initialData) { + await updateFilament(initialData.id, payload) + } else { + await createFilament(payload) + } + onSuccess() + onClose() + } catch (err: any) { + setSubmitError(err?.response?.data?.error || 'Failed to save spool. Please try again.') + } finally { + setSubmitting(false) + } + } + + return ( +
+
+ {/* Header */} +
+

+ {mode === 'edit' ? 'Edit Filament Spool' : 'Add Filament Spool'} +

+ +
+ + {/* Error banner */} + {submitError && ( +
+ + {submitError} +
+ )} + + {/* Form */} +
+ {/* Row 1: Name + Brand */} +
+
+ + setName(e.target.value)} + className={`w-full rounded-lg bg-slate-900 border px-3 py-2.5 text-sm text-slate-100 focus:outline-none focus:ring-2 ${errors.name ? 'border-red-500 focus:ring-red-500' : 'border-slate-600 focus:ring-emerald-500'}`} + placeholder="e.g. Sunlu PLA Silk Red" + /> + {errors.name &&

{errors.name}

} +
+
+ + setBrand(e.target.value)} + className="w-full rounded-lg bg-slate-900 border border-slate-600 px-3 py-2.5 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-emerald-500" + placeholder="e.g. Hatchbox" + /> +
+
+ + {/* Row 2: Material Base + Finish + Modifier */} +
+
+ + + {errors.material_base_id &&

{errors.material_base_id}

} +
+
+ + + {errors.material_finish_id &&

{errors.material_finish_id}

} +
+
+ + +
+
+ + {/* Row 3: Color + Diameter */} +
+
+ +
+ setColorHex(e.target.value)} + className="h-10 w-14 rounded border border-slate-600 bg-slate-900 cursor-pointer" + /> +
+ setColorHex(e.target.value)} + className={`w-full rounded-lg bg-slate-900 border px-3 py-2.5 text-sm text-slate-100 font-mono uppercase focus:outline-none focus:ring-2 ${errors.color_hex ? 'border-red-500 focus:ring-red-500' : 'border-slate-600 focus:ring-emerald-500'}`} + placeholder="#FF0000" + maxLength={7} + /> +
+ +
+ {errors.color_hex &&

{errors.color_hex}

} +
+
+ + setDiameterMm(e.target.value)} + className={`w-full rounded-lg bg-slate-900 border px-3 py-2.5 text-sm text-slate-100 focus:outline-none focus:ring-2 ${errors.diameter_mm ? 'border-red-500 focus:ring-red-500' : 'border-slate-600 focus:ring-emerald-500'}`} + /> + {errors.diameter_mm &&

{errors.diameter_mm}

} +
+
+ + {/* Row 4: Grams */} +
+
+ + setInitialGrams(e.target.value)} + className={`w-full rounded-lg bg-slate-900 border px-3 py-2.5 text-sm text-slate-100 focus:outline-none focus:ring-2 ${errors.initial_grams ? 'border-red-500 focus:ring-red-500' : 'border-slate-600 focus:ring-emerald-500'}`} + /> + {errors.initial_grams &&

{errors.initial_grams}

} +
+
+ + setRemainingGrams(e.target.value)} + className={`w-full rounded-lg bg-slate-900 border px-3 py-2.5 text-sm text-slate-100 focus:outline-none focus:ring-2 ${errors.remaining_grams ? 'border-red-500 focus:ring-red-500' : 'border-slate-600 focus:ring-emerald-500'}`} + /> + {errors.remaining_grams &&

{errors.remaining_grams}

} +
+
+ + setCostUsd(e.target.value)} + className={`w-full rounded-lg bg-slate-900 border px-3 py-2.5 text-sm text-slate-100 focus:outline-none focus:ring-2 ${errors.cost_usd ? 'border-red-500 focus:ring-red-500' : 'border-slate-600 focus:ring-emerald-500'}`} + /> +
+
+ + {/* Row 5: Threshold + Barcode */} +
+
+ + setLowStockThreshold(e.target.value)} + className={`w-full rounded-lg bg-slate-900 border px-3 py-2.5 text-sm text-slate-100 focus:outline-none focus:ring-2 ${errors.low_stock_threshold_grams ? 'border-red-500 focus:ring-red-500' : 'border-slate-600 focus:ring-emerald-500'}`} + /> + {errors.low_stock_threshold_grams &&

{errors.low_stock_threshold_grams}

} +
+
+ + setBarcode(e.target.value)} + className="w-full rounded-lg bg-slate-900 border border-slate-600 px-3 py-2.5 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-emerald-500" + placeholder="e.g. 123456789012" + /> +
+
+ + {/* Notes */} +
+ +