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:
co-authored by
Claude Opus 5.5
parent
6a452f1510
commit
78d6bef52c
@@ -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
@@ -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`).
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user