feat(coupons): лимит на пользователя и удаление партии в кабинете

Парная фронтовая часть к бэкенду (бот: 82609441).

- Поле «Лимит на пользователя» в форме создания партии: 0 — без
  ограничения, для раздач и конкурсов ставится 1. Значение показывается в
  карточке партии, если задано.
- Кнопка «Удалить партию» с подтверждением: отдельно от отзыва, который
  лишь гасит ссылки. Если в партии есть погашенные купоны, подтверждение
  предупреждает о потере истории активаций (выданные подписки не
  отзываются). После удаления — возврат к списку.
- API: max_per_user в типах партии и запросе создания, deleteBatch.

Локали ru/en — раздел admin.coupons в zh/fa отсутствует целиком (купоны там
никогда не переводились), поэтому новые ключи добавлены в том же объёме.
This commit is contained in:
Fringg
2026-07-29 22:02:07 +03:00
parent c9afea7a50
commit 390da33914
5 changed files with 129 additions and 7 deletions

View File

@@ -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<CouponRedeemResponse> => {
const response = await apiClient.post('/cabinet/coupon/redeem', { token });
return response.data;

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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<number | ''>(50);
const [priceRubles, setPriceRubles] = useState<number | ''>('');
const [validDays, setValidDays] = useState<number | ''>('');
const [maxPerUser, setMaxPerUser] = useState<number | ''>('');
const [validationErrors, setValidationErrors] = useState<string[]>([]);
const [serverError, setServerError] = useState<string | null>(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() {
/>
<p className="mt-1 text-xs text-dark-500">{t('admin.coupons.form.validDaysHint')}</p>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-dark-300">
{t('admin.coupons.form.maxPerUser')}
</label>
<input
type="number"
value={maxPerUser}
onChange={createNumberInputHandler(setMaxPerUser, 0, 500)}
min={0}
max={500}
placeholder="0"
className="input w-full"
/>
<p className="mt-1 text-xs text-dark-500">{t('admin.coupons.form.maxPerUserHint')}</p>
</div>
</div>
{/* Actions */}

View File

@@ -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() {
</span>
</div>
)}
{batch.max_per_user > 0 && (
<div className="flex justify-between gap-4">
<span className="text-dark-400">{t('admin.coupons.detail.maxPerUser')}</span>
<span className="text-dark-100">{batch.max_per_user}</span>
</div>
)}
<div className="flex justify-between gap-4">
<span className="text-dark-400">{t('admin.coupons.detail.validUntil')}</span>
<span className="text-dark-100">
@@ -228,6 +257,49 @@ export default function AdminCouponDetail() {
</PermissionGate>
)}
{/* Delete — полностью убирает партию, в отличие от отзыва */}
<PermissionGate permission="coupons:edit" fallback={null}>
<button
onClick={() => setDeleteConfirm(true)}
className="w-full rounded-lg border border-error-500/30 px-4 py-2.5 text-error-400 transition-colors hover:bg-error-500/20"
>
{t('admin.coupons.delete.button')}
</button>
</PermissionGate>
{/* Delete confirmation */}
{deleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-dark-950/70 p-4">
<div className="w-full max-w-sm rounded-xl bg-dark-800 p-6">
<h3 className="mb-2 text-lg font-semibold text-dark-100">
{t('admin.coupons.delete.confirmTitle')}
</h3>
<p className="mb-2 text-dark-400">
{t('admin.coupons.delete.confirmText', { count: batch.coupons_total })}
</p>
{batch.redeemed_count > 0 && (
<p className="mb-4 text-sm text-warning-400">
{t('admin.coupons.delete.redeemedWarning', { count: batch.redeemed_count })}
</p>
)}
<div className="flex gap-3">
<button onClick={() => setDeleteConfirm(false)} className="btn-secondary flex-1">
{t('admin.coupons.revoke.cancel')}
</button>
<button
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
className={`flex-1 rounded-lg bg-error-500 px-4 py-2 text-white transition-colors hover:bg-error-600 ${
deleteMutation.isPending ? 'cursor-not-allowed opacity-50' : ''
}`}
>
{t('admin.coupons.delete.confirm')}
</button>
</div>
</div>
</div>
)}
{/* Revoke confirmation */}
{revokeConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-dark-950/70 p-4">