diff --git a/src/api/coupons.ts b/src/api/coupons.ts index 6a6820e..fc02445 100644 --- a/src/api/coupons.ts +++ b/src/api/coupons.ts @@ -10,6 +10,8 @@ export interface CouponBatch { period_days: number; coupons_total: number; wholesale_price_kopeks: number; + /** Сколько купонов партии может активировать один пользователь; 0 — без ограничения */ + max_per_user: number; valid_until: string | null; is_revoked: boolean; created_at: string; @@ -31,6 +33,7 @@ export interface CouponBatchCreateRequest { period_days: number; coupons_count: number; wholesale_price_kopeks?: number; + max_per_user?: number; valid_days?: number; } @@ -99,6 +102,12 @@ export const couponsApi = { }, // User: redeem a coupon for the current cabinet user + /** Полное удаление партии (в отличие от revoke, который гасит ссылки) */ + deleteBatch: async (id: number): Promise<{ batch_id: number; deleted_coupons: number }> => { + const response = await apiClient.delete(`/cabinet/admin/coupons/${id}`); + return response.data; + }, + redeemCoupon: async (token: string): Promise => { const response = await apiClient.post('/cabinet/coupon/redeem', { token }); return response.data; diff --git a/src/locales/en.json b/src/locales/en.json index f6247a3..de4dde5 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -3168,7 +3168,9 @@ "validDaysHint": "0 or empty — perpetual", "create": "Create batch", "creating": "Creating…", - "cancel": "Cancel" + "cancel": "Cancel", + "maxPerUser": "Per-user limit", + "maxPerUserHint": "How many coupons from this batch one person may redeem. 0 — unlimited; use 1 for giveaways and contests." }, "validation": { "nameRequired": "Enter the batch name", @@ -3193,7 +3195,8 @@ "validUntil": "Valid until", "perpetual": "Perpetual", "createdAt": "Created", - "linksTitle": "Active links ({{count}})" + "linksTitle": "Active links ({{count}})", + "maxPerUser": "Per user" }, "revoke": { "button": "Revoke unredeemed ({{count}})", @@ -3206,7 +3209,16 @@ "errors": { "loadFailed": "Batch not found", "createFailed": "Failed to create the batch", - "revokeFailed": "Failed to revoke the batch" + "revokeFailed": "Failed to revoke the batch", + "deleteFailed": "Could not delete the batch" + }, + "delete": { + "button": "🗑 Delete batch", + "confirmTitle": "Delete the batch?", + "confirmText": "{{count}} coupons will be deleted along with the batch itself.", + "redeemedWarning": "{{count}} of them are already redeemed. The redemption history will be lost; granted subscriptions are not revoked.", + "confirm": "Delete", + "success": "Batch deleted ({{count}} coupons)" } }, "promocodes": { diff --git a/src/locales/ru.json b/src/locales/ru.json index 77050b9..72f742f 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -3560,7 +3560,9 @@ "validDaysHint": "0 или пусто — бессрочно", "create": "Создать партию", "creating": "Создание…", - "cancel": "Отмена" + "cancel": "Отмена", + "maxPerUser": "Лимит на пользователя", + "maxPerUserHint": "Сколько купонов этой партии может активировать один человек. 0 — без ограничения; для раздач и конкурсов ставьте 1." }, "validation": { "nameRequired": "Укажите название партии", @@ -3585,7 +3587,8 @@ "validUntil": "Действует до", "perpetual": "Бессрочно", "createdAt": "Создана", - "linksTitle": "Активные ссылки ({{count}} шт.)" + "linksTitle": "Активные ссылки ({{count}} шт.)", + "maxPerUser": "На пользователя" }, "revoke": { "button": "Отозвать непогашенные ({{count}})", @@ -3598,7 +3601,16 @@ "errors": { "loadFailed": "Партия не найдена", "createFailed": "Не удалось создать партию", - "revokeFailed": "Не удалось отозвать партию" + "revokeFailed": "Не удалось отозвать партию", + "deleteFailed": "Не удалось удалить партию" + }, + "delete": { + "button": "🗑 Удалить партию", + "confirmTitle": "Удалить партию?", + "confirmText": "Будет удалено купонов: {{count}} — вместе с самой партией.", + "redeemedWarning": "Среди них уже погашено: {{count}}. Пропадёт история активаций, выданные подписки не отзываются.", + "confirm": "Удалить", + "success": "Партия удалена (купонов: {{count}})" } }, "promocodes": { diff --git a/src/pages/AdminCouponCreate.tsx b/src/pages/AdminCouponCreate.tsx index 2f6670f..7bdc31a 100644 --- a/src/pages/AdminCouponCreate.tsx +++ b/src/pages/AdminCouponCreate.tsx @@ -3,7 +3,7 @@ import { useNavigate } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { createNumberInputHandler } from '../utils/inputHelpers'; -import { couponsApi, CouponBatchCreated } from '../api/coupons'; +import { couponsApi, type CouponBatchCreated } from '../api/coupons'; import { tariffsApi } from '../api/tariffs'; import { usePlatform } from '../platform/hooks/usePlatform'; import { copyToClipboard } from '../utils/clipboard'; @@ -33,6 +33,7 @@ export default function AdminCouponCreate() { const [couponsCount, setCouponsCount] = useState(50); const [priceRubles, setPriceRubles] = useState(''); const [validDays, setValidDays] = useState(''); + const [maxPerUser, setMaxPerUser] = useState(''); const [validationErrors, setValidationErrors] = useState([]); const [serverError, setServerError] = useState(null); @@ -77,6 +78,7 @@ export default function AdminCouponCreate() { coupons_count: Number(couponsCount), wholesale_price_kopeks: priceRubles === '' ? 0 : Math.round(Number(priceRubles) * 100), valid_days: validDays === '' ? 0 : Number(validDays), + max_per_user: maxPerUser === '' ? 0 : Number(maxPerUser), }); }; @@ -275,6 +277,21 @@ export default function AdminCouponCreate() { />

{t('admin.coupons.form.validDaysHint')}

+
+ + +

{t('admin.coupons.form.maxPerUserHint')}

+
{/* Actions */} diff --git a/src/pages/AdminCouponDetail.tsx b/src/pages/AdminCouponDetail.tsx index 06ebaa4..85fd202 100644 --- a/src/pages/AdminCouponDetail.tsx +++ b/src/pages/AdminCouponDetail.tsx @@ -31,6 +31,7 @@ export default function AdminCouponDetail() { const batchId = Number(id); const [revokeConfirm, setRevokeConfirm] = useState(false); + const [deleteConfirm, setDeleteConfirm] = useState(false); const [copied, setCopied] = useState(false); const { data: batch, isLoading } = useQuery({ @@ -70,6 +71,28 @@ export default function AdminCouponDetail() { }, }); + const deleteMutation = useMutation({ + mutationFn: () => couponsApi.deleteBatch(batchId), + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey: ['admin-coupon-batches'] }); + setDeleteConfirm(false); + showToast({ + type: 'success', + title: t('admin.coupons.delete.success', { count: result.deleted_coupons }), + message: '', + }); + navigate('/admin/coupons', { replace: true }); + }, + onError: (err) => { + setDeleteConfirm(false); + showToast({ + type: 'error', + title: t('admin.coupons.errors.deleteFailed'), + message: getApiErrorMessage(err, ''), + }); + }, + }); + const handleCopyAll = () => { if (!links) return; void copyToClipboard(links.links.join('\n')); @@ -169,6 +192,12 @@ export default function AdminCouponDetail() { )} + {batch.max_per_user > 0 && ( +
+ {t('admin.coupons.detail.maxPerUser')} + {batch.max_per_user} +
+ )}
{t('admin.coupons.detail.validUntil')} @@ -228,6 +257,49 @@ export default function AdminCouponDetail() { )} + {/* Delete — полностью убирает партию, в отличие от отзыва */} + + + + + {/* Delete confirmation */} + {deleteConfirm && ( +
+
+

+ {t('admin.coupons.delete.confirmTitle')} +

+

+ {t('admin.coupons.delete.confirmText', { count: batch.coupons_total })} +

+ {batch.redeemed_count > 0 && ( +

+ {t('admin.coupons.delete.redeemedWarning', { count: batch.redeemed_count })} +

+ )} +
+ + +
+
+
+ )} + {/* Revoke confirmation */} {revokeConfirm && (