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:
co-authored by
Claude Opus 5.5
parent
78d6bef52c
commit
33c9dbdb2b
@@ -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
@@ -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` |
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
{!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 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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user