220 lines
8.5 KiB
TypeScript
220 lines
8.5 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { CheckCircle, XCircle, Loader } from 'lucide-react'
|
|
import { getSettings, postSettings, testNewbook } from '../api'
|
|
import type { AppSettings } from '../types'
|
|
|
|
type TestState = 'idle' | 'testing' | 'ok' | 'error'
|
|
|
|
export function Settings() {
|
|
const [form, setForm] = useState<AppSettings | null>(null)
|
|
const [saving, setSaving] = useState(false)
|
|
const [saved, setSaved] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [testState, setTest] = useState<TestState>('idle')
|
|
const [testMsg, setTestMsg] = useState('')
|
|
|
|
useEffect(() => {
|
|
getSettings()
|
|
.then(s => setForm(s))
|
|
.catch(e => setError(e.message))
|
|
}, [])
|
|
|
|
function patch(key: keyof AppSettings, value: string) {
|
|
setForm(prev => prev ? { ...prev, [key]: value } : prev)
|
|
}
|
|
|
|
async function save(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
if (!form) return
|
|
setSaving(true)
|
|
setError(null)
|
|
setSaved(false)
|
|
try {
|
|
const updated = await postSettings(form)
|
|
setForm(updated)
|
|
setSaved(true)
|
|
setTimeout(() => setSaved(false), 3000)
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Save failed')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
async function runTest() {
|
|
setTest('testing')
|
|
setTestMsg('')
|
|
try {
|
|
const r = await testNewbook()
|
|
setTest(r.ok ? 'ok' : 'error')
|
|
setTestMsg(r.message || r.error || '')
|
|
} catch (e) {
|
|
setTest('error')
|
|
setTestMsg(e instanceof Error ? e.message : 'Connection failed')
|
|
}
|
|
}
|
|
|
|
if (!form) {
|
|
return (
|
|
<div style={{ padding: '2rem', color: 'var(--text-mid)', fontSize: '0.875rem' }}>
|
|
{error ? `Error: ${error}` : 'Loading…'}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const inputStyle: React.CSSProperties = {
|
|
width: '100%', border: '1px solid var(--card-border)', borderRadius: '6px',
|
|
padding: '0.45rem 0.6rem', fontSize: '0.85rem', color: 'var(--text-dark)',
|
|
background: 'var(--body-bg)',
|
|
}
|
|
|
|
return (
|
|
<div style={{ padding: '1.25rem', maxWidth: '640px' }}>
|
|
<h2 style={{ fontSize: '1.1rem', fontWeight: 700, color: 'var(--text-dark)', marginBottom: '1.25rem' }}>
|
|
Settings
|
|
</h2>
|
|
|
|
<form onSubmit={save}>
|
|
{/* Twin detection */}
|
|
<div className="settings-section">
|
|
<h3>Twin Detection</h3>
|
|
|
|
<div className="field-group">
|
|
<label className="field-label">Custom field names (comma-separated)</label>
|
|
<input
|
|
className="field-input"
|
|
type="text"
|
|
value={form.custom_field_names}
|
|
onChange={e => patch('custom_field_names', e.target.value)}
|
|
placeholder="Bed Type, Room Configuration"
|
|
/>
|
|
<p className="field-hint">NewBook booking custom field names to check for twin indicators.</p>
|
|
</div>
|
|
|
|
<div className="field-group">
|
|
<label className="field-label">Values to detect (comma-separated)</label>
|
|
<input
|
|
className="field-input"
|
|
type="text"
|
|
value={form.custom_field_values}
|
|
onChange={e => patch('custom_field_values', e.target.value)}
|
|
placeholder="twin, 2 x single, 2x single"
|
|
/>
|
|
<p className="field-hint">Case-insensitive partial match against the field values above. These trigger a confirmed twin.</p>
|
|
</div>
|
|
|
|
<div className="field-group">
|
|
<label className="field-label">Notes search terms (comma-separated)</label>
|
|
<input
|
|
className="field-input"
|
|
type="text"
|
|
value={form.notes_search_terms}
|
|
onChange={e => patch('notes_search_terms', e.target.value)}
|
|
placeholder="twin bed, two singles, separate beds"
|
|
/>
|
|
<p className="field-hint">If no custom field match is found, these are searched in booking notes. Triggers a potential twin (amber).</p>
|
|
</div>
|
|
|
|
<div className="field-group" style={{ marginBottom: 0 }}>
|
|
<label className="field-label">Excluded terms (comma-separated, case-sensitive)</label>
|
|
<input
|
|
className="field-input"
|
|
type="text"
|
|
value={form.excluded_terms}
|
|
onChange={e => patch('excluded_terms', e.target.value)}
|
|
placeholder="Double or Twin:, Suite or Twin:"
|
|
/>
|
|
<p className="field-hint">Removed from notes before searching — use to strip ambiguous boilerplate like "Double or Twin:" so it doesn't trigger a false positive.</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Colors */}
|
|
<div className="settings-section">
|
|
<h3>Display colours</h3>
|
|
<div className="color-row">
|
|
<div className="color-field">
|
|
<input
|
|
type="color"
|
|
className="color-input"
|
|
value={form.normal_color}
|
|
onChange={e => patch('normal_color', e.target.value)}
|
|
/>
|
|
<span className="color-label">Normal booking</span>
|
|
</div>
|
|
<div className="color-field">
|
|
<input
|
|
type="color"
|
|
className="color-input"
|
|
value={form.twin_color}
|
|
onChange={e => patch('twin_color', e.target.value)}
|
|
/>
|
|
<span className="color-label">Confirmed twin</span>
|
|
</div>
|
|
<div className="color-field">
|
|
<input
|
|
type="color"
|
|
className="color-input"
|
|
value={form.potential_twin_color}
|
|
onChange={e => patch('potential_twin_color', e.target.value)}
|
|
/>
|
|
<span className="color-label">Potential twin</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Save */}
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
|
<button
|
|
type="submit"
|
|
disabled={saving}
|
|
style={{
|
|
background: 'var(--app-color)', color: '#fff', border: 'none',
|
|
borderRadius: '6px', padding: '0.5rem 1.25rem', fontSize: '0.875rem', fontWeight: 600,
|
|
}}
|
|
>
|
|
{saving ? 'Saving…' : 'Save settings'}
|
|
</button>
|
|
{saved && (
|
|
<span style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', color: '#16a34a', fontSize: '0.875rem' }}>
|
|
<CheckCircle size={15} /> Saved
|
|
</span>
|
|
)}
|
|
{error && <span style={{ color: '#dc2626', fontSize: '0.875rem' }}>{error}</span>}
|
|
</div>
|
|
</form>
|
|
|
|
{/* Newbook test */}
|
|
<div className="settings-section" style={{ marginTop: '1.5rem' }}>
|
|
<h3>Newbook connection</h3>
|
|
<p className="field-hint" style={{ marginBottom: '0.75rem' }}>
|
|
Credentials are configured in the Settings service. This tests the connection.
|
|
</p>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
|
<button
|
|
onClick={runTest}
|
|
disabled={testState === 'testing'}
|
|
type="button"
|
|
style={{
|
|
background: 'var(--navy)', color: 'var(--text)', border: '1px solid var(--surface-2)',
|
|
borderRadius: '6px', padding: '0.45rem 1rem', fontSize: '0.82rem', fontWeight: 600,
|
|
}}
|
|
>
|
|
{testState === 'testing' ? <Loader size={14} className="spin" /> : 'Test connection'}
|
|
</button>
|
|
{testState === 'ok' && <span style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', color: '#16a34a', fontSize: '0.82rem' }}><CheckCircle size={14} />{testMsg}</span>}
|
|
{testState === 'error' && <span style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', color: '#dc2626', fontSize: '0.82rem' }}><XCircle size={14} />{testMsg}</span>}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Detection legend */}
|
|
<div className="settings-section" style={{ marginTop: '1rem' }}>
|
|
<h3>How detection works</h3>
|
|
<ol style={{ fontSize: '0.82rem', color: 'var(--text-mid)', lineHeight: 1.6, paddingLeft: '1.1rem' }}>
|
|
<li><strong>Confirmed twin</strong> — custom field names matched against field values above (case-insensitive, partial match).</li>
|
|
<li><strong>Legacy fallback</strong> — "Bed Type" field containing "twin" or "2 x single" (always active).</li>
|
|
<li><strong>Potential twin</strong> — notes search terms found in booking notes, after excluded terms are stripped out.</li>
|
|
</ol>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|