Quellcode des ICON CRM (5)

This commit is contained in:
2026-09-14 16:55:38 +02:00
parent e383400d7c
commit 4fcb34f324
25 changed files with 7616 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
import React, { useEffect, useState } from 'react';
import type { DirectoryUser, HistoryEntry } from '../../types';
import { historyApi } from '../../lib/api';
import { HistoryFeed } from './HistoryFeed';
import { inputClass } from '../ui/form';
import { useNavigation, type OpenTarget } from '../../lib/navigation';
import { useToast } from '../Toast';
/*
* Reiter "Historie": alle Taetigkeiten chronologisch, ueber alle Bereiche.
*
* Geblaettert wird ueber die Kennung des letzten Eintrags statt ueber einen
* Versatz - bei laufend neuen Eintraegen wuerde ein Offset Zeilen doppelt
* liefern oder auslassen.
*/
const TYPES: Array<{ value: string; label: string }> = [
{ value: '', label: 'Alle Bereiche' },
{ value: 'contact', label: 'Kontakte' },
{ value: 'company', label: 'Unternehmen' },
{ value: 'tender', label: 'Ausschreibungen' },
{ value: 'project', label: 'Laufende Projekte' },
{ value: 'lead', label: 'Leads' },
{ value: 'user', label: 'Benutzer' },
];
const PAGE_SIZE = 100;
export const History: React.FC<{ users: DirectoryUser[] }> = ({ users }) => {
const { toast } = useToast();
const { open } = useNavigation();
const [entries, setEntries] = useState<HistoryEntry[]>([]);
const [type, setType] = useState('');
const [userId, setUserId] = useState('');
const [term, setTerm] = useState('');
const [loading, setLoading] = useState(true);
const [done, setDone] = useState(false);
const load = async (beforeId?: number) => {
setLoading(true);
try {
const { entries: rows } = await historyApi.list({
entity_type: type || undefined,
user_id: userId || undefined,
q: term.trim() || undefined,
limit: PAGE_SIZE,
before_id: beforeId,
});
setEntries((current) => (beforeId ? [...current, ...rows] : rows));
setDone(rows.length < PAGE_SIZE);
} catch (err) {
toast(err instanceof Error ? err.message : 'Laden fehlgeschlagen.', 'error');
} finally {
setLoading(false);
}
};
useEffect(() => {
const timer = setTimeout(() => void load(), 200);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [type, userId, term]);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<input
value={term}
onChange={(event) => setTerm(event.target.value)}
placeholder="Historie durchsuchen …"
className={`${inputClass} max-w-xs`}
/>
<select value={type} onChange={(event) => setType(event.target.value)} className={`${inputClass} w-auto`}>
{TYPES.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
<select
value={userId}
onChange={(event) => setUserId(event.target.value)}
className={`${inputClass} w-auto`}
>
<option value="">Alle Benutzer</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
<span className="ml-auto text-sm text-ink-400">{entries.length} Einträge</span>
</div>
<div className="rounded-xl border border-ink-200 bg-surface p-5">
<HistoryFeed
entries={entries}
searchable={false}
showEntity
emptyText={loading ? 'Wird geladen …' : 'Noch keine Einträge.'}
onOpenEntity={(entry) => {
if (!entry.entity_id) return;
const openable: Record<string, OpenTarget | undefined> = {
contact: 'contact',
company: 'company',
tender: 'tender',
project: 'project',
lead: 'lead',
};
const target = openable[entry.entity_type];
if (target) open(target, entry.entity_id);
}}
/>
{!done && entries.length > 0 && (
<div className="mt-4 flex justify-center">
<button
type="button"
disabled={loading}
onClick={() => void load(entries[entries.length - 1]?.id)}
className="rounded-lg border border-ink-200 px-4 py-2 text-sm font-medium text-ink-700 hover:bg-ink-100 disabled:opacity-50"
>
{loading ? 'Wird geladen …' : 'Ältere Einträge laden'}
</button>
</div>
)}
</div>
</div>
);
};
+170
View File
@@ -0,0 +1,170 @@
import React, { useMemo, useState } from 'react';
import type { HistoryEntry } from '../../types';
import { formatStamp, userColor } from '../../types';
import { MentionField, MentionText, mentionPlainText } from '../../lib/mentions';
import { Avatar } from '../ui/Avatar';
import { PrimaryButton } from '../ui/form';
/*
* Historie einer Karte.
*
* Jede Zeile traegt Zeitstempel, Benutzer und Text - genau das Format aus der
* Featureliste: 14.09.'26 09:35 | Hans Peter neu angelegt.
*
* Systemeintraege entstehen beim Speichern von selbst; Notizen schreibt der
* Benutzer hier. Beides steht in derselben Liste, damit sich der Verlauf am
* Stueck lesen laesst.
*/
const ACTION_TONE: Record<string, string> = {
created: 'bg-emerald-500',
updated: 'bg-sky-500',
deleted: 'bg-red-500',
note: 'bg-brand-500',
interview: 'bg-violet-500',
left: 'bg-amber-500',
joined: 'bg-emerald-500',
employer: 'bg-amber-500',
connection: 'bg-sky-500',
project: 'bg-brand-500',
stage: 'bg-sky-500',
};
interface HistoryFeedProps {
entries: HistoryEntry[];
/** Ohne diese Angabe ist die Liste rein lesend (z. B. der globale Reiter). */
onAdd?: (text: string) => Promise<void> | void;
/** Suche innerhalb dieser Historie - laut Featureliste je Karte. */
searchable?: boolean;
emptyText?: string;
showEntity?: boolean;
onOpenEntity?: (entry: HistoryEntry) => void;
}
export const HistoryFeed: React.FC<HistoryFeedProps> = ({
entries,
onAdd,
searchable = true,
emptyText = 'Noch keine Einträge.',
showEntity = false,
onOpenEntity,
}) => {
const [draft, setDraft] = useState('');
const [term, setTerm] = useState('');
const [busy, setBusy] = useState(false);
const visible = useMemo(() => {
const q = term.trim().toLowerCase();
if (!q) return entries;
return entries.filter((entry) =>
`${mentionPlainText(entry.text ?? '')} ${entry.entity_label ?? ''} ${entry.user_name ?? ''}`
.toLowerCase()
.includes(q)
);
}, [entries, term]);
const submit = async () => {
const text = draft.trim();
if (!text || !onAdd) return;
setBusy(true);
try {
await onAdd(text);
setDraft('');
} finally {
setBusy(false);
}
};
return (
<div className="space-y-4">
{onAdd && (
<div>
<MentionField
value={draft}
onChange={setDraft}
rows={3}
placeholder="Was ist passiert? Z. B. bei @Kunde vorgestellt …"
/>
<div className="mt-2 flex justify-end">
<PrimaryButton type="button" onClick={submit} disabled={busy || !draft.trim()}>
{busy ? 'Wird gespeichert …' : 'Eintrag hinzufügen'}
</PrimaryButton>
</div>
</div>
)}
{searchable && entries.length > 5 && (
<input
value={term}
onChange={(event) => setTerm(event.target.value)}
placeholder="In dieser Historie suchen …"
className="w-full rounded-lg border border-ink-200 bg-surface px-3 py-2 text-sm"
/>
)}
{visible.length === 0 ? (
<p className="py-6 text-center text-sm text-ink-400">
{entries.length === 0 ? emptyText : 'Kein Eintrag passt zur Suche.'}
</p>
) : (
<ol className="relative space-y-0 border-l border-ink-200 pl-5">
{visible.map((entry) => (
<li key={entry.id} className="relative py-2.5">
<span
className={`absolute -left-[1.44rem] top-4 h-2.5 w-2.5 rounded-full ring-4 ring-surface ${
ACTION_TONE[entry.action] ?? 'bg-ink-300'
}`}
aria-hidden="true"
/>
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<time className="font-mono text-xs text-ink-400">{formatStamp(entry.created_at)}</time>
{entry.user_name && (
<span className="inline-flex items-center gap-1 text-xs text-ink-500">
<Avatar
name={entry.user_name}
src={entry.user_avatar}
size={16}
color={userColor({ id: entry.user_id ?? 0, color: entry.user_color })}
/>
{entry.user_name}
</span>
)}
{showEntity && entry.entity_label && (
<button
type="button"
onClick={() => onOpenEntity?.(entry)}
className="rounded bg-ink-100 px-1.5 py-0.5 text-xs font-medium text-ink-600 hover:bg-ink-200"
>
{entry.entity_label}
</button>
)}
</div>
<p className="mt-0.5 text-sm text-ink-800">
<MentionText text={entry.text} />
</p>
{entry.changes && Object.keys(entry.changes).length > 0 && (
<details className="mt-1">
<summary className="cursor-pointer text-xs text-ink-400">Was genau</summary>
<ul className="mt-1 space-y-0.5 text-xs text-ink-500">
{Object.entries(entry.changes).map(([field, [from, to]]) => (
<li key={field}>
<span className="font-medium">{field}</span>: {display(from)} → {display(to)}
</li>
))}
</ul>
</details>
)}
</li>
))}
</ol>
)}
</div>
);
};
function display(value: unknown): string {
if (value === null || value === undefined || value === '') return '–';
const text = String(value);
return text.length > 80 ? `${text.slice(0, 80)}…` : text;
}