feat: Replace purpose selection in BillingManager forms with PurposeSelectInput component, streamlining the UI and improving code maintainability by removing hardcoded options.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m38s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m38s
This commit is contained in:
@@ -8,6 +8,7 @@ import PageHeader from './components/PageHeader.jsx';
|
|||||||
import PageHeaderActions from './components/PageHeaderActions.jsx';
|
import PageHeaderActions from './components/PageHeaderActions.jsx';
|
||||||
import FormField from './components/FormField.jsx';
|
import FormField from './components/FormField.jsx';
|
||||||
import ServerAutocompleteInput from './components/ServerAutocompleteInput.jsx';
|
import ServerAutocompleteInput from './components/ServerAutocompleteInput.jsx';
|
||||||
|
import PurposeSelectInput, { PURPOSES as PURPOSE_OPTIONS } from './components/PurposeSelectInput.jsx';
|
||||||
import ErrorAlert from './components/ErrorAlert.jsx';
|
import ErrorAlert from './components/ErrorAlert.jsx';
|
||||||
import {
|
import {
|
||||||
IconPlus,
|
IconPlus,
|
||||||
@@ -1577,17 +1578,6 @@ function BillingManager() {
|
|||||||
|
|
||||||
// Компоненты форм для модальных окон
|
// Компоненты форм для модальных окон
|
||||||
function AddBillingForm({ item, onItemChange, servers }) {
|
function AddBillingForm({ item, onItemChange, servers }) {
|
||||||
const purposeOptions = [
|
|
||||||
{ value: '', label: 'Выберите назначение' },
|
|
||||||
{ value: 'relay', label: 'Relay VPS' },
|
|
||||||
{ value: 'bgp', label: 'BGP сервер' },
|
|
||||||
{ value: 'monitoring', label: 'Мониторинг' },
|
|
||||||
{ value: 'dns', label: 'DNS сервер' },
|
|
||||||
{ value: 'proxy', label: 'Прокси сервер' },
|
|
||||||
{ value: 'backup', label: 'Backup сервер' },
|
|
||||||
{ value: 'other', label: 'Другое' }
|
|
||||||
];
|
|
||||||
|
|
||||||
const statusOptions = [
|
const statusOptions = [
|
||||||
{ value: 'active', label: 'Активен' },
|
{ value: 'active', label: 'Активен' },
|
||||||
{ value: 'inactive', label: 'Неактивен' }
|
{ value: 'inactive', label: 'Неактивен' }
|
||||||
@@ -1635,13 +1625,10 @@ function AddBillingForm({ item, onItemChange, servers }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-6">
|
<div className="col-md-6">
|
||||||
<FormField
|
<label className="form-label">Назначение</label>
|
||||||
label="Назначение"
|
<PurposeSelectInput
|
||||||
name="purpose"
|
|
||||||
type="select"
|
|
||||||
value={item.purpose}
|
value={item.purpose}
|
||||||
onChange={(value) => onItemChange({ ...item, purpose: value })}
|
onChange={(value) => onItemChange({ ...item, purpose: value })}
|
||||||
options={purposeOptions}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-6">
|
<div className="col-md-6">
|
||||||
@@ -1731,17 +1718,6 @@ function AddBillingForm({ item, onItemChange, servers }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function EditBillingForm({ item, onItemChange, servers }) {
|
function EditBillingForm({ item, onItemChange, servers }) {
|
||||||
const purposeOptions = [
|
|
||||||
{ value: '', label: 'Выберите назначение' },
|
|
||||||
{ value: 'relay', label: 'Relay VPS' },
|
|
||||||
{ value: 'bgp', label: 'BGP сервер' },
|
|
||||||
{ value: 'monitoring', label: 'Мониторинг' },
|
|
||||||
{ value: 'dns', label: 'DNS сервер' },
|
|
||||||
{ value: 'proxy', label: 'Прокси сервер' },
|
|
||||||
{ value: 'backup', label: 'Backup сервер' },
|
|
||||||
{ value: 'other', label: 'Другое' }
|
|
||||||
];
|
|
||||||
|
|
||||||
const statusOptions = [
|
const statusOptions = [
|
||||||
{ value: 'active', label: 'Активен' },
|
{ value: 'active', label: 'Активен' },
|
||||||
{ value: 'inactive', label: 'Неактивен' }
|
{ value: 'inactive', label: 'Неактивен' }
|
||||||
@@ -1789,13 +1765,10 @@ function EditBillingForm({ item, onItemChange, servers }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-6">
|
<div className="col-md-6">
|
||||||
<FormField
|
<label className="form-label">Назначение</label>
|
||||||
label="Назначение"
|
<PurposeSelectInput
|
||||||
name="purpose"
|
|
||||||
type="select"
|
|
||||||
value={item.purpose || ''}
|
value={item.purpose || ''}
|
||||||
onChange={(value) => onItemChange({ ...item, purpose: value })}
|
onChange={(value) => onItemChange({ ...item, purpose: value })}
|
||||||
options={purposeOptions}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-6">
|
<div className="col-md-6">
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { useMemo, useRef, useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
IconRoute,
|
||||||
|
IconTopologyStar3,
|
||||||
|
IconActivity,
|
||||||
|
IconWorld,
|
||||||
|
IconShield,
|
||||||
|
IconDatabaseExport,
|
||||||
|
IconDots,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
|
||||||
|
const PURPOSES = [
|
||||||
|
{
|
||||||
|
value: 'relay',
|
||||||
|
label: 'Relay VPS',
|
||||||
|
description: 'Трафик/ретрансляция',
|
||||||
|
icon: IconRoute,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'bgp',
|
||||||
|
label: 'BGP сервер',
|
||||||
|
description: 'Маршрутизация и объявления префиксов',
|
||||||
|
icon: IconTopologyStar3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'monitoring',
|
||||||
|
label: 'Мониторинг',
|
||||||
|
description: 'Сбор метрик и алертов',
|
||||||
|
icon: IconActivity,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'dns',
|
||||||
|
label: 'DNS сервер',
|
||||||
|
description: 'Резолвинг и обслуживание DNS-зон',
|
||||||
|
icon: IconWorld,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'proxy',
|
||||||
|
label: 'Прокси сервер',
|
||||||
|
description: 'Проксирование HTTP(S)/SOCKS и т.п.',
|
||||||
|
icon: IconShield,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'backup',
|
||||||
|
label: 'Backup сервер',
|
||||||
|
description: 'Резервные копии и архивы',
|
||||||
|
icon: IconDatabaseExport,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'other',
|
||||||
|
label: 'Другое',
|
||||||
|
description: 'Свободное назначение',
|
||||||
|
icon: IconDots,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function PurposeSelectInput({ value, onChange, placeholder = 'Выберите назначение' }) {
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [activeIndex, setActiveIndex] = useState(-1);
|
||||||
|
|
||||||
|
const selected = useMemo(
|
||||||
|
() => PURPOSES.find((p) => p.value === value) || null,
|
||||||
|
[value]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e) => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
if (!containerRef.current.contains(e.target)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('click', handler);
|
||||||
|
return () => document.removeEventListener('click', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelect = (p) => {
|
||||||
|
onChange(p ? p.value : '');
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="position-relative" style={{ width: '100%' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="form-select text-start d-flex align-items-center"
|
||||||
|
onClick={() => setOpen((prev) => !prev)}
|
||||||
|
>
|
||||||
|
{selected ? (
|
||||||
|
<span className="d-flex align-items-center">
|
||||||
|
<selected.icon size={18} className="me-2 text-primary" />
|
||||||
|
<span>{selected.label}</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted">{placeholder}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
className="dropdown-menu show"
|
||||||
|
style={{ display: 'block', width: '100%', maxHeight: 280, overflowY: 'auto' }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`dropdown-item${activeIndex === -1 && !value ? ' active' : ''}`}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSelect(null);
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setActiveIndex(-1)}
|
||||||
|
>
|
||||||
|
<div className="text-muted small">Без назначения</div>
|
||||||
|
</button>
|
||||||
|
{PURPOSES.map((p, idx) => {
|
||||||
|
const Icon = p.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={p.value}
|
||||||
|
className={`dropdown-item${
|
||||||
|
p.value === value || idx === activeIndex ? ' active' : ''
|
||||||
|
}`}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSelect(p);
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setActiveIndex(idx)}
|
||||||
|
>
|
||||||
|
<div className="d-flex align-items-start">
|
||||||
|
<span className="avatar me-2 bg-blue-lt text-blue border-0" style={{ width: 24, height: 24 }}>
|
||||||
|
<Icon size={16} />
|
||||||
|
</span>
|
||||||
|
<div className="flex-fill text-start">
|
||||||
|
<div className="fw-medium">{p.label}</div>
|
||||||
|
<div className="text-muted small text-truncate">{p.description}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { PURPOSES };
|
||||||
|
export default PurposeSelectInput;
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user