From aeff998b9087d8f3a99e577d6805bc24f692d518 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Thu, 3 Sep 2026 17:17:25 +0200 Subject: [PATCH] feat(import): folder pick for underlag attach (#2189) (#2230) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A migration's underlag arrives as one folder, and Ctrl+A in the file picker is not obvious to everyone. The underlag wizard gets a second, outlined "Välj mapp" button next to "Välj filer", backed by a hidden webkitdirectory input. A directory pick ignores the accept list and returns every file in the tree, so the handler keeps only the document types the attach route takes (PDF, JPEG, PNG, WebP), drops dotfiles, and explains itself when nothing qualifies. The plan keys on file.name, so nested folders flatten harmlessly. Closes #2189 Claude-Session: https://claude.ai/code/session_01QPQLwHNEiQfiCNLSMzXMiQ Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 --- components/import/UnderlagImportWizard.tsx | 70 ++++++++++++++++++---- messages/en.json | 2 + messages/sv.json | 2 + 3 files changed, 61 insertions(+), 13 deletions(-) diff --git a/components/import/UnderlagImportWizard.tsx b/components/import/UnderlagImportWizard.tsx index 47c691b7..4a6e061d 100644 --- a/components/import/UnderlagImportWizard.tsx +++ b/components/import/UnderlagImportWizard.tsx @@ -19,7 +19,7 @@ import { FyPicker } from '@/components/common/FyPicker' import { mapWithConcurrency } from '@/lib/concurrency' import { getErrorMessage } from '@/lib/errors/get-error-message' import { cn, formatDate } from '@/lib/utils' -import { FileUp, Loader2 } from 'lucide-react' +import { FileUp, FolderUp, Loader2 } from 'lucide-react' import type { FiscalPeriod } from '@/types' import type { UnderlagPlan, @@ -49,6 +49,7 @@ import type { type Step = 'select' | 'review' | 'result' const ACCEPTED_TYPES = 'application/pdf,image/jpeg,image/png,image/webp' +const ACCEPTED_MIME = new Set(ACCEPTED_TYPES.split(',')) /** * Parallel attach requests during the final step. Same size as the @@ -105,6 +106,7 @@ export default function UnderlagImportWizard() { const { toast } = useToast() const { dialogProps, confirm } = useDestructiveConfirm() const fileInputRef = useRef(null) + const folderInputRef = useRef(null) const [step, setStep] = useState('select') const [isLoading, setIsLoading] = useState(false) @@ -162,7 +164,7 @@ export default function UnderlagImportWizard() { ) const handleFilesSelected = useCallback( - async (fileList: FileList | null) => { + async (fileList: FileList | File[] | null) => { if (!fileList || fileList.length === 0) return if (!fiscalPeriodId) return const files = Array.from(fileList) @@ -207,6 +209,27 @@ export default function UnderlagImportWizard() { [fetchPlan, fiscalPeriod, fiscalPeriodId], ) + // Folder pick (#2189): a migration's underlag arrives as one folder, and + // Ctrl+A in the file picker is not obvious to everyone. A directory pick + // ignores `accept` and includes every file in the tree (Thumbs.db, + // .DS_Store, the export tool's own CSV), so keep only the document types + // the attach route takes before planning; the plan keys on file.name, so + // subfolders flatten harmlessly. + const handleFolderSelected = useCallback( + (fileList: FileList | null) => { + if (!fileList || fileList.length === 0) return + const files = Array.from(fileList).filter( + (f) => ACCEPTED_MIME.has(f.type) && !f.name.startsWith('.'), + ) + if (files.length === 0) { + setError(t('underlag_folder_no_documents')) + return + } + void handleFilesSelected(files) + }, + [handleFilesSelected, t], + ) + const updateRow = useCallback((id: string, patch: Partial) => { setRows((prev) => prev.map((row) => (row.id === id ? { ...row, ...patch } : row))) }, []) @@ -433,18 +456,39 @@ export default function UnderlagImportWizard() { className="hidden" onChange={(e) => handleFilesSelected(e.target.files)} /> + {/* webkitdirectory is not in React's input typings but is what + every current browser honours for a folder pick; spread so + the attribute reaches the DOM without a type escape hatch. */} + handleFolderSelected(e.target.files)} + {...({ webkitdirectory: '' } as Record)} + /> - +
+ + +
{/* Both the picker and the button are disabled until a year is chosen, and if the company has no fiscal years at all the diff --git a/messages/en.json b/messages/en.json index b9b65539..48eda726 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7451,6 +7451,8 @@ "underlag_intro": "A SIE file carries the bookkeeping but not the receipts. If your old system named each receipt after its verifikat, they can be attached automatically, with no AI reading required.", "underlag_intro_formats": "Filenames that work: A31_abc123.pdf, A31.pdf, A-31 receipt.pdf, 2024-A-31.pdf. Matching uses the voucher number from your old system, not the number here.", "underlag_pick_files": "Pick files", + "underlag_pick_folder": "Pick a folder", + "underlag_folder_no_documents": "The folder holds no PDF or image files (PDF, JPEG, PNG, WebP).", "underlag_year_help": "The old system restarts voucher numbering every year, so A31 only identifies a verifikat within one year. Pick the year the export came from. Files are never attached to any other year.", "underlag_year_required": "Pick a fiscal year above to continue. If the year you want is missing, its bookkeeping was not imported via SIE.", "underlag_year_label": "Which fiscal year are these receipts from?", diff --git a/messages/sv.json b/messages/sv.json index 797e903d..ff980177 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -7451,6 +7451,8 @@ "underlag_intro": "En SIE-fil innehåller bokföringen men inte underlagen. Om ditt gamla system namngav varje underlag efter sitt verifikat kan de kopplas automatiskt, utan AI-tolkning.", "underlag_intro_formats": "Filnamn som fungerar: A31_abc123.pdf, A31.pdf, A-31 kvitto.pdf, 2024-A-31.pdf. Matchningen sker mot verifikatnumret i ditt gamla system, inte mot numret här.", "underlag_pick_files": "Välj filer", + "underlag_pick_folder": "Välj mapp", + "underlag_folder_no_documents": "Mappen innehåller inga PDF- eller bildfiler (PDF, JPEG, PNG, WebP).", "underlag_year_help": "Verifikatnumret i filnamnet räknas om varje år i det gamla systemet, så A31 pekar bara ut ett verifikat inom ett år. Välj det år exporten kommer från. Filerna kopplas aldrig till något annat år.", "underlag_year_required": "Välj ett räkenskapsår ovan för att fortsätta. Saknas det år du söker har bokföringen för det året inte importerats via SIE.", "underlag_year_label": "Vilket räkenskapsår gäller underlagen?",