mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-20 09:03:43 +02:00
feat(#729): per-spool category + low-stock threshold override
Two new optional fields on Spool: free-text `category` (max 50) and `low_stock_threshold_pct` (1-99). Powers the "differentiate critical spools from prototype spools and alert at different thresholds" use case from #729 without taking on the full multi-tag taxonomy + auto- apply rules + per-tag alert system the ticket originally proposed. Form gains: - Category input with datalist autocomplete sourced from categories already in use, so casing/spelling stays consistent. - Per-spool low-stock threshold input. Empty = global default; the global value renders as the placeholder. Inventory page: - New category filter chip (hidden until at least one spool carries a category — keeps the chip row uncluttered). - Stat-card "Low Stock" count and the "Low Stock" filter both honour the per-spool override. Plus: rename "Delete Tag" button to "Clear RFID Tag" (the original ticket reporter mistook it for a taxonomy-tag delete; the button actually clears the RFID UID/UUID off the spool record). Toast key renamed from `tagDeleted` to `rfidCleared`. i18n: full translations across all 8 locales. Tests: 9 new backend schema tests (defaults, partial-update, range rejection, max-length); 2 new frontend tests (per-spool threshold pulls extra spools into low-stock count, filter chip hidden when no categories exist).
This commit is contained in:
@@ -5,6 +5,8 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
## [0.2.4b1] - Unreleased
|
||||
|
||||
### Added
|
||||
- **Per-spool category + low-stock threshold override** ([#729](https://github.com/maziggy/bambuddy/issues/729) — minimal version) — Two new fields on the spool form: a free-text **Category** (with autocomplete from categories already in use, so users naturally re-use "Production" instead of accidentally typing "production" / "prod") and a per-spool **Low-stock threshold (%)** override that defaults to the global setting if left blank. Powers the "I want to differentiate critical spools from prototype spools and alert at different thresholds" use case from the issue without taking on the full multi-tag taxonomy + auto-apply-rules + per-tag alert system the ticket originally proposed (which would have been ~5x the work for the same underlying value). Inventory page gains a Category filter chip — only renders once at least one spool carries a category, otherwise hidden so the chip row stays uncluttered. Low-stock counts in the stat-card and the "Low Stock" filter both honour the per-spool override (so a "Production" spool with override = 90% will count as low-stock at 80% remaining even when the global threshold is 20%). 50-char cap on category, 1-99% range on threshold (0 and 100 are both rejected as footguns). 9 new backend schema-validation tests covering the field defaults, partial-update behaviour, range/length rejection; 2 new frontend tests confirming the per-spool threshold pulls in spools the global threshold misses, and that the category filter chip stays hidden until at least one spool has a category. Localised across all 8 UI languages with full translations. The full multi-tag taxonomy from the original issue isn't going forward; if demand for it grows past the current 3 thumbs-up the design can layer on top of these fields without breakage.
|
||||
|
||||
- **Per-event ntfy priority** ([#990](https://github.com/maziggy/bambuddy/issues/990)) — ntfy supports a `Priority` header (1=min, 2=low, 3=default, 4=high, 5=urgent) that drives sound, visibility, and push behaviour on the receiving device, but the existing notifier sent every event at the server default — so a "50% complete" ping looked identical to "print failed" or "printer offline". The Add/Edit Notification modal now renders a per-event "ntfy Priority" section (visible only when the provider type is `ntfy`) listing each *enabled* event with its own Min / Low / Default / High / Urgent dropdown; selections persist into the provider's `config.event_priorities` map and the backend emits a matching `Priority: N` header on the ntfy POST/PUT request (including the image-attachment path). Events not explicitly mapped, malformed values, and out-of-range values (0, 6, "abc", null) all fall through to ntfy's server-side default — there is no clamping, so a misconfigured value never silently sends at the wrong urgency. Test sends (no `event_type` context) deliberately omit the header so the test path cannot accidentally page someone at urgent priority. Existing providers without `event_priorities` are untouched on upgrade. Localised across all 8 UI languages with full translations (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW). 6 new backend tests covering header set on mapped event, omitted on unmapped event, omitted when no `event_priorities` configured, omitted when `event_type` is missing, ignored for out-of-range / non-numeric values, and propagated through the image-attachment PUT path.
|
||||
|
||||
- **Long-lived camera-stream tokens for HA / Frigate / kiosks** ([#1108](https://github.com/maziggy/bambuddy/issues/1108)) — The existing `?token=…` camera-stream tokens expire after 60 minutes which forced home-automation integrations (Home Assistant cards, Frigate, hallway kiosks) to either refresh on a cron or run with auth disabled. New self-service "Camera API Tokens" panel under **Settings → API Keys** (also reachable via the existing settings search box — type "camera token" / "frigate" / "home assistant") lets any user holding `camera:view` mint a long-lived token they can paste once and forget. Revoke uses Bambuddy's standard styled confirmation modal (no `window.confirm` browser default — same pattern as the rest of the app). Tokens are scoped strictly to camera streaming (no privilege escalation surface — no other endpoint accepts them), formatted `bblt_<8-char-prefix>_<32-char-secret>`, and stored as a pbkdf2 hash so even a DB dump can't replay them; the plaintext is shown to the user **exactly once** in a copy-to-clipboard modal (with a `document.execCommand('copy')` fallback for plain-HTTP LAN deployments where `navigator.clipboard` is gated by the secure-context requirement). Hard 365-day max — the issue's `expire_in: 0` (never) is explicitly rejected because an irrevocable infinite token is a footgun-by-design; UI defaults to 90 days, the cap is enforced both client-side (input clamp) and server-side (validation guard). Owners can revoke their own tokens; admins additionally see an "All users" view for leak triage and can revoke anyone's. The `/camera/stream?token=…` auth dependency tries the existing 60-min ephemeral row first (no behaviour change for the common browser case) and falls through to the long-lived path, so the SPA's existing camera flow is unaffected. Indexed `lookup_prefix` keeps verify O(1) per token even on large installs — pbkdf2 only runs against the one candidate row that matches the prefix, never the whole table. New `long_lived_tokens` table (separate from `auth_ephemeral_tokens` because the lifecycle is different — user-owned, named, revocable, hashed; and separate from `api_keys` because that one is for global webhooks with no user FK and a different permission shape). 15 unit tests covering create-validation/scope/expiry rules, verify happy/garbage/expired/revoked/scope-mismatch/prefix-collision paths, list-by-user vs list-all, idempotent revoke; 14 integration tests covering the create-once-then-listing-hides-plaintext contract, the 365-day cap, the auth gate, owner-vs-admin revoke ownership rules, and that the long-lived token verifies through the same camera-stream auth dependency the route uses (and that revoke immediately invalidates it). 6 frontend tests covering list render, empty state, create-then-shown-once flow, days-input clamp, revoke-with-confirm, and revoke-cancelled paths. New `cameraTokens.*` keys across all 8 locales (English fully translated; the seven other locales seeded with English copies pending native translation, matching the project's existing flow for newly-added user-facing features).
|
||||
@@ -20,6 +22,8 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
**Test coverage** — 46 unit tests for `services/makerworld.py` (header shape, API base, `get_design`/`get_design_instances`/`get_profile`, `get_profile_download` 200/401/403/404/no-token, `download_3mf` SSRF rejection of 4 hostile hosts, S3 path delegation, CDN path with minimal headers, size-cap, `_download_s3_urllib` happy/redirect/size/network paths, `fetch_thumbnail` with `follow_redirects=False`); 19 route tests (`/resolve`, `/import` with folder autocreation + explicit folder + dedupe + filename basename + profile_id response, `/recent-imports` with empty-list / ordering / pydantic shape / limit clamping, `_canonical_url` unit); 12 frontend tests (button labels, slicer-name interpolation, URL-change detection, inline post-import actions, Recent imports rendering, DOMPurify `<script>` strip).
|
||||
|
||||
### Changed
|
||||
- **Inventory: "Delete Tag" button renamed to "Clear RFID Tag"** ([#729](https://github.com/maziggy/bambuddy/issues/729) follow-up) — The reporter mistook the button for a taxonomy-tag delete (it actually clears the RFID tag UID/UUID off the spool record so the row can be re-attached to a different physical spool). Renaming it to "Clear RFID Tag" + the success toast to "RFID tag cleared" removes the ambiguity. No behaviour change. Localised across all 8 UI languages with full translations.
|
||||
|
||||
- **Nozzle icon on the dual-nozzle status card** ([#1115](https://github.com/maziggy/bambuddy/issues/1115)) — the dual-nozzle active-extruder card on the printer status bar was the only card in that row without a theme icon (the Nozzle/Bed/Chamber temperature cards all carry a thermometer icon), which left the row looking visually uneven on H2D / H2S / H2C. Adds a small schematic nozzle icon (filament body + heater block + tip) above the L/R diameter labels, styled in amber-400 to match the card's active-extruder accent. SVG design contributed by @m4rtini2.
|
||||
- **Settings page: permission-gated instead of admin-only** — the Settings sidebar entry has always been visible to any user holding `settings:read`, but the route guard required admin role, so a non-admin with `settings:read` would see the entry, click it, and get silently redirected back to the dashboard. The route guard now matches the sidebar: any user with `settings:read` can open the page, and the individual tabs / cards continue to enforce their own per-feature permissions (`users:read`, `groups:update`, `oidc:*`, etc. — many of them admin-only, some not). Group editor routes moved to permission-based guards too (`groups:create` for `/groups/new`, `groups:update` for `/groups/:id/edit`), so permission delegation works end-to-end. Admins retain full access since admins implicitly hold every permission.
|
||||
|
||||
|
||||
@@ -1108,6 +1108,12 @@ async def run_migrations(conn):
|
||||
|
||||
# Migration: Add cost tracking fields to spool table
|
||||
await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN cost_per_kg REAL")
|
||||
|
||||
# Migration: Per-spool category + low-stock threshold override (#729). Both
|
||||
# nullable — NULL category leaves the spool uncategorised, NULL threshold
|
||||
# falls back to the global low_stock_threshold setting.
|
||||
await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN category VARCHAR(50)")
|
||||
await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN low_stock_threshold_pct INTEGER")
|
||||
# Migration: Add cost field to spool_usage_history table
|
||||
await _safe_execute(conn, "ALTER TABLE spool_usage_history ADD COLUMN cost REAL")
|
||||
# Migration: Add archive_id field to spool_usage_history table
|
||||
|
||||
@@ -33,6 +33,16 @@ class Spool(Base):
|
||||
note: Mapped[str | None] = mapped_column(String(500))
|
||||
added_full: Mapped[bool | None] = mapped_column() # Whether spool was added as full (unused)
|
||||
|
||||
# User-defined category (e.g. "Production", "Prototype", "Client A") for
|
||||
# filtering and per-group low-stock thresholds (#729). Free text — the
|
||||
# form autocompletes from categories already present on other spools.
|
||||
category: Mapped[str | None] = mapped_column(String(50))
|
||||
# Per-spool override of the global inventory low-stock threshold (%).
|
||||
# NULL falls back to the `low_stock_threshold` setting. Lets users mark
|
||||
# production spools with a higher threshold (alert earlier) and prototype
|
||||
# spools with a lower one without changing the global default.
|
||||
low_stock_threshold_pct: Mapped[int | None] = mapped_column(Integer)
|
||||
|
||||
# Cost tracking
|
||||
cost_per_kg: Mapped[float | None] = mapped_column(Float) # Cost per kilogram
|
||||
|
||||
|
||||
@@ -26,6 +26,9 @@ class SpoolBase(BaseModel):
|
||||
weight_locked: bool = False
|
||||
last_scale_weight: int | None = None
|
||||
last_weighed_at: datetime | None = None
|
||||
# User-defined category + per-spool low-stock threshold override (#729).
|
||||
category: str | None = Field(default=None, max_length=50)
|
||||
low_stock_threshold_pct: int | None = Field(default=None, ge=1, le=99)
|
||||
|
||||
|
||||
class SpoolCreate(SpoolBase):
|
||||
@@ -58,6 +61,9 @@ class SpoolUpdate(BaseModel):
|
||||
tag_type: str | None = None
|
||||
cost_per_kg: float | None = Field(default=None, ge=0)
|
||||
weight_locked: bool | None = None
|
||||
# User-defined category + per-spool low-stock threshold override (#729).
|
||||
category: str | None = Field(default=None, max_length=50)
|
||||
low_stock_threshold_pct: int | None = Field(default=None, ge=1, le=99)
|
||||
|
||||
|
||||
class SpoolKProfileBase(BaseModel):
|
||||
|
||||
@@ -129,3 +129,52 @@ class TestSpoolResponseRgbaLeniency:
|
||||
|
||||
response = SpoolResponse(**self._make_response_kwargs(rgba="FF00AAFF"))
|
||||
assert response.rgba == "FF00AAFF"
|
||||
|
||||
|
||||
class TestSpoolCategoryAndThreshold:
|
||||
"""#729: per-spool category + low-stock threshold override schema validation."""
|
||||
|
||||
def test_create_accepts_category_and_threshold(self):
|
||||
spool = SpoolCreate(material="PLA", category="Production", low_stock_threshold_pct=50)
|
||||
assert spool.category == "Production"
|
||||
assert spool.low_stock_threshold_pct == 50
|
||||
|
||||
def test_create_defaults_to_null(self):
|
||||
"""Both new fields are optional and default to None — backward compat."""
|
||||
spool = SpoolCreate(material="PLA")
|
||||
assert spool.category is None
|
||||
assert spool.low_stock_threshold_pct is None
|
||||
|
||||
def test_update_accepts_partial_changes(self):
|
||||
spool = SpoolUpdate(category="Prototype")
|
||||
assert spool.category == "Prototype"
|
||||
assert spool.low_stock_threshold_pct is None
|
||||
|
||||
def test_update_clears_via_explicit_null(self):
|
||||
"""Sending null on PATCH explicitly resets the override."""
|
||||
spool = SpoolUpdate(category=None, low_stock_threshold_pct=None)
|
||||
assert spool.category is None
|
||||
assert spool.low_stock_threshold_pct is None
|
||||
|
||||
def test_threshold_rejects_zero(self):
|
||||
"""0% would mean the spool is never low-stock — disallow as a footgun."""
|
||||
with pytest.raises(ValidationError, match="low_stock_threshold_pct"):
|
||||
SpoolCreate(material="PLA", low_stock_threshold_pct=0)
|
||||
|
||||
def test_threshold_rejects_100(self):
|
||||
"""100% would mean the spool is always low-stock — disallow."""
|
||||
with pytest.raises(ValidationError, match="low_stock_threshold_pct"):
|
||||
SpoolCreate(material="PLA", low_stock_threshold_pct=100)
|
||||
|
||||
def test_threshold_rejects_negative(self):
|
||||
with pytest.raises(ValidationError, match="low_stock_threshold_pct"):
|
||||
SpoolCreate(material="PLA", low_stock_threshold_pct=-5)
|
||||
|
||||
def test_category_rejects_too_long(self):
|
||||
"""50-char cap matches the DB column to prevent silent truncation."""
|
||||
with pytest.raises(ValidationError, match="category"):
|
||||
SpoolCreate(material="PLA", category="X" * 51)
|
||||
|
||||
def test_category_accepts_max_length(self):
|
||||
spool = SpoolCreate(material="PLA", category="X" * 50)
|
||||
assert spool.category == "X" * 50
|
||||
|
||||
@@ -393,4 +393,49 @@ describe('InventoryPage - Low Stock Threshold', () => {
|
||||
// Implementation would show appropriate count based on 30% threshold
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-spool overrides (#729)', () => {
|
||||
it('per-spool low_stock_threshold_pct overrides the global threshold', async () => {
|
||||
// Global = 20%. Spool 2 (PETG Blue) is at 80% remaining — normally NOT
|
||||
// low-stock. But its per-spool threshold is 90% (it's a "production" spool
|
||||
// the user wants to alert on early), so it MUST count as low-stock.
|
||||
// Spool 1 (10%) and Spool 3 (15%) are already < 20% and stay low-stock.
|
||||
// Expected low-stock count: 3 (was 2 before the override).
|
||||
const spoolsWithOverride = mockSpools.map((s) =>
|
||||
s.id === 2 ? { ...s, low_stock_threshold_pct: 90 } : s,
|
||||
);
|
||||
server.use(
|
||||
http.get('/api/v1/inventory/spools', () => HttpResponse.json(spoolsWithOverride)),
|
||||
);
|
||||
|
||||
render(<InventoryPageRouter />);
|
||||
|
||||
// The low-stock stat-card pill renders the count with "< 20%" suffix.
|
||||
// Find it and confirm it reflects 3 (override pulled spool 2 in).
|
||||
await waitFor(() => {
|
||||
const pills = screen.getAllByText(/< 20%/i);
|
||||
expect(pills.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// The exact count cell is rendered separately. Easiest robust assertion:
|
||||
// there's a "3" in the low-stock card. Looking up by text would be brittle
|
||||
// (other "3"s on the page), so we assert via the stat card structure.
|
||||
// The card is keyed by the same "< 20%" pill we already found.
|
||||
const pill = screen.getAllByText(/< 20%/i)[0];
|
||||
const card = pill.closest('div')!.parentElement!;
|
||||
// Card text reads e.g. "Low Stock3< 20%" with the count concatenated.
|
||||
expect(card.textContent).toMatch(/Low Stock\D*3\D/);
|
||||
});
|
||||
|
||||
it('category filter chip is hidden when no spool has a category', async () => {
|
||||
// mockSpools all have category=undefined → chip shouldn't render.
|
||||
render(<InventoryPageRouter />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText(/< 20%/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
// None of the spools carry a category, so the inventory.category chip
|
||||
// never appears.
|
||||
expect(screen.queryByRole('combobox', { name: /category/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2126,6 +2126,9 @@ export interface InventorySpool {
|
||||
cost_per_kg: number | null;
|
||||
last_scale_weight: number | null;
|
||||
last_weighed_at: string | null;
|
||||
// User-defined category + per-spool low-stock threshold override (#729).
|
||||
category: string | null;
|
||||
low_stock_threshold_pct: number | null;
|
||||
k_profiles?: SpoolKProfile[];
|
||||
}
|
||||
|
||||
|
||||
@@ -276,6 +276,8 @@ export function SpoolFormModal({
|
||||
slicer_filament: spool.slicer_filament || '',
|
||||
note: spool.note || '',
|
||||
cost_per_kg: spool.cost_per_kg ?? null,
|
||||
category: spool.category || '',
|
||||
low_stock_threshold_pct: spool.low_stock_threshold_pct ?? null,
|
||||
});
|
||||
setPresetInputValue(spool.slicer_filament_name || spool.slicer_filament || '');
|
||||
|
||||
@@ -385,7 +387,7 @@ export function SpoolFormModal({
|
||||
api.updateSpool(spool!.id, { tag_uid: null, tray_uuid: null, tag_type: null, data_origin: null } as Parameters<typeof api.updateSpool>[1]),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
|
||||
showToast(t('inventory.tagDeleted', 'Tag removed'), 'success');
|
||||
showToast(t('inventory.rfidCleared', 'RFID tag cleared'), 'success');
|
||||
onClose();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
@@ -401,6 +403,28 @@ export function SpoolFormModal({
|
||||
});
|
||||
const spoolAssignment = spool ? assignments?.find(a => a.spool_id === spool.id) : undefined;
|
||||
|
||||
// Read inventory + settings caches (already populated by InventoryPage) to
|
||||
// drive the category autocomplete and low-stock-threshold placeholder. #729
|
||||
const { data: allSpools } = useQuery({
|
||||
queryKey: ['inventory-spools'],
|
||||
queryFn: () => api.getSpools(true),
|
||||
enabled: isOpen,
|
||||
});
|
||||
const { data: settingsForForm } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: api.getSettings,
|
||||
enabled: isOpen,
|
||||
});
|
||||
const availableCategories = (() => {
|
||||
const set = new Set<string>();
|
||||
for (const s of allSpools ?? []) {
|
||||
const c = s.category?.trim();
|
||||
if (c) set.add(c);
|
||||
}
|
||||
return Array.from(set).sort((a, b) => a.localeCompare(b));
|
||||
})();
|
||||
const globalLowStockThreshold = settingsForForm?.low_stock_threshold ?? 20;
|
||||
|
||||
const unassignMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!spoolAssignment) throw new Error('No assignment');
|
||||
@@ -503,6 +527,8 @@ export function SpoolFormModal({
|
||||
nozzle_temp_max: null,
|
||||
note: formData.note || null,
|
||||
cost_per_kg: formData.cost_per_kg,
|
||||
category: formData.category.trim() || null,
|
||||
low_stock_threshold_pct: formData.low_stock_threshold_pct,
|
||||
};
|
||||
|
||||
// Only send weight_used when creating or when explicitly changed by the user.
|
||||
@@ -653,6 +679,8 @@ export function SpoolFormModal({
|
||||
updateField={updateField}
|
||||
spoolCatalog={spoolCatalog}
|
||||
currencySymbol={currencySymbol}
|
||||
availableCategories={availableCategories}
|
||||
globalLowStockThreshold={globalLowStockThreshold}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -686,7 +714,7 @@ export function SpoolFormModal({
|
||||
disabled={isPending || !spool?.tag_uid}
|
||||
>
|
||||
<Tag className="w-4 h-4" />
|
||||
{t('inventory.deleteTag', 'Delete Tag')}
|
||||
{t('inventory.clearRfid', 'Clear RFID Tag')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
|
||||
@@ -173,6 +173,8 @@ export function AdditionalSection({
|
||||
updateField,
|
||||
spoolCatalog,
|
||||
currencySymbol,
|
||||
availableCategories,
|
||||
globalLowStockThreshold,
|
||||
}: AdditionalSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToast();
|
||||
@@ -303,6 +305,64 @@ export function AdditionalSection({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category (#729) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-bambu-gray mb-1" htmlFor="spool-category">
|
||||
{t('inventory.category')}
|
||||
</label>
|
||||
<input
|
||||
id="spool-category"
|
||||
type="text"
|
||||
list="spool-category-options"
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
|
||||
placeholder={t('inventory.categoryPlaceholder')}
|
||||
value={formData.category}
|
||||
maxLength={50}
|
||||
onChange={(e) => updateField('category', e.target.value)}
|
||||
/>
|
||||
{availableCategories.length > 0 && (
|
||||
<datalist id="spool-category-options">
|
||||
{availableCategories.map((c) => <option key={c} value={c} />)}
|
||||
</datalist>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Per-spool low-stock threshold override (#729) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-bambu-gray mb-1" htmlFor="spool-low-stock-threshold">
|
||||
{t('inventory.lowStockThresholdOverride')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
id="spool-low-stock-threshold"
|
||||
type="number"
|
||||
className="w-full px-3 py-2 pr-8 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
|
||||
placeholder={String(globalLowStockThreshold)}
|
||||
value={formData.low_stock_threshold_pct ?? ''}
|
||||
min={1}
|
||||
max={99}
|
||||
step={1}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
if (raw === '') {
|
||||
updateField('low_stock_threshold_pct', null);
|
||||
return;
|
||||
}
|
||||
const n = Number(raw);
|
||||
if (Number.isFinite(n)) {
|
||||
updateField('low_stock_threshold_pct', Math.min(99, Math.max(1, Math.round(n))));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-bambu-gray pointer-events-none">%</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-bambu-gray mt-1">
|
||||
{t('inventory.lowStockThresholdOverrideHelp', { global: globalLowStockThreshold })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Note */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.note')}</label>
|
||||
|
||||
@@ -23,6 +23,9 @@ export interface SpoolFormData {
|
||||
slicer_filament: string;
|
||||
note: string;
|
||||
cost_per_kg: number | null;
|
||||
// User-defined category + per-spool low-stock threshold override (#729).
|
||||
category: string;
|
||||
low_stock_threshold_pct: number | null;
|
||||
}
|
||||
|
||||
export const defaultFormData: SpoolFormData = {
|
||||
@@ -38,6 +41,8 @@ export const defaultFormData: SpoolFormData = {
|
||||
slicer_filament: '',
|
||||
note: '',
|
||||
cost_per_kg: null,
|
||||
category: '',
|
||||
low_stock_threshold_pct: null,
|
||||
};
|
||||
|
||||
// Printer with calibrations type
|
||||
@@ -106,6 +111,13 @@ export interface ColorSectionProps extends SectionProps {
|
||||
export interface AdditionalSectionProps extends SectionProps {
|
||||
spoolCatalog: { id: number; name: string; weight: number }[];
|
||||
currencySymbol: string;
|
||||
// Categories already used on other spools — drives the category autocomplete
|
||||
// datalist so users naturally re-use existing names instead of creating
|
||||
// near-duplicates ("Production" vs "production" vs "prod"). #729
|
||||
availableCategories: string[];
|
||||
// Global low-stock threshold (%); shown as placeholder on the per-spool
|
||||
// override input so users see what they're overriding. #729
|
||||
globalLowStockThreshold: number;
|
||||
}
|
||||
|
||||
// PA Profile section props
|
||||
|
||||
@@ -3298,6 +3298,15 @@ export default {
|
||||
tempOverrides: 'Temperatur-Überschreibungen',
|
||||
note: 'Notiz',
|
||||
notePlaceholder: 'Zusätzliche Notizen zu dieser Spule...',
|
||||
// Per-spool category + low-stock threshold override (#729)
|
||||
category: 'Kategorie',
|
||||
categoryPlaceholder: 'z. B. Produktion, Prototyp, Kunde A',
|
||||
categoryNone: 'Ohne Kategorie',
|
||||
lowStockThresholdOverride: 'Niedrigbestandsschwelle (diese Spule)',
|
||||
lowStockThresholdOverrideHelp: 'Leer lassen, um den globalen Schwellenwert ({{global}}%) zu verwenden.',
|
||||
// RFID button rename (was "Tag löschen")
|
||||
clearRfid: 'RFID-Tag löschen',
|
||||
rfidCleared: 'RFID-Tag gelöscht',
|
||||
archive: 'Archivieren',
|
||||
restore: 'Wiederherstellen',
|
||||
noSpools: 'Noch keine Spulen. Fügen Sie Ihre erste Spule hinzu.',
|
||||
|
||||
@@ -3301,6 +3301,16 @@ export default {
|
||||
tempOverrides: 'Temperature Overrides',
|
||||
note: 'Note',
|
||||
notePlaceholder: 'Any additional notes about this spool...',
|
||||
// Per-spool category + low-stock threshold override (#729)
|
||||
category: 'Category',
|
||||
categoryPlaceholder: 'e.g. Production, Prototype, Client A',
|
||||
categoryNone: 'Uncategorized',
|
||||
lowStockThresholdOverride: 'Low-stock threshold (this spool)',
|
||||
lowStockThresholdOverrideHelp: 'Leave blank to use the global threshold ({{global}}%).',
|
||||
// RFID button rename (was "Delete Tag" — confusing because it sounds like a
|
||||
// taxonomy delete; this clears the RFID tag/UUID off the spool record)
|
||||
clearRfid: 'Clear RFID Tag',
|
||||
rfidCleared: 'RFID tag cleared',
|
||||
archive: 'Archive',
|
||||
restore: 'Restore',
|
||||
noSpools: 'No spools yet. Add your first spool to get started.',
|
||||
|
||||
@@ -3220,6 +3220,14 @@ export default {
|
||||
tempOverrides: 'Exceptions Température',
|
||||
note: 'Note',
|
||||
notePlaceholder: 'Notes additionnelles sur cette bobine...',
|
||||
// Per-spool category + low-stock threshold override (#729)
|
||||
category: 'Catégorie',
|
||||
categoryPlaceholder: 'ex. Production, Prototype, Client A',
|
||||
categoryNone: 'Sans catégorie',
|
||||
lowStockThresholdOverride: 'Seuil bas (cette bobine)',
|
||||
lowStockThresholdOverrideHelp: 'Laisser vide pour utiliser le seuil global ({{global}} %).',
|
||||
clearRfid: 'Effacer le tag RFID',
|
||||
rfidCleared: 'Tag RFID effacé',
|
||||
archive: 'Archiver',
|
||||
restore: 'Restaurer',
|
||||
noSpools: 'Aucune bobine. Ajoutez votre première bobine pour commencer.',
|
||||
|
||||
@@ -3219,6 +3219,14 @@ export default {
|
||||
tempOverrides: 'Override Temperatura',
|
||||
note: 'Nota',
|
||||
notePlaceholder: 'Eventuali note aggiuntive su questa bobina...',
|
||||
// Per-spool category + low-stock threshold override (#729)
|
||||
category: 'Categoria',
|
||||
categoryPlaceholder: 'es. Produzione, Prototipo, Cliente A',
|
||||
categoryNone: 'Senza categoria',
|
||||
lowStockThresholdOverride: 'Soglia scorte basse (questa bobina)',
|
||||
lowStockThresholdOverrideHelp: 'Lascia vuoto per usare la soglia globale ({{global}}%).',
|
||||
clearRfid: 'Cancella tag RFID',
|
||||
rfidCleared: 'Tag RFID cancellato',
|
||||
archive: 'Archivia',
|
||||
restore: 'Ripristina',
|
||||
noSpools: 'Ancora nessuna bobina. Aggiungi la tua prima bobina per iniziare.',
|
||||
|
||||
@@ -3258,6 +3258,14 @@ export default {
|
||||
tempOverrides: '温度オーバーライド',
|
||||
note: 'メモ',
|
||||
notePlaceholder: 'このスプールに関する追加メモ...',
|
||||
// Per-spool category + low-stock threshold override (#729)
|
||||
category: 'カテゴリ',
|
||||
categoryPlaceholder: '例:本番、試作、クライアントA',
|
||||
categoryNone: 'カテゴリなし',
|
||||
lowStockThresholdOverride: '在庫低下のしきい値(このスプール)',
|
||||
lowStockThresholdOverrideHelp: '空欄の場合、グローバル設定({{global}}%)を使用します。',
|
||||
clearRfid: 'RFIDタグをクリア',
|
||||
rfidCleared: 'RFIDタグをクリアしました',
|
||||
archive: 'アーカイブ',
|
||||
restore: '復元',
|
||||
noSpools: 'スプールがありません。最初のスプールを追加してください。',
|
||||
|
||||
@@ -3233,6 +3233,14 @@ export default {
|
||||
tempOverrides: 'Substituições de Temperatura',
|
||||
note: 'Nota',
|
||||
notePlaceholder: 'Quaisquer notas adicionais sobre este spool...',
|
||||
// Per-spool category + low-stock threshold override (#729)
|
||||
category: 'Categoria',
|
||||
categoryPlaceholder: 'ex. Produção, Protótipo, Cliente A',
|
||||
categoryNone: 'Sem categoria',
|
||||
lowStockThresholdOverride: 'Limite de estoque baixo (este carretel)',
|
||||
lowStockThresholdOverrideHelp: 'Deixe em branco para usar o limite global ({{global}}%).',
|
||||
clearRfid: 'Limpar tag RFID',
|
||||
rfidCleared: 'Tag RFID limpa',
|
||||
archive: 'Arquivar',
|
||||
restore: 'Restaurar',
|
||||
noSpools: 'Nenhum carretel ainda. Adicione seu primeiro carretel para começar.',
|
||||
|
||||
@@ -3285,6 +3285,14 @@ export default {
|
||||
tempOverrides: '温度覆盖',
|
||||
note: '备注',
|
||||
notePlaceholder: '关于此耗材的任何备注...',
|
||||
// Per-spool category + low-stock threshold override (#729)
|
||||
category: '类别',
|
||||
categoryPlaceholder: '例如:生产、原型、客户A',
|
||||
categoryNone: '未分类',
|
||||
lowStockThresholdOverride: '低库存阈值(此料盘)',
|
||||
lowStockThresholdOverrideHelp: '留空以使用全局阈值({{global}}%)。',
|
||||
clearRfid: '清除 RFID 标签',
|
||||
rfidCleared: 'RFID 标签已清除',
|
||||
archive: '归档',
|
||||
restore: '恢复',
|
||||
noSpools: '暂无耗材。添加您的第一个耗材开始使用。',
|
||||
|
||||
@@ -3285,6 +3285,14 @@ export default {
|
||||
tempOverrides: '溫度覆蓋',
|
||||
note: '備註',
|
||||
notePlaceholder: '關於此耗材的任何備註...',
|
||||
// Per-spool category + low-stock threshold override (#729)
|
||||
category: '類別',
|
||||
categoryPlaceholder: '例如:生產、原型、客戶A',
|
||||
categoryNone: '未分類',
|
||||
lowStockThresholdOverride: '低庫存閾值(此料盤)',
|
||||
lowStockThresholdOverrideHelp: '留空以使用全域閾值({{global}}%)。',
|
||||
clearRfid: '清除 RFID 標籤',
|
||||
rfidCleared: 'RFID 標籤已清除',
|
||||
archive: '歸檔',
|
||||
restore: '恢復',
|
||||
noSpools: '尚無耗材。新增您的第一個耗材開始使用。',
|
||||
|
||||
@@ -482,6 +482,7 @@ function InventoryPage() {
|
||||
const [usageFilter, setUsageFilter] = useState<UsageFilter>('all');
|
||||
const [materialFilter, setMaterialFilter] = useState('');
|
||||
const [brandFilter, setBrandFilter] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState('');
|
||||
const [spoolFilter, setSpoolFilter] = useState('');
|
||||
const [stockFilter, setStockFilter] = useState<'all' | 'stock' | 'configured'>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -608,7 +609,8 @@ function InventoryPage() {
|
||||
totalWeight += remaining;
|
||||
totalConsumed += s.weight_used;
|
||||
const pct = s.label_weight > 0 ? (remaining / s.label_weight) * 100 : 0;
|
||||
if (pct < lowStockThreshold) lowStock++;
|
||||
const threshold = s.low_stock_threshold_pct ?? lowStockThreshold;
|
||||
if (pct < threshold) lowStock++;
|
||||
const mat = s.material || 'Unknown';
|
||||
if (!byMaterial[mat]) byMaterial[mat] = { count: 0, weight: 0 };
|
||||
byMaterial[mat].count++;
|
||||
@@ -667,7 +669,8 @@ function InventoryPage() {
|
||||
filtered = filtered.filter((s) => {
|
||||
const remaining = Math.max(0, s.label_weight - s.weight_used);
|
||||
const pct = s.label_weight > 0 ? (remaining / s.label_weight) * 100 : 0;
|
||||
return pct < lowStockThreshold;
|
||||
const threshold = s.low_stock_threshold_pct ?? lowStockThreshold;
|
||||
return pct < threshold;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -681,6 +684,15 @@ function InventoryPage() {
|
||||
filtered = filtered.filter((s) => s.brand === brandFilter);
|
||||
}
|
||||
|
||||
// Category dropdown (#729)
|
||||
if (categoryFilter) {
|
||||
if (categoryFilter === '__none__') {
|
||||
filtered = filtered.filter((s) => !s.category);
|
||||
} else {
|
||||
filtered = filtered.filter((s) => s.category === categoryFilter);
|
||||
}
|
||||
}
|
||||
|
||||
// Spool name dropdown
|
||||
if (spoolFilter) {
|
||||
const catalogId = Number(spoolFilter);
|
||||
@@ -708,7 +720,7 @@ function InventoryPage() {
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [spools, archiveFilter, usageFilter, materialFilter, brandFilter, spoolFilter, stockFilter, search, lowStockThreshold]);
|
||||
}, [spools, archiveFilter, usageFilter, materialFilter, brandFilter, categoryFilter, spoolFilter, stockFilter, search, lowStockThreshold]);
|
||||
|
||||
// Reset page on filter changes
|
||||
const resetPage = () => setPageIndex(0);
|
||||
@@ -716,6 +728,8 @@ function InventoryPage() {
|
||||
// Unique values for filter dropdowns
|
||||
const uniqueMaterials = [...new Set(spools?.map((s) => s.material) || [])].sort();
|
||||
const uniqueBrands = [...new Set(spools?.map((s) => s.brand).filter(Boolean) || [])].sort() as string[];
|
||||
const uniqueCategories = [...new Set(spools?.map((s) => s.category?.trim()).filter(Boolean) as string[] || [])].sort();
|
||||
const hasUncategorized = (spools ?? []).some((s) => !s.category);
|
||||
const uniqueSpoolCatalogIds = [...new Set(spools?.map((s) => s.core_weight_catalog_id).filter((id): id is number => id != null) || [])].sort((a, b) => {
|
||||
const nameA = (catalogMap[a]?.name || '').toLowerCase();
|
||||
const nameB = (catalogMap[b]?.name || '').toLowerCase();
|
||||
@@ -723,7 +737,7 @@ function InventoryPage() {
|
||||
});
|
||||
|
||||
// Check if any filters are non-default
|
||||
const hasActiveFilters = archiveFilter !== 'active' || usageFilter !== 'all' || !!materialFilter || !!brandFilter || !!spoolFilter || stockFilter !== 'all' || !!search;
|
||||
const hasActiveFilters = archiveFilter !== 'active' || usageFilter !== 'all' || !!materialFilter || !!brandFilter || !!categoryFilter || !!spoolFilter || stockFilter !== 'all' || !!search;
|
||||
|
||||
const handleColumnConfigSave = (config: ColumnConfig[]) => {
|
||||
setColumnConfig(config);
|
||||
@@ -844,6 +858,7 @@ function InventoryPage() {
|
||||
setUsageFilter('all');
|
||||
setMaterialFilter('');
|
||||
setBrandFilter('');
|
||||
setCategoryFilter('');
|
||||
setSpoolFilter('');
|
||||
setStockFilter('all');
|
||||
setSearch('');
|
||||
@@ -1200,6 +1215,28 @@ function InventoryPage() {
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Category dropdown chip (#729) — only render once at least one
|
||||
spool carries a category, otherwise it's noise. */}
|
||||
{(uniqueCategories.length > 0 || categoryFilter) && (
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => { setCategoryFilter(e.target.value); resetPage(); }}
|
||||
className={`px-3 py-1.5 rounded-lg border text-xs font-medium transition-colors cursor-pointer focus:outline-none ${
|
||||
categoryFilter
|
||||
? 'bg-bambu-green/20 text-bambu-green border-bambu-green/30'
|
||||
: 'bg-transparent text-bambu-gray border-bambu-dark-tertiary hover:bg-bambu-dark-tertiary'
|
||||
}`}
|
||||
>
|
||||
<option value="">{t('inventory.category')}</option>
|
||||
{uniqueCategories.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
{hasUncategorized && (
|
||||
<option value="__none__">{t('inventory.categoryNone')}</option>
|
||||
)}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{/* Spool name dropdown chip */}
|
||||
{uniqueSpoolCatalogIds.length > 0 && (
|
||||
<select
|
||||
|
||||
@@ -277,6 +277,8 @@ export function SpoolBuddyDashboard() {
|
||||
cost_per_kg: null,
|
||||
last_scale_weight: weight !== null ? Math.round(weight) : null,
|
||||
last_weighed_at: weight !== null ? new Date().toISOString() : null,
|
||||
category: null,
|
||||
low_stock_threshold_pct: null,
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
|
||||
@@ -381,6 +381,17 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, t }: {
|
||||
selectedSpool: InventorySpool | null;
|
||||
t: (key: string, fallback: string) => string;
|
||||
}) {
|
||||
// Read inventory + settings from the shared react-query cache to drive the
|
||||
// category autocomplete and low-stock-threshold placeholder. #729
|
||||
const { data: allSpoolsForForm = [] } = useQuery({
|
||||
queryKey: ['inventory-spools'],
|
||||
queryFn: () => api.getSpools(true),
|
||||
});
|
||||
const { data: settingsForForm } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: api.getSettings,
|
||||
});
|
||||
|
||||
const [viewMode, setViewMode] = useState<NewSpoolViewMode>('simple');
|
||||
const [activeSubTab, setActiveSubTab] = useState<NewSpoolSubTab>('filament');
|
||||
const [formData, setFormData] = useState<SpoolFormData>(defaultFormData);
|
||||
@@ -645,6 +656,8 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, t }: {
|
||||
tag_type: null,
|
||||
last_scale_weight: null,
|
||||
last_weighed_at: null,
|
||||
category: formData.category.trim() || null,
|
||||
low_stock_threshold_pct: formData.low_stock_threshold_pct,
|
||||
};
|
||||
|
||||
setCreating(true);
|
||||
@@ -847,6 +860,10 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, t }: {
|
||||
updateField={updateField}
|
||||
spoolCatalog={spoolCatalog}
|
||||
currencySymbol={currencySymbol}
|
||||
availableCategories={Array.from(new Set(
|
||||
allSpoolsForForm.map((s) => s.category?.trim()).filter((c): c is string => !!c),
|
||||
)).sort((a, b) => a.localeCompare(b))}
|
||||
globalLowStockThreshold={settingsForForm?.low_stock_threshold ?? 20}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -26,7 +26,7 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index--ZvtiDKM.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-SD-TbXyn.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-telVPl_h.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user