Simpl-Resultat/src/components/import/SourceConfigPanel.tsx
Le-King-Fu 9914737f26 fix: replace refresh icon with save icon on update template button
Closes #4

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 12:01:14 +00:00

372 lines
13 KiB
TypeScript

import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Wand2, Check, Save, X } from "lucide-react";
import type {
ScannedSource,
ScannedFile,
SourceConfig,
AmountMode,
ColumnMapping,
ImportConfigTemplate,
} from "../../shared/types";
import ColumnMappingEditor from "./ColumnMappingEditor";
interface SourceConfigPanelProps {
source: ScannedSource;
config: SourceConfig;
selectedFiles: ScannedFile[];
importedFileNames?: Set<string>;
headers: string[];
configTemplates: ImportConfigTemplate[];
onConfigChange: (config: SourceConfig) => void;
onFileToggle: (file: ScannedFile) => void;
onSelectAllFiles: () => void;
onAutoDetect: () => void;
onSaveAsTemplate: (name: string) => void;
onApplyTemplate: (id: number) => void;
onUpdateTemplate: () => void;
onDeleteTemplate: (id: number) => void;
selectedTemplateId: number | null;
isLoading?: boolean;
}
export default function SourceConfigPanel({
source,
config,
selectedFiles,
importedFileNames,
headers,
configTemplates,
onConfigChange,
onFileToggle,
onSelectAllFiles,
onAutoDetect,
onSaveAsTemplate,
onApplyTemplate,
onUpdateTemplate,
onDeleteTemplate,
selectedTemplateId,
isLoading,
}: SourceConfigPanelProps) {
const { t } = useTranslation();
const [showSaveTemplate, setShowSaveTemplate] = useState(false);
const [templateName, setTemplateName] = useState("");
const selectClass =
"w-full px-3 py-2 text-sm rounded-lg border border-[var(--border)] bg-[var(--card)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]";
const inputClass = selectClass;
const updateConfig = (partial: Partial<SourceConfig>) => {
onConfigChange({ ...config, ...partial });
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">
{t("import.config.title")} {source.folder_name}
</h2>
<button
onClick={onAutoDetect}
disabled={isLoading || selectedFiles.length === 0}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg bg-[var(--primary)] text-white hover:opacity-90 disabled:opacity-50 transition-opacity"
>
<Wand2 size={16} />
{t("import.config.autoDetect")}
</button>
</div>
{/* Template row */}
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2 flex-1 min-w-[200px]">
<label className="text-sm text-[var(--muted-foreground)] whitespace-nowrap">
{t("import.config.loadTemplate")}
</label>
<select
value={selectedTemplateId ?? ""}
onChange={(e) => {
if (e.target.value) onApplyTemplate(Number(e.target.value));
}}
className={selectClass + " flex-1"}
>
<option value="">
{configTemplates.length === 0
? t("import.config.noTemplates")
: `${t("import.config.loadTemplate")}`}
</option>
{configTemplates.map((tpl) => (
<option key={tpl.id} value={tpl.id}>
{tpl.name}
</option>
))}
</select>
{selectedTemplateId && (
<>
<button
onClick={onUpdateTemplate}
title={t("import.config.updateTemplate")}
className="p-1.5 rounded-lg text-[var(--primary)] hover:bg-[var(--muted)] transition-colors"
>
<Save size={16} />
</button>
<button
onClick={() => onDeleteTemplate(selectedTemplateId)}
title={t("import.config.deleteTemplate")}
className="p-1.5 rounded-lg text-[var(--muted-foreground)] hover:text-[var(--negative)] transition-colors"
>
<X size={16} />
</button>
</>
)}
</div>
{showSaveTemplate ? (
<div className="flex items-center gap-2">
<input
type="text"
value={templateName}
onChange={(e) => setTemplateName(e.target.value)}
placeholder={t("import.config.templateName")}
className={inputClass + " w-48"}
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter" && templateName.trim()) {
onSaveAsTemplate(templateName.trim());
setTemplateName("");
setShowSaveTemplate(false);
} else if (e.key === "Escape") {
setShowSaveTemplate(false);
setTemplateName("");
}
}}
/>
<button
onClick={() => {
if (templateName.trim()) {
onSaveAsTemplate(templateName.trim());
setTemplateName("");
setShowSaveTemplate(false);
}
}}
disabled={!templateName.trim()}
className="p-1.5 rounded-lg bg-[var(--positive)] text-white hover:opacity-90 disabled:opacity-50 transition-opacity"
>
<Check size={16} />
</button>
<button
onClick={() => {
setShowSaveTemplate(false);
setTemplateName("");
}}
className="p-1.5 rounded-lg text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
>
<X size={16} />
</button>
</div>
) : (
<button
onClick={() => setShowSaveTemplate(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
>
<Save size={16} />
{t("import.config.saveAsTemplate")}
</button>
)}
</div>
{/* Source name */}
<div>
<label className="block text-sm text-[var(--muted-foreground)] mb-1">
{t("import.config.sourceName")}
</label>
<input
type="text"
value={config.name}
onChange={(e) => updateConfig({ name: e.target.value })}
className={inputClass}
/>
</div>
{/* Basic settings */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm text-[var(--muted-foreground)] mb-1">
{t("import.config.delimiter")}
</label>
<select
value={config.delimiter}
onChange={(e) => updateConfig({ delimiter: e.target.value })}
className={selectClass}
>
<option value=";">{t("import.config.semicolon")} (;)</option>
<option value=",">{t("import.config.comma")} (,)</option>
<option value="\t">{t("import.config.tab")} ()</option>
<option value="|">Pipe (|)</option>
</select>
</div>
<div>
<label className="block text-sm text-[var(--muted-foreground)] mb-1">
{t("import.config.encoding")}
</label>
<select
value={config.encoding}
onChange={(e) => updateConfig({ encoding: e.target.value })}
className={selectClass}
>
<option value="utf-8">UTF-8</option>
<option value="windows-1252">Windows-1252</option>
<option value="iso-8859-1">ISO-8859-1</option>
<option value="iso-8859-15">ISO-8859-15</option>
</select>
</div>
<div>
<label className="block text-sm text-[var(--muted-foreground)] mb-1">
{t("import.config.dateFormat")}
</label>
<select
value={config.dateFormat}
onChange={(e) => updateConfig({ dateFormat: e.target.value })}
className={selectClass}
>
<option value="DD/MM/YYYY">DD/MM/YYYY</option>
<option value="MM/DD/YYYY">MM/DD/YYYY</option>
<option value="YYYY-MM-DD">YYYY-MM-DD</option>
<option value="YYYY/MM/DD">YYYY/MM/DD</option>
<option value="DD-MM-YYYY">DD-MM-YYYY</option>
<option value="DD.MM.YYYY">DD.MM.YYYY</option>
<option value="YYYYMMDD">YYYYMMDD</option>
</select>
</div>
</div>
{/* Skip lines & header */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm text-[var(--muted-foreground)] mb-1">
{t("import.config.skipLines")}
</label>
<input
type="number"
min={0}
value={config.skipLines}
onChange={(e) =>
updateConfig({ skipLines: parseInt(e.target.value) || 0 })
}
className={inputClass}
/>
</div>
<div className="flex items-end">
<label className="flex items-center gap-2 text-sm cursor-pointer pb-2">
<input
type="checkbox"
checked={config.hasHeader}
onChange={(e) => updateConfig({ hasHeader: e.target.checked })}
className="accent-[var(--primary)]"
/>
{t("import.config.hasHeader")}
</label>
</div>
</div>
{/* Sign convention */}
<div>
<label className="block text-sm text-[var(--muted-foreground)] mb-1">
{t("import.config.signConvention")}
</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="radio"
name="signConvention"
value="negative_expense"
checked={config.signConvention === "negative_expense"}
onChange={() =>
updateConfig({ signConvention: "negative_expense" })
}
className="accent-[var(--primary)]"
/>
{t("import.config.negativeExpense")}
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="radio"
name="signConvention"
value="positive_expense"
checked={config.signConvention === "positive_expense"}
onChange={() =>
updateConfig({ signConvention: "positive_expense" })
}
className="accent-[var(--primary)]"
/>
{t("import.config.positiveExpense")}
</label>
</div>
</div>
{/* Column mapping */}
{headers.length > 0 && (
<ColumnMappingEditor
headers={headers}
mapping={config.columnMapping}
amountMode={config.amountMode}
onMappingChange={(mapping: ColumnMapping) =>
onConfigChange({ ...config, columnMapping: mapping })
}
onAmountModeChange={(mode: AmountMode) =>
onConfigChange({ ...config, amountMode: mode })
}
/>
)}
{/* File selection */}
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold text-[var(--foreground)]">
{t("import.config.selectFiles")}
</h3>
<button
onClick={onSelectAllFiles}
className="text-xs text-[var(--primary)] hover:underline"
>
{t("import.config.selectAll")}
</button>
</div>
<div className="space-y-1 max-h-48 overflow-y-auto">
{source.files.map((file) => {
const isSelected = selectedFiles.some(
(f) => f.file_path === file.file_path
);
const isImported = importedFileNames?.has(file.filename) ?? false;
return (
<label
key={file.file_path}
className={`flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-[var(--muted)] cursor-pointer text-sm ${
isImported ? "opacity-60" : ""
}`}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => onFileToggle(file)}
className="accent-[var(--primary)]"
/>
<span className="flex-1">{file.filename}</span>
{isImported && (
<span className="flex items-center gap-1 text-xs text-[var(--positive)]">
<Check size={12} />
{t("import.config.alreadyImported")}
</span>
)}
<span className="text-xs text-[var(--muted-foreground)]">
{(file.size_bytes / 1024).toFixed(1)} KB
</span>
</label>
);
})}
</div>
</div>
</div>
);
}