From 88822861467edce47631f711ea4bb6685fbd643f Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Mon, 23 Mar 2026 19:05:26 +0000 Subject: [PATCH] Fix voice recording and add Excel column mapping verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Voice: switch CommandBar from PTT (hold) to toggle mode (click), update to use new useSpeechRecognition toggle/listening API with auto-restart - Mapping detection: new detect_excel_mapping() reads row 1 headers and auto-detects name/status/media columns via keyword matching - Mapping endpoints: POST /api/admin/dropdowns/detect-mapping and /api/admin/clients/{id}/dropdowns/detect-mapping - Upload/preview now accept name_col/status_col/media_col form fields to apply a confirmed mapping override - Frontend: ColumnMappingStep component shows detected columns + 5-row sample for confirmation before upload - AdminDropdownsPage and AdminClientsPage use 3-stage flow: detect → confirm mapping → preview all → apply Co-Authored-By: Claude Sonnet 4.6 --- backend/server/api/admin.py | 71 ++++++- backend/server/api/dropdowns.py | 60 +++++- frontend/src/api/admin.ts | 52 ++++- .../components/admin/ColumnMappingStep.tsx | 102 ++++++++++ frontend/src/components/sheet/CommandBar.tsx | 9 +- frontend/src/hooks/useSpeechRecognition.ts | 109 ++++++++--- frontend/src/pages/admin/AdminClientsPage.tsx | 184 +++++++++++------- .../src/pages/admin/AdminDropdownsPage.tsx | 151 ++++++++------ 8 files changed, 560 insertions(+), 178 deletions(-) create mode 100644 frontend/src/components/admin/ColumnMappingStep.tsx diff --git a/backend/server/api/admin.py b/backend/server/api/admin.py index 0cf24bd..ab5798b 100644 --- a/backend/server/api/admin.py +++ b/backend/server/api/admin.py @@ -11,7 +11,7 @@ from quart import Blueprint, jsonify, request from ..auth.middleware import admin_required from ..auth.user_store import list_users, set_role, set_active -from ..api.dropdowns import save_dropdowns, parse_excel_dropdowns +from ..api.dropdowns import save_dropdowns, parse_excel_dropdowns, detect_excel_mapping from ..api.clients import load_clients, get_client_by_id logger = logging.getLogger(__name__) @@ -48,7 +48,21 @@ def _read_xlsx_file(file) -> bytes: return file.read() -async def _parse_uploaded_xlsx(files) -> tuple[list, str | None]: +def _extract_mapping(form) -> dict | None: + """Extract mapping override from multipart form fields (name_col, status_col, media_col).""" + try: + if 'name_col' in form and 'status_col' in form and 'media_col' in form: + return { + 'name_col': int(form['name_col']), + 'status_col': int(form['status_col']), + 'media_col': int(form['media_col']), + } + except (ValueError, KeyError): + pass + return None + + +async def _parse_uploaded_xlsx(files, form=None) -> tuple[list, str | None]: """Returns (categories, error_message). error_message is None on success.""" file = files.get('file') if not file: @@ -57,7 +71,8 @@ async def _parse_uploaded_xlsx(files) -> tuple[list, str | None]: return [], 'Only .xlsx files accepted' try: data = _read_xlsx_file(file) - categories = parse_excel_dropdowns(data) + mapping = _extract_mapping(form) if form else None + categories = parse_excel_dropdowns(data, mapping=mapping) if not categories: return [], 'No categories found in file' return categories, None @@ -66,12 +81,32 @@ async def _parse_uploaded_xlsx(files) -> tuple[list, str | None]: return [], str(e) +@admin_bp.route('/dropdowns/detect-mapping', methods=['POST']) +@admin_required +async def detect_mapping(): + """Detect column mapping from an uploaded .xlsx without saving. Returns headers + mapping.""" + files = await request.files + file = files.get('file') + if not file: + return jsonify({'error': 'no_file'}), 400 + if not (file.filename or '').lower().endswith('.xlsx'): + return jsonify({'error': 'Only .xlsx files accepted'}), 400 + try: + data = _read_xlsx_file(file) + result = detect_excel_mapping(data) + return jsonify(result) + except Exception as e: + logger.error(f"Mapping detection error: {e}", exc_info=True) + return jsonify({'error': str(e)}), 400 + + @admin_bp.route('/dropdowns/upload', methods=['POST']) @admin_required async def upload_dropdowns(): """Upload a new .xlsx to update global dropdown categories.""" files = await request.files - categories, err = await _parse_uploaded_xlsx(files) + form = await request.form + categories, err = await _parse_uploaded_xlsx(files, form) if err: return jsonify({'error': err}), 400 save_dropdowns(categories) @@ -85,7 +120,8 @@ async def upload_dropdowns(): async def preview_dropdowns(): """Preview parsed categories from an uploaded file without saving.""" files = await request.files - categories, err = await _parse_uploaded_xlsx(files) + form = await request.form + categories, err = await _parse_uploaded_xlsx(files, form) if err: return jsonify({'error': err}), 400 return jsonify({'categories': categories, 'total': len(categories)}) @@ -93,6 +129,25 @@ async def preview_dropdowns(): # ── Per-client dropdown endpoints ───────────────────────────────────────────── +@admin_bp.route('/clients//dropdowns/detect-mapping', methods=['POST']) +@admin_required +async def detect_client_mapping(client_id: str): + """Detect column mapping from a per-client .xlsx upload.""" + files = await request.files + file = files.get('file') + if not file: + return jsonify({'error': 'no_file'}), 400 + if not (file.filename or '').lower().endswith('.xlsx'): + return jsonify({'error': 'Only .xlsx files accepted'}), 400 + try: + data = _read_xlsx_file(file) + result = detect_excel_mapping(data) + return jsonify(result) + except Exception as e: + logger.error(f"Mapping detection error: {e}", exc_info=True) + return jsonify({'error': str(e)}), 400 + + @admin_bp.route('/clients//dropdowns/upload', methods=['POST']) @admin_required async def upload_client_dropdowns(client_id: str): @@ -100,7 +155,8 @@ async def upload_client_dropdowns(client_id: str): if not get_client_by_id(client_id): return jsonify({'error': 'client_not_found'}), 404 files = await request.files - categories, err = await _parse_uploaded_xlsx(files) + form = await request.form + categories, err = await _parse_uploaded_xlsx(files, form) if err: return jsonify({'error': err}), 400 save_dropdowns(categories, client_id=client_id) @@ -124,7 +180,8 @@ async def upload_client_dropdowns(client_id: str): async def preview_client_dropdowns(client_id: str): """Preview per-client dropdown file without saving.""" files = await request.files - categories, err = await _parse_uploaded_xlsx(files) + form = await request.form + categories, err = await _parse_uploaded_xlsx(files, form) if err: return jsonify({'error': err}), 400 return jsonify({'categories': categories, 'total': len(categories)}) diff --git a/backend/server/api/dropdowns.py b/backend/server/api/dropdowns.py index 415f723..3d2187b 100644 --- a/backend/server/api/dropdowns.py +++ b/backend/server/api/dropdowns.py @@ -179,22 +179,68 @@ def save_dropdowns(categories: list, client_id: str = None): json.dump(categories, f, indent=2) -def parse_excel_dropdowns(file_bytes: bytes) -> list: - """Parse an .xlsx file into [{name, status, mediaTypes}] list. - Expected columns: A=Category name, E=Status, G=Media types (comma-separated). +def detect_excel_mapping(file_bytes: bytes) -> dict: + """ + Read the first row of an .xlsx and auto-detect column mapping for + name/status/media fields. Returns: + { + headers: [...], # all header strings from row 1 + mapping: {name_col, status_col, media_col}, # 0-based indices + sample: [...] # up to 5 parsed rows using detected mapping + } """ import openpyxl from io import BytesIO wb = openpyxl.load_workbook(BytesIO(file_bytes)) ws = wb.active + header_row = [str(c.value or '').strip() for c in next(ws.iter_rows(min_row=1, max_row=1))] + + def _find(keywords, headers): + for i, h in enumerate(headers): + hl = h.lower() + if any(k in hl for k in keywords): + return i + return None + + name_col = _find(['category', 'name', 'task', 'deliverable'], header_row) or 0 + status_col = _find(['status', 'active', 'archived'], header_row) or 4 + media_col = _find(['media', 'type', 'channel'], header_row) or 6 + + mapping = {'name_col': name_col, 'status_col': status_col, 'media_col': media_col} + sample = [] + for row in ws.iter_rows(min_row=2, max_row=6, values_only=True): + if len(row) <= name_col or not row[name_col]: + continue + name = str(row[name_col]).strip() + status_raw = str(row[status_col]).strip() if len(row) > status_col and row[status_col] else 'Active' + status = 'Active' if 'active' in status_raw.lower() else 'Archived' + media_raw = str(row[media_col]).strip() if len(row) > media_col and row[media_col] else '' + media_types = [m.strip() for m in media_raw.split(',') if m.strip()] if media_raw else [] + sample.append({'name': name, 'status': status, 'mediaTypes': media_types}) + + return {'headers': header_row, 'mapping': mapping, 'sample': sample} + + +def parse_excel_dropdowns(file_bytes: bytes, mapping: dict = None) -> list: + """Parse an .xlsx file into [{name, status, mediaTypes}] list. + Default columns: A=Category name (0), E=Status (4), G=Media types (6). + Pass mapping={'name_col': int, 'status_col': int, 'media_col': int} to override. + """ + import openpyxl + from io import BytesIO + wb = openpyxl.load_workbook(BytesIO(file_bytes)) + ws = wb.active + name_col = mapping['name_col'] if mapping else 0 + status_col = mapping['status_col'] if mapping else 4 + media_col = mapping['media_col'] if mapping else 6 categories = [] for row in ws.iter_rows(min_row=2, values_only=True): - if len(row) < 5 or not row[0]: + if len(row) <= name_col or not row[name_col]: continue - name = str(row[0]).strip() - status_raw = str(row[4]).strip() if row[4] else 'Active' + name = str(row[name_col]).strip() + status_raw = str(row[status_col]).strip() if len(row) > status_col and row[status_col] else 'Active' status = 'Active' if 'active' in status_raw.lower() else 'Archived' - media_raw = str(row[6]).strip() if len(row) > 6 and row[6] else '' + media_raw = str(row[media_col]).strip() if len(row) > media_col and row[media_col] else '' media_types = [m.strip() for m in media_raw.split(',') if m.strip()] if media_raw else [] categories.append({'name': name, 'status': status, 'mediaTypes': media_types}) return categories diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 7a28753..1fb126b 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -1,33 +1,77 @@ import api from './client' import type { User, CategoryData } from '../types' +export interface ColumnMapping { + name_col: number + status_col: number + media_col: number +} + +export interface DetectMappingResult { + headers: string[] + mapping: ColumnMapping + sample: CategoryData[] +} + export const listUsers = () => api.get<{ users: User[] }>('/admin/users').then(r => r.data.users) export const updateUser = (id: string, patch: { role?: User['role']; active?: boolean }) => api.patch<{ success: boolean; user: User }>(`/admin/users/${id}`, patch).then(r => r.data.user) -export const uploadDropdowns = (file: File) => { +export const detectDropdownMapping = (file: File) => { const form = new FormData() form.append('file', file) + return api.post('/admin/dropdowns/detect-mapping', form).then(r => r.data) +} + +export const uploadDropdowns = (file: File, mapping?: ColumnMapping) => { + const form = new FormData() + form.append('file', file) + if (mapping) { + form.append('name_col', String(mapping.name_col)) + form.append('status_col', String(mapping.status_col)) + form.append('media_col', String(mapping.media_col)) + } return api.post<{ success: boolean; total: number; active: number }>('/admin/dropdowns/upload', form).then(r => r.data) } -export const previewDropdowns = (file: File) => { +export const previewDropdowns = (file: File, mapping?: ColumnMapping) => { const form = new FormData() form.append('file', file) + if (mapping) { + form.append('name_col', String(mapping.name_col)) + form.append('status_col', String(mapping.status_col)) + form.append('media_col', String(mapping.media_col)) + } return api.post<{ categories: CategoryData[] }>('/admin/dropdowns/preview', form).then(r => r.data.categories) } -export const uploadClientDropdowns = (clientId: string, file: File) => { +export const detectClientDropdownMapping = (clientId: string, file: File) => { const form = new FormData() form.append('file', file) + return api.post(`/admin/clients/${clientId}/dropdowns/detect-mapping`, form).then(r => r.data) +} + +export const uploadClientDropdowns = (clientId: string, file: File, mapping?: ColumnMapping) => { + const form = new FormData() + form.append('file', file) + if (mapping) { + form.append('name_col', String(mapping.name_col)) + form.append('status_col', String(mapping.status_col)) + form.append('media_col', String(mapping.media_col)) + } return api.post<{ success: boolean; total: number; active: number }>(`/admin/clients/${clientId}/dropdowns/upload`, form).then(r => r.data) } -export const previewClientDropdowns = (clientId: string, file: File) => { +export const previewClientDropdowns = (clientId: string, file: File, mapping?: ColumnMapping) => { const form = new FormData() form.append('file', file) + if (mapping) { + form.append('name_col', String(mapping.name_col)) + form.append('status_col', String(mapping.status_col)) + form.append('media_col', String(mapping.media_col)) + } return api.post<{ categories: CategoryData[] }>(`/admin/clients/${clientId}/dropdowns/preview`, form).then(r => r.data.categories) } diff --git a/frontend/src/components/admin/ColumnMappingStep.tsx b/frontend/src/components/admin/ColumnMappingStep.tsx new file mode 100644 index 0000000..fcfa549 --- /dev/null +++ b/frontend/src/components/admin/ColumnMappingStep.tsx @@ -0,0 +1,102 @@ +import { useState } from 'react' +import type { ColumnMapping, DetectMappingResult } from '../../api/admin' + +interface Props { + detection: DetectMappingResult + onConfirm: (mapping: ColumnMapping) => void + onCancel: () => void +} + +const FIELD_LABELS: Record = { + name_col: 'Category name', + status_col: 'Status (Active / Archived)', + media_col: 'Media types', +} + +export default function ColumnMappingStep({ detection, onConfirm, onCancel }: Props) { + const [mapping, setMapping] = useState({ ...detection.mapping }) + + const setCol = (field: keyof ColumnMapping, idx: number) => + setMapping(m => ({ ...m, [field]: idx })) + + return ( +
+
+
Confirm column mapping
+
+ Verify which columns contain the data fields, then click Confirm. +
+
+ +
+ {(Object.keys(FIELD_LABELS) as (keyof ColumnMapping)[]).map(field => ( +
+ + {FIELD_LABELS[field]} + + +
+ ))} +
+ + {/* Sample preview */} + {detection.sample.length > 0 && ( +
+
+ Sample (first {detection.sample.length} rows) +
+
+ + + + + + + + + + {detection.sample.map((row, i) => ( + + + + + + ))} + +
CategoryStatusMedia Types
{row.name}{row.status}{row.mediaTypes.join(', ') || '—'}
+
+
+ )} + +
+ + +
+
+ ) +} diff --git a/frontend/src/components/sheet/CommandBar.tsx b/frontend/src/components/sheet/CommandBar.tsx index 5167542..aea2bac 100644 --- a/frontend/src/components/sheet/CommandBar.tsx +++ b/frontend/src/components/sheet/CommandBar.tsx @@ -26,7 +26,7 @@ export default function CommandBar({ onCommand, loading, yolo, onYoloChange }: P const [input, setInput] = useState('') const inputRef = useRef(null) - const { listening, start, stop, supported } = useSpeechRecognition((text) => { + const { listening, toggle, supported } = useSpeechRecognition((text) => { setInput(text) }) @@ -64,15 +64,14 @@ export default function CommandBar({ onCommand, loading, yolo, onYoloChange }: P {supported && ( diff --git a/frontend/src/hooks/useSpeechRecognition.ts b/frontend/src/hooks/useSpeechRecognition.ts index baf221c..30bebf6 100644 --- a/frontend/src/hooks/useSpeechRecognition.ts +++ b/frontend/src/hooks/useSpeechRecognition.ts @@ -1,10 +1,9 @@ -import { useState, useRef, useCallback } from 'react' +import { useState, useRef, useCallback, useEffect } from 'react' interface SpeechRecognitionHook { transcript: string listening: boolean - start: () => void - stop: () => void + toggle: () => void supported: boolean } @@ -12,37 +11,93 @@ export function useSpeechRecognition(onResult: (text: string) => void): SpeechRe const [transcript, setTranscript] = useState('') const [listening, setListening] = useState(false) const recognitionRef = useRef(null) + const accumulatedRef = useRef('') - const supported = 'webkitSpeechRecognition' in window || 'SpeechRecognition' in window + const supported = + typeof window !== 'undefined' && + ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window) - const start = useCallback(() => { - if (!supported) return - const SpeechRecognition = (window as any).webkitSpeechRecognition || (window as any).SpeechRecognition - const recognition = new SpeechRecognition() - recognition.continuous = true - recognition.interimResults = true - recognition.lang = 'en-US' - - recognition.onresult = (e: any) => { - let final = '' - for (let i = e.resultIndex; i < e.results.length; i++) { - if (e.results[i].isFinal) final += e.results[i][0].transcript - } - if (final) { - setTranscript(final) - onResult(final) - } + // Clean up on unmount + useEffect(() => { + return () => { + recognitionRef.current?.abort() } - recognition.onend = () => setListening(false) - recognitionRef.current = recognition - recognition.start() - setListening(true) - }, [supported, onResult]) + }, []) const stop = useCallback(() => { recognitionRef.current?.stop() + recognitionRef.current = null setListening(false) }, []) - return { transcript, listening, start, stop, supported } + const start = useCallback(() => { + if (!supported) return + // Reset accumulator for new session + accumulatedRef.current = '' + setTranscript('') + + const SR = (window as any).webkitSpeechRecognition || (window as any).SpeechRecognition + const recognition = new SR() + recognition.continuous = true + recognition.interimResults = true + recognition.lang = 'en-US' + recognition.maxAlternatives = 1 + + recognition.onresult = (e: any) => { + let interimText = '' + for (let i = e.resultIndex; i < e.results.length; i++) { + const text = e.results[i][0].transcript + if (e.results[i].isFinal) { + accumulatedRef.current += text + ' ' + } else { + interimText += text + } + } + // Show accumulated finals + current interim + const display = (accumulatedRef.current + interimText).trim() + setTranscript(display) + onResult(display) + } + + recognition.onerror = (e: any) => { + // 'no-speech' and 'aborted' are normal — don't treat as fatal + if (e.error !== 'no-speech' && e.error !== 'aborted') { + console.warn('Speech recognition error:', e.error) + } + setListening(false) + recognitionRef.current = null + } + + recognition.onend = () => { + // Auto-restart if we're still supposed to be listening + // (browser stops after silence; we restart to keep it continuous) + if (recognitionRef.current) { + try { + recognition.start() + } catch { + setListening(false) + recognitionRef.current = null + } + } + } + + recognitionRef.current = recognition + try { + recognition.start() + setListening(true) + } catch (e) { + console.warn('Failed to start speech recognition:', e) + recognitionRef.current = null + } + }, [supported, onResult]) + + const toggle = useCallback(() => { + if (listening) { + stop() + } else { + start() + } + }, [listening, start, stop]) + + return { transcript, listening, toggle, supported } } diff --git a/frontend/src/pages/admin/AdminClientsPage.tsx b/frontend/src/pages/admin/AdminClientsPage.tsx index aa99965..f032442 100644 --- a/frontend/src/pages/admin/AdminClientsPage.tsx +++ b/frontend/src/pages/admin/AdminClientsPage.tsx @@ -1,21 +1,31 @@ import { useEffect, useState, useCallback } from 'react' import { useDropzone } from 'react-dropzone' import { useClientStore } from '../../stores/useClientStore' -import { previewClientDropdowns, uploadClientDropdowns, deleteClientDropdowns } from '../../api/admin' +import { + detectClientDropdownMapping, previewClientDropdowns, + uploadClientDropdowns, deleteClientDropdowns, +} from '../../api/admin' +import type { ColumnMapping, DetectMappingResult } from '../../api/admin' import type { Client, CategoryData } from '../../types' +import ColumnMappingStep from '../../components/admin/ColumnMappingStep' import toast from 'react-hot-toast' +// Per-client upload state +interface UploadState { + clientId: string + stage: 'mapping' | 'preview' + file: File + detection?: DetectMappingResult + mapping?: ColumnMapping + preview?: CategoryData[] + busy: boolean +} + export default function AdminClientsPage() { const { clients, loaded, fetch, create, remove } = useClientStore() const [newName, setNewName] = useState('') const [creating, setCreating] = useState(false) - - // Per-client dropdown upload state - const [uploadingFor, setUploadingFor] = useState(null) - const [previewFor, setPreviewFor] = useState(null) - const [previewFile, setPreviewFile] = useState(null) - const [preview, setPreview] = useState(null) - const [applying, setApplying] = useState(false) + const [upload, setUpload] = useState(null) useEffect(() => { if (!loaded) fetch() @@ -49,36 +59,40 @@ export default function AdminClientsPage() { const handleDropFile = useCallback(async (clientId: string, files: File[]) => { if (!files[0]) return const file = files[0] - setPreviewFor(clientId) - setPreviewFile(file) - setUploadingFor(clientId) + setUpload({ clientId, stage: 'mapping', file, busy: true }) try { - const result = await previewClientDropdowns(clientId, file) - setPreview(result) + const detection = await detectClientDropdownMapping(clientId, file) + setUpload(u => u ? { ...u, detection, busy: false } : null) } catch { - toast.error('Failed to parse Excel file') - setPreview(null) - } finally { - setUploadingFor(null) + toast.error('Failed to read Excel file') + setUpload(null) } }, []) - const handleApply = async () => { - if (!previewFor || !previewFile) return - setApplying(true) + const handleMappingConfirm = async (mapping: ColumnMapping) => { + if (!upload) return + setUpload(u => u ? { ...u, busy: true, mapping } : null) try { - const result = await uploadClientDropdowns(previewFor, previewFile) - // Force reload client list to refresh hasCustomDropdowns flag + const preview = await previewClientDropdowns(upload.clientId, upload.file, mapping) + setUpload(u => u ? { ...u, stage: 'preview', preview, busy: false } : null) + } catch { + toast.error('Failed to parse file with this mapping') + setUpload(u => u ? { ...u, busy: false } : null) + } + } + + const handleApply = async () => { + if (!upload) return + setUpload(u => u ? { ...u, busy: true } : null) + try { + const result = await uploadClientDropdowns(upload.clientId, upload.file, upload.mapping) useClientStore.setState({ loaded: false }) await fetch() - setPreview(null) - setPreviewFor(null) - setPreviewFile(null) + setUpload(null) toast.success(`Saved ${result.active} active categories for this client`) } catch { toast.error('Upload failed') - } finally { - setApplying(false) + setUpload(u => u ? { ...u, busy: false } : null) } } @@ -99,9 +113,6 @@ export default function AdminClientsPage() {

Clients

Manage clients and assign custom Category / Media dropdown files per client. -
- Each client can have its own .xlsx file — same column format as the global dropdown file - (Col A = Category, Col E = Status, Col G = Media Types comma-separated). If no custom file is uploaded, the global hierarchy is used.

@@ -142,21 +153,23 @@ export default function AdminClientsPage() {
No clients yet. Create one above.
) : ( -
- {clients.map(client => ( - handleDelete(client)} - onDrop={files => handleDropFile(client.id, files)} - onRemoveCustom={() => handleRemoveCustom(client.id)} - uploading={uploadingFor === client.id} - previewData={previewFor === client.id ? preview : null} - onApply={handleApply} - onCancelPreview={() => { setPreview(null); setPreviewFor(null); setPreviewFile(null) }} - applying={applying && previewFor === client.id} - /> - ))} +
+ {clients.map(client => { + const isActive = upload?.clientId === client.id + return ( + handleDelete(client)} + onDrop={files => handleDropFile(client.id, files)} + onRemoveCustom={() => handleRemoveCustom(client.id)} + uploadState={isActive ? upload : null} + onMappingConfirm={handleMappingConfirm} + onApply={handleApply} + onCancel={() => setUpload(null)} + /> + ) + })}
)}
@@ -170,25 +183,28 @@ interface ClientCardProps { onDelete: () => void onDrop: (files: File[]) => void onRemoveCustom: () => void - uploading: boolean - previewData: CategoryData[] | null + uploadState: UploadState | null + onMappingConfirm: (mapping: ColumnMapping) => void onApply: () => void - onCancelPreview: () => void - applying: boolean + onCancel: () => void } -function ClientCard({ client, onDelete, onDrop, onRemoveCustom, uploading, previewData, onApply, onCancelPreview, applying }: ClientCardProps) { +function ClientCard({ + client, onDelete, onDrop, onRemoveCustom, + uploadState, onMappingConfirm, onApply, onCancel, +}: ClientCardProps) { + const busy = uploadState?.busy ?? false const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop, accept: { 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'] }, maxFiles: 1, - disabled: uploading, + disabled: busy || uploadState !== null, }) return (
{/* Header */} -
+
{client.name}
@@ -220,39 +236,59 @@ function ClientCard({ client, onDelete, onDrop, onRemoveCustom, uploading, previ
- {/* Drop zone */} -
- -

- {uploading ? 'Parsing…' : isDragActive ? 'Drop .xlsx here' : 'Drop .xlsx or click to upload custom dropdown hierarchy'} -

-
+ {/* Upload states */} + {!uploadState && ( +
+ +

+ {isDragActive ? 'Drop .xlsx here' : 'Drop .xlsx or click to upload custom dropdown hierarchy'} +

+
+ )} - {/* Preview */} - {previewData && ( + {uploadState?.stage === 'mapping' && uploadState.busy && ( +
+ Reading file… +
+ )} + + {uploadState?.stage === 'mapping' && !uploadState.busy && uploadState.detection && ( +
+ + {uploadState.busy && ( +
Loading preview…
+ )} +
+ )} + + {uploadState?.stage === 'preview' && uploadState.preview && (
- Preview — {previewData.length} categories + Preview — {uploadState.preview.length} categories
-
@@ -261,13 +297,15 @@ function ClientCard({ client, onDelete, onDrop, onRemoveCustom, uploading, previ Category + Status Media Types - {previewData.map(cat => ( + {uploadState.preview.map(cat => ( {cat.name} + {cat.status} {cat.mediaTypes.join(', ') || '—'} ))} diff --git a/frontend/src/pages/admin/AdminDropdownsPage.tsx b/frontend/src/pages/admin/AdminDropdownsPage.tsx index 2c9c862..825c80d 100644 --- a/frontend/src/pages/admin/AdminDropdownsPage.tsx +++ b/frontend/src/pages/admin/AdminDropdownsPage.tsx @@ -1,57 +1,87 @@ import { useEffect, useState, useCallback } from 'react' import { useDropzone } from 'react-dropzone' -import { previewDropdowns, uploadDropdowns } from '../../api/admin' +import { detectDropdownMapping, previewDropdowns, uploadDropdowns } from '../../api/admin' +import type { ColumnMapping, DetectMappingResult } from '../../api/admin' import { useDropdownStore } from '../../stores/useDropdownStore' import type { CategoryData } from '../../types' +import ColumnMappingStep from '../../components/admin/ColumnMappingStep' import toast from 'react-hot-toast' export default function AdminDropdownsPage() { const { categories, fetch: fetchCategories } = useDropdownStore() + + // Stage: null → 'mapping' → 'preview' → null (done) + const [stage, setStage] = useState<'mapping' | 'preview' | null>(null) + const [pendingFile, setPendingFile] = useState(null) + const [detection, setDetection] = useState(null) + const [confirmedMapping, setConfirmedMapping] = useState(null) const [preview, setPreview] = useState(null) - const [previewFile, setPreviewFile] = useState(null) - const [uploading, setUploading] = useState(false) - const [previewing, setPreviewing] = useState(false) + const [busy, setBusy] = useState(false) useEffect(() => { fetchCategories() }, []) + const reset = () => { + setStage(null) + setPendingFile(null) + setDetection(null) + setConfirmedMapping(null) + setPreview(null) + } + const onDrop = useCallback(async (files: File[]) => { if (!files[0]) return const file = files[0] - setPreviewFile(file) - setPreviewing(true) + setPendingFile(file) + setBusy(true) try { - const result = await previewDropdowns(file) - setPreview(result) + const result = await detectDropdownMapping(file) + setDetection(result) + setStage('mapping') } catch { - toast.error('Failed to parse Excel file') - setPreview(null) + toast.error('Failed to read Excel file') } finally { - setPreviewing(false) + setBusy(false) } }, []) + const handleMappingConfirm = async (mapping: ColumnMapping) => { + if (!pendingFile) return + setConfirmedMapping(mapping) + setBusy(true) + try { + const result = await previewDropdowns(pendingFile, mapping) + setPreview(result) + setStage('preview') + } catch { + toast.error('Failed to parse file with this mapping') + } finally { + setBusy(false) + } + } + + const handleApply = async () => { + if (!pendingFile) return + setBusy(true) + try { + await uploadDropdowns(pendingFile, confirmedMapping ?? undefined) + useDropdownStore.setState({ categories: [], clientId: null }) + await fetchCategories() + reset() + toast.success('Dropdowns updated successfully') + } catch { + toast.error('Upload failed') + } finally { + setBusy(false) + } + } + const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop, accept: { 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'] }, maxFiles: 1, + disabled: busy || stage !== null, }) - const handleApply = async () => { - if (!previewFile) return - setUploading(true) - try { - await uploadDropdowns(previewFile) - await fetchCategories() - setPreview(null) - setPreviewFile(null) - toast.success('Dropdowns updated successfully') - } catch { - toast.error('Upload failed') - } finally { - setUploading(false) - } - } - return (
@@ -62,30 +92,41 @@ export default function AdminDropdownsPage() {
{/* Upload zone */} -
- -
📊
-

- {isDragActive ? 'Drop Excel file here' : 'Drag & drop an .xlsx file, or click to select'} -

-

- Expected format: columns "Category" and "Media Type" (one row per category/media pair) -

-
- - {previewing && ( -
Parsing file…
+ {stage === null && ( +
+ +
📊
+

+ {busy ? 'Reading file…' : isDragActive ? 'Drop Excel file here' : 'Drag & drop an .xlsx file, or click to select'} +

+

+ Column mapping will be detected automatically and shown for confirmation +

+
)} - {/* Preview */} - {preview && ( + {/* Step 1 — Mapping confirmation */} + {stage === 'mapping' && detection && ( +
+ + {busy &&
Loading preview…
} +
+ )} + + {/* Step 2 — Full preview */} + {stage === 'preview' && preview && (

@@ -93,7 +134,7 @@ export default function AdminDropdownsPage() {

@@ -114,6 +155,7 @@ export default function AdminDropdownsPage() { Category + Status Media Types @@ -121,9 +163,8 @@ export default function AdminDropdownsPage() { {preview.map(cat => ( {cat.name} - - {cat.mediaTypes.join(', ') || '—'} - + {cat.status} + {cat.mediaTypes.join(', ') || '—'} ))}