Suspects: Eingabezeile oben, Letzter Kontakt standardmäßig heute

- 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 <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-29 12:37:56 +02:00
co-authored by Claude Opus 5.5
parent 78d6bef52c
commit 33c9dbdb2b
4 changed files with 97 additions and 51 deletions
+3 -2
View File
@@ -33,8 +33,9 @@ die zugehörige Karte.
Personen, die interessant sein könnten – bevor daraus ein Lead wird. Technik Personen, die interessant sein könnten – bevor daraus ein Lead wird. Technik
siehe [13 Suspects](13-suspects.md). siehe [13 Suspects](13-suspects.md).
1. **Erfassen:** in die leere Zeile unten schreiben (Name ist Pflicht). Die Zeile 1. **Erfassen:** in die Eingabezeile ganz oben schreiben (Name ist Pflicht,
speichert, sobald du sie verlässt oder Enter drückst. Vorhandene Zellen *Letzter Kontakt* steht schon auf heute). Enter oder *Hinzufügen* speichert;
der neue Eintrag erscheint direkt darunter. Vorhandene Zellen
einfach anklicken und ändern. einfach anklicken und ändern.
2. **Links:** Mail, LinkedIn und Website werden nach dem Speichern zum Symbol. 2. **Links:** Mail, LinkedIn und Website werden nach dem Speichern zum Symbol.
Die Adresse steht im Tooltip, ein Klick öffnet sie (Mail: das Mailprogramm). Die Adresse steht im Tooltip, ein Klick öffnet sie (Mail: das Mailprogramm).
+6 -4
View File
@@ -25,11 +25,13 @@ flowchart LR
| LinkedIn | `linkedin` | nach dem Speichern nur ein Symbol, URL im Tooltip | | LinkedIn | `linkedin` | nach dem Speichern nur ein Symbol, URL im Tooltip |
| Website | `website` | ebenso; fehlt `https://`, ergänzt es der Server | | Website | `website` | ebenso; fehlt `https://`, ergänzt es der Server |
| Notiz | `notes` | einzeilig, volle Länge im Tooltip | | 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 | | 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 **Bedienung wie eine Liste.** Oben steht immer eine Eingabezeile, mit etwas
wird, sobald der Fokus die Zeile verlässt oder Enter gedrückt wird. Vorhandene 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 Zellen speichern beim Verlassen der Zelle; Esc verwirft die Eingabe. Links
bearbeitet man über den kleinen Stift, der beim Überfahren neben dem Symbol bearbeitet man über den kleinen Stift, der beim Überfahren neben dem Symbol
erscheint. erscheint.
@@ -98,7 +100,7 @@ Alle unter `/api/suspects`, alle hinter `requireAuth`.
| Methode | Pfad | Zweck | | 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) | | GET | `/reminders` | fällige Impulse (`remind_at <= NOW()`, nicht archiviert) |
| POST | `/` | anlegen, `name` Pflicht | | POST | `/` | anlegen, `name` Pflicht |
| PATCH | `/:id` | beliebige Felder; `remind_in_days` (Zahl oder `null`), `archived`, `lead_id` | | PATCH | `/:id` | beliebige Felder; `remind_in_days` (Zahl oder `null`), `archived`, `lead_id` |
+7 -2
View File
@@ -46,6 +46,9 @@ function readBody(body) {
return out; 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 inDays = (days) => new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 19).replace('T', ' ');
const SELECT = ` const SELECT = `
@@ -58,8 +61,8 @@ const normalize = (row) => ({ ...row, archived: Boolean(row.archived) });
router.get('/', async (req, res, next) => { router.get('/', async (req, res, next) => {
const archived = String(req.query.archived ?? '0') === '1'; const archived = String(req.query.archived ?? '0') === '1';
try { try {
// Neueste unten: die Tabelle waechst wie eine Liste nach unten. // 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'}`, [ const [rows] = await pool.query(`${SELECT} WHERE s.archived = ? ORDER BY ${archived ? 's.updated_at DESC' : 's.id DESC'}`, [
archived ? 1 : 0, archived ? 1 : 0,
]); ]);
res.json({ suspects: rows.map(normalize) }); res.json({ suspects: rows.map(normalize) });
@@ -83,6 +86,8 @@ router.get('/reminders', async (req, res, next) => {
router.post('/', async (req, res, next) => { router.post('/', async (req, res, next) => {
const data = readBody(req.body || {}); const data = readBody(req.body || {});
if (!data.name) return res.status(400).json({ error: 'Der Name ist erforderlich.' }); 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 { try {
const columns = Object.keys(data); const columns = Object.keys(data);
const [result] = await pool.query( const [result] = await pool.query(
+81 -43
View File
@@ -9,8 +9,9 @@ import { useToast } from '../Toast';
/* /*
* Suspects - die Stufe vor dem Lead. * Suspects - die Stufe vor dem Lead.
* *
* Eine Tabelle, die sich wie eine Liste füllt: unten steht immer eine leere * Eine Tabelle, die sich wie eine Liste füllt: oben steht immer eine
* Zeile, jede Zelle ist direkt bearbeitbar und speichert beim Verlassen. * 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 * Links (LinkedIn, Website) werden nach dem Speichern zu einem Symbol - die
* Adresse steht im Tooltip. Das spart die Breite, die eine URL sonst frisst. * Adresse steht im Tooltip. Das spart die Breite, die eine URL sonst frisst.
@@ -47,12 +48,19 @@ const EMPTY_DRAFT: Record<TextField, string> & { last_contact_on: string } = {
const cellInput = 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'; '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<SuspectsProps> = ({ archived, companies, contacts, users, onChanged }) => { export const Suspects: React.FC<SuspectsProps> = ({ archived, companies, contacts, users, onChanged }) => {
const { toast } = useToast(); const { toast } = useToast();
const [rows, setRows] = useState<Suspect[] | null>(null); const [rows, setRows] = useState<Suspect[] | null>(null);
const [draft, setDraft] = useState({ ...EMPTY_DRAFT }); const [draft, setDraft] = useState(freshDraft);
const [impulse, setImpulse] = useState<Suspect | null>(null); const [impulse, setImpulse] = useState<Suspect | null>(null);
const [toLead, setToLead] = useState<Suspect | null>(null); const [toLead, setToLead] = useState<Suspect | null>(null);
const [removing, setRemoving] = useState<Suspect | null>(null); const [removing, setRemoving] = useState<Suspect | null>(null);
@@ -89,7 +97,9 @@ export const Suspects: React.FC<SuspectsProps> = ({ archived, companies, contact
/** Neue Zeile anlegen - sobald die Zeile verlassen oder Enter gedrückt wird. */ /** Neue Zeile anlegen - sobald die Zeile verlassen oder Enter gedrückt wird. */
const createDraft = async () => { 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 (!hasContent) return;
if (!draft.name.trim()) { if (!draft.name.trim()) {
toast('Bitte einen Namen eintragen – dann wird die Zeile gespeichert.', 'error'); toast('Bitte einen Namen eintragen – dann wird die Zeile gespeichert.', 'error');
@@ -100,8 +110,8 @@ export const Suspects: React.FC<SuspectsProps> = ({ archived, companies, contact
...draft, ...draft,
last_contact_on: draft.last_contact_on || null, last_contact_on: draft.last_contact_on || null,
}); });
setRows((list) => [...(list ?? []), suspect]); setRows((list) => [suspect, ...(list ?? [])]);
setDraft({ ...EMPTY_DRAFT }); setDraft(freshDraft());
onChanged(); onChanged();
requestAnimationFrame(() => draftName.current?.focus()); requestAnimationFrame(() => draftName.current?.focus());
} catch (err) { } catch (err) {
@@ -158,7 +168,7 @@ export const Suspects: React.FC<SuspectsProps> = ({ archived, companies, contact
<p className="max-w-3xl text-sm text-ink-500"> <p className="max-w-3xl text-sm text-ink-500">
{archived {archived
? 'Archivierte Suspects und solche, aus denen ein Lead wurde. Zurückholen oder endgültig löschen.' ? '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.'}
</p> </p>
<div className="overflow-x-auto rounded-xl border border-ink-200 bg-surface"> <div className="overflow-x-auto rounded-xl border border-ink-200 bg-surface">
@@ -189,6 +199,65 @@ export const Suspects: React.FC<SuspectsProps> = ({ archived, companies, contact
<th className="px-3 py-3" aria-label="Aktionen" /> <th className="px-3 py-3" aria-label="Aktionen" />
</tr> </tr>
</thead> </thead>
{/*
Eingabezeile steht immer oben, abgesetzt von der Liste - neue
Einträge erscheinen direkt darunter (Liste: neueste zuerst).
*/}
{!archived && (
<tbody>
<tr
className="bg-brand-50/60"
onBlur={(event) => {
// 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) => (
<td key={field} className="px-1 py-1.5">
<input
ref={field === 'name' ? draftName : undefined}
value={draft[field]}
onChange={(event) => setDraft({ ...draft, [field]: event.target.value })}
placeholder={DRAFT_PLACEHOLDERS[field]}
className={`${cellInput} border-ink-200 bg-surface`}
/>
</td>
))}
<td className="px-1 py-1.5">
<input
type="date"
value={draft.last_contact_on}
max={today()}
onChange={(event) => setDraft({ ...draft, last_contact_on: event.target.value })}
className={`${cellInput} border-ink-200 bg-surface`}
/>
</td>
<td />
<td className="px-2 py-1.5 text-right">
<button
type="button"
onClick={() => void createDraft()}
disabled={!draft.name.trim()}
className="rounded-md bg-brand-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-brand-700 disabled:opacity-40"
>
Hinzufügen
</button>
</td>
</tr>
{/* Abstand zwischen Eingabe und Liste */}
<tr aria-hidden="true">
<td colSpan={10} className="h-4 border-b border-ink-200 bg-canvas p-0" />
</tr>
</tbody>
)}
<tbody> <tbody>
{rows.map((row) => {rows.map((row) =>
archived ? ( archived ? (
@@ -244,42 +313,11 @@ export const Suspects: React.FC<SuspectsProps> = ({ archived, companies, contact
) )
)} )}
{!archived && ( {!archived && rows.length === 0 && (
<tr <tr>
className="bg-ink-50/40" <td colSpan={10} className="py-10 text-center text-sm text-ink-400">
onBlur={(event) => { Noch keine Suspects – oben in die Eingabezeile schreiben.
// 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) => (
<td key={field} className="px-1 py-1">
<input
ref={field === 'name' ? draftName : undefined}
value={draft[field]}
onChange={(event) => setDraft({ ...draft, [field]: event.target.value })}
placeholder={DRAFT_PLACEHOLDERS[field]}
className={cellInput}
/>
</td> </td>
))}
<td className="px-1 py-1">
<input
type="date"
value={draft.last_contact_on}
max={today()}
onChange={(event) => setDraft({ ...draft, last_contact_on: event.target.value })}
className={cellInput}
/>
</td>
<td />
<td />
</tr> </tr>
)} )}