From 217d60ab1909f54ded2ecbd5489a83824706e271 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Wed, 27 May 2026 09:28:39 +0300 Subject: [PATCH] refactor(bulk-actions): extract ActionModal + DropdownSelect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First and largest step in the AdminBulkActions decomp. Two new files under src/components/admin/bulkActions/: - DropdownSelect.tsx (47 lines): the small native- setDays(Number(e.target.value))} + className="w-full rounded-xl border border-dark-700 bg-dark-800 px-3 py-2.5 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500/40" + /> + + ); + case 'change_tariff': + return ( +
+ + ({ value: String(tt.id), label: tt.name }))} + onChange={(v) => setTariffId(Number(v))} + /> +
+ ); + case 'add_traffic': + return ( +
+ + setTrafficGb(Number(e.target.value))} + className="w-full rounded-xl border border-dark-700 bg-dark-800 px-3 py-2.5 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500/40" + /> +
+ ); + case 'add_balance': + return ( +
+ + setBalanceRub(Number(e.target.value))} + className="w-full rounded-xl border border-dark-700 bg-dark-800 px-3 py-2.5 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500/40" + /> +
+ ); + case 'assign_promo_group': + return ( +
+ + ({ value: String(pg.id), label: pg.name })), + ]} + onChange={(v) => setPromoGroupId(v === 'null' ? null : Number(v))} + /> +
+ ); + case 'set_devices': + return ( +
+ + setDeviceLimit(Number(e.target.value))} + className="w-full rounded-xl border border-dark-700 bg-dark-800 px-3 py-2.5 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500/40" + /> +
+ ); + case 'grant_subscription': + return ( +
+
+ + ({ value: String(tt.id), label: tt.name }))} + onChange={(v) => setGrantTariffId(Number(v))} + /> +
+
+ + setGrantDays(Number(e.target.value))} + className="w-full rounded-xl border border-dark-700 bg-dark-800 px-3 py-2.5 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500/40" + /> +
+
+

+ {t('admin.bulkActions.grantSubscription.warning')} +

