Files
dashboards/apps/business/src/components/TechnicalFormModal.tsx
T

366 lines
12 KiB
TypeScript

import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Plus, Sparkles, Trash2, X } from 'lucide-react'
import type { TechnicalFieldType, TechnicalFormFieldDraft } from '../types/technicalForm'
import { createId } from '../utils/id'
import styles from './VariationsModal.module.css'
import fieldStyles from './TechnicalFormModal.module.css'
import aiStyles from '../styles/ai.module.css'
interface TechnicalFormModalProps {
open: boolean
categoryName: string
fields: TechnicalFormFieldDraft[]
isLoading?: boolean
isSaving?: boolean
isGenerating?: boolean
error?: string
onClose: () => void
onChange: (fields: TechnicalFormFieldDraft[]) => void
onSave: () => void
onGenerate?: () => void
onGenerateClick?: () => void
}
const ANIMATION_MS = 220
const FIELD_TYPE_LABELS: Record<TechnicalFieldType, string> = {
text: 'Text',
textarea: 'Textarea',
select: 'Select',
multi_select: 'Multi',
}
function createEmptyField(): TechnicalFormFieldDraft {
return {
id: createId(),
label: '',
type: 'text',
isRequired: false,
options: [''],
}
}
export function TechnicalFormModal({
open,
categoryName,
fields,
isLoading = false,
isSaving = false,
isGenerating = false,
error = '',
onClose,
onChange,
onSave,
onGenerate,
onGenerateClick,
}: TechnicalFormModalProps) {
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const optionInputRefs = useRef<Record<string, (HTMLInputElement | null)[]>>({})
const pendingFocus = useRef<{ fieldId: string; index: number } | null>(null)
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted])
useEffect(() => {
if (!pendingFocus.current) return
const { fieldId, index } = pendingFocus.current
optionInputRefs.current[fieldId]?.[index]?.focus()
pendingFocus.current = null
}, [fields])
useEffect(() => {
if (!mounted || closing) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [mounted, closing, onClose])
if (!mounted) return null
function updateField(fieldId: string, patch: Partial<TechnicalFormFieldDraft>) {
onChange(
fields.map((field) => (field.id === fieldId ? { ...field, ...patch } : field)),
)
}
function removeField(fieldId: string) {
onChange(fields.filter((field) => field.id !== fieldId))
}
function addField() {
onChange([...fields, createEmptyField()])
}
function updateOption(fieldId: string, index: number, value: string) {
onChange(
fields.map((field) => {
if (field.id !== fieldId) return field
return {
...field,
options: field.options.map((option, optionIndex) =>
optionIndex === index ? value : option,
),
}
}),
)
}
function addOption(fieldId: string) {
onChange(
fields.map((field) =>
field.id === fieldId ? { ...field, options: [...field.options, ''] } : field,
),
)
}
function removeOption(fieldId: string, index: number) {
onChange(
fields.map((field) => {
if (field.id !== fieldId) return field
const next = field.options.filter((_, optionIndex) => optionIndex !== index)
return { ...field, options: next.length ? next : [''] }
}),
)
}
function handleOptionKeyDown(
fieldId: string,
index: number,
e: React.KeyboardEvent<HTMLInputElement>,
options: string[],
) {
if (e.key !== 'Enter') return
e.preventDefault()
const nextIndex = index + 1
if (nextIndex < options.length) {
optionInputRefs.current[fieldId]?.[nextIndex]?.focus()
return
}
pendingFocus.current = { fieldId, index: nextIndex }
addOption(fieldId)
}
const canSave =
!isLoading &&
!isSaving &&
!isGenerating &&
fields.every((field) => {
if (!field.label.trim()) return false
if (field.type === 'select' || field.type === 'multi_select') {
return field.options.some((option) => option.trim())
}
return true
})
return createPortal(
<div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose}
>
<div
className={`${styles.modal} ${fieldStyles.modalWide} ${closing ? styles.modalOut : styles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="technical-form-title"
>
<div className={styles.header}>
<div>
<h3 id="technical-form-title" className={styles.title}>
Technical Data Form
</h3>
{categoryName && <p className={styles.subtitle}>{categoryName}</p>}
</div>
<button className={styles.closeBtn} onClick={onClose} aria-label="Close">
<X size={20} />
</button>
</div>
<div className={styles.body}>
{isLoading ? (
<p className={styles.emptyText}>Loading form...</p>
) : (
<>
<div className={fieldStyles.fieldList}>
{fields.map((field) => (
<div key={field.id} className={fieldStyles.fieldCard}>
<div className={fieldStyles.fieldMainRow}>
<input
id={`label-${field.id}`}
type="text"
className={fieldStyles.compactInput}
placeholder="Field label"
value={field.label}
onChange={(e) => updateField(field.id, { label: e.target.value })}
aria-label="Field label"
/>
<select
id={`type-${field.id}`}
className={fieldStyles.compactInput}
value={field.type}
aria-label="Input type"
onChange={(e) => {
const type = e.target.value as TechnicalFieldType
updateField(field.id, {
type,
options:
type === 'select' || type === 'multi_select'
? field.options.length
? field.options
: ['']
: [],
})
}}
>
{Object.entries(FIELD_TYPE_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<label className={fieldStyles.requiredCell} title="Required">
<input
type="checkbox"
checked={field.isRequired}
onChange={(e) =>
updateField(field.id, { isRequired: e.target.checked })
}
/>
<span>Required</span>
</label>
<div className={fieldStyles.removeCell}>
<button
type="button"
className={styles.removeRowBtn}
onClick={() => removeField(field.id)}
aria-label="Remove field"
>
<Trash2 size={15} />
</button>
</div>
</div>
{(field.type === 'select' || field.type === 'multi_select') && (
<div className={fieldStyles.optionsBlock}>
<span className={fieldStyles.optionsLabel}>Options</span>
<div className={fieldStyles.customValues}>
{field.options.map((option, optionIndex) => (
<div key={optionIndex} className={fieldStyles.customRow}>
<input
ref={(el) => {
if (!optionInputRefs.current[field.id]) {
optionInputRefs.current[field.id] = []
}
optionInputRefs.current[field.id][optionIndex] = el
}}
type="text"
placeholder={`Option ${optionIndex + 1}`}
value={option}
onChange={(e) =>
updateOption(field.id, optionIndex, e.target.value)
}
onKeyDown={(e) =>
handleOptionKeyDown(field.id, optionIndex, e, field.options)
}
/>
{field.options.length > 1 && (
<div className={fieldStyles.removeCell}>
<button
type="button"
className={styles.removeRowBtn}
onClick={() => removeOption(field.id, optionIndex)}
aria-label="Remove option"
>
<X size={16} />
</button>
</div>
)}
{field.options.length <= 1 && <div />}
</div>
))}
<button
type="button"
className={styles.addValueBtn}
onClick={() => addOption(field.id)}
>
<Plus size={14} />
Add option
</button>
</div>
</div>
)}
</div>
))}
{!fields.length && (
<p className={styles.emptyText}>
No fields yet. Add text, textarea, select, or multi-select inputs.
</p>
)}
</div>
<div className={fieldStyles.toolbar}>
{(onGenerateClick ?? onGenerate) && (
<button
type="button"
className={aiStyles.aiBtn}
onClick={onGenerateClick ?? onGenerate}
disabled={isLoading || isSaving || isGenerating}
>
<Sparkles size={16} />
{isGenerating ? 'Generating…' : 'Generate with AI'}
</button>
)}
<button type="button" className={styles.addBtn} onClick={addField}>
<Plus size={18} />
Add field
</button>
</div>
</>
)}
{error && <p className={styles.errorText}>{error}</p>}
<div className={styles.actions}>
<button
type="button"
className={styles.cancelBtn}
onClick={onClose}
disabled={isSaving || isGenerating}
>
Cancel
</button>
<button
type="button"
className={styles.submitBtn}
onClick={onSave}
disabled={!canSave}
>
{isSaving ? 'Saving…' : 'Save form'}
</button>
</div>
</div>
</div>
</div>
,
document.body,
)
}