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