From 33c9dbdb2be7bd87b241efc08f6640a1d97d7e90 Mon Sep 17 00:00:00 2001 From: schakal00783 Date: Tue, 29 Sep 2026 12:37:56 +0200 Subject: [PATCH] =?UTF-8?q?Suspects:=20Eingabezeile=20oben,=20Letzter=20Ko?= =?UTF-8?q?ntakt=20standardm=C3=A4=C3=9Fig=20heute?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Eingabezeile über der Liste, mit Abstand und Knopf Hinzufügen; Liste neueste zuerst, neue Einträge erscheinen direkt darunter - Letzter Kontakt neuer Zeilen = heute (Ortszeit), auch serverseitig, wenn das Feld beim Anlegen fehlt Co-Authored-By: Claude Opus 5.5 --- docs/08-anleitung-benutzer.md | 5 +- docs/13-suspects.md | 10 ++- server/src/routes/suspects.js | 9 +- src/components/suspects/Suspects.tsx | 124 +++++++++++++++++---------- 4 files changed, 97 insertions(+), 51 deletions(-) diff --git a/docs/08-anleitung-benutzer.md b/docs/08-anleitung-benutzer.md index 2973169..1d96739 100644 --- a/docs/08-anleitung-benutzer.md +++ b/docs/08-anleitung-benutzer.md @@ -33,8 +33,9 @@ die zugehörige Karte. Personen, die interessant sein könnten – bevor daraus ein Lead wird. Technik siehe [13 Suspects](13-suspects.md). -1. **Erfassen:** in die leere Zeile unten schreiben (Name ist Pflicht). Die Zeile - speichert, sobald du sie verlässt oder Enter drückst. Vorhandene Zellen +1. **Erfassen:** in die Eingabezeile ganz oben schreiben (Name ist Pflicht, + *Letzter Kontakt* steht schon auf heute). Enter oder *Hinzufügen* speichert; + der neue Eintrag erscheint direkt darunter. Vorhandene Zellen einfach anklicken und ändern. 2. **Links:** Mail, LinkedIn und Website werden nach dem Speichern zum Symbol. Die Adresse steht im Tooltip, ein Klick öffnet sie (Mail: das Mailprogramm). diff --git a/docs/13-suspects.md b/docs/13-suspects.md index 0997eb2..bb228ef 100644 --- a/docs/13-suspects.md +++ b/docs/13-suspects.md @@ -25,11 +25,13 @@ flowchart LR | LinkedIn | `linkedin` | nach dem Speichern nur ein Symbol, URL im Tooltip | | Website | `website` | ebenso; fehlt `https://`, ergänzt es der Server | | Notiz | `notes` | einzeilig, volle Länge im Tooltip | -| Letzter Kontakt | `last_contact_on` | Datum | +| Letzter Kontakt | `last_contact_on` | Datum; neue Zeilen stehen auf **heute** (änderbar per Kalender). Auch die API setzt heute, wenn das Feld fehlt – ein bewusst geleertes Feld (`null`) bleibt leer | | Nächster Impuls | `remind_at` | Wecker: Anzahl Tage eingeben, der Server rechnet das Datum | -**Bedienung wie eine Liste.** Unten steht immer eine leere Zeile. Gespeichert -wird, sobald der Fokus die Zeile verlässt oder Enter gedrückt wird. Vorhandene +**Bedienung wie eine Liste.** Oben steht immer eine Eingabezeile, mit etwas +Abstand zur Liste darunter; die Liste zeigt die neuesten Einträge zuerst, ein +neuer landet also direkt unter der Eingabe. Gespeichert wird mit Enter, dem +Knopf **Hinzufügen** oder sobald der Fokus die Zeile verlässt. Vorhandene Zellen speichern beim Verlassen der Zelle; Esc verwirft die Eingabe. Links bearbeitet man über den kleinen Stift, der beim Überfahren neben dem Symbol erscheint. @@ -98,7 +100,7 @@ Alle unter `/api/suspects`, alle hinter `requireAuth`. | Methode | Pfad | Zweck | | --- | --- | --- | -| GET | `/?archived=0\|1` | Liste; aktiv älteste zuerst, Archiv neueste zuerst | +| GET | `/?archived=0\|1` | Liste; aktiv neueste Anlage zuerst, Archiv zuletzt geändert zuerst | | GET | `/reminders` | fällige Impulse (`remind_at <= NOW()`, nicht archiviert) | | POST | `/` | anlegen, `name` Pflicht | | PATCH | `/:id` | beliebige Felder; `remind_in_days` (Zahl oder `null`), `archived`, `lead_id` | diff --git a/server/src/routes/suspects.js b/server/src/routes/suspects.js index c97d527..3450db1 100644 --- a/server/src/routes/suspects.js +++ b/server/src/routes/suspects.js @@ -46,6 +46,9 @@ function readBody(body) { return out; } +/** Heute in deutscher Zeit als YYYY-MM-DD (sv-SE formatiert genau so). */ +const localDate = () => new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' }); + const inDays = (days) => new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 19).replace('T', ' '); const SELECT = ` @@ -58,8 +61,8 @@ const normalize = (row) => ({ ...row, archived: Boolean(row.archived) }); router.get('/', async (req, res, next) => { const archived = String(req.query.archived ?? '0') === '1'; try { - // Neueste unten: die Tabelle waechst wie eine Liste nach unten. - const [rows] = await pool.query(`${SELECT} WHERE s.archived = ? ORDER BY ${archived ? 's.updated_at DESC' : 's.id'}`, [ + // Neueste oben - direkt unter der Eingabezeile, die oben steht. + const [rows] = await pool.query(`${SELECT} WHERE s.archived = ? ORDER BY ${archived ? 's.updated_at DESC' : 's.id DESC'}`, [ archived ? 1 : 0, ]); res.json({ suspects: rows.map(normalize) }); @@ -83,6 +86,8 @@ router.get('/reminders', async (req, res, next) => { router.post('/', async (req, res, next) => { const data = readBody(req.body || {}); if (!data.name) return res.status(400).json({ error: 'Der Name ist erforderlich.' }); + // Ohne Angabe gilt heute als letzter Kontakt; ein bewusst leeres Feld (null) bleibt leer. + if (req.body?.last_contact_on === undefined) data.last_contact_on = localDate(); try { const columns = Object.keys(data); const [result] = await pool.query( diff --git a/src/components/suspects/Suspects.tsx b/src/components/suspects/Suspects.tsx index 2784683..5e8d360 100644 --- a/src/components/suspects/Suspects.tsx +++ b/src/components/suspects/Suspects.tsx @@ -9,8 +9,9 @@ import { useToast } from '../Toast'; /* * Suspects - die Stufe vor dem Lead. * - * Eine Tabelle, die sich wie eine Liste füllt: unten steht immer eine leere - * Zeile, jede Zelle ist direkt bearbeitbar und speichert beim Verlassen. + * Eine Tabelle, die sich wie eine Liste füllt: oben steht immer eine + * Eingabezeile, abgesetzt von der Liste darunter (neueste zuerst). Jede Zelle + * der Liste ist direkt bearbeitbar und speichert beim Verlassen. * * Links (LinkedIn, Website) werden nach dem Speichern zu einem Symbol - die * Adresse steht im Tooltip. Das spart die Breite, die eine URL sonst frisst. @@ -47,12 +48,19 @@ const EMPTY_DRAFT: Record & { last_contact_on: string } = { const cellInput = 'w-full min-w-0 rounded border border-transparent bg-transparent px-2 py-1.5 text-sm text-ink-900 placeholder:text-ink-300 hover:border-ink-200 focus:border-brand-400 focus:bg-surface focus:outline-none'; -const today = () => new Date().toISOString().slice(0, 10); +/** Heute in Ortszeit - toISOString waere UTC und laege kurz nach Mitternacht einen Tag zurueck. */ +const today = () => { + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; +}; + +/** Neue Zeile: "Letzter Kontakt" steht auf heute - meist erfasst man jemanden direkt nach dem Gespräch. */ +const freshDraft = () => ({ ...EMPTY_DRAFT, last_contact_on: today() }); export const Suspects: React.FC = ({ archived, companies, contacts, users, onChanged }) => { const { toast } = useToast(); const [rows, setRows] = useState(null); - const [draft, setDraft] = useState({ ...EMPTY_DRAFT }); + const [draft, setDraft] = useState(freshDraft); const [impulse, setImpulse] = useState(null); const [toLead, setToLead] = useState(null); const [removing, setRemoving] = useState(null); @@ -89,7 +97,9 @@ export const Suspects: React.FC = ({ archived, companies, contact /** Neue Zeile anlegen - sobald die Zeile verlassen oder Enter gedrückt wird. */ const createDraft = async () => { - const hasContent = Object.values(draft).some((value) => value.trim()); + // Das vorbelegte Datum allein zaehlt nicht als Eingabe. + const { last_contact_on: _date, ...texts } = draft; + const hasContent = Object.values(texts).some((value) => value.trim()); if (!hasContent) return; if (!draft.name.trim()) { toast('Bitte einen Namen eintragen – dann wird die Zeile gespeichert.', 'error'); @@ -100,8 +110,8 @@ export const Suspects: React.FC = ({ archived, companies, contact ...draft, last_contact_on: draft.last_contact_on || null, }); - setRows((list) => [...(list ?? []), suspect]); - setDraft({ ...EMPTY_DRAFT }); + setRows((list) => [suspect, ...(list ?? [])]); + setDraft(freshDraft()); onChanged(); requestAnimationFrame(() => draftName.current?.focus()); } catch (err) { @@ -158,7 +168,7 @@ export const Suspects: React.FC = ({ archived, companies, contact

