feat: add new navigation item for rules in the dashboard sidebar with associated icon
This commit is contained in:
@@ -15,6 +15,7 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Settings,
|
||||
Wand2,
|
||||
} from "lucide-react";
|
||||
|
||||
const navItems = [
|
||||
@@ -23,6 +24,7 @@ const navItems = [
|
||||
{ href: "/folders", label: "Organisation", icon: FolderTree },
|
||||
{ href: "/transactions", label: "Transactions", icon: Upload },
|
||||
{ href: "/categories", label: "Catégories", icon: Tags },
|
||||
{ href: "/rules", label: "Règles", icon: Wand2 },
|
||||
{ href: "/statistics", label: "Statistiques", icon: BarChart3 },
|
||||
];
|
||||
|
||||
|
||||
95
components/rules/constants.ts
Normal file
95
components/rules/constants.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
// Minimum transactions in a group to be displayed
|
||||
export const MIN_GROUP_SIZE = 1;
|
||||
|
||||
// Common words to filter out when suggesting keywords
|
||||
export const STOP_WORDS = [
|
||||
"de",
|
||||
"du",
|
||||
"la",
|
||||
"le",
|
||||
"les",
|
||||
"des",
|
||||
"un",
|
||||
"une",
|
||||
"et",
|
||||
"ou",
|
||||
"par",
|
||||
"pour",
|
||||
"avec",
|
||||
"sur",
|
||||
"dans",
|
||||
"en",
|
||||
"au",
|
||||
"aux",
|
||||
"ce",
|
||||
"cette",
|
||||
"ces",
|
||||
"mon",
|
||||
"ma",
|
||||
"mes",
|
||||
"ton",
|
||||
"ta",
|
||||
"tes",
|
||||
"son",
|
||||
"sa",
|
||||
"ses",
|
||||
"notre",
|
||||
"nos",
|
||||
"votre",
|
||||
"vos",
|
||||
"leur",
|
||||
"leurs",
|
||||
];
|
||||
|
||||
// Function to normalize transaction descriptions for grouping
|
||||
export function normalizeDescription(description: string): string {
|
||||
return description
|
||||
.toLowerCase()
|
||||
.replace(/\d{2}\/\d{2}\/\d{4}/g, "") // Remove dates
|
||||
.replace(/\d{2}-\d{2}-\d{4}/g, "") // Remove dates
|
||||
.replace(/\d+[.,]\d+/g, "") // Remove amounts
|
||||
.replace(/carte \*+\d+/gi, "CARTE") // Normalize card numbers
|
||||
.replace(/cb\*+\d+/gi, "CB") // Normalize CB numbers
|
||||
.replace(/\s+/g, " ") // Normalize spaces
|
||||
.replace(/[^\w\s]/g, " ") // Remove special chars
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Extract meaningful keywords from description
|
||||
export function extractKeywords(description: string): string[] {
|
||||
const normalized = normalizeDescription(description);
|
||||
const words = normalized.split(/\s+/);
|
||||
|
||||
return words
|
||||
.filter((word) => word.length > 2)
|
||||
.filter((word) => !STOP_WORDS.includes(word.toLowerCase()))
|
||||
.filter((word) => !/^\d+$/.test(word)); // Remove pure numbers
|
||||
}
|
||||
|
||||
// Suggest a keyword based on common patterns in descriptions
|
||||
export function suggestKeyword(descriptions: string[]): string {
|
||||
// Find common substrings
|
||||
const keywords = descriptions.flatMap(extractKeywords);
|
||||
const frequency: Record<string, number> = {};
|
||||
|
||||
keywords.forEach((keyword) => {
|
||||
frequency[keyword] = (frequency[keyword] || 0) + 1;
|
||||
});
|
||||
|
||||
// Find the most frequent keyword that appears in most descriptions
|
||||
const sorted = Object.entries(frequency)
|
||||
.filter(([_, count]) => count >= Math.ceil(descriptions.length * 0.5))
|
||||
.sort((a, b) => b[1] - a[1]);
|
||||
|
||||
if (sorted.length > 0) {
|
||||
// Return the longest frequent keyword
|
||||
return sorted.reduce((best, current) =>
|
||||
current[0].length > best[0].length ? current : best
|
||||
)[0];
|
||||
}
|
||||
|
||||
// Fallback: first meaningful word from first description
|
||||
const firstKeywords = extractKeywords(descriptions[0]);
|
||||
return firstKeywords[0] || descriptions[0].slice(0, 15);
|
||||
}
|
||||
|
||||
4
components/rules/index.ts
Normal file
4
components/rules/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { RuleGroupCard } from "./rule-group-card";
|
||||
export { RuleCreateDialog } from "./rule-create-dialog";
|
||||
export { RulesSearchBar } from "./rules-search-bar";
|
||||
|
||||
278
components/rules/rule-create-dialog.tsx
Normal file
278
components/rules/rule-create-dialog.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { CategoryIcon } from "@/components/ui/category-icon";
|
||||
import { Tag, AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
import type { Category, Transaction } from "@/lib/types";
|
||||
|
||||
interface TransactionGroup {
|
||||
key: string;
|
||||
displayName: string;
|
||||
transactions: Transaction[];
|
||||
totalAmount: number;
|
||||
suggestedKeyword: string;
|
||||
}
|
||||
|
||||
interface RuleCreateDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
group: TransactionGroup | null;
|
||||
categories: Category[];
|
||||
onSave: (data: {
|
||||
keyword: string;
|
||||
categoryId: string;
|
||||
applyToExisting: boolean;
|
||||
transactionIds: string[];
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
export function RuleCreateDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
group,
|
||||
categories,
|
||||
onSave,
|
||||
}: RuleCreateDialogProps) {
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [categoryId, setCategoryId] = useState<string>("");
|
||||
const [applyToExisting, setApplyToExisting] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Reset form when group changes
|
||||
useEffect(() => {
|
||||
if (group) {
|
||||
setKeyword(group.suggestedKeyword);
|
||||
setCategoryId("");
|
||||
setApplyToExisting(true);
|
||||
}
|
||||
}, [group]);
|
||||
|
||||
// Organize categories by parent
|
||||
const { parentCategories, childrenByParent } = useMemo(() => {
|
||||
const parents = categories.filter((c) => c.parentId === null);
|
||||
const children: Record<string, Category[]> = {};
|
||||
|
||||
categories
|
||||
.filter((c) => c.parentId !== null)
|
||||
.forEach((child) => {
|
||||
if (!children[child.parentId!]) {
|
||||
children[child.parentId!] = [];
|
||||
}
|
||||
children[child.parentId!].push(child);
|
||||
});
|
||||
|
||||
return { parentCategories: parents, childrenByParent: children };
|
||||
}, [categories]);
|
||||
|
||||
// Check if keyword already exists in any category
|
||||
const existingCategory = useMemo(() => {
|
||||
if (!keyword) return null;
|
||||
const lowerKeyword = keyword.toLowerCase();
|
||||
return categories.find((c) =>
|
||||
c.keywords.some((k) => k.toLowerCase() === lowerKeyword)
|
||||
);
|
||||
}, [keyword, categories]);
|
||||
|
||||
const selectedCategory = categories.find((c) => c.id === categoryId);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!keyword || !categoryId || !group) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await onSave({
|
||||
keyword,
|
||||
categoryId,
|
||||
applyToExisting,
|
||||
transactionIds: group.transactions.map((t) => t.id),
|
||||
});
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to create rule:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!group) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Créer une règle de catégorisation</DialogTitle>
|
||||
<DialogDescription>
|
||||
Associez un mot-clé à une catégorie pour catégoriser automatiquement
|
||||
les transactions similaires.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Group info */}
|
||||
<div className="p-3 rounded-lg bg-muted/50 border border-border">
|
||||
<div className="text-sm font-medium text-foreground mb-1">
|
||||
{group.displayName}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{group.transactions.length} transaction
|
||||
{group.transactions.length > 1 ? "s" : ""} seront catégorisées
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keyword input */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="keyword">Mot-clé de détection</Label>
|
||||
<div className="relative">
|
||||
<Tag className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="keyword"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
placeholder="ex: spotify, carrefour, sncf..."
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Les transactions contenant ce mot-clé seront automatiquement
|
||||
catégorisées.
|
||||
</p>
|
||||
{existingCategory && (
|
||||
<div className="flex items-center gap-2 text-xs text-amber-600 dark:text-amber-400">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
<span>
|
||||
Ce mot-clé existe déjà dans "{existingCategory.name}"
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Category select */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">Catégorie</Label>
|
||||
<Select value={categoryId} onValueChange={setCategoryId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Sélectionner une catégorie" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{parentCategories.map((parent) => (
|
||||
<div key={parent.id}>
|
||||
<SelectItem value={parent.id} className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<CategoryIcon
|
||||
icon={parent.icon}
|
||||
color={parent.color}
|
||||
size={16}
|
||||
/>
|
||||
<span>{parent.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
{childrenByParent[parent.id]?.map((child) => (
|
||||
<SelectItem key={child.id} value={child.id}>
|
||||
<div className="flex items-center gap-2 pl-4">
|
||||
<CategoryIcon
|
||||
icon={child.icon}
|
||||
color={child.color}
|
||||
size={16}
|
||||
/>
|
||||
<span>{child.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedCategory && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Mots-clés actuels:
|
||||
</span>
|
||||
{selectedCategory.keywords.length > 0 ? (
|
||||
selectedCategory.keywords.map((k, i) => (
|
||||
<Badge key={i} variant="outline" className="text-xs">
|
||||
{k}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground italic">
|
||||
Aucun
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Apply to existing checkbox */}
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="apply-existing"
|
||||
checked={applyToExisting}
|
||||
onCheckedChange={(checked) =>
|
||||
setApplyToExisting(checked as boolean)
|
||||
}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label
|
||||
htmlFor="apply-existing"
|
||||
className="text-sm font-medium cursor-pointer"
|
||||
>
|
||||
Appliquer aux transactions existantes
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Catégoriser immédiatement les {group.transactions.length}{" "}
|
||||
transaction
|
||||
{group.transactions.length > 1 ? "s" : ""} de ce groupe
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
{keyword && categoryId && (
|
||||
<div className="p-3 rounded-lg bg-success/10 border border-success/20">
|
||||
<div className="flex items-center gap-2 text-sm text-success">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
<span>
|
||||
Le mot-clé "<strong>{keyword}</strong>" sera ajouté à la
|
||||
catégorie "<strong>{selectedCategory?.name}</strong>"
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!keyword || !categoryId || isLoading}
|
||||
>
|
||||
{isLoading ? "Création..." : "Créer la règle"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
157
components/rules/rule-group-card.tsx
Normal file
157
components/rules/rule-group-card.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronRight, Plus, Tag } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Transaction, Category } from "@/lib/types";
|
||||
|
||||
interface TransactionGroup {
|
||||
key: string;
|
||||
displayName: string;
|
||||
transactions: Transaction[];
|
||||
totalAmount: number;
|
||||
suggestedKeyword: string;
|
||||
}
|
||||
|
||||
interface RuleGroupCardProps {
|
||||
group: TransactionGroup;
|
||||
isExpanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
onCreateRule: () => void;
|
||||
categories: Category[];
|
||||
formatCurrency: (amount: number) => string;
|
||||
formatDate: (date: string) => string;
|
||||
}
|
||||
|
||||
export function RuleGroupCard({
|
||||
group,
|
||||
isExpanded,
|
||||
onToggleExpand,
|
||||
onCreateRule,
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
}: RuleGroupCardProps) {
|
||||
const avgAmount =
|
||||
group.transactions.reduce((sum, t) => sum + t.amount, 0) /
|
||||
group.transactions.length;
|
||||
const isDebit = avgAmount < 0;
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-lg bg-card overflow-hidden">
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center gap-3 p-4 cursor-pointer hover:bg-accent/5 transition-colors"
|
||||
onClick={onToggleExpand}
|
||||
>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 shrink-0">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-foreground truncate">
|
||||
{group.displayName}
|
||||
</span>
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
{group.transactions.length} transaction
|
||||
{group.transactions.length > 1 ? "s" : ""}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground">
|
||||
<Tag className="h-3 w-3" />
|
||||
<span className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">
|
||||
{group.suggestedKeyword}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<div
|
||||
className={cn(
|
||||
"font-semibold tabular-nums",
|
||||
isDebit ? "text-destructive" : "text-success"
|
||||
)}
|
||||
>
|
||||
{formatCurrency(group.totalAmount)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Moy: {formatCurrency(avgAmount)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCreateRule();
|
||||
}}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Créer règle
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded transactions list */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border bg-muted/30">
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-medium text-muted-foreground">
|
||||
Date
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-muted-foreground">
|
||||
Description
|
||||
</th>
|
||||
<th className="px-4 py-2 text-right font-medium text-muted-foreground">
|
||||
Montant
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{group.transactions.map((transaction) => (
|
||||
<tr
|
||||
key={transaction.id}
|
||||
className="border-t border-border/50 hover:bg-muted/50"
|
||||
>
|
||||
<td className="px-4 py-2 text-muted-foreground whitespace-nowrap">
|
||||
{formatDate(transaction.date)}
|
||||
</td>
|
||||
<td className="px-4 py-2 truncate max-w-md">
|
||||
{transaction.description}
|
||||
{transaction.memo && (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
({transaction.memo})
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
className={cn(
|
||||
"px-4 py-2 text-right tabular-nums whitespace-nowrap",
|
||||
transaction.amount < 0
|
||||
? "text-destructive"
|
||||
: "text-success"
|
||||
)}
|
||||
>
|
||||
{formatCurrency(transaction.amount)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
78
components/rules/rules-search-bar.tsx
Normal file
78
components/rules/rules-search-bar.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Search, Filter, ArrowUpDown } from "lucide-react";
|
||||
|
||||
interface RulesSearchBarProps {
|
||||
searchQuery: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
sortBy: "count" | "amount" | "name";
|
||||
onSortChange: (value: "count" | "amount" | "name") => void;
|
||||
filterMinCount: number;
|
||||
onFilterMinCountChange: (value: number) => void;
|
||||
}
|
||||
|
||||
export function RulesSearchBar({
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
sortBy,
|
||||
onSortChange,
|
||||
filterMinCount,
|
||||
onFilterMinCountChange,
|
||||
}: RulesSearchBarProps) {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Rechercher dans les descriptions..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Select
|
||||
value={sortBy}
|
||||
onValueChange={(v) => onSortChange(v as "count" | "amount" | "name")}
|
||||
>
|
||||
<SelectTrigger className="w-44">
|
||||
<ArrowUpDown className="h-4 w-4 mr-2" />
|
||||
<SelectValue placeholder="Trier par" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="count">Nombre de transactions</SelectItem>
|
||||
<SelectItem value="amount">Montant total</SelectItem>
|
||||
<SelectItem value="name">Nom</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={filterMinCount.toString()}
|
||||
onValueChange={(v) => onFilterMinCountChange(parseInt(v))}
|
||||
>
|
||||
<SelectTrigger className="w-36">
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
<SelectValue placeholder="Minimum" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1+ transactions</SelectItem>
|
||||
<SelectItem value="2">2+ transactions</SelectItem>
|
||||
<SelectItem value="3">3+ transactions</SelectItem>
|
||||
<SelectItem value="5">5+ transactions</SelectItem>
|
||||
<SelectItem value="10">10+ transactions</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user