+
+
+ ); + case 'delete_subscription': { + const totalSelected = selectedSubscriptionIds.length; + return ( +
+
+

+ {t('admin.bulkActions.deleteSubscription.warning')} +

+

+ {t('admin.bulkActions.deleteSubscription.hint')} +

+
+ {activePaidCount > 0 && ( + <> +
+

+ {t('admin.bulkActions.deleteSubscription.activePaidWarning', { + count: activePaidCount, + total: totalSelected, + })} +

+
+ + + )} +
+ ); + } + case 'delete_user': + return ( +
+
+

+ {t('admin.bulkActions.deleteUser.warning')} +

+

+ {t('admin.bulkActions.deleteUser.hint')} +

+
+ +
+ ); + default: + return null; + } + }; + + return createPortal( +
+
+ +
+
+

+ {t(actionLabelKeys[modal.action])} +

+ {!modal.loading && ( + + )} +
+ + {modal.loading && modal.progress ? ( +
+ +

+ {t('admin.bulkActions.progress.doNotClose')} +

+
+ ) : modal.result ? ( +
+
+
+ {t('admin.bulkActions.complete')} +
+
+ + {t('admin.bulkActions.progress.summarySuccess', { + count: modal.result.success_count, + })} + + {modal.result.error_count > 0 && ( + <> + + + {t('admin.bulkActions.progress.summaryErrors', { + count: modal.result.error_count, + })} + + + )} +
+
+
+
+ {modal.result.success_count} +
+
+ {t('admin.bulkActions.successCount', { count: modal.result.success_count })} +
+
+ {modal.result.error_count > 0 && ( +
+
+ {modal.result.error_count} +
+
+ {t('admin.bulkActions.errorCount', { count: modal.result.error_count })} +
+
+ )} +
+
+ + + + +
+ ) : ( + <> +
+

+ {t('admin.bulkActions.selectedCount', { count: selectedCount })} +

+
+ +
{renderInputs()}
+ +
+ + +
+ + )} +
+
, + document.body, + ); +} diff --git a/src/components/admin/bulkActions/DropdownSelect.tsx b/src/components/admin/bulkActions/DropdownSelect.tsx new file mode 100644 index 0000000..6b40139 --- /dev/null +++ b/src/components/admin/bulkActions/DropdownSelect.tsx @@ -0,0 +1,47 @@ +import { cn } from '@/lib/utils'; + +// ────────────────────────────────────────────────────────────────── +// DropdownSelect — small native- onChange(e.target.value)} + className="w-full appearance-none rounded-xl border border-dark-700 bg-dark-800 px-3 py-2.5 pr-8 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500/40 focus:shadow-[0_0_0_3px_rgba(var(--color-accent-500),0.08)]" + > + {options.map((o) => ( + + ))} + +
+ +
+
+ ); +} diff --git a/src/pages/AdminBulkActions.tsx b/src/pages/AdminBulkActions.tsx index a53500f..8ffb851 100644 --- a/src/pages/AdminBulkActions.tsx +++ b/src/pages/AdminBulkActions.tsx @@ -19,13 +19,16 @@ import { adminBulkActionsApi, type BulkActionType, type BulkActionParams, - type BulkActionResult, - type BulkProgressEvent, } from '../api/adminBulkActions'; import { usePlatform } from '../platform/hooks/usePlatform'; import { useCurrency } from '../hooks/useCurrency'; -import { useFocusTrap } from '@/hooks/useFocusTrap'; import { cn } from '@/lib/utils'; +import { ActionModal, type ModalState } from '@/components/admin/bulkActions/ActionModal'; +import { + DropdownSelect, + ChevronDownIcon, + type DropdownOption, +} from '@/components/admin/bulkActions/DropdownSelect'; // ============ Types ============ @@ -38,21 +41,22 @@ interface ActionConfig { colorClass: string; } -interface ProgressState { - current: number; - total: number; - successCount: number; - errorCount: number; - log: BulkProgressEvent[]; -} +// (ProgressState + ModalState moved into ; ModalState is re-exported.) -interface ModalState { - open: boolean; - action: BulkActionType | null; - loading: boolean; - result: BulkActionResult | null; - progress: ProgressState | null; -} +// Local CheckIcon — still used by MultiSelectDropdown checkboxes and +// the user-table column defs (not the modal). Kept here rather than +// re-exported from a shared icons module. +const CheckIcon = () => ( + + + +); // ============ Icons ============ @@ -100,30 +104,6 @@ const RefreshIcon = ({ className = 'w-5 h-5' }: { className?: string }) => ( ); -const CheckIcon = () => ( - - - -); - -const XCloseIcon = () => ( - - - -); - -const ChevronDownIcon = () => ( - - - -); - const ChevronExpandIcon = ({ expanded }: { expanded: boolean }) => ( void; - className?: string; -}) { - return ( -
- -
- -
-
- ); -} +// (DropdownSelect moved into ./bulkActions/DropdownSelect.tsx) // ============ Multi-Select Dropdown ============ @@ -581,675 +525,11 @@ function MultiSelectDropdown({ ); } -// ============ Progress View ============ +// (ProgressView moved into ) -function ProgressView({ progress }: { progress: ProgressState }) { - const { t } = useTranslation(); - const logEndRef = useRef(null); - const percentage = progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0; +// (ErrorDetails moved into ) - useEffect(() => { - logEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [progress.log.length]); - - return ( -
- {/* Progress bar */} -
-
- - {t('admin.bulkActions.progress.processed', { - current: progress.current, - total: progress.total, - })} - - {percentage}% -
-
-
-
-
- - {/* Counters */} -
-
-
- {progress.successCount} - {t('admin.bulkActions.progress.succeeded')} -
-
-
- {progress.errorCount} - {t('admin.bulkActions.progress.failed')} -
-
- - {/* Live log */} - {progress.log.length > 0 && ( -
-
- {progress.log.slice(-10).map((entry, idx) => ( -
- {entry.success ? ( - - ) : ( - - )} - - {entry.username ? `@${entry.username}` : `#${entry.user_id}`} - - - {entry.success - ? entry.message || t('admin.bulkActions.progress.ok') - : entry.message || entry.error || t('admin.bulkActions.progress.errorGeneric')} - -
- ))} -
-
-
- )} -
- ); -} - -// ============ Error Details ============ - -function ErrorDetails({ result }: { result: BulkActionResult }) { - const { t } = useTranslation(); - const [expanded, setExpanded] = useState(false); - - if (result.error_count === 0 || result.errors.length === 0) return null; - - return ( -
- - {expanded && ( -
-
- {result.errors.map((err, idx) => ( -
- - - {err.username ? `@${err.username}` : `#${err.user_id}`} - - {err.error} -
- ))} -
-
- )} -
- ); -} - -// ============ Action Modal ============ - -interface ActionModalProps { - modal: ModalState; - selectedCount: number; - tariffs: TariffListItem[]; - promoGroups: PromoGroup[]; - users: UserListItem[]; - selectedSubscriptionIds: number[]; - onClose: () => void; - onExecute: (params: BulkActionParams) => void; -} - -function ActionModal({ - modal, - selectedCount, - tariffs, - promoGroups, - users, - selectedSubscriptionIds, - onClose, - onExecute, -}: ActionModalProps) { - const { t } = useTranslation(); - const [days, setDays] = useState(30); - const [tariffId, setTariffId] = useState(tariffs[0]?.id ?? 0); - const [trafficGb, setTrafficGb] = useState(10); - const [balanceRub, setBalanceRub] = useState(100); - const [promoGroupId, setPromoGroupId] = useState(promoGroups[0]?.id ?? null); - const [grantTariffId, setGrantTariffId] = useState(tariffs[0]?.id ?? 0); - const [grantDays, setGrantDays] = useState(30); - const [deviceLimit, setDeviceLimit] = useState(1); - const [deleteFromPanel, setDeleteFromPanel] = useState(true); - const [forceDeleteActivePaid, setForceDeleteActivePaid] = useState(false); - - useEffect(() => { - if (tariffs.length > 0 && tariffId === 0) { - setTariffId(tariffs[0].id); - } - if (tariffs.length > 0 && grantTariffId === 0) { - setGrantTariffId(tariffs[0].id); - } - }, [tariffs, tariffId, grantTariffId]); - - useEffect(() => { - if (promoGroups.length > 0 && promoGroupId === null) { - setPromoGroupId(promoGroups[0].id); - } - }, [promoGroups, promoGroupId]); - - // Reset modal-specific state when modal opens - useEffect(() => { - if (modal.open) { - if (modal.action === 'delete_user') { - setDeleteFromPanel(true); - } - if (modal.action === 'delete_subscription') { - setForceDeleteActivePaid(false); - } - } - }, [modal.open, modal.action]); - - // Escape key handler — only when not loading - // Focus trap + scroll lock + Escape close (only while not loading). - const dialogRef = useFocusTrap(modal.open, { - onEscape: modal.loading ? undefined : onClose, - }); - - // Count active paid subscriptions among selected ones - const activePaidCount = useMemo(() => { - if (modal.action !== 'delete_subscription') return 0; - const selectedSubIdSet = new Set(selectedSubscriptionIds); - let count = 0; - for (const user of users) { - for (const sub of user.subscriptions ?? []) { - if (selectedSubIdSet.has(sub.id) && sub.status === 'active' && !sub.is_trial) { - count++; - } - } - } - return count; - }, [modal.action, selectedSubscriptionIds, users]); - - const isConfirmDisabled = - modal.loading || - (modal.action === 'delete_subscription' && activePaidCount > 0 && !forceDeleteActivePaid); - - if (!modal.open || !modal.action) return null; - - const actionLabelKeys: Record = { - extend_subscription: 'admin.bulkActions.actions.extend', - add_days: 'admin.bulkActions.actions.extend', - cancel_subscription: 'admin.bulkActions.actions.cancel', - activate_subscription: 'admin.bulkActions.actions.activate', - change_tariff: 'admin.bulkActions.actions.changeTariff', - add_traffic: 'admin.bulkActions.actions.addTraffic', - add_balance: 'admin.bulkActions.actions.addBalance', - assign_promo_group: 'admin.bulkActions.actions.assignPromoGroup', - grant_subscription: 'admin.bulkActions.actions.grantSubscription', - set_devices: 'admin.bulkActions.actions.setDevices', - delete_subscription: 'admin.bulkActions.actions.deleteSubscription', - delete_user: 'admin.bulkActions.actions.deleteUser', - }; - - const handleSubmit = () => { - const params: BulkActionParams = {}; - switch (modal.action) { - case 'extend_subscription': - params.days = days; - break; - case 'change_tariff': - params.tariff_id = tariffId; - break; - case 'add_traffic': - params.traffic_gb = trafficGb; - break; - case 'add_balance': - params.amount_kopeks = Math.round(balanceRub * 100); - break; - case 'assign_promo_group': - params.promo_group_id = promoGroupId; - break; - case 'grant_subscription': - params.tariff_id = grantTariffId; - params.days = grantDays; - break; - case 'set_devices': - params.device_limit = deviceLimit; - break; - case 'delete_subscription': - params.force_delete_active_paid = forceDeleteActivePaid; - break; - case 'delete_user': - params.delete_from_panel = deleteFromPanel; - break; - } - onExecute(params); - }; - - const handleBackdropClick = () => { - if (!modal.loading) { - onClose(); - } - }; - - const renderInputs = () => { - switch (modal.action) { - case 'extend_subscription': - return ( -
- - setDays(Number(e.target.value))} - className="w-full rounded-xl border border-dark-700 bg-dark-800 px-3 py-2.5 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500/40" - /> -
- ); - case 'change_tariff': - return ( -
- - ({ value: String(tt.id), label: tt.name }))} - onChange={(v) => setTariffId(Number(v))} - /> -
- ); - case 'add_traffic': - return ( -
- - setTrafficGb(Number(e.target.value))} - className="w-full rounded-xl border border-dark-700 bg-dark-800 px-3 py-2.5 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500/40" - /> -
- ); - case 'add_balance': - return ( -
- - setBalanceRub(Number(e.target.value))} - className="w-full rounded-xl border border-dark-700 bg-dark-800 px-3 py-2.5 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500/40" - /> -
- ); - case 'assign_promo_group': - return ( -
- - ({ value: String(pg.id), label: pg.name })), - ]} - onChange={(v) => setPromoGroupId(v === 'null' ? null : Number(v))} - /> -
- ); - case 'set_devices': - return ( -
- - setDeviceLimit(Number(e.target.value))} - className="w-full rounded-xl border border-dark-700 bg-dark-800 px-3 py-2.5 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500/40" - /> -
- ); - case 'grant_subscription': - return ( -
-
- - ({ value: String(tt.id), label: tt.name }))} - onChange={(v) => setGrantTariffId(Number(v))} - /> -
-
- - setGrantDays(Number(e.target.value))} - className="w-full rounded-xl border border-dark-700 bg-dark-800 px-3 py-2.5 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500/40" - /> -
- {/* Warning about users with existing subscriptions */} -
-

