Lead Radar: исходный код

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
yuriandClaude Opus 5.5 committed 2026-10-25 23:11:04 +04:00
commit 9749da5ae7
201 files changed
+97685

No files matched your search

+189
View File
@@ -0,0 +1,189 @@
import type { Pipeline } from '@lr/shared';
import { ArrowDown, ArrowUp, Lock, Plus, Trash2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { api } from '../api.ts';
import { ErrorNote } from '../components/ui.tsx';
import { useAction } from '../hooks.ts';
import { usePipeline, type Status } from './api.ts';
import { useCrm } from './i18n.ts';
import { Dialog } from './parts.tsx';
/** Название, которое сохраняется при уходе из поля или по Enter. */
function NameField({ value, label, onSave }: { value: string; label: string; onSave: (name: string) => void }) {
const [text, setText] = useState(value);
useEffect(() => setText(value), [value]);
const commit = () => {
const name = text.trim();
if (name && name !== value) onSave(name);
else setText(value);
};
return (
<input
className="field min-w-0 flex-1"
maxLength={60}
aria-label={label}
value={text}
onChange={(e) => setText(e.target.value)}
onBlur={commit}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
/>
);
}
function IconButton({ label, onClick, disabled, danger, children }: { label: string; onClick: () => void; disabled?: boolean; danger?: boolean; children: React.ReactNode }) {
return (
<button
type="button"
aria-label={label}
title={label}
disabled={disabled}
onClick={onClick}
className={`flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-lg text-muted disabled:cursor-default disabled:opacity-30 ${danger ? 'enabled:hover:text-danger' : 'enabled:hover:text-text'}`}
>
{children}
</button>
);
}
function AddRow({ placeholder, onAdd, pending }: { placeholder: string; onAdd: (name: string, done: () => void) => void; pending: boolean }) {
const { c } = useCrm();
const [name, setName] = useState('');
return (
<form className="mt-3 flex gap-2" onSubmit={(e) => { e.preventDefault(); if (name.trim()) onAdd(name.trim(), () => setName('')); }}>
<input className="field min-w-0 flex-1" maxLength={60} placeholder={placeholder} aria-label={placeholder} value={name} onChange={(e) => setName(e.target.value)} />
<button className="btn btn-ghost shrink-0" disabled={pending || !name.trim()}>
<Plus size={16} aria-hidden="true" />{c.pl.add}
</button>
</form>
);
}
const move = <T,>(list: T[], from: number, to: number) => {
const next = [...list];
next.splice(to, 0, ...next.splice(from, 1));
return next;
};
/**
* Настройка воронки руководителем: статусы и причины отказа. Системные статусы можно только
* переименовать — на них завязаны пул, аналитика и биллинг.
*/
export function PipelineSettings({ pipeline }: { pipeline: Pipeline }) {
const { c, t } = useCrm();
const x = c.pl;
const info = usePipeline(pipeline);
const call = (path: string, method: string, body?: object) => api(path, { method, body });
const act = useAction((v: { path: string; method: string; body?: object }) => call(v.path, v.method, v.body));
const [removing, setRemoving] = useState<Status | null>(null);
const [moveTo, setMoveTo] = useState('');
const statuses = info.data?.statuses ?? [];
const reasons = info.data?.reasons ?? [];
const middle = statuses.filter((s) => !s.code);
const reorder = (from: number, to: number) =>
act.mutate({ path: `/pipelines/${pipeline}/order`, method: 'POST', body: { ids: move(middle, from, to).map((s) => s.id) } });
const reorderReasons = (from: number, to: number) =>
act.mutate({ path: `/pipelines/${pipeline}/reasons-order`, method: 'POST', body: { ids: move(reasons, from, to).map((r) => r.id) } });
const remove = (s: Status) => {
if (s.count > 0) {
setMoveTo('');
setRemoving(s);
} else if (confirm(x.confirmStatus(s.name))) act.mutate({ path: `/pipeline-statuses/${s.id}`, method: 'DELETE' });
};
// Карточки удаляемого статуса переходят в другой рабочий статус.
const targets = statuses.filter((s) => s.id !== removing?.id && (!s.code || s.code === 'taken'));
return (
<section className="card p-5 sm:p-6">
<h2 className="text-lg font-medium tracking-tight">{x.title[pipeline]}</h2>
<p className="mb-5 mt-1 text-sm text-muted">{x.hint}</p>
<ErrorNote error={info.error ?? (removing ? null : act.error)} />
<h3 className="label">{x.statuses}</h3>
<ul className="space-y-2">
{statuses.map((s) => {
const i = middle.findIndex((m) => m.id === s.id);
const system = Boolean(s.code);
return (
<li key={s.id} className="flex flex-wrap items-center gap-1">
<div className="flex">
<IconButton label={x.up} disabled={system || i <= 0} onClick={() => reorder(i, i - 1)}><ArrowUp size={16} /></IconButton>
<IconButton label={x.down} disabled={system || i === middle.length - 1} onClick={() => reorder(i, i + 1)}><ArrowDown size={16} /></IconButton>
</div>
<NameField value={s.name} label={x.statusName} onSave={(name) => act.mutate({ path: `/pipeline-statuses/${s.id}`, method: 'PATCH', body: { name } })} />
{pipeline === 'object' && !['new', 'deal', 'rejected'].includes(s.code ?? '') && (
<label className="flex min-h-9 cursor-pointer items-center gap-2 px-2 text-sm text-muted" title={x.poolHint}>
<input
type="checkbox"
className="size-4 accent-(--capture)"
checked={s.poolReturn}
onChange={(e) => act.mutate({ path: `/pipeline-statuses/${s.id}`, method: 'PATCH', body: { poolReturn: e.target.checked } })}
/>
{x.pool}
</label>
)}
{system ? (
<span className="flex size-9 shrink-0 items-center justify-center text-muted" title={x.system} aria-label={x.system}><Lock size={15} /></span>
) : (
<IconButton label={t.common.delete} danger onClick={() => remove(s)}><Trash2 size={16} /></IconButton>
)}
</li>
);
})}
</ul>
<AddRow
placeholder={x.newStatus}
pending={act.isPending}
onAdd={(name, done) => act.mutate({ path: `/pipelines/${pipeline}/statuses`, method: 'POST', body: { name } }, { onSuccess: done })}
/>
<h3 className="label mt-7">{x.reasons}</h3>
<ul className="space-y-2">
{reasons.map((r, i) => (
<li key={r.id} className="flex items-center gap-1">
<div className="flex">
<IconButton label={x.up} disabled={i === 0} onClick={() => reorderReasons(i, i - 1)}><ArrowUp size={16} /></IconButton>
<IconButton label={x.down} disabled={i === reasons.length - 1} onClick={() => reorderReasons(i, i + 1)}><ArrowDown size={16} /></IconButton>
</div>
<NameField value={r.name} label={x.reasonName} onSave={(name) => act.mutate({ path: `/reject-reasons/${r.id}`, method: 'PATCH', body: { name } })} />
{r.code ? (
<span className="flex size-9 shrink-0 items-center justify-center text-muted" title={x.systemReason} aria-label={x.systemReason}><Lock size={15} /></span>
) : (
<IconButton label={t.common.delete} danger onClick={() => confirm(x.confirmReason(r.name)) && act.mutate({ path: `/reject-reasons/${r.id}`, method: 'DELETE' })}>
<Trash2 size={16} />
</IconButton>
)}
</li>
))}
</ul>
<AddRow
placeholder={x.newReason}
pending={act.isPending}
onAdd={(name, done) => act.mutate({ path: `/pipelines/${pipeline}/reasons`, method: 'POST', body: { name } }, { onSuccess: done })}
/>
{removing && (
<Dialog title={x.removeTitle(removing.name)} onClose={() => { setRemoving(null); act.reset(); }}>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
act.mutate({ path: `/pipeline-statuses/${removing.id}?moveTo=${moveTo}`, method: 'DELETE' }, { onSuccess: () => setRemoving(null) });
}}
>
<p className="text-sm text-muted">{x.removeHint(removing.count)}</p>
<select className="field" required aria-label={x.moveTo} value={moveTo} onChange={(e) => setMoveTo(e.target.value)}>
<option value="">{x.moveTo}</option>
{targets.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
<ErrorNote error={act.error} />
<div className="flex justify-end gap-2">
<button type="button" className="btn btn-ghost" onClick={() => { setRemoving(null); act.reset(); }}>{t.common.cancel}</button>
<button className="btn btn-danger" disabled={act.isPending}>{t.common.delete}</button>
</div>
</form>
</Dialog>
)}
</section>
);
}