Suspects: Spalte Mail und Löschen direkt in der Tabelle

- email als Symbol mit Adresse im Tooltip, Klick öffnet mailto:
  (Spalte per migrateColumns nachgezogen)
- Löschen-Knopf am Zeilenende; Server erlaubt Löschen jetzt auch
  ohne vorheriges Archivieren

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-29 12:33:32 +02:00
co-authored by Claude Opus 5.5
parent 6a452f1510
commit 78d6bef52c
7 changed files with 62 additions and 42 deletions
+4 -3
View File
@@ -36,14 +36,15 @@ 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
einfach anklicken und ändern.
2. **Links:** LinkedIn und Website werden nach dem Speichern zum Symbol. Die
Adresse steht im Tooltip, ein Klick öffnet sie. Zum Ändern auf den kleinen
Stift daneben.
2. **Links:** Mail, LinkedIn und Website werden nach dem Speichern zum Symbol.
Die Adresse steht im Tooltip, ein Klick öffnet sie (Mail: das Mailprogramm).
Zum Ändern auf den kleinen Stift daneben.
3. **Nächster Impuls:** Wecker anklicken, Anzahl Tage eingeben, *Speichern*. Ist
der Tag erreicht, erinnert das CRM daran, die Person erneut anzusprechen.
*Kontakt aufgenommen* trägt heute als letzten Kontakt ein.
4. **Archivieren** (Kasten-Symbol am Zeilenende): die Zeile wandert nach
*Suspects → Archiv*. Dort *Zurückholen* oder *Endgültig löschen*.
**Löschen** (rotes Symbol) entfernt die Zeile sofort endgültig – nach Rückfrage.
5. **Lead erstellen** (Symbol daneben): die Lead-Karte öffnet sich mit allen
bekannten Daten. Titel eintragen, *Speichern* – der Lead steht dann unter
*Neuer Lead*, der Suspect im Archiv.
+10 -6
View File
@@ -21,6 +21,7 @@ flowchart LR
| Name | `name` | Pflicht – ohne Namen wird eine neue Zeile nicht gespeichert |
| Funktion | `role` | Freitext |
| Unternehmen | `company` | Freitext, **kein** Verweis auf `icon_companies` |
| Mail | `email` | nach dem Speichern nur ein Briefsymbol, Adresse im Tooltip, Klick öffnet das Mailprogramm |
| 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 |
@@ -56,19 +57,21 @@ Das Lead-Symbol am Zeilenende öffnet die **Lead-Karte vorbelegt**
- Phase **Neuer Lead**, zuständig der angemeldete Benutzer
- **Unternehmen** und **Ansprechpartner** nur, wenn es im CRM einen mit gleichem
Namen gibt (Groß/klein, Rechtsform und Satzzeichen egal)
- **Notiz** mit allem aus der Zeile: Name, Funktion, Unternehmen, LinkedIn,
- **Notiz** mit allem aus der Zeile: Name, Funktion, Unternehmen, Mail, LinkedIn,
Website, letzter Kontakt, Notiz
- **Titel bleibt leer** – der ist Pflicht und muss eingetragen werden
Erst beim Speichern entsteht der Lead; danach wandert der Suspect ins Archiv
und trägt `lead_id` (im Archiv als **Lead** markiert). Abbrechen ändert nichts.
## Archiv
## Archiv und Löschen
Archivierte und in Leads übernommene Suspects, neueste zuerst. **Zurückholen**
stellt die Zeile wieder in die Tabelle, **Endgültig löschen** entfernt sie
(mit Rückfrage). Löschen geht nur aus dem Archiv – der Server antwortet sonst
mit `409`.
stellt die Zeile wieder in die Tabelle, **Endgültig löschen** entfernt sie.
Löschen geht auch direkt in der Tabelle (rotes Symbol am Zeilenende) – beide
Wege fragen vorher nach. Anfangs war Löschen nur aus dem Archiv möglich; auf
Wunsch vom 29.09.2026 gibt es den Knopf jetzt auch in der Tabelle.
## Tabelle `icon_suspects`
@@ -76,6 +79,7 @@ mit `409`.
| --- | --- | --- |
| `id` | INT | |
| `name`, `role`, `company` | VARCHAR(190) | |
| `email` | VARCHAR(190) | ohne `mailto:`; nachträglich per `migrateColumns` ergänzt |
| `linkedin`, `website` | VARCHAR(500) | immer mit Protokoll gespeichert |
| `notes` | TEXT | |
| `last_contact_on` | DATE | |
@@ -99,7 +103,7 @@ Alle unter `/api/suspects`, alle hinter `requireAuth`.
| POST | `/` | anlegen, `name` Pflicht |
| PATCH | `/:id` | beliebige Felder; `remind_in_days` (Zahl oder `null`), `archived`, `lead_id` |
| POST | `/:id/acknowledge` | `{ snooze_days }` verschiebt, sonst Kontakt heute + Impuls weg |
| DELETE | `/:id` | nur archivierte (`409` sonst) |
| DELETE | `/:id` | endgültig löschen (aus Tabelle oder Archiv) |
Die Zähler im Menü kommen aus `GET /api/dashboard`
(`counts.suspects_open`, `counts.suspects_archived`).
+6
View File
@@ -179,6 +179,12 @@ async function migrateColumns() {
'title',
'ALTER TABLE icon_contacts ADD COLUMN title VARCHAR(60) NULL AFTER id',
],
// Mail-Adresse der Suspects - kam am Tag der Einfuehrung dazu (29.09.2026).
[
'icon_suspects',
'email',
'ALTER TABLE icon_suspects ADD COLUMN email VARCHAR(190) NULL AFTER company',
],
[
'icon_cv_imports',
'photo',
+5 -4
View File
@@ -13,7 +13,7 @@ import { logHistory } from '../history.js';
const router = express.Router();
router.use(requireAuth);
const TEXT_FIELDS = { name: 190, role: 190, company: 190, linkedin: 500, website: 500, notes: 5000 };
const TEXT_FIELDS = { name: 190, role: 190, company: 190, email: 190, linkedin: 500, website: 500, notes: 5000 };
function readBody(body) {
const out = {};
@@ -26,6 +26,8 @@ function readBody(body) {
for (const field of ['linkedin', 'website']) {
if (out[field] && !/^https?:\/\//i.test(out[field])) out[field] = `https://${out[field]}`;
}
// "mailto:" aus kopierten Links entfernen - gespeichert wird die blanke Adresse.
if (out.email) out.email = out.email.replace(/^mailto:/i, '');
if (body.last_contact_on !== undefined) {
out.last_contact_on = /^\d{4}-\d{2}-\d{2}$/.test(String(body.last_contact_on ?? ''))
? body.last_contact_on
@@ -152,14 +154,13 @@ router.post('/:id/acknowledge', async (req, res, next) => {
}
});
/** Endgueltig loeschen - nur aus dem Archiv, damit nichts aus Versehen verschwindet. */
/** Endgueltig loeschen - aus der Tabelle wie aus dem Archiv (die Oberflaeche fragt nach). */
router.delete('/:id', async (req, res, next) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Kennung.' });
try {
const [[row]] = await pool.query('SELECT name, archived FROM icon_suspects WHERE id = ?', [id]);
const [[row]] = await pool.query('SELECT name FROM icon_suspects WHERE id = ?', [id]);
if (!row) return res.status(404).json({ error: 'Suspect nicht gefunden.' });
if (!row.archived) return res.status(409).json({ error: 'Bitte zuerst archivieren.' });
await pool.query('DELETE FROM icon_suspects WHERE id = ?', [id]);
await logHistory({
entityType: 'system',
+1
View File
@@ -356,6 +356,7 @@ CREATE TABLE IF NOT EXISTS icon_suspects (
name VARCHAR(190) NOT NULL,
role VARCHAR(190) NULL,
company VARCHAR(190) NULL,
email VARCHAR(190) NULL,
linkedin VARCHAR(500) NULL,
website VARCHAR(500) NULL,
notes TEXT NULL,
+34 -28
View File
@@ -31,12 +31,13 @@ interface SuspectsProps {
onChanged: () => void;
}
type TextField = 'name' | 'role' | 'company' | 'linkedin' | 'website' | 'notes';
type TextField = 'name' | 'role' | 'company' | 'email' | 'linkedin' | 'website' | 'notes';
const EMPTY_DRAFT: Record<TextField, string> & { last_contact_on: string } = {
name: '',
role: '',
company: '',
email: '',
linkedin: '',
website: '',
notes: '',
@@ -161,23 +162,25 @@ export const Suspects: React.FC<SuspectsProps> = ({ archived, companies, contact
</p>
<div className="overflow-x-auto rounded-xl border border-ink-200 bg-surface">
<table className="w-full min-w-[1100px] table-fixed text-sm">
<table className="w-full min-w-[1200px] table-fixed text-sm">
<colgroup>
<col className="w-[15%]" />
<col className="w-[14%]" />
<col className="w-[14%]" />
<col className="w-[64px]" />
<col className="w-[72px]" />
<col className="w-[72px]" />
<col />
<col className="w-[130px]" />
<col className="w-[120px]" />
<col className="w-[96px]" />
<col className="w-[128px]" />
</colgroup>
<thead>
<tr className="border-b border-ink-200 text-left text-[11px] font-semibold uppercase tracking-wider text-ink-500">
<th className="px-3 py-3">Name</th>
<th className="px-3 py-3">Funktion</th>
<th className="px-3 py-3">Unternehmen</th>
<th className="px-3 py-3 text-center">Mail</th>
<th className="px-3 py-3 text-center">LinkedIn</th>
<th className="px-3 py-3 text-center">Website</th>
<th className="px-3 py-3">Notiz</th>
@@ -206,6 +209,9 @@ export const Suspects: React.FC<SuspectsProps> = ({ archived, companies, contact
/>
</td>
))}
<td className="px-1 py-1 text-center">
<LinkCell value={row.email} kind="mail" onCommit={(value) => void update(row, { email: value })} />
</td>
<td className="px-1 py-1 text-center">
<LinkCell value={row.linkedin} kind="linkedin" onCommit={(value) => void update(row, { linkedin: value })} />
</td>
@@ -231,6 +237,7 @@ export const Suspects: React.FC<SuspectsProps> = ({ archived, companies, contact
<div className="flex justify-end gap-1">
<IconButton title="Archivieren" onClick={() => void archive(row)} path={ARCHIVE_ICON} />
<IconButton title="Lead erstellen" onClick={() => setToLead(row)} path={LEAD_ICON} tone="brand" />
<IconButton title="Löschen" onClick={() => setRemoving(row)} path={DELETE_ICON} tone="red" />
</div>
</td>
</tr>
@@ -251,7 +258,7 @@ export const Suspects: React.FC<SuspectsProps> = ({ archived, companies, contact
}
}}
>
{(['name', 'role', 'company', 'linkedin', 'website', 'notes'] as const).map((field) => (
{(['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}
@@ -278,7 +285,7 @@ export const Suspects: React.FC<SuspectsProps> = ({ archived, companies, contact
{archived && rows.length === 0 && (
<tr>
<td colSpan={9} className="py-12 text-center text-sm text-ink-400">
<td colSpan={10} className="py-12 text-center text-sm text-ink-400">
Das Archiv ist leer.
</td>
</tr>
@@ -328,6 +335,7 @@ const DRAFT_PLACEHOLDERS: Record<TextField, string> = {
name: '+ Name',
role: 'Funktion',
company: 'Unternehmen',
email: 'Mail',
linkedin: 'LinkedIn-URL',
website: 'Website',
notes: 'Notiz',
@@ -372,8 +380,16 @@ const TextCell: React.FC<{ value: string | null; required?: boolean; onCommit: (
);
};
type LinkKind = 'linkedin' | 'website' | 'mail';
const LINK_KINDS: Record<LinkKind, { color: string; href: (value: string) => string; placeholder: string }> = {
linkedin: { color: 'text-[#0a66c2]', href: (v) => v, placeholder: 'URL' },
website: { color: 'text-brand-600', href: (v) => v, placeholder: 'URL' },
mail: { color: 'text-brand-600', href: (v) => `mailto:${v}`, placeholder: 'Mail' },
};
/** Link als Symbol; die Adresse zeigt der Tooltip. Bearbeiten über den Stift. */
const LinkCell: React.FC<{ value: string | null; kind: 'linkedin' | 'website'; onCommit: (value: string | null) => void }> = ({
const LinkCell: React.FC<{ value: string | null; kind: LinkKind; onCommit: (value: string | null) => void }> = ({
value,
kind,
onCommit,
@@ -399,7 +415,8 @@ const LinkCell: React.FC<{ value: string | null; kind: 'linkedin' | 'website'; o
setEditing(false);
}
}}
placeholder="URL"
type={kind === 'mail' ? 'email' : 'text'}
placeholder={LINK_KINDS[kind].placeholder}
className={`${cellInput} text-xs`}
/>
);
@@ -407,23 +424,11 @@ const LinkCell: React.FC<{ value: string | null; kind: 'linkedin' | 'website'; o
return (
<span className="group inline-flex items-center gap-0.5">
<a
href={value}
target="_blank"
rel="noopener noreferrer"
title={value}
className={`inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-ink-100 ${
kind === 'linkedin' ? 'text-[#0a66c2]' : 'text-brand-600'
}`}
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="currentColor" aria-hidden="true">
<path d={kind === 'linkedin' ? LINKEDIN_ICON : LINK_ICON} />
</svg>
</a>
<StaticLink href={value} kind={kind} />
<button
type="button"
onClick={() => setEditing(true)}
title="Link bearbeiten"
title={kind === 'mail' ? 'Mail bearbeiten' : 'Link bearbeiten'}
className="invisible rounded p-0.5 text-ink-400 hover:text-ink-700 group-hover:visible"
>
<svg viewBox="0 0 24 24" className="h-3 w-3" fill="currentColor" aria-hidden="true">
@@ -534,6 +539,7 @@ const ArchivedRow: React.FC<{ row: Suspect; onRestore: () => void; onDelete: ()
</td>
<td className="truncate px-3 py-2.5 text-ink-600">{row.role ?? '–'}</td>
<td className="truncate px-3 py-2.5 text-ink-600">{row.company ?? '–'}</td>
<td className="px-3 py-2.5 text-center">{row.email && <StaticLink href={row.email} kind="mail" />}</td>
<td className="px-3 py-2.5 text-center">{row.linkedin && <StaticLink href={row.linkedin} kind="linkedin" />}</td>
<td className="px-3 py-2.5 text-center">{row.website && <StaticLink href={row.website} kind="website" />}</td>
<td className="truncate px-3 py-2.5 text-ink-600" title={row.notes ?? undefined}>
@@ -550,18 +556,16 @@ const ArchivedRow: React.FC<{ row: Suspect; onRestore: () => void; onDelete: ()
</tr>
);
const StaticLink: React.FC<{ href: string; kind: 'linkedin' | 'website' }> = ({ href, kind }) => (
const StaticLink: React.FC<{ href: string; kind: LinkKind }> = ({ href, kind }) => (
<a
href={href}
target="_blank"
href={LINK_KINDS[kind].href(href)}
target={kind === 'mail' ? undefined : '_blank'}
rel="noopener noreferrer"
title={href}
className={`inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-ink-100 ${
kind === 'linkedin' ? 'text-[#0a66c2]' : 'text-brand-600'
}`}
className={`inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-ink-100 ${LINK_KINDS[kind].color}`}
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="currentColor" aria-hidden="true">
<path d={kind === 'linkedin' ? LINKEDIN_ICON : LINK_ICON} />
<path d={kind === 'linkedin' ? LINKEDIN_ICON : kind === 'mail' ? MAIL_ICON : LINK_ICON} />
</svg>
</a>
);
@@ -607,6 +611,7 @@ function leadFromSuspect(row: Suspect, companies: Company[], contacts: Contact[]
const notes = [
`Aus Suspects: ${row.name}${row.role ? `, ${row.role}` : ''}${row.company ? ` (${row.company})` : ''}`,
row.email ? `Mail: ${row.email}` : null,
row.linkedin ? `LinkedIn: ${row.linkedin}` : null,
row.website ? `Website: ${row.website}` : null,
row.last_contact_on ? `Letzter Kontakt: ${formatDate(row.last_contact_on)}` : null,
@@ -634,5 +639,6 @@ const ALARM_ICON =
'M12 5a8 8 0 1 1 0 16 8 8 0 0 1 0-16Zm0 2a6 6 0 1 0 0 12 6 6 0 0 0 0-12Zm-1 2h2v4l3 2-1 1.6-4-2.6V9ZM6.3 2.3l1.4 1.4-3.9 3.9-1.4-1.4 3.9-3.9Zm11.4 0 3.9 3.9-1.4 1.4-3.9-3.9 1.4-1.4Z';
const LINK_ICON =
'M10.6 13.4a1 1 0 0 1 0-1.4l3.5-3.5a1 1 0 1 1 1.4 1.4L12 13.4a1 1 0 0 1-1.4 0ZM8.5 11.3 6.4 13.4a3 3 0 0 0 4.2 4.2l2.1-2.1 1.4 1.4-2.1 2.1a5 5 0 0 1-7-7l2.1-2.1 1.4 1.4Zm7-5.7a3 3 0 0 0-4.2 0L9.2 7.7 7.8 6.3l2.1-2.1a5 5 0 0 1 7 7l-2.1 2.1-1.4-1.4 2.1-2.1a3 3 0 0 0 0-4.2Z';
const MAIL_ICON = 'M3 5h18v14H3V5Zm2 2v.4l7 4.6 7-4.6V7H5Zm14 2.8-7 4.6-7-4.6V17h14V9.8Z';
const LINKEDIN_ICON =
'M4 3h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Zm3.3 7H5v8h2.3v-8ZM6.2 5.8a1.3 1.3 0 1 0 0 2.7 1.3 1.3 0 0 0 0-2.7ZM19 13.4c0-2.2-1.2-3.5-3.1-3.5-1.1 0-1.9.6-2.2 1.1V10h-2.2v8h2.3v-4.2c0-1.1.5-1.8 1.4-1.8.8 0 1.3.6 1.3 1.8V18H19v-4.6Z';
+2 -1
View File
@@ -440,6 +440,7 @@ export interface Suspect {
name: string;
role: string | null;
company: string | null;
email: string | null;
linkedin: string | null;
website: string | null;
notes: string | null;
@@ -454,7 +455,7 @@ export interface Suspect {
}
export type SuspectInput = Partial<
Pick<Suspect, 'name' | 'role' | 'company' | 'linkedin' | 'website' | 'notes' | 'last_contact_on' | 'archived' | 'lead_id'>
Pick<Suspect, 'name' | 'role' | 'company' | 'email' | 'linkedin' | 'website' | 'notes' | 'last_contact_on' | 'archived' | 'lead_id'>
> & { remind_in_days?: number | null };
export const suspectsApi = {