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

This commit is contained in:
Samed Yüksel
2026-06-08 09:30:16 +02:00
committed by GitHub
parent 2c2725cb53
commit 66c09dff2d
18 changed files with 1918 additions and 2 deletions
@@ -0,0 +1,87 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor, fireEvent } from '@testing-library/react';
import { render } from '../utils';
import { SpoolCsvImportModal } from '../../components/SpoolCsvImportModal';
import { api, type CsvImportPreview } from '../../api/client';
vi.mock('../../api/client', () => ({
api: {
importSpoolsCsvPreview: vi.fn(),
importSpoolsCsv: vi.fn(),
getSettings: vi.fn().mockResolvedValue({}),
getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
},
}));
const preview: CsvImportPreview = {
columns: ['material', 'brand', 'color_name', 'rgba'],
total: 3,
valid_count: 2,
error_count: 1,
skipped_count: 0,
warnings: [],
rows: [
{ row_number: 1, status: 'valid', reason: null, material: 'PLA', brand: 'Polymaker', color_name: 'White', rgba: 'ffffffff', resolved_color: false, cross_material_color: false, duplicate_of_existing: false },
{ row_number: 2, status: 'error', reason: 'material is required', material: null, brand: 'Polymaker', color_name: 'X', rgba: null, resolved_color: false, cross_material_color: false, duplicate_of_existing: false },
{ row_number: 3, status: 'valid', reason: null, material: 'PETG', brand: 'Brand', color_name: 'Jade', rgba: 'e8e8e8ff', resolved_color: true, cross_material_color: true, duplicate_of_existing: false },
],
};
function selectFile() {
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['material\nPLA\n'], 'inventory.csv', { type: 'text/csv' });
fireEvent.change(input, { target: { files: [file] } });
return file;
}
describe('SpoolCsvImportModal', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('shows the preview table with per-row status after a file is chosen', async () => {
vi.mocked(api.importSpoolsCsvPreview).mockResolvedValue(preview);
render(<SpoolCsvImportModal onClose={vi.fn()} onImported={vi.fn()} />);
selectFile();
await waitFor(() => expect(api.importSpoolsCsvPreview).toHaveBeenCalledOnce());
// Summary counts surfaced.
expect(await screen.findByText('2 valid')).toBeInTheDocument();
expect(screen.getByText('1 error')).toBeInTheDocument();
// Error reason rendered inline.
expect(screen.getByText('material is required')).toBeInTheDocument();
// Import button reflects the valid count.
expect(screen.getByText('Import 2 valid rows')).toBeInTheDocument();
});
it('imports only when there are valid rows and reports the created count', async () => {
vi.mocked(api.importSpoolsCsvPreview).mockResolvedValue(preview);
vi.mocked(api.importSpoolsCsv).mockResolvedValue({ created: 2, skipped: 0, errors: 1, error_rows: [] });
const onImported = vi.fn();
render(<SpoolCsvImportModal onClose={vi.fn()} onImported={onImported} />);
selectFile();
const importBtn = await screen.findByText('Import 2 valid rows');
fireEvent.click(importBtn);
await waitFor(() => expect(api.importSpoolsCsv).toHaveBeenCalledOnce());
expect(onImported).toHaveBeenCalledWith(2);
});
it('disables import when no rows are valid', async () => {
vi.mocked(api.importSpoolsCsvPreview).mockResolvedValue({
...preview,
valid_count: 0,
error_count: 1,
rows: [preview.rows[1]],
});
render(<SpoolCsvImportModal onClose={vi.fn()} onImported={vi.fn()} />);
selectFile();
const noValid = await screen.findByText('No valid rows');
expect(noValid.closest('button')).toBeDisabled();
});
});
+88
View File
@@ -178,6 +178,30 @@ async function request<T>(
return await response.json();
}
/** Upload a CSV to the spool import endpoint (#1576). Multipart, so it bypasses
* `request<T>()` (which sends JSON): the browser must set the form-data
* boundary itself. `dryRun` toggles preview-only vs. real import. */
async function uploadSpoolsCsv<T>(file: File, dryRun: boolean): Promise<T> {
const form = new FormData();
form.append('file', file);
const headers: Record<string, string> = {};
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
const response = await fetch(`${API_BASE}/inventory/spools/import${dryRun ? '?dry_run=true' : ''}`, {
method: 'POST',
headers,
body: form,
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
// detail may be a plain string or a structured {code, message} object
// (e.g. the 413 too-large response). Surface the human message either way.
const detail = error?.detail;
const message = typeof detail === 'string' ? detail : detail?.message;
throw new Error(message || `HTTP ${response.status}`);
}
return response.json();
}
// Camera diagnostic result (#1395 follow-up). Returned by
// POST /printers/{id}/camera/diagnose; the frontend modal renders one
// row per stage and looks up the summary code in i18n for the user-
@@ -2626,6 +2650,45 @@ export interface SpoolmanBulkCreateResult {
failed_count: number;
}
// ── CSV import/export (#1576) ──────────────────────────────────────────────
/** One row's outcome from the import preview / real import. */
export interface CsvImportRow {
row_number: number;
status: 'valid' | 'error' | 'skipped';
reason: string | null;
material: string | null;
brand: string | null;
color_name: string | null;
rgba: string | null;
/** rgba/extra_colors/effect_type were filled from the Color Catalog. */
resolved_color: boolean;
/** The catalog match came from a different material's variant (no exact
* material match). Shown as a warning in the preview. */
cross_material_color: boolean;
/** An active spool with the same material+brand+color already exists.
* Informational only — the import still creates the row. */
duplicate_of_existing: boolean;
}
/** Dry-run preview: per-row classification, no rows written. */
export interface CsvImportPreview {
columns: string[];
total: number;
valid_count: number;
error_count: number;
skipped_count: number;
rows: CsvImportRow[];
warnings: string[];
}
/** Summary returned after a real (non-dry-run) import. */
export interface CsvImportResult {
created: number;
skipped: number;
errors: number;
error_rows: CsvImportRow[];
}
export interface SpoolUsageRecord {
id: number;
spool_id: number;
@@ -4878,6 +4941,31 @@ export const api = {
method: 'POST',
body: JSON.stringify({ spool: data, quantity }),
}),
// ── CSV import/export (#1576) ────────────────────────────────────────────
// dry_run=true → preview (no write); omitted → real import. Both share one
// multipart upload helper; see `uploadSpoolsCsv` below.
importSpoolsCsvPreview: (file: File): Promise<CsvImportPreview> => uploadSpoolsCsv<CsvImportPreview>(file, true),
importSpoolsCsv: (file: File): Promise<CsvImportResult> => uploadSpoolsCsv<CsvImportResult>(file, false),
exportSpoolsCsv: async (): Promise<void> => {
const headers: Record<string, string> = {};
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
const response = await fetch(`${API_BASE}/inventory/spools/export`, { headers });
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.detail || `HTTP ${response.status}`);
}
const disposition = response.headers.get('Content-Disposition');
const filename = parseContentDispositionFilename(disposition) || 'bambuddy_inventory.csv';
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
},
updateSpool: (id: number, data: Partial<Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>>) =>
request<InventorySpool>(`/inventory/spools/${id}`, {
method: 'PATCH',
@@ -0,0 +1,242 @@
import { useState, useRef, type DragEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { Upload, X, FileText, Loader2, CheckCircle, XCircle, MinusCircle, Wand2, AlertTriangle, Copy } from 'lucide-react';
import { api, type CsvImportPreview, type CsvImportRow } from '../api/client';
import { getSwatchStyle } from '../utils/colors';
import { Button } from './Button';
interface SpoolCsvImportModalProps {
onClose: () => void;
/** Called after a successful import so the page can refetch the inventory. */
onImported: (created: number) => void;
}
/**
* CSV import flow (#1576): pick a file → backend dry-run preview (per-row
* valid/error/skipped, colours resolved) → user reviews → confirm imports only
* the valid rows. Nothing is written until confirm.
*/
export function SpoolCsvImportModal({ onClose, onImported }: SpoolCsvImportModalProps) {
const { t } = useTranslation();
const [file, setFile] = useState<File | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [preview, setPreview] = useState<CsvImportPreview | null>(null);
const [loading, setLoading] = useState(false);
const [importing, setImporting] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const loadPreview = async (selected: File) => {
setFile(selected);
setPreview(null);
setError(null);
setLoading(true);
try {
const result = await api.importSpoolsCsvPreview(selected);
setPreview(result);
} catch (err) {
setError(err instanceof Error ? err.message : t('inventory.csv.previewError', 'Could not read the CSV file'));
} finally {
setLoading(false);
}
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const selected = e.target.files?.[0];
if (selected) loadPreview(selected);
};
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(false);
const dropped = e.dataTransfer.files?.[0];
if (dropped) loadPreview(dropped);
};
const handleImport = async () => {
if (!file) return;
setImporting(true);
setError(null);
try {
const result = await api.importSpoolsCsv(file);
onImported(result.created);
} catch (err) {
setError(err instanceof Error ? err.message : t('inventory.csv.importError', 'Import failed'));
setImporting(false);
}
};
const statusIcon = (status: CsvImportRow['status']) => {
if (status === 'valid') return <CheckCircle className="w-4 h-4 text-green-500 flex-shrink-0" />;
if (status === 'error') return <XCircle className="w-4 h-4 text-red-500 flex-shrink-0" />;
return <MinusCircle className="w-4 h-4 text-bambu-gray flex-shrink-0" />;
};
const validCount = preview?.valid_count ?? 0;
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-bambu-dark-secondary rounded-lg w-full max-w-3xl border border-bambu-dark-tertiary flex flex-col max-h-[90vh]">
<div className="p-4 border-b border-bambu-dark-tertiary flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">{t('inventory.csv.modalTitle', 'Import spools from CSV')}</h2>
<button onClick={onClose} className="p-1 hover:bg-bambu-dark rounded">
<X className="w-5 h-5 text-bambu-gray" />
</button>
</div>
<div className="p-4 space-y-4 overflow-y-auto flex-1">
{/* Drop zone / file picker */}
<div
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={(e) => {
e.preventDefault();
setIsDragging(false);
}}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
isDragging
? 'border-bambu-green bg-bambu-green/10'
: 'border-bambu-dark-tertiary hover:border-bambu-green/50'
}`}
>
<Upload className={`w-9 h-9 mx-auto mb-2 ${isDragging ? 'text-bambu-green' : 'text-bambu-gray'}`} />
{file ? (
<p className="text-white font-medium flex items-center justify-center gap-2">
<FileText className="w-4 h-4" /> {file.name}
</p>
) : (
<>
<p className="text-white font-medium">{t('inventory.csv.selectFile', 'Choose a CSV file or drag it here')}</p>
<p className="text-xs text-bambu-gray/70 mt-1">{t('inventory.csv.dragHint', 'Header: material (required), brand, subtype, color_name, rgba, …')}</p>
</>
)}
</div>
<input ref={fileInputRef} type="file" accept=".csv,text/csv" className="hidden" onChange={handleFileSelect} />
{loading && (
<div className="flex items-center justify-center gap-2 text-bambu-gray py-4">
<Loader2 className="w-4 h-4 animate-spin" />
{t('inventory.csv.parsing', 'Reading file…')}
</div>
)}
{error && (
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg flex items-start gap-3">
<XCircle className="w-5 h-5 text-red-400 mt-0.5 flex-shrink-0" />
<p className="text-sm text-red-300 break-words">{error}</p>
</div>
)}
{preview && (
<>
{/* Summary */}
<div className="flex flex-wrap gap-3 text-sm">
<span className="px-2 py-1 rounded bg-green-500/10 text-green-400">
{t('inventory.csv.validCount', '{{count}} valid', { count: preview.valid_count })}
</span>
<span className="px-2 py-1 rounded bg-red-500/10 text-red-400">
{t('inventory.csv.errorCount', '{{count}} error', { count: preview.error_count })}
</span>
<span className="px-2 py-1 rounded bg-bambu-dark text-bambu-gray">
{t('inventory.csv.skippedCount', '{{count}} skipped', { count: preview.skipped_count })}
</span>
</div>
{preview.warnings.length > 0 && (
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg space-y-1">
{preview.warnings.map((w, i) => (
<p key={i} className="text-xs text-yellow-300">{w}</p>
))}
</div>
)}
{/* Preview table */}
{preview.rows.length > 0 && (
<div className="border border-bambu-dark-tertiary rounded-lg overflow-hidden">
<div className="max-h-72 overflow-y-auto">
<table className="w-full text-sm">
<thead className="bg-bambu-dark sticky top-0">
<tr className="text-left text-bambu-gray">
<th className="px-3 py-2 font-medium">{t('inventory.csv.colRow', 'Row')}</th>
<th className="px-3 py-2 font-medium">{t('inventory.csv.colStatus', 'Status')}</th>
<th className="px-3 py-2 font-medium">{t('inventory.material', 'Material')}</th>
<th className="px-3 py-2 font-medium">{t('inventory.brand', 'Brand')}</th>
<th className="px-3 py-2 font-medium">{t('inventory.csv.colColor', 'Color')}</th>
</tr>
</thead>
<tbody>
{preview.rows.map((row) => (
<tr key={row.row_number} className="border-t border-bambu-dark-tertiary">
<td className="px-3 py-2 text-bambu-gray">{row.row_number}</td>
<td className="px-3 py-2">
<div className="flex items-center gap-1.5">
{statusIcon(row.status)}
{row.status === 'error' && row.reason && (
<span className="text-xs text-red-400 break-words">{row.reason}</span>
)}
</div>
</td>
<td className="px-3 py-2 text-white">{row.material || '—'}</td>
<td className="px-3 py-2 text-white">{row.brand || '—'}</td>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
{row.rgba && (
<span
className="inline-block w-4 h-4 rounded-full border border-bambu-dark-tertiary flex-shrink-0"
style={getSwatchStyle(row.rgba)}
/>
)}
<span className="text-white">{row.color_name || '—'}</span>
{row.resolved_color && !row.cross_material_color && (
<span title={t('inventory.csv.colorResolved', 'Color filled from catalog')}>
<Wand2 className="w-3.5 h-3.5 text-bambu-green flex-shrink-0" />
</span>
)}
{row.cross_material_color && (
<span title={t('inventory.csv.colorCrossMaterial', 'Color taken from a different material — no exact match in catalog')}>
<AlertTriangle className="w-3.5 h-3.5 text-yellow-500 flex-shrink-0" />
</span>
)}
{row.duplicate_of_existing && (
<span title={t('inventory.csv.duplicateExisting', 'A spool with this material, brand and color already exists — it will still be imported as a new spool')}>
<Copy className="w-3.5 h-3.5 text-amber-400 flex-shrink-0" />
</span>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</>
)}
</div>
<div className="p-4 border-t border-bambu-dark-tertiary flex justify-end gap-2">
<Button variant="secondary" onClick={onClose} disabled={importing}>
{t('common.cancel')}
</Button>
<Button onClick={handleImport} disabled={!preview || validCount === 0 || importing}>
{importing ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{t('inventory.csv.importing', 'Importing…')}
</>
) : validCount > 0 ? (
t('inventory.csv.importValidRows', 'Import {{count}} valid rows', { count: validCount })
) : (
t('inventory.csv.noValidRows', 'No valid rows')
)}
</Button>
</div>
</div>
</div>
);
}
+25
View File
@@ -3679,6 +3679,31 @@ export default {
},
},
},
csv: {
importButton: 'CSV importieren',
exportButton: 'CSV exportieren',
modalTitle: 'Spulen aus CSV importieren',
selectFile: 'CSV-Datei auswählen oder hierher ziehen',
dragHint: 'Kopfzeile: material (erforderlich), brand, subtype, color_name, rgba, …',
parsing: 'Datei wird gelesen…',
previewError: 'CSV-Datei konnte nicht gelesen werden',
validCount: '{{count}} gültig',
errorCount: '{{count}} Fehler',
skippedCount: '{{count}} übersprungen',
colRow: 'Zeile',
colStatus: 'Status',
colColor: 'Farbe',
colorResolved: 'Farbe aus Katalog übernommen',
colorCrossMaterial: 'Farbe von einem anderen Material übernommen — keine exakte Übereinstimmung im Katalog',
duplicateExisting: 'Eine Spule mit diesem Material, dieser Marke und Farbe existiert bereits — sie wird trotzdem als neue Spule importiert',
spoolmanHint: 'Im Spoolman-Modus den integrierten CSV-Import/-Export von Spoolman verwenden.',
importValidRows: '{{count}} gültige Zeilen importieren',
noValidRows: 'Keine gültigen Zeilen',
importing: 'Wird importiert…',
importSuccess: '{{count}} Spulen importiert',
importError: 'Import fehlgeschlagen',
exportError: 'Export fehlgeschlagen',
},
addSpool: 'Spule hinzufügen',
copySpool: 'Spule kopieren',
editSpool: 'Spule bearbeiten',
+25
View File
@@ -3682,6 +3682,31 @@ export default {
},
},
},
csv: {
importButton: 'Import CSV',
exportButton: 'Export CSV',
modalTitle: 'Import spools from CSV',
selectFile: 'Choose a CSV file or drag it here',
dragHint: 'Header: material (required), brand, subtype, color_name, rgba, …',
parsing: 'Reading file…',
previewError: 'Could not read the CSV file',
validCount: '{{count}} valid',
errorCount: '{{count}} error',
skippedCount: '{{count}} skipped',
colRow: 'Row',
colStatus: 'Status',
colColor: 'Color',
colorResolved: 'Color filled from catalog',
colorCrossMaterial: 'Color taken from a different material — no exact match in catalog',
duplicateExisting: 'A spool with this material, brand and color already exists — it will still be imported as a new spool',
spoolmanHint: 'In Spoolman mode, use Spoolman\'s built-in CSV import/export.',
importValidRows: 'Import {{count}} valid rows',
noValidRows: 'No valid rows',
importing: 'Importing…',
importSuccess: '{{count}} spools imported',
importError: 'Import failed',
exportError: 'Export failed',
},
addSpool: 'Add Spool',
editSpool: 'Edit Spool',
copySpool: 'Copy Spool',
+25
View File
@@ -3682,6 +3682,31 @@ export default {
},
},
},
csv: {
importButton: 'Importar CSV',
exportButton: 'Exportar CSV',
modalTitle: 'Importar bobinas desde CSV',
selectFile: 'Elige un archivo CSV o arrástralo aquí',
dragHint: 'Encabezado: material (obligatorio), brand, subtype, color_name, rgba, …',
parsing: 'Leyendo archivo…',
previewError: 'No se pudo leer el archivo CSV',
validCount: '{{count}} válidas',
errorCount: '{{count}} con error',
skippedCount: '{{count}} omitidas',
colRow: 'Fila',
colStatus: 'Estado',
colColor: 'Color',
colorResolved: 'Color rellenado desde el catálogo',
colorCrossMaterial: 'Color tomado de un material diferente — sin coincidencia exacta en el catálogo',
duplicateExisting: 'Ya existe una bobina con este material, marca y color — se importará igualmente como una bobina nueva',
spoolmanHint: 'En modo Spoolman, usa la importación/exportación CSV integrada de Spoolman.',
importValidRows: 'Importar {{count}} filas válidas',
noValidRows: 'Sin filas válidas',
importing: 'Importando…',
importSuccess: '{{count}} bobinas importadas',
importError: 'Error al importar',
exportError: 'Error al exportar',
},
addSpool: 'Añadir bobina',
editSpool: 'Editar bobina',
copySpool: 'Copiar bobina',
+25
View File
@@ -3668,6 +3668,31 @@ export default {
},
},
},
csv: {
importButton: 'Importer CSV',
exportButton: 'Exporter CSV',
modalTitle: 'Importer des bobines depuis un CSV',
selectFile: 'Choisissez un fichier CSV ou glissez-le ici',
dragHint: 'En-tête : material (requis), brand, subtype, color_name, rgba, …',
parsing: 'Lecture du fichier…',
previewError: 'Impossible de lire le fichier CSV',
validCount: '{{count}} valides',
errorCount: '{{count}} en erreur',
skippedCount: '{{count}} ignorées',
colRow: 'Ligne',
colStatus: 'Statut',
colColor: 'Couleur',
colorResolved: 'Couleur remplie depuis le catalogue',
colorCrossMaterial: 'Couleur reprise d\'un autre matériau — aucune correspondance exacte dans le catalogue',
duplicateExisting: 'Une bobine avec ce matériau, cette marque et cette couleur existe déjà — elle sera tout de même importée comme une nouvelle bobine',
spoolmanHint: 'En mode Spoolman, utilisez l\'import/export CSV intégré de Spoolman.',
importValidRows: 'Importer {{count}} lignes valides',
noValidRows: 'Aucune ligne valide',
importing: 'Importation…',
importSuccess: '{{count}} bobines importées',
importError: 'Échec de l\'importation',
exportError: 'Échec de l\'exportation',
},
addSpool: 'Ajouter Bobine',
editSpool: 'Modifier Bobine',
copySpool: 'Copier Bobine',
+25
View File
@@ -3667,6 +3667,31 @@ export default {
},
},
},
csv: {
importButton: 'Importa CSV',
exportButton: 'Esporta CSV',
modalTitle: 'Importa bobine da CSV',
selectFile: 'Scegli un file CSV o trascinalo qui',
dragHint: 'Intestazione: material (obbligatorio), brand, subtype, color_name, rgba, …',
parsing: 'Lettura del file…',
previewError: 'Impossibile leggere il file CSV',
validCount: '{{count}} valide',
errorCount: '{{count}} con errore',
skippedCount: '{{count}} ignorate',
colRow: 'Riga',
colStatus: 'Stato',
colColor: 'Colore',
colorResolved: 'Colore compilato dal catalogo',
colorCrossMaterial: 'Colore preso da un materiale diverso — nessuna corrispondenza esatta nel catalogo',
duplicateExisting: 'Esiste già una bobina con questo materiale, marca e colore — verrà comunque importata come nuova bobina',
spoolmanHint: 'In modalità Spoolman, usa l\'importazione/esportazione CSV integrata di Spoolman.',
importValidRows: 'Importa {{count}} righe valide',
noValidRows: 'Nessuna riga valida',
importing: 'Importazione…',
importSuccess: '{{count}} bobine importate',
importError: 'Importazione non riuscita',
exportError: 'Esportazione non riuscita',
},
addSpool: 'Aggiungi Bobina',
editSpool: 'Modifica Bobina',
copySpool: 'Copia Bobina',
+25
View File
@@ -3679,6 +3679,31 @@ export default {
},
},
},
csv: {
importButton: 'CSVインポート',
exportButton: 'CSVエクスポート',
modalTitle: 'CSVからスプールをインポート',
selectFile: 'CSVファイルを選択するか、ここにドラッグ',
dragHint: 'ヘッダー: material(必須)、brand、subtype、color_name、rgba、…',
parsing: 'ファイルを読み込み中…',
previewError: 'CSVファイルを読み込めませんでした',
validCount: '有効{{count}}件',
errorCount: 'エラー{{count}}件',
skippedCount: 'スキップ{{count}}件',
colRow: '行',
colStatus: 'ステータス',
colColor: '色',
colorResolved: 'カタログから色を補完',
colorCrossMaterial: '別の素材から色を取得しました — カタログに完全一致なし',
duplicateExisting: 'この素材・ブランド・色のスプールは既に存在します — それでも新しいスプールとしてインポートされます',
spoolmanHint: 'Spoolmanモードでは、Spoolman内蔵のCSVインポート/エクスポートを使用してください。',
importValidRows: '有効な{{count}}行をインポート',
noValidRows: '有効な行がありません',
importing: 'インポート中…',
importSuccess: '{{count}}個のスプールをインポートしました',
importError: 'インポートに失敗しました',
exportError: 'エクスポートに失敗しました',
},
addSpool: 'スプールを追加',
editSpool: 'スプールを編集',
copySpool: 'スプールをコピー',
+25
View File
@@ -3470,6 +3470,31 @@ export default {
color: '색상 순'
}
},
csv: {
importButton: 'CSV 가져오기',
exportButton: 'CSV 내보내기',
modalTitle: 'CSV에서 스풀 가져오기',
selectFile: 'CSV 파일을 선택하거나 여기로 끌어다 놓으세요',
dragHint: '헤더: material(필수), brand, subtype, color_name, rgba, …',
parsing: '파일을 읽는 중…',
previewError: 'CSV 파일을 읽을 수 없습니다',
validCount: '유효 {{count}}개',
errorCount: '오류 {{count}}개',
skippedCount: '건너뜀 {{count}}개',
colRow: '행',
colStatus: '상태',
colColor: '색상',
colorResolved: '카탈로그에서 색상 채움',
colorCrossMaterial: '다른 재료에서 색상을 가져옴 — 카탈로그에 정확히 일치하는 항목 없음',
duplicateExisting: '이 재료, 브랜드, 색상의 스풀이 이미 존재합니다 — 그래도 새 스풀로 가져옵니다',
spoolmanHint: 'Spoolman 모드에서는 Spoolman의 기본 CSV 가져오기/내보내기를 사용하세요.',
importValidRows: '유효한 {{count}}개 행 가져오기',
noValidRows: '유효한 행 없음',
importing: '가져오는 중…',
importSuccess: '{{count}}개 스풀을 가져왔습니다',
importError: '가져오기 실패',
exportError: '내보내기 실패',
},
addSpool: '스풀 추가',
editSpool: '스풀 편집',
copySpool: '스풀 복사',
+25
View File
@@ -3667,6 +3667,31 @@ export default {
},
},
},
csv: {
importButton: 'Importar CSV',
exportButton: 'Exportar CSV',
modalTitle: 'Importar carretéis de CSV',
selectFile: 'Escolha um arquivo CSV ou arraste-o aqui',
dragHint: 'Cabeçalho: material (obrigatório), brand, subtype, color_name, rgba, …',
parsing: 'Lendo arquivo…',
previewError: 'Não foi possível ler o arquivo CSV',
validCount: '{{count}} válidos',
errorCount: '{{count}} com erro',
skippedCount: '{{count}} ignorados',
colRow: 'Linha',
colStatus: 'Status',
colColor: 'Cor',
colorResolved: 'Cor preenchida pelo catálogo',
colorCrossMaterial: 'Cor obtida de um material diferente — sem correspondência exata no catálogo',
duplicateExisting: 'Já existe uma bobina com este material, marca e cor — ela será importada mesmo assim como uma nova bobina',
spoolmanHint: 'No modo Spoolman, use a importação/exportação CSV integrada do Spoolman.',
importValidRows: 'Importar {{count}} linhas válidas',
noValidRows: 'Nenhuma linha válida',
importing: 'Importando…',
importSuccess: '{{count}} carretéis importados',
importError: 'Falha na importação',
exportError: 'Falha na exportação',
},
addSpool: 'Adicionar Carretel',
editSpool: 'Editar Carretel',
copySpool: 'Copiar Carretel',
+25
View File
@@ -3668,6 +3668,31 @@ export default {
},
},
},
csv: {
importButton: 'CSV İçe Aktar',
exportButton: 'CSV Dışa Aktar',
modalTitle: 'CSV\'den makara içe aktar',
selectFile: 'Bir CSV dosyası seçin veya buraya sürükleyin',
dragHint: 'Başlık: material (zorunlu), brand, subtype, color_name, rgba, …',
parsing: 'Dosya okunuyor…',
previewError: 'CSV dosyası okunamadı',
validCount: '{{count}} geçerli',
errorCount: '{{count}} hatalı',
skippedCount: '{{count}} atlandı',
colRow: 'Satır',
colStatus: 'Durum',
colColor: 'Renk',
colorResolved: 'Renk katalogdan dolduruldu',
colorCrossMaterial: 'Renk farklı bir malzemeden alındı — katalogda tam eşleşme yok',
duplicateExisting: 'Bu malzeme, marka ve renkte bir makara zaten var — yine de yeni makara olarak içe aktarılacak',
spoolmanHint: 'Spoolman modunda Spoolman\'ın yerleşik CSV içe/dışa aktarımını kullanın.',
importValidRows: '{{count}} geçerli satırı içe aktar',
noValidRows: 'Geçerli satır yok',
importing: 'İçe aktarılıyor…',
importSuccess: '{{count}} makara içe aktarıldı',
importError: 'İçe aktarma başarısız',
exportError: 'Dışa aktarma başarısız',
},
addSpool: 'Makara Ekle',
editSpool: 'Makarayı Düzenle',
copySpool: 'Makarayı Kopyala',
+25
View File
@@ -3667,6 +3667,31 @@ export default {
},
},
},
csv: {
importButton: '导入 CSV',
exportButton: '导出 CSV',
modalTitle: '从 CSV 导入耗材',
selectFile: '选择 CSV 文件或拖放到此处',
dragHint: '表头:material(必填)、brand、subtype、color_name、rgba、…',
parsing: '正在读取文件…',
previewError: '无法读取 CSV 文件',
validCount: '{{count}} 个有效',
errorCount: '{{count}} 个错误',
skippedCount: '{{count}} 个跳过',
colRow: '行',
colStatus: '状态',
colColor: '颜色',
colorResolved: '已从目录填充颜色',
colorCrossMaterial: '颜色取自其他材料 — 目录中无精确匹配',
duplicateExisting: '已存在具有此材料、品牌和颜色的料卷 — 仍会作为新料卷导入',
spoolmanHint: '在 Spoolman 模式下,请使用 Spoolman 内置的 CSV 导入/导出。',
importValidRows: '导入 {{count}} 个有效行',
noValidRows: '没有有效行',
importing: '正在导入…',
importSuccess: '已导入 {{count}} 个耗材',
importError: '导入失败',
exportError: '导出失败',
},
addSpool: '添加耗材',
editSpool: '编辑耗材',
copySpool: '复制耗材',
+25
View File
@@ -3667,6 +3667,31 @@ export default {
},
},
},
csv: {
importButton: '匯入 CSV',
exportButton: '匯出 CSV',
modalTitle: '從 CSV 匯入耗材',
selectFile: '選擇 CSV 檔案或拖放到此處',
dragHint: '標頭:material(必填)、brand、subtype、color_name、rgba、…',
parsing: '正在讀取檔案…',
previewError: '無法讀取 CSV 檔案',
validCount: '{{count}} 個有效',
errorCount: '{{count}} 個錯誤',
skippedCount: '{{count}} 個略過',
colRow: '列',
colStatus: '狀態',
colColor: '顏色',
colorResolved: '已從目錄填入顏色',
colorCrossMaterial: '顏色取自其他材料 — 目錄中無精確匹配',
duplicateExisting: '已存在具有此材料、品牌和顏色的線材卷 — 仍會作為新線材卷匯入',
spoolmanHint: '在 Spoolman 模式下,請使用 Spoolman 內建的 CSV 匯入/匯出。',
importValidRows: '匯入 {{count}} 個有效列',
noValidRows: '沒有有效列',
importing: '正在匯入…',
importSuccess: '已匯入 {{count}} 個耗材',
importError: '匯入失敗',
exportError: '匯出失敗',
},
addSpool: '新增耗材',
editSpool: '編輯耗材',
copySpool: '複製耗材',
+58
View File
@@ -7,6 +7,7 @@ import {
Search, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
TrendingDown, Layers, Printer, AlertTriangle, X, Clock, LayoutGrid, TableProperties, Columns,
ArrowUp, ArrowDown, ArrowUpDown, Group, ChevronDown, Check, RefreshCw, TrendingUp, Lock, Copy, Eraser,
Upload, Download,
} from 'lucide-react';
import { ForecastPanel } from '../components/ForecastPanel';
import { api, spoolbuddyApi, ApiError } from '../api/client';
@@ -18,6 +19,7 @@ import {SpoolFormModal, type SpoolFormMode} from '../components/SpoolFormModal';
import { ConfirmModal } from '../components/ConfirmModal';
import { ColumnConfigModal, type ColumnConfig } from '../components/ColumnConfigModal';
import { LabelTemplatePickerModal } from '../components/LabelTemplatePickerModal';
import { SpoolCsvImportModal } from '../components/SpoolCsvImportModal';
import { useToast } from '../contexts/ToastContext';
import { useAuth } from '../contexts/AuthContext';
import { resolveSpoolColorName } from '../utils/colors';
@@ -473,6 +475,9 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
>(null);
// Label printing (#809). null = closed; otherwise the IDs to print labels for.
const [labelPickerSpoolIds, setLabelPickerSpoolIds] = useState<number[] | null>(null);
// CSV import/export (#1576). Local inventory only — hidden in Spoolman mode.
const [csvImportOpen, setCsvImportOpen] = useState(false);
const [exportingCsv, setExportingCsv] = useState(false);
// Filter state
const [archiveFilter, setArchiveFilter] = useState<ArchiveFilter>('active');
@@ -527,6 +532,26 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
refetchInterval: 30000,
});
// CSV export (#1576) — downloads the active inventory as a CSV file.
const handleExportCsv = useCallback(async () => {
setExportingCsv(true);
try {
await api.exportSpoolsCsv();
} catch (err) {
showToast(
err instanceof Error ? err.message : t('inventory.csv.exportError', 'Export failed'),
'error',
);
} finally {
setExportingCsv(false);
}
}, [showToast, t]);
// Shown as the tooltip on both CSV buttons when they're disabled in Spoolman mode.
const spoolmanCsvHint = spoolmanMode
? t('inventory.csv.spoolmanHint', 'In Spoolman mode, use Spoolman\'s built-in CSV import/export.')
: undefined;
// Deep-link: open edit modal for ?spool=<id>
// Prefer the already-loaded spool list (no extra API call); fall back to a
// targeted fetch for the rare case where the full list hasn't arrived yet.
@@ -1104,6 +1129,28 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
<p className="text-bambu-gray mt-1">{t('inventory.subtitle')}</p>
</div>
<div className="flex items-center gap-2">
{/* CSV import/export (#1576). Operates on Bambuddy's local inventory.
In Spoolman mode the buttons stay visible (feature parity) but are
disabled with a hint pointing at Spoolman's own CSV export, since
Spoolman owns the data store in that mode. */}
<Button
variant="secondary"
disabled={spoolmanMode}
onClick={() => setCsvImportOpen(true)}
title={spoolmanCsvHint}
>
<Upload className="w-4 h-4" />
{t('inventory.csv.importButton', 'Import CSV')}
</Button>
<Button
variant="secondary"
disabled={spoolmanMode || exportingCsv}
onClick={handleExportCsv}
title={spoolmanCsvHint}
>
{exportingCsv ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
{t('inventory.csv.exportButton', 'Export CSV')}
</Button>
<Button
variant="secondary"
disabled={filteredSpools.length === 0}
@@ -1931,6 +1978,17 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
initialSelectedIds={labelPickerSpoolIds ?? []}
spoolmanMode={spoolmanMode}
/>
{csvImportOpen && (
<SpoolCsvImportModal
onClose={() => setCsvImportOpen(false)}
onImported={(created) => {
setCsvImportOpen(false);
queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
showToast(t('inventory.csv.importSuccess', '{{count}} spools imported', { count: created }), 'success');
}}
/>
)}
</div>
);
}