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:
parent
1b051f4d0d
commit
8882286146
8 changed files with 560 additions and 178 deletions
|
|
@ -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)})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
102
frontend/src/components/admin/ColumnMappingStep.tsx
Normal file
102
frontend/src/components/admin/ColumnMappingStep.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue