mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 10:03:45 +03:00
484 lines
23 KiB
JavaScript
484 lines
23 KiB
JavaScript
import { DropdownField } from "./DropdownField.jsx";
|
||
import { resolveAvatarSrc, roleLabel } from "./utils.js";
|
||
import { AvatarCropEditor } from "./AvatarCropEditor.jsx";
|
||
|
||
const { useEffect, useRef, useState } = React;
|
||
|
||
export function RecordModal({
|
||
open,
|
||
title,
|
||
tableKey,
|
||
mode,
|
||
fields,
|
||
form,
|
||
status,
|
||
accessToken,
|
||
onClose,
|
||
onChange,
|
||
onSubmit,
|
||
onUploadField,
|
||
onUploadFieldWithCrop,
|
||
onRecropAvatar,
|
||
onApplyRecrop,
|
||
OverlayComponent,
|
||
IconButtonComponent,
|
||
UserAvatarComponent,
|
||
StatusLineComponent,
|
||
}) {
|
||
const Overlay = OverlayComponent;
|
||
const IconButton = IconButtonComponent;
|
||
const UserAvatar = UserAvatarComponent;
|
||
const StatusLine = StatusLineComponent;
|
||
const [avatarPreviewOpen, setAvatarPreviewOpen] = useState(false);
|
||
const [userEditing, setUserEditing] = useState(false);
|
||
const [cropFile, setCropFile] = useState(null); // File waiting for crop selection
|
||
const [cropInitial, setCropInitial] = useState(null); // {x,y,zoom} restored from avatar_crop_json
|
||
const isRecropRef = useRef(false); // true when cropFile came from existing original (re-crop flow)
|
||
const avatarUploadRef = useRef(null);
|
||
const visibleFields = (fields || []).filter((field) => {
|
||
if (field.hidden) return false;
|
||
if (typeof field.visibleWhen !== "function") return true;
|
||
try {
|
||
return Boolean(field.visibleWhen(form || {}));
|
||
} catch (_) {
|
||
return true;
|
||
}
|
||
});
|
||
const isUserModal = tableKey === "users";
|
||
const avatarField = isUserModal ? visibleFields.find((field) => field.key === "avatar_url") : null;
|
||
const topicField = isUserModal ? visibleFields.find((field) => field.key === "primary_topic_code") : null;
|
||
const formFields = isUserModal ? visibleFields.filter((field) => field.key !== "avatar_url") : visibleFields;
|
||
const fieldMap = new Map(visibleFields.map((field) => [field.key, field]));
|
||
const avatarValue = String(form?.avatar_url || "").trim();
|
||
const userName = String(form?.name || "").trim();
|
||
const userEmail = String(form?.email || "").trim();
|
||
const userPhone = String(form?.phone || "").trim();
|
||
const userRole = roleLabel(form?.role);
|
||
const topicOptions = topicField && typeof topicField.options === "function" ? topicField.options(form || {}) : [];
|
||
const currentTopicValue = String(form?.primary_topic_code || "").trim();
|
||
const userTopic =
|
||
(topicOptions.find((option) => String(option?.value || "") === currentTopicValue)?.label || currentTopicValue || "").trim() ||
|
||
"Профиль не указан";
|
||
const defaultRate = String(form?.default_rate || "").trim();
|
||
const salaryPercent = String(form?.salary_percent || "").trim();
|
||
const userActiveRaw = String(form?.is_active ?? "");
|
||
const activeLabel = userActiveRaw === "false" ? "Неактивен" : userActiveRaw === "true" || !userActiveRaw ? "Активен" : "Статус не задан";
|
||
const avatarPreviewSrc = avatarValue ? resolveAvatarSrc(avatarValue, accessToken, 512) : "";
|
||
const statusTone = userActiveRaw === "false" ? "danger" : userActiveRaw === "true" || !userActiveRaw ? "success" : "warn";
|
||
const isCreateMode = isUserModal && mode === "create";
|
||
|
||
useEffect(() => {
|
||
if (!isUserModal) {
|
||
setUserEditing(false);
|
||
setAvatarPreviewOpen(false);
|
||
return;
|
||
}
|
||
setUserEditing(isCreateMode);
|
||
setAvatarPreviewOpen(false);
|
||
}, [isCreateMode, isUserModal, open, tableKey]);
|
||
|
||
if (!open) return null;
|
||
|
||
const renderField = (field) => {
|
||
const value = form[field.key] ?? "";
|
||
const options = typeof field.options === "function" ? field.options(form || {}) : [];
|
||
const id = "record-field-" + field.key;
|
||
const disabled = Boolean(field.readOnly) || (typeof field.readOnlyWhen === "function" ? Boolean(field.readOnlyWhen(form || {})) : false);
|
||
|
||
if (field.type === "textarea" || field.type === "json") {
|
||
return (
|
||
<textarea
|
||
id={id}
|
||
value={value}
|
||
onChange={(event) => onChange(field.key, event.target.value)}
|
||
placeholder={field.placeholder || ""}
|
||
required={Boolean(field.required)}
|
||
disabled={disabled}
|
||
/>
|
||
);
|
||
}
|
||
if (field.type === "boolean") {
|
||
return (
|
||
<DropdownField
|
||
id={id}
|
||
value={value}
|
||
onChange={(nextValue) => onChange(field.key, nextValue)}
|
||
options={[
|
||
{ value: "true", label: "Да" },
|
||
{ value: "false", label: "Нет" },
|
||
]}
|
||
disabled={disabled}
|
||
placeholder="Выберите значение"
|
||
/>
|
||
);
|
||
}
|
||
if (field.type === "reference" || field.type === "enum") {
|
||
const extraOptions = Array.isArray(field.extraOptions) ? field.extraOptions : [];
|
||
const hasCurrentValue =
|
||
String(value || "").trim() !== "" &&
|
||
[...extraOptions, ...options].some((option) => String(option?.value || "") === String(value));
|
||
const selectOptions = [];
|
||
if (field.optional) selectOptions.push({ value: "", label: "-" });
|
||
if (!hasCurrentValue && String(value || "").trim() !== "") selectOptions.push({ value: String(value), label: String(value) });
|
||
extraOptions.forEach((option) => selectOptions.push({ value: String(option.value), label: option.label }));
|
||
options.forEach((option) => selectOptions.push({ value: String(option.value), label: option.label }));
|
||
return (
|
||
<DropdownField
|
||
id={id}
|
||
value={value}
|
||
onChange={(nextValue) => onChange(field.key, nextValue)}
|
||
options={selectOptions}
|
||
disabled={disabled}
|
||
placeholder={field.optional ? "-" : field.placeholder || "Выберите значение"}
|
||
/>
|
||
);
|
||
}
|
||
if (field.uploadScope) {
|
||
return (
|
||
<div className="field-inline">
|
||
<input
|
||
id={id}
|
||
type="text"
|
||
value={value}
|
||
onChange={(event) => onChange(field.key, event.target.value)}
|
||
placeholder={field.placeholder || ""}
|
||
required={Boolean(field.required)}
|
||
disabled={disabled}
|
||
/>
|
||
<label className="btn secondary btn-sm" style={{ whiteSpace: "nowrap", opacity: disabled ? 0.6 : 1, pointerEvents: disabled ? "none" : "auto" }}>
|
||
Загрузить
|
||
<input
|
||
type="file"
|
||
accept={field.accept || "*/*"}
|
||
style={{ display: "none" }}
|
||
onChange={(event) => {
|
||
const file = event.target.files && event.target.files[0];
|
||
if (file && onUploadField) onUploadField(field, file);
|
||
event.target.value = "";
|
||
}}
|
||
disabled={disabled}
|
||
/>
|
||
</label>
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<input
|
||
id={id}
|
||
type={field.type === "number" ? "number" : field.type === "password" ? "password" : "text"}
|
||
step={field.type === "number" ? "any" : undefined}
|
||
value={value}
|
||
onChange={(event) => onChange(field.key, event.target.value)}
|
||
placeholder={field.placeholder || ""}
|
||
required={Boolean(field.required)}
|
||
disabled={disabled}
|
||
/>
|
||
);
|
||
};
|
||
|
||
const renderUserCard = (fieldKey) => {
|
||
const field = fieldMap.get(fieldKey);
|
||
if (!field) return null;
|
||
const value = form[fieldKey] ?? "";
|
||
const isPassword = fieldKey === "password";
|
||
const inEdit = isCreateMode || userEditing;
|
||
let content = null;
|
||
|
||
if (inEdit) {
|
||
content = renderField(field);
|
||
} else if (isPassword) {
|
||
content = <span className="record-user-card-value muted">Пароль скрыт</span>;
|
||
} else {
|
||
let displayValue = value;
|
||
if (fieldKey === "role") displayValue = userRole || "Не указана";
|
||
if (fieldKey === "is_active") displayValue = activeLabel;
|
||
if (fieldKey === "primary_topic_code") displayValue = userTopic;
|
||
if (fieldKey === "default_rate") displayValue = defaultRate || "—";
|
||
if (fieldKey === "salary_percent") displayValue = salaryPercent || "—";
|
||
content = <span className="record-user-card-value">{String(displayValue || "Не указано")}</span>;
|
||
}
|
||
|
||
return (
|
||
<div className="record-user-card" key={fieldKey}>
|
||
<span className="record-user-card-label">{field.label}</span>
|
||
{content}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const renderUserRateCard = () => {
|
||
const inEdit = isCreateMode || userEditing;
|
||
if (inEdit) {
|
||
return (
|
||
<div className="record-user-card" key="rate-combo">
|
||
<span className="record-user-card-label">Ставка / % зарплаты</span>
|
||
<div className="record-user-rate-grid">
|
||
{fieldMap.get("default_rate") ? renderField(fieldMap.get("default_rate")) : null}
|
||
{fieldMap.get("salary_percent") ? renderField(fieldMap.get("salary_percent")) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<div className="record-user-summary-item" key="rate-combo-view">
|
||
<span className="record-user-summary-label">Ставка / % зарплаты</span>
|
||
<span className="record-user-summary-value">{defaultRate || "—"} / {salaryPercent || "—"}</span>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<Overlay open={open} id="record-overlay" onClose={(event) => event.target.id === "record-overlay" && onClose()}>
|
||
<div className={"modal" + (isUserModal ? " record-user-modal" : "")} style={{ width: isUserModal ? "min(920px, 100%)" : "min(760px, 100%)" }} onClick={(event) => event.stopPropagation()}>
|
||
<div className="modal-head">
|
||
<div>
|
||
<h3>{title}</h3>
|
||
<p className="muted" style={{ marginTop: "0.35rem" }}>
|
||
{isUserModal ? (isCreateMode ? "Создание профиля пользователя." : userEditing ? "Редактирование профиля пользователя." : "Просмотр профиля пользователя.") : "Создание и редактирование записи."}
|
||
</p>
|
||
</div>
|
||
<div className="modal-head-actions">
|
||
{isUserModal && !isCreateMode ? (
|
||
userEditing ? (
|
||
<>
|
||
<button className="icon-btn" type="submit" form="record-modal-form" data-tooltip="Сохранить" aria-label="Сохранить">
|
||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
|
||
<path d="M5 4h11.59a2 2 0 0 1 1.41.59l1.41 1.41A2 2 0 0 1 20 7.41V19a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5a1 1 0 0 1 1-1Zm1 2v13h12V8.24L15.76 6H15v4a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V6H6Zm4 0v3h3V6h-3Z" fill="currentColor" />
|
||
</svg>
|
||
</button>
|
||
<button className="icon-btn" type="button" onClick={onClose} data-tooltip="Закрыть" aria-label="Закрыть">
|
||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
|
||
<path d="M6.7 6.7a1 1 0 0 1 1.4 0L12 10.58l3.9-3.88a1 1 0 1 1 1.4 1.42L13.42 12l3.88 3.9a1 1 0 1 1-1.42 1.4L12 13.42l-3.9 3.88a1 1 0 0 1-1.4-1.42L10.58 12 6.7 8.1a1 1 0 0 1 0-1.4Z" fill="currentColor" />
|
||
</svg>
|
||
</button>
|
||
</>
|
||
) : (
|
||
<button className="icon-btn" type="button" onClick={() => setUserEditing(true)} data-tooltip="Редактировать" aria-label="Редактировать">
|
||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
|
||
<path d="M15.86 3.49a2 2 0 0 1 2.83 0l1.82 1.82a2 2 0 0 1 0 2.83l-9.9 9.9a1 1 0 0 1-.45.26l-4 1a1 1 0 0 1-1.21-1.21l1-4a1 1 0 0 1 .26-.45l9.9-9.9Zm1.41 1.42-9.67 9.67-.54 2.16 2.16-.54 9.67-9.67-1.62-1.62Z" fill="currentColor" />
|
||
</svg>
|
||
</button>
|
||
)
|
||
) : null}
|
||
<button className="close" type="button" onClick={onClose}>
|
||
×
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<form className={"stack" + (isUserModal ? " record-user-scroll" : "")} id="record-modal-form" onSubmit={onSubmit}>
|
||
{isUserModal ? (
|
||
<div className={"record-user-top" + (cropFile ? " record-user-top--crop-mode" : "")}>
|
||
<div className="record-user-avatar-area">
|
||
{cropFile ? (
|
||
// Crop editor takes over the avatar area while selecting focus
|
||
<AvatarCropEditor
|
||
imageFile={cropFile}
|
||
initialCrop={cropInitial}
|
||
onApply={({ file, cropJson }) => {
|
||
const wasRecrop = isRecropRef.current;
|
||
isRecropRef.current = false;
|
||
setCropFile(null);
|
||
setCropInitial(null);
|
||
if (wasRecrop && onApplyRecrop) {
|
||
// Re-crop flow: just send new crop params, no re-upload
|
||
onApplyRecrop(cropJson);
|
||
} else if (onUploadFieldWithCrop) {
|
||
onUploadFieldWithCrop(avatarField, file, cropJson);
|
||
} else if (onUploadField) {
|
||
onUploadField(avatarField, file);
|
||
}
|
||
}}
|
||
onCancel={() => {
|
||
isRecropRef.current = false;
|
||
setCropFile(null);
|
||
setCropInitial(null);
|
||
}}
|
||
/>
|
||
) : (
|
||
<>
|
||
<button
|
||
type="button"
|
||
className={"record-user-avatar-shell" + (avatarPreviewSrc ? " interactive" : "")}
|
||
onClick={() => {
|
||
if (avatarPreviewSrc) setAvatarPreviewOpen(true);
|
||
}}
|
||
disabled={!avatarPreviewSrc}
|
||
aria-label={avatarPreviewSrc ? "Открыть аватар крупно" : "Аватар не загружен"}
|
||
>
|
||
<UserAvatar name={userName} email={userEmail} avatarUrl={avatarValue} accessToken={accessToken} size={148} />
|
||
</button>
|
||
{avatarField && (isCreateMode || userEditing) ? (
|
||
<>
|
||
<input
|
||
ref={avatarUploadRef}
|
||
type="file"
|
||
accept={avatarField.accept || "image/*"}
|
||
style={{ display: "none" }}
|
||
onChange={(event) => {
|
||
const file = event.target.files && event.target.files[0];
|
||
if (file) {
|
||
setCropInitial(null); // new upload → start from center
|
||
setCropFile(file);
|
||
}
|
||
event.target.value = "";
|
||
}}
|
||
/>
|
||
<div className="record-user-avatar-toolbar">
|
||
<IconButton
|
||
icon={
|
||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
|
||
<path d="M12 5a1 1 0 0 1 1 1v6.17l2.59-2.58a1 1 0 1 1 1.41 1.42l-4.29 4.29a1 1 0 0 1-1.42 0L7 11.01a1 1 0 1 1 1.41-1.42L11 12.17V6a1 1 0 0 1 1-1Zm-7 12a1 1 0 0 1 1 1v1h12v-1a1 1 0 1 1 2 0v2a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-2a1 1 0 0 1 1-1Z" fill="currentColor" />
|
||
</svg>
|
||
}
|
||
tooltip="Загрузить аватар"
|
||
onClick={() => avatarUploadRef.current?.click()}
|
||
/>
|
||
{avatarValue && form?.avatar_original_key && onRecropAvatar ? (
|
||
<IconButton
|
||
icon={
|
||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
|
||
<path d="M7 3a1 1 0 0 1 1 1v1h8V4a1 1 0 1 1 2 0v1h1a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h1V4a1 1 0 0 1 1-1Zm-2 6v10h14V9H5Zm4 2h2v2H9v-2Zm4 0h2v2h-2v-2Zm-4 4h2v2H9v-2Zm4 0h2v2h-2v-2Z" fill="currentColor" />
|
||
</svg>
|
||
}
|
||
tooltip="Изменить кадрирование"
|
||
onClick={() => {
|
||
isRecropRef.current = true;
|
||
// Restore previously saved crop so editor opens at last position
|
||
let saved = null;
|
||
try {
|
||
saved = form?.avatar_crop_json
|
||
? JSON.parse(form.avatar_crop_json)
|
||
: null;
|
||
} catch (_) {}
|
||
setCropInitial(saved);
|
||
onRecropAvatar(avatarField, form, setCropFile);
|
||
}}
|
||
/>
|
||
) : null}
|
||
<IconButton
|
||
icon={
|
||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
|
||
<path d="M6.7 6.7a1 1 0 0 1 1.4 0L12 10.58l3.9-3.88a1 1 0 1 1 1.4 1.42L13.42 12l3.88 3.9a1 1 0 1 1-1.42 1.4L12 13.42l-3.9 3.88a1 1 0 0 1-1.4-1.42L10.58 12 6.7 8.1a1 1 0 0 1 0-1.4Z" fill="currentColor" />
|
||
</svg>
|
||
}
|
||
tooltip="Сбросить аватар"
|
||
onClick={() => {
|
||
onChange(avatarField.key, "");
|
||
setAvatarPreviewOpen(false);
|
||
}}
|
||
disabled={!avatarValue}
|
||
/>
|
||
</div>
|
||
</>
|
||
) : null}
|
||
</>
|
||
)}
|
||
</div>
|
||
<div className="record-user-summary">
|
||
<div className="record-user-summary-head">
|
||
{isCreateMode || userEditing ? renderUserCard("name") : <h4>{userName || "Новый пользователь"}</h4>}
|
||
{isCreateMode || userEditing ? (
|
||
<div className="record-user-summary-edit-meta">
|
||
{renderUserCard("role")}
|
||
{renderUserCard("is_active")}
|
||
</div>
|
||
) : (
|
||
<div className="record-user-summary-badges">
|
||
<span className="record-user-badge">{userRole || "Роль не выбрана"}</span>
|
||
<span className={"record-user-badge status-" + statusTone}>{activeLabel}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="record-user-summary-grid">
|
||
{isCreateMode || userEditing ? (
|
||
<>
|
||
{renderUserCard("email")}
|
||
{renderUserCard("phone")}
|
||
{renderUserCard("primary_topic_code")}
|
||
{renderUserRateCard()}
|
||
{renderUserCard("password")}
|
||
</>
|
||
) : (
|
||
<>
|
||
<div className="record-user-summary-item">
|
||
<span className="record-user-summary-label">Email</span>
|
||
<span className="record-user-summary-value">{userEmail || "Не указан"}</span>
|
||
</div>
|
||
<div className="record-user-summary-item">
|
||
<span className="record-user-summary-label">Телефон</span>
|
||
<span className="record-user-summary-value">{userPhone || "Не указан"}</span>
|
||
</div>
|
||
<div className="record-user-summary-item">
|
||
<span className="record-user-summary-label">Профиль</span>
|
||
<span className="record-user-summary-value">{userTopic}</span>
|
||
</div>
|
||
{renderUserRateCard()}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
{!isUserModal ? (
|
||
<div className="filters" style={{ gridTemplateColumns: "repeat(2, minmax(0,1fr))" }}>
|
||
{formFields.map((field) => (
|
||
<div className="field" key={field.key} style={field.fullRow ? { gridColumn: "1 / -1" } : undefined}>
|
||
<label htmlFor={"record-field-" + field.key}>{field.label}</label>
|
||
{renderField(field)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
{isUserModal && isCreateMode ? (
|
||
<div style={{ display: "flex", gap: "0.6rem", flexWrap: "wrap" }}>
|
||
<button className="btn" type="submit">
|
||
Сохранить
|
||
</button>
|
||
<button className="btn secondary" type="button" onClick={onClose}>
|
||
Отмена
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
{!isUserModal ? (
|
||
<div style={{ display: "flex", gap: "0.6rem", flexWrap: "wrap" }}>
|
||
<button className="btn" type="submit">
|
||
Сохранить
|
||
</button>
|
||
<button className="btn secondary" type="button" onClick={onClose}>
|
||
Отмена
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
<StatusLine status={status} />
|
||
</form>
|
||
</div>
|
||
{isUserModal ? (
|
||
<Overlay open={avatarPreviewOpen} id="record-avatar-preview-overlay" onClose={() => setAvatarPreviewOpen(false)}>
|
||
<div className="modal record-avatar-preview-modal" onClick={(event) => event.stopPropagation()}>
|
||
<div className="modal-head">
|
||
<div>
|
||
<h3>{userName || "Аватар пользователя"}</h3>
|
||
<p className="muted" style={{ marginTop: "0.35rem" }}>
|
||
Простомотр изображения.
|
||
</p>
|
||
</div>
|
||
<button className="close" type="button" onClick={() => setAvatarPreviewOpen(false)} aria-label="Закрыть">
|
||
×
|
||
</button>
|
||
</div>
|
||
<div className="record-avatar-preview-body">
|
||
{avatarPreviewSrc ? (
|
||
<img className="record-avatar-preview-image" src={avatarPreviewSrc} alt={userName || userEmail || "avatar"} />
|
||
) : (
|
||
<div className="record-avatar-preview-empty">
|
||
<UserAvatar name={userName} email={userEmail} avatarUrl="" accessToken={accessToken} size={128} />
|
||
<span>Аватар еще не загружен</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Overlay>
|
||
) : null}
|
||
</Overlay>
|
||
);
|
||
}
|