- Wohnorte als Liste mit "seit"-Datum (Tabelle travel_homes, ersetzt den einzelnen Wohnort; Migration Schritt 5 übernimmt ihn): Reisen starten beim damals gültigen Wohnort, Wechsel zählen als Umzug, Tage am Wohnort nicht als Reisetage - "In dieser Zeit hier gewohnt" im Besuch: legt Wohnort ab Beginn und den vorherigen wieder ab Ende + 1 Tag an - Anreise je Besuch (automatisch / von zuhause / vom vorherigen Ort): Rundreisen auch mit mehreren Tagen zwischen den Orten - Fix: Besuche bleiben bearbeitbar, auch wenn eine Fotodatei fehlt; Speicherfehler erscheinen im Dialog statt verdeckt dahinter Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
122 lines
5.6 KiB
React
122 lines
5.6 KiB
React
import { useRef, useState } from 'react';
|
||
import { imgUrl, uploadImage } from '../../api.js';
|
||
|
||
/**
|
||
* Besuch einer Stadt eintragen oder bearbeiten: Zeitraum, Begleitung, Notizen, Fotos.
|
||
* Fotos werden beim Hochladen auf dem Server von Metadaten (GPS usw.) befreit.
|
||
* onSave(body) / onDelete() liefern bei Erfolg den neuen Stand (truthy).
|
||
*/
|
||
// pageError: Fehlermeldung der Seite (z. B. vom Speichern) – im Dialog anzeigen, sonst läge sie verdeckt dahinter
|
||
export default function VisitDialog({ ref, city, visit, onSave, onDelete, onLivedThere, busy, pageError }) {
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
const [form, setForm] = useState(() => ({
|
||
dateFrom: visit?.dateFrom || today,
|
||
dateTo: visit?.dateTo || '',
|
||
companions: visit?.companions || '',
|
||
notes: visit?.notes || '',
|
||
photos: visit?.photos || [],
|
||
// Anreise: '' automatisch, 'home' von zuhause, 'continued' vom vorherigen Ort weitergereist (Rundreise)
|
||
arrival: visit?.continued == null ? '' : visit.continued ? 'continued' : 'home',
|
||
}));
|
||
const [uploading, setUploading] = useState(0);
|
||
const [error, setError] = useState(null);
|
||
const fileInput = useRef(null);
|
||
const field = (name) => ({ value: form[name], onChange: (e) => setForm((f) => ({ ...f, [name]: e.target.value })) });
|
||
|
||
async function addPhotos(files) {
|
||
setError(null);
|
||
for (const file of files) {
|
||
setUploading((n) => n + 1);
|
||
try {
|
||
const name = await uploadImage(file);
|
||
setForm((f) => ({ ...f, photos: [...f.photos, name] }));
|
||
} catch (e) {
|
||
setError(`${file.name}: ${e.message}`);
|
||
} finally {
|
||
setUploading((n) => n - 1);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function submit(e) {
|
||
e.preventDefault();
|
||
const { arrival, ...rest } = form;
|
||
const continued = arrival === '' ? null : arrival === 'continued';
|
||
if (await onSave({ ...rest, dateTo: form.dateTo || null, continued })) ref.current.close();
|
||
}
|
||
|
||
async function remove() {
|
||
if (!window.confirm(`Besuch in ${city.name} löschen?`)) return;
|
||
if (await onDelete()) ref.current.close();
|
||
}
|
||
|
||
return (
|
||
<dialog className="modal task-dialog" ref={ref}>
|
||
<form className="form" onSubmit={submit}>
|
||
<h2>{visit ? 'Besuch bearbeiten' : 'Besuch eintragen'} – {city.name}</h2>
|
||
<div className="task-dialog-row">
|
||
<label>
|
||
Von
|
||
<input type="date" {...field('dateFrom')} required max={today} />
|
||
</label>
|
||
<label>
|
||
Bis
|
||
<input type="date" {...field('dateTo')} min={form.dateFrom} aria-describedby="bis-hint" />
|
||
</label>
|
||
</div>
|
||
<p className="hint visit-hint" id="bis-hint">„Bis“ leer lassen für einen einzelnen Tag.</p>
|
||
<label>
|
||
Anreise
|
||
<select {...field('arrival')}>
|
||
<option value="">Automatisch (höchstens 1 Tag nach dem vorigen Besuch = Weiterreise)</option>
|
||
<option value="home">Von zuhause</option>
|
||
<option value="continued">Vom vorherigen Ort weitergereist (Rundreise)</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Begleitung <small>(optional)</small>
|
||
<input {...field('companions')} maxLength={200} placeholder="z. B. Anna, Ben" />
|
||
</label>
|
||
<label>
|
||
Notizen <small>(optional)</small>
|
||
<textarea {...field('notes')} rows={4} maxLength={5000} placeholder="Was war schön? Tipps fürs nächste Mal …" />
|
||
</label>
|
||
<fieldset className="link-picker">
|
||
<legend>Fotos <small>({form.photos.length})</small></legend>
|
||
{form.photos.length > 0 && (
|
||
<ul className="visit-photos">
|
||
{form.photos.map((p) => (
|
||
<li key={p}>
|
||
<img src={imgUrl(p)} alt="" loading="lazy" />
|
||
<button type="button" className="link-btn danger" onClick={() => setForm((f) => ({ ...f, photos: f.photos.filter((x) => x !== p) }))} aria-label="Foto entfernen">✕</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
<input ref={fileInput} type="file" accept="image/jpeg,image/png,image/webp,image/gif" multiple hidden
|
||
onChange={(e) => { addPhotos([...e.target.files]); e.target.value = ''; }} />
|
||
<button className="btn ghost" type="button" onClick={() => fileInput.current.click()} disabled={uploading > 0}>
|
||
{uploading ? `Lade ${uploading} Foto${uploading === 1 ? '' : 's'} hoch …` : '+ Fotos hinzufügen'}
|
||
</button>
|
||
<p className="hint">Standort, Kamera und Aufnahmezeit werden beim Hochladen aus den Fotos entfernt.</p>
|
||
</fieldset>
|
||
{visit?.dateTo && visit.dateTo !== visit.dateFrom && (
|
||
<p className="hint visit-lived">
|
||
Längerer Aufenthalt, z. B. Auslandssemester?{' '}
|
||
<button className="link-btn" type="button" disabled={busy} onClick={async () => { if (await onLivedThere(visit)) ref.current.close(); }}>
|
||
⌂ In dieser Zeit hier gewohnt
|
||
</button>
|
||
{' '}– macht {city.name} für diesen Zeitraum zum Wohnort (Strecken als Umzug, keine Reisetage).
|
||
</p>
|
||
)}
|
||
{(error || pageError) && <p className="error" role="alert">{error || pageError}</p>}
|
||
<div className="form-actions">
|
||
{visit && <button className="link-btn danger task-delete" type="button" disabled={busy} onClick={remove}>Löschen</button>}
|
||
<button className="btn ghost" type="button" onClick={() => ref.current.close()}>Abbrechen</button>
|
||
<button className="btn" type="submit" disabled={busy || uploading > 0}>{visit ? 'Speichern' : 'Eintragen'}</button>
|
||
</div>
|
||
</form>
|
||
</dialog>
|
||
);
|
||
}
|