();
+ return (
+ e.stopPropagation()}>
+ ({ value: o.value, label: o.label }))}
+ selected={selected}
+ onChange={(next) => {
+ const arr = Array.from(next);
+ updateEditValue(arr);
+ commitValue(arr, { close: false });
+ }}
+ />
+
+ );
+ }
+ // Одиночный выбор: сохранение сразу по выбору
+ return (
+
+ );
+ }
+ // Чек-лист: сохранение по каждому переключению, выход — клик вне / Esc
+ if (field.type === 'checklist') {
+ const items = Array.isArray(field.options) ? field.options : [];
+ const valueArr = Array.isArray(editValue) ? (editValue as Array<{ itemId?: string; id?: string; checked?: boolean }>) : [];
+ return (
+ e.stopPropagation()}>
+ {items.map((item: unknown, idx: number) => {
+ const it = item as { id?: string; value?: string; label?: string; name?: string; period?: string };
+ const itemId = it.id || it.value || String(idx);
+ const itemLabel = it.label || it.name || String(item);
+ const checkedItem = valueArr.find((v) => (v.itemId || v.id) === itemId);
+ const isChecked = !!checkedItem?.checked;
+ return (
+
+ );
+ })}
+
+ );
+ }
+ // Пользователь/роль: сохранение сразу по выбору (radix Select)
+ if (field.type === 'user') {
+ return (
+ e.stopPropagation()}>
+ commitValue(val)}
+ users={users}
+ roles={roles}
+ autoOpen
+ />
+
+ );
+ }
+ // Гео: составной редактор, сохранение по закрытию
+ if (field.type === 'geo') {
+ return (
+ e.stopPropagation()}>
+ updateEditValue(val)}
+ />
+
+ );
+ }
+ // Компания: выбор подсказки сохраняет сразу (с autofill), закрытие — сохраняет введённый текст
+ if (field.type === 'company') {
+ return (
+ e.stopPropagation()}>
+ updateEditValue(val)}
+ onSelect={company => {
+ updateEditValue(company.value);
+ if (field.companyAutofill && allFields) {
+ pendingAutofillRef.current = Object.entries(field.companyAutofill)
+ .filter(([, code]) => code && code !== 'none')
+ .flatMap(([dadataKey, code]) => {
+ const targetField = allFields.find(f => f.code === code);
+ return targetField ? [{ fieldId: targetField.id, value: getDadataFieldValue(dadataKey, company) }] : [];
+ });
+ }
+ commitValue(company.value);
+ }}
+ onClear={() => {
+ if (field.companyAutofill && allFields) {
+ pendingAutofillRef.current = Object.values(field.companyAutofill)
+ .filter(code => code && code !== 'none')
+ .flatMap(code => {
+ const targetField = allFields.find(f => f.code === code);
+ return targetField ? [{ fieldId: targetField.id, value: '' }] : [];
+ });
+ }
+ }}
+ />
+
+ );
+ }
+ // Номер договора: составной редактор, сохранение по закрытию
+ if (field.type === 'contract-number') {
+ const cnConfig = (field.options as any)?.contractNumber;
+ return (
+ e.stopPropagation()}>
+ updateEditValue(JSON.stringify(val))}
+ config={cnConfig}
+ formId={formId}
+ fieldId={field.id}
+ compact
+ />
+
+ );
+ }
+ if (field.type === 'table') {
+ const showSubdirectory = isTableSubdirectory;
+ // Подсправочник: составной редактор, сохранение по закрытию
+ if (showSubdirectory) {
+ return (
+ e.stopPropagation()}>
+
updateEditValue(val)}
+ allowMultiple={field.allowMultiple || false}
+ size="detail"
+ />
+
+ );
+ }
+ // Мультивыбор строк: сохранение по каждому переключению
+ if (field.allowMultiple) {
+ return (
+ e.stopPropagation()}>
+
{
+ updateEditValue(val);
+ commitValue(val, { close: false });
+ }}
+ />
+
+ );
+ }
+ // Одиночный выбор строки: сохранение сразу по выбору
+ return (
+ e.stopPropagation()}>
+
commitValue(val)}
+ />
+
+ );
+ }
+ // Контакт: составной редактор, сохранение по закрытию
+ if (field.type === 'contact') {
+ return (
+ e.stopPropagation()}>
+ updateEditValue(JSON.stringify(val))}
+ />
+
+ );
+ }
+ // Связанная задача: сохранение сразу по выбору
+ if (field.type === 'task' && field.linkedFormId) {
+ return (
+ e.stopPropagation()}>
+ commitValue(val)}
+ showCreateButton={false}
+ size="detail"
+ />
+
+ );
+ }
+ return null;
+ };
+
+ // Кнопка — всегда read-only (своя логика действий)
+ if (field.type === 'button') {
+ return ;
+ }
+
+ // Файл — своя логика загрузки
+ if (field.type === 'file') {
+ return ;
+ }
+
+ // Шаблон документа — выбор шаблона и генерация
+ if (field.type === 'document_template') {
+ return (
+ saveValue(val)}
+ readOnly={readOnly}
+ />
+ );
+ }
+
+ const fieldUrl = `/api/tasks/${taskId}/field-values/${field.id}`;
+ const isFieldPending = isPending ? isPending(fieldUrl) : false;
+ const effectiveError = errorMessage || localError;
+ const fieldErrorId = `fv-error-${field.id}`;
+ // checkbox/toggle — мгновенное переключение по клику, без входа в режим редактирования
+ const isInstantToggle = field.type === 'checkbox' || field.type === 'toggle';
+ const editorNode = isEditing && !isInstantToggle ? renderEditor() : null;
+
+ // Значение поля: мгновенный переключатель / редактор / read-view (общий для обеих раскладок label)
+ const valueNode = isInstantToggle ? (
+
+ ) : isEditing && editorNode != null ? (
+
+ {editorNode}
+
+ ) : (
+ {
+ if (!canEdit) return;
+ if (isInteractiveClickTarget(e.target)) return;
+ e.stopPropagation();
+ enterEdit();
+ }}
+ onTouchEnd={handleDoubleTap}
+ >
+ {renderReadValue()}
+
+ );
+
+ return (
+ <>
+
+ {/* Левая колонка: индикаторы обязательности / неизменяемости / карандаш */}
+
+ {isFieldPending ? (
+
+
+
+ ) : fieldState.immutable ? (
+
+ ) : fieldState.required ? (
+
+ *
+
+ ) : (
+ /* Карандаш: на мобильном всегда виден, на десктопе — по hover строки или фокусу */
+ isEditable && !readOnly && !isEditing && (
+
+ )
+ )}
+
+
+ {/* Контент поля: read-view и редактор занимают один и тот же слот (без оверлеев) */}
+
+ {isLabelTop ? (
+ <>
+ {/* Вертикальная раскладка (label.position === 'top'): label-ряд сверху h-5, значение снизу */}
+
+
+ {field.name}
+
+
+
+ {valueNode}
+
+ >
+ ) : (
+
+
+ {field.name}:
+
+ {valueNode}
+
+ )}
+ {isFieldPending && (
+
+
+ ожидает
+
+ )}
+ {effectiveError && (
+
{effectiveError}
+ )}
+
+
+ { if (!open) setNavGeo(null); }}
+ />
+ >
+ );
+}
diff --git a/client/src/components/ui/button.tsx b/client/src/components/ui/button.tsx
index f50ec39..b473b5f 100644
--- a/client/src/components/ui/button.tsx
+++ b/client/src/components/ui/button.tsx
@@ -5,7 +5,8 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
- "inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-[color,background-color,border-color,box-shadow,transform] duration-150 active:not-disabled:scale-[0.96] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
+ // Тема 2.0: радиус кнопок — var(--button-radius), fallback = прежний rounded-md
+ "inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-[var(--button-radius,calc(var(--radius)_-_2px))] text-sm font-medium ring-offset-background transition-[color,background-color,border-color,box-shadow,transform] duration-150 active:not-disabled:scale-[0.96] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
diff --git a/client/src/components/ui/dialog.tsx b/client/src/components/ui/dialog.tsx
index 975cf5b..8669ea5 100644
--- a/client/src/components/ui/dialog.tsx
+++ b/client/src/components/ui/dialog.tsx
@@ -5,6 +5,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
+import { useSkinComponents } from "@/themes/ThemeProvider"
const Dialog = DialogPrimitive.Root
@@ -17,40 +18,54 @@ const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-))
+>(({ className, ...props }, ref) => {
+ // Тема 2.0: backdrop 'blur' добавляет размытие фона под диалогом
+ const { dialog } = useSkinComponents()
+ return (
+
+ )
+})
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
+// Тема 2.0: dialog.mobile === 'sheet' — на мобильных диалог становится full-screen
+// (паттерн из CreateTaskDialog), на десктопе вид не меняется
+const MOBILE_SHEET_CLASSES =
+ "max-md:left-0 max-md:right-0 max-md:top-0 max-md:bottom-0 max-md:translate-x-0 max-md:translate-y-0 max-md:w-full max-md:h-dvh max-md:max-w-none max-md:max-h-none max-md:rounded-none"
+
const DialogContent = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
->(({ className, children, ...props }, ref) => (
-
-
-
- {children}
-
-
- Close
-
-
-
-))
+>(({ className, children, ...props }, ref) => {
+ const { dialog } = useSkinComponents()
+ return (
+
+
+
+ {children}
+
+
+ Close
+
+
+
+ )
+})
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
diff --git a/client/src/components/ui/table.tsx b/client/src/components/ui/table.tsx
index c25d708..0de9d85 100644
--- a/client/src/components/ui/table.tsx
+++ b/client/src/components/ui/table.tsx
@@ -1,6 +1,7 @@
import * as React from "react"
import { cn } from "@/lib/utils"
+import { useSkinComponents } from "@/themes/ThemeProvider"
const Table = React.forwardRef<
HTMLTableElement,
@@ -19,9 +20,17 @@ Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes
->(({ className, ...props }, ref) => (
-
-))
+>(({ className, ...props }, ref) => {
+ // Тема 2.0: шапка таблицы — muted (с фоном, дефолт) или plain (без фона)
+ const { table } = useSkinComponents()
+ return (
+
+ )
+})
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
@@ -54,16 +63,22 @@ TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes
->(({ className, ...props }, ref) => (
-
-))
+>(({ className, ...props }, ref) => {
+ // Тема 2.0: высота строки — var(--table-row-height); разделители — rows (дефолт) / grid / none
+ const { table } = useSkinComponents()
+ return (
+ td]:border-x",
+ className
+ )}
+ {...props}
+ />
+ )
+})
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
@@ -73,7 +88,7 @@ const TableHead = React.forwardRef<
f.type !== 'company' && (tabId === undefined || f.tabId === tabId) && props.isFieldVisibleForTask(f)
+ );
+}
+
+interface PresetFieldProps {
+ preset: LayoutPresetProps;
+ field: FormFieldData;
+ labelPosition?: 'inline' | 'top';
+ labelClassName?: string;
+ rowClassName?: string;
+}
+
+// Строка поля пресета: та же механика, что у TabFieldsContent в TaskDetail —
+// значение с учётом офлайн-очереди, per-field редактирование через FieldValueRow.
+export function PresetField({ preset, field, labelPosition, labelClassName, rowClassName }: PresetFieldProps) {
+ const { isPending: isUrlPending, getPendingBody } = useOfflineSyncContext();
+ const fieldValue = preset.fieldValues.find((fv: TaskFieldValueData) => fv.fieldId === field.id);
+ const fieldUrl = `/api/tasks/${preset.task.id}/field-values/${field.id}`;
+ const pendingBody = isUrlPending(fieldUrl) ? getPendingBody(fieldUrl) : null;
+ const effectiveValue = (pendingBody && 'value' in pendingBody)
+ ? (pendingBody as { value: string | number | boolean | null | undefined }).value
+ : fieldValue?.value;
+
+ return (
+
+ preset.setFieldErrors?.((prev) => { const next = { ...prev }; delete next[field.id]; return next; })}
+ labelPosition={labelPosition}
+ labelClassName={labelClassName}
+ rowClassName={rowClassName}
+ />
+
+ );
+}
diff --git a/client/src/layouts/PyrusPreset.tsx b/client/src/layouts/PyrusPreset.tsx
new file mode 100644
index 0000000..0e48af0
--- /dev/null
+++ b/client/src/layouts/PyrusPreset.tsx
@@ -0,0 +1,58 @@
+import type { LayoutPresetProps } from './types';
+import { PresetField, getPresetVisibleFields } from './PresetField';
+
+// Пресет «Pyrus»: плотный вид, секции по вкладкам формы с разделителями,
+// label uppercase 11px muted, значения 13px, строки с border-b.
+export function PyrusPreset(props: LayoutPresetProps) {
+ // Секции: при выбранной вкладке (desktop) — только она; иначе — все вкладки с полями + блок без вкладки
+ const sections: Array<{ key: string; title: string | null; tabId?: number }> = [];
+
+ if (props.tabId !== undefined) {
+ const tab = props.tabs.find(t => t.id === props.tabId);
+ sections.push({ key: `tab-${props.tabId}`, title: tab?.name ?? null, tabId: props.tabId });
+ } else if (props.tabs.length > 0) {
+ for (const tab of props.tabs) {
+ if (getPresetVisibleFields(props, tab.id).length > 0) {
+ sections.push({ key: `tab-${tab.id}`, title: tab.name, tabId: tab.id });
+ }
+ }
+ const untabbed = getPresetVisibleFields(props, undefined).filter(f => f.tabId == null || !props.tabs.some(t => t.id === f.tabId));
+ if (untabbed.length > 0) {
+ sections.push({ key: 'untabbed', title: 'Прочее', tabId: undefined });
+ }
+ } else {
+ sections.push({ key: 'all', title: null, tabId: undefined });
+ }
+
+ return (
+
+ {sections.map((section) => {
+ const fields = section.key === 'untabbed'
+ ? getPresetVisibleFields(props, undefined).filter(f => f.tabId == null || !props.tabs.some(t => t.id === f.tabId))
+ : getPresetVisibleFields(props, section.tabId);
+ if (fields.length === 0) return null;
+ return (
+
+ {section.title && (
+
+ {section.title}
+
+ )}
+
+ {fields.map((field) => (
+
+ ))}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/client/src/layouts/VerticalPreset.tsx b/client/src/layouts/VerticalPreset.tsx
new file mode 100644
index 0000000..b37a28b
--- /dev/null
+++ b/client/src/layouts/VerticalPreset.tsx
@@ -0,0 +1,29 @@
+import { cn } from '@/lib/utils';
+import type { LayoutPresetProps } from './types';
+import { PresetField, getPresetVisibleFields } from './PresetField';
+
+// Широкие типы полей занимают обе колонки сетки
+const WIDE_FIELD_TYPES = new Set(['textarea', 'rich-text', 'geo', 'contact', 'company', 'file', 'checklist']);
+
+// Пресет «Вертикальное» (NocoBase-стиль): label сверху, сетка в 2 колонки на desktop
+export function VerticalPreset(props: LayoutPresetProps) {
+ const visibleFields = getPresetVisibleFields(props, props.tabId);
+
+ if (visibleFields.length === 0) {
+ return (
+
+ Нет полей для отображения
+
+ );
+ }
+
+ return (
+
+ {visibleFields.map((field) => (
+
+ ))}
+
+ );
+}
diff --git a/client/src/layouts/index.ts b/client/src/layouts/index.ts
new file mode 100644
index 0000000..1e3b94a
--- /dev/null
+++ b/client/src/layouts/index.ts
@@ -0,0 +1,48 @@
+import type { LayoutPresetDef } from './types';
+import { VerticalPreset } from './VerticalPreset';
+import { PyrusPreset } from './PyrusPreset';
+
+// Реестр встроенных пресетов представления области полей карточки задачи.
+// Выбор хранится в forms.detail_layout_code маркером `@preset:` (первая строка) — без миграций.
+// compact — passthrough: пресет = отсутствие кастомного layout (стандартный вид карточки).
+export const LAYOUT_PRESETS: Record = {
+ compact: {
+ id: 'compact',
+ name: 'Стандартное (компактное)',
+ description: 'Текущий компактный вид карточки: label и значение в одной строке.',
+ },
+ vertical: {
+ id: 'vertical',
+ name: 'Вертикальное',
+ description: 'Label над значением, сетка в две колонки на десктопе (стиль NocoBase).',
+ component: VerticalPreset,
+ },
+ pyrus: {
+ id: 'pyrus',
+ name: 'Pyrus',
+ description: 'Плотный вид: секции по вкладкам с разделителями, label uppercase, строки с border-b.',
+ component: PyrusPreset,
+ },
+};
+
+export type ParsedDetailLayout =
+ | { kind: 'preset'; preset: LayoutPresetDef }
+ | { kind: 'js'; code: string }
+ | { kind: 'standard' };
+
+// Разбор detailLayoutCode: маркер `@preset:` (первая строка), JS-код или стандартный вид.
+// Неизвестный preset-id и compact трактуются как стандартный вид (безопасный fallback).
+export function parseDetailLayout(code?: string | null): ParsedDetailLayout {
+ const trimmed = (code ?? '').trim();
+ if (!trimmed) return { kind: 'standard' };
+ const firstLine = trimmed.split('\n', 1)[0].trim();
+ const match = firstLine.match(/^@preset:([a-z0-9-]+)$/);
+ if (match) {
+ const preset = LAYOUT_PRESETS[match[1]];
+ if (preset?.component) return { kind: 'preset', preset };
+ return { kind: 'standard' };
+ }
+ return { kind: 'js', code: trimmed };
+}
+
+export type { LayoutPresetProps, LayoutPresetDef } from './types';
diff --git a/client/src/layouts/types.ts b/client/src/layouts/types.ts
new file mode 100644
index 0000000..f650907
--- /dev/null
+++ b/client/src/layouts/types.ts
@@ -0,0 +1,38 @@
+import type { ComponentType, Dispatch, SetStateAction } from 'react';
+import type { FormFieldData, TaskData, TaskFieldValueData } from '@/components/task-fields/FieldValueRow';
+
+// Пропсы пресета представления области полей карточки задачи.
+// Набор полей повторяет то, что TaskDetail передаёт в JsComponentRenderer (extraCtx),
+// плюс данные для per-field редактирования через FieldValueRow.
+export interface LayoutPresetProps {
+ task: TaskData;
+ formId: number;
+ form: { id: number; name: string };
+ fields: FormFieldData[];
+ fieldValues: TaskFieldValueData[];
+ fieldValuesMap: Record;
+ tabs: Array<{ id: number; name: string; code?: string; type?: string; position?: number }>;
+ statuses: Array<{ id: number; name?: string; color?: string; position: number; isInitial?: boolean; isFinal?: boolean | null }>;
+ users: Array<{ id: number; email: string; firstName?: string | null; lastName?: string | null; middleName?: string | null; fullName?: string }>;
+ roles: Array<{ id: number; name: string }>;
+ // Доступные переходы статусов и исполнители задачи (слоты расширения ctx)
+ transitions?: unknown[];
+ assignees?: unknown[];
+ // Desktop: id текущей вкладки полей; undefined — все поля (нет вкладок / mobile / preview)
+ tabId?: number;
+ mode: 'desktop' | 'mobile' | 'preview';
+ // readOnly: финальный статус задачи или предпросмотр в редакторе формы
+ readOnly?: boolean;
+ conditionContextStatuses: Array<{ id: number; name?: string; position: number; isFinal?: boolean | null }>;
+ isFieldVisibleForTask: (field: FormFieldData) => boolean;
+ fieldErrors?: Record;
+ setFieldErrors?: Dispatch>>;
+}
+
+export interface LayoutPresetDef {
+ id: string;
+ name: string;
+ description: string;
+ // compact — намеренно без компонента: passthrough на стандартный вид карточки
+ component?: ComponentType;
+}
diff --git a/client/src/pages/AppearanceSettings.tsx b/client/src/pages/AppearanceSettings.tsx
index 06e88e5..406668a 100644
--- a/client/src/pages/AppearanceSettings.tsx
+++ b/client/src/pages/AppearanceSettings.tsx
@@ -1,4 +1,4 @@
-import { useState, useMemo, useEffect } from 'react';
+import { useState, useMemo, useEffect, useRef } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { queryClient, apiRequest } from '@/lib/queryClient';
import { useToast } from '@/hooks/use-toast';
@@ -8,6 +8,7 @@ import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useSkin, type Theme } from '@/themes/ThemeProvider';
import {
EDITABLE_COLOR_TOKENS,
@@ -17,7 +18,7 @@ import {
type SkinThemeTokens,
generateSkinCss,
} from '@/themes/tokens';
-import { Loader2, Palette, Copy, Trash2, Check, Sparkles } from 'lucide-react';
+import { Loader2, Palette, Copy, Trash2, Check, Sparkles, Download, Upload } from 'lucide-react';
interface SkinListItem {
id: string;
@@ -70,6 +71,143 @@ function setTokenValue(
return next;
}
+/** Тема 2.0: чтение общего токена (components/density/spacing) — из светлой темы. */
+function getSharedTokenValue(
+ tokens: { light: SkinThemeTokens; dark: SkinThemeTokens },
+ path: string,
+): string | number | boolean | undefined {
+ const parts = path.split('.');
+ let current: any = tokens.light;
+ for (const part of parts) {
+ current = current?.[part];
+ }
+ return current;
+}
+
+/** Тема 2.0: запись общего токена сразу в обе темы; пустое значение удаляет ключ. */
+function setSharedTokenValue(
+ tokens: { light: SkinThemeTokens; dark: SkinThemeTokens },
+ path: string,
+ value: string | number | boolean,
+): { light: SkinThemeTokens; dark: SkinThemeTokens } {
+ const next: { light: SkinThemeTokens; dark: SkinThemeTokens } = {
+ light: cloneTokens(tokens.light),
+ dark: cloneTokens(tokens.dark),
+ };
+ const parts = path.split('.');
+ for (const theme of ['light', 'dark'] as const) {
+ let current: any = next[theme];
+ for (let i = 0; i < parts.length - 1; i++) {
+ if (!current[parts[i]] || typeof current[parts[i]] !== 'object') {
+ current[parts[i]] = {};
+ }
+ current = current[parts[i]];
+ }
+ if (value === '') {
+ delete current[parts[parts.length - 1]];
+ } else {
+ current[parts[parts.length - 1]] = value;
+ }
+ }
+ return next;
+}
+
+/** Тема 2.0: селекты структурных вариантов компонентов для редактора скина. */
+const COMPONENT_SELECTS: Array<{
+ path: string;
+ label: string;
+ defaultValue: string;
+ options: Array<{ value: string; label: string }>;
+ numeric?: boolean;
+}> = [
+ {
+ path: 'density.preset',
+ label: 'Плотность',
+ defaultValue: 'comfortable',
+ options: [
+ { value: 'compact', label: 'Компактная' },
+ { value: 'comfortable', label: 'Комфортная' },
+ { value: 'spacious', label: 'Просторная' },
+ ],
+ },
+ {
+ path: 'components.control.variant',
+ label: 'Вид полей',
+ defaultValue: 'outline',
+ options: [
+ { value: 'outline', label: 'Контурные' },
+ { value: 'filled', label: 'Залитые' },
+ { value: 'underline', label: 'Подчёркнутые' },
+ ],
+ },
+ {
+ path: 'components.label.position',
+ label: 'Подпись поля',
+ defaultValue: 'inline',
+ options: [
+ { value: 'top', label: 'Сверху' },
+ { value: 'inline', label: 'В строку' },
+ ],
+ },
+ {
+ path: 'components.form.columns',
+ label: 'Колонки формы',
+ defaultValue: '2',
+ numeric: true,
+ options: [
+ { value: '1', label: '1 колонка' },
+ { value: '2', label: '2 колонки' },
+ ],
+ },
+ {
+ path: 'components.dialog.mobile',
+ label: 'Диалоги на мобильном',
+ defaultValue: 'modal',
+ options: [
+ { value: 'modal', label: 'Окно' },
+ { value: 'sheet', label: 'На весь экран' },
+ ],
+ },
+ {
+ path: 'components.table.dividers',
+ label: 'Разделители таблиц',
+ defaultValue: 'rows',
+ options: [
+ { value: 'rows', label: 'Строки' },
+ { value: 'grid', label: 'Сетка' },
+ { value: 'none', label: 'Без линий' },
+ ],
+ },
+ {
+ path: 'components.button.radius',
+ label: 'Радиус кнопок',
+ defaultValue: 'md',
+ options: [
+ { value: 'sm', label: 'Малый' },
+ { value: 'md', label: 'Средний' },
+ { value: 'pill', label: 'Капсула' },
+ ],
+ },
+];
+
+/** Тема 2.0: текстовые переопределения отступов (пусто = дефолт). */
+const SPACING_OVERRIDE_INPUTS: Array<{ path: string; label: string; placeholder: string }> = [
+ { path: 'spacing.fieldGapY', label: 'Отступ полей Y', placeholder: '8px' },
+ { path: 'spacing.fieldGapX', label: 'Отступ полей X', placeholder: '16px' },
+ { path: 'spacing.controlPaddingX', label: 'Паддинг контрола', placeholder: '10px' },
+ { path: 'spacing.sectionGap', label: 'Отступ секций', placeholder: '16px' },
+ { path: 'spacing.pageGutter', label: 'Поля страницы', placeholder: '16px' },
+];
+
+/** Тема 2.0: текстовые переопределения высот (пусто = пресет плотности). */
+const DENSITY_OVERRIDE_INPUTS: Array<{ path: string; label: string; placeholder: string }> = [
+ { path: 'density.controlForm', label: 'Высота контрола (форма)', placeholder: '32px' },
+ { path: 'density.controlDetail', label: 'Высота контрола (карточка)', placeholder: '28px' },
+ { path: 'density.controlCell', label: 'Высота контрола (ячейка)', placeholder: '28px' },
+ { path: 'density.tableRow', label: 'Высота строки таблицы', placeholder: '40px' },
+ { path: 'density.fieldRow', label: 'Высота строки поля', placeholder: '28px' },
+];
+
export function AppearanceSettings() {
const { toast } = useToast();
const { skinId: currentSkinId, setSkinId, availableSkins } = useSkin();
@@ -164,6 +302,87 @@ export function AppearanceSettings() {
onError: () => toast({ title: 'Ошибка удаления скина', variant: 'destructive' }),
});
+ // Импорт темы из JSON-файла: создаёт кастомный скин через существующий POST
+ const importFileRef = useRef(null);
+ const importMutation = useMutation({
+ mutationFn: async (payload: { name: string; description?: string; tokens: Skin['tokens'] }) => {
+ const res = await apiRequest('POST', '/api/organization/skins', {
+ skinId: `custom-${Date.now()}`,
+ name: payload.name,
+ description: payload.description,
+ tokens: payload.tokens,
+ });
+ return res.json() as Promise<{ success: boolean; skin: Skin; dbId: number }>;
+ },
+ onSuccess: (result) => {
+ queryClient.invalidateQueries({ queryKey: ['/api/organization/skins'] });
+ setSelectedSkinId(result.skin.id);
+ toast({ title: 'Тема импортирована' });
+ },
+ onError: () => toast({ title: 'Ошибка импорта темы', variant: 'destructive' }),
+ });
+
+ /** Экспорт скина в JSON-файл ({ id, name, description, tokens }). */
+ const handleExport = (skin: Skin) => {
+ if (!skin.tokens?.light || !skin.tokens?.dark) {
+ toast({ title: 'Нет токенов для экспорта', variant: 'destructive' });
+ return;
+ }
+ const payload = {
+ id: skin.id,
+ name: skin.name,
+ description: skin.description,
+ tokens: skin.tokens,
+ };
+ const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = `${skin.id}.json`;
+ link.click();
+ URL.revokeObjectURL(url);
+ };
+
+ /** Экспорт по элементу списка: токены берём из доступных скинов или подгружаем детали. */
+ const handleExportById = async (item: SkinListItem) => {
+ const withTokens = availableSkins.find((s) => s.id === item.id && s.tokens?.light);
+ if (withTokens) {
+ handleExport(withTokens);
+ return;
+ }
+ try {
+ const res = await apiRequest('GET', `/api/organization/skins/${encodeURIComponent(item.id)}`);
+ const data = await res.json();
+ if (data?.skin) {
+ handleExport(data.skin as Skin);
+ } else {
+ toast({ title: 'Не удалось загрузить скин для экспорта', variant: 'destructive' });
+ }
+ } catch {
+ toast({ title: 'Ошибка экспорта темы', variant: 'destructive' });
+ }
+ };
+
+ /** Импорт темы: валидация структуры (id + tokens.light/dark обязательны). */
+ const handleImportFile = async (file: File) => {
+ let parsed: any;
+ try {
+ parsed = JSON.parse(await file.text());
+ } catch {
+ toast({ title: 'Не удалось прочитать файл: это не JSON', variant: 'destructive' });
+ return;
+ }
+ if (!parsed || typeof parsed.id !== 'string' || !parsed.tokens?.light || !parsed.tokens?.dark) {
+ toast({ title: 'Неверная структура файла: нужны id и tokens.light/tokens.dark', variant: 'destructive' });
+ return;
+ }
+ importMutation.mutate({
+ name: typeof parsed.name === 'string' && parsed.name ? parsed.name : parsed.id,
+ description: typeof parsed.description === 'string' ? parsed.description : undefined,
+ tokens: parsed.tokens,
+ });
+ };
+
// Когда выбран встроенный скин, берём токены из availableSkins.
// Когда выбран кастомный — из формы или загружаем детали.
const effectiveSkin = selectedSkin ?? selectedSkinData?.skin;
@@ -190,6 +409,18 @@ export function AppearanceSettings() {
return generateSkinCss({ ...effectiveSkin, tokens } as Skin, previewTheme);
}, [effectiveSkin, form, previewTheme]);
+ // Тема 2.0: обновление общего токена (components/density/spacing) в обеих темах
+ const updateSharedToken = (path: string, value: string | number | boolean) => {
+ if (!effectiveSkin) return;
+ const base = form?.tokens ?? effectiveSkin.tokens;
+ const next = setSharedTokenValue(base, path, value);
+ setForm((prev) => ({
+ name: prev?.name ?? effectiveSkin.name,
+ description: prev?.description ?? effectiveSkin.description ?? '',
+ tokens: next,
+ }));
+ };
+
if (isLoading) {
return (
@@ -270,6 +501,7 @@ export function AppearanceSettings() {
size="sm"
variant="outline"
className="h-7 px-2"
+ title="Дублировать скин"
onClick={(e) => {
e.stopPropagation();
createMutation.mutate({ baseSkinId: skin.id, name: `${skin.name} (копия)` });
@@ -277,6 +509,19 @@ export function AppearanceSettings() {
>
+
{!skin.isBuiltIn && skin.isEditable && (
Создать скин
+
+ {
+ const file = e.target.files?.[0];
+ if (file) handleImportFile(file);
+ e.target.value = '';
+ }}
+ />
@@ -436,6 +700,70 @@ export function AppearanceSettings() {
+
+ Компоненты и отступы
+
+ Тема 2.0: плотность, вид полей и варианты компонентов. Применяется к обеим темам сразу.
+
+
+ {COMPONENT_SELECTS.map((item) => {
+ const raw = getSharedTokenValue(form?.tokens ?? effectiveSkin.tokens, item.path);
+ const value = String(raw ?? item.defaultValue);
+ return (
+
+
+
+
+ );
+ })}
+
+
+
+
+
+
+ {[...SPACING_OVERRIDE_INPUTS, ...DENSITY_OVERRIDE_INPUTS].map((item) => {
+ const value = (getSharedTokenValue(form?.tokens ?? effectiveSkin.tokens, item.path) as string) ?? '';
+ return (
+
+
+ updateSharedToken(item.path, e.target.value)}
+ className="h-8 text-xs"
+ />
+
+ );
+ })}
+
+
+
|