feat(inventory): CSV import/export for the inventory page (#1576) (#1659)

This commit is contained in:
Samed Yüksel
2026-06-08 09:30:16 +02:00
committed by GitHub
parent 2c2725cb53
commit 66c09dff2d
18 changed files with 1918 additions and 2 deletions
+99 -2
View File
@@ -2,8 +2,8 @@ import json
import logging
import httpx
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from fastapi.responses import Response, StreamingResponse
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -38,6 +38,13 @@ from backend.app.schemas.spool import (
normalize_extra_colors,
)
from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
from backend.app.services.spool_csv import (
MAX_CSV_IMPORT_BYTES,
ImportPreview,
ImportResult,
parse_and_validate,
serialize,
)
from backend.app.utils.filament_ids import (
GENERIC_FILAMENT_IDS,
MATERIAL_TEMPS,
@@ -52,6 +59,10 @@ _GENERIC_ID_VALUES = set(GENERIC_FILAMENT_IDS.values())
router = APIRouter(prefix="/inventory", tags=["inventory"])
# Bounded read size for the CSV import body so a chunked upload with no
# Content-Length can't stream past the cap into memory before we notice.
_CSV_UPLOAD_CHUNK_BYTES = 64 * 1024
# FilamentColors.xyz API
FILAMENT_COLORS_API = "https://filamentcolors.xyz/api"
@@ -952,6 +963,92 @@ async def list_spools(
return list(result.scalars().all())
# ── CSV import / export (#1576) ──────────────────────────────────────────────
# Declared before the dynamic `/spools/{spool_id}` route below so the literal
# `export` / `import` segments match here instead of being parsed as an int id.
@router.get("/spools/export")
async def export_spools_csv(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
):
"""Export the active inventory as CSV (same schema the importer accepts)."""
from datetime import datetime, timezone
query = select(Spool).where(Spool.archived_at.is_(None)).order_by(Spool.material, Spool.brand, Spool.color_name)
result = await db.execute(query)
spools = list(result.scalars().all())
content = serialize(spools)
# Date-stamp the filename so repeat exports don't overwrite each other in
# the browser's default download folder.
filename = f"bambuddy_inventory_{datetime.now(timezone.utc).strftime('%Y%m%d')}.csv"
return Response(
content=content,
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/spools/import", response_model=ImportPreview | ImportResult)
async def import_spools_csv(
file: UploadFile = File(...),
dry_run: bool = Query(False),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Import spools from a CSV file.
With ``dry_run=true`` returns an ImportPreview (per-row valid/error/skipped,
colours resolved) and writes nothing — the UI shows this before the user
confirms. With ``dry_run=false`` it validates the same way and then persists
only the valid rows in a single transaction (invalid rows are skipped, the
user fixes the CSV and re-uploads), returning an ImportResult summary.
"""
def _too_large() -> HTTPException:
return HTTPException(
status_code=413,
detail={
"code": "csv_import_too_large",
"message": f"CSV file exceeds the {MAX_CSV_IMPORT_BYTES // (1024 * 1024)} MB limit.",
},
)
# Reject by declared size first (fast path when Content-Length is set), then
# read in bounded chunks and bail the moment the accumulated body crosses the
# cap — file.size is None for chunked uploads, so the loop is what actually
# keeps an oversized stream from filling memory.
if file.size is not None and file.size > MAX_CSV_IMPORT_BYTES:
raise _too_large()
raw = bytearray()
while chunk := await file.read(_CSV_UPLOAD_CHUNK_BYTES):
raw.extend(chunk)
if len(raw) > MAX_CSV_IMPORT_BYTES:
raise _too_large()
preview = await parse_and_validate(bytes(raw), db)
if dry_run:
return preview
created = 0
for row in preview.rows:
if row.status == "valid" and row.spool is not None:
db.add(Spool(**row.spool))
created += 1
if created:
await db.commit()
await ws_manager.broadcast({"type": "inventory_changed"})
return ImportResult(
created=created,
skipped=preview.skipped_count,
errors=preview.error_count,
error_rows=[r for r in preview.rows if r.status == "error"],
)
@router.get("/spools/{spool_id}", response_model=SpoolResponse)
async def get_spool(
spool_id: int,
+549
View File
@@ -0,0 +1,549 @@
"""CSV import/export for the spool inventory (#1576).
One module owns the round-trip: the same fixed column schema is used to
serialise existing spools out and to parse + validate a user-supplied CSV
back in. Validation reuses the `SpoolCreate` Pydantic model so the CSV path
and the form path share a single source of truth — anything the form rejects,
the import rejects too, with the same rules.
The import flow is two-phase by design: `parse_and_validate()` never writes.
The route calls it once for the dry-run preview (so the user sees per-row
valid/error/skipped before committing) and again on confirm, then persists
only the rows that came back `valid`.
"""
import csv
import io
from datetime import datetime
from pydantic import BaseModel, ValidationError
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.models.color_catalog import ColorCatalogEntry
from backend.app.models.spool import Spool
from backend.app.schemas.spool import SpoolCreate
# Fixed CSV header, in output order. Round-trips cleanly: export writes these
# columns, import expects them. `material` is the only required field; the rest
# are optional. Keep aligned with the SpoolCreate fields referenced below.
#
# `remaining` is a derived, export-only column (= label_weight - weight_used).
# It's written out for human readability and round-trip clarity, but ignored on
# import — `weight_used` is the source of truth, and accepting both would let
# them contradict. `last_used` is a timestamp the model carries but SpoolCreate
# does not, so import applies it to the ORM object directly (see persist path).
# `storage_location`, `category` and `low_stock_threshold_pct` are SpoolCreate
# fields included so a round-trip preserves them (they'd otherwise be lost).
CSV_COLUMNS = [
"material",
"brand",
"subtype",
"color_name",
"rgba",
"extra_colors",
"effect_type",
"label_weight",
"weight_used",
"remaining",
"cost_per_kg",
"nozzle_temp_min",
"nozzle_temp_max",
"last_used",
"note",
"storage_location",
"category",
"low_stock_threshold_pct",
]
# Upload ceiling for the import endpoint. A spool inventory CSV is a few KB
# even with thousands of rows; 5 MB is a generous cap that still refuses an
# OOM-sized body before it's read into memory.
MAX_CSV_IMPORT_BYTES = 5 * 1024 * 1024
# Spreadsheet formula-injection guard. A cell whose first character is one of
# these is treated as a formula by Excel / LibreOffice / Sheets; we prefix it
# with a single quote on export so the value renders as literal text.
_FORMULA_INJECTION_PREFIXES = ("=", "+", "-", "@", "\t", "\r")
# Columns whose CSV cell must be coerced to a number before SpoolCreate sees it.
# DictReader hands us strings; SpoolCreate wants int/float. Empty cell → omit
# the field (falls back to the schema default / None).
_INT_COLUMNS = {"label_weight", "nozzle_temp_min", "nozzle_temp_max", "low_stock_threshold_pct"}
_FLOAT_COLUMNS = {"cost_per_kg", "weight_used"}
# label_weight default, pulled from the schema so the weight_used bounds check
# stays in sync if the schema default ever changes.
_DEFAULT_LABEL_WEIGHT = SpoolCreate.model_fields["label_weight"].default
class ImportRowResult(BaseModel):
"""Per-row outcome of a parse+validate pass.
`spool` carries the validated, SpoolCreate-shaped dict for `valid` rows so
the route can persist without re-parsing. `resolved_color` flags rows whose
rgba/extra_colors/effect_type were filled in from the Color Catalog rather
than supplied in the CSV — surfaced in the preview so the user knows a
colour was inferred.
"""
row_number: int # 1-based data row (header is not counted)
status: str # "valid" | "error" | "skipped"
reason: str | None = None
material: str | None = None
brand: str | None = None
color_name: str | None = None
rgba: str | None = None
resolved_color: bool = False
# True when the colour was resolved from a catalog entry of a DIFFERENT
# material (no exact material match existed). Surfaced so the preview can
# warn the user the colour came from another material's variant.
cross_material_color: bool = False
# True when an active spool with the same material+brand+color_name already
# exists. Informational only — the import still creates the row (there's no
# unique constraint); the preview warns so a double-click / re-upload of the
# same CSV doesn't silently duplicate the inventory.
duplicate_of_existing: bool = False
spool: dict | None = None
class ImportPreview(BaseModel):
"""Result of a dry-run (or the pre-write pass of a real import)."""
columns: list[str]
total: int
valid_count: int
error_count: int
skipped_count: int
rows: list[ImportRowResult]
warnings: list[str] = []
class ImportResult(BaseModel):
"""Summary returned after a real (non-dry-run) import."""
created: int
skipped: int
errors: int
error_rows: list[ImportRowResult] = []
def _normalize_header(name: str) -> str:
"""Map a CSV header cell to a canonical field name.
Case- and space-tolerant: "Color Name", "color-name", " COLOR_NAME "
all collapse to "color_name".
"""
return name.strip().lower().replace(" ", "_").replace("-", "_")
def _normalize_rgba(value: str) -> str | None:
"""Coerce a user-supplied colour cell to 8-char RRGGBBAA hex, or None.
Accepts an optional leading `#` and a 6-char RRGGBB form (alpha defaults to
`ff`). Returns None if the value isn't valid hex of length 6 or 8 — the
caller turns that into a row error so it isn't silently dropped.
"""
raw = value.strip().lstrip("#")
if len(raw) not in (6, 8):
return None
try:
int(raw, 16)
except ValueError:
return None
if len(raw) == 6:
raw += "ff"
return raw.lower()
def _parse_datetime(value: str) -> datetime | None:
"""Parse an ISO-8601 timestamp, or None if it isn't valid.
Accepts what `datetime.isoformat()` produces (what export writes) plus a
trailing 'Z' for UTC, which `fromisoformat` rejects before Python 3.11.
"""
raw = value.strip()
if not raw:
return None
if raw.endswith("Z"):
raw = raw[:-1] + "+00:00"
try:
return datetime.fromisoformat(raw)
except ValueError:
return None
async def _load_color_catalog(db: AsyncSession) -> list[ColorCatalogEntry]:
"""Load the whole Color Catalog once so per-row resolution is in-memory.
A CSV can hold hundreds of rows; resolving each with its own SELECT would
be an N+1 against a small, rarely-changing table. We pull it once here and
let `_resolve_color` match against the list.
"""
result = await db.execute(select(ColorCatalogEntry))
return list(result.scalars().all())
def _spool_key(material: str | None, brand: str | None, color_name: str | None) -> tuple[str, str, str]:
"""Case/space-insensitive identity used for the duplicate soft-warn."""
return (
(material or "").strip().lower(),
(brand or "").strip().lower(),
(color_name or "").strip().lower(),
)
async def _load_existing_spool_keys(db: AsyncSession) -> set[tuple[str, str, str]]:
"""Load material+brand+color_name keys of active spools for the dup warning.
Spool has no unique constraint, so a double-click or re-upload of the same
CSV would silently duplicate the inventory. We pull the active spools' keys
once and let the preview flag matching rows — informational only, the import
still creates them.
"""
result = await db.execute(select(Spool.material, Spool.brand, Spool.color_name).where(Spool.archived_at.is_(None)))
return {_spool_key(m, b, c) for m, b, c in result.all()}
def _resolve_color(
catalog: list[ColorCatalogEntry], brand: str | None, color_name: str | None, material: str | None
) -> tuple[str, str | None, str | None, bool] | None:
"""Match brand + color_name against the preloaded catalog (case-insensitive).
Returns (rgba, extra_colors, effect_type, cross_material) on a match, else
None. Prefers an entry whose material matches the row; a catalog entry with
a NULL material is the project's "matches any material" convention and counts
as an exact match too. Only when neither exists does it fall back to another
material's entry and set cross_material=True so the caller can warn that the
colour came from a different material's variant.
"""
if not brand or not color_name:
return None
brand_l = brand.strip().lower()
name_l = color_name.strip().lower()
material_l = material.strip().lower() if material else None
matches = [
entry
for entry in catalog
if entry.hex_color and entry.manufacturer.lower() == brand_l and entry.color_name.lower() == name_l
]
if not matches:
return None
exact = next(
(e for e in matches if e.material is None or (material_l and e.material.lower() == material_l)),
None,
)
row = exact or matches[0]
cross_material = exact is None
rgba = _normalize_rgba(row.hex_color)
if rgba is None:
return None
return rgba, row.extra_colors, row.effect_type, cross_material
def _readable_validation_error(exc: ValidationError) -> str:
"""Flatten a Pydantic ValidationError into one short, user-facing line."""
parts = []
for err in exc.errors():
loc = ".".join(str(p) for p in err.get("loc", ())) or "value"
parts.append(f"{loc}: {err.get('msg', 'invalid')}")
return "; ".join(parts)
def _empty_preview(warnings: list[str]) -> ImportPreview:
"""A preview with no rows — used for the early-exit cases (bad/empty file)."""
return ImportPreview(
columns=CSV_COLUMNS,
total=0,
valid_count=0,
error_count=0,
skipped_count=0,
rows=[],
warnings=warnings,
)
async def parse_and_validate(raw_bytes: bytes, db: AsyncSession) -> ImportPreview:
"""Parse a CSV blob, validate + colour-resolve each row. Never writes.
Decodes UTF-8 (BOM tolerant), reads with DictReader against the fixed
schema, and classifies each row as valid / error / skipped. Valid rows
carry a SpoolCreate-shaped `spool` dict ready to persist.
"""
warnings: list[str] = []
try:
text = raw_bytes.decode("utf-8-sig")
except UnicodeDecodeError:
return _empty_preview(["File is not valid UTF-8 text."])
reader = csv.reader(io.StringIO(text))
try:
header = next(reader)
except StopIteration:
return _empty_preview(["CSV is empty."])
norm_header = [_normalize_header(h) for h in header]
known = set(CSV_COLUMNS)
unknown = [h for h in norm_header if h and h not in known]
if unknown:
warnings.append(f"Ignoring unknown columns: {', '.join(unknown)}")
# Map canonical field name → column index in this file (first occurrence).
col_index: dict[str, int] = {}
for idx, h in enumerate(norm_header):
if h in known and h not in col_index:
col_index[h] = idx
if "material" not in col_index:
return _empty_preview(warnings + ["Required column 'material' is missing from the header."])
# Pull the catalog and the existing-spool keys once; per-row colour
# resolution and the duplicate soft-warn both match in memory rather than
# issuing a SELECT per row.
catalog = await _load_color_catalog(db)
existing_keys = await _load_existing_spool_keys(db)
def cell(row: list[str], field: str) -> str:
idx = col_index.get(field)
if idx is None or idx >= len(row):
return ""
# Strip whitespace, then undo any export-side formula-injection quoting
# so export → import round-trips without accumulating a leading quote.
return _desanitize_cell(row[idx].strip())
rows: list[ImportRowResult] = []
valid = error = skipped = 0
for row_number, raw_row in enumerate(reader, start=1):
# Fully blank row (no non-empty cell) → skip silently.
if not any(c.strip() for c in raw_row):
rows.append(ImportRowResult(row_number=row_number, status="skipped", reason="Empty row"))
skipped += 1
continue
material = cell(raw_row, "material")
brand = cell(raw_row, "brand") or None
color_name = cell(raw_row, "color_name") or None
if not material:
rows.append(
ImportRowResult(
row_number=row_number,
status="error",
reason="material is required",
brand=brand,
color_name=color_name,
)
)
error += 1
continue
data: dict = {"material": material}
if brand:
data["brand"] = brand
if color_name:
data["color_name"] = color_name
row_error: str | None = None
# Plain text passthrough columns.
for field in ("subtype", "effect_type", "extra_colors", "note", "storage_location", "category"):
value = cell(raw_row, field)
if value:
data[field] = value
# Numeric columns: parse only if present, else leave to schema defaults.
for field in _INT_COLUMNS:
value = cell(raw_row, field)
if value:
try:
data[field] = int(value)
except ValueError:
row_error = f"{field} must be a whole number (got '{value}')"
break
if row_error is None:
for field in _FLOAT_COLUMNS:
value = cell(raw_row, field)
if value:
try:
data[field] = float(value)
except ValueError:
row_error = f"{field} must be a number (got '{value}')"
break
# Bounds check: weight_used must be within [0, label_weight]. The schema
# accepts any float, so a negative or over-full value would otherwise be
# imported silently. label_weight falls back to the schema default when
# the CSV omits it.
if row_error is None and "weight_used" in data:
used = data["weight_used"]
label = data.get("label_weight", _DEFAULT_LABEL_WEIGHT)
if used < 0:
row_error = f"weight_used cannot be negative (got {used})"
elif used > label:
row_error = f"weight_used ({used}) exceeds label_weight ({label})"
# `last_used` is an ORM-only timestamp (not on SpoolCreate); parse it
# here and apply it to the validated dict after the SpoolCreate gate.
last_used: datetime | None = None
if row_error is None:
last_used_cell = cell(raw_row, "last_used")
if last_used_cell:
last_used = _parse_datetime(last_used_cell)
if last_used is None:
row_error = f"last_used must be an ISO date/time (got '{last_used_cell}')"
resolved_color = False
cross_material_color = False
if row_error is None:
# Colour precedence: explicit rgba wins; else resolve brand+name
# from the catalog; else leave blank.
rgba_cell = cell(raw_row, "rgba")
if rgba_cell:
normalized = _normalize_rgba(rgba_cell)
if normalized is None:
row_error = f"rgba must be 6- or 8-char hex (got '{rgba_cell}')"
else:
data["rgba"] = normalized
else:
resolved = _resolve_color(catalog, brand, color_name, material)
if resolved is not None:
rgba_val, extra_val, effect_val, cross_material_color = resolved
data["rgba"] = rgba_val
# CSV-supplied extra_colors/effect_type take precedence over
# the catalog's; only fill from catalog when absent.
if extra_val and "extra_colors" not in data:
data["extra_colors"] = extra_val
if effect_val and "effect_type" not in data:
data["effect_type"] = effect_val
resolved_color = True
if row_error is not None:
rows.append(
ImportRowResult(
row_number=row_number,
status="error",
reason=row_error,
material=material,
brand=brand,
color_name=color_name,
)
)
error += 1
continue
# Final gate: SpoolCreate runs the same validators the form uses
# (rgba pattern, extra_colors/effect_type normalisation, bounds).
try:
spool = SpoolCreate(**data)
except ValidationError as exc:
rows.append(
ImportRowResult(
row_number=row_number,
status="error",
reason=_readable_validation_error(exc),
material=material,
brand=brand,
color_name=color_name,
)
)
error += 1
continue
spool_data = spool.model_dump()
if last_used is not None:
# last_used isn't a SpoolCreate field; graft it onto the persisted
# dict so the ORM object carries it.
spool_data["last_used"] = last_used
rows.append(
ImportRowResult(
row_number=row_number,
status="valid",
material=material,
brand=brand,
color_name=color_name,
rgba=spool.rgba,
resolved_color=resolved_color,
cross_material_color=cross_material_color,
duplicate_of_existing=_spool_key(material, brand, color_name) in existing_keys,
spool=spool_data,
)
)
valid += 1
return ImportPreview(
columns=CSV_COLUMNS,
total=valid + error + skipped,
valid_count=valid,
error_count=error,
skipped_count=skipped,
rows=rows,
warnings=warnings,
)
def serialize(spools: list[Spool]) -> bytes:
"""Render spools to CSV bytes using the fixed schema (export side).
rgba is written without a leading `#`, matching the import-side
normalisation, so export → import round-trips without transformation.
`remaining` is derived (label_weight - weight_used) and `last_used` is
written as ISO-8601; empty/None fields become empty cells.
"""
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(CSV_COLUMNS)
for spool in spools:
writer.writerow([_sanitize_cell(_cell_value(spool, col)) for col in CSV_COLUMNS])
return output.getvalue().encode("utf-8")
def _sanitize_cell(value: str) -> str:
"""Neutralise spreadsheet formula injection.
A free-text field (note, color_name) starting with =, +, -, @, tab, or CR
is evaluated as a formula by Excel/Sheets/LibreOffice when the CSV is
opened. Prefixing with a single quote forces it to render as literal text.
`_desanitize_cell` is the exact inverse, applied on import.
"""
if value and value[0] in _FORMULA_INJECTION_PREFIXES:
return "'" + value
return value
def _desanitize_cell(value: str) -> str:
"""Undo `_sanitize_cell` on import so the round-trip is lossless.
Export prefixes formula-looking cells with a single quote; strip exactly
that quote back off when the next character is one of the guarded prefixes,
so `'=SUM(A1)` reads back as `=SUM(A1)` and the value doesn't accumulate a
leading quote on every export→import cycle. A quote followed by anything
else is left untouched — only the prefix `_sanitize_cell` could have added
is removed.
"""
if len(value) >= 2 and value[0] == "'" and value[1] in _FORMULA_INJECTION_PREFIXES:
return value[1:]
return value
def _cell_value(spool: Spool, col: str) -> str:
"""Render one spool field for export. Handles the derived `remaining`
column and ISO-formats `last_used`; everything else is str() of the value."""
if col == "remaining":
# Derived for display: label_weight - weight_used, clamped at 0.
return str(max(0, round((spool.label_weight or 0) - (spool.weight_used or 0))))
value = getattr(spool, col, None)
if value is None:
return ""
if isinstance(value, datetime):
return value.isoformat()
# Whole-number floats (weight_used, cost_per_kg) export as ints — "300",
# not "300.0" — for a cleaner, human-friendly CSV. import re-parses fine.
if isinstance(value, float) and value.is_integer():
return str(int(value))
return str(value)
@@ -0,0 +1,520 @@
"""Integration tests for inventory CSV import/export (#1576).
Covers the export → import round-trip, dry-run preview (no writes), real
import (only valid rows persisted, atomically), and Color Catalog resolution
of brand + color_name → rgba.
"""
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.models.color_catalog import ColorCatalogEntry
from backend.app.models.spool import Spool
def _csv_upload(text: str):
"""Build the multipart `files=` payload for the import endpoint."""
return {"file": ("inventory.csv", text.encode("utf-8"), "text/csv")}
@pytest.mark.asyncio
@pytest.mark.integration
class TestInventoryCsvExport:
async def test_export_returns_csv_with_header_and_rows(self, async_client: AsyncClient, db_session: AsyncSession):
db_session.add(
Spool(material="PLA", brand="Polymaker", color_name="Jade White", rgba="e8e8e8ff", label_weight=1000)
)
await db_session.commit()
response = await async_client.get("/api/v1/inventory/spools/export")
assert response.status_code == 200, response.text
assert response.headers["content-type"].startswith("text/csv")
body = response.text
lines = body.strip().splitlines()
# Header row uses the fixed schema.
assert lines[0].split(",")[0] == "material"
assert "rgba" in lines[0]
# Data row present, rgba written without leading '#'.
assert "Polymaker" in body
assert "e8e8e8ff" in body
assert "#e8e8e8ff" not in body
async def test_export_excludes_archived(self, async_client: AsyncClient, db_session: AsyncSession):
from datetime import datetime, timezone
db_session.add(Spool(material="PLA", brand="Active", color_name="A", rgba="ffffffff"))
db_session.add(
Spool(
material="PETG",
brand="Archived",
color_name="B",
rgba="000000ff",
archived_at=datetime.now(timezone.utc),
)
)
await db_session.commit()
response = await async_client.get("/api/v1/inventory/spools/export")
assert response.status_code == 200, response.text
assert "Active" in response.text
assert "Archived" not in response.text
@pytest.mark.asyncio
@pytest.mark.integration
class TestInventoryCsvImportDryRun:
async def test_dry_run_classifies_rows_and_writes_nothing(
self, async_client: AsyncClient, db_session: AsyncSession
):
csv_text = (
"material,brand,color_name,rgba,label_weight\n"
"PLA,Polymaker,Jade White,e8e8e8ff,1000\n" # valid
",Polymaker,No Material,ffffffff,1000\n" # error: material missing
"PETG,Brand,Bad Hex,zzzz,1000\n" # error: invalid rgba
"\n" # skipped: blank
"ABS,Brand,Color,#00ff00,500\n" # valid: 6-char + '#' tolerated
)
response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
assert response.status_code == 200, response.text
data = response.json()
assert data["valid_count"] == 2
assert data["error_count"] == 2
assert data["skipped_count"] == 1
# 6-char hex got normalised to 8-char.
valid_rows = [r for r in data["rows"] if r["status"] == "valid"]
green = next(r for r in valid_rows if r["color_name"] == "Color")
assert green["rgba"] == "00ff00ff"
# Nothing was written.
result = await db_session.execute(select(Spool))
assert result.scalars().first() is None
async def test_missing_material_column_fails_whole_file(self, async_client: AsyncClient):
csv_text = "brand,color_name\nPolymaker,Jade White\n"
response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
assert response.status_code == 200, response.text
data = response.json()
assert data["valid_count"] == 0
assert any("material" in w for w in data["warnings"])
@pytest.mark.asyncio
@pytest.mark.integration
class TestInventoryCsvImportReal:
async def test_import_persists_only_valid_rows(self, async_client: AsyncClient, db_session: AsyncSession):
csv_text = (
"material,brand,color_name,rgba\n"
"PLA,Polymaker,White,ffffffff\n" # valid
",Polymaker,No Material,ffffffff\n" # error
"PETG,Brand,Color,ff0000ff\n" # valid
)
response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(csv_text))
assert response.status_code == 200, response.text
data = response.json()
assert data["created"] == 2
assert data["errors"] == 1
assert len(data["error_rows"]) == 1
result = await db_session.execute(select(Spool).order_by(Spool.material))
spools = result.scalars().all()
assert len(spools) == 2
assert {s.material for s in spools} == {"PLA", "PETG"}
async def test_case_and_space_tolerant_headers(self, async_client: AsyncClient, db_session: AsyncSession):
csv_text = "Material, Color Name ,RGBA\nPLA,Snow,ffffffff\n"
response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(csv_text))
assert response.status_code == 200, response.text
assert response.json()["created"] == 1
result = await db_session.execute(select(Spool))
spool = result.scalars().one()
assert spool.color_name == "Snow"
@pytest.mark.asyncio
@pytest.mark.integration
class TestInventoryCsvColorResolution:
async def test_brand_and_color_resolve_rgba_from_catalog(self, async_client: AsyncClient, db_session: AsyncSession):
db_session.add(
ColorCatalogEntry(
manufacturer="Polymaker",
color_name="Jade White",
hex_color="#E8E8E8",
material="PLA",
is_default=False,
)
)
await db_session.commit()
# No rgba in CSV — resolved from catalog (case-insensitive match).
csv_text = "material,brand,color_name\nPLA,polymaker,jade white\n"
response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
assert response.status_code == 200, response.text
data = response.json()
assert data["valid_count"] == 1
row = data["rows"][0]
assert row["resolved_color"] is True
assert row["rgba"] == "e8e8e8ff"
async def test_explicit_rgba_wins_over_catalog(self, async_client: AsyncClient, db_session: AsyncSession):
db_session.add(
ColorCatalogEntry(manufacturer="Polymaker", color_name="Jade White", hex_color="#E8E8E8", material="PLA")
)
await db_session.commit()
csv_text = "material,brand,color_name,rgba\nPLA,Polymaker,Jade White,123456ff\n"
response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
assert response.status_code == 200, response.text
row = response.json()["rows"][0]
assert row["rgba"] == "123456ff"
assert row["resolved_color"] is False
async def test_cross_material_fallback_is_flagged(self, async_client: AsyncClient, db_session: AsyncSession):
# Catalog only has a PLA variant of this colour; a PETG row resolves it
# via cross-material fallback and must be flagged.
db_session.add(
ColorCatalogEntry(manufacturer="Polymaker", color_name="Jade White", hex_color="#E8E8E8", material="PLA")
)
await db_session.commit()
csv_text = "material,brand,color_name\nPETG,Polymaker,Jade White\n"
response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
assert response.status_code == 200, response.text
row = response.json()["rows"][0]
assert row["resolved_color"] is True
assert row["cross_material_color"] is True
assert row["rgba"] == "e8e8e8ff"
async def test_exact_material_match_not_flagged(self, async_client: AsyncClient, db_session: AsyncSession):
db_session.add(
ColorCatalogEntry(manufacturer="Polymaker", color_name="Jade White", hex_color="#E8E8E8", material="PETG")
)
await db_session.commit()
csv_text = "material,brand,color_name\nPETG,Polymaker,Jade White\n"
response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
assert response.status_code == 200, response.text
row = response.json()["rows"][0]
assert row["resolved_color"] is True
assert row["cross_material_color"] is False
async def test_generic_material_catalog_entry_not_flagged(
self, async_client: AsyncClient, db_session: AsyncSession
):
# A NULL-material catalog entry is the project's "matches any material"
# convention — resolving a PLA row from it is an exact match, not a
# cross-material fallback, so it must not raise the yellow warning.
db_session.add(
ColorCatalogEntry(manufacturer="Polymaker", color_name="Jade White", hex_color="#E8E8E8", material=None)
)
await db_session.commit()
csv_text = "material,brand,color_name\nPLA,Polymaker,Jade White\n"
response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
assert response.status_code == 200, response.text
row = response.json()["rows"][0]
assert row["resolved_color"] is True
assert row["cross_material_color"] is False
assert row["rgba"] == "e8e8e8ff"
@pytest.mark.asyncio
@pytest.mark.integration
class TestInventoryCsvReviewFollowups:
"""Covers the maintainer-requested hardening (PR #1659 review)."""
async def test_oversized_upload_rejected_413(self, async_client: AsyncClient):
# Build a body just over the 5 MB cap.
from backend.app.services.spool_csv import MAX_CSV_IMPORT_BYTES
header = "material\n"
filler = "PLA\n" * ((MAX_CSV_IMPORT_BYTES // 4) + 10)
big = header + filler
response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(big))
assert response.status_code == 413, response.text
detail = response.json()["detail"]
assert detail["code"] == "csv_import_too_large"
async def test_weight_used_negative_is_error(self, async_client: AsyncClient):
csv_text = "material,color_name,rgba,weight_used\nPLA,X,ffffffff,-5\n"
response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
assert response.status_code == 200, response.text
data = response.json()
assert data["error_count"] == 1
assert "weight_used" in data["rows"][0]["reason"]
async def test_weight_used_exceeds_label_is_error(self, async_client: AsyncClient):
csv_text = "material,color_name,rgba,label_weight,weight_used\nPLA,X,ffffffff,1000,1500\n"
response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
assert response.status_code == 200, response.text
data = response.json()
assert data["error_count"] == 1
assert "exceeds" in data["rows"][0]["reason"]
async def test_export_neutralises_formula_injection(self, async_client: AsyncClient, db_session: AsyncSession):
# A note starting with '=' must be prefixed with a quote on export so
# spreadsheets don't evaluate it as a formula.
db_session.add(Spool(material="PLA", color_name="X", rgba="ffffffff", note="=SUM(A1:A9)"))
await db_session.commit()
response = await async_client.get("/api/v1/inventory/spools/export")
assert response.status_code == 200, response.text
assert "'=SUM(A1:A9)" in response.text
async def test_formula_injection_round_trips_without_quote_accumulation(
self, async_client: AsyncClient, db_session: AsyncSession
):
# The export quote-guard must be undone on import so a formula-looking
# note survives export → import unchanged (no accumulating leading ').
db_session.add(Spool(material="PLA", color_name="X", rgba="ffffffff", note="=SUM(A1)"))
await db_session.commit()
export = await async_client.get("/api/v1/inventory/spools/export")
assert export.status_code == 200, export.text
assert "'=SUM(A1)" in export.text # guarded on export
# Wipe, re-import the exact export, and confirm the note is restored
# to its original value (not "'=SUM(A1)").
existing = await db_session.execute(select(Spool))
for spool in existing.scalars().all():
await db_session.delete(spool)
await db_session.commit()
response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(export.text))
assert response.status_code == 200, response.text
assert response.json()["created"] == 1
result = await db_session.execute(select(Spool))
spool = result.scalars().one()
assert spool.note == "=SUM(A1)" # original value, no leading quote
async def test_export_filename_is_date_stamped(self, async_client: AsyncClient):
response = await async_client.get("/api/v1/inventory/spools/export")
assert response.status_code == 200, response.text
disposition = response.headers.get("content-disposition", "")
assert "bambuddy_inventory_" in disposition
assert disposition.rstrip('"').endswith(".csv")
@pytest.mark.asyncio
@pytest.mark.integration
class TestInventoryCsvRoundTrip:
async def test_export_then_import_recreates_spools(self, async_client: AsyncClient, db_session: AsyncSession):
db_session.add(
Spool(
material="PLA",
brand="Polymaker",
subtype="Matte",
color_name="Jade White",
rgba="e8e8e8ff",
label_weight=1000,
weight_used=250,
cost_per_kg=24.99,
note="batch order",
)
)
await db_session.commit()
export = await async_client.get("/api/v1/inventory/spools/export")
assert export.status_code == 200, export.text
csv_text = export.text
# Wipe and re-import the exact export.
existing = await db_session.execute(select(Spool))
for spool in existing.scalars().all():
await db_session.delete(spool)
await db_session.commit()
response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(csv_text))
assert response.status_code == 200, response.text
assert response.json()["created"] == 1
result = await db_session.execute(select(Spool))
spool = result.scalars().one()
assert spool.material == "PLA"
assert spool.brand == "Polymaker"
assert spool.subtype == "Matte"
assert spool.color_name == "Jade White"
assert spool.rgba == "e8e8e8ff"
assert spool.label_weight == 1000
assert spool.weight_used == 250 # usage round-trips
assert spool.cost_per_kg == 24.99
assert spool.note == "batch order"
@pytest.mark.asyncio
@pytest.mark.integration
class TestInventoryCsvUsageColumns:
async def test_export_writes_weight_used_and_derived_remaining(
self, async_client: AsyncClient, db_session: AsyncSession
):
from datetime import datetime, timezone
db_session.add(
Spool(
material="PLA",
brand="Polymaker",
color_name="White",
rgba="ffffffff",
label_weight=1000,
weight_used=300,
last_used=datetime(2026, 6, 1, 12, 30, tzinfo=timezone.utc),
)
)
await db_session.commit()
response = await async_client.get("/api/v1/inventory/spools/export")
assert response.status_code == 200, response.text
header, row = response.text.strip().splitlines()[:2]
cols = header.split(",")
cells = row.split(",")
record = dict(zip(cols, cells, strict=False))
assert record["weight_used"] == "300"
assert record["remaining"] == "700" # 1000 - 300, derived
assert record["last_used"].startswith("2026-06-01T12:30")
async def test_import_reads_weight_used_ignores_remaining(
self, async_client: AsyncClient, db_session: AsyncSession
):
# remaining is intentionally contradictory — it must be ignored; only
# weight_used is read back.
csv_text = (
"material,brand,color_name,rgba,label_weight,weight_used,remaining\nPLA,Brand,White,ffffffff,1000,400,999\n"
)
response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(csv_text))
assert response.status_code == 200, response.text
assert response.json()["created"] == 1
result = await db_session.execute(select(Spool))
spool = result.scalars().one()
assert spool.weight_used == 400 # from CSV
assert spool.label_weight == 1000 # remaining=999 ignored, not used to back-compute
async def test_import_parses_last_used_iso(self, async_client: AsyncClient, db_session: AsyncSession):
csv_text = "material,color_name,rgba,last_used\nPLA,White,ffffffff,2026-06-01T12:30:00+00:00\n"
response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(csv_text))
assert response.status_code == 200, response.text
assert response.json()["created"] == 1
result = await db_session.execute(select(Spool))
spool = result.scalars().one()
assert spool.last_used is not None
assert spool.last_used.year == 2026 and spool.last_used.month == 6 and spool.last_used.day == 1
async def test_import_rejects_bad_last_used(self, async_client: AsyncClient):
csv_text = "material,color_name,rgba,last_used\nPLA,White,ffffffff,not-a-date\n"
response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
assert response.status_code == 200, response.text
data = response.json()
assert data["error_count"] == 1
assert "last_used" in data["rows"][0]["reason"]
@pytest.mark.asyncio
@pytest.mark.integration
class TestInventoryCsvExtraColumns:
async def test_storage_category_threshold_round_trip(self, async_client: AsyncClient, db_session: AsyncSession):
# storage_location / category / low_stock_threshold_pct must survive an
# export → import cycle (would otherwise be silently lost).
db_session.add(
Spool(
material="PLA",
brand="Polymaker",
color_name="White",
rgba="ffffffff",
storage_location="Shelf B3",
category="Production",
low_stock_threshold_pct=20,
)
)
await db_session.commit()
csv_text = (await async_client.get("/api/v1/inventory/spools/export")).text
for spool in (await db_session.execute(select(Spool))).scalars().all():
await db_session.delete(spool)
await db_session.commit()
response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(csv_text))
assert response.status_code == 200, response.text
assert response.json()["created"] == 1
spool = (await db_session.execute(select(Spool))).scalars().one()
assert spool.storage_location == "Shelf B3"
assert spool.category == "Production"
assert spool.low_stock_threshold_pct == 20
async def test_low_stock_threshold_out_of_range_is_error(self, async_client: AsyncClient):
# SpoolCreate bounds low_stock_threshold_pct to 1..99; the CSV path must
# reject an out-of-range value rather than persist it.
csv_text = "material,color_name,rgba,low_stock_threshold_pct\nPLA,White,ffffffff,150\n"
response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
assert response.status_code == 200, response.text
data = response.json()
assert data["error_count"] == 1
assert "low_stock_threshold_pct" in data["rows"][0]["reason"]
@pytest.mark.asyncio
@pytest.mark.integration
class TestInventoryCsvDuplicateWarning:
async def test_existing_spool_flags_duplicate_but_still_imports(
self, async_client: AsyncClient, db_session: AsyncSession
):
db_session.add(Spool(material="PLA", brand="Polymaker", color_name="Jade White", rgba="e8e8e8ff"))
await db_session.commit()
# Row 1 matches the existing spool (case-insensitively); row 2 is new.
csv_text = (
"material,brand,color_name,rgba\n"
"pla,polymaker,jade white,e8e8e8ff\n" # duplicate of existing
"PETG,OtherBrand,Black,000000ff\n" # new
)
response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
assert response.status_code == 200, response.text
rows = response.json()["rows"]
assert rows[0]["duplicate_of_existing"] is True
assert rows[1]["duplicate_of_existing"] is False
# Soft-warn only: a real import still creates the duplicate row.
real = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(csv_text))
assert real.json()["created"] == 2
all_spools = (await db_session.execute(select(Spool))).scalars().all()
assert len(all_spools) == 3 # 1 pre-existing + 2 imported
@@ -0,0 +1,87 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor, fireEvent } from '@testing-library/react';
import { render } from '../utils';
import { SpoolCsvImportModal } from '../../components/SpoolCsvImportModal';
import { api, type CsvImportPreview } from '../../api/client';
vi.mock('../../api/client', () => ({
api: {
importSpoolsCsvPreview: vi.fn(),
importSpoolsCsv: vi.fn(),
getSettings: vi.fn().mockResolvedValue({}),
getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
},
}));
const preview: CsvImportPreview = {
columns: ['material', 'brand', 'color_name', 'rgba'],
total: 3,
valid_count: 2,
error_count: 1,
skipped_count: 0,
warnings: [],
rows: [
{ row_number: 1, status: 'valid', reason: null, material: 'PLA', brand: 'Polymaker', color_name: 'White', rgba: 'ffffffff', resolved_color: false, cross_material_color: false, duplicate_of_existing: false },
{ row_number: 2, status: 'error', reason: 'material is required', material: null, brand: 'Polymaker', color_name: 'X', rgba: null, resolved_color: false, cross_material_color: false, duplicate_of_existing: false },
{ row_number: 3, status: 'valid', reason: null, material: 'PETG', brand: 'Brand', color_name: 'Jade', rgba: 'e8e8e8ff', resolved_color: true, cross_material_color: true, duplicate_of_existing: false },
],
};
function selectFile() {
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['material\nPLA\n'], 'inventory.csv', { type: 'text/csv' });
fireEvent.change(input, { target: { files: [file] } });
return file;
}
describe('SpoolCsvImportModal', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('shows the preview table with per-row status after a file is chosen', async () => {
vi.mocked(api.importSpoolsCsvPreview).mockResolvedValue(preview);
render(<SpoolCsvImportModal onClose={vi.fn()} onImported={vi.fn()} />);
selectFile();
await waitFor(() => expect(api.importSpoolsCsvPreview).toHaveBeenCalledOnce());
// Summary counts surfaced.
expect(await screen.findByText('2 valid')).toBeInTheDocument();
expect(screen.getByText('1 error')).toBeInTheDocument();
// Error reason rendered inline.
expect(screen.getByText('material is required')).toBeInTheDocument();
// Import button reflects the valid count.
expect(screen.getByText('Import 2 valid rows')).toBeInTheDocument();
});
it('imports only when there are valid rows and reports the created count', async () => {
vi.mocked(api.importSpoolsCsvPreview).mockResolvedValue(preview);
vi.mocked(api.importSpoolsCsv).mockResolvedValue({ created: 2, skipped: 0, errors: 1, error_rows: [] });
const onImported = vi.fn();
render(<SpoolCsvImportModal onClose={vi.fn()} onImported={onImported} />);
selectFile();
const importBtn = await screen.findByText('Import 2 valid rows');
fireEvent.click(importBtn);
await waitFor(() => expect(api.importSpoolsCsv).toHaveBeenCalledOnce());
expect(onImported).toHaveBeenCalledWith(2);
});
it('disables import when no rows are valid', async () => {
vi.mocked(api.importSpoolsCsvPreview).mockResolvedValue({
...preview,
valid_count: 0,
error_count: 1,
rows: [preview.rows[1]],
});
render(<SpoolCsvImportModal onClose={vi.fn()} onImported={vi.fn()} />);
selectFile();
const noValid = await screen.findByText('No valid rows');
expect(noValid.closest('button')).toBeDisabled();
});
});
+88
View File
@@ -178,6 +178,30 @@ async function request<T>(
return await response.json();
}
/** Upload a CSV to the spool import endpoint (#1576). Multipart, so it bypasses
* `request<T>()` (which sends JSON): the browser must set the form-data
* boundary itself. `dryRun` toggles preview-only vs. real import. */
async function uploadSpoolsCsv<T>(file: File, dryRun: boolean): Promise<T> {
const form = new FormData();
form.append('file', file);
const headers: Record<string, string> = {};
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
const response = await fetch(`${API_BASE}/inventory/spools/import${dryRun ? '?dry_run=true' : ''}`, {
method: 'POST',
headers,
body: form,
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
// detail may be a plain string or a structured {code, message} object
// (e.g. the 413 too-large response). Surface the human message either way.
const detail = error?.detail;
const message = typeof detail === 'string' ? detail : detail?.message;
throw new Error(message || `HTTP ${response.status}`);
}
return response.json();
}
// Camera diagnostic result (#1395 follow-up). Returned by
// POST /printers/{id}/camera/diagnose; the frontend modal renders one
// row per stage and looks up the summary code in i18n for the user-
@@ -2626,6 +2650,45 @@ export interface SpoolmanBulkCreateResult {
failed_count: number;
}
// ── CSV import/export (#1576) ──────────────────────────────────────────────
/** One row's outcome from the import preview / real import. */
export interface CsvImportRow {
row_number: number;
status: 'valid' | 'error' | 'skipped';
reason: string | null;
material: string | null;
brand: string | null;
color_name: string | null;
rgba: string | null;
/** rgba/extra_colors/effect_type were filled from the Color Catalog. */
resolved_color: boolean;
/** The catalog match came from a different material's variant (no exact
* material match). Shown as a warning in the preview. */
cross_material_color: boolean;
/** An active spool with the same material+brand+color already exists.
* Informational only — the import still creates the row. */
duplicate_of_existing: boolean;
}
/** Dry-run preview: per-row classification, no rows written. */
export interface CsvImportPreview {
columns: string[];
total: number;
valid_count: number;
error_count: number;
skipped_count: number;
rows: CsvImportRow[];
warnings: string[];
}
/** Summary returned after a real (non-dry-run) import. */
export interface CsvImportResult {
created: number;
skipped: number;
errors: number;
error_rows: CsvImportRow[];
}
export interface SpoolUsageRecord {
id: number;
spool_id: number;
@@ -4878,6 +4941,31 @@ export const api = {
method: 'POST',
body: JSON.stringify({ spool: data, quantity }),
}),
// ── CSV import/export (#1576) ────────────────────────────────────────────
// dry_run=true → preview (no write); omitted → real import. Both share one
// multipart upload helper; see `uploadSpoolsCsv` below.
importSpoolsCsvPreview: (file: File): Promise<CsvImportPreview> => uploadSpoolsCsv<CsvImportPreview>(file, true),
importSpoolsCsv: (file: File): Promise<CsvImportResult> => uploadSpoolsCsv<CsvImportResult>(file, false),
exportSpoolsCsv: async (): Promise<void> => {
const headers: Record<string, string> = {};
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
const response = await fetch(`${API_BASE}/inventory/spools/export`, { headers });
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.detail || `HTTP ${response.status}`);
}
const disposition = response.headers.get('Content-Disposition');
const filename = parseContentDispositionFilename(disposition) || 'bambuddy_inventory.csv';
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
},
updateSpool: (id: number, data: Partial<Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>>) =>
request<InventorySpool>(`/inventory/spools/${id}`, {
method: 'PATCH',
@@ -0,0 +1,242 @@
import { useState, useRef, type DragEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { Upload, X, FileText, Loader2, CheckCircle, XCircle, MinusCircle, Wand2, AlertTriangle, Copy } from 'lucide-react';
import { api, type CsvImportPreview, type CsvImportRow } from '../api/client';
import { getSwatchStyle } from '../utils/colors';
import { Button } from './Button';
interface SpoolCsvImportModalProps {
onClose: () => void;
/** Called after a successful import so the page can refetch the inventory. */
onImported: (created: number) => void;
}
/**
* CSV import flow (#1576): pick a file → backend dry-run preview (per-row
* valid/error/skipped, colours resolved) → user reviews → confirm imports only
* the valid rows. Nothing is written until confirm.
*/
export function SpoolCsvImportModal({ onClose, onImported }: SpoolCsvImportModalProps) {
const { t } = useTranslation();
const [file, setFile] = useState<File | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [preview, setPreview] = useState<CsvImportPreview | null>(null);
const [loading, setLoading] = useState(false);
const [importing, setImporting] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const loadPreview = async (selected: File) => {
setFile(selected);
setPreview(null);
setError(null);
setLoading(true);
try {
const result = await api.importSpoolsCsvPreview(selected);
setPreview(result);
} catch (err) {
setError(err instanceof Error ? err.message : t('inventory.csv.previewError', 'Could not read the CSV file'));
} finally {
setLoading(false);
}
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const selected = e.target.files?.[0];
if (selected) loadPreview(selected);
};
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(false);
const dropped = e.dataTransfer.files?.[0];
if (dropped) loadPreview(dropped);
};
const handleImport = async () => {
if (!file) return;
setImporting(true);
setError(null);
try {
const result = await api.importSpoolsCsv(file);
onImported(result.created);
} catch (err) {
setError(err instanceof Error ? err.message : t('inventory.csv.importError', 'Import failed'));
setImporting(false);
}
};
const statusIcon = (status: CsvImportRow['status']) => {
if (status === 'valid') return <CheckCircle className="w-4 h-4 text-green-500 flex-shrink-0" />;
if (status === 'error') return <XCircle className="w-4 h-4 text-red-500 flex-shrink-0" />;
return <MinusCircle className="w-4 h-4 text-bambu-gray flex-shrink-0" />;
};
const validCount = preview?.valid_count ?? 0;
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-bambu-dark-secondary rounded-lg w-full max-w-3xl border border-bambu-dark-tertiary flex flex-col max-h-[90vh]">
<div className="p-4 border-b border-bambu-dark-tertiary flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">{t('inventory.csv.modalTitle', 'Import spools from CSV')}</h2>
<button onClick={onClose} className="p-1 hover:bg-bambu-dark rounded">
<X className="w-5 h-5 text-bambu-gray" />
</button>
</div>
<div className="p-4 space-y-4 overflow-y-auto flex-1">
{/* Drop zone / file picker */}
<div
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={(e) => {
e.preventDefault();
setIsDragging(false);
}}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
isDragging
? 'border-bambu-green bg-bambu-green/10'
: 'border-bambu-dark-tertiary hover:border-bambu-green/50'
}`}
>
<Upload className={`w-9 h-9 mx-auto mb-2 ${isDragging ? 'text-bambu-green' : 'text-bambu-gray'}`} />
{file ? (
<p className="text-white font-medium flex items-center justify-center gap-2">
<FileText className="w-4 h-4" /> {file.name}
</p>
) : (
<>
<p className="text-white font-medium">{t('inventory.csv.selectFile', 'Choose a CSV file or drag it here')}</p>
<p className="text-xs text-bambu-gray/70 mt-1">{t('inventory.csv.dragHint', 'Header: material (required), brand, subtype, color_name, rgba, …')}</p>
</>
)}
</div>
<input ref={fileInputRef} type="file" accept=".csv,text/csv" className="hidden" onChange={handleFileSelect} />
{loading && (
<div className="flex items-center justify-center gap-2 text-bambu-gray py-4">
<Loader2 className="w-4 h-4 animate-spin" />
{t('inventory.csv.parsing', 'Reading file…')}
</div>
)}
{error && (
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg flex items-start gap-3">
<XCircle className="w-5 h-5 text-red-400 mt-0.5 flex-shrink-0" />
<p className="text-sm text-red-300 break-words">{error}</p>
</div>
)}
{preview && (
<>
{/* Summary */}
<div className="flex flex-wrap gap-3 text-sm">
<span className="px-2 py-1 rounded bg-green-500/10 text-green-400">
{t('inventory.csv.validCount', '{{count}} valid', { count: preview.valid_count })}
</span>
<span className="px-2 py-1 rounded bg-red-500/10 text-red-400">
{t('inventory.csv.errorCount', '{{count}} error', { count: preview.error_count })}
</span>
<span className="px-2 py-1 rounded bg-bambu-dark text-bambu-gray">
{t('inventory.csv.skippedCount', '{{count}} skipped', { count: preview.skipped_count })}
</span>
</div>
{preview.warnings.length > 0 && (
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg space-y-1">
{preview.warnings.map((w, i) => (
<p key={i} className="text-xs text-yellow-300">{w}</p>
))}
</div>
)}
{/* Preview table */}
{preview.rows.length > 0 && (
<div className="border border-bambu-dark-tertiary rounded-lg overflow-hidden">
<div className="max-h-72 overflow-y-auto">
<table className="w-full text-sm">
<thead className="bg-bambu-dark sticky top-0">
<tr className="text-left text-bambu-gray">
<th className="px-3 py-2 font-medium">{t('inventory.csv.colRow', 'Row')}</th>
<th className="px-3 py-2 font-medium">{t('inventory.csv.colStatus', 'Status')}</th>
<th className="px-3 py-2 font-medium">{t('inventory.material', 'Material')}</th>
<th className="px-3 py-2 font-medium">{t('inventory.brand', 'Brand')}</th>
<th className="px-3 py-2 font-medium">{t('inventory.csv.colColor', 'Color')}</th>
</tr>
</thead>
<tbody>
{preview.rows.map((row) => (
<tr key={row.row_number} className="border-t border-bambu-dark-tertiary">
<td className="px-3 py-2 text-bambu-gray">{row.row_number}</td>
<td className="px-3 py-2">
<div className="flex items-center gap-1.5">
{statusIcon(row.status)}
{row.status === 'error' && row.reason && (
<span className="text-xs text-red-400 break-words">{row.reason}</span>
)}
</div>
</td>
<td className="px-3 py-2 text-white">{row.material || '—'}</td>
<td className="px-3 py-2 text-white">{row.brand || '—'}</td>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
{row.rgba && (
<span
className="inline-block w-4 h-4 rounded-full border border-bambu-dark-tertiary flex-shrink-0"
style={getSwatchStyle(row.rgba)}
/>
)}
<span className="text-white">{row.color_name || '—'}</span>
{row.resolved_color && !row.cross_material_color && (
<span title={t('inventory.csv.colorResolved', 'Color filled from catalog')}>
<Wand2 className="w-3.5 h-3.5 text-bambu-green flex-shrink-0" />
</span>
)}
{row.cross_material_color && (
<span title={t('inventory.csv.colorCrossMaterial', 'Color taken from a different material — no exact match in catalog')}>
<AlertTriangle className="w-3.5 h-3.5 text-yellow-500 flex-shrink-0" />
</span>
)}
{row.duplicate_of_existing && (
<span title={t('inventory.csv.duplicateExisting', 'A spool with this material, brand and color already exists — it will still be imported as a new spool')}>
<Copy className="w-3.5 h-3.5 text-amber-400 flex-shrink-0" />
</span>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</>
)}
</div>
<div className="p-4 border-t border-bambu-dark-tertiary flex justify-end gap-2">
<Button variant="secondary" onClick={onClose} disabled={importing}>
{t('common.cancel')}
</Button>
<Button onClick={handleImport} disabled={!preview || validCount === 0 || importing}>
{importing ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{t('inventory.csv.importing', 'Importing…')}
</>
) : validCount > 0 ? (
t('inventory.csv.importValidRows', 'Import {{count}} valid rows', { count: validCount })
) : (
t('inventory.csv.noValidRows', 'No valid rows')
)}
</Button>
</div>
</div>
</div>
);
}
+25
View File
@@ -3679,6 +3679,31 @@ export default {
},
},
},
csv: {
importButton: 'CSV importieren',
exportButton: 'CSV exportieren',
modalTitle: 'Spulen aus CSV importieren',
selectFile: 'CSV-Datei auswählen oder hierher ziehen',
dragHint: 'Kopfzeile: material (erforderlich), brand, subtype, color_name, rgba, …',
parsing: 'Datei wird gelesen…',
previewError: 'CSV-Datei konnte nicht gelesen werden',
validCount: '{{count}} gültig',
errorCount: '{{count}} Fehler',
skippedCount: '{{count}} übersprungen',
colRow: 'Zeile',
colStatus: 'Status',
colColor: 'Farbe',
colorResolved: 'Farbe aus Katalog übernommen',
colorCrossMaterial: 'Farbe von einem anderen Material übernommen — keine exakte Übereinstimmung im Katalog',
duplicateExisting: 'Eine Spule mit diesem Material, dieser Marke und Farbe existiert bereits — sie wird trotzdem als neue Spule importiert',
spoolmanHint: 'Im Spoolman-Modus den integrierten CSV-Import/-Export von Spoolman verwenden.',
importValidRows: '{{count}} gültige Zeilen importieren',
noValidRows: 'Keine gültigen Zeilen',
importing: 'Wird importiert…',
importSuccess: '{{count}} Spulen importiert',
importError: 'Import fehlgeschlagen',
exportError: 'Export fehlgeschlagen',
},
addSpool: 'Spule hinzufügen',
copySpool: 'Spule kopieren',
editSpool: 'Spule bearbeiten',
+25
View File
@@ -3682,6 +3682,31 @@ export default {
},
},
},
csv: {
importButton: 'Import CSV',
exportButton: 'Export CSV',
modalTitle: 'Import spools from CSV',
selectFile: 'Choose a CSV file or drag it here',
dragHint: 'Header: material (required), brand, subtype, color_name, rgba, …',
parsing: 'Reading file…',
previewError: 'Could not read the CSV file',
validCount: '{{count}} valid',
errorCount: '{{count}} error',
skippedCount: '{{count}} skipped',
colRow: 'Row',
colStatus: 'Status',
colColor: 'Color',
colorResolved: 'Color filled from catalog',
colorCrossMaterial: 'Color taken from a different material — no exact match in catalog',
duplicateExisting: 'A spool with this material, brand and color already exists — it will still be imported as a new spool',
spoolmanHint: 'In Spoolman mode, use Spoolman\'s built-in CSV import/export.',
importValidRows: 'Import {{count}} valid rows',
noValidRows: 'No valid rows',
importing: 'Importing…',
importSuccess: '{{count}} spools imported',
importError: 'Import failed',
exportError: 'Export failed',
},
addSpool: 'Add Spool',
editSpool: 'Edit Spool',
copySpool: 'Copy Spool',
+25
View File
@@ -3682,6 +3682,31 @@ export default {
},
},
},
csv: {
importButton: 'Importar CSV',
exportButton: 'Exportar CSV',
modalTitle: 'Importar bobinas desde CSV',
selectFile: 'Elige un archivo CSV o arrástralo aquí',
dragHint: 'Encabezado: material (obligatorio), brand, subtype, color_name, rgba, …',
parsing: 'Leyendo archivo…',
previewError: 'No se pudo leer el archivo CSV',
validCount: '{{count}} válidas',
errorCount: '{{count}} con error',
skippedCount: '{{count}} omitidas',
colRow: 'Fila',
colStatus: 'Estado',
colColor: 'Color',
colorResolved: 'Color rellenado desde el catálogo',
colorCrossMaterial: 'Color tomado de un material diferente — sin coincidencia exacta en el catálogo',
duplicateExisting: 'Ya existe una bobina con este material, marca y color — se importará igualmente como una bobina nueva',
spoolmanHint: 'En modo Spoolman, usa la importación/exportación CSV integrada de Spoolman.',
importValidRows: 'Importar {{count}} filas válidas',
noValidRows: 'Sin filas válidas',
importing: 'Importando…',
importSuccess: '{{count}} bobinas importadas',
importError: 'Error al importar',
exportError: 'Error al exportar',
},
addSpool: 'Añadir bobina',
editSpool: 'Editar bobina',
copySpool: 'Copiar bobina',
+25
View File
@@ -3668,6 +3668,31 @@ export default {
},
},
},
csv: {
importButton: 'Importer CSV',
exportButton: 'Exporter CSV',
modalTitle: 'Importer des bobines depuis un CSV',
selectFile: 'Choisissez un fichier CSV ou glissez-le ici',
dragHint: 'En-tête : material (requis), brand, subtype, color_name, rgba, …',
parsing: 'Lecture du fichier…',
previewError: 'Impossible de lire le fichier CSV',
validCount: '{{count}} valides',
errorCount: '{{count}} en erreur',
skippedCount: '{{count}} ignorées',
colRow: 'Ligne',
colStatus: 'Statut',
colColor: 'Couleur',
colorResolved: 'Couleur remplie depuis le catalogue',
colorCrossMaterial: 'Couleur reprise d\'un autre matériau — aucune correspondance exacte dans le catalogue',
duplicateExisting: 'Une bobine avec ce matériau, cette marque et cette couleur existe déjà — elle sera tout de même importée comme une nouvelle bobine',
spoolmanHint: 'En mode Spoolman, utilisez l\'import/export CSV intégré de Spoolman.',
importValidRows: 'Importer {{count}} lignes valides',
noValidRows: 'Aucune ligne valide',
importing: 'Importation…',
importSuccess: '{{count}} bobines importées',
importError: 'Échec de l\'importation',
exportError: 'Échec de l\'exportation',
},
addSpool: 'Ajouter Bobine',
editSpool: 'Modifier Bobine',
copySpool: 'Copier Bobine',
+25
View File
@@ -3667,6 +3667,31 @@ export default {
},
},
},
csv: {
importButton: 'Importa CSV',
exportButton: 'Esporta CSV',
modalTitle: 'Importa bobine da CSV',
selectFile: 'Scegli un file CSV o trascinalo qui',
dragHint: 'Intestazione: material (obbligatorio), brand, subtype, color_name, rgba, …',
parsing: 'Lettura del file…',
previewError: 'Impossibile leggere il file CSV',
validCount: '{{count}} valide',
errorCount: '{{count}} con errore',
skippedCount: '{{count}} ignorate',
colRow: 'Riga',
colStatus: 'Stato',
colColor: 'Colore',
colorResolved: 'Colore compilato dal catalogo',
colorCrossMaterial: 'Colore preso da un materiale diverso — nessuna corrispondenza esatta nel catalogo',
duplicateExisting: 'Esiste già una bobina con questo materiale, marca e colore — verrà comunque importata come nuova bobina',
spoolmanHint: 'In modalità Spoolman, usa l\'importazione/esportazione CSV integrata di Spoolman.',
importValidRows: 'Importa {{count}} righe valide',
noValidRows: 'Nessuna riga valida',
importing: 'Importazione…',
importSuccess: '{{count}} bobine importate',
importError: 'Importazione non riuscita',
exportError: 'Esportazione non riuscita',
},
addSpool: 'Aggiungi Bobina',
editSpool: 'Modifica Bobina',
copySpool: 'Copia Bobina',
+25
View File
@@ -3679,6 +3679,31 @@ export default {
},
},
},
csv: {
importButton: 'CSVインポート',
exportButton: 'CSVエクスポート',
modalTitle: 'CSVからスプールをインポート',
selectFile: 'CSVファイルを選択するか、ここにドラッグ',
dragHint: 'ヘッダー: material(必須)、brand、subtype、color_name、rgba、…',
parsing: 'ファイルを読み込み中…',
previewError: 'CSVファイルを読み込めませんでした',
validCount: '有効{{count}}件',
errorCount: 'エラー{{count}}件',
skippedCount: 'スキップ{{count}}件',
colRow: '行',
colStatus: 'ステータス',
colColor: '色',
colorResolved: 'カタログから色を補完',
colorCrossMaterial: '別の素材から色を取得しました — カタログに完全一致なし',
duplicateExisting: 'この素材・ブランド・色のスプールは既に存在します — それでも新しいスプールとしてインポートされます',
spoolmanHint: 'Spoolmanモードでは、Spoolman内蔵のCSVインポート/エクスポートを使用してください。',
importValidRows: '有効な{{count}}行をインポート',
noValidRows: '有効な行がありません',
importing: 'インポート中…',
importSuccess: '{{count}}個のスプールをインポートしました',
importError: 'インポートに失敗しました',
exportError: 'エクスポートに失敗しました',
},
addSpool: 'スプールを追加',
editSpool: 'スプールを編集',
copySpool: 'スプールをコピー',
+25
View File
@@ -3470,6 +3470,31 @@ export default {
color: '색상 순'
}
},
csv: {
importButton: 'CSV 가져오기',
exportButton: 'CSV 내보내기',
modalTitle: 'CSV에서 스풀 가져오기',
selectFile: 'CSV 파일을 선택하거나 여기로 끌어다 놓으세요',
dragHint: '헤더: material(필수), brand, subtype, color_name, rgba, …',
parsing: '파일을 읽는 중…',
previewError: 'CSV 파일을 읽을 수 없습니다',
validCount: '유효 {{count}}개',
errorCount: '오류 {{count}}개',
skippedCount: '건너뜀 {{count}}개',
colRow: '행',
colStatus: '상태',
colColor: '색상',
colorResolved: '카탈로그에서 색상 채움',
colorCrossMaterial: '다른 재료에서 색상을 가져옴 — 카탈로그에 정확히 일치하는 항목 없음',
duplicateExisting: '이 재료, 브랜드, 색상의 스풀이 이미 존재합니다 — 그래도 새 스풀로 가져옵니다',
spoolmanHint: 'Spoolman 모드에서는 Spoolman의 기본 CSV 가져오기/내보내기를 사용하세요.',
importValidRows: '유효한 {{count}}개 행 가져오기',
noValidRows: '유효한 행 없음',
importing: '가져오는 중…',
importSuccess: '{{count}}개 스풀을 가져왔습니다',
importError: '가져오기 실패',
exportError: '내보내기 실패',
},
addSpool: '스풀 추가',
editSpool: '스풀 편집',
copySpool: '스풀 복사',
+25
View File
@@ -3667,6 +3667,31 @@ export default {
},
},
},
csv: {
importButton: 'Importar CSV',
exportButton: 'Exportar CSV',
modalTitle: 'Importar carretéis de CSV',
selectFile: 'Escolha um arquivo CSV ou arraste-o aqui',
dragHint: 'Cabeçalho: material (obrigatório), brand, subtype, color_name, rgba, …',
parsing: 'Lendo arquivo…',
previewError: 'Não foi possível ler o arquivo CSV',
validCount: '{{count}} válidos',
errorCount: '{{count}} com erro',
skippedCount: '{{count}} ignorados',
colRow: 'Linha',
colStatus: 'Status',
colColor: 'Cor',
colorResolved: 'Cor preenchida pelo catálogo',
colorCrossMaterial: 'Cor obtida de um material diferente — sem correspondência exata no catálogo',
duplicateExisting: 'Já existe uma bobina com este material, marca e cor — ela será importada mesmo assim como uma nova bobina',
spoolmanHint: 'No modo Spoolman, use a importação/exportação CSV integrada do Spoolman.',
importValidRows: 'Importar {{count}} linhas válidas',
noValidRows: 'Nenhuma linha válida',
importing: 'Importando…',
importSuccess: '{{count}} carretéis importados',
importError: 'Falha na importação',
exportError: 'Falha na exportação',
},
addSpool: 'Adicionar Carretel',
editSpool: 'Editar Carretel',
copySpool: 'Copiar Carretel',
+25
View File
@@ -3668,6 +3668,31 @@ export default {
},
},
},
csv: {
importButton: 'CSV İçe Aktar',
exportButton: 'CSV Dışa Aktar',
modalTitle: 'CSV\'den makara içe aktar',
selectFile: 'Bir CSV dosyası seçin veya buraya sürükleyin',
dragHint: 'Başlık: material (zorunlu), brand, subtype, color_name, rgba, …',
parsing: 'Dosya okunuyor…',
previewError: 'CSV dosyası okunamadı',
validCount: '{{count}} geçerli',
errorCount: '{{count}} hatalı',
skippedCount: '{{count}} atlandı',
colRow: 'Satır',
colStatus: 'Durum',
colColor: 'Renk',
colorResolved: 'Renk katalogdan dolduruldu',
colorCrossMaterial: 'Renk farklı bir malzemeden alındı — katalogda tam eşleşme yok',
duplicateExisting: 'Bu malzeme, marka ve renkte bir makara zaten var — yine de yeni makara olarak içe aktarılacak',
spoolmanHint: 'Spoolman modunda Spoolman\'ın yerleşik CSV içe/dışa aktarımını kullanın.',
importValidRows: '{{count}} geçerli satırı içe aktar',
noValidRows: 'Geçerli satır yok',
importing: 'İçe aktarılıyor…',
importSuccess: '{{count}} makara içe aktarıldı',
importError: 'İçe aktarma başarısız',
exportError: 'Dışa aktarma başarısız',
},
addSpool: 'Makara Ekle',
editSpool: 'Makarayı Düzenle',
copySpool: 'Makarayı Kopyala',
+25
View File
@@ -3667,6 +3667,31 @@ export default {
},
},
},
csv: {
importButton: '导入 CSV',
exportButton: '导出 CSV',
modalTitle: '从 CSV 导入耗材',
selectFile: '选择 CSV 文件或拖放到此处',
dragHint: '表头:material(必填)、brand、subtype、color_name、rgba、…',
parsing: '正在读取文件…',
previewError: '无法读取 CSV 文件',
validCount: '{{count}} 个有效',
errorCount: '{{count}} 个错误',
skippedCount: '{{count}} 个跳过',
colRow: '行',
colStatus: '状态',
colColor: '颜色',
colorResolved: '已从目录填充颜色',
colorCrossMaterial: '颜色取自其他材料 — 目录中无精确匹配',
duplicateExisting: '已存在具有此材料、品牌和颜色的料卷 — 仍会作为新料卷导入',
spoolmanHint: '在 Spoolman 模式下,请使用 Spoolman 内置的 CSV 导入/导出。',
importValidRows: '导入 {{count}} 个有效行',
noValidRows: '没有有效行',
importing: '正在导入…',
importSuccess: '已导入 {{count}} 个耗材',
importError: '导入失败',
exportError: '导出失败',
},
addSpool: '添加耗材',
editSpool: '编辑耗材',
copySpool: '复制耗材',
+25
View File
@@ -3667,6 +3667,31 @@ export default {
},
},
},
csv: {
importButton: '匯入 CSV',
exportButton: '匯出 CSV',
modalTitle: '從 CSV 匯入耗材',
selectFile: '選擇 CSV 檔案或拖放到此處',
dragHint: '標頭:material(必填)、brand、subtype、color_name、rgba、…',
parsing: '正在讀取檔案…',
previewError: '無法讀取 CSV 檔案',
validCount: '{{count}} 個有效',
errorCount: '{{count}} 個錯誤',
skippedCount: '{{count}} 個略過',
colRow: '列',
colStatus: '狀態',
colColor: '顏色',
colorResolved: '已從目錄填入顏色',
colorCrossMaterial: '顏色取自其他材料 — 目錄中無精確匹配',
duplicateExisting: '已存在具有此材料、品牌和顏色的線材卷 — 仍會作為新線材卷匯入',
spoolmanHint: '在 Spoolman 模式下,請使用 Spoolman 內建的 CSV 匯入/匯出。',
importValidRows: '匯入 {{count}} 個有效列',
noValidRows: '沒有有效列',
importing: '正在匯入…',
importSuccess: '已匯入 {{count}} 個耗材',
importError: '匯入失敗',
exportError: '匯出失敗',
},
addSpool: '新增耗材',
editSpool: '編輯耗材',
copySpool: '複製耗材',
+58
View File
@@ -7,6 +7,7 @@ import {
Search, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
TrendingDown, Layers, Printer, AlertTriangle, X, Clock, LayoutGrid, TableProperties, Columns,
ArrowUp, ArrowDown, ArrowUpDown, Group, ChevronDown, Check, RefreshCw, TrendingUp, Lock, Copy, Eraser,
Upload, Download,
} from 'lucide-react';
import { ForecastPanel } from '../components/ForecastPanel';
import { api, spoolbuddyApi, ApiError } from '../api/client';
@@ -18,6 +19,7 @@ import {SpoolFormModal, type SpoolFormMode} from '../components/SpoolFormModal';
import { ConfirmModal } from '../components/ConfirmModal';
import { ColumnConfigModal, type ColumnConfig } from '../components/ColumnConfigModal';
import { LabelTemplatePickerModal } from '../components/LabelTemplatePickerModal';
import { SpoolCsvImportModal } from '../components/SpoolCsvImportModal';
import { useToast } from '../contexts/ToastContext';
import { useAuth } from '../contexts/AuthContext';
import { resolveSpoolColorName } from '../utils/colors';
@@ -473,6 +475,9 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
>(null);
// Label printing (#809). null = closed; otherwise the IDs to print labels for.
const [labelPickerSpoolIds, setLabelPickerSpoolIds] = useState<number[] | null>(null);
// CSV import/export (#1576). Local inventory only — hidden in Spoolman mode.
const [csvImportOpen, setCsvImportOpen] = useState(false);
const [exportingCsv, setExportingCsv] = useState(false);
// Filter state
const [archiveFilter, setArchiveFilter] = useState<ArchiveFilter>('active');
@@ -527,6 +532,26 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
refetchInterval: 30000,
});
// CSV export (#1576) — downloads the active inventory as a CSV file.
const handleExportCsv = useCallback(async () => {
setExportingCsv(true);
try {
await api.exportSpoolsCsv();
} catch (err) {
showToast(
err instanceof Error ? err.message : t('inventory.csv.exportError', 'Export failed'),
'error',
);
} finally {
setExportingCsv(false);
}
}, [showToast, t]);
// Shown as the tooltip on both CSV buttons when they're disabled in Spoolman mode.
const spoolmanCsvHint = spoolmanMode
? t('inventory.csv.spoolmanHint', 'In Spoolman mode, use Spoolman\'s built-in CSV import/export.')
: undefined;
// Deep-link: open edit modal for ?spool=<id>
// Prefer the already-loaded spool list (no extra API call); fall back to a
// targeted fetch for the rare case where the full list hasn't arrived yet.
@@ -1104,6 +1129,28 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
<p className="text-bambu-gray mt-1">{t('inventory.subtitle')}</p>
</div>
<div className="flex items-center gap-2">
{/* CSV import/export (#1576). Operates on Bambuddy's local inventory.
In Spoolman mode the buttons stay visible (feature parity) but are
disabled with a hint pointing at Spoolman's own CSV export, since
Spoolman owns the data store in that mode. */}
<Button
variant="secondary"
disabled={spoolmanMode}
onClick={() => setCsvImportOpen(true)}
title={spoolmanCsvHint}
>
<Upload className="w-4 h-4" />
{t('inventory.csv.importButton', 'Import CSV')}
</Button>
<Button
variant="secondary"
disabled={spoolmanMode || exportingCsv}
onClick={handleExportCsv}
title={spoolmanCsvHint}
>
{exportingCsv ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
{t('inventory.csv.exportButton', 'Export CSV')}
</Button>
<Button
variant="secondary"
disabled={filteredSpools.length === 0}
@@ -1931,6 +1978,17 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
initialSelectedIds={labelPickerSpoolIds ?? []}
spoolmanMode={spoolmanMode}
/>
{csvImportOpen && (
<SpoolCsvImportModal
onClose={() => setCsvImportOpen(false)}
onImported={(created) => {
setCsvImportOpen(false);
queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
showToast(t('inventory.csv.importSuccess', '{{count}} spools imported', { count: created }), 'success');
}}
/>
)}
</div>
);
}