Fix voice recording and add Excel column mapping verification

- 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 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-03-23 19:05:26 +00:00
parent 1b051f4d0d
commit 8882286146
8 changed files with 560 additions and 178 deletions

View file

@ -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/<client_id>/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/<client_id>/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)})

View file

@ -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

View file

@ -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<DetectMappingResult>('/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<DetectMappingResult>(`/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)
}

View file

@ -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<keyof ColumnMapping, string> = {
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<ColumnMapping>({ ...detection.mapping })
const setCol = (field: keyof ColumnMapping, idx: number) =>
setMapping(m => ({ ...m, [field]: idx }))
return (
<div className="rounded-xl overflow-hidden" style={{ border: '1px solid var(--accent)', background: 'var(--bg-card)' }}>
<div className="px-4 py-3" style={{ borderBottom: '1px solid var(--border)', background: 'rgba(255,196,7,0.06)' }}>
<div className="text-sm font-semibold" style={{ color: 'var(--accent)' }}>Confirm column mapping</div>
<div className="text-xs mt-0.5" style={{ color: 'var(--text-muted)' }}>
Verify which columns contain the data fields, then click Confirm.
</div>
</div>
<div className="p-4 space-y-3">
{(Object.keys(FIELD_LABELS) as (keyof ColumnMapping)[]).map(field => (
<div key={field} className="flex items-center gap-3">
<span className="text-xs w-44 flex-shrink-0" style={{ color: 'var(--text-secondary)' }}>
{FIELD_LABELS[field]}
</span>
<select
value={mapping[field]}
onChange={e => setCol(field, Number(e.target.value))}
className="flex-1 px-2 py-1.5 rounded text-xs outline-none"
style={{
background: 'var(--bg-elevated, #1a1a1a)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
}}
>
{detection.headers.map((h, i) => (
<option key={i} value={i}>{`Col ${i + 1}${h ? `${h}` : ''}`}</option>
))}
</select>
</div>
))}
</div>
{/* Sample preview */}
{detection.sample.length > 0 && (
<div className="px-4 pb-3">
<div className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-muted)' }}>
Sample (first {detection.sample.length} rows)
</div>
<div className="rounded overflow-hidden" style={{ border: '1px solid var(--border)' }}>
<table className="w-full text-xs border-collapse">
<thead style={{ background: '#1a1a1a' }}>
<tr>
<th className="px-3 py-1.5 text-left" style={{ color: 'var(--accent)' }}>Category</th>
<th className="px-3 py-1.5 text-left" style={{ color: 'var(--accent)' }}>Status</th>
<th className="px-3 py-1.5 text-left" style={{ color: 'var(--accent)' }}>Media Types</th>
</tr>
</thead>
<tbody>
{detection.sample.map((row, i) => (
<tr key={i} style={{ borderTop: '1px solid var(--border)' }}>
<td className="px-3 py-1.5" style={{ color: 'var(--text-primary)' }}>{row.name}</td>
<td className="px-3 py-1.5" style={{ color: row.status === 'Active' ? 'var(--accent)' : 'var(--text-muted)' }}>{row.status}</td>
<td className="px-3 py-1.5" style={{ color: 'var(--text-muted)' }}>{row.mediaTypes.join(', ') || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<div className="flex justify-end gap-2 px-4 pb-4">
<button
onClick={onCancel}
className="px-3 py-1.5 rounded text-xs"
style={{ border: '1px solid var(--border)', color: 'var(--text-muted)' }}
>
Cancel
</button>
<button
onClick={() => onConfirm(mapping)}
className="px-4 py-1.5 rounded text-xs font-medium"
style={{ background: 'var(--accent)', color: '#000' }}
>
Confirm mapping
</button>
</div>
</div>
)
}

View file

@ -26,7 +26,7 @@ export default function CommandBar({ onCommand, loading, yolo, onYoloChange }: P
const [input, setInput] = useState('')
const inputRef = useRef<HTMLInputElement>(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 && (
<button
onMouseDown={start}
onMouseUp={stop}
onClick={toggle}
className="w-9 h-9 rounded-lg flex items-center justify-center transition-colors"
style={{
background: listening ? 'var(--danger)' : 'var(--bg-card)',
color: listening ? '#fff' : 'var(--text-muted)',
border: '1px solid var(--border)',
border: `1px solid ${listening ? 'var(--danger)' : 'var(--border)'}`,
}}
title="Hold to speak"
title={listening ? 'Click to stop recording' : 'Click to speak'}
>
<IconMic active={listening} />
</button>

View file

@ -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<any>(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 }
}

View file

@ -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<string | null>(null)
const [previewFor, setPreviewFor] = useState<string | null>(null)
const [previewFile, setPreviewFile] = useState<File | null>(null)
const [preview, setPreview] = useState<CategoryData[] | null>(null)
const [applying, setApplying] = useState(false)
const [upload, setUpload] = useState<UploadState | null>(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() {
<h1 className="text-xl font-bold mb-1" style={{ color: 'var(--text-primary)' }}>Clients</h1>
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>
Manage clients and assign custom Category / Media dropdown files per client.
<br />
Each client can have its own <strong>.xlsx</strong> 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.
</p>
</div>
@ -142,21 +153,23 @@ export default function AdminClientsPage() {
<div className="text-sm">No clients yet. Create one above.</div>
</div>
) : (
<div className="space-y-3">
{clients.map(client => (
<ClientCard
key={client.id}
client={client}
onDelete={() => 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}
/>
))}
<div className="space-y-4">
{clients.map(client => {
const isActive = upload?.clientId === client.id
return (
<ClientCard
key={client.id}
client={client}
onDelete={() => handleDelete(client)}
onDrop={files => handleDropFile(client.id, files)}
onRemoveCustom={() => handleRemoveCustom(client.id)}
uploadState={isActive ? upload : null}
onMappingConfirm={handleMappingConfirm}
onApply={handleApply}
onCancel={() => setUpload(null)}
/>
)
})}
</div>
)}
</div>
@ -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 (
<div className="rounded-xl overflow-hidden" style={{ border: '1px solid var(--border)', background: 'var(--bg-card)' }}>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3" style={{ borderBottom: previewData ? '1px solid var(--border)' : undefined }}>
<div className="flex items-center justify-between px-4 py-3" style={{ borderBottom: '1px solid var(--border)' }}>
<div>
<div className="font-semibold text-sm" style={{ color: 'var(--text-primary)' }}>{client.name}</div>
<div className="text-xs mt-0.5" style={{ color: 'var(--text-muted)' }}>
@ -220,39 +236,59 @@ function ClientCard({ client, onDelete, onDrop, onRemoveCustom, uploading, previ
</div>
</div>
{/* Drop zone */}
<div
{...getRootProps()}
className="mx-4 my-3 rounded-lg p-4 text-center cursor-pointer transition-colors"
style={{
border: `1px dashed ${isDragActive ? 'var(--accent)' : 'var(--border)'}`,
background: isDragActive ? 'rgba(255,196,7,0.05)' : 'transparent',
}}
>
<input {...getInputProps()} />
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>
{uploading ? 'Parsing…' : isDragActive ? 'Drop .xlsx here' : 'Drop .xlsx or click to upload custom dropdown hierarchy'}
</p>
</div>
{/* Upload states */}
{!uploadState && (
<div
{...getRootProps()}
className="mx-4 my-3 rounded-lg p-4 text-center cursor-pointer transition-colors"
style={{
border: `1px dashed ${isDragActive ? 'var(--accent)' : 'var(--border)'}`,
background: isDragActive ? 'rgba(255,196,7,0.05)' : 'transparent',
}}
>
<input {...getInputProps()} />
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>
{isDragActive ? 'Drop .xlsx here' : 'Drop .xlsx or click to upload custom dropdown hierarchy'}
</p>
</div>
)}
{/* Preview */}
{previewData && (
{uploadState?.stage === 'mapping' && uploadState.busy && (
<div className="mx-4 my-3 text-xs text-center py-3" style={{ color: 'var(--text-muted)' }}>
Reading file
</div>
)}
{uploadState?.stage === 'mapping' && !uploadState.busy && uploadState.detection && (
<div className="mx-4 my-3">
<ColumnMappingStep
detection={uploadState.detection}
onConfirm={onMappingConfirm}
onCancel={onCancel}
/>
{uploadState.busy && (
<div className="text-xs text-center mt-2" style={{ color: 'var(--text-muted)' }}>Loading preview</div>
)}
</div>
)}
{uploadState?.stage === 'preview' && uploadState.preview && (
<div className="mx-4 mb-4">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
Preview {previewData.length} categories
Preview {uploadState.preview.length} categories
</span>
<div className="flex gap-2">
<button onClick={onCancelPreview} className="text-xs px-2 py-1 rounded" style={{ border: '1px solid var(--border)', color: 'var(--text-muted)' }}>
<button onClick={onCancel} className="text-xs px-2 py-1 rounded" style={{ border: '1px solid var(--border)', color: 'var(--text-muted)' }}>
Cancel
</button>
<button
onClick={onApply}
disabled={applying}
disabled={uploadState.busy}
className="text-xs px-3 py-1 rounded font-medium disabled:opacity-40"
style={{ background: 'var(--accent)', color: '#000' }}
>
{applying ? 'Saving…' : 'Apply'}
{uploadState.busy ? 'Saving…' : 'Apply'}
</button>
</div>
</div>
@ -261,13 +297,15 @@ function ClientCard({ client, onDelete, onDrop, onRemoveCustom, uploading, previ
<thead className="sticky top-0" style={{ background: '#1a1a1a' }}>
<tr>
<th className="px-3 py-1.5 text-left" style={{ color: 'var(--accent)' }}>Category</th>
<th className="px-3 py-1.5 text-left" style={{ color: 'var(--accent)' }}>Status</th>
<th className="px-3 py-1.5 text-left" style={{ color: 'var(--accent)' }}>Media Types</th>
</tr>
</thead>
<tbody>
{previewData.map(cat => (
{uploadState.preview.map(cat => (
<tr key={cat.name} style={{ borderTop: '1px solid var(--border)' }}>
<td className="px-3 py-1.5" style={{ color: 'var(--text-primary)' }}>{cat.name}</td>
<td className="px-3 py-1.5" style={{ color: cat.status === 'Active' ? 'var(--accent)' : 'var(--text-muted)' }}>{cat.status}</td>
<td className="px-3 py-1.5" style={{ color: 'var(--text-muted)' }}>{cat.mediaTypes.join(', ') || '—'}</td>
</tr>
))}

View file

@ -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<File | null>(null)
const [detection, setDetection] = useState<DetectMappingResult | null>(null)
const [confirmedMapping, setConfirmedMapping] = useState<ColumnMapping | null>(null)
const [preview, setPreview] = useState<CategoryData[] | null>(null)
const [previewFile, setPreviewFile] = useState<File | null>(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 (
<div className="max-w-4xl">
<div className="mb-6">
@ -62,30 +92,41 @@ export default function AdminDropdownsPage() {
</div>
{/* Upload zone */}
<div
{...getRootProps()}
className="rounded-xl p-8 text-center cursor-pointer mb-6 transition-colors"
style={{
border: `2px dashed ${isDragActive ? 'var(--accent)' : 'var(--border)'}`,
background: isDragActive ? 'rgba(255,196,7,0.05)' : 'var(--bg-card)',
}}
>
<input {...getInputProps()} />
<div className="text-3xl mb-3">📊</div>
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
{isDragActive ? 'Drop Excel file here' : 'Drag & drop an .xlsx file, or click to select'}
</p>
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>
Expected format: columns "Category" and "Media Type" (one row per category/media pair)
</p>
</div>
{previewing && (
<div className="text-sm text-center py-4" style={{ color: 'var(--text-muted)' }}>Parsing file</div>
{stage === null && (
<div
{...getRootProps()}
className="rounded-xl p-8 text-center cursor-pointer mb-6 transition-colors"
style={{
border: `2px dashed ${isDragActive ? 'var(--accent)' : 'var(--border)'}`,
background: isDragActive ? 'rgba(255,196,7,0.05)' : 'var(--bg-card)',
opacity: busy ? 0.6 : 1,
}}
>
<input {...getInputProps()} />
<div className="text-3xl mb-3">📊</div>
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
{busy ? 'Reading file…' : isDragActive ? 'Drop Excel file here' : 'Drag & drop an .xlsx file, or click to select'}
</p>
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>
Column mapping will be detected automatically and shown for confirmation
</p>
</div>
)}
{/* Preview */}
{preview && (
{/* Step 1 — Mapping confirmation */}
{stage === 'mapping' && detection && (
<div className="mb-6">
<ColumnMappingStep
detection={detection}
onConfirm={handleMappingConfirm}
onCancel={reset}
/>
{busy && <div className="text-xs text-center mt-3" style={{ color: 'var(--text-muted)' }}>Loading preview</div>}
</div>
)}
{/* Step 2 — Full preview */}
{stage === 'preview' && preview && (
<div className="mb-6">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
@ -93,7 +134,7 @@ export default function AdminDropdownsPage() {
</h2>
<div className="flex gap-2">
<button
onClick={() => { setPreview(null); setPreviewFile(null) }}
onClick={reset}
className="px-3 py-1.5 rounded text-xs"
style={{ border: '1px solid var(--border)', color: 'var(--text-muted)' }}
>
@ -101,11 +142,11 @@ export default function AdminDropdownsPage() {
</button>
<button
onClick={handleApply}
disabled={uploading}
disabled={busy}
className="px-4 py-1.5 rounded text-xs font-medium disabled:opacity-40"
style={{ background: 'var(--accent)', color: '#000' }}
>
{uploading ? 'Applying…' : 'Apply Changes'}
{busy ? 'Applying…' : 'Apply Changes'}
</button>
</div>
</div>
@ -114,6 +155,7 @@ export default function AdminDropdownsPage() {
<thead className="sticky top-0" style={{ background: '#1a1a1a' }}>
<tr>
<th className="px-3 py-2 text-left font-semibold uppercase tracking-wider" style={{ color: 'var(--accent)' }}>Category</th>
<th className="px-3 py-2 text-left font-semibold uppercase tracking-wider" style={{ color: 'var(--accent)' }}>Status</th>
<th className="px-3 py-2 text-left font-semibold uppercase tracking-wider" style={{ color: 'var(--accent)' }}>Media Types</th>
</tr>
</thead>
@ -121,9 +163,8 @@ export default function AdminDropdownsPage() {
{preview.map(cat => (
<tr key={cat.name} className="border-t" style={{ borderColor: 'var(--border)' }}>
<td className="px-3 py-2" style={{ color: 'var(--text-primary)' }}>{cat.name}</td>
<td className="px-3 py-2" style={{ color: 'var(--text-muted)' }}>
{cat.mediaTypes.join(', ') || '—'}
</td>
<td className="px-3 py-2" style={{ color: cat.status === 'Active' ? 'var(--accent)' : 'var(--text-muted)' }}>{cat.status}</td>
<td className="px-3 py-2" style={{ color: 'var(--text-muted)' }}>{cat.mediaTypes.join(', ') || '—'}</td>
</tr>
))}
</tbody>