mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
implemented merging
This commit is contained in:
1 parent
1e6a6b93b1
commit
41c1b3c574
7 files changed
+390
-43
No files matched your search
@@ -1,51 +1,62 @@
|
|||||||
|
import re
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.db.models import Count
|
from django.db.models import Count, F, Func, Value, CharField
|
||||||
|
|
||||||
from api.models import Flavor, Rating
|
from api.models import Flavor, Category
|
||||||
|
from api.services.flavor import merge_flavors
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = "Identifies and merges duplicate flavors (same name and category)"
|
help = "Identifies and merges duplicate flavors (normalized name and category)"
|
||||||
|
|
||||||
|
def normalize_name(self, name: str) -> str:
|
||||||
|
"""Strip common suffixes and normalize for comparison."""
|
||||||
|
# Strip common suffixes
|
||||||
|
suffixes = [
|
||||||
|
r"\s+Energy$",
|
||||||
|
r"\s+Hydration$",
|
||||||
|
r"\s+Iced\s+Tea$",
|
||||||
|
r"\s+\(Legacy\)$",
|
||||||
|
]
|
||||||
|
norm = name.strip()
|
||||||
|
for suffix in suffixes:
|
||||||
|
norm = re.sub(suffix, "", norm, flags=re.IGNORECASE)
|
||||||
|
return norm.strip().lower()
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
# Find duplicates based on name and category
|
# We need to find duplicates based on normalized names within each category
|
||||||
duplicates = (
|
# Since we can't easily do normalization in a single SQL query with complex regex,
|
||||||
Flavor.objects.values("name", "category")
|
# we'll fetch all flavors and group them in Python.
|
||||||
.annotate(name_count=Count("id"))
|
|
||||||
.filter(name_count__gt=1)
|
categories = Category.objects.all()
|
||||||
)
|
total_merged = 0
|
||||||
|
|
||||||
self.stdout.write(f"Found {duplicates.count()} sets of duplicates.")
|
for category in categories:
|
||||||
|
flavors = Flavor.objects.filter(category=category)
|
||||||
|
grouped: dict[str, list[Flavor]] = {}
|
||||||
|
|
||||||
|
for f in flavors:
|
||||||
|
norm = self.normalize_name(f.name)
|
||||||
|
if norm not in grouped:
|
||||||
|
grouped[norm] = []
|
||||||
|
grouped[norm].append(f)
|
||||||
|
|
||||||
|
for norm_name, flavor_set in grouped.items():
|
||||||
|
if len(flavor_set) > 1:
|
||||||
|
# Sort: external_id first (current catalog), then created_at
|
||||||
|
# We want to keep the one with external_id
|
||||||
|
flavor_set.sort(key=lambda x: (x.external_id is None, x.created_at))
|
||||||
|
|
||||||
|
keep = flavor_set[0]
|
||||||
|
others = flavor_set[1:]
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
f"Merging {len(others)} duplicates into '{keep.name}' "
|
||||||
|
f"(Category: {category.name}, ID: {keep.id})"
|
||||||
|
)
|
||||||
|
|
||||||
|
for other in others:
|
||||||
|
merge_flavors(keep, other)
|
||||||
|
total_merged += 1
|
||||||
|
|
||||||
for dup in duplicates:
|
self.stdout.write(self.style.SUCCESS(f"Finished cleaning up {total_merged} duplicates."))
|
||||||
name = dup["name"]
|
|
||||||
category_id = dup["category"]
|
|
||||||
|
|
||||||
# Get all flavors in this set, ordered by having an external_id first, then by date
|
|
||||||
flavor_set = Flavor.objects.filter(name=name, category_id=category_id).order_by(
|
|
||||||
"-external_id", "created_at"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Keep the first one, merge others into it
|
|
||||||
keep = flavor_set[0]
|
|
||||||
others = flavor_set[1:]
|
|
||||||
|
|
||||||
self.stdout.write(
|
|
||||||
f"Merging {len(others)} duplicates into '{keep.name}' (ID: {keep.id}, Ext: {keep.external_id})"
|
|
||||||
)
|
|
||||||
|
|
||||||
for other in others:
|
|
||||||
# Re-assign ratings
|
|
||||||
for rating in Rating.objects.filter(flavor=other):
|
|
||||||
try:
|
|
||||||
rating.flavor = keep
|
|
||||||
rating.save()
|
|
||||||
except Exception:
|
|
||||||
# User already has a rating for the 'keep' flavor
|
|
||||||
# Delete the duplicate rating instead
|
|
||||||
rating.delete()
|
|
||||||
|
|
||||||
# Delete the duplicate flavor
|
|
||||||
other.delete()
|
|
||||||
|
|
||||||
self.stdout.write(self.style.SUCCESS("Finished cleaning up duplicates."))
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
from django.db import transaction
|
||||||
|
from api.models import Flavor, Rating, Reply
|
||||||
|
|
||||||
|
def merge_flavors(keep_flavor: Flavor, remove_flavor: Flavor) -> None:
|
||||||
|
"""
|
||||||
|
Merges remove_flavor into keep_flavor.
|
||||||
|
Re-assigns all ratings and ensures integrity by keeping the most detailed
|
||||||
|
review if a user has rated both.
|
||||||
|
"""
|
||||||
|
if keep_flavor.id == remove_flavor.id:
|
||||||
|
return
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
# Re-assign or resolve ratings
|
||||||
|
remove_ratings = Rating.objects.filter(flavor=remove_flavor)
|
||||||
|
|
||||||
|
for rating in remove_ratings:
|
||||||
|
# Check if user already rated the target flavor
|
||||||
|
existing_rating = Rating.objects.filter(user=rating.user, flavor=keep_flavor).first()
|
||||||
|
|
||||||
|
if not existing_rating:
|
||||||
|
# Safe to just move it
|
||||||
|
rating.flavor = keep_flavor
|
||||||
|
rating.save()
|
||||||
|
else:
|
||||||
|
# Conflict Resolution: Keep the longest comment
|
||||||
|
keep_comment = existing_rating.comment or ""
|
||||||
|
remove_comment = rating.comment or ""
|
||||||
|
|
||||||
|
if len(remove_comment) > len(keep_comment):
|
||||||
|
# Update the one we are keeping to have the better content from the one being removed
|
||||||
|
existing_rating.score = rating.score
|
||||||
|
existing_rating.comment = rating.comment
|
||||||
|
existing_rating.save()
|
||||||
|
|
||||||
|
# Move ALL replies from the rating being deleted to the one being kept
|
||||||
|
Reply.objects.filter(rating=rating).update(rating=existing_rating)
|
||||||
|
|
||||||
|
# Delete the redundant rating
|
||||||
|
rating.delete()
|
||||||
|
|
||||||
|
# Transfer external_id if keep_flavor doesn't have one
|
||||||
|
if not keep_flavor.external_id and remove_flavor.external_id:
|
||||||
|
keep_flavor.external_id = remove_flavor.external_id
|
||||||
|
keep_flavor.save(update_fields=["external_id"])
|
||||||
|
|
||||||
|
# Finally, delete the flavor
|
||||||
|
remove_flavor.delete()
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
from api.models import Category, Flavor, Rating, Reply, User
|
||||||
|
from api.services.flavor import merge_flavors
|
||||||
|
from api.management.commands.cleanup_duplicates import Command
|
||||||
|
from io import StringIO
|
||||||
|
|
||||||
|
class FlavorMergeTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.cat = Category.objects.create(name="Energy", slug="energy")
|
||||||
|
self.user1 = User.objects.create_user(username="user1")
|
||||||
|
self.user2 = User.objects.create_user(username="user2")
|
||||||
|
|
||||||
|
self.keep = Flavor.objects.create(name="Dolphin Daiquiri", category=self.cat)
|
||||||
|
self.remove = Flavor.objects.create(name="Dolphin Daiquiri Energy", category=self.cat)
|
||||||
|
|
||||||
|
def test_basic_merge(self):
|
||||||
|
# User 1 rates 'remove'
|
||||||
|
r1 = Rating.objects.create(user=self.user1, flavor=self.remove, score=8, comment="Good!")
|
||||||
|
|
||||||
|
merge_flavors(self.keep, self.remove)
|
||||||
|
|
||||||
|
self.assertFalse(Flavor.objects.filter(id=self.remove.id).exists())
|
||||||
|
r1.refresh_from_db()
|
||||||
|
self.assertEqual(r1.flavor, self.keep)
|
||||||
|
|
||||||
|
def test_merge_with_conflict_keeps_longest_comment(self):
|
||||||
|
# User 1 rates both
|
||||||
|
r_short = Rating.objects.create(user=self.user1, flavor=self.keep, score=5, comment="Short")
|
||||||
|
r_long = Rating.objects.create(user=self.user1, flavor=self.remove, score=9, comment="Very long and detailed review")
|
||||||
|
|
||||||
|
# Add a reply to the one being deleted
|
||||||
|
reply = Reply.objects.create(user=self.user2, rating=r_long, text="Nice review!")
|
||||||
|
|
||||||
|
merge_flavors(self.keep, self.remove)
|
||||||
|
|
||||||
|
self.assertEqual(Rating.objects.filter(user=self.user1, flavor=self.keep).count(), 1)
|
||||||
|
kept_rating = Rating.objects.get(user=self.user1, flavor=self.keep)
|
||||||
|
self.assertEqual(kept_rating.comment, "Very long and detailed review")
|
||||||
|
self.assertEqual(kept_rating.score, 9)
|
||||||
|
|
||||||
|
# Verify reply moved
|
||||||
|
reply.refresh_from_db()
|
||||||
|
self.assertEqual(reply.rating, kept_rating)
|
||||||
|
|
||||||
|
def test_merge_with_conflict_preserves_replies_from_discarded(self):
|
||||||
|
r_keep = Rating.objects.create(user=self.user1, flavor=self.keep, score=10, comment="Best ever!")
|
||||||
|
r_remove = Rating.objects.create(user=self.user1, flavor=self.remove, score=8, comment="Meh")
|
||||||
|
|
||||||
|
reply_on_remove = Reply.objects.create(user=self.user2, rating=r_remove, text="I disagree")
|
||||||
|
|
||||||
|
merge_flavors(self.keep, self.remove)
|
||||||
|
|
||||||
|
kept_rating = Rating.objects.get(user=self.user1, flavor=self.keep)
|
||||||
|
self.assertEqual(kept_rating.comment, "Best ever!")
|
||||||
|
|
||||||
|
reply_on_remove.refresh_from_db()
|
||||||
|
self.assertEqual(reply_on_remove.rating, kept_rating)
|
||||||
|
|
||||||
|
class CleanupDuplicatesTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.cat = Category.objects.create(name="Energy", slug="energy")
|
||||||
|
self.other_cat = Category.objects.create(name="Iced Tea", slug="iced-tea")
|
||||||
|
|
||||||
|
def test_normalization_logic(self):
|
||||||
|
f1 = Flavor.objects.create(name="Dolphin Daiquiri", category=self.cat)
|
||||||
|
f2 = Flavor.objects.create(name="Dolphin Daiquiri Energy", category=self.cat)
|
||||||
|
f3 = Flavor.objects.create(name="Dolphin Daiquiri", category=self.other_cat)
|
||||||
|
|
||||||
|
out = StringIO()
|
||||||
|
cmd = Command()
|
||||||
|
cmd.stdout = out
|
||||||
|
cmd.handle()
|
||||||
|
|
||||||
|
self.assertTrue(Flavor.objects.filter(id=f1.id).exists())
|
||||||
|
self.assertFalse(Flavor.objects.filter(id=f2.id).exists())
|
||||||
|
self.assertTrue(Flavor.objects.filter(id=f3.id).exists()) # Different category
|
||||||
|
|
||||||
|
def test_various_suffixes(self):
|
||||||
|
Flavor.objects.create(name="Berry", category=self.cat)
|
||||||
|
Flavor.objects.create(name="Berry Energy", category=self.cat)
|
||||||
|
Flavor.objects.create(name="Berry Hydration", category=self.cat)
|
||||||
|
Flavor.objects.create(name="Berry Iced Tea", category=self.cat)
|
||||||
|
Flavor.objects.create(name="Berry (Legacy)", category=self.cat)
|
||||||
|
|
||||||
|
cmd = Command()
|
||||||
|
cmd.stdout = StringIO()
|
||||||
|
cmd.handle()
|
||||||
|
|
||||||
|
self.assertEqual(Flavor.objects.filter(category=self.cat).count(), 1)
|
||||||
@@ -8,7 +8,8 @@ from rest_framework.decorators import action
|
|||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from api.models import Job, Rating, Reply, SystemConfig, Ticket, User
|
from api.models import Flavor, Job, Rating, Reply, SystemConfig, Ticket, User
|
||||||
|
from api.services.flavor import merge_flavors
|
||||||
from api.serializers import (
|
from api.serializers import (
|
||||||
AdminUserDetailSerializer,
|
AdminUserDetailSerializer,
|
||||||
AdminUserListSerializer,
|
AdminUserListSerializer,
|
||||||
@@ -154,6 +155,32 @@ class AdminViewSet(viewsets.ViewSet):
|
|||||||
_sync_periodic_task(job)
|
_sync_periodic_task(job)
|
||||||
return Response(JobSerializer(job).data)
|
return Response(JobSerializer(job).data)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["post"])
|
||||||
|
def merge_flavors(self, request: Request) -> Response:
|
||||||
|
keep_id = request.data.get("keep_id")
|
||||||
|
remove_id = request.data.get("remove_id")
|
||||||
|
|
||||||
|
if not keep_id or not remove_id:
|
||||||
|
return Response(
|
||||||
|
{"error": "Both keep_id and remove_id are required"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
keep_flavor = Flavor.objects.get(pk=keep_id)
|
||||||
|
remove_flavor = Flavor.objects.get(pk=remove_id)
|
||||||
|
except Flavor.DoesNotExist:
|
||||||
|
return Response({"error": "One or both flavors not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
if keep_flavor.category_id != remove_flavor.category_id:
|
||||||
|
return Response(
|
||||||
|
{"error": "Flavors must belong to the same category"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
merge_flavors(keep_flavor, remove_flavor)
|
||||||
|
return Response({"status": "Flavors merged successfully"})
|
||||||
|
|
||||||
@action(detail=False, methods=["post"])
|
@action(detail=False, methods=["post"])
|
||||||
def send_test_email(self, request: Request) -> Response:
|
def send_test_email(self, request: Request) -> Response:
|
||||||
me = current_user(request)
|
me = current_user(request)
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ const AdminBanners = lazy(() => import('./pages/admin/AdminBanners'));
|
|||||||
const AdminJobs = lazy(() => import('./pages/admin/AdminJobs'));
|
const AdminJobs = lazy(() => import('./pages/admin/AdminJobs'));
|
||||||
const AdminTickets = lazy(() => import('./pages/admin/AdminTickets'));
|
const AdminTickets = lazy(() => import('./pages/admin/AdminTickets'));
|
||||||
const AdminConfig = lazy(() => import('./pages/admin/AdminConfig'));
|
const AdminConfig = lazy(() => import('./pages/admin/AdminConfig'));
|
||||||
|
const AdminFlavors = lazy(() => import('./pages/admin/AdminFlavors'));
|
||||||
const AdminUserDetail = lazy(() => import('./pages/AdminUserDetail'));
|
const AdminUserDetail = lazy(() => import('./pages/AdminUserDetail'));
|
||||||
const AdminRatingDetail = lazy(() => import('./pages/AdminRatingDetail'));
|
const AdminRatingDetail = lazy(() => import('./pages/AdminRatingDetail'));
|
||||||
const AdminReplyDetail = lazy(() => import('./pages/AdminReplyDetail'));
|
const AdminReplyDetail = lazy(() => import('./pages/AdminReplyDetail'));
|
||||||
@@ -338,6 +339,7 @@ const App: React.FC = () => {
|
|||||||
<Route path="users" element={<AdminUsers />} />
|
<Route path="users" element={<AdminUsers />} />
|
||||||
<Route path="users/:id" element={<AdminUserDetail />} />
|
<Route path="users/:id" element={<AdminUserDetail />} />
|
||||||
<Route path="user/:id" element={<AdminUserDetail />} />
|
<Route path="user/:id" element={<AdminUserDetail />} />
|
||||||
|
<Route path="flavors" element={<AdminFlavors />} />
|
||||||
<Route path="banners" element={<AdminBanners />} />
|
<Route path="banners" element={<AdminBanners />} />
|
||||||
<Route path="jobs" element={<AdminJobs />} />
|
<Route path="jobs" element={<AdminJobs />} />
|
||||||
<Route path="tickets" element={<AdminTickets />} />
|
<Route path="tickets" element={<AdminTickets />} />
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Autocomplete,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogTitle,
|
||||||
|
Divider,
|
||||||
|
Grid,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useSnackbar } from '../../contexts/NotificationContext';
|
||||||
|
import { useFlavorsList } from '../../api/queries/useFlavorsList';
|
||||||
|
import api from '../../lib/api';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { queryKeys } from '../../api/keys';
|
||||||
|
|
||||||
|
const AdminFlavors: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { notify } = useSnackbar();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { data: flavors = [], isLoading } = useFlavorsList();
|
||||||
|
|
||||||
|
const [keepFlavor, setKeepFlavor] = useState<any>(null);
|
||||||
|
const [removeFlavor, setRemoveFlavor] = useState<any>(null);
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
const [isMerging, setIsMerging] = useState(false);
|
||||||
|
|
||||||
|
const handleMerge = async () => {
|
||||||
|
if (!keepFlavor || !removeFlavor) return;
|
||||||
|
|
||||||
|
setIsMerging(true);
|
||||||
|
try {
|
||||||
|
await api.post('admin/merge_flavors/', {
|
||||||
|
keep_id: keepFlavor.id,
|
||||||
|
remove_id: removeFlavor.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
notify({ message: t('admin.mergeSuccess', { defaultValue: 'Flavors merged successfully' }), severity: 'success' });
|
||||||
|
|
||||||
|
// Invalidate queries
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.flavors({ scope: 'all' }) });
|
||||||
|
|
||||||
|
// Reset selection
|
||||||
|
setKeepFlavor(null);
|
||||||
|
setRemoveFlavor(null);
|
||||||
|
setConfirmOpen(false);
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMsg = err.response?.data?.error || err.message;
|
||||||
|
notify({ message: `Error: ${errorMsg}`, severity: 'error' });
|
||||||
|
} finally {
|
||||||
|
setIsMerging(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const canMerge = keepFlavor && removeFlavor && keepFlavor.id !== removeFlavor.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" sx={{ mb: 3, fontWeight: 'bold' }}>
|
||||||
|
{t('admin.manageFlavors', { defaultValue: 'Manage Flavors' })}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Card sx={{ mb: 4 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
{t('admin.mergeFlavors', { defaultValue: 'Merge Flavors' })}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
{t('admin.mergeFlavorsDesc', {
|
||||||
|
defaultValue: 'Combine two flavors into one. Ratings from the source flavor will be moved to the target flavor. If a user rated both, the more detailed review is kept.'
|
||||||
|
})}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Grid container spacing={3} alignItems="center">
|
||||||
|
<Grid item xs={12} md={5}>
|
||||||
|
<Autocomplete
|
||||||
|
options={flavors}
|
||||||
|
getOptionLabel={(option) => `${option.name} (${option.category_name})`}
|
||||||
|
value={removeFlavor}
|
||||||
|
onChange={(_, newValue) => setRemoveFlavor(newValue)}
|
||||||
|
loading={isLoading}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label={t('admin.flavorToRemove', { defaultValue: 'Flavor to Remove (Source)' })}
|
||||||
|
helperText={t('admin.flavorToRemoveHint', { defaultValue: 'This flavor will be DELETED' })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} md={2} sx={{ textAlign: 'center' }}>
|
||||||
|
<Typography variant="h4">→</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} md={5}>
|
||||||
|
<Autocomplete
|
||||||
|
options={flavors}
|
||||||
|
getOptionLabel={(option) => `${option.name} (${option.category_name})`}
|
||||||
|
value={keepFlavor}
|
||||||
|
onChange={(_, newValue) => setKeepFlavor(newValue)}
|
||||||
|
loading={isLoading}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label={t('admin.flavorToKeep', { defaultValue: 'Flavor to Keep (Target)' })}
|
||||||
|
helperText={t('admin.flavorToKeepHint', { defaultValue: 'This flavor will PRESERVED' })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Divider sx={{ my: 1 }} />
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 2 }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="warning"
|
||||||
|
disabled={!canMerge}
|
||||||
|
onClick={() => setConfirmOpen(true)}
|
||||||
|
>
|
||||||
|
{t('admin.startMerge', { defaultValue: 'Start Merge' })}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={confirmOpen} onClose={() => !isMerging && setConfirmOpen(false)}>
|
||||||
|
<DialogTitle>{t('admin.confirmMergeTitle', { defaultValue: 'Confirm Flavor Merge' })}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
{t('admin.confirmMergeText', {
|
||||||
|
defaultValue: 'Are you sure you want to merge "{{remove}}" into "{{keep}}"? This action is irreversible. "{{remove}}" will be deleted, and all its ratings will be transferred.',
|
||||||
|
remove: removeFlavor?.name,
|
||||||
|
keep: keepFlavor?.name
|
||||||
|
})}
|
||||||
|
</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setConfirmOpen(false)} disabled={isMerging}>
|
||||||
|
{t('common.cancel', { defaultValue: 'Cancel' })}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleMerge}
|
||||||
|
color="warning"
|
||||||
|
variant="contained"
|
||||||
|
autoFocus
|
||||||
|
disabled={isMerging}
|
||||||
|
>
|
||||||
|
{isMerging ? t('common.processing', { defaultValue: 'Processing...' }) : t('admin.confirmMerge', { defaultValue: 'Merge Flavors' })}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminFlavors;
|
||||||
@@ -16,6 +16,7 @@ import WallpaperIcon from '@mui/icons-material/Wallpaper';
|
|||||||
import TerminalIcon from '@mui/icons-material/Terminal';
|
import TerminalIcon from '@mui/icons-material/Terminal';
|
||||||
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
|
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
|
||||||
import TuneIcon from '@mui/icons-material/Tune';
|
import TuneIcon from '@mui/icons-material/Tune';
|
||||||
|
import LocalDrinkIcon from '@mui/icons-material/LocalDrink';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { PageShell } from '../../components/ui';
|
import { PageShell } from '../../components/ui';
|
||||||
import { useTitle } from '../../hooks/useTitle';
|
import { useTitle } from '../../hooks/useTitle';
|
||||||
@@ -23,6 +24,7 @@ import { useTitle } from '../../hooks/useTitle';
|
|||||||
const SECTIONS = [
|
const SECTIONS = [
|
||||||
{ value: 'overview', icon: <SettingsIcon /> },
|
{ value: 'overview', icon: <SettingsIcon /> },
|
||||||
{ value: 'users', icon: <PeopleIcon /> },
|
{ value: 'users', icon: <PeopleIcon /> },
|
||||||
|
{ value: 'flavors', icon: <LocalDrinkIcon /> },
|
||||||
{ value: 'banners', icon: <WallpaperIcon /> },
|
{ value: 'banners', icon: <WallpaperIcon /> },
|
||||||
{ value: 'jobs', icon: <TerminalIcon /> },
|
{ value: 'jobs', icon: <TerminalIcon /> },
|
||||||
{ value: 'tickets', icon: <HelpOutlineIcon /> },
|
{ value: 'tickets', icon: <HelpOutlineIcon /> },
|
||||||
|
|||||||
Reference in new issue
Block a user