Files
card-forge/src/lib/components/wizard/StepColumnMap.svelte
2025-08-06 14:34:52 +02:00

740 lines
23 KiB
Svelte

<script lang="ts">
import { selectedSheet, currentStep, columnMapping } from '$lib/stores';
import type { ColumnMappingType, SheetInfoType } from '$lib/stores';
import { getSheetNames, getSheetData } from '$lib/google';
import { onMount } from 'svelte';
import Navigator from './subcomponents/Navigator.svelte';
let isLoadingSheets = $state(false);
let isLoadingData = $state(false);
let availableSheets = $state<string[]>([]);
let selectedSheetName = $state('');
let error = $state('');
let sheetHeaders = $state<string[]>([]);
let previewData = $state<string[][]>([]);
let mappingComplete = $state(false);
let hasSavedMapping = $state(false);
let showMappingEditor = $state(false);
let savedSheetInfo = $state<SheetInfoType | null>(null);
let mappedIndices = $state<ColumnMappingType>({
name: -1,
surname: -1,
nationality: -1,
birthday: -1,
pictureUrl: -1,
alreadyPrinted: -1,
sheetName: ''
});
const requiredFields = [
{ key: 'name', label: 'First Name', required: true },
{ key: 'surname', label: 'Last Name', required: true },
{ key: 'nationality', label: 'Nationality', required: true },
{ key: 'birthday', label: 'Birthday', required: true },
{ key: 'pictureUrl', label: 'Photo URL', required: true },
{ key: 'alreadyPrinted', label: 'Already Printed', required: false }
];
// Load available sheets when component mounts
onMount(async () => {
if ($selectedSheet) {
console.log('Selected sheet on mount:', $selectedSheet);
// Check if we already have saved mapping data
const recentSheetsData = localStorage.getItem('recent-sheets');
if (recentSheetsData) {
try {
const recentSheets = JSON.parse(recentSheetsData);
if (recentSheets && recentSheets.length > 0) {
// Find a sheet that matches the current spreadsheet
const savedSheet = recentSheets.find(
(sheet: SheetInfoType) => sheet.id === $selectedSheet.id
);
if (savedSheet) {
console.log('Found saved sheet configuration:', savedSheet);
// We have a saved sheet for this spreadsheet
selectedSheetName = savedSheet.columnMapping.sheetName;
savedSheetInfo = savedSheet;
if (savedSheet.columnMapping) {
// Set the mapped indices from saved data
mappedIndices = {
name: savedSheet.columnMapping.name,
surname: savedSheet.columnMapping.surname,
nationality: savedSheet.columnMapping.nationality,
birthday: savedSheet.columnMapping.birthday,
pictureUrl: savedSheet.columnMapping.pictureUrl,
alreadyPrinted: savedSheet.columnMapping.alreadyPrinted,
sheetName: selectedSheetName
};
hasSavedMapping = true;
updateMappingStatus();
return;
}
}
}
} catch (err) {
console.error('Error parsing saved sheets data:', err);
}
}
// If no saved data was found or it couldn't be used, load sheets as usual
await loadAvailableSheets();
} else {
console.error('No spreadsheet selected on mount');
}
});
async function loadAvailableSheets() {
if (!$selectedSheet) {
console.error('Cannot load available sheets: no sheet selected');
return;
}
console.log('Loading available sheets for spreadsheet:', $selectedSheet.id);
isLoadingSheets = true;
error = '';
try {
const sheetNames = await getSheetNames($selectedSheet.id);
console.log('Loaded sheet names:', sheetNames);
availableSheets = sheetNames;
} catch (err) {
console.error('Error loading sheet names:', err);
error = 'Failed to load sheet names. Please try again.';
} finally {
isLoadingSheets = false;
}
}
function handleSheetSelect(sheetName: string) {
console.log('Sheet selected:', sheetName);
selectedSheetName = sheetName;
// Clear any previous data when selecting a new sheet
sheetHeaders = [];
previewData = [];
mappedIndices = {
name: -1,
surname: -1,
nationality: -1,
birthday: -1,
pictureUrl: -1,
alreadyPrinted: -1,
sheetName: sheetName
};
mappingComplete = false;
hasSavedMapping = false;
showMappingEditor = true;
// Load sheet data
if (sheetName) {
loadSheetData(sheetName);
}
}
async function loadSheetData(sheetName: string) {
if (!$selectedSheet) {
console.error('Cannot load sheet data: no sheet selected');
return;
}
console.log('Loading sheet data for spreadsheet:', $selectedSheet.id, 'sheet:', sheetName);
isLoadingData = true;
error = '';
try {
// Fetch first 10 rows for headers and preview
const range = `${sheetName}!A1:Z10`;
const data = await getSheetData($selectedSheet.id, range);
if (data && data.length > 0) {
console.log('Loaded sheet data with', data.length, 'rows');
sheetHeaders = data[0];
previewData = data.slice(1, Math.min(4, data.length)); // Get up to 3 rows for preview
// We don't need to set all the raw data here
// Try to auto-map columns
autoMapColumns();
// Check if we have saved column mapping for this sheet
loadSavedColumnMapping();
} else {
error = 'The selected sheet appears to be empty.';
console.warn('Sheet is empty');
}
} catch (err) {
console.error('Error loading sheet data:', err);
error = 'Failed to load sheet data. Please try again.';
} finally {
isLoadingData = false;
}
}
function autoMapColumns() {
// Reset mappings
mappedIndices = {
name: -1,
surname: -1,
nationality: -1,
birthday: -1,
pictureUrl: -1,
alreadyPrinted: -1,
sheetName: selectedSheetName
};
// Auto-mapping patterns
const patterns: Record<keyof Omit<ColumnMappingType, 'sheetName'>, RegExp> = {
name: /first[\s_-]*name|name|given[\s_-]*name|vorname/i,
surname: /last[\s_-]*name|surname|family[\s_-]*name|nachname/i,
nationality: /nationality|country|nation/i,
birthday: /birth|date[\s_-]*of[\s_-]*birth|birthday|dob/i,
pictureUrl: /photo|picture|image|url|avatar/i,
alreadyPrinted: /already[\s_-]*printed|printed|status/i
};
sheetHeaders.forEach((header, index) => {
for (const [field, pattern] of Object.entries(patterns)) {
const key = field as keyof ColumnMappingType;
if (pattern.test(header) && mappedIndices[key] === -1) {
mappedIndices[key] = index;
break;
}
}
});
// If "Already Printed" column wasn't found, try to find the first empty column
if (mappedIndices.alreadyPrinted === -1 && previewData.length > 0) {
// Check up to 26 columns (A-Z) or the number of headers, whichever is larger
const maxColumns = Math.max(sheetHeaders.length, 26);
for (let colIndex = 0; colIndex < maxColumns; colIndex++) {
// Check if this column is empty (all preview rows are empty for this column)
const isEmpty = previewData.every(
(row) => !row[colIndex] || String(row[colIndex]).trim() === ''
);
// Also check if this column isn't already mapped to another field
const isAlreadyMapped = Object.entries(mappedIndices).some(
([field, index]) =>
field !== 'alreadyPrinted' &&
index === colIndex &&
field !== 'sheetName' &&
index === colIndex
);
if (isEmpty && !isAlreadyMapped) {
mappedIndices.alreadyPrinted = colIndex;
break;
}
}
}
console.log('Auto-mapped columns:', mappedIndices);
updateMappingStatus();
}
function loadSavedColumnMapping() {
if (!$selectedSheet || !selectedSheetName) {
console.log('Cannot load saved column mapping: missing selectedSheet or selectedSheetName');
return;
}
try {
const recentSheetsKey = 'recent-sheets';
const existingData = localStorage.getItem(recentSheetsKey);
if (existingData) {
const recentSheets = JSON.parse(existingData);
const savedSheet = recentSheets.find(
(sheet: SheetInfoType) => sheet.id === $selectedSheet.id
);
if (savedSheet && savedSheet.columnMapping) {
console.log('Found saved column mapping for current sheet:', savedSheet.columnMapping);
// Override auto-mapping with saved mapping
mappedIndices = {
name: savedSheet.columnMapping.name ?? -1,
surname: savedSheet.columnMapping.surname ?? -1,
nationality: savedSheet.columnMapping.nationality ?? -1,
birthday: savedSheet.columnMapping.birthday ?? -1,
pictureUrl: savedSheet.columnMapping.pictureUrl ?? -1,
alreadyPrinted: savedSheet.columnMapping.alreadyPrinted ?? -1,
sheetName: selectedSheetName
};
hasSavedMapping = true;
savedSheetInfo = savedSheet;
updateMappingStatus();
} else {
console.log('No saved column mapping found for the current sheet');
}
}
} catch (err) {
console.error('Failed to load saved column mapping:', err);
}
}
function handleColumnMapping(field: keyof ColumnMappingType, index: number) {
if (!mappedIndices) {
mappedIndices = {
name: -1,
surname: -1,
nationality: -1,
birthday: -1,
pictureUrl: -1,
alreadyPrinted: -1,
sheetName: selectedSheetName
};
}
mappedIndices[field] = index;
updateMappingStatus();
}
function updateMappingStatus() {
if (!mappedIndices) {
mappingComplete = false;
return;
}
// Only check required fields for completion
const requiredIndices = {
name: mappedIndices.name,
surname: mappedIndices.surname,
nationality: mappedIndices.nationality,
birthday: mappedIndices.birthday,
pictureUrl: mappedIndices.pictureUrl,
sheetName: selectedSheetName
};
mappingComplete = Object.values(requiredIndices).every((index) => index !== -1);
console.log('Mapping complete:', mappingComplete);
// Update the column mapping store
columnMapping.set({
name: mappedIndices.name,
surname: mappedIndices.surname,
nationality: mappedIndices.nationality,
birthday: mappedIndices.birthday,
pictureUrl: mappedIndices.pictureUrl,
alreadyPrinted: mappedIndices.alreadyPrinted,
sheetName: selectedSheetName
});
}
function handleContinue() {
if (!mappingComplete || !$selectedSheet || !selectedSheetName) return;
// Save column mapping to localStorage for the selected sheet
try {
const recentSheetsKey = 'recent-sheets';
const existingData = localStorage.getItem(recentSheetsKey);
let recentSheets = existingData ? JSON.parse(existingData) : [];
// Find the current sheet in recent sheets and update its column mapping
const sheetIndex = recentSheets.findIndex(
(sheet: SheetInfoType) => sheet.id === $selectedSheet.id
);
const columnMappingData = {
name: mappedIndices.name,
surname: mappedIndices.surname,
nationality: mappedIndices.nationality,
birthday: mappedIndices.birthday,
pictureUrl: mappedIndices.pictureUrl,
alreadyPrinted: mappedIndices.alreadyPrinted,
sheetName: selectedSheetName
};
if (sheetIndex !== -1) {
// Update existing entry
recentSheets[sheetIndex].columnMapping = columnMappingData;
recentSheets[sheetIndex].lastUsed = new Date().toISOString();
// Ensure we have consistent property names
recentSheets[sheetIndex].id = recentSheets[sheetIndex].id || recentSheets[sheetIndex].id;
} else {
// Add new entry
const newEntry = {
id: $selectedSheet.id,
name: $selectedSheet.name,
columnMapping: columnMappingData,
lastUsed: new Date().toISOString()
};
recentSheets.unshift(newEntry);
// Keep only the 3 most recent
if (recentSheets.length > 3) {
recentSheets = recentSheets.slice(0, 3);
}
}
localStorage.setItem(recentSheetsKey, JSON.stringify(recentSheets));
} catch (err) {
console.error('Failed to save column mapping to localStorage:', err);
}
}
async function handleShowEditor() {
showMappingEditor = true;
// Load available sheets if they haven't been loaded yet
if (availableSheets.length === 0) {
await loadAvailableSheets();
}
// Ensure we have sheet data if a sheet is already selected
if (selectedSheetName && sheetHeaders.length === 0) {
// Load the sheet data but keep mappings intact
try {
isLoadingData = true;
const range = `${selectedSheetName}!A1:Z10`;
const data = await getSheetData($selectedSheet.id, range);
if (data && data.length > 0) {
sheetHeaders = data[0];
previewData = data.slice(1, Math.min(4, data.length));
}
} catch (err) {
console.error('Error loading sheet data for editor:', err);
} finally {
isLoadingData = false;
}
}
}
</script>
<div class="p-6">
<div class="mb-6">
<h2 class="mb-2 text-xl font-semibold text-gray-900">Select Sheet and Map Columns</h2>
<p class="mb-4 text-sm text-gray-700">
First, select which sheet contains your member data, then map the columns to the required
fields.
</p>
</div>
{#if hasSavedMapping && !showMappingEditor}
<!-- Simplified view when we have saved mapping -->
<div class="mb-6 rounded-lg border border-blue-200 bg-blue-50 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg
class="h-5 w-5 text-blue-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3 flex-1 md:flex md:justify-between">
<div>
<h3 class="text-sm font-medium text-blue-800">Saved Configuration Found</h3>
<div class="mt-2 text-sm text-blue-700">
<p>
Using saved mapping for sheet <span class="font-semibold"
>"{selectedSheetName}"</span
>
from spreadsheet <span class="font-semibold">"{savedSheetInfo?.name}"</span>.
</p>
</div>
</div>
<div class="mt-3 md:mt-0 md:ml-6">
<button
onclick={handleShowEditor}
class="rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium whitespace-nowrap text-white shadow-sm hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none"
>
Edit Mapping
</button>
</div>
</div>
</div>
</div>
{:else}
<!-- Sheet Selection -->
<div class="mb-6 rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-3 text-sm font-medium text-gray-700">Step 1: Select Sheet</h3>
{#if isLoadingSheets}
<div class="flex items-center">
<div
class="mr-3 h-5 w-5 animate-spin rounded-full border-2 border-blue-600 border-t-transparent"
></div>
<span class="text-sm text-gray-600">Loading sheets...</span>
</div>
{:else if error}
<div class="mb-3 rounded-lg border border-red-300 bg-red-50 p-3">
<p class="text-sm text-red-800">{error}</p>
<button
class="mt-2 text-sm text-blue-600 hover:text-blue-800"
onclick={loadAvailableSheets}
>
Try again
</button>
</div>
{:else if availableSheets.length === 0}
<div class="rounded border border-gray-200 bg-white py-6 text-center">
<svg
class="mx-auto h-12 w-12 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No sheets found</h3>
<p class="mt-1 text-sm text-gray-500">
This spreadsheet doesn't appear to contain any sheets.
</p>
</div>
{:else}
<div class="space-y-3">
<p class="text-sm text-gray-600">
Spreadsheet: <span class="font-medium">{$selectedSheet?.name}</span>
</p>
<div>
<p class="mb-3 block text-sm font-medium text-gray-700">Choose sheet:</p>
<div class="space-y-2">
{#each availableSheets as sheetName}
<div
role="button"
tabindex="0"
class="cursor-pointer rounded-lg border p-3 transition-colors hover:bg-gray-50
{selectedSheetName === sheetName
? 'border-blue-500 bg-blue-50'
: 'border-gray-200'}"
onclick={() => handleSheetSelect(sheetName)}
onkeydown={(e) => e.key === 'Enter' && handleSheetSelect(sheetName)}
>
<div class="flex items-center">
<div class="flex-shrink-0">
<div
class="flex h-4 w-4 items-center justify-center rounded-full border-2
{selectedSheetName === sheetName
? 'border-blue-500 bg-blue-500'
: 'border-gray-300'}"
>
{#if selectedSheetName === sheetName}
<div class="h-2 w-2 rounded-full bg-white"></div>
{/if}
</div>
</div>
<div class="ml-3 flex-grow">
<p class="text-sm font-medium text-gray-900">{sheetName}</p>
</div>
{#if selectedSheetName === sheetName}
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
</div>
{/if}
</div>
</div>
{/each}
</div>
</div>
</div>
{/if}
</div>
<!-- Column Mapping Section -->
<div class="mb-6 rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-3 text-sm font-medium text-gray-700">Step 2: Map Columns</h3>
{#if isLoadingData}
<div class="flex items-center">
<div
class="mr-3 h-5 w-5 animate-spin rounded-full border-2 border-blue-600 border-t-transparent"
></div>
<span class="text-sm text-gray-600">Loading sheet data...</span>
</div>
{:else if sheetHeaders.length === 0}
<div class="py-8 text-center text-gray-500">
<p class="text-sm">Select a sheet above to map columns</p>
</div>
{:else}
<div class="space-y-4">
<p class="mb-4 text-sm text-gray-600">
Map the columns from your sheet to the required fields:
</p>
<!-- Column mapping dropdowns -->
<div class="grid gap-4">
{#each requiredFields as field}
<div class="flex items-center">
<div class="w-32 flex-shrink-0">
<label for={`field-${field.key}`} class="text-sm font-medium text-gray-700">
{field.label}
{#if field.required}
<span class="text-red-500">*</span>
{/if}
</label>
</div>
<div class="flex-grow">
<select
id={`field-${field.key}`}
bind:value={mappedIndices[field.key]}
onchange={() =>
handleColumnMapping(
field.key as keyof ColumnMappingType,
mappedIndices[field.key]
)}
class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
<option value={-1}>-- Select column --</option>
{#each Array.from({ length: Math.max(sheetHeaders.length, 26) }, (_, i) => i) as index}
<option value={index}>
{sheetHeaders[index] || `Column ${String.fromCharCode(65 + index)}`}
{#if !sheetHeaders[index]}
(empty)
{/if}
</option>
{/each}
</select>
</div>
</div>
{/each}
</div>
<!-- Data preview -->
{#if previewData.length > 0}
<div class="mt-6">
<h4 class="mb-3 text-sm font-medium text-gray-700">Data Preview:</h4>
<div class="overflow-x-auto">
<table
class="min-w-full divide-y divide-gray-200 rounded-lg border border-gray-200"
>
<thead class="bg-gray-50">
<tr>
{#each Array.from({ length: Math.min(Math.max(sheetHeaders.length, previewData[0]?.length || 0), 26) }, (_, i) => i) as index}
<th
class="px-3 py-2 text-left text-xs font-medium tracking-wider text-gray-500 uppercase
{Object.values(mappedIndices).includes(index)
? 'bg-blue-100'
: ''}"
>
{sheetHeaders[index] || `Column ${String.fromCharCode(65 + index)}`}
{#if Object.values(mappedIndices).includes(index)}
<div class="mt-1 text-xs text-blue-600">
{requiredFields.find((f) => mappedIndices[f.key] === index)?.label}
</div>
{/if}
</th>
{/each}
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#each previewData as row}
<tr>
{#each Array.from({ length: Math.min(Math.max(sheetHeaders.length, row.length), 26) }, (_, i) => i) as index}
<td
class="max-w-xs truncate px-3 py-2 text-sm text-gray-500
{Object.values(mappedIndices).includes(index)
? 'bg-blue-50'
: ''}"
>
{row[index] || ''}
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
{#if mappingComplete}
<div class="rounded-md border border-green-200 bg-green-50 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg
class="h-5 w-5 text-green-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-green-800">
All required fields are mapped. You can now proceed.
</p>
</div>
</div>
</div>
{:else}
<div class="rounded-md bg-yellow-50 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg
class="h-5 w-5 text-yellow-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 3.01-1.742 3.01H4.42c-1.53 0-2.493-1.676-1.743-3.01l5.58-9.92zM10 5a1 1 0 011 1v3a1 1 0 01-2 0V6a1 1 0 011-1zm1 5a1 1 0 10-2 0v2a1 1 0 102 0v-2z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-yellow-800">
Please map all required fields (<span class="text-red-500">*</span>) to
continue.
</p>
</div>
</div>
</div>
{/if}
</div>
{/if}
</div>
{/if}
<!-- Navigation -->
<Navigator
canProceed={mappingComplete}
{currentStep}
textBack="Back to Sheet Selection"
textForwardDisabled="Select a column mapping"
textForwardEnabled="Continue"
onForward={handleContinue}
/>
</div>