diff --git a/src/api/tariffs.ts b/src/api/tariffs.ts index 47bd121..bd8ba11 100644 --- a/src/api/tariffs.ts +++ b/src/api/tariffs.ts @@ -231,6 +231,11 @@ export const tariffsApi = { return response.data; }, + // Update tariff display order + updateOrder: async (tariffIds: number[]): Promise => { + await apiClient.put('/cabinet/admin/tariffs/order', { tariff_ids: tariffIds }); + }, + // Get available servers for selection getAvailableServers: async (): Promise => { const response = await apiClient.get('/cabinet/admin/tariffs/available-servers'); diff --git a/src/locales/en.json b/src/locales/en.json index f8f29b6..765f609 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1306,6 +1306,9 @@ "confirmDeleteText": "This action cannot be undone. The tariff will be permanently deleted.", "confirmDeleteWithSubscriptions": "This tariff has {{count}} active subscriptions. After deletion, users will need to select a new tariff when renewing.", "deleteSuccess": "Tariff deleted successfully", + "saveOrder": "Save order", + "orderSaved": "Order saved", + "dragToReorder": "Drag to reorder", "days": "days", "days_one": "{{count}} day", "days_other": "{{count}} days", diff --git a/src/locales/fa.json b/src/locales/fa.json index c66978e..061db03 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1012,6 +1012,9 @@ "confirmDeleteText": "این عمل قابل بازگشت نیست. تعرفه برای همیشه حذف می‌شود.", "confirmDeleteWithSubscriptions": "این تعرفه {{count}} اشتراک فعال دارد. پس از حذف، کاربران هنگام تمدید باید تعرفه جدید انتخاب کنند.", "deleteSuccess": "تعرفه با موفقیت حذف شد", + "saveOrder": "ذخیره ترتیب", + "orderSaved": "ترتیب ذخیره شد", + "dragToReorder": "برای تغییر ترتیب بکشید", "days": "روز", "days_other": "{{count}} روز", "tabs": { diff --git a/src/locales/ru.json b/src/locales/ru.json index c0ca124..1462dad 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1824,6 +1824,9 @@ "confirmDeleteText": "Это действие нельзя отменить. Тариф будет удалён навсегда.", "confirmDeleteWithSubscriptions": "У этого тарифа {{count}} активных подписок. При удалении пользователям потребуется выбрать новый тариф при продлении.", "deleteSuccess": "Тариф успешно удален", + "saveOrder": "Сохранить порядок", + "orderSaved": "Порядок сохранён", + "dragToReorder": "Перетащите для изменения порядка", "days": "дней", "days_one": "{{count}} день", "days_few": "{{count}} дня", diff --git a/src/locales/zh.json b/src/locales/zh.json index 06e268d..73588d9 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1050,6 +1050,9 @@ "confirmDeleteText": "此操作不可撤销。套餐将被永久删除。", "confirmDeleteWithSubscriptions": "此套餐有 {{count}} 个活跃订阅。删除后,用户续订时需要选择新套餐。", "deleteSuccess": "套餐删除成功", + "saveOrder": "保存排序", + "orderSaved": "排序已保存", + "dragToReorder": "拖动以重新排序", "days": "天", "days_other": "{{count}} 天", "tabs": { diff --git a/src/pages/AdminTariffs.tsx b/src/pages/AdminTariffs.tsx index 2dd540e..ba93d31 100644 --- a/src/pages/AdminTariffs.tsx +++ b/src/pages/AdminTariffs.tsx @@ -1,9 +1,26 @@ +import { useState, useCallback, useEffect } from 'react'; import { useNavigate } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { tariffsApi, TariffListItem } from '../api/tariffs'; import { useDestructiveConfirm, useNotify } from '@/platform'; import { usePlatform } from '../platform/hooks/usePlatform'; +import { + DndContext, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from '@dnd-kit/core'; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; // Icons const PlusIcon = () => ( @@ -54,7 +71,6 @@ const GiftIcon = () => ( ); -// BackIcon const BackIcon = () => ( ( ); +const GripIcon = () => ( + + + +); + +const SaveIcon = () => ( + + + +); + +// ============ Sortable Tariff Card ============ + +interface SortableTariffCardProps { + tariff: TariffListItem; + onEdit: () => void; + onDelete: () => void; + onToggle: () => void; + onToggleTrial: () => void; +} + +function SortableTariffCard({ + tariff, + onEdit, + onDelete, + onToggle, + onToggleTrial, +}: SortableTariffCardProps) { + const { t } = useTranslation(); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: tariff.id, + }); + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 50 : undefined, + position: isDragging ? 'relative' : undefined, + }; + + return ( +
+
+ {/* Drag handle */} + + + {/* Content */} +
+
+

{tariff.name}

