fix(inventory): "Reset usage to 0" works in Spoolman mode too (#1390)

First cut of this action only wired the built-in inventory path, so the
  eraser buttons vanished when the user switched to Spoolman mode. Mirror
  the endpoints on the Spoolman router:

  - POST /spoolman/inventory/spools/{id}/reset-usage
  - POST /spoolman/inventory/spools/reset-usage-bulk

  Both route to a new SpoolmanClient.reset_spool_usage() helper that PATCHes
  /spool/{id} with used_weight=0. The bulk variant keeps the same typo-wipe
  guard (rejects empty/missing spool_ids), and individual Spoolman failures
  are logged + counted out without aborting the batch.

  InventoryPage mutations now switch on spoolmanMode to pick the right
  client method, and the three "spoolmanMode ? undefined : ..." gates on
  the eraser buttons are gone.
This commit is contained in:
maziggy
2026-05-17 15:04:31 +02:00
parent f645bd2bbe
commit 8b9efd0160
19 changed files with 626 additions and 140 deletions
+4
View File
@@ -4,6 +4,10 @@ All notable changes to Bambuddy will be documented in this file.
## [0.2.5b1] - Unreleased
### Added
- **Inventory: "Reset usage to 0" also works in Spoolman mode (#1390 follow-up)** — The first cut of this action only wired the built-in inventory path, so Spoolman users saw the eraser icon disappear when they switched modes. Now the same two endpoints exist on the Spoolman inventory router: `POST /spoolman/inventory/spools/{spool_id}/reset-usage` PATCHes Spoolman's `/spool/{id}` with `used_weight: 0` for a single spool, `POST /spoolman/inventory/spools/reset-usage-bulk` does the same per ID across an explicit list and returns `{reset: N}` (individual Spoolman failures are logged and counted out, the batch keeps going). A `reset_spool_usage(spool_id)` helper on `SpoolmanClient` is the actual HTTP call. The mutations in `InventoryPage.tsx` already had the right shape — they now switch on `spoolmanMode` to pick `api.resetSpoolmanInventorySpoolUsage` / `api.bulkResetSpoolmanInventorySpoolUsage` vs the internal-inventory client methods, and the three `spoolmanMode ? undefined : ...` gates that hid the eraser buttons in Spoolman mode are gone. Three new tests in `test_spoolman_inventory_api.py` lock the Spoolman path (per-spool, bulk, and the typo-wipe guard on empty list). The wiki page now says "Spoolman users get the same actions" instead of the original "Spoolman-mode users don't see either button" note. 4900 backend tests green.
- **Inventory: "Reset usage to 0" per spool and across all active spools (#1390 follow-up, requested by @IndividualGhost1905)** — Each spool's `weight_used` counter accumulates over its lifetime and feeds the "Total Consumed (Since tracking started)" stat on the Inventory page. There was no way to clear it without nuking the spool or manually editing the field — and manually setting `weight_used=0` via PATCH /spools/{id} auto-locks the spool (`weight_locked=true` is auto-set whenever `weight_used` is sent explicitly, so AMS auto-sync stops touching the spool), which is the wrong behaviour for "clean-slate my Total Consumed stat so future prints track from zero". **Two dedicated endpoints** in `backend/app/api/routes/inventory.py` zero the counter without touching the lock flag: `POST /inventory/spools/{spool_id}/reset-usage` (single spool) returns the updated `SpoolResponse`; `POST /inventory/spools/reset-usage-bulk` (`{spool_ids: [int, ...]}`) returns `{reset: N}`. The bulk endpoint rejects empty / missing `spool_ids` (HTTP 400) — no wildcard / "reset-all" shortcut, since a typo there would wipe the entire inventory's tracking; the caller must explicitly pass the list. Both leave `weight_locked` alone: if the user had locked the spool, the lock stays; if it was unlocked, it stays unlocked and the next AMS sync picks up from zero. **Frontend** adds two affordances: a small eraser icon button on the "Total Consumed" stat card (visible only when there's actually usage to reset AND we're not in Spoolman mode) that opens a confirm modal explaining what the reset clears and that the spools / remaining weights are not changed, and an eraser icon in each table row's action column (visible only on active spools with `weight_used > 0`, hidden in Spoolman mode since Spoolman manages its own usage accounting). Both routes share the same `ConfirmModal` infrastructure as delete/archive — `confirmAction` state now covers `'delete' | 'archive' | 'reset-usage' | 'reset-all-usage'`. **i18n**: 10 new keys (`resetUsage`, `resetUsageTooltip`, `resetUsageConfirm`, `resetAllUsage`, `resetAllUsageTooltip`, `resetAllUsageConfirm`, `usageReset`, `allUsageReset`, `resetUsageFailed`, plus `resetUsage` reused as confirm button label) translated across all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW). Parity check holds at 4827 leaves per locale. **Tests**: 8 new regressions in `test_spool_reset_usage.py` cover per-spool reset zeroes `weight_used`, per-spool reset does NOT auto-lock, per-spool reset preserves an existing lock, 404 for missing spool, bulk reset zeroes only listed spools (untouched spools keep their usage — the typo-wipe guard), bulk reset rejects empty list (400), bulk reset rejects missing `spool_ids` field (400), bulk reset preserves `weight_locked` across mixed locked/unlocked targets. 4897 backend + 1901 frontend tests green.
### Changed
- **Settings → Filament: "Spool Catalog" now shows the same UI in Spoolman mode as in internal-inventory mode** — Previously, switching to Spoolman mode hijacked the Spool Catalog card and replaced it with a Spoolman filament list (Vendor — Name / Material / Weight / Spool Weight) with inline edit for name + spool_weight. Two separate concepts had been merged into one card: a Bambuddy-local **spool tare catalog** (the actual purpose of the card — name + weight definitions used to compute spool tare) vs a **filament editor** for Spoolman's `Filament` entity. The filament-editor view replaced the spool tare table entirely in Spoolman mode, with no way to see or manage the spool catalog. Now the card always renders the local Spool Catalog (Add / Edit / Delete / Export / Import / Reset / bulk-delete) regardless of inventory mode. The Spoolman-filament inline editor is removed — Spoolman users edit filament name / spool_weight in Spoolman's own UI. Side effect of the rewrite: the noisy `GET /api/v1/spoolman/inventory/filaments → 400 Bad Request` that fired on the Filament settings page even when Spoolman is disabled is gone, because the component no longer issues the probe at all. Files affected: `frontend/src/components/SpoolCatalogSettings.tsx` (rewrite, ~750 → ~445 lines), `frontend/src/components/SpoolWeightUpdateModal.tsx` (deleted — only used by the removed editor), test file rewritten to match the simplified component. No backend changes — `PATCH /spoolman/inventory/filaments/{id}` route still exists for API consumers, just no longer wired to a UI.
+52
View File
@@ -1065,6 +1065,58 @@ async def restore_spool(
return result.scalar_one()
@router.post("/spools/{spool_id}/reset-usage", response_model=SpoolResponse)
async def reset_spool_usage(
spool_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Zero the spool's weight_used counter without locking the spool.
Unlike PATCH /spools/{id} with weight_used=0, this endpoint does NOT
auto-set weight_locked — the spool keeps receiving AMS auto-sync
updates from the next print onward. Used to clear the accumulated
"Total Consumed" stat so subsequent prints track from a clean zero.
"""
result = await db.execute(select(Spool).where(Spool.id == spool_id))
spool = result.scalar_one_or_none()
if not spool:
raise HTTPException(404, "Spool not found")
spool.weight_used = 0
await db.commit()
result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
await ws_manager.broadcast({"type": "inventory_changed"})
return result.scalar_one()
@router.post("/spools/reset-usage-bulk")
async def bulk_reset_spool_usage(
payload: dict,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Bulk-reset weight_used to 0 across the given spool IDs.
Caller passes an explicit list of IDs — no "reset all" shortcut, since
a typo on a wildcard would wipe the entire inventory's tracking.
Same semantics as the per-spool endpoint: weight_locked is left alone.
"""
spool_ids = payload.get("spool_ids")
if not isinstance(spool_ids, list) or not spool_ids:
raise HTTPException(400, "spool_ids must be a non-empty list")
if not all(isinstance(sid, int) for sid in spool_ids):
raise HTTPException(400, "spool_ids must contain integers")
result = await db.execute(select(Spool).where(Spool.id.in_(spool_ids)))
spools = list(result.scalars().all())
for spool in spools:
spool.weight_used = 0
await db.commit()
await ws_manager.broadcast({"type": "inventory_changed"})
return {"reset": len(spools)}
# ── K-Profiles ───────────────────────────────────────────────────────────────
@@ -719,6 +719,54 @@ async def restore_spool(
raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc
@router.post("/spools/{spool_id}/reset-usage")
async def reset_spool_usage(
spool_id: int = Path(..., gt=0),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
) -> dict:
"""Zero the spool's used_weight in Spoolman without touching anything else."""
client = await _get_client(db)
async with _translate_spoolman_errors():
spool = await client.reset_spool_usage(spool_id)
try:
return _map_spoolman_spool(spool)
except ValueError as exc:
logger.warning("Malformed Spoolman spool (id=%r): %s", spool_id, exc)
raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc
@router.post("/spools/reset-usage-bulk")
async def bulk_reset_spool_usage(
payload: dict = Body(...),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
) -> dict:
"""Bulk-reset used_weight to 0 across the given Spoolman spool IDs.
Caller passes an explicit list of IDs — no "reset all" shortcut, since
a typo on a wildcard would wipe the entire inventory's tracking.
Returns the count of spools successfully reset; individual failures are
logged but do not abort the batch.
"""
spool_ids = payload.get("spool_ids")
if not isinstance(spool_ids, list) or not spool_ids:
raise HTTPException(status_code=400, detail="spool_ids must be a non-empty list")
if not all(isinstance(sid, int) for sid in spool_ids):
raise HTTPException(status_code=400, detail="spool_ids must contain integers")
client = await _get_client(db)
reset_count = 0
for spool_id in spool_ids:
try:
async with _translate_spoolman_errors():
await client.reset_spool_usage(spool_id)
reset_count += 1
except HTTPException as exc:
logger.warning("Spoolman reset-usage failed for spool %s: %s", spool_id, exc.detail)
return {"reset": reset_count}
@router.patch("/spools/{spool_id}/weight")
async def sync_spool_weight(
*,
+15
View File
@@ -558,6 +558,21 @@ class SpoolmanClient:
)
return response.json()
async def reset_spool_usage(self, spool_id: int) -> dict:
"""Reset a spool's used_weight to 0 in Spoolman.
Used by the per-spool / bulk "Reset usage to 0" actions on the
Inventory page so the Total Consumed stat can be cleared without
touching the rest of the spool's data.
"""
response = await self._request_spool(
"PATCH",
spool_id,
json_body={"used_weight": 0},
operation="reset-usage",
)
return response.json()
async def update_spool_full(
self,
spool_id: int,
@@ -0,0 +1,157 @@
"""Reset-usage endpoint regressions (#1390 follow-up).
The per-spool and bulk reset endpoints zero `weight_used` without touching
`weight_locked`. They exist because PATCH /spools/{id} auto-locks the spool
when weight_used is set explicitly, and that's wrong for the "clean-slate
my Total Consumed stat" workflow — the user wants the spool to keep
receiving AMS auto-sync updates from the next print onward.
"""
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.models.spool import Spool
@pytest.fixture
async def spool_factory(db_session: AsyncSession):
"""Create a Spool with sensible defaults."""
async def _create(**kwargs):
defaults = {
"material": "PLA",
"subtype": "Basic",
"brand": "Bambu",
"color_name": "Red",
"rgba": "FF0000FF",
"label_weight": 1000,
"weight_used": 0,
"weight_locked": False,
}
defaults.update(kwargs)
spool = Spool(**defaults)
db_session.add(spool)
await db_session.commit()
await db_session.refresh(spool)
return spool
return _create
class TestResetSpoolUsage:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_reset_zeroes_weight_used(self, async_client: AsyncClient, spool_factory, db_session):
"""Endpoint sets weight_used to 0."""
spool = await spool_factory(weight_used=234.5)
response = await async_client.post(f"/api/v1/inventory/spools/{spool.id}/reset-usage")
assert response.status_code == 200
assert response.json()["weight_used"] == 0
await db_session.refresh(spool)
assert spool.weight_used == 0
@pytest.mark.asyncio
@pytest.mark.integration
async def test_reset_does_not_lock_spool(self, async_client: AsyncClient, spool_factory, db_session):
"""Reset must leave weight_locked alone.
PATCH /spools/{id} auto-locks when weight_used is set explicitly;
the dedicated reset endpoint must NOT, because the user's intent
is "track fresh from zero", not "freeze at zero forever".
"""
spool = await spool_factory(weight_used=100.0, weight_locked=False)
response = await async_client.post(f"/api/v1/inventory/spools/{spool.id}/reset-usage")
assert response.status_code == 200
await db_session.refresh(spool)
assert spool.weight_used == 0
assert spool.weight_locked is False, "Reset must not auto-lock the spool"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_reset_preserves_existing_lock(self, async_client: AsyncClient, spool_factory, db_session):
"""If the user previously locked the spool, the lock is preserved."""
spool = await spool_factory(weight_used=500.0, weight_locked=True)
response = await async_client.post(f"/api/v1/inventory/spools/{spool.id}/reset-usage")
assert response.status_code == 200
await db_session.refresh(spool)
assert spool.weight_used == 0
assert spool.weight_locked is True, "Pre-existing lock must be preserved"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_reset_404_for_missing_spool(self, async_client: AsyncClient):
response = await async_client.post("/api/v1/inventory/spools/99999/reset-usage")
assert response.status_code == 404
class TestBulkResetSpoolUsage:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_bulk_reset_zeroes_only_listed_spools(self, async_client: AsyncClient, spool_factory, db_session):
"""Only spools in the request are reset; others are untouched."""
target1 = await spool_factory(weight_used=100.0)
target2 = await spool_factory(weight_used=200.0)
untouched = await spool_factory(weight_used=300.0)
response = await async_client.post(
"/api/v1/inventory/spools/reset-usage-bulk",
json={"spool_ids": [target1.id, target2.id]},
)
assert response.status_code == 200
assert response.json() == {"reset": 2}
# The endpoint commits via its own session — expire our session so the
# next read pulls fresh values rather than the cached pre-reset state.
db_session.expire_all()
spools = (await db_session.execute(select(Spool))).scalars().all()
by_id = {s.id: s for s in spools}
assert by_id[target1.id].weight_used == 0
assert by_id[target2.id].weight_used == 0
assert by_id[untouched.id].weight_used == 300.0, "Spool not in request must keep its usage"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_bulk_reset_rejects_empty_list(self, async_client: AsyncClient):
"""Empty list must be rejected — guards against accidental wildcard wipes."""
response = await async_client.post(
"/api/v1/inventory/spools/reset-usage-bulk",
json={"spool_ids": []},
)
assert response.status_code == 400
@pytest.mark.asyncio
@pytest.mark.integration
async def test_bulk_reset_rejects_missing_field(self, async_client: AsyncClient):
"""Missing spool_ids field must be rejected."""
response = await async_client.post(
"/api/v1/inventory/spools/reset-usage-bulk",
json={},
)
assert response.status_code == 400
@pytest.mark.asyncio
@pytest.mark.integration
async def test_bulk_reset_does_not_lock_spools(self, async_client: AsyncClient, spool_factory, db_session):
"""Bulk reset preserves weight_locked across all targets."""
unlocked = await spool_factory(weight_used=100.0, weight_locked=False)
locked = await spool_factory(weight_used=200.0, weight_locked=True)
response = await async_client.post(
"/api/v1/inventory/spools/reset-usage-bulk",
json={"spool_ids": [unlocked.id, locked.id]},
)
assert response.status_code == 200
await db_session.refresh(unlocked)
await db_session.refresh(locked)
assert unlocked.weight_used == 0 and unlocked.weight_locked is False
assert locked.weight_used == 0 and locked.weight_locked is True
@@ -63,6 +63,7 @@ def mock_spoolman_client():
mock_client.set_spool_archived = AsyncMock(
side_effect=lambda spool_id, archived: {**SAMPLE_SPOOLMAN_SPOOL, "archived": archived}
)
mock_client.reset_spool_usage = AsyncMock(return_value={**SAMPLE_SPOOLMAN_SPOOL, "used_weight": 0})
mock_client.update_spool_full = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
mock_client.merge_spool_extra = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
mock_client.find_or_create_filament = AsyncMock(return_value=7)
@@ -487,6 +488,56 @@ class TestSpoolmanInventoryCRUD:
assert response.status_code == 200
mock_spoolman_client.set_spool_archived.assert_called_once_with(42, archived=False)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_reset_spool_usage(
self,
async_client: AsyncClient,
spoolman_settings,
mock_spoolman_client,
):
"""POST /spoolman/inventory/spools/{id}/reset-usage zeroes used_weight in Spoolman."""
response = await async_client.post("/api/v1/spoolman/inventory/spools/42/reset-usage")
assert response.status_code == 200
assert response.json()["weight_used"] == 0
mock_spoolman_client.reset_spool_usage.assert_called_once_with(42)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_bulk_reset_spool_usage(
self,
async_client: AsyncClient,
spoolman_settings,
mock_spoolman_client,
):
"""Bulk endpoint resets each listed spool and returns the count."""
response = await async_client.post(
"/api/v1/spoolman/inventory/spools/reset-usage-bulk",
json={"spool_ids": [1, 2, 3]},
)
assert response.status_code == 200
assert response.json() == {"reset": 3}
assert mock_spoolman_client.reset_spool_usage.call_count == 3
@pytest.mark.asyncio
@pytest.mark.integration
async def test_bulk_reset_rejects_empty_list(
self,
async_client: AsyncClient,
spoolman_settings,
mock_spoolman_client,
):
"""Empty list must be rejected — guards against accidental wildcard wipes."""
response = await async_client.post(
"/api/v1/spoolman/inventory/spools/reset-usage-bulk",
json={"spool_ids": []},
)
assert response.status_code == 400
mock_spoolman_client.reset_spool_usage.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_sync_weight(
+14
View File
@@ -4651,6 +4651,13 @@ export const api = {
request<InventorySpool>(`/inventory/spools/${id}/archive`, { method: 'POST' }),
restoreSpool: (id: number) =>
request<InventorySpool>(`/inventory/spools/${id}/restore`, { method: 'POST' }),
resetSpoolUsage: (id: number) =>
request<InventorySpool>(`/inventory/spools/${id}/reset-usage`, { method: 'POST' }),
bulkResetSpoolUsage: (spoolIds: number[]) =>
request<{ reset: number }>(`/inventory/spools/reset-usage-bulk`, {
method: 'POST',
body: JSON.stringify({ spool_ids: spoolIds }),
}),
getSpoolKProfiles: (spoolId: number) =>
request<SpoolKProfile[]>(`/inventory/spools/${spoolId}/k-profiles`),
saveSpoolKProfiles: (spoolId: number, profiles: SpoolKProfileInput[]) =>
@@ -4815,6 +4822,13 @@ export const api = {
request<InventorySpool>(`/spoolman/inventory/spools/${id}/archive`, { method: 'POST' }),
restoreSpoolmanInventorySpool: (id: number) =>
request<InventorySpool>(`/spoolman/inventory/spools/${id}/restore`, { method: 'POST' }),
resetSpoolmanInventorySpoolUsage: (id: number) =>
request<InventorySpool>(`/spoolman/inventory/spools/${id}/reset-usage`, { method: 'POST' }),
bulkResetSpoolmanInventorySpoolUsage: (spoolIds: number[]) =>
request<{ reset: number }>(`/spoolman/inventory/spools/reset-usage-bulk`, {
method: 'POST',
body: JSON.stringify({ spool_ids: spoolIds }),
}),
linkTagToSpoolmanSpool: (spoolId: number, data: { tag_uid?: string; tray_uuid?: string }) =>
request<InventorySpool>(`/spoolman/inventory/spools/${spoolId}/tag`, {
method: 'PATCH',
+9
View File
@@ -3691,6 +3691,15 @@ export default {
inPrinter: 'Im Drucker',
lowStock: 'Niedriger Bestand',
sinceTracking: 'Seit Beginn der Erfassung',
resetUsage: 'Verbrauch auf 0 zurücksetzen',
resetUsageTooltip: 'Den verbrauchten Gramm-Zähler dieser Spule auf null setzen',
resetUsageConfirm: 'Verbrauchten Gramm-Zähler dieser Spule auf 0 zurücksetzen? Künftige Drucke zählen wieder ab null. Die Spule selbst, ihre Restgewichtsberechnung und Ihre Einstellungen bleiben unverändert.',
resetAllUsage: 'Verbrauch aller Spulen zurücksetzen',
resetAllUsageTooltip: 'Den verbrauchten Gramm-Zähler auf jeder aktiven Spule auf null setzen',
resetAllUsageConfirm: 'Verbrauchten Gramm-Zähler auf allen {{count}} aktiven Spulen auf 0 zurücksetzen? Das löscht den Wert „Insgesamt verbraucht“, sodass künftige Drucke wieder ab null gezählt werden. Spulen und Restgewichte bleiben unverändert.',
usageReset: 'Spulenverbrauch auf 0 zurückgesetzt',
allUsageReset: '{{count}} Spule(n) zurückgesetzt',
resetUsageFailed: 'Zurücksetzen des Spulenverbrauchs fehlgeschlagen',
loadedInAms: 'Im AMS/Ext geladen',
remaining: 'Verbleibend',
weightCheck: 'Gewichtskontrolle',
+9
View File
@@ -3703,6 +3703,15 @@ export default {
inPrinter: 'In Printer',
lowStock: 'Low Stock',
sinceTracking: 'Since tracking started',
resetUsage: 'Reset usage to 0',
resetUsageTooltip: 'Zero the consumed-grams counter for this spool',
resetUsageConfirm: 'Reset this spool\'s consumed-grams counter to 0? Future prints will track from zero again. The spool itself, its remaining weight calculation, and your settings are not changed.',
resetAllUsage: 'Reset all spool usage',
resetAllUsageTooltip: 'Zero the consumed-grams counter on every active spool',
resetAllUsageConfirm: 'Reset the consumed-grams counter to 0 on all {{count}} active spools? This clears the "Total Consumed" stat so future prints track from zero. Spools and remaining weights are not changed.',
usageReset: 'Spool usage reset to 0',
allUsageReset: 'Reset {{count}} spool(s)',
resetUsageFailed: 'Failed to reset spool usage',
loadedInAms: 'Loaded in AMS/Ext',
remaining: 'Remaining',
weightCheck: 'Weight Check',
+9
View File
@@ -3680,6 +3680,15 @@ export default {
inPrinter: 'Dans Imprimante',
lowStock: 'Stock Bas',
sinceTracking: 'Depuis le début du suivi',
resetUsage: 'Réinitialiser l\'usage à 0',
resetUsageTooltip: 'Remettre à zéro le compteur de grammes consommés pour cette bobine',
resetUsageConfirm: 'Remettre à 0 le compteur de grammes consommés de cette bobine ? Les futures impressions repartiront de zéro. La bobine, son calcul de poids restant et vos paramètres ne sont pas modifiés.',
resetAllUsage: 'Réinitialiser l\'usage de toutes les bobines',
resetAllUsageTooltip: 'Remettre à zéro le compteur de grammes consommés sur chaque bobine active',
resetAllUsageConfirm: 'Remettre à 0 le compteur de grammes consommés sur les {{count}} bobines actives ? Cela efface la valeur « Total Consommé » pour que les futures impressions repartent de zéro. Les bobines et les poids restants ne sont pas modifiés.',
usageReset: 'Usage de la bobine remis à 0',
allUsageReset: '{{count}} bobine(s) réinitialisée(s)',
resetUsageFailed: 'Échec de la réinitialisation de l\'usage',
loadedInAms: 'Chargé dans AMS/Ext',
remaining: 'Restant',
weightCheck: 'Vérification poids',
+9
View File
@@ -3679,6 +3679,15 @@ export default {
inPrinter: 'In stampante',
lowStock: 'Scorta bassa',
sinceTracking: 'Dall\'inizio del tracciamento',
resetUsage: 'Azzera utilizzo',
resetUsageTooltip: 'Azzera il contatore di grammi consumati per questa bobina',
resetUsageConfirm: 'Azzerare il contatore di grammi consumati di questa bobina? Le stampe future ripartiranno da zero. La bobina, il calcolo del peso rimanente e le impostazioni non vengono modificati.',
resetAllUsage: 'Azzera utilizzo di tutte le bobine',
resetAllUsageTooltip: 'Azzera il contatore di grammi consumati su ogni bobina attiva',
resetAllUsageConfirm: 'Azzerare il contatore di grammi consumati su tutte le {{count}} bobine attive? La statistica "Totale Consumato" verrà azzerata e le stampe future ripartiranno da zero. Bobine e pesi rimanenti restano invariati.',
usageReset: 'Utilizzo della bobina azzerato',
allUsageReset: '{{count}} bobina/e azzerata/e',
resetUsageFailed: 'Impossibile azzerare l\'utilizzo della bobina',
loadedInAms: 'Caricato in AMS/Est',
remaining: 'Rimanente',
weightCheck: 'Controllo Peso',
+9
View File
@@ -3691,6 +3691,15 @@ export default {
inPrinter: 'プリンター内',
lowStock: '残量少',
sinceTracking: '追跡開始以降',
resetUsage: '使用量を0にリセット',
resetUsageTooltip: 'このスプールの消費量カウンタを0にする',
resetUsageConfirm: 'このスプールの消費量カウンタを0にリセットしますか?以降の印刷は再びゼロからカウントされます。スプール自体、残量計算、設定は変更されません。',
resetAllUsage: '全スプールの使用量をリセット',
resetAllUsageTooltip: 'すべてのアクティブなスプールの消費量カウンタを0にする',
resetAllUsageConfirm: '{{count}}件すべてのアクティブなスプールの消費量カウンタを0にリセットしますか?「累計消費量」の値がクリアされ、以降の印刷はゼロからカウントされます。スプール自体と残量は変更されません。',
usageReset: 'スプールの使用量を0にリセットしました',
allUsageReset: '{{count}}件のスプールをリセットしました',
resetUsageFailed: 'スプールの使用量リセットに失敗しました',
loadedInAms: 'AMS/Extに装填中',
remaining: '残り',
weightCheck: '重量チェック',
+9
View File
@@ -3679,6 +3679,15 @@ export default {
inPrinter: 'Na Impressora',
lowStock: 'Estoque Baixo',
sinceTracking: 'Desde o início do rastreamento',
resetUsage: 'Zerar uso',
resetUsageTooltip: 'Zerar o contador de gramas consumidas desta bobina',
resetUsageConfirm: 'Zerar o contador de gramas consumidas desta bobina? As impressões futuras voltarão a contar do zero. A bobina em si, o cálculo do peso restante e as configurações não são alterados.',
resetAllUsage: 'Zerar uso de todas as bobinas',
resetAllUsageTooltip: 'Zerar o contador de gramas consumidas em todas as bobinas ativas',
resetAllUsageConfirm: 'Zerar o contador de gramas consumidas nas {{count}} bobinas ativas? Isso limpa o "Total Consumido" para que as impressões futuras contem do zero. Bobinas e pesos restantes não são alterados.',
usageReset: 'Uso da bobina zerado',
allUsageReset: '{{count}} bobina(s) zerada(s)',
resetUsageFailed: 'Falha ao zerar o uso da bobina',
loadedInAms: 'Carregado no AMS/Ext',
remaining: 'Restante',
weightCheck: 'Verificação de Peso',
+9
View File
@@ -3684,6 +3684,15 @@ export default {
inPrinter: '在打印机中',
lowStock: '库存不足',
sinceTracking: '自开始追踪',
resetUsage: '将用量重置为 0',
resetUsageTooltip: '将此料盘的已消耗克数计数器清零',
resetUsageConfirm: '将此料盘的已消耗克数计数器重置为 0?后续打印将从零开始计数。料盘本身、剩余重量计算和您的设置不会改变。',
resetAllUsage: '重置所有料盘的用量',
resetAllUsageTooltip: '将每个活动料盘的已消耗克数计数器清零',
resetAllUsageConfirm: '将全部 {{count}} 个活动料盘的已消耗克数计数器重置为 0?这将清空"累计消耗"统计值,后续打印从零开始计数。料盘和剩余重量不会改变。',
usageReset: '料盘用量已重置为 0',
allUsageReset: '已重置 {{count}} 个料盘',
resetUsageFailed: '重置料盘用量失败',
loadedInAms: '已装载到 AMS/外置',
remaining: '剩余',
weightCheck: '重量检查',
+9
View File
@@ -3684,6 +3684,15 @@ export default {
inPrinter: '在印表機中',
lowStock: '庫存不足',
sinceTracking: '自開始追蹤',
resetUsage: '將用量重置為 0',
resetUsageTooltip: '將此料盤的已消耗克數計數器歸零',
resetUsageConfirm: '將此料盤的已消耗克數計數器重置為 0?後續列印將從零開始計算。料盤本身、剩餘重量計算與您的設定均不會變更。',
resetAllUsage: '重置所有料盤的用量',
resetAllUsageTooltip: '將每個有效料盤的已消耗克數計數器歸零',
resetAllUsageConfirm: '將全部 {{count}} 個有效料盤的已消耗克數計數器重置為 0?這將清空「累計消耗」統計值,後續列印從零開始計算。料盤與剩餘重量不會變更。',
usageReset: '料盤用量已重置為 0',
allUsageReset: '已重置 {{count}} 個料盤',
resetUsageFailed: '重置料盤用量失敗',
loadedInAms: '已裝載到 AMS/外接',
remaining: '剩餘',
weightCheck: '重量檢查',
+86 -13
View File
@@ -6,7 +6,7 @@ import {
Plus, Loader2, Trash2, Archive, RotateCcw, Edit2, Package,
Search, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
TrendingDown, Layers, Printer, AlertTriangle, X, Clock, LayoutGrid, TableProperties, Columns,
ArrowUp, ArrowDown, ArrowUpDown, Group, ChevronDown, Check, RefreshCw, TrendingUp, Lock, Copy,
ArrowUp, ArrowDown, ArrowUpDown, Group, ChevronDown, Check, RefreshCw, TrendingUp, Lock, Copy, Eraser,
} from 'lucide-react';
import { ForecastPanel } from '../components/ForecastPanel';
import { api, spoolbuddyApi, ApiError } from '../api/client';
@@ -465,7 +465,11 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
const [searchParams, setSearchParams] = useSearchParams();
const [formModal, setFormModal] = useState<{ spool?: InventorySpool | null; mode: SpoolFormMode } | null>(null);
const deepLinkHandled = useRef(false);
const [confirmAction, setConfirmAction] = useState<{ type: 'delete' | 'archive'; spoolId: number } | null>(null);
const [confirmAction, setConfirmAction] = useState<
| { type: 'delete' | 'archive' | 'reset-usage'; spoolId: number }
| { type: 'reset-all-usage' }
| null
>(null);
// Label printing (#809). null = closed; otherwise the IDs to print labels for.
const [labelPickerSpoolIds, setLabelPickerSpoolIds] = useState<number[] | null>(null);
@@ -682,6 +686,35 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
},
});
const resetUsageMutation = useMutation({
mutationFn: (id: number) =>
spoolmanMode ? api.resetSpoolmanInventorySpoolUsage(id) : api.resetSpoolUsage(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
showToast(t('inventory.usageReset'), 'success');
},
onError: () => {
showToast(t('inventory.resetUsageFailed'), 'error');
},
});
const bulkResetUsageMutation = useMutation({
mutationFn: (ids: number[]) =>
spoolmanMode ? api.bulkResetSpoolmanInventorySpoolUsage(ids) : api.bulkResetSpoolUsage(ids),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
showToast(t('inventory.allUsageReset', { count: data.reset }), 'success');
},
onError: () => {
showToast(t('inventory.resetUsageFailed'), 'error');
},
});
const activeSpoolIds = useMemo(
() => (spools ?? []).filter((s) => !s.archived_at).map((s) => s.id),
[spools],
);
const handleSyncWeight = async (spool: InventorySpool) => {
if (spool.last_scale_weight == null) return;
try {
@@ -1086,9 +1119,21 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
{/* Total Consumed */}
<div className="bg-bambu-dark-secondary rounded-lg p-4">
<div className="flex items-center gap-2 mb-1">
<TrendingDown className="w-4 h-4 text-blue-400" />
<span className="text-xs text-bambu-gray font-medium uppercase tracking-wide">{t('inventory.totalConsumed')}</span>
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2">
<TrendingDown className="w-4 h-4 text-blue-400" />
<span className="text-xs text-bambu-gray font-medium uppercase tracking-wide">{t('inventory.totalConsumed')}</span>
</div>
{stats.totalConsumed > 0 && activeSpoolIds.length > 0 && (
<button
onClick={() => setConfirmAction({ type: 'reset-all-usage' })}
className="p-1 text-bambu-gray hover:text-red-400 rounded transition-colors"
title={t('inventory.resetAllUsageTooltip')}
aria-label={t('inventory.resetAllUsage')}
>
<Eraser className="w-3.5 h-3.5" />
</button>
)}
</div>
<div className="text-xl font-bold text-white">{formatWeight(stats.totalConsumed, true)}</div>
<div className="text-xs text-bambu-gray mt-1">{t('inventory.sinceTracking')}</div>
@@ -1672,6 +1717,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
onArchive={(id) => setConfirmAction({ type: 'archive', spoolId: id })}
onDelete={(id) => setConfirmAction({ type: 'delete', spoolId: id })}
onPrintLabel={(id) => setLabelPickerSpoolIds([id])}
onResetUsage={(id) => setConfirmAction({ type: 'reset-usage', spoolId: id })}
visibleColumns={visibleColumns}
assignmentMap={assignmentMap}
catalogMap={catalogMap}
@@ -1697,6 +1743,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
onArchive={() => setConfirmAction({ type: 'archive', spoolId: spool.id })}
onDelete={() => setConfirmAction({ type: 'delete', spoolId: spool.id })}
onPrintLabel={() => setLabelPickerSpoolIds([spool.id])}
onResetUsage={() => setConfirmAction({ type: 'reset-usage', spoolId: spool.id })}
visibleColumns={visibleColumns}
assignmentMap={assignmentMap}
catalogMap={catalogMap}
@@ -1797,18 +1844,36 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
/>
)}
{/* Confirm Modal (delete / archive) */}
{/* Confirm Modal (delete / archive / reset-usage / reset-all-usage) */}
{confirmAction && (
<ConfirmModal
title={confirmAction.type === 'delete' ? t('common.delete') : t('inventory.archive')}
message={confirmAction.type === 'delete' ? t('inventory.deleteConfirm') : t('inventory.archiveConfirm')}
confirmText={confirmAction.type === 'delete' ? t('common.delete') : t('inventory.archive')}
variant={confirmAction.type === 'delete' ? 'danger' : 'warning'}
title={
confirmAction.type === 'delete' ? t('common.delete') :
confirmAction.type === 'archive' ? t('inventory.archive') :
confirmAction.type === 'reset-usage' ? t('inventory.resetUsage') :
t('inventory.resetAllUsage')
}
message={
confirmAction.type === 'delete' ? t('inventory.deleteConfirm') :
confirmAction.type === 'archive' ? t('inventory.archiveConfirm') :
confirmAction.type === 'reset-usage' ? t('inventory.resetUsageConfirm') :
t('inventory.resetAllUsageConfirm', { count: activeSpoolIds.length })
}
confirmText={
confirmAction.type === 'delete' ? t('common.delete') :
confirmAction.type === 'archive' ? t('inventory.archive') :
t('inventory.resetUsage')
}
variant={confirmAction.type === 'archive' ? 'warning' : 'danger'}
onConfirm={() => {
if (confirmAction.type === 'delete') {
deleteMutation.mutate(confirmAction.spoolId);
} else {
} else if (confirmAction.type === 'archive') {
archiveMutation.mutate(confirmAction.spoolId);
} else if (confirmAction.type === 'reset-usage') {
resetUsageMutation.mutate(confirmAction.spoolId);
} else {
bulkResetUsageMutation.mutate(activeSpoolIds);
}
setConfirmAction(null);
}}
@@ -2024,7 +2089,7 @@ function SpoolCard({
/* Single spool row for table view */
function SpoolTableRow({
spool, remaining, pct, onEdit, onCopy, onRestore, onArchive, onDelete, onPrintLabel,
spool, remaining, pct, onEdit, onCopy, onRestore, onArchive, onDelete, onPrintLabel, onResetUsage,
visibleColumns, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight,
}: {
spool: InventorySpool;
@@ -2036,6 +2101,7 @@ function SpoolTableRow({
onArchive: () => void;
onDelete: () => void;
onPrintLabel?: () => void;
onResetUsage?: () => void;
visibleColumns: string[];
assignmentMap: Record<number, LocationDisplay>;
catalogMap: Record<number, SpoolCatalogEntry>;
@@ -2071,6 +2137,11 @@ function SpoolTableRow({
<Printer className="w-4 h-4" />
</button>
)}
{onResetUsage && !spool.archived_at && spool.weight_used > 0 && (
<button onClick={onResetUsage} className="p-1.5 text-bambu-gray hover:text-orange-400 rounded transition-colors" title={t('inventory.resetUsageTooltip')}>
<Eraser className="w-4 h-4" />
</button>
)}
{spool.archived_at ? (
<button onClick={onRestore} className="p-1.5 text-bambu-gray hover:text-bambu-green rounded transition-colors" title={t('inventory.restore')}>
<RotateCcw className="w-4 h-4" />
@@ -2092,7 +2163,7 @@ function SpoolTableRow({
/* Grouped spool rows for table view */
function SpoolTableGroup({
spools, representative, remaining, pct, isExpanded, onToggle,
onEdit, onCopy, onArchive, onDelete, onPrintLabel,
onEdit, onCopy, onArchive, onDelete, onPrintLabel, onResetUsage,
visibleColumns, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight,
}: {
spools: InventorySpool[];
@@ -2106,6 +2177,7 @@ function SpoolTableGroup({
onArchive: (id: number) => void;
onDelete: (id: number) => void;
onPrintLabel?: (spoolId: number) => void;
onResetUsage?: (id: number) => void;
visibleColumns: string[];
assignmentMap: Record<number, LocationDisplay>;
catalogMap: Record<number, SpoolCatalogEntry>;
@@ -2159,6 +2231,7 @@ function SpoolTableGroup({
onArchive={() => onArchive(spool.id)}
onDelete={() => onDelete(spool.id)}
onPrintLabel={onPrintLabel ? () => onPrintLabel(spool.id) : undefined}
onResetUsage={onResetUsage ? () => onResetUsage(spool.id) : undefined}
visibleColumns={visibleColumns}
assignmentMap={assignmentMap}
catalogMap={catalogMap}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -26,8 +26,8 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-CdDo4ToZ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CLI--QzS.css">
<script type="module" crossorigin src="/assets/index-BI09b1Nz.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-KYwGxnG9.css">
</head>
<body>
<div id="root"></div>