Initial commit: twin-optimiser sub-app (housekeeping category)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
a695bd843f
28 changed files with 1913 additions and 0 deletions
220
frontend/src/pages/Settings.tsx
Normal file
220
frontend/src/pages/Settings.tsx
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue