mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 21:47:39 +02:00
Add bulk spool addition and stock spools
Add Quick Add mode to the spool form for simplified entry (material + color + weight only), and a quantity field (1-100) for creating multiple identical spools at once. Stock spools (no slicer profile) are computed rather than stored — any spool without slicer_filament shows an amber "Stock" badge. New filter chips (All/Stock/Configured) on the inventory page. Backend bulk endpoint creates N spools in one transaction. Backend: - SpoolBulkCreate schema with quantity validation (1-100) - POST /inventory/spools/bulk endpoint Frontend: - Quick Add toggle in SpoolFormModal (create mode only) - Quantity field in FilamentSection (both modes) - bulkCreateSpools API method and bulkCreateMutation - Stock column (hidden by default) and stock filter chips - Inline error rendering moved into FilamentSection - CellCtx extended with t() for translatable cell content Tests: - 13 backend tests (schema validation + endpoint logic) - 10 frontend tests (validateForm quickAdd + UI behavior) i18n: 6 locales (en, de, fr, it, ja, pt-BR) Docs: CHANGELOG, README, website features, wiki inventory
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, Loader2, Save, Beaker, Palette } from 'lucide-react';
|
||||
import { X, Loader2, Save, Beaker, Palette, Zap } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset } from '../api/client';
|
||||
import { Button } from './Button';
|
||||
@@ -38,6 +38,8 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof SpoolFormData, string>>>({});
|
||||
const [activeTab, setActiveTab] = useState<TabId>('filament');
|
||||
const [weightTouched, setWeightTouched] = useState(false);
|
||||
const [quickAdd, setQuickAdd] = useState(false);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
|
||||
// Cloud presets
|
||||
const [cloudAuthenticated, setCloudAuthenticated] = useState(false);
|
||||
@@ -275,6 +277,8 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
|
||||
setFormData(defaultFormData);
|
||||
setPresetInputValue('');
|
||||
setSelectedProfiles(new Set());
|
||||
setQuickAdd(false);
|
||||
setQuantity(1);
|
||||
}
|
||||
setErrors({});
|
||||
setActiveTab('filament');
|
||||
@@ -321,6 +325,24 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
|
||||
},
|
||||
});
|
||||
|
||||
const bulkCreateMutation = useMutation({
|
||||
mutationFn: ({ data, qty }: { data: Record<string, unknown>; qty: number }) =>
|
||||
api.bulkCreateSpools(data as Parameters<typeof api.bulkCreateSpools>[0], qty),
|
||||
onSuccess: async (newSpools) => {
|
||||
if (selectedProfiles.size > 0) {
|
||||
for (const spool of newSpools) {
|
||||
await saveKProfiles(spool.id);
|
||||
}
|
||||
}
|
||||
await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
|
||||
showToast(t('inventory.spoolsCreated', { count: newSpools.length }), 'success');
|
||||
onClose();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message, 'error');
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) =>
|
||||
api.updateSpool(spool!.id, data as Parameters<typeof api.updateSpool>[1]),
|
||||
@@ -397,7 +419,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSubmit = () => {
|
||||
const validation = validateForm(formData);
|
||||
const validation = validateForm(formData, quickAdd);
|
||||
if (!validation.isValid) {
|
||||
setErrors(validation.errors);
|
||||
// Switch to filament tab if there are errors there
|
||||
@@ -435,12 +457,14 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
|
||||
|
||||
if (isEditing) {
|
||||
updateMutation.mutate(data);
|
||||
} else if (quantity > 1) {
|
||||
bulkCreateMutation.mutate({ data, qty: quantity });
|
||||
} else {
|
||||
createMutation.mutate(data);
|
||||
}
|
||||
};
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
const isPending = createMutation.isPending || bulkCreateMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
@@ -463,6 +487,32 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quick Add toggle — only in create mode */}
|
||||
{!isEditing && (
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-bambu-dark-tertiary flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-amber-400" />
|
||||
<span className="text-sm text-white">{t('inventory.quickAdd')}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setQuickAdd(!quickAdd);
|
||||
if (!quickAdd) setActiveTab('filament');
|
||||
}}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
||||
quickAdd ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${
|
||||
quickAdd ? 'translate-x-4' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-bambu-dark-tertiary flex-shrink-0">
|
||||
<button
|
||||
@@ -476,22 +526,24 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
|
||||
<Palette className="w-4 h-4" />
|
||||
{t('inventory.filamentInfoTab')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('pa-profile')}
|
||||
className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${
|
||||
activeTab === 'pa-profile'
|
||||
? 'text-bambu-green border-b-2 border-bambu-green'
|
||||
: 'text-bambu-gray hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Beaker className="w-4 h-4" />
|
||||
{t('inventory.paProfileTab')}
|
||||
{selectedProfileCount > 0 && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-full bg-bambu-green/20 text-bambu-green">
|
||||
{selectedProfileCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{!quickAdd && (
|
||||
<button
|
||||
onClick={() => setActiveTab('pa-profile')}
|
||||
className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${
|
||||
activeTab === 'pa-profile'
|
||||
? 'text-bambu-green border-b-2 border-bambu-green'
|
||||
: 'text-bambu-gray hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Beaker className="w-4 h-4" />
|
||||
{t('inventory.paProfileTab')}
|
||||
{selectedProfileCount > 0 && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-full bg-bambu-green/20 text-bambu-green">
|
||||
{selectedProfileCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
@@ -514,19 +566,11 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
|
||||
filamentOptions={filamentOptions}
|
||||
availableBrands={availableBrands}
|
||||
availableMaterials={availableMaterials}
|
||||
quickAdd={quickAdd}
|
||||
quantity={quantity}
|
||||
onQuantityChange={setQuantity}
|
||||
errors={errors}
|
||||
/>
|
||||
{errors.slicer_filament && (
|
||||
<p className="mt-1 text-xs text-red-400">{errors.slicer_filament}</p>
|
||||
)}
|
||||
{errors.material && (
|
||||
<p className="mt-1 text-xs text-red-400">{errors.material}</p>
|
||||
)}
|
||||
{errors.brand && (
|
||||
<p className="mt-1 text-xs text-red-400">{errors.brand}</p>
|
||||
)}
|
||||
{errors.subtype && (
|
||||
<p className="mt-1 text-xs text-red-400">{errors.subtype}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Color Section */}
|
||||
|
||||
Reference in New Issue
Block a user