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
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).
+6 -4
View File
@@ -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` |
+7 -2
View File
@@ -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(
+81 -43
View File
@@ -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<TextField, string> & { 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<SuspectsProps> = ({ archived, companies, contacts, users, onChanged }) => {
const { toast } = useToast();
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 [toLead, setToLead] = 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. */
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<SuspectsProps> = ({ 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<SuspectsProps> = ({ archived, companies, contact
<p className="max-w-3xl text-sm text-ink-500">
{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.'}
</p>
<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" />
</tr>
</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>
{rows.map((row) =>
archived ? (
@@ -244,42 +313,11 @@ export const Suspects: React.FC<SuspectsProps> = ({ archived, companies, contact
)
)}
{!archived && (
<tr
className="bg-ink-50/40"
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">
<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 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}
/>
{!archived && rows.length === 0 && (
<tr>
<td colSpan={10} className="py-10 text-center text-sm text-ink-400">
Noch keine Suspects – oben in die Eingabezeile schreiben.
</td>
<td />
<td />
</tr>
)}