+
{
+ if (saveMutation.isPending || deleteMutation.isPending) return;
+ onClose();
+ }}
+ />
+
+
+
+
+
+ {t('locations.title')}
+
+
{t('locations.subtitle')}
+
+
+
+
+
+
+
+
+ {isLoading ? (
+
+
+ {t('common.loading')}
+
+ ) : locations.length === 0 ? (
+
{t('locations.empty')}
+ ) : (
+
+
+
+ | {t('locations.name')} |
+ {t('locations.spools')} |
+ {t('common.actions')} |
+
+
+
+ {locations.map((loc) => (
+ {
+ if (onPickLocation) {
+ onPickLocation(loc.id);
+ onClose();
+ }
+ }}
+ >
+ | {loc.name} |
+ {loc.spool_count} |
+ e.stopPropagation()}>
+
+
+
+
+ |
+
+ ))}
+
+
+ )}
+
+
+
+ {editorOpen && (
+
+
+
+
+ {editing ? t('locations.edit') : t('locations.add')}
+
+
+
+
+ )}
+
+ {deleteTarget && (
+
deleteMutation.mutate(deleteTarget.id)}
+ onCancel={() => setDeleteTarget(null)}
+ />
+ )}
+
+ );
+}
diff --git a/frontend/src/components/SpoolFormModal.tsx b/frontend/src/components/SpoolFormModal.tsx
index 1bb37ede2..d5e4e4ee9 100644
--- a/frontend/src/components/SpoolFormModal.tsx
+++ b/frontend/src/components/SpoolFormModal.tsx
@@ -16,6 +16,10 @@ import { AdditionalSection } from './spool-form/AdditionalSection';
import { SpoolmanFilamentPicker } from './spool-form/SpoolmanFilamentPicker';
import { PAProfileSection } from './spool-form/PAProfileSection';
import { SpoolUsageHistory } from './SpoolUsageHistory';
+import {
+ invalidateInventoryLocations,
+ invalidateSpoolAndLocationQueries,
+} from '../utils/inventoryQueries';
type TabId = 'filament' | 'pa-profile';
@@ -52,6 +56,9 @@ export function SpoolFormModal({
const queryClient = useQueryClient();
const { showToast } = useToast();
+ const refreshSpoolQueries = () =>
+ invalidateSpoolAndLocationQueries(queryClient, spoolsQueryKey);
+
const isEditing = mode === 'edit';
const isCopying = mode === 'copy';
@@ -60,7 +67,7 @@ export function SpoolFormModal({
const [errors, setErrors] = useState
>>({});
const [activeTab, setActiveTab] = useState('filament');
const [weightTouched, setWeightTouched] = useState(false);
- const [storageLocationTouched, setStorageLocationTouched] = useState(false);
+ const [locationIdTouched, setLocationIdTouched] = useState(false);
const [quickAdd, setQuickAdd] = useState(false);
const [quantity, setQuantity] = useState(1);
@@ -72,6 +79,7 @@ export function SpoolFormModal({
// Spool catalog
const [spoolCatalog, setSpoolCatalog] = useState([]);
+ const [storageLocations, setStorageLocations] = useState<{ id: number; name: string }[]>([]);
// Local presets (OrcaSlicer imports)
const [localPresets, setLocalPresets] = useState([]);
@@ -176,6 +184,7 @@ export function SpoolFormModal({
api.getColorCatalog().then(setColorCatalog).catch(console.error);
api.getLocalPresets().then(r => setLocalPresets(r.filament)).catch(console.error);
api.getBuiltinFilaments().then(setBuiltinFilaments).catch(console.error);
+ api.getLocations().then((locs) => setStorageLocations(locs.map((l) => ({ id: l.id, name: l.name })))).catch(console.error);
// Fetch printer calibrations if not provided via props
if (printersWithCalibrations.length === 0) {
@@ -360,7 +369,7 @@ export function SpoolFormModal({
cost_per_kg: spool.cost_per_kg ?? null,
category: spool.category || '',
low_stock_threshold_pct: spool.low_stock_threshold_pct ?? null,
- storage_location: spool.storage_location || '',
+ location_id: spool.location_id ?? null,
spoolman_filament_id: null,
});
setPresetInputValue(spool.slicer_filament_name || spool.slicer_filament || '');
@@ -387,10 +396,21 @@ export function SpoolFormModal({
setErrors({});
setActiveTab('filament');
setWeightTouched(false);
- setStorageLocationTouched(false);
+ setLocationIdTouched(false);
}
}, [isOpen, spool, mode, isCopying]);
+ // Legacy rows may have storage_location text but no location_id yet — link when catalog loads.
+ useEffect(() => {
+ if (!isOpen || !spool || locationIdTouched || formData.location_id != null) return;
+ const legacy = spool.storage_location?.trim();
+ if (!legacy || storageLocations.length === 0) return;
+ const match = storageLocations.find((l) => l.name.toLowerCase() === legacy.toLowerCase());
+ if (match) {
+ setFormData((prev) => (prev.location_id === match.id ? prev : { ...prev, location_id: match.id }));
+ }
+ }, [isOpen, spool, storageLocations, formData.location_id, locationIdTouched]);
+
// Expand all printers in PA profile section when calibrations are available
useEffect(() => {
if (isOpen && resolvedCalibrations.length > 0) {
@@ -412,7 +432,7 @@ export function SpoolFormModal({
: {}),
}));
if (key === 'weight_used') setWeightTouched(true);
- if (key === 'storage_location') setStorageLocationTouched(true);
+ if (key === 'location_id') setLocationIdTouched(true);
if (errors[key]) {
setErrors(prev => ({ ...prev, [key]: undefined }));
}
@@ -456,7 +476,7 @@ export function SpoolFormModal({
const ok = await saveKProfiles(newSpool.id);
if (!ok) return;
}
- await queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
+ await refreshSpoolQueries();
if (onSpoolsCreated) onSpoolsCreated([newSpool]);
showToast(t('inventory.spoolCreated'), 'success');
onClose();
@@ -495,7 +515,7 @@ export function SpoolFormModal({
await saveKProfiles(s.id);
}
}
- await queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
+ await refreshSpoolQueries();
if (onSpoolsCreated) onSpoolsCreated(createdSpools);
if (spoolmanResult && spoolmanResult.failed_count > 0) {
showToast(
@@ -529,7 +549,7 @@ export function SpoolFormModal({
const ok = await saveKProfiles(spool.id);
if (!ok) return;
}
- await queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
+ await refreshSpoolQueries();
showToast(t('inventory.spoolUpdated'), 'success');
onClose();
},
@@ -550,7 +570,7 @@ export function SpoolFormModal({
return api.updateSpool(spool!.id, CLEAR_TAG_PAYLOAD as Parameters[1]);
},
onSuccess: async () => {
- await queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
+ await refreshSpoolQueries();
showToast(t('inventory.rfidCleared', 'RFID tag cleared'), 'success');
onClose();
},
@@ -748,11 +768,10 @@ export function SpoolFormModal({
data.weight_used = formData.weight_used;
}
- // Only send storage_location when creating or when explicitly changed by the user.
- // This prevents the modal round-trip from overwriting the Spoolman location field
- // with a stale cached value when the user saves without touching this field.
- if (!isEditing || storageLocationTouched) {
- data.storage_location = formData.storage_location || null;
+ // Only send location_id when creating or when explicitly changed by the user.
+ // Backend derives storage_location; omitting on untouched edit avoids stale overwrites.
+ if (!isEditing || locationIdTouched) {
+ data.location_id = formData.location_id;
}
if (isEditing) {
@@ -921,6 +940,23 @@ export function SpoolFormModal({
spoolCatalog={spoolCatalog}
currencySymbol={currencySymbol}
availableCategories={availableCategories}
+ availableLocations={storageLocations}
+ onCreateLocation={async (name) => {
+ try {
+ const created = await api.createLocation({ name });
+ setStorageLocations((prev) => [...prev, { id: created.id, name: created.name }].sort((a, b) => a.name.localeCompare(b.name)));
+ await invalidateInventoryLocations(queryClient);
+ return { id: created.id, name: created.name };
+ } catch (e) {
+ // Surface the backend's actual error so the user can
+ // distinguish 409 duplicate / 400 validation / 500 from
+ // a generic "save failed" message.
+ console.error(e);
+ const message = e instanceof Error ? e.message : t('locations.saveFailed');
+ showToast(message || t('locations.saveFailed'), 'error');
+ return null;
+ }
+ }}
globalLowStockThreshold={globalLowStockThreshold}
spoolmanMode={spoolmanMode}
/>
diff --git a/frontend/src/components/spool-form/AdditionalSection.tsx b/frontend/src/components/spool-form/AdditionalSection.tsx
index 185c1ee15..67a383872 100644
--- a/frontend/src/components/spool-form/AdditionalSection.tsx
+++ b/frontend/src/components/spool-form/AdditionalSection.tsx
@@ -174,6 +174,8 @@ export function AdditionalSection({
spoolCatalog,
currencySymbol,
availableCategories,
+ availableLocations = [],
+ onCreateLocation,
globalLowStockThreshold,
spoolmanMode = false,
}: AdditionalSectionProps) {
@@ -183,6 +185,8 @@ export function AdditionalSection({
const [isMeasuredFocused, setIsMeasuredFocused] = useState(false);
const [remainingInput, setRemainingInput] = useState('');
const [isRemainingFocused, setIsRemainingFocused] = useState(false);
+ const [newLocationName, setNewLocationName] = useState('');
+ const [creatingLocation, setCreatingLocation] = useState(false);
const remainingWeight = Math.max(0, formData.label_weight - formData.weight_used);
const measuredDefault = formData.core_weight + remainingWeight;
@@ -381,15 +385,61 @@ export function AdditionalSection({
{/* Storage Location */}
-
-
updateField('storage_location', e.target.value)}
- />
+
+
+ {onCreateLocation && (
+
+ setNewLocationName(e.target.value)}
+ />
+
+
+ )}
);
diff --git a/frontend/src/components/spool-form/types.ts b/frontend/src/components/spool-form/types.ts
index a1c2083d4..5d5bccb9f 100644
--- a/frontend/src/components/spool-form/types.ts
+++ b/frontend/src/components/spool-form/types.ts
@@ -36,7 +36,7 @@ export interface SpoolFormData {
// User-defined category + per-spool low-stock threshold override (#729).
category: string;
low_stock_threshold_pct: number | null;
- storage_location: string;
+ location_id: number | null;
// When set the spool is linked to a specific Spoolman filament catalog entry;
// the backend skips find_or_create_filament() and uses this ID directly.
spoolman_filament_id: number | null;
@@ -59,7 +59,7 @@ export const defaultFormData: SpoolFormData = {
cost_per_kg: null,
category: '',
low_stock_threshold_pct: null,
- storage_location: '',
+ location_id: null,
spoolman_filament_id: null,
};
@@ -143,6 +143,8 @@ export interface AdditionalSectionProps extends SectionProps {
// Global low-stock threshold (%); shown as placeholder on the per-spool
// override input so users see what they're overriding. #729
globalLowStockThreshold: number;
+ availableLocations?: { id: number; name: string }[];
+ onCreateLocation?: (name: string) => Promise<{ id: number; name: string } | null>;
// When true the empty-spool weight is managed by Spoolman on the filament
// object, so SpoolWeightPicker is hidden and an info notice is shown instead.
spoolmanMode?: boolean;
diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts
index e99b4ab99..773f98294 100644
--- a/frontend/src/hooks/useWebSocket.ts
+++ b/frontend/src/hooks/useWebSocket.ts
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { useToast } from '../contexts/ToastContext';
import { useTranslation } from 'react-i18next';
import { api } from '../api/client';
+import { inventoryLocationsQueryKey } from '../utils/inventoryQueries';
interface WebSocketMessage {
type: string;
@@ -288,6 +289,8 @@ export function useWebSocket() {
case 'inventory_changed':
// Spool created/updated/deleted/archived/restored - refresh inventory across all tabs
debouncedInvalidate('inventory-spools');
+ debouncedInvalidate('spoolman-inventory-spools');
+ debouncedInvalidate(inventoryLocationsQueryKey[0]);
break;
case 'spool_assignment_changed':
diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts
index 93774582a..149b8d289 100644
--- a/frontend/src/i18n/locales/de.ts
+++ b/frontend/src/i18n/locales/de.ts
@@ -3690,6 +3690,28 @@ export default {
reportPartialUsageDesc: 'Wenn ein Druck fehlschlägt oder abgebrochen wird, den geschätzten Filamentverbrauch bis zu diesem Zeitpunkt basierend auf dem Schichtfortschritt melden.',
},
+ locations: {
+ title: 'Lagerorte',
+ subtitle: 'Regale, Schubladen und andere physische Lagerplätze für Spulen verwalten',
+ add: 'Lagerort hinzufügen',
+ addShort: 'Hinzufügen',
+ edit: 'Lagerort bearbeiten',
+ name: 'Name',
+ spools: 'Spulen',
+ empty: 'Noch keine Lagerorte. Erstellen Sie Ihr erstes Regal oder Ihre erste Schublade.',
+ manage: 'Lagerorte',
+ createPlaceholder: 'z. B. Regal A, Schublade 1',
+ nameRequired: 'Name des Lagerorts ist erforderlich',
+ created: 'Lagerort erstellt',
+ updated: 'Lagerort aktualisiert',
+ deleted: 'Lagerort gelöscht',
+ saveFailed: 'Lagerort konnte nicht gespeichert werden',
+ deleteFailed: 'Lagerort konnte nicht gelöscht werden',
+ deleteBlocked: 'Entfernen Sie zuerst alle Spulen von diesem Lagerort',
+ confirmDelete: '„{{name}}“ löschen?',
+ confirmDeleteMessage: 'Dieser Lagerort wird aus dem Katalog entfernt. Spulen müssen zuerst verschoben werden.',
+ },
+
// Inventar
inventory: {
title: 'Spulen-Inventar',
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index 88f6cb400..1bb70b2f0 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -3701,6 +3701,28 @@ export default {
reportPartialUsageDesc: 'When a print fails or is cancelled, report the estimated filament used up to that point based on layer progress.',
},
+ locations: {
+ title: 'Storage Locations',
+ subtitle: 'Manage shelves, drawers, and other physical storage spots for your spools',
+ add: 'Add Location',
+ addShort: 'Add',
+ edit: 'Edit Location',
+ name: 'Name',
+ spools: 'Spools',
+ empty: 'No storage locations yet. Create your first shelf or drawer.',
+ manage: 'Locations',
+ createPlaceholder: 'e.g. Shelf A, Drawer 1',
+ nameRequired: 'Location name is required',
+ created: 'Location created',
+ updated: 'Location updated',
+ deleted: 'Location deleted',
+ saveFailed: 'Failed to save location',
+ deleteFailed: 'Failed to delete location',
+ deleteBlocked: 'Remove all spools from this location before deleting',
+ confirmDelete: 'Delete "{{name}}"?',
+ confirmDeleteMessage: 'This location will be removed from the catalog. Spools must be moved first.',
+ },
+
// Inventory
inventory: {
title: 'Spool Inventory',
diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts
index 0ef2a1402..0da10881b 100644
--- a/frontend/src/i18n/locales/es.ts
+++ b/frontend/src/i18n/locales/es.ts
@@ -3693,6 +3693,28 @@ export default {
reportPartialUsageDesc: 'Cuando una impresión falla o se cancela, informar del filamento estimado usado hasta ese punto según el progreso de las capas.',
},
+ locations: {
+ title: 'Ubicaciones de almacenamiento',
+ subtitle: 'Gestione estantes, cajones y otros lugares físicos para sus bobinas',
+ add: 'Añadir ubicación',
+ addShort: 'Añadir',
+ edit: 'Editar ubicación',
+ name: 'Nombre',
+ spools: 'Bobinas',
+ empty: 'Aún no hay ubicaciones de almacenamiento. Cree su primer estante o cajón.',
+ manage: 'Ubicaciones',
+ createPlaceholder: 'p. ej. Estante A, Cajón 1',
+ nameRequired: 'El nombre de la ubicación es obligatorio',
+ created: 'Ubicación creada',
+ updated: 'Ubicación actualizada',
+ deleted: 'Ubicación eliminada',
+ saveFailed: 'No se pudo guardar la ubicación',
+ deleteFailed: 'No se pudo eliminar la ubicación',
+ deleteBlocked: 'Retire todas las bobinas de esta ubicación antes de eliminarla',
+ confirmDelete: '¿Eliminar «{{name}}»?',
+ confirmDeleteMessage: 'Esta ubicación se eliminará del catálogo. Mueva las bobinas primero.',
+ },
+
// Inventory
inventory: {
title: 'Inventario de bobinas',
diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts
index 01d07b715..adb20d2cf 100644
--- a/frontend/src/i18n/locales/fr.ts
+++ b/frontend/src/i18n/locales/fr.ts
@@ -3679,6 +3679,28 @@ export default {
reportPartialUsageDesc: 'Si l\'impression échoue, rapporte le filament consommé selon les couches.',
},
+ locations: {
+ title: 'Emplacements de stockage',
+ subtitle: 'Gérez étagères, tiroirs et autres emplacements physiques pour vos bobines',
+ add: 'Ajouter un emplacement',
+ addShort: 'Ajouter',
+ edit: 'Modifier l\'emplacement',
+ name: 'Nom',
+ spools: 'Bobines',
+ empty: 'Aucun emplacement de stockage. Créez votre première étagère ou tiroir.',
+ manage: 'Emplacements',
+ createPlaceholder: 'ex. Étagère A, Tiroir 1',
+ nameRequired: 'Le nom de l\'emplacement est requis',
+ created: 'Emplacement créé',
+ updated: 'Emplacement mis à jour',
+ deleted: 'Emplacement supprimé',
+ saveFailed: 'Échec de l\'enregistrement de l\'emplacement',
+ deleteFailed: 'Échec de la suppression de l\'emplacement',
+ deleteBlocked: 'Retirez d\'abord toutes les bobines de cet emplacement',
+ confirmDelete: 'Supprimer « {{name}} » ?',
+ confirmDeleteMessage: 'Cet emplacement sera retiré du catalogue. Déplacez d\'abord les bobines.',
+ },
+
// Inventory
inventory: {
title: 'Inventaire de Bobines',
diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts
index 075ba5173..757daa5e1 100644
--- a/frontend/src/i18n/locales/it.ts
+++ b/frontend/src/i18n/locales/it.ts
@@ -3678,6 +3678,28 @@ export default {
reportPartialUsageDesc: 'Quando una stampa fallisce o viene annullata, segnala il filamento stimato usato fino a quel punto in base all\'avanzamento layer.',
},
+ locations: {
+ title: 'Ubicazioni di stoccaggio',
+ subtitle: 'Gestisci scaffali, cassetti e altri posti fisici per le bobine',
+ add: 'Aggiungi ubicazione',
+ addShort: 'Aggiungi',
+ edit: 'Modifica ubicazione',
+ name: 'Nome',
+ spools: 'Bobine',
+ empty: 'Nessuna ubicazione di stoccaggio. Crea il tuo primo scaffale o cassetto.',
+ manage: 'Ubicazioni',
+ createPlaceholder: 'es. Scaffale A, Cassetto 1',
+ nameRequired: 'Il nome dell\'ubicazione è obbligatorio',
+ created: 'Ubicazione creata',
+ updated: 'Ubicazione aggiornata',
+ deleted: 'Ubicazione eliminata',
+ saveFailed: 'Impossibile salvare l\'ubicazione',
+ deleteFailed: 'Impossibile eliminare l\'ubicazione',
+ deleteBlocked: 'Rimuovi prima tutte le bobine da questa ubicazione',
+ confirmDelete: 'Eliminare «{{name}}»?',
+ confirmDeleteMessage: 'Questa ubicazione verrà rimossa dal catalogo. Sposta prima le bobine.',
+ },
+
// Inventory
inventory: {
title: 'Inventario Bobine',
diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts
index 852b3a75d..38f694cfc 100644
--- a/frontend/src/i18n/locales/ja.ts
+++ b/frontend/src/i18n/locales/ja.ts
@@ -3690,6 +3690,28 @@ export default {
reportPartialUsageDesc: '印刷が失敗またはキャンセルされた場合、レイヤー進捗に基づいてその時点までの推定フィラメント使用量を報告します。',
},
+ locations: {
+ title: '保管場所',
+ subtitle: '棚・引き出しなど、スプールの物理的な保管場所を管理',
+ add: '場所を追加',
+ addShort: '追加',
+ edit: '場所を編集',
+ name: '名前',
+ spools: 'スプール',
+ empty: '保管場所がありません。最初の棚または引き出しを作成してください。',
+ manage: '保管場所',
+ createPlaceholder: '例: 棚A、引き出し1',
+ nameRequired: '場所名が必要です',
+ created: '場所を作成しました',
+ updated: '場所を更新しました',
+ deleted: '場所を削除しました',
+ saveFailed: '場所の保存に失敗しました',
+ deleteFailed: '場所の削除に失敗しました',
+ deleteBlocked: '削除前にこの場所のスプールをすべて移動してください',
+ confirmDelete: '「{{name}}」を削除しますか?',
+ confirmDeleteMessage: 'この場所はカタログから削除されます。先にスプールを移動してください。',
+ },
+
// Inventory
inventory: {
title: 'スプール在庫管理',
diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts
index e727331ac..30c4f637a 100644
--- a/frontend/src/i18n/locales/ko.ts
+++ b/frontend/src/i18n/locales/ko.ts
@@ -3483,6 +3483,29 @@ export default {
reportPartialUsage: '실패한 인쇄물에 대한 부분 사용량 보고',
reportPartialUsageDesc: '인쇄가 실패하거나 취소될 때 레이어 진행률을 기반으로 해당 시점까지 사용된 예상 필라멘트를 보고합니다.'
},
+
+ locations: {
+ title: '보관 위치',
+ subtitle: '스풀의 선반, 서랍 등 물리적 보관 장소를 관리합니다',
+ add: '위치 추가',
+ addShort: '추가',
+ edit: '위치 편집',
+ name: '이름',
+ spools: '스풀',
+ empty: '아직 보관 위치가 없습니다. 첫 번째 선반이나 서랍을 만드세요.',
+ manage: '위치',
+ createPlaceholder: '예: 선반 A, 서랍 1',
+ nameRequired: '위치 이름은 필수입니다',
+ created: '위치가 생성되었습니다',
+ updated: '위치가 업데이트되었습니다',
+ deleted: '위치가 삭제되었습니다',
+ saveFailed: '위치 저장에 실패했습니다',
+ deleteFailed: '위치 삭제에 실패했습니다',
+ deleteBlocked: '삭제하기 전에 이 위치의 모든 스풀을 옮기세요',
+ confirmDelete: '"{{name}}"을(를) 삭제하시겠습니까?',
+ confirmDeleteMessage: '이 위치가 카탈로그에서 제거됩니다. 스풀을 먼저 옮겨야 합니다.',
+ },
+
inventory: {
title: '스풀 재고',
spoolmanMixedContentTitle: 'HTTPS에서 Spoolman을 불러올 수 없음 — 브라우저가 혼합 콘텐츠를 차단함',
diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts
index 2d500a29f..d2c1837fb 100644
--- a/frontend/src/i18n/locales/pt-BR.ts
+++ b/frontend/src/i18n/locales/pt-BR.ts
@@ -3678,6 +3678,28 @@ export default {
reportPartialUsageDesc: 'Quando uma impressão falha ou é cancelada, relate o filamento estimado usado até aquele ponto com base no progresso das camadas.',
},
+ locations: {
+ title: 'Locais de armazenamento',
+ subtitle: 'Gerencie prateleiras, gavetas e outros locais físicos para bobinas',
+ add: 'Adicionar local',
+ addShort: 'Adicionar',
+ edit: 'Editar local',
+ name: 'Nome',
+ spools: 'Bobinas',
+ empty: 'Nenhum local de armazenamento. Crie sua primeira prateleira ou gaveta.',
+ manage: 'Locais',
+ createPlaceholder: 'ex. Prateleira A, Gaveta 1',
+ nameRequired: 'O nome do local é obrigatório',
+ created: 'Local criado',
+ updated: 'Local atualizado',
+ deleted: 'Local excluído',
+ saveFailed: 'Falha ao salvar local',
+ deleteFailed: 'Falha ao excluir local',
+ deleteBlocked: 'Remova todas as bobinas deste local antes de excluir',
+ confirmDelete: 'Excluir «{{name}}»?',
+ confirmDeleteMessage: 'Este local será removido do catálogo. Mova as bobinas primeiro.',
+ },
+
// Inventory
inventory: {
title: 'Inventário de Carretéis',
diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts
index e9804740b..210b69be2 100644
--- a/frontend/src/i18n/locales/tr.ts
+++ b/frontend/src/i18n/locales/tr.ts
@@ -3679,6 +3679,28 @@ export default {
reportPartialUsageDesc: 'Bir baskı başarısız olduğunda veya iptal edildiğinde, katman ilerlemesine göre o noktaya kadar kullanılan tahmini filamenti bildir.',
},
+ locations: {
+ title: 'Depolama Konumları',
+ subtitle: 'Makaralarınız için raf, çekmece ve diğer fiziksel depolama yerlerini yönetin',
+ add: 'Konum Ekle',
+ addShort: 'Ekle',
+ edit: 'Konumu Düzenle',
+ name: 'Ad',
+ spools: 'Makaralar',
+ empty: 'Henüz depolama konumu yok. İlk rafınızı veya çekmecenizi oluşturun.',
+ manage: 'Konumlar',
+ createPlaceholder: 'örn. Raf A, Çekmece 1',
+ nameRequired: 'Konum adı zorunludur',
+ created: 'Konum oluşturuldu',
+ updated: 'Konum güncellendi',
+ deleted: 'Konum silindi',
+ saveFailed: 'Konum kaydedilemedi',
+ deleteFailed: 'Konum silinemedi',
+ deleteBlocked: 'Silmeden önce bu konumdaki tüm makaraları taşıyın',
+ confirmDelete: '"{{name}}" silinsin mi?',
+ confirmDeleteMessage: 'Bu konum kataloğdan kaldırılacak. Önce makaralar taşınmalıdır.',
+ },
+
// Envanter
inventory: {
title: 'Makara Envanteri',
diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts
index aa9758d13..82e763660 100644
--- a/frontend/src/i18n/locales/zh-CN.ts
+++ b/frontend/src/i18n/locales/zh-CN.ts
@@ -3678,6 +3678,28 @@ export default {
reportPartialUsageDesc: '当打印失败或被取消时,根据层进度报告估计的耗材使用量。',
},
+ locations: {
+ title: '存储位置',
+ subtitle: '管理货架、抽屉等线轴物理存放位置',
+ add: '添加位置',
+ addShort: '添加',
+ edit: '编辑位置',
+ name: '名称',
+ spools: '线轴',
+ empty: '尚无存储位置。创建第一个货架或抽屉。',
+ manage: '位置',
+ createPlaceholder: '例如:A 架、抽屉 1',
+ nameRequired: '位置名称为必填项',
+ created: '位置已创建',
+ updated: '位置已更新',
+ deleted: '位置已删除',
+ saveFailed: '保存位置失败',
+ deleteFailed: '删除位置失败',
+ deleteBlocked: '删除前请移走此位置上的所有线轴',
+ confirmDelete: '删除「{{name}}」?',
+ confirmDeleteMessage: '此位置将从目录中移除。请先移走线轴。',
+ },
+
// Inventory
inventory: {
title: '耗材库存',
diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts
index 0f357e91a..d983b19cd 100644
--- a/frontend/src/i18n/locales/zh-TW.ts
+++ b/frontend/src/i18n/locales/zh-TW.ts
@@ -3678,6 +3678,28 @@ export default {
reportPartialUsageDesc: '當列印失敗或被取消時,根據層進度報告估計的耗材使用量。',
},
+ locations: {
+ title: '儲存位置',
+ subtitle: '管理貨架、抽屜等線軸實體存放位置',
+ add: '新增位置',
+ addShort: '新增',
+ edit: '編輯位置',
+ name: '名稱',
+ spools: '線軸',
+ empty: '尚無儲存位置。建立第一個貨架或抽屜。',
+ manage: '位置',
+ createPlaceholder: '例如:A 架、抽屜 1',
+ nameRequired: '位置名稱為必填',
+ created: '位置已建立',
+ updated: '位置已更新',
+ deleted: '位置已刪除',
+ saveFailed: '儲存位置失敗',
+ deleteFailed: '刪除位置失敗',
+ deleteBlocked: '刪除前請移走此位置上的所有線軸',
+ confirmDelete: '刪除「{{name}}」?',
+ confirmDeleteMessage: '此位置將從目錄中移除。請先移走線軸。',
+ },
+
// Inventory
inventory: {
title: '耗材庫存',
diff --git a/frontend/src/pages/InventoryPage.tsx b/frontend/src/pages/InventoryPage.tsx
index 2eb83868f..87dbc1920 100644
--- a/frontend/src/pages/InventoryPage.tsx
+++ b/frontend/src/pages/InventoryPage.tsx
@@ -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, Eraser,
+ ArrowUp, ArrowDown, ArrowUpDown, Group, ChevronDown, Check, RefreshCw, TrendingUp, Lock, Copy, Eraser, MapPin,
Upload, Download,
} from 'lucide-react';
import { ForecastPanel } from '../components/ForecastPanel';
@@ -20,6 +20,7 @@ import { ConfirmModal } from '../components/ConfirmModal';
import { ColumnConfigModal, type ColumnConfig } from '../components/ColumnConfigModal';
import { LabelTemplatePickerModal } from '../components/LabelTemplatePickerModal';
import { SpoolCsvImportModal } from '../components/SpoolCsvImportModal';
+import { LocationsModal } from '../components/LocationsModal';
import { useToast } from '../contexts/ToastContext';
import { useAuth } from '../contexts/AuthContext';
import { resolveSpoolColorName } from '../utils/colors';
@@ -27,6 +28,10 @@ import { getCurrencySymbol } from '../utils/currency';
import { formatDateInput, parseUTCDate, type DateFormat } from '../utils/date';
import { formatSlotLabel } from '../utils/amsHelpers';
import { filterSpoolsByQuery } from '../utils/inventorySearch';
+import {
+ inventoryLocationsQueryKey,
+ invalidateSpoolAndLocationQueries,
+} from '../utils/inventoryQueries';
import { aggregateGroupSpool } from '../utils/inventoryGrouping';
type ArchiveFilter = 'active' | 'archived';
@@ -478,6 +483,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
// CSV import/export (#1576). Local inventory only — hidden in Spoolman mode.
const [csvImportOpen, setCsvImportOpen] = useState(false);
const [exportingCsv, setExportingCsv] = useState(false);
+ const [locationsModalOpen, setLocationsModalOpen] = useState(false);
// Filter state
const [archiveFilter, setArchiveFilter] = useState