Sheet selection and mapping
This commit is contained in:
3
.github/copilot-instructions.md
vendored
3
.github/copilot-instructions.md
vendored
@@ -4,4 +4,5 @@
|
|||||||
- It's important to only use modern Svelte 5 syntax, runes, and features.
|
- It's important to only use modern Svelte 5 syntax, runes, and features.
|
||||||
- Use styling from ".github/styling.md" for any UI components.
|
- Use styling from ".github/styling.md" for any UI components.
|
||||||
- Refer to the ".github/core-instructions.md" for the overall structure of the application.
|
- Refer to the ".github/core-instructions.md" for the overall structure of the application.
|
||||||
- Generate ".github/done.md" file to see what is done and what is not. Check it when you start and finish a task.
|
- Generate ".github/done.md" file to see what is done and what is not. Check it when you start and finish a task.
|
||||||
|
- Remain consistent in styling and code structure.
|
||||||
1
.github/core-instructions.md
vendored
1
.github/core-instructions.md
vendored
@@ -18,7 +18,6 @@
|
|||||||
| Face detection | **Mediapipe BlazeFace via TF.js (lite)** in a Web Worker. Confidence ≥ 0.8. |
|
| Face detection | **Mediapipe BlazeFace via TF.js (lite)** in a Web Worker. Confidence ≥ 0.8. |
|
||||||
| Virtual list | `svelte-virtual` (react‑window equivalent). |
|
| Virtual list | `svelte-virtual` (react‑window equivalent). |
|
||||||
| PDF | **pdf-lib** (via ESM import). Embed custom font **Lato** (ttf provided in `/static/fonts`). |
|
| PDF | **pdf-lib** (via ESM import). Embed custom font **Lato** (ttf provided in `/static/fonts`). |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. User Stories & Acceptance Criteria
|
## 1. User Stories & Acceptance Criteria
|
||||||
|
|||||||
11
package-lock.json
generated
11
package-lock.json
generated
@@ -14,8 +14,10 @@
|
|||||||
"@types/gapi.client.drive": "^3.0.15",
|
"@types/gapi.client.drive": "^3.0.15",
|
||||||
"@types/gapi.client.sheets": "^4.0.20201031",
|
"@types/gapi.client.sheets": "^4.0.20201031",
|
||||||
"@types/google.accounts": "^0.0.17",
|
"@types/google.accounts": "^0.0.17",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
"pdf-lib": "^1.17.1"
|
"pdf-lib": "^1.17.1",
|
||||||
|
"uuid": "^11.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-node": "^5.2.12",
|
"@sveltejs/adapter-node": "^5.2.12",
|
||||||
@@ -1565,6 +1567,12 @@
|
|||||||
"integrity": "sha512-ytDiArvrn/3Xk6/vtylys5tlY6eo7Ane0hvcx++TKo6RxQXuVfW0AF/oeWqAj9dN29SyhtawuXstgmPlwNcv/A==",
|
"integrity": "sha512-ytDiArvrn/3Xk6/vtylys5tlY6eo7Ane0hvcx++TKo6RxQXuVfW0AF/oeWqAj9dN29SyhtawuXstgmPlwNcv/A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/uuid": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@webgpu/types": {
|
"node_modules/@webgpu/types": {
|
||||||
"version": "0.1.38",
|
"version": "0.1.38",
|
||||||
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.38.tgz",
|
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.38.tgz",
|
||||||
@@ -3191,7 +3199,6 @@
|
|||||||
"version": "11.1.0",
|
"version": "11.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||||
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
"https://github.com/sponsors/broofa",
|
"https://github.com/sponsors/broofa",
|
||||||
"https://github.com/sponsors/ctavan"
|
"https://github.com/sponsors/ctavan"
|
||||||
|
|||||||
@@ -35,7 +35,9 @@
|
|||||||
"@types/gapi.client.drive": "^3.0.15",
|
"@types/gapi.client.drive": "^3.0.15",
|
||||||
"@types/gapi.client.sheets": "^4.0.20201031",
|
"@types/gapi.client.sheets": "^4.0.20201031",
|
||||||
"@types/google.accounts": "^0.0.17",
|
"@types/google.accounts": "^0.0.17",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
"pdf-lib": "^1.17.1"
|
"pdf-lib": "^1.17.1",
|
||||||
|
"uuid": "^11.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { currentStep } from '$lib/stores.js';
|
import { currentStep } from '$lib/stores.js';
|
||||||
import StepAuth from './wizard/StepAuth.svelte';
|
import StepAuth from './wizard/StepAuth.svelte';
|
||||||
|
import StepSheetSearch from './wizard/StepSheetSearch.svelte';
|
||||||
|
import StepColumnMap from './wizard/StepColumnMap.svelte';
|
||||||
// Additional steps to be added as they are implemented
|
// Additional steps to be added as they are implemented
|
||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
StepAuth
|
StepAuth,
|
||||||
|
StepSheetSearch,
|
||||||
|
StepColumnMap
|
||||||
];
|
];
|
||||||
|
|
||||||
const stepTitles = [
|
const stepTitles = [
|
||||||
'Authenticate'
|
'Authenticate',
|
||||||
|
'Select Sheet',
|
||||||
|
'Map Columns'
|
||||||
];
|
];
|
||||||
|
|
||||||
function goToPreviousStep() {
|
function goToPreviousStep() {
|
||||||
|
|||||||
@@ -1,4 +1,449 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { selectedSheet, columnMapping, rawSheetData, currentStep } from '$lib/stores';
|
||||||
|
import { getSheetNames, getSheetData } from '$lib/google';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
let isLoadingSheets = false;
|
||||||
|
let isLoadingData = false;
|
||||||
|
let availableSheets: string[] = [];
|
||||||
|
let selectedSheetName = '';
|
||||||
|
let error = '';
|
||||||
|
let sheetHeaders: string[] = [];
|
||||||
|
let previewData: string[][] = [];
|
||||||
|
let mappingComplete = false;
|
||||||
|
|
||||||
|
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 }
|
||||||
|
];
|
||||||
|
|
||||||
|
let mappedIndices = {
|
||||||
|
name: -1,
|
||||||
|
surname: -1,
|
||||||
|
nationality: -1,
|
||||||
|
birthday: -1,
|
||||||
|
pictureUrl: -1
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load available sheets when component mounts
|
||||||
|
onMount(async () => {
|
||||||
|
if ($selectedSheet) {
|
||||||
|
await loadAvailableSheets();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadAvailableSheets() {
|
||||||
|
if (!$selectedSheet) return;
|
||||||
|
|
||||||
|
isLoadingSheets = true;
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
availableSheets = await getSheetNames($selectedSheet.id);
|
||||||
|
// Don't auto-select any sheet - let user choose
|
||||||
|
} 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) {
|
||||||
|
selectedSheetName = sheetName;
|
||||||
|
// Clear any previous data when selecting a new sheet
|
||||||
|
rawSheetData.set([]);
|
||||||
|
sheetHeaders = [];
|
||||||
|
previewData = [];
|
||||||
|
mappedIndices = {
|
||||||
|
name: -1,
|
||||||
|
surname: -1,
|
||||||
|
nationality: -1,
|
||||||
|
birthday: -1,
|
||||||
|
pictureUrl: -1
|
||||||
|
};
|
||||||
|
mappingComplete = false;
|
||||||
|
|
||||||
|
// Load sheet data
|
||||||
|
if (sheetName) {
|
||||||
|
loadSheetData(sheetName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSheetData(sheetName: string) {
|
||||||
|
if (!$selectedSheet) return;
|
||||||
|
|
||||||
|
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) {
|
||||||
|
sheetHeaders = data[0];
|
||||||
|
previewData = data.slice(1, Math.min(4, data.length)); // Get up to 3 rows for preview
|
||||||
|
rawSheetData.set(data);
|
||||||
|
|
||||||
|
// 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.';
|
||||||
|
}
|
||||||
|
} 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
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-mapping patterns
|
||||||
|
const patterns = {
|
||||||
|
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
|
||||||
|
};
|
||||||
|
|
||||||
|
sheetHeaders.forEach((header, index) => {
|
||||||
|
for (const [field, pattern] of Object.entries(patterns)) {
|
||||||
|
if (pattern.test(header) && mappedIndices[field] === -1) {
|
||||||
|
mappedIndices[field] = index;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updateMappingStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSavedColumnMapping() {
|
||||||
|
if (!$selectedSheet || !selectedSheetName) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const recentSheetsKey = 'esn-recent-sheets';
|
||||||
|
const existingData = localStorage.getItem(recentSheetsKey);
|
||||||
|
|
||||||
|
if (existingData) {
|
||||||
|
const recentSheets = JSON.parse(existingData);
|
||||||
|
const savedSheet = recentSheets.find(sheet =>
|
||||||
|
sheet.id === $selectedSheet.id && sheet.sheetName === selectedSheetName
|
||||||
|
);
|
||||||
|
|
||||||
|
if (savedSheet && 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
|
||||||
|
};
|
||||||
|
|
||||||
|
updateMappingStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load saved column mapping:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleColumnMapping(field: string, index: number) {
|
||||||
|
mappedIndices[field] = index;
|
||||||
|
updateMappingStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMappingStatus() {
|
||||||
|
mappingComplete = Object.values(mappedIndices).every(index => index !== -1);
|
||||||
|
|
||||||
|
// Update the column mapping store
|
||||||
|
columnMapping.set({
|
||||||
|
name: mappedIndices.name,
|
||||||
|
surname: mappedIndices.surname,
|
||||||
|
nationality: mappedIndices.nationality,
|
||||||
|
birthday: mappedIndices.birthday,
|
||||||
|
pictureUrl: mappedIndices.pictureUrl
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleContinue() {
|
||||||
|
if (!mappingComplete || !$selectedSheet || !selectedSheetName) return;
|
||||||
|
|
||||||
|
// Save column mapping to localStorage for the selected sheet
|
||||||
|
try {
|
||||||
|
const recentSheetsKey = 'esn-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 =>
|
||||||
|
sheet.id === $selectedSheet.id && sheet.sheetName === selectedSheetName
|
||||||
|
);
|
||||||
|
|
||||||
|
const columnMappingData = {
|
||||||
|
name: mappedIndices.name,
|
||||||
|
surname: mappedIndices.surname,
|
||||||
|
nationality: mappedIndices.nationality,
|
||||||
|
birthday: mappedIndices.birthday,
|
||||||
|
pictureUrl: mappedIndices.pictureUrl
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sheetIndex !== -1) {
|
||||||
|
// Update existing entry
|
||||||
|
recentSheets[sheetIndex].columnMapping = columnMappingData;
|
||||||
|
recentSheets[sheetIndex].lastUsed = new Date().toISOString();
|
||||||
|
} else {
|
||||||
|
// Add new entry
|
||||||
|
const newEntry = {
|
||||||
|
id: $selectedSheet.id,
|
||||||
|
name: $selectedSheet.name,
|
||||||
|
sheetName: selectedSheetName,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentStep.set(4); // Move to next step
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<h2 class="text-xl font-semibold text-gray-900">Map Columns</h2>
|
<div class="max-w-3xl mx-auto">
|
||||||
<p class="text-sm text-gray-700">Column mapping functionality will be implemented here.</p>
|
<div class="mb-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-2">
|
||||||
|
Select Sheet and Map Columns
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p class="text-sm text-gray-700 mb-4">
|
||||||
|
First, select which sheet contains your member data, then map the columns to the required fields.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sheet Selection -->
|
||||||
|
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-6">
|
||||||
|
<h3 class="text-sm font-medium text-gray-700 mb-3">
|
||||||
|
Step 1: Select Sheet
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{#if isLoadingSheets}
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mr-3"></div>
|
||||||
|
<span class="text-sm text-gray-600">Loading sheets...</span>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="bg-red-50 border border-red-300 rounded-lg p-3 mb-3">
|
||||||
|
<p class="text-sm text-red-800">{error}</p>
|
||||||
|
<button
|
||||||
|
class="mt-2 text-sm text-blue-600 hover:text-blue-800"
|
||||||
|
on:click={loadAvailableSheets}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else if availableSheets.length === 0}
|
||||||
|
<div class="text-center py-6 bg-white rounded border border-gray-200">
|
||||||
|
<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>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-3">
|
||||||
|
Choose sheet:
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each availableSheets as sheetName}
|
||||||
|
<div
|
||||||
|
class="border rounded-lg p-3 cursor-pointer transition-colors hover:bg-gray-50
|
||||||
|
{selectedSheetName === sheetName ? 'border-blue-500 bg-blue-50' : 'border-gray-200'}"
|
||||||
|
on:click={() => handleSheetSelect(sheetName)}
|
||||||
|
>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="w-4 h-4 rounded-full border-2 flex items-center justify-center
|
||||||
|
{selectedSheetName === sheetName ? 'border-blue-500 bg-blue-500' : 'border-gray-300'}">
|
||||||
|
{#if selectedSheetName === sheetName}
|
||||||
|
<div class="w-2 h-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="w-5 h-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="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-6">
|
||||||
|
<h3 class="text-sm font-medium text-gray-700 mb-3">
|
||||||
|
Step 2: Map Columns
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{#if isLoadingData}
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mr-3"></div>
|
||||||
|
<span class="text-sm text-gray-600">Loading sheet data...</span>
|
||||||
|
</div>
|
||||||
|
{:else if sheetHeaders.length === 0}
|
||||||
|
<div class="text-center py-8 text-gray-500">
|
||||||
|
<p class="text-sm">Select a sheet above to map columns</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<p class="text-sm text-gray-600 mb-4">
|
||||||
|
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 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
|
||||||
|
bind:value={mappedIndices[field.key]}
|
||||||
|
on:change={() => handleColumnMapping(field.key, mappedIndices[field.key])}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value={-1}>-- Select column --</option>
|
||||||
|
{#each sheetHeaders as header, index}
|
||||||
|
<option value={index}>{header}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Data preview -->
|
||||||
|
{#if previewData.length > 0}
|
||||||
|
<div class="mt-6">
|
||||||
|
<h4 class="text-sm font-medium text-gray-700 mb-3">Data Preview:</h4>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 border border-gray-200 rounded-lg">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
{#each sheetHeaders as header, index}
|
||||||
|
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider
|
||||||
|
{Object.values(mappedIndices).includes(index) ? 'bg-blue-100' : ''}">
|
||||||
|
{header}
|
||||||
|
{#if Object.values(mappedIndices).includes(index)}
|
||||||
|
<div class="text-blue-600 text-xs mt-1">
|
||||||
|
{requiredFields.find(f => mappedIndices[f.key] === index)?.label}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</th>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
{#each previewData as row}
|
||||||
|
<tr>
|
||||||
|
{#each row as cell, index}
|
||||||
|
<td class="px-3 py-2 text-sm text-gray-500 max-w-xs truncate
|
||||||
|
{Object.values(mappedIndices).includes(index) ? 'bg-blue-50' : ''}">
|
||||||
|
{cell}
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Mapping status -->
|
||||||
|
{#if mappingComplete}
|
||||||
|
<div class="bg-green-50 border border-green-200 rounded p-3">
|
||||||
|
<p class="text-sm text-green-800">
|
||||||
|
✓ All required fields are mapped! You can continue to the next step.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="bg-yellow-50 border border-yellow-200 rounded p-3">
|
||||||
|
<p class="text-sm text-yellow-800">
|
||||||
|
Please map all required fields to continue.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button
|
||||||
|
on:click={handleContinue}
|
||||||
|
disabled={!mappingComplete}
|
||||||
|
class="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{mappingComplete ? 'Continue →' : 'Complete mapping to continue'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { availableSheets, selectedSheet, currentStep } from '$lib/stores';
|
||||||
|
import { searchSheets } from '$lib/google';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
let searchQuery = '';
|
||||||
|
let isLoading = false;
|
||||||
|
let error = '';
|
||||||
|
let searchResults: any[] = [];
|
||||||
|
let hasSearched = false;
|
||||||
|
let recentSheets: any[] = [];
|
||||||
|
|
||||||
|
const RECENT_SHEETS_KEY = 'esn-recent-sheets';
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
loadRecentSheets();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleSearch() {
|
||||||
|
if (!searchQuery.trim()) return;
|
||||||
|
|
||||||
|
isLoading = true;
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
searchResults = await searchSheets(searchQuery);
|
||||||
|
availableSheets.set(
|
||||||
|
searchResults.map(sheet => ({
|
||||||
|
id: sheet.id,
|
||||||
|
name: sheet.name,
|
||||||
|
url: sheet.webViewLink
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
hasSearched = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error searching sheets:', err);
|
||||||
|
error = 'Failed to search sheets. Please check your connection and try again.';
|
||||||
|
searchResults = [];
|
||||||
|
availableSheets.set([]);
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadRecentSheets() {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(RECENT_SHEETS_KEY);
|
||||||
|
if (saved) {
|
||||||
|
recentSheets = JSON.parse(saved);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading recent sheets:', err);
|
||||||
|
// If there's an error, clear the stored value
|
||||||
|
localStorage.removeItem(RECENT_SHEETS_KEY);
|
||||||
|
recentSheets = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveToRecentSheets(sheet) {
|
||||||
|
// Create a copy of the sheet object with just the properties we need
|
||||||
|
const sheetToSave = {
|
||||||
|
id: sheet.id,
|
||||||
|
name: sheet.name,
|
||||||
|
url: sheet.webViewLink || sheet.url,
|
||||||
|
iconLink: sheet.iconLink
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove this sheet if it already exists in the list
|
||||||
|
recentSheets = recentSheets.filter(s => s.id !== sheetToSave.id);
|
||||||
|
|
||||||
|
// Add the sheet to the beginning of the list
|
||||||
|
recentSheets = [sheetToSave, ...recentSheets];
|
||||||
|
|
||||||
|
// Keep only up to 3 recent sheets
|
||||||
|
if (recentSheets.length > 3) {
|
||||||
|
recentSheets = recentSheets.slice(0, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to localStorage
|
||||||
|
try {
|
||||||
|
localStorage.setItem(RECENT_SHEETS_KEY, JSON.stringify(recentSheets));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving recent sheets:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectSheet(sheet) {
|
||||||
|
const sheetData = {
|
||||||
|
id: sheet.id,
|
||||||
|
name: sheet.name,
|
||||||
|
url: sheet.webViewLink || sheet.url
|
||||||
|
};
|
||||||
|
|
||||||
|
selectedSheet.set(sheetData);
|
||||||
|
saveToRecentSheets(sheet);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleContinue() {
|
||||||
|
currentStep.set(3); // Move to the column mapping step
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="max-w-2xl mx-auto">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-2">
|
||||||
|
Select Google Sheet
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p class="text-sm text-gray-700 mb-4">
|
||||||
|
Search for and select the Google Sheet containing your member data.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search input -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="sheet-search" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Search sheets
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex">
|
||||||
|
<input
|
||||||
|
id="sheet-search"
|
||||||
|
type="text"
|
||||||
|
bind:value={searchQuery}
|
||||||
|
placeholder="Type sheet name..."
|
||||||
|
class="flex-grow px-4 py-2 border border-gray-300 rounded-l-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
on:click={handleSearch}
|
||||||
|
disabled={isLoading || !searchQuery.trim()}
|
||||||
|
class="px-4 py-2 bg-blue-600 text-white rounded-r-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||||
|
{:else}
|
||||||
|
Search
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="bg-red-50 border border-red-300 rounded-lg p-4 mb-6">
|
||||||
|
<p class="text-sm text-red-800">{error}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Results -->
|
||||||
|
{#if hasSearched}
|
||||||
|
<div class="mb-6">
|
||||||
|
<h3 class="text-sm font-medium text-gray-700 mb-3">
|
||||||
|
{searchResults.length
|
||||||
|
? `Found ${searchResults.length} matching sheets`
|
||||||
|
: 'No matching sheets found'}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{#if searchResults.length}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each searchResults as sheet}
|
||||||
|
<div
|
||||||
|
class="border rounded-lg p-4 cursor-pointer transition-colors hover:bg-gray-50
|
||||||
|
{$selectedSheet?.id === sheet.id ? 'border-blue-500 bg-blue-50' : 'border-gray-200'}"
|
||||||
|
on:click={() => handleSelectSheet(sheet)}
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-gray-900">{sheet.name}</p>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">ID: {sheet.id}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
{#if sheet.iconLink}
|
||||||
|
<img src={sheet.iconLink} alt="Sheet icon" class="w-5 h-5 mr-2" />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if $selectedSheet?.id === sheet.id}
|
||||||
|
<svg class="w-5 h-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>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="text-center py-8 bg-gray-50 rounded-lg border border-gray-200">
|
||||||
|
<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>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">Try a different search term</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- If we have recent sheets and haven't searched yet, show them -->
|
||||||
|
{#if recentSheets.length > 0 && !hasSearched}
|
||||||
|
<div class="mb-6">
|
||||||
|
<h3 class="text-sm font-medium text-gray-700 mb-3">
|
||||||
|
Recent sheets
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each recentSheets as sheet}
|
||||||
|
<div
|
||||||
|
class="border rounded-lg p-4 cursor-pointer transition-colors hover:bg-gray-50
|
||||||
|
{$selectedSheet?.id === sheet.id ? 'border-blue-500 bg-blue-50' : 'border-gray-200'}"
|
||||||
|
on:click={() => handleSelectSheet(sheet)}
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-gray-900">{sheet.name}</p>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Recently used</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
{#if sheet.iconLink}
|
||||||
|
<img src={sheet.iconLink} alt="Sheet icon" class="w-5 h-5 mr-2" />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if $selectedSheet?.id === sheet.id}
|
||||||
|
<svg class="w-5 h-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>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-gray-200 mt-4 pt-4">
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
Or search for a different sheet above
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="text-center py-12 bg-gray-50 rounded-lg border border-gray-200 mb-6">
|
||||||
|
<svg class="mx-auto h-16 w-16 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||||
|
</svg>
|
||||||
|
<h3 class="mt-2 text-lg font-medium text-gray-900">Search for your sheet</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
Enter a name or keyword to find your Google Sheets
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Continue button -->
|
||||||
|
{#if $selectedSheet}
|
||||||
|
<div class="mt-6 flex justify-end">
|
||||||
|
<button
|
||||||
|
on:click={handleContinue}
|
||||||
|
class="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Continue →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -100,6 +100,22 @@ export async function searchSheets(query: string) {
|
|||||||
return response.result.files || [];
|
return response.result.files || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getSheetNames(spreadsheetId: string) {
|
||||||
|
if (!gapi.client.sheets) {
|
||||||
|
throw new Error('Google Sheets API not loaded');
|
||||||
|
}
|
||||||
|
const response = await gapi.client.sheets.spreadsheets.get({
|
||||||
|
spreadsheetId,
|
||||||
|
fields: 'sheets.properties'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.result.sheets) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.result.sheets.map(sheet => sheet.properties?.title || '');
|
||||||
|
}
|
||||||
|
|
||||||
export async function getSheetData(spreadsheetId: string, range: string) {
|
export async function getSheetData(spreadsheetId: string, range: string) {
|
||||||
if (!gapi.client.sheets) {
|
if (!gapi.client.sheets) {
|
||||||
throw new Error('Google Sheets API not loaded');
|
throw new Error('Google Sheets API not loaded');
|
||||||
|
|||||||
Reference in New Issue
Block a user