Files
fitcrm/packages/crm-ui/src/components/entity-form-dialog.tsx
root 204f8ce396
Some checks failed
CI / Lint & Format (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Build All Apps (push) Has been cancelled
CI / E2E Tests (Playwright) (push) Has been cancelled
CI / Deploy to Production (push) Has been cancelled
feat(crm): мультисущностная архитектура, роли, раскладка карточек
- 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>
2026-02-27 09:27:51 +00:00

376 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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}
/>
);
}
}