+ {tariff.is_daily ? ( + + {t('admin.tariffs.dailyType')} + + ) : ( + + {t('admin.tariffs.periodType')} + + )} + {tariff.is_trial_available && ( + + {t('admin.tariffs.trial')} + + )} + {!tariff.is_active && ( + + {t('admin.tariffs.inactive')} + + )} +
+
+ {tariff.is_daily && tariff.daily_price_kopeks > 0 && ( + + {(tariff.daily_price_kopeks / 100).toFixed(2)} {t('admin.tariffs.currencyPerDay')} + + )} + + {tariff.traffic_limit_gb === 0 + ? t('admin.tariffs.unlimited') + : `${tariff.traffic_limit_gb} GB`} + + {t('admin.tariffs.devices', { count: tariff.device_limit })} + {t('admin.tariffs.servers', { count: tariff.servers_count })} + {t('admin.tariffs.subscriptions', { count: tariff.subscriptions_count })} +
+
+ + {/* Actions */} +
+ + + + + + + +
+
+
+ ); +} + +// ============ Main Page ============ + export default function AdminTariffs() { const { t } = useTranslation(); const navigate = useNavigate(); @@ -75,12 +253,32 @@ export default function AdminTariffs() { const notify = useNotify(); const { capabilities } = usePlatform(); + const [localTariffs, setLocalTariffs] = useState([]); + const [orderChanged, setOrderChanged] = useState(false); + // Queries const { data: tariffsData, isLoading } = useQuery({ queryKey: ['admin-tariffs'], queryFn: () => tariffsApi.getTariffs(true), }); + // Sync fetched data to local state + useEffect(() => { + if (tariffsData?.tariffs && !orderChanged) { + setLocalTariffs(tariffsData.tariffs); + } + }, [tariffsData, orderChanged]); + + // Save order mutation + const saveOrderMutation = useMutation({ + mutationFn: (tariffIds: number[]) => tariffsApi.updateOrder(tariffIds), + onSuccess: () => { + setOrderChanged(false); + queryClient.invalidateQueries({ queryKey: ['admin-tariffs'] }); + notify.success(t('admin.tariffs.orderSaved')); + }, + }); + // Mutations const deleteMutation = useMutation({ mutationFn: tariffsApi.deleteTariff, @@ -91,7 +289,6 @@ export default function AdminTariffs() { }); const handleDelete = async (tariff: TariffListItem) => { - // If tariff has active subscriptions, show warning but still allow deletion const confirmText = tariff.subscriptions_count > 0 ? t('admin.tariffs.confirmDeleteWithSubscriptions', { @@ -124,7 +321,28 @@ export default function AdminTariffs() { }, }); - const tariffs = tariffsData?.tariffs || []; + // DnD sensors + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + + const handleDragEnd = useCallback((event: DragEndEvent) => { + const { active, over } = event; + if (over && active.id !== over.id) { + setLocalTariffs((prev) => { + const oldIndex = prev.findIndex((t) => t.id === active.id); + const newIndex = prev.findIndex((t) => t.id === over.id); + if (oldIndex === -1 || newIndex === -1) return prev; + return arrayMove(prev, oldIndex, newIndex); + }); + setOrderChanged(true); + } + }, []); + + const handleSaveOrder = () => { + saveOrderMutation.mutate(localTariffs.map((t) => t.id)); + }; return (
@@ -145,13 +363,35 @@ export default function AdminTariffs() {

{t('admin.tariffs.subtitle')}

- +
+ {orderChanged && ( + + )} + +
+ + + {/* Drag hint */} +
+ + {t('admin.tariffs.dragToReorder')}
{/* Tariffs List */} @@ -159,114 +399,30 @@ export default function AdminTariffs() {
- ) : tariffs.length === 0 ? ( + ) : localTariffs.length === 0 ? (

{t('admin.tariffs.noTariffs')}

) : ( -
- {tariffs.map((tariff: TariffListItem) => ( -
-
-
-
-

{tariff.name}

- {tariff.is_daily ? ( - - {t('admin.tariffs.dailyType')} - - ) : ( - - {t('admin.tariffs.periodType')} - - )} - {tariff.is_trial_available && ( - - {t('admin.tariffs.trial')} - - )} - {!tariff.is_active && ( - - {t('admin.tariffs.inactive')} - - )} -
-
- {tariff.is_daily && tariff.daily_price_kopeks > 0 && ( - - {(tariff.daily_price_kopeks / 100).toFixed(2)}{' '} - {t('admin.tariffs.currencyPerDay')} - - )} - - {tariff.traffic_limit_gb === 0 - ? t('admin.tariffs.unlimited') - : `${tariff.traffic_limit_gb} GB`} - - {t('admin.tariffs.devices', { count: tariff.device_limit })} - {t('admin.tariffs.servers', { count: tariff.servers_count })} - - {t('admin.tariffs.subscriptions', { count: tariff.subscriptions_count })} - -
-
- -
- {/* Toggle Active */} - - - {/* Toggle Trial */} - - - {/* Edit */} - - - {/* Delete */} - -
-
+ + t.id)} + strategy={verticalListSortingStrategy} + > +
+ {localTariffs.map((tariff) => ( + navigate(`/admin/tariffs/${tariff.id}/edit`)} + onDelete={() => handleDelete(tariff)} + onToggle={() => toggleMutation.mutate(tariff.id)} + onToggleTrial={() => toggleTrialMutation.mutate(tariff.id)} + /> + ))}
- ))} -
+ + )}
);