{archived ? 'Archivierte Suspects und solche, aus denen ein Lead wurde. Zurückholen oder endgültig löschen.' - : 'Personen, die interessant sein könnten. Einfach in die leere Zeile unten schreiben – gespeichert wird beim Verlassen der Zeile oder mit Enter.'} + : 'Personen, die interessant sein könnten. Oben in die Eingabezeile schreiben – gespeichert wird mit Enter, „Hinzufügen“ oder beim Verlassen der Zeile.'}

@@ -189,6 +199,65 @@ export const Suspects: React.FC = ({ archived, companies, contact + + {/* + Eingabezeile steht immer oben, abgesetzt von der Liste - neue + Einträge erscheinen direkt darunter (Liste: neueste zuerst). + */} + {!archived && ( + + { + // Erst speichern, wenn der Fokus die Zeile ganz verlässt. + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) void createDraft(); + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + void createDraft(); + } + }} + > + {(['name', 'role', 'company', 'email', 'linkedin', 'website', 'notes'] as const).map((field) => ( + + setDraft({ ...draft, [field]: event.target.value })} + placeholder={DRAFT_PLACEHOLDERS[field]} + className={`${cellInput} border-ink-200 bg-surface`} + /> + + ))} + + setDraft({ ...draft, last_contact_on: event.target.value })} + className={`${cellInput} border-ink-200 bg-surface`} + /> + + + + + + + {/* Abstand zwischen Eingabe und Liste */} + + + + + )} + {rows.map((row) => archived ? ( @@ -244,42 +313,11 @@ export const Suspects: React.FC = ({ archived, companies, contact ) )} - {!archived && ( - { - // Erst speichern, wenn der Fokus die Zeile ganz verlässt. - if (!event.currentTarget.contains(event.relatedTarget as Node | null)) void createDraft(); - }} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault(); - void createDraft(); - } - }} - > - {(['name', 'role', 'company', 'email', 'linkedin', 'website', 'notes'] as const).map((field) => ( - - setDraft({ ...draft, [field]: event.target.value })} - placeholder={DRAFT_PLACEHOLDERS[field]} - className={cellInput} - /> - - ))} - - setDraft({ ...draft, last_contact_on: event.target.value })} - className={cellInput} - /> + {!archived && rows.length === 0 && ( + + + Noch keine Suspects – oben in die Eingabezeile schreiben. - - )}