- {t('admin.bulkActions.grantSubscription.warning')} -

-
-
- ); - case 'delete_subscription': { - const totalSelected = selectedSubscriptionIds.length; - - return ( -
-
-

- {t('admin.bulkActions.deleteSubscription.warning')} -

-

- {t('admin.bulkActions.deleteSubscription.hint')} -

-
- {activePaidCount > 0 && ( - <> -
-

- {t('admin.bulkActions.deleteSubscription.activePaidWarning', { - count: activePaidCount, - total: totalSelected, - })} -

-
- - - )} -
- ); - } - case 'delete_user': - return ( -
-
-

- {t('admin.bulkActions.deleteUser.warning')} -

-

- {t('admin.bulkActions.deleteUser.hint')} -

-
- -
- ); - default: - return null; - } - }; - - return createPortal( -
- {/* Backdrop — no close on click during loading */} -
- - {/* Modal — role/aria-modal live on the focus-trapped element so AT - announces and Tab cycles correctly. */} -
- {/* Header */} -
-

- {t(actionLabelKeys[modal.action])} -

- {!modal.loading && ( - - )} -
- - {/* Progress view during execution */} - {modal.loading && modal.progress ? ( -
- -

- {t('admin.bulkActions.progress.doNotClose')} -

-
- ) : modal.result ? ( - /* Result view */ -
- {/* Summary line */} -
-
- {t('admin.bulkActions.complete')} -
- {/* Colored summary */} -
- - {t('admin.bulkActions.progress.summarySuccess', { - count: modal.result.success_count, - })} - - {modal.result.error_count > 0 && ( - <> - - - {t('admin.bulkActions.progress.summaryErrors', { - count: modal.result.error_count, - })} - - - )} -
-
-
-
- {modal.result.success_count} -
-
- {t('admin.bulkActions.successCount', { count: modal.result.success_count })} -
-
- {modal.result.error_count > 0 && ( -
-
- {modal.result.error_count} -
-
- {t('admin.bulkActions.errorCount', { count: modal.result.error_count })} -
-
- )} -
-
- - {/* Collapsible error details */} - - - -
- ) : ( - <> - {/* Affected count */} -
-

- {t('admin.bulkActions.selectedCount', { count: selectedCount })} -

-
- - {/* Action-specific inputs */} -
{renderInputs()}
- - {/* Buttons */} -
- - -
- - )} -
-
, - document.body, - ); -} +// (ActionModal moved into ./bulkActions/ActionModal.tsx) // ============ Floating Action Bar ============