{/* Toolbar chỉ hiện các nút cơ bản để tiết kiệm diện tích */}
{/* Hiển thị số ký tự hoặc trạng thái nhỏ bên góc phải nếu cần */}
HTML Editor
setIsFocused(true)}
onBlur={() => setIsFocused(false)}
className="editor-editable-area"
style={{
// ĐÂY LÀ PHẦN THAY ĐỔI CHÍNH
minHeight: '38px', // Tương đương 1 hàng input thông thường
height: 'auto', // Tự động nở theo nội dung
maxHeight: '600px', // Giới hạn chiều cao tối đa để không làm vỡ layout
padding: '8px 12px'
}}
/>
{isInvalid && {message}}
);
};
const TagInputCtl = ({ label, value = [], onChange, disabled }) => {
// Đảm bảo value luôn là mảng để tránh lỗi .map
const tags = Array.isArray(value) ? value : [];
const handleKeyDown = (e) => {
if (e.key === 'Enter') {
// Chặn tuyệt đối việc trigger submit form hoặc các sự kiện cha
e.preventDefault();
e.stopPropagation();
const inputValue = e.target.value.trim();
// Kiểm tra: Không rỗng và không trùng lặp
if (inputValue && !tags.includes(inputValue)) {
const newTags = [...tags, inputValue];
onChange(newTags);
e.target.value = ''; // Reset input
}
}
};
const removeTag = (idxToRemove, e) => {
e.preventDefault();
e.stopPropagation();
const newTags = tags.filter((_, i) => i !== idxToRemove);
onChange(newTags);
};
return (
{label && }
!disabled && document.getElementById(`input-${label}`)?.focus()}
>
{tags.map((tag, idx) => (
{tag}
{!disabled && (
removeTag(idx, e)}
>
)}
))}
{!disabled && (
{
// Tùy chọn: Tự động thêm tag khi mất focus (blur)
if (e.target.value.trim()) handleKeyDown({ ...e, key: 'Enter', preventDefault: () => {}, stopPropagation: () => {} });
}}
/>
)}
);
};
const DynamicMetaCtl = ({
title = "Thông tin mở rộng",
meta = {},
setMeta,
inValidInput = [],
// Config default
config = {
field_name_tag: { type: "TagInputCtl", visible: true, disable:false, caption: "Field name tag" },
field_name_text: { type: "TextInputCtl", visible: true, disable:false, caption: "Field name text" },
field_name_html: { type: "HtmlContentEditor", visible: true, disable:false, disable:false, caption: "Field name html" },
field_name_number: { type: "Number", visible: true, disable:false, disable:false, caption: "Field name number" },
field_name_check: { type: "Check", visible: true, disable:false, disable:false, caption: "Field name check" },
}
}) => {
const { Row, Col, Accordion, Form } = ReactBootstrap;
const [isOpen, setIsOpen] = React.useState(false);
const handleMetaChange = (key, value) => {
// Chặn cập nhật nếu config định nghĩa field này là disable
if (config[key]?.disable) return;
setMeta(prev => ({ ...prev, [key]: value }));
};
const renderControl = (key, value) => {
const fieldConfig = config[key];
// Chỉ render nếu visible: true
if (!fieldConfig || fieldConfig.visible === false) return null;
// Ưu tiên hiển thị đúng CAPTION từ config
const label = fieldConfig.caption;
const isFieldDisabled = fieldConfig.disable; // Trạng thái khóa field
const invalidObj = inValidInput?.find(n => n.id === key);
// Render dựa theo type
switch (fieldConfig.type) {
case 'TagInputCtl':
return (
handleMetaChange(key, val)}
disabled={isFieldDisabled} // Không thể chỉnh sửa
/>
);
case 'HtmlContentEditor':
return (
handleMetaChange(key, val)}
disabled={isFieldDisabled} // Khóa trình soạn thảo
isInvalid={invalidObj?.isInvalid}
message={invalidObj?.msg}
/>
);
case 'Check':
return (
{options.map((opt, index) => (
{opt}}
name={`group-${key}`} // Đảm bảo chỉ chọn được 1 trong group
value={opt}
checked={value === opt}
disabled={isFieldDisabled}
onChange={() => handleMetaChange(key, opt)}
// Tăng diện tích chạm cho mobile
className="d-flex align-items-center me-3"
style={{ cursor: isFieldDisabled ? 'not-allowed' : 'pointer' }}
/>
))}
{invalidObj?.isInvalid && (
{invalidObj?.msg}
)}
);
case 'Number':
case 'TextInputCtl':
default:
return (
handleMetaChange(key, e.target.value)}
size="sm"
disabled={isFieldDisabled} // Input sẽ mờ và không thể gõ
allowClear={!isFieldDisabled}
id={key}
inValidInput={inValidInput}
/>
);
}
};
return (
setIsOpen(!isOpen)} className="bg-white">
{title}
{/* Map qua config để đảm bảo thứ tự hiển thị và dùng đúng key */}
{Object.keys(config).map(key => renderControl(key, meta[key]))}
);
};
const MetaRender = ({ configStructureMeta, meta, setMeta, level = 0, actionCallBack, parentList, parentIndex, errors = {} }) => {
const { Form, Row, Col, Button } = ReactBootstrap;
// Helper: Định dạng số hàng nghìn
const isVi = window.config?.lang === 'vi' || !window.config?.lang;
const thousandSep = isVi ? '.' : ',';
const decimalSep = isVi ? ',' : '.';
const formatNumber = (val) => {
if (val === undefined || val === null || val === '') return '';
let str = val.toString();
// Preserve negative sign if it is alone
if (str === '-') return '-';
const isNegative = str.startsWith('-');
if (isNegative) {
str = str.substring(1);
}
const parts = str.split('.');
const intPart = parseFloat(parts[0]);
if (isNaN(intPart)) return isNegative ? '-' : '';
const formattedInt = new Intl.NumberFormat(isVi ? 'vi-VN' : 'en-US').format(intPart);
let result = formattedInt;
if (parts.length > 1) {
result = formattedInt + decimalSep + parts[1];
}
return isNegative ? '-' + result : result;
};
const styles = {
container: { fontSize: '13px', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif' },
fieldWrapper: { marginBottom: '8px', padding: '0 4px' },
nestedSection: {
marginLeft: level > 0 ? '10px' : '0',
paddingLeft: level > 0 ? '10px' : '0',
borderLeft: level > 0 ? '2px solid #dee2e6' : 'none',
marginBottom: '12px'
},
label: (isRequired) => ({
fontSize: '11.5px',
fontWeight: '600',
color: isRequired ? '#dc3545' : '#555',
marginBottom: '3px',
display: 'block'
}),
input: {
fontSize: '13px',
borderRadius: '6px',
border: '1px solid #ced4da',
transition: 'border-color 0.15s ease-in-out'
},
numberInput: { textAlign: 'right', fontWeight: '500' },
listCard: {
backgroundColor: '#ffffff',
border: '1px solid #e0e0e0',
borderRadius: '8px',
padding: '10px',
marginBottom: '6px',
position: 'relative',
boxShadow: '0 1px 3px rgba(0,0,0,0.05)'
},
deleteBtn: {
position: 'absolute',
top: '-8px',
right: '-8px',
padding: '0',
width: '20px',
height: '20px',
fontSize: '12px',
borderRadius: '50%',
zIndex: 10
}
};
const hasFieldError = (fieldKey, parentList, parentIndex) => {
if (!errors) return false;
if (parentList !== undefined && parentIndex !== undefined) {
if (fieldKey === 'threshold_from') {
return !!(
errors[`tiers_${parentIndex}_from`] ||
errors[`tiers_${parentIndex}_from_neg`] ||
errors[`tiers_${parentIndex}_overlap`] ||
errors[`tiers_${parentIndex}_gap`]
);
}
if (fieldKey === 'threshold_to') {
return !!(
errors[`tiers_${parentIndex}_to`] ||
errors[`tiers_${parentIndex}_to_neg`] ||
errors[`tiers_${parentIndex}_to_compare_from`]
);
}
if (fieldKey === 'tier_value') {
return !!errors[`tiers_${parentIndex}_value`];
}
} else {
return !!errors[fieldKey];
}
return false;
};
const getFieldErrorText = (fieldKey, parentList, parentIndex) => {
if (!errors) return '';
if (parentList !== undefined && parentIndex !== undefined) {
if (fieldKey === 'threshold_from') {
return (
errors[`tiers_${parentIndex}_from`] ||
errors[`tiers_${parentIndex}_from_neg`] ||
errors[`tiers_${parentIndex}_overlap`] ||
errors[`tiers_${parentIndex}_gap`] ||
''
);
}
if (fieldKey === 'threshold_to') {
return (
errors[`tiers_${parentIndex}_to`] ||
errors[`tiers_${parentIndex}_to_neg`] ||
errors[`tiers_${parentIndex}_to_compare_from`] ||
''
);
}
if (fieldKey === 'tier_value') {
return errors[`tiers_${parentIndex}_value`] || '';
}
} else {
return errors[fieldKey] || '';
}
return '';
};
const handleUpdate = (path, value) => {
const updateDeep = (obj, pathArray, val) => {
const [current, ...rest] = pathArray;
if (rest.length === 0) {
let finalVal = val;
// Tự động chuẩn hóa liên kết mốc khi cập nhật mảng (ví dụ: sau khi xóa)
if (Array.isArray(val) && val.length > 0 && typeof val[0] === 'object' && val[0] !== null && 'threshold_from' in val[0] && 'threshold_to' in val[0]) {
finalVal = val.map((item, idx) => {
if (idx === 0) return item;
return {
...item,
threshold_from: val[idx - 1].threshold_to
};
});
}
return { ...obj, [current]: finalVal };
}
return {
...obj,
[current]: updateDeep(obj[current] || {}, rest, val)
};
};
const pathParts = path.split('.');
if (pathParts.length === 3 && pathParts[2] === 'threshold_to') {
const listKey = pathParts[0];
const idx = parseInt(pathParts[1]);
const nextIdx = idx + 1;
setMeta(prev => {
let updated = updateDeep(prev, pathParts, value);
const list = updated[listKey];
if (Array.isArray(list) && nextIdx < list.length) {
const nextPath = `${listKey}.${nextIdx}.threshold_from`;
updated = updateDeep(updated, nextPath.split('.'), value);
}
return updated;
});
} else {
setMeta(prev => updateDeep(prev, pathParts, value));
}
};
// Xử lý sự kiện nhấn phím Enter
const handleKeyDown = (e, cfg, key, val) => {
if (e.key === 'Enter' && cfg.action_callback && actionCallBack) {
e.preventDefault(); // Ngăn submit form mặc định
actionCallBack(key, val);
}
};
const handleAddListItem = (path, itemProperties, currentList) => {
const newItem = {};
Object.keys(itemProperties).forEach(key => {
if (itemProperties[key].type === 'list_object') newItem[key] = [];
else if (itemProperties[key].type === 'object') newItem[key] = {};
else newItem[key] = itemProperties[key].defaultValue || (itemProperties[key].type === 'number' ? 0 : '');
});
// Tự động điền mốc Từ của dòng mới bằng mốc Đến của dòng cuối cùng trước đó
if (currentList && currentList.length > 0) {
const lastItem = currentList[currentList.length - 1];
if ('threshold_from' in newItem && lastItem && 'threshold_to' in lastItem) {
newItem.threshold_from = lastItem.threshold_to;
}
}
handleUpdate(path, [...(currentList || []), newItem]);
};
const renderControl = (cfg, val, path, fieldKey) => {
const isRequired = cfg.required === true;
// Khóa cứng Từ mốc (threshold_from) của các dòng từ dòng thứ 2 trở đi (index > 0)
let isControlDisabled = cfg.disabled;
if (fieldKey === 'threshold_from' && parentIndex !== undefined && parentIndex > 0) {
isControlDisabled = true;
}
const commonProps = {
size: "sm",
style: { ...styles.input, ...(cfg.type === 'number' ? styles.numberInput : {}) },
disabled: isControlDisabled,
placeholder: cfg.placeholder || '',
onKeyDown: (e) => handleKeyDown(e, cfg, fieldKey, val) // Gắn sự kiện Enter
};
switch (cfg.type) {
case 'dropdown':
return (
handleUpdate(path, e.target.value)}
>
{cfg.options?.map(opt => )}
);
case 'check':
return (
{cfg.label} {isRequired && '*'}}
checked={!!val}
disabled={cfg.disabled}
onChange={(e) => {
const newValue = e.target.checked;
handleUpdate(path, newValue);
// Call nhanh nếu là checkbox và có action_callback (thường Enter cho text, nhưng trigger on change cho switch sẽ tiện hơn)
if(cfg.action_callback && actionCallBack) actionCallBack(fieldKey, newValue);
}}
/>
);
case 'multicheck':
const selectedList = Array.isArray(val) ? val : [];
return (