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; period_days: number;
coupons_total: number; coupons_total: number;
wholesale_price_kopeks: number; wholesale_price_kopeks: number;
/** Сколько купонов партии может активировать один пользователь; 0 — без ограничения */
max_per_user: number;
valid_until: string | null; valid_until: string | null;
is_revoked: boolean; is_revoked: boolean;
created_at: string; created_at: string;
@@ -31,6 +33,7 @@ export interface CouponBatchCreateRequest {
period_days: number; period_days: number;
coupons_count: number; coupons_count: number;
wholesale_price_kopeks?: number; wholesale_price_kopeks?: number;
max_per_user?: number;
valid_days?: number; valid_days?: number;
} }
@@ -99,6 +102,12 @@ export const couponsApi = {
}, },
// User: redeem a coupon for the current cabinet user // 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> => { redeemCoupon: async (token: string): Promise<CouponRedeemResponse> => {
const response = await apiClient.post('/cabinet/coupon/redeem', { token }); const response = await apiClient.post('/cabinet/coupon/redeem', { token });
return response.data; return response.data;

View File

@@ -3168,7 +3168,9 @@
"validDaysHint": "0 or empty — perpetual", "validDaysHint": "0 or empty — perpetual",
"create": "Create batch", "create": "Create batch",
"creating": "Creating…", "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": { "validation": {
"nameRequired": "Enter the batch name", "nameRequired": "Enter the batch name",
@@ -3193,7 +3195,8 @@
"validUntil": "Valid until", "validUntil": "Valid until",
"perpetual": "Perpetual", "perpetual": "Perpetual",
"createdAt": "Created", "createdAt": "Created",
"linksTitle": "Active links ({{count}})" "linksTitle": "Active links ({{count}})",
"maxPerUser": "Per user"
}, },
"revoke": { "revoke": {
"button": "Revoke unredeemed ({{count}})", "button": "Revoke unredeemed ({{count}})",
@@ -3206,7 +3209,16 @@
"errors": { "errors": {
"loadFailed": "Batch not found", "loadFailed": "Batch not found",
"createFailed": "Failed to create the batch", "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": { "promocodes": {

View File

@@ -3560,7 +3560,9 @@
"validDaysHint": "0 или пусто — бессрочно", "validDaysHint": "0 или пусто — бессрочно",
"create": "Создать партию", "create": "Создать партию",
"creating": "Создание…", "creating": "Создание…",
"cancel": "Отмена" "cancel": "Отмена",
"maxPerUser": "Лимит на пользователя",
"maxPerUserHint": "Сколько купонов этой партии может активировать один человек. 0 — без ограничения; для раздач и конкурсов ставьте 1."
}, },
"validation": { "validation": {
"nameRequired": "Укажите название партии", "nameRequired": "Укажите название партии",
@@ -3585,7 +3587,8 @@
"validUntil": "Действует до", "validUntil": "Действует до",
"perpetual": "Бессрочно", "perpetual": "Бессрочно",
"createdAt": "Создана", "createdAt": "Создана",
"linksTitle": "Активные ссылки ({{count}} шт.)" "linksTitle": "Активные ссылки ({{count}} шт.)",
"maxPerUser": "На пользователя"
}, },
"revoke": { "revoke": {
"button": "Отозвать непогашенные ({{count}})", "button": "Отозвать непогашенные ({{count}})",
@@ -3598,7 +3601,16 @@
"errors": { "errors": {
"loadFailed": "Партия не найдена", "loadFailed": "Партия не найдена",
"createFailed": "Не удалось создать партию", "createFailed": "Не удалось создать партию",
"revokeFailed": "Не удалось отозвать партию" "revokeFailed": "Не удалось отозвать партию",
"deleteFailed": "Не удалось удалить партию"
},
"delete": {
"button": "🗑 Удалить партию",
"confirmTitle": "Удалить партию?",
"confirmText": "Будет удалено купонов: {{count}} — вместе с самой партией.",
"redeemedWarning": "Среди них уже погашено: {{count}}. Пропадёт история активаций, выданные подписки не отзываются.",
"confirm": "Удалить",
"success": "Партия удалена (купонов: {{count}})"
} }
}, },
"promocodes": { "promocodes": {

View File

@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { createNumberInputHandler } from '../utils/inputHelpers'; import { createNumberInputHandler } from '../utils/inputHelpers';
import { couponsApi, CouponBatchCreated } from '../api/coupons'; import { couponsApi, type CouponBatchCreated } from '../api/coupons';
import { tariffsApi } from '../api/tariffs'; import { tariffsApi } from '../api/tariffs';
import { usePlatform } from '../platform/hooks/usePlatform'; import { usePlatform } from '../platform/hooks/usePlatform';
import { copyToClipboard } from '../utils/clipboard'; import { copyToClipboard } from '../utils/clipboard';
@@ -33,6 +33,7 @@ export default function AdminCouponCreate() {
const [couponsCount, setCouponsCount] = useState<number | ''>(50); const [couponsCount, setCouponsCount] = useState<number | ''>(50);
const [priceRubles, setPriceRubles] = useState<number | ''>(''); const [priceRubles, setPriceRubles] = useState<number | ''>('');
const [validDays, setValidDays] = useState<number | ''>(''); const [validDays, setValidDays] = useState<number | ''>('');
const [maxPerUser, setMaxPerUser] = useState<number | ''>('');
const [validationErrors, setValidationErrors] = useState<string[]>([]); const [validationErrors, setValidationErrors] = useState<string[]>([]);
const [serverError, setServerError] = useState<string | null>(null); const [serverError, setServerError] = useState<string | null>(null);
@@ -77,6 +78,7 @@ export default function AdminCouponCreate() {
coupons_count: Number(couponsCount), coupons_count: Number(couponsCount),
wholesale_price_kopeks: priceRubles === '' ? 0 : Math.round(Number(priceRubles) * 100), wholesale_price_kopeks: priceRubles === '' ? 0 : Math.round(Number(priceRubles) * 100),
valid_days: validDays === '' ? 0 : Number(validDays), 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> <p className="mt-1 text-xs text-dark-500">{t('admin.coupons.form.validDaysHint')}</p>
</div> </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> </div>
{/* Actions */} {/* Actions */}

View File

@@ -31,6 +31,7 @@ export default function AdminCouponDetail() {
const batchId = Number(id); const batchId = Number(id);
const [revokeConfirm, setRevokeConfirm] = useState(false); const [revokeConfirm, setRevokeConfirm] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState(false);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const { data: batch, isLoading } = useQuery({ 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 = () => { const handleCopyAll = () => {
if (!links) return; if (!links) return;
void copyToClipboard(links.links.join('\n')); void copyToClipboard(links.links.join('\n'));
@@ -169,6 +192,12 @@ export default function AdminCouponDetail() {
</span> </span>
</div> </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"> <div className="flex justify-between gap-4">
<span className="text-dark-400">{t('admin.coupons.detail.validUntil')}</span> <span className="text-dark-400">{t('admin.coupons.detail.validUntil')}</span>
<span className="text-dark-100"> <span className="text-dark-100">
@@ -228,6 +257,49 @@ export default function AdminCouponDetail() {
</PermissionGate> </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 */} {/* Revoke confirmation */}
{revokeConfirm && ( {revokeConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-dark-950/70 p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-dark-950/70 p-4">