Initial commit: twin-optimiser sub-app (housekeeping category)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-02 20:14:01 +00:00
commit a695bd843f
28 changed files with 1913 additions and 0 deletions

View 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>
)
}