Lead Radar: исходный код
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
9749da5ae7
201 files changed
+97685
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user