Some checks failed
- packages/crm-ui: переиспользуемые компоненты (EntityKanban, EntityTable, EntityCard, EntityFormDialog, StageSwitcher, ActivityList, TimelineFeed, FieldManager, PipelineManager, StageBadge) - Pipeline entityType: воронки привязаны к типу сущности - Role system: таблица roles + user_roles, multi-role JWT, RolesGuard - Card layouts: admin-default + user-override раскладка карточек - Field roleAccess: видимость полей per role (hidden/readonly/editable) - EntityPermissions: multi-role поддержка (string | string[]) - DnD стадий, произвольный цвет стадий, FieldManager entityType prop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
376 lines
12 KiB
TypeScript
376 lines
12 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument */
|
||
'use client';
|
||
|
||
import { useEffect, useState, useCallback } from 'react';
|
||
import { X, Loader2 } from 'lucide-react';
|
||
import { useCrmApi } from '../context';
|
||
import type { EntityConfig, CustomFieldDef, PipelineData } from '../types';
|
||
|
||
interface EntityFormDialogProps {
|
||
config: EntityConfig;
|
||
open: boolean;
|
||
onClose: () => void;
|
||
onSaved: () => void;
|
||
mode: 'create' | 'edit';
|
||
initialData?: Record<string, any>;
|
||
entityId?: string;
|
||
}
|
||
|
||
const inputClass =
|
||
'w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/30 bg-white text-gray-900';
|
||
|
||
export function EntityFormDialog({
|
||
config,
|
||
open,
|
||
onClose,
|
||
onSaved,
|
||
mode,
|
||
initialData,
|
||
entityId,
|
||
}: EntityFormDialogProps) {
|
||
const api = useCrmApi();
|
||
const [saving, setSaving] = useState(false);
|
||
const [values, setValues] = useState<Record<string, any>>({});
|
||
const [customFields, setCustomFields] = useState<CustomFieldDef[]>([]);
|
||
const [customValues, setCustomValues] = useState<Record<string, any>>({});
|
||
const [pipelines, setPipelines] = useState<PipelineData[]>([]);
|
||
const [staffList, setStaffList] = useState<{ id: string; name: string }[]>([]);
|
||
|
||
const fetchMeta = useCallback(async () => {
|
||
try {
|
||
const promises: Promise<any>[] = [];
|
||
|
||
// Fetch custom fields
|
||
if (config.features.customFields) {
|
||
promises.push(api.get<CustomFieldDef[]>(`/crm/fields?entityType=${config.entityType}`));
|
||
} else {
|
||
promises.push(Promise.resolve([]));
|
||
}
|
||
|
||
// Fetch pipelines
|
||
if (config.features.pipelines) {
|
||
promises.push(api.get<PipelineData[]>(`/crm/pipelines?entityType=${config.entityType}`));
|
||
} else {
|
||
promises.push(Promise.resolve([]));
|
||
}
|
||
|
||
// Fetch staff for assignee
|
||
promises.push(
|
||
api
|
||
.get<{ id: string; firstName: string; lastName: string }[]>('/users?role=manager')
|
||
.catch(() => []),
|
||
);
|
||
|
||
const [cf, pl, staff] = await Promise.all(promises);
|
||
setCustomFields((cf as CustomFieldDef[]) || []);
|
||
setPipelines((pl as PipelineData[]) || []);
|
||
const staffData = (staff || []) as { id: string; firstName: string; lastName: string }[];
|
||
setStaffList(
|
||
staffData.map((s) => ({
|
||
id: s.id,
|
||
name: `${s.firstName} ${s.lastName}`,
|
||
})),
|
||
);
|
||
} catch {
|
||
//
|
||
}
|
||
}, [api, config]);
|
||
|
||
useEffect(() => {
|
||
if (open) {
|
||
void fetchMeta();
|
||
if (mode === 'edit' && initialData) {
|
||
const vals: Record<string, any> = {};
|
||
for (const field of config.systemFields) {
|
||
if (initialData[field.key] !== undefined) {
|
||
vals[field.key] = initialData[field.key];
|
||
}
|
||
}
|
||
// Also copy other known keys
|
||
for (const key of ['pipelineId', 'stageId', 'assigneeId']) {
|
||
if (initialData[key] !== undefined) vals[key] = initialData[key];
|
||
}
|
||
setValues(vals);
|
||
setCustomValues(initialData.customFieldValues || {});
|
||
} else {
|
||
setValues({});
|
||
setCustomValues({});
|
||
}
|
||
}
|
||
}, [open, mode, initialData, config, fetchMeta]);
|
||
|
||
const setVal = (key: string, value: any) => {
|
||
setValues((prev) => ({ ...prev, [key]: value }));
|
||
};
|
||
|
||
const setCfVal = (fieldName: string, value: any) => {
|
||
setCustomValues((prev) => ({ ...prev, [fieldName]: value }));
|
||
};
|
||
|
||
const handleSave = async () => {
|
||
setSaving(true);
|
||
try {
|
||
const data: Record<string, any> = { ...values };
|
||
|
||
// Add custom fields
|
||
if (Object.keys(customValues).length > 0) {
|
||
data.customFieldValues = customValues;
|
||
}
|
||
|
||
if (mode === 'edit' && entityId) {
|
||
await api.patch(`${config.apiPrefix}/${entityId}`, data);
|
||
} else {
|
||
await api.post(config.apiPrefix, data);
|
||
}
|
||
onSaved();
|
||
onClose();
|
||
} catch (err: unknown) {
|
||
alert(err instanceof Error ? err.message : 'Ошибка сохранения');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
if (!open) return null;
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
||
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 max-h-[85vh] flex flex-col">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
||
<h2 className="text-lg font-semibold text-gray-900">
|
||
{mode === 'edit'
|
||
? `Редактировать ${config.label.singular.toLowerCase()}`
|
||
: `Новая ${config.label.singular.toLowerCase()}`}
|
||
</h2>
|
||
<button onClick={onClose} className="p-1 rounded-lg hover:bg-gray-100 transition-colors">
|
||
<X className="h-5 w-5 text-gray-400" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Body */}
|
||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||
{/* System fields */}
|
||
{config.systemFields
|
||
.filter((f) => f.defaultVisible !== false)
|
||
.map((field) => (
|
||
<div key={field.key}>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
{field.label}
|
||
</label>
|
||
{renderFieldInput(field, values[field.key], (v) => setVal(field.key, v))}
|
||
</div>
|
||
))}
|
||
|
||
{/* Pipeline selector */}
|
||
{config.features.pipelines && pipelines.length > 0 && mode === 'create' && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">Воронка</label>
|
||
<select
|
||
value={values.pipelineId || pipelines[0]?.id || ''}
|
||
onChange={(e) => setVal('pipelineId', e.target.value)}
|
||
className={inputClass}
|
||
>
|
||
{pipelines.map((p) => (
|
||
<option key={p.id} value={p.id}>
|
||
{p.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)}
|
||
|
||
{/* Assignee */}
|
||
{staffList.length > 0 && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">Ответственный</label>
|
||
<select
|
||
value={values.assigneeId || ''}
|
||
onChange={(e) => setVal('assigneeId', e.target.value || undefined)}
|
||
className={inputClass}
|
||
>
|
||
<option value="">— Не назначен —</option>
|
||
{staffList.map((s) => (
|
||
<option key={s.id} value={s.id}>
|
||
{s.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)}
|
||
|
||
{/* Custom fields */}
|
||
{customFields.length > 0 && (
|
||
<>
|
||
<div className="border-t border-gray-100 pt-4 mt-4">
|
||
<h3 className="text-xs font-medium text-gray-500 mb-3 uppercase">
|
||
Дополнительные поля
|
||
</h3>
|
||
</div>
|
||
{customFields.map((cf) => (
|
||
<div key={cf.id}>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
{cf.name}
|
||
{cf.isRequired && <span className="text-red-500 ml-0.5">*</span>}
|
||
</label>
|
||
{renderCustomFieldInput(cf, customValues[cf.fieldName], (v) =>
|
||
setCfVal(cf.fieldName, v),
|
||
)}
|
||
{cf.description && (
|
||
<p className="text-xs text-gray-400 mt-0.5">{cf.description}</p>
|
||
)}
|
||
</div>
|
||
))}
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-100">
|
||
<button
|
||
onClick={onClose}
|
||
className="px-4 py-2 text-sm text-gray-500 hover:text-gray-700 transition-colors"
|
||
>
|
||
Отмена
|
||
</button>
|
||
<button
|
||
onClick={() => void handleSave()}
|
||
disabled={saving}
|
||
className="flex items-center gap-2 px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||
>
|
||
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
|
||
{saving ? 'Сохранение...' : 'Сохранить'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function renderFieldInput(
|
||
field: { key: string; type: string; enumOptions?: { value: string; label: string }[] },
|
||
value: any,
|
||
onChange: (v: any) => void,
|
||
) {
|
||
switch (field.type) {
|
||
case 'currency':
|
||
case 'number':
|
||
return (
|
||
<input
|
||
type="number"
|
||
value={value ?? ''}
|
||
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : undefined)}
|
||
className={inputClass}
|
||
/>
|
||
);
|
||
case 'date':
|
||
return (
|
||
<input
|
||
type="date"
|
||
value={value ?? ''}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
className={inputClass}
|
||
/>
|
||
);
|
||
case 'enum':
|
||
return (
|
||
<select
|
||
value={value ?? ''}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
className={inputClass}
|
||
>
|
||
<option value="">—</option>
|
||
{(field.enumOptions || []).map((opt) => (
|
||
<option key={opt.value} value={opt.value}>
|
||
{opt.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
);
|
||
case 'phone':
|
||
case 'email':
|
||
case 'string':
|
||
default:
|
||
return (
|
||
<input
|
||
type={field.type === 'email' ? 'email' : field.type === 'phone' ? 'tel' : 'text'}
|
||
value={value ?? ''}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
className={inputClass}
|
||
/>
|
||
);
|
||
}
|
||
}
|
||
|
||
function renderCustomFieldInput(cf: CustomFieldDef, value: any, onChange: (v: any) => void) {
|
||
switch (cf.type) {
|
||
case 'INTEGER':
|
||
case 'FLOAT':
|
||
return (
|
||
<input
|
||
type="number"
|
||
step={cf.type === 'FLOAT' ? '0.01' : '1'}
|
||
value={value ?? ''}
|
||
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : undefined)}
|
||
className={inputClass}
|
||
/>
|
||
);
|
||
case 'BOOLEAN':
|
||
return (
|
||
<label className="flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={!!value}
|
||
onChange={(e) => onChange(e.target.checked)}
|
||
className="rounded border-gray-300"
|
||
/>
|
||
<span className="text-sm text-gray-700">{cf.name}</span>
|
||
</label>
|
||
);
|
||
case 'DATE':
|
||
return (
|
||
<input
|
||
type="date"
|
||
value={value ?? ''}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
className={inputClass}
|
||
/>
|
||
);
|
||
case 'DATETIME':
|
||
return (
|
||
<input
|
||
type="datetime-local"
|
||
value={value ?? ''}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
className={inputClass}
|
||
/>
|
||
);
|
||
case 'LIST':
|
||
return (
|
||
<select
|
||
value={value ?? ''}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
className={inputClass}
|
||
>
|
||
<option value="">—</option>
|
||
{(cf.listOptions || []).map((opt) => (
|
||
<option key={opt} value={opt}>
|
||
{opt}
|
||
</option>
|
||
))}
|
||
</select>
|
||
);
|
||
case 'STRING':
|
||
default:
|
||
return (
|
||
<input
|
||
type="text"
|
||
value={value ?? ''}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
className={inputClass}
|
||
/>
|
||
);
|
||
}
|
||
}
|