Quellcode des ICON CRM (5)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user