mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'スプールをコピー',
|
||||
|
||||
@@ -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: '스풀 복사',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '复制耗材',
|
||||
|
||||
@@ -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: '複製耗材',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user