Compare commits
26 Commits
053b742f89
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b64c6cf612 | |||
| 958400a8c1 | |||
| da8d82955e | |||
| 021bee9444 | |||
| ecd4a57da9 | |||
| f30d53df7e | |||
| 4edf8a0736 | |||
| 4fb44fcb4c | |||
| 268dd26d9a | |||
| 6ba8f6ec8b | |||
| e3ddb733ee | |||
| 9f07c0740d | |||
| 83321c1d39 | |||
| 6c3a7ed5f4 | |||
| 5c15727516 | |||
| fc94ceb9f5 | |||
| 4e24b96068 | |||
| a76a8a96ff | |||
| ec38a4435e | |||
| 9d05973a19 | |||
| 4e800a8bd9 | |||
| 0e7bf49b61 | |||
| 9080b0caea | |||
| 25bd46ea2a | |||
| 9e5be244f0 | |||
| 10992303de |
32
package-lock.json
generated
32
package-lock.json
generated
@@ -56,10 +56,12 @@
|
|||||||
"next": "^15.4.6",
|
"next": "^15.4.6",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
"react-date-object": "^2.1.9",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.61.1",
|
"react-hook-form": "^7.61.1",
|
||||||
"react-markdown": "^9.0.3",
|
"react-markdown": "^9.0.3",
|
||||||
|
"react-multi-date-picker": "^4.5.2",
|
||||||
"react-qr-code": "^2.0.11",
|
"react-qr-code": "^2.0.11",
|
||||||
"react-resizable-panels": "^2.1.9",
|
"react-resizable-panels": "^2.1.9",
|
||||||
"react-syntax-highlighter": "^16.1.1",
|
"react-syntax-highlighter": "^16.1.1",
|
||||||
@@ -6653,6 +6655,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-date-object": {
|
||||||
|
"version": "2.1.9",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/react-date-object/-/react-date-object-2.1.9.tgz",
|
||||||
|
"integrity": "sha512-BHxD/quWOTo9fLKV/cfL/M31ePoj4a1JaJ/CnOf8Ndg3mrkh4x9wEMMkCfTrzduxDOgU8ZgR8uarhqI5G71sTg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/react-day-picker": {
|
"node_modules/react-day-picker": {
|
||||||
"version": "8.10.1",
|
"version": "8.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz",
|
||||||
@@ -6680,6 +6688,16 @@
|
|||||||
"react": "^18.3.1"
|
"react": "^18.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-element-popper": {
|
||||||
|
"version": "2.1.7",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/react-element-popper/-/react-element-popper-2.1.7.tgz",
|
||||||
|
"integrity": "sha512-tuM2OxKlW32h+6uFSK6EENHPeZ2OGgOipHfOAl+VLWEv9/j3QkSGbD+ADX3A9uJlmq24i37n28RjJmAbGTfpEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-hook-form": {
|
"node_modules/react-hook-form": {
|
||||||
"version": "7.61.1",
|
"version": "7.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.61.1.tgz",
|
||||||
@@ -6728,6 +6746,20 @@
|
|||||||
"react": ">=18"
|
"react": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-multi-date-picker": {
|
||||||
|
"version": "4.5.2",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/react-multi-date-picker/-/react-multi-date-picker-4.5.2.tgz",
|
||||||
|
"integrity": "sha512-FgWjZB3Z6IA6XpcWiLPk85PwcRUhOiYhKK42o5k672gD/n2I6rzPfQ8bUrldOIiF/Z7FfOCdH7a6FeubzqteLg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-date-object": "^2.1.8",
|
||||||
|
"react-element-popper": "^2.1.6"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-qr-code": {
|
"node_modules/react-qr-code": {
|
||||||
"version": "2.0.18",
|
"version": "2.0.18",
|
||||||
"resolved": "https://registry.npmjs.org/react-qr-code/-/react-qr-code-2.0.18.tgz",
|
"resolved": "https://registry.npmjs.org/react-qr-code/-/react-qr-code-2.0.18.tgz",
|
||||||
|
|||||||
@@ -58,10 +58,12 @@
|
|||||||
"next": "^15.4.6",
|
"next": "^15.4.6",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
"react-date-object": "^2.1.9",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.61.1",
|
"react-hook-form": "^7.61.1",
|
||||||
"react-markdown": "^9.0.3",
|
"react-markdown": "^9.0.3",
|
||||||
|
"react-multi-date-picker": "^4.5.2",
|
||||||
"react-qr-code": "^2.0.11",
|
"react-qr-code": "^2.0.11",
|
||||||
"react-resizable-panels": "^2.1.9",
|
"react-resizable-panels": "^2.1.9",
|
||||||
"react-syntax-highlighter": "^16.1.1",
|
"react-syntax-highlighter": "^16.1.1",
|
||||||
|
|||||||
5
src/app/admin/coupons/page.tsx
Normal file
5
src/app/admin/coupons/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import AdminCoupons from "@/views/AdminCoupons";
|
||||||
|
|
||||||
|
export default function AdminCouponsRoute() {
|
||||||
|
return <AdminCoupons />;
|
||||||
|
}
|
||||||
5
src/app/admin/dashboard/page.tsx
Normal file
5
src/app/admin/dashboard/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import AdminDashboard from "@/views/AdminDashboard";
|
||||||
|
|
||||||
|
export default function AdminDashboardPage() {
|
||||||
|
return <AdminDashboard />;
|
||||||
|
}
|
||||||
5
src/app/admin/events/create/page.tsx
Normal file
5
src/app/admin/events/create/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import AdminEventForm from "@/views/AdminEventForm";
|
||||||
|
|
||||||
|
export default function AdminEventCreateRoute() {
|
||||||
|
return <AdminEventForm mode="create" />;
|
||||||
|
}
|
||||||
5
src/app/admin/majors/page.tsx
Normal file
5
src/app/admin/majors/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import AdminMetaOptions from "@/views/AdminMetaOptions";
|
||||||
|
|
||||||
|
export default function AdminMajorsRoute() {
|
||||||
|
return <AdminMetaOptions kind="majors" />;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
redirect("/admin/users");
|
redirect("/admin/dashboard");
|
||||||
}
|
}
|
||||||
|
|||||||
5
src/app/admin/universities/page.tsx
Normal file
5
src/app/admin/universities/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import AdminMetaOptions from "@/views/AdminMetaOptions";
|
||||||
|
|
||||||
|
export default function AdminUniversitiesRoute() {
|
||||||
|
return <AdminMetaOptions kind="universities" />;
|
||||||
|
}
|
||||||
96
src/components/AdminDateTimeField.tsx
Normal file
96
src/components/AdminDateTimeField.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import DateObject from "react-date-object";
|
||||||
|
import persian from "react-date-object/calendars/persian";
|
||||||
|
import persian_fa from "react-date-object/locales/persian_fa";
|
||||||
|
import DatePicker from "react-multi-date-picker";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
type AdminDateTimeFieldProps = {
|
||||||
|
label: string;
|
||||||
|
value?: string | null;
|
||||||
|
onChange: (value: string | null) => void;
|
||||||
|
required?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function splitDateTime(value?: string | null) {
|
||||||
|
if (!value) {
|
||||||
|
return { date: null as DateObject | null, time: "" };
|
||||||
|
}
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return { date: null, time: "" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
date: new DateObject({ date, calendar: persian, locale: persian_fa }),
|
||||||
|
time: `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function combineDateTime(date: DateObject | null, time: string) {
|
||||||
|
if (!date || !time || !/^\d{2}:\d{2}$/.test(time)) return null;
|
||||||
|
const gregorian = date.toDate();
|
||||||
|
const [hours, minutes] = time.split(":").map(Number);
|
||||||
|
gregorian.setHours(hours, minutes, 0, 0);
|
||||||
|
return gregorian.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminDateTimeField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
required,
|
||||||
|
disabled,
|
||||||
|
}: AdminDateTimeFieldProps) {
|
||||||
|
const initial = React.useMemo(() => splitDateTime(value), [value]);
|
||||||
|
const [date, setDate] = React.useState<DateObject | null>(initial.date);
|
||||||
|
const [time, setTime] = React.useState(initial.time);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setDate(initial.date);
|
||||||
|
setTime(initial.time);
|
||||||
|
}, [initial.date, initial.time]);
|
||||||
|
|
||||||
|
const emitChange = (nextDate: DateObject | null, nextTime: string) => {
|
||||||
|
onChange(combineDateTime(nextDate, nextTime));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>
|
||||||
|
{label}
|
||||||
|
{required ? <span className="text-destructive"> *</span> : null}
|
||||||
|
</Label>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-[1fr_120px]">
|
||||||
|
<DatePicker
|
||||||
|
value={date}
|
||||||
|
onChange={(next) => {
|
||||||
|
const nextDate = next instanceof DateObject ? next : null;
|
||||||
|
setDate(nextDate);
|
||||||
|
emitChange(nextDate, time);
|
||||||
|
}}
|
||||||
|
calendar={persian}
|
||||||
|
locale={persian_fa}
|
||||||
|
calendarPosition="bottom-right"
|
||||||
|
disabled={disabled}
|
||||||
|
inputClass="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-right ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
placeholder="تاریخ"
|
||||||
|
containerClassName="w-full"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
dir="ltr"
|
||||||
|
type="time"
|
||||||
|
value={time}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => {
|
||||||
|
setTime(event.target.value);
|
||||||
|
emitChange(date, event.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
178
src/components/AsyncSearchableCombobox.tsx
Normal file
178
src/components/AsyncSearchableCombobox.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Check, ChevronsUpDown, Loader2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type AsyncComboboxOption = {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AsyncSearchableComboboxProps = {
|
||||||
|
value?: string | null;
|
||||||
|
onChange: (value: string | null) => void;
|
||||||
|
loadOptions: (params: { search: string; limit: number; offset: number }) => Promise<{
|
||||||
|
count: number;
|
||||||
|
results: AsyncComboboxOption[];
|
||||||
|
}>;
|
||||||
|
placeholder?: string;
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
emptyText?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
allowClear?: boolean;
|
||||||
|
pageSize?: number;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AsyncSearchableCombobox({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
loadOptions,
|
||||||
|
placeholder = "انتخاب کنید",
|
||||||
|
searchPlaceholder = "جستجو...",
|
||||||
|
emptyText = "موردی پیدا نشد.",
|
||||||
|
disabled = false,
|
||||||
|
allowClear = true,
|
||||||
|
pageSize = 20,
|
||||||
|
className,
|
||||||
|
}: AsyncSearchableComboboxProps) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const [search, setSearch] = React.useState("");
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = React.useState("");
|
||||||
|
const [options, setOptions] = React.useState<AsyncComboboxOption[]>([]);
|
||||||
|
const [count, setCount] = React.useState(0);
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const [loadingMore, setLoadingMore] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const timer = window.setTimeout(() => setDebouncedSearch(search.trim()), 300);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
const selected = React.useMemo(
|
||||||
|
() => options.find((option) => option.value === value),
|
||||||
|
[options, value],
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchPage = React.useCallback(
|
||||||
|
async (offset: number, append = false) => {
|
||||||
|
if (append) setLoadingMore(true);
|
||||||
|
else setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await loadOptions({ search: debouncedSearch, limit: pageSize, offset });
|
||||||
|
setCount(data.count);
|
||||||
|
setOptions((current) => {
|
||||||
|
const next = append ? [...current, ...data.results] : data.results;
|
||||||
|
const byValue = new Map(next.map((option) => [option.value, option]));
|
||||||
|
return Array.from(byValue.values());
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[debouncedSearch, loadOptions, pageSize],
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
void fetchPage(0);
|
||||||
|
}, [fetchPage, open]);
|
||||||
|
|
||||||
|
const hasMore = options.length < count;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn("w-full justify-between gap-2", className)}
|
||||||
|
>
|
||||||
|
<span className="truncate text-right">{selected?.label || value || placeholder}</span>
|
||||||
|
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start" dir="rtl">
|
||||||
|
<Command shouldFilter={false}>
|
||||||
|
<CommandInput value={search} onValueChange={setSearch} placeholder={searchPlaceholder} />
|
||||||
|
<CommandList>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
در حال جستجو...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CommandEmpty>{emptyText}</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{allowClear ? (
|
||||||
|
<CommandItem
|
||||||
|
value="__clear"
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(null);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check className={cn("ml-2 h-4 w-4", !value ? "opacity-100" : "opacity-0")} />
|
||||||
|
همه موارد
|
||||||
|
</CommandItem>
|
||||||
|
) : null}
|
||||||
|
{options.map((option) => (
|
||||||
|
<CommandItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(option.value === value ? null : option.value);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="flex items-center justify-between gap-2"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 text-right">
|
||||||
|
<span className="block truncate">{option.label}</span>
|
||||||
|
{option.description ? (
|
||||||
|
<span className="block truncate text-xs text-muted-foreground">{option.description}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<Check className={cn("h-4 w-4", value === option.value ? "opacity-100" : "opacity-0")} />
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
{hasMore ? (
|
||||||
|
<div className="p-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
disabled={loadingMore}
|
||||||
|
onClick={() => void fetchPage(options.length, true)}
|
||||||
|
>
|
||||||
|
{loadingMore ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : null}
|
||||||
|
بارگذاری بیشتر
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/components/ConfirmAction.tsx
Normal file
58
src/components/ConfirmAction.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
|
type ConfirmActionProps = {
|
||||||
|
trigger: ReactNode;
|
||||||
|
title: string;
|
||||||
|
description: ReactNode;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
onConfirm: () => unknown | Promise<unknown>;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ConfirmAction({
|
||||||
|
trigger,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
confirmLabel = "حذف",
|
||||||
|
cancelLabel = "انصراف",
|
||||||
|
onConfirm,
|
||||||
|
disabled = false,
|
||||||
|
}: ConfirmActionProps) {
|
||||||
|
return (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
{trigger}
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent dir="rtl">
|
||||||
|
<AlertDialogHeader className="text-right">
|
||||||
|
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="leading-7">{description}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter className="gap-2 sm:justify-start">
|
||||||
|
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => void onConfirm()}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Bell, CheckCheck, Loader2, Trash2 } from "lucide-react";
|
import { Bell, CheckCheck, Loader2, Trash2 } from "lucide-react";
|
||||||
|
import ConfirmAction from "@/components/ConfirmAction";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
@@ -33,15 +34,21 @@ function NotificationItem({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<Button
|
<ConfirmAction
|
||||||
type="button"
|
title="حذف اعلان"
|
||||||
size="icon"
|
description="آیا از حذف این اعلان مطمئن هستید؟"
|
||||||
variant="ghost"
|
onConfirm={() => onDelete(notification)}
|
||||||
className="h-8 w-8 shrink-0 rounded-full text-muted-foreground hover:text-destructive"
|
trigger={
|
||||||
onClick={() => void onDelete(notification)}
|
<Button
|
||||||
>
|
type="button"
|
||||||
<Trash2 className="h-4 w-4" />
|
size="icon"
|
||||||
</Button>
|
variant="ghost"
|
||||||
|
className="h-8 w-8 shrink-0 rounded-full text-muted-foreground hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void onOpen(notification)}
|
onClick={() => void onOpen(notification)}
|
||||||
|
|||||||
240
src/lib/api.ts
240
src/lib/api.ts
@@ -397,6 +397,10 @@ class ApiClient {
|
|||||||
return this.request<Types.UserListSchema[]>(`/api/auth/users${query.toString() ? `?${query.toString()}` : ''}`);
|
return this.request<Types.UserListSchema[]>(`/api/auth/users${query.toString() ? `?${query.toString()}` : ''}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getUserDetail(userId: number) {
|
||||||
|
return this.request<Types.UserProfileSchema>(`/api/auth/users/${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
async listAuthorizationRoles() {
|
async listAuthorizationRoles() {
|
||||||
return this.request<Types.AuthorizationRoleSchema[]>('/api/auth/roles');
|
return this.request<Types.AuthorizationRoleSchema[]>('/api/auth/roles');
|
||||||
}
|
}
|
||||||
@@ -412,6 +416,60 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAdminDashboard(params?: {
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
event_id?: number;
|
||||||
|
granularity?: 'auto' | 'day' | 'week' | 'month';
|
||||||
|
}) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params?.date_from) query.set('date_from', params.date_from);
|
||||||
|
if (params?.date_to) query.set('date_to', params.date_to);
|
||||||
|
if (params?.event_id != null) query.set('event_id', String(params.event_id));
|
||||||
|
if (params?.granularity) query.set('granularity', params.granularity);
|
||||||
|
return this.request<Types.AdminDashboardAnalyticsSchema>(
|
||||||
|
`/api/analytics/admin/dashboard${query.toString() ? `?${query.toString()}` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAdminUserAnalytics(params?: { date_from?: string; date_to?: string }) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params?.date_from) query.set('date_from', params.date_from);
|
||||||
|
if (params?.date_to) query.set('date_to', params.date_to);
|
||||||
|
return this.request<Types.UserAnalyticsSchema>(
|
||||||
|
`/api/analytics/admin/users${query.toString() ? `?${query.toString()}` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAdminEventAnalytics(params?: { date_from?: string; date_to?: string; event_id?: number }) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params?.date_from) query.set('date_from', params.date_from);
|
||||||
|
if (params?.date_to) query.set('date_to', params.date_to);
|
||||||
|
if (params?.event_id != null) query.set('event_id', String(params.event_id));
|
||||||
|
return this.request<Types.EventAnalyticsSchema>(
|
||||||
|
`/api/analytics/admin/events${query.toString() ? `?${query.toString()}` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAdminBlogAnalytics(params?: { date_from?: string; date_to?: string }) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params?.date_from) query.set('date_from', params.date_from);
|
||||||
|
if (params?.date_to) query.set('date_to', params.date_to);
|
||||||
|
return this.request<Types.BlogAnalyticsSchema>(
|
||||||
|
`/api/analytics/admin/blog${query.toString() ? `?${query.toString()}` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAdminDashboardEventOptions(params?: { search?: string; limit?: number; offset?: number }) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params?.search) query.set('search', params.search);
|
||||||
|
if (params?.limit != null) query.set('limit', String(params.limit));
|
||||||
|
if (params?.offset != null) query.set('offset', String(params.offset));
|
||||||
|
return this.request<Types.AnalyticsEventOptionsSchema>(
|
||||||
|
`/api/analytics/admin/events/options${query.toString() ? `?${query.toString()}` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ============= Blog Endpoints =============
|
// ============= Blog Endpoints =============
|
||||||
|
|
||||||
async getPosts(params?: {
|
async getPosts(params?: {
|
||||||
@@ -856,6 +914,68 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createEvent(data: Types.EventCreateSchema) {
|
||||||
|
return this.request<Types.EventDetailSchema>('/api/events/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadEventFeaturedImage(eventId: number, file: File) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
const token = this.getStorageValue('access_token');
|
||||||
|
const response = await fetch(`${this.baseUrl}/api/events/${eventId}/featured-image`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = (await response.json().catch(() => ({}))) as ApiErrorBody;
|
||||||
|
throw new Error(body.error || body.detail || 'Event image upload failed');
|
||||||
|
}
|
||||||
|
return response.json() as Promise<Types.EventDetailSchema>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteEventFeaturedImage(eventId: number) {
|
||||||
|
return this.request<Types.EventDetailSchema>(`/api/events/${eventId}/featured-image`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listEventGallery(eventId: number) {
|
||||||
|
return this.request<Types.EventGalleryItem[]>(`/api/events/${eventId}/gallery`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadEventGalleryImage(eventId: number, file: File, data: { title?: string; alt_text?: string } = {}) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
if (data.title) formData.append('title', data.title);
|
||||||
|
if (data.alt_text) formData.append('alt_text', data.alt_text);
|
||||||
|
const token = this.getStorageValue('access_token');
|
||||||
|
const response = await fetch(`${this.baseUrl}/api/events/${eventId}/gallery`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = (await response.json().catch(() => ({}))) as ApiErrorBody;
|
||||||
|
throw new Error(body.error || body.detail || 'Event gallery upload failed');
|
||||||
|
}
|
||||||
|
return response.json() as Promise<Types.EventGalleryItem>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteEventGalleryImage(eventId: number, imageId: number) {
|
||||||
|
return this.request<Types.MessageSchema>(`/api/events/${eventId}/gallery/${imageId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async deleteEvent(eventId: number) {
|
async deleteEvent(eventId: number) {
|
||||||
return this.request<Types.MessageSchema>(`/api/events/${eventId}`, {
|
return this.request<Types.MessageSchema>(`/api/events/${eventId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
@@ -971,6 +1091,44 @@ class ApiClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listDiscountCodes(params?: {
|
||||||
|
search?: string;
|
||||||
|
is_active?: boolean;
|
||||||
|
type?: 'percent' | 'fixed';
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params?.search) query.set('search', params.search);
|
||||||
|
if (params?.is_active != null) query.set('is_active', String(params.is_active));
|
||||||
|
if (params?.type) query.set('type', params.type);
|
||||||
|
if (params?.limit != null) query.set('limit', String(params.limit));
|
||||||
|
if (params?.offset != null) query.set('offset', String(params.offset));
|
||||||
|
return this.request<Types.PagedDiscountCodeSchema>(
|
||||||
|
`/api/payments/admin/discount-codes${query.toString() ? `?${query.toString()}` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createDiscountCode(data: Types.DiscountCodeWriteSchema) {
|
||||||
|
return this.request<Types.DiscountCodeSchema>('/api/payments/admin/discount-codes', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateDiscountCode(codeId: number, data: Types.DiscountCodeWriteSchema) {
|
||||||
|
return this.request<Types.DiscountCodeSchema>(`/api/payments/admin/discount-codes/${codeId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteDiscountCode(codeId: number) {
|
||||||
|
return this.request<Types.MessageSchema>(`/api/payments/admin/discount-codes/${codeId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ============= Gallery Endpoints =============
|
// ============= Gallery Endpoints =============
|
||||||
|
|
||||||
async getGalleryImages(params?: {
|
async getGalleryImages(params?: {
|
||||||
@@ -1019,12 +1177,86 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMajors(): Promise<Types.MajorOption[]> {
|
async getMajors(params?: { search?: string; limit?: number; offset?: number }): Promise<Types.MajorOption[]> {
|
||||||
return this.request('/api/meta/majors', { method: 'GET' });
|
const data = await this.getMajorsPaged({ limit: 100, offset: 0, ...params });
|
||||||
|
return data.results.map((item) => ({ code: item.code, label: item.label, id: item.id, name: item.name, user_count: item.user_count }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUniversities(): Promise<Types.MajorOption[]> {
|
async getUniversities(params?: { search?: string; limit?: number; offset?: number }): Promise<Types.MajorOption[]> {
|
||||||
return this.request('/api/meta/universities', { method: 'GET' });
|
const data = await this.getUniversitiesPaged({ limit: 100, offset: 0, ...params });
|
||||||
|
return data.results.map((item) => ({ code: item.code, label: item.label, id: item.id, name: item.name, user_count: item.user_count }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMajorsPaged(params?: { search?: string; limit?: number; offset?: number }) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params?.search) query.set('search', params.search);
|
||||||
|
if (params?.limit != null) query.set('limit', String(params.limit));
|
||||||
|
if (params?.offset != null) query.set('offset', String(params.offset));
|
||||||
|
return this.request<Types.PagedMetaOptionSchema>(`/api/meta/majors${query.toString() ? `?${query.toString()}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUniversitiesPaged(params?: { search?: string; limit?: number; offset?: number }) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params?.search) query.set('search', params.search);
|
||||||
|
if (params?.limit != null) query.set('limit', String(params.limit));
|
||||||
|
if (params?.offset != null) query.set('offset', String(params.offset));
|
||||||
|
return this.request<Types.PagedMetaOptionSchema>(`/api/meta/universities${query.toString() ? `?${query.toString()}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAdminMajors(params?: { search?: string; limit?: number; offset?: number }) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params?.search) query.set('search', params.search);
|
||||||
|
if (params?.limit != null) query.set('limit', String(params.limit));
|
||||||
|
if (params?.offset != null) query.set('offset', String(params.offset));
|
||||||
|
return this.request<Types.PagedMetaOptionSchema>(`/api/meta/admin/majors${query.toString() ? `?${query.toString()}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createMajor(data: Types.MetaOptionWriteSchema) {
|
||||||
|
return this.request<Types.MetaOptionSchema>('/api/meta/admin/majors', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateMajor(id: number, data: Types.MetaOptionWriteSchema) {
|
||||||
|
return this.request<Types.MetaOptionSchema>(`/api/meta/admin/majors/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMajor(id: number) {
|
||||||
|
return this.request<Types.MessageSchema>(`/api/meta/admin/majors/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAdminUniversities(params?: { search?: string; limit?: number; offset?: number }) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params?.search) query.set('search', params.search);
|
||||||
|
if (params?.limit != null) query.set('limit', String(params.limit));
|
||||||
|
if (params?.offset != null) query.set('offset', String(params.offset));
|
||||||
|
return this.request<Types.PagedMetaOptionSchema>(`/api/meta/admin/universities${query.toString() ? `?${query.toString()}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createUniversity(data: Types.MetaOptionWriteSchema) {
|
||||||
|
return this.request<Types.MetaOptionSchema>('/api/meta/admin/universities', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUniversity(id: number, data: Types.MetaOptionWriteSchema) {
|
||||||
|
return this.request<Types.MetaOptionSchema>(`/api/meta/admin/universities/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteUniversity(id: number) {
|
||||||
|
return this.request<Types.MessageSchema>(`/api/meta/admin/universities/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async subscribeNewsletter(email: string) {
|
async subscribeNewsletter(email: string) {
|
||||||
|
|||||||
301
src/lib/types.ts
301
src/lib/types.ts
@@ -18,6 +18,27 @@ export interface TokenSchema {
|
|||||||
export interface MajorOption {
|
export interface MajorOption {
|
||||||
code: string;
|
code: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
user_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetaOptionSchema {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
user_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagedMetaOptionSchema {
|
||||||
|
count: number;
|
||||||
|
results: MetaOptionSchema[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetaOptionWriteSchema {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserProfileSchema {
|
export interface UserProfileSchema {
|
||||||
@@ -62,6 +83,19 @@ export interface UserListSchema {
|
|||||||
full_name?: string | null;
|
full_name?: string | null;
|
||||||
university?: string | null;
|
university?: string | null;
|
||||||
major?: string | null;
|
major?: string | null;
|
||||||
|
profile_picture?: string | null;
|
||||||
|
profile_picture_thumbnail_url?: string | null;
|
||||||
|
profile_picture_preview_url?: string | null;
|
||||||
|
student_id?: string | null;
|
||||||
|
year_of_study?: number | null;
|
||||||
|
bio?: string | null;
|
||||||
|
is_email_verified?: boolean;
|
||||||
|
is_mobile_verified?: boolean;
|
||||||
|
is_deleted?: boolean;
|
||||||
|
deleted_at?: string | null;
|
||||||
|
can_access_blog_admin?: boolean;
|
||||||
|
can_write_blog_posts?: boolean;
|
||||||
|
can_review_blog_posts?: boolean;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
is_staff: boolean;
|
is_staff: boolean;
|
||||||
is_superuser: boolean;
|
is_superuser: boolean;
|
||||||
@@ -524,6 +558,12 @@ export interface EventGalleryItem {
|
|||||||
absolute_image_blur_url?: string | null;
|
absolute_image_blur_url?: string | null;
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
|
file_size_mb?: number;
|
||||||
|
markdown_url?: string;
|
||||||
|
image?: string;
|
||||||
|
alt_text?: string | null;
|
||||||
|
is_public?: boolean;
|
||||||
|
created_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EventDetailSchema extends EventListItemSchema {
|
export interface EventDetailSchema extends EventListItemSchema {
|
||||||
@@ -536,19 +576,28 @@ export interface EventDetailSchema extends EventListItemSchema {
|
|||||||
export interface EventCreateSchema {
|
export interface EventCreateSchema {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
start_date: string;
|
slug?: string | null;
|
||||||
end_date?: string;
|
event_type: 'online' | 'on_site' | 'hybrid';
|
||||||
location: string;
|
address?: string | null;
|
||||||
capacity?: number;
|
location?: string | null;
|
||||||
event_image?: string;
|
online_link?: string | null;
|
||||||
requirements?: string;
|
start_time: string;
|
||||||
is_registration_open?: boolean;
|
end_time: string;
|
||||||
|
registration_start_date?: string | null;
|
||||||
|
registration_end_date?: string | null;
|
||||||
|
capacity?: number | null;
|
||||||
|
price?: number | null;
|
||||||
|
status?: 'draft' | 'published' | 'cancelled' | 'completed';
|
||||||
|
registration_success_markdown?: string | null;
|
||||||
|
gallery_image_ids?: number[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaymentAdminSchema {
|
export interface PaymentAdminSchema {
|
||||||
id: number;
|
id: number;
|
||||||
authority?: string | null;
|
authority?: string | null;
|
||||||
ref_id?: string | null;
|
ref_id?: string | null;
|
||||||
|
card_pan?: string | null;
|
||||||
|
card_hash?: string | null;
|
||||||
status: number;
|
status: number;
|
||||||
status_label: string;
|
status_label: string;
|
||||||
base_amount: number;
|
base_amount: number;
|
||||||
@@ -573,6 +622,14 @@ export interface RegistrationAdminSchema {
|
|||||||
first_name: string;
|
first_name: string;
|
||||||
last_name: string;
|
last_name: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
mobile?: string | null;
|
||||||
|
profile_picture?: string | null;
|
||||||
|
profile_picture_thumbnail_url?: string | null;
|
||||||
|
profile_picture_preview_url?: string | null;
|
||||||
|
university?: string | null;
|
||||||
|
major?: string | null;
|
||||||
|
student_id?: string | null;
|
||||||
|
year_of_study?: number | null;
|
||||||
};
|
};
|
||||||
payments: PaymentAdminSchema[];
|
payments: PaymentAdminSchema[];
|
||||||
}
|
}
|
||||||
@@ -582,6 +639,7 @@ export interface EventAdminDetailSchema extends EventDetailSchema {
|
|||||||
}
|
}
|
||||||
export interface EventUpdateSchema {
|
export interface EventUpdateSchema {
|
||||||
title?: string;
|
title?: string;
|
||||||
|
slug?: string | null;
|
||||||
description?: string;
|
description?: string;
|
||||||
event_type?: 'online' | 'on_site' | 'hybrid';
|
event_type?: 'online' | 'on_site' | 'hybrid';
|
||||||
address?: string | null;
|
address?: string | null;
|
||||||
@@ -594,9 +652,47 @@ export interface EventUpdateSchema {
|
|||||||
capacity?: number | null;
|
capacity?: number | null;
|
||||||
price?: number | null;
|
price?: number | null;
|
||||||
status?: 'draft' | 'published' | 'cancelled' | 'completed';
|
status?: 'draft' | 'published' | 'cancelled' | 'completed';
|
||||||
|
registration_success_markdown?: string | null;
|
||||||
gallery_image_ids?: number[] | null;
|
gallery_image_ids?: number[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DiscountCodeSchema {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
type: 'percent' | 'fixed';
|
||||||
|
value: number;
|
||||||
|
max_discount?: number | null;
|
||||||
|
is_active: boolean;
|
||||||
|
starts_at?: string | null;
|
||||||
|
ends_at?: string | null;
|
||||||
|
usage_limit_total?: number | null;
|
||||||
|
usage_limit_per_user?: number | null;
|
||||||
|
min_amount?: number | null;
|
||||||
|
applicable_event_ids: number[];
|
||||||
|
usage_count: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagedDiscountCodeSchema {
|
||||||
|
count: number;
|
||||||
|
results: DiscountCodeSchema[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscountCodeWriteSchema {
|
||||||
|
code: string;
|
||||||
|
type: 'percent' | 'fixed';
|
||||||
|
value: number;
|
||||||
|
max_discount?: number | null;
|
||||||
|
is_active?: boolean;
|
||||||
|
starts_at?: string | null;
|
||||||
|
ends_at?: string | null;
|
||||||
|
usage_limit_total?: number | null;
|
||||||
|
usage_limit_per_user?: number | null;
|
||||||
|
min_amount?: number | null;
|
||||||
|
applicable_event_ids?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface EventRegistrationSchema {
|
export interface EventRegistrationSchema {
|
||||||
id: number;
|
id: number;
|
||||||
status: 'pending' | 'confirmed' | 'cancelled' | 'attended';
|
status: 'pending' | 'confirmed' | 'cancelled' | 'attended';
|
||||||
@@ -657,6 +753,197 @@ export interface PaginatedResponse<T> {
|
|||||||
previous?: string;
|
previous?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Admin analytics
|
||||||
|
export interface AnalyticsPointSchema {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsPointGroupSchema {
|
||||||
|
items: AnalyticsPointSchema[];
|
||||||
|
top_items: AnalyticsPointSchema[];
|
||||||
|
other_count: number;
|
||||||
|
total_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsTrendPointSchema {
|
||||||
|
date: string;
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsRegistrationStatusSchema {
|
||||||
|
status: string;
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsTopEventSchema {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
attendees: number;
|
||||||
|
capacity?: number | null;
|
||||||
|
fill_rate?: number | null;
|
||||||
|
revenue: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsPostPopularitySchema {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
likes: number;
|
||||||
|
saves: number;
|
||||||
|
comments: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsPostPopularityGroupSchema {
|
||||||
|
items: AnalyticsPostPopularitySchema[];
|
||||||
|
top_items: AnalyticsPostPopularitySchema[];
|
||||||
|
other_count: number;
|
||||||
|
total_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsTopPostSchema extends AnalyticsPostPopularitySchema {
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminDashboardAnalyticsSchema {
|
||||||
|
filters: {
|
||||||
|
date_from?: string | null;
|
||||||
|
date_to?: string | null;
|
||||||
|
event_id?: number | null;
|
||||||
|
granularity: 'day' | 'week' | 'month';
|
||||||
|
};
|
||||||
|
summary: {
|
||||||
|
total_users: number;
|
||||||
|
verified_users: number;
|
||||||
|
total_events: number;
|
||||||
|
total_registrations: number;
|
||||||
|
total_revenue: number;
|
||||||
|
total_discount: number;
|
||||||
|
published_posts: number;
|
||||||
|
total_likes: number;
|
||||||
|
total_saves: number;
|
||||||
|
total_comments: number;
|
||||||
|
};
|
||||||
|
users: {
|
||||||
|
signup_trend: AnalyticsTrendPointSchema[];
|
||||||
|
by_major: AnalyticsPointSchema[];
|
||||||
|
by_university: AnalyticsPointSchema[];
|
||||||
|
by_year: AnalyticsPointSchema[];
|
||||||
|
};
|
||||||
|
events: {
|
||||||
|
registration_status: AnalyticsRegistrationStatusSchema[];
|
||||||
|
by_major: AnalyticsPointSchema[];
|
||||||
|
by_university: AnalyticsPointSchema[];
|
||||||
|
top_events: AnalyticsTopEventSchema[];
|
||||||
|
registration_trend: AnalyticsTrendPointSchema[];
|
||||||
|
};
|
||||||
|
revenue: {
|
||||||
|
trend: AnalyticsTrendPointSchema[];
|
||||||
|
by_event: AnalyticsPointSchema[];
|
||||||
|
payment_status: AnalyticsRegistrationStatusSchema[];
|
||||||
|
total_paid: number;
|
||||||
|
total_discount: number;
|
||||||
|
total_base: number;
|
||||||
|
};
|
||||||
|
blog: {
|
||||||
|
totals: {
|
||||||
|
posts: number;
|
||||||
|
likes: number;
|
||||||
|
saves: number;
|
||||||
|
comments: number;
|
||||||
|
};
|
||||||
|
post_popularity: AnalyticsPostPopularitySchema[];
|
||||||
|
top_posts: AnalyticsTopPostSchema[];
|
||||||
|
activity_trend: Array<{ date: string; likes: number; saves: number; comments: number }>;
|
||||||
|
by_category: AnalyticsPointSchema[];
|
||||||
|
by_tag: AnalyticsPointSchema[];
|
||||||
|
};
|
||||||
|
achievements: {
|
||||||
|
distinct_participants: number;
|
||||||
|
learning_hours: number;
|
||||||
|
published_content: number;
|
||||||
|
community_engagement: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsEventOptionsSchema {
|
||||||
|
count: number;
|
||||||
|
results: Array<{
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
description?: string | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserAnalyticsSchema {
|
||||||
|
filters: {
|
||||||
|
date_from?: string | null;
|
||||||
|
date_to?: string | null;
|
||||||
|
granularity: 'day' | 'week' | 'month';
|
||||||
|
};
|
||||||
|
summary: {
|
||||||
|
total_users: number;
|
||||||
|
verified_users: number;
|
||||||
|
unverified_users: number;
|
||||||
|
profile_completion_rate: number;
|
||||||
|
};
|
||||||
|
signup_trend: AnalyticsTrendPointSchema[];
|
||||||
|
by_major: AnalyticsPointGroupSchema;
|
||||||
|
by_university: AnalyticsPointGroupSchema;
|
||||||
|
by_year: AnalyticsPointGroupSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventAnalyticsSchema {
|
||||||
|
filters: {
|
||||||
|
date_from?: string | null;
|
||||||
|
date_to?: string | null;
|
||||||
|
event_id?: number | null;
|
||||||
|
};
|
||||||
|
summary: {
|
||||||
|
total_events: number;
|
||||||
|
total_registrations: number;
|
||||||
|
distinct_participants: number;
|
||||||
|
total_revenue: number;
|
||||||
|
total_discount: number;
|
||||||
|
total_base: number;
|
||||||
|
learning_hours: number;
|
||||||
|
};
|
||||||
|
registration_status: AnalyticsRegistrationStatusSchema[];
|
||||||
|
payment_status: AnalyticsRegistrationStatusSchema[];
|
||||||
|
attendee_by_major: AnalyticsPointGroupSchema;
|
||||||
|
attendee_by_university: AnalyticsPointGroupSchema;
|
||||||
|
registration_trend: AnalyticsTrendPointSchema[];
|
||||||
|
revenue_trend: AnalyticsTrendPointSchema[];
|
||||||
|
revenue_by_event: AnalyticsPointGroupSchema;
|
||||||
|
top_events: {
|
||||||
|
top_items: AnalyticsTopEventSchema[];
|
||||||
|
other_count: number;
|
||||||
|
total_count: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlogAnalyticsSchema {
|
||||||
|
filters: {
|
||||||
|
date_from?: string | null;
|
||||||
|
date_to?: string | null;
|
||||||
|
};
|
||||||
|
summary: {
|
||||||
|
published_posts: number;
|
||||||
|
total_likes: number;
|
||||||
|
total_saves: number;
|
||||||
|
total_comments: number;
|
||||||
|
community_engagement: number;
|
||||||
|
};
|
||||||
|
activity_trend: Array<{ date: string; likes: number; saves: number; comments: number }>;
|
||||||
|
post_popularity: AnalyticsPostPopularityGroupSchema;
|
||||||
|
top_posts: AnalyticsTopPostSchema[];
|
||||||
|
by_category: AnalyticsPointGroupSchema;
|
||||||
|
by_tag: AnalyticsPointGroupSchema;
|
||||||
|
}
|
||||||
|
|
||||||
// payment
|
// payment
|
||||||
export interface CreatePaymentOut {
|
export interface CreatePaymentOut {
|
||||||
start_pay_url: string;
|
start_pay_url: string;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Loader2, Search, ShieldCheck, UserCog } from "lucide-react";
|
import { Loader2, Search, ShieldCheck, UserCog } from "lucide-react";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
@@ -16,6 +16,11 @@ import { Switch } from "@/components/ui/switch";
|
|||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
|
const normalizeSearchDigits = (value: string) =>
|
||||||
|
value
|
||||||
|
.replace(/[۰-۹]/g, (digit) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(digit)))
|
||||||
|
.replace(/[٠-٩]/g, (digit) => String("٠١٢٣٤٥٦٧٨٩".indexOf(digit)));
|
||||||
|
|
||||||
function fullName(user: Pick<Types.UserListSchema, "first_name" | "last_name" | "username">) {
|
function fullName(user: Pick<Types.UserListSchema, "first_name" | "last_name" | "username">) {
|
||||||
return [user.first_name, user.last_name].filter(Boolean).join(" ") || user.username;
|
return [user.first_name, user.last_name].filter(Boolean).join(" ") || user.username;
|
||||||
}
|
}
|
||||||
@@ -34,6 +39,14 @@ export default function AdminAuthorizations() {
|
|||||||
queryFn: () => api.listUsers({ search: search || undefined, limit: PAGE_SIZE, offset: 0 }),
|
queryFn: () => api.listUsers({ search: search || undefined, limit: PAGE_SIZE, offset: 0 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
setSearch(normalizeSearchDigits(searchDraft).trim());
|
||||||
|
}, 400);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
|
}, [searchDraft]);
|
||||||
|
|
||||||
const authQuery = useQuery({
|
const authQuery = useQuery({
|
||||||
queryKey: ["admin", "authorizations", selectedUserId],
|
queryKey: ["admin", "authorizations", selectedUserId],
|
||||||
queryFn: () => api.getUserAuthorization(selectedUserId as number),
|
queryFn: () => api.getUserAuthorization(selectedUserId as number),
|
||||||
@@ -104,18 +117,13 @@ export default function AdminAuthorizations() {
|
|||||||
<CardDescription>نام، موبایل، ایمیل یا نام کاربری را جستجو کنید.</CardDescription>
|
<CardDescription>نام، موبایل، ایمیل یا نام کاربری را جستجو کنید.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4" dir="ltr">
|
<CardContent className="space-y-4" dir="ltr">
|
||||||
<div className="flex gap-2">
|
<div className="relative">
|
||||||
<Button type="button" onClick={() => setSearch(searchDraft.trim())} size="icon" aria-label="جستجو">
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Search className="h-5 w-5 bold" />
|
|
||||||
</Button>
|
|
||||||
<Input
|
<Input
|
||||||
value={searchDraft}
|
value={searchDraft}
|
||||||
onChange={(event) => setSearchDraft(event.target.value)}
|
onChange={(event) => setSearchDraft(event.target.value)}
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter") setSearch(searchDraft.trim());
|
|
||||||
}}
|
|
||||||
placeholder="جستجو..."
|
placeholder="جستجو..."
|
||||||
className="text-right"
|
className="pl-10 text-right"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{usersQuery.isLoading ? (
|
{usersQuery.isLoading ? (
|
||||||
@@ -140,7 +148,10 @@ export default function AdminAuthorizations() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
<span className="font-medium">{fullName(item)}</span>
|
<span className="font-medium">{fullName(item)}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">{item.mobile || item.email || item.username}</p>
|
<div className="mt-2 space-y-1 text-xs text-muted-foreground">
|
||||||
|
<p dir="ltr" className="text-right">{item.mobile || "بدون شماره موبایل"}</p>
|
||||||
|
<p>{item.email || item.username}</p>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import ConfirmAction from "@/components/ConfirmAction";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
@@ -342,16 +343,23 @@ export default function AdminBlogAssets({ postId }: Props) {
|
|||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button
|
<ConfirmAction
|
||||||
variant="ghost"
|
title="حذف فایل"
|
||||||
size="icon"
|
description={`آیا از حذف فایل «${asset.title}» مطمئن هستید؟ لینکهای استفادهشده از این فایل دیگر کار نخواهند کرد.`}
|
||||||
className="text-destructive hover:text-destructive"
|
onConfirm={() => deleteAsset(asset.id)}
|
||||||
onClick={() => deleteAsset(asset.id)}
|
|
||||||
disabled={deletingId === asset.id}
|
disabled={deletingId === asset.id}
|
||||||
aria-label="حذف فایل"
|
trigger={
|
||||||
>
|
<Button
|
||||||
{deletingId === asset.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
variant="ghost"
|
||||||
</Button>
|
size="icon"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
disabled={deletingId === asset.id}
|
||||||
|
aria-label="حذف فایل"
|
||||||
|
>
|
||||||
|
{deletingId === asset.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1 text-right">
|
<div className="min-w-0 flex-1 text-right">
|
||||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Edit3, Loader2, Plus, RotateCcw, Trash2 } from "lucide-react";
|
import { Edit3, Loader2, Plus, RotateCcw, Trash2 } from "lucide-react";
|
||||||
|
import ConfirmAction from "@/components/ConfirmAction";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type * as Types from "@/lib/types";
|
import type * as Types from "@/lib/types";
|
||||||
@@ -119,7 +120,6 @@ export default function AdminBlogCategories() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const deleteCategory = async (category: Types.AdminCategorySchema) => {
|
const deleteCategory = async (category: Types.AdminCategorySchema) => {
|
||||||
if (!window.confirm(`دستهبندی «${category.name}» حذف شود؟`)) return;
|
|
||||||
try {
|
try {
|
||||||
await api.deleteCategory(category.id);
|
await api.deleteCategory(category.id);
|
||||||
toast({ title: "دستهبندی حذف شد", variant: "success" });
|
toast({ title: "دستهبندی حذف شد", variant: "success" });
|
||||||
@@ -197,9 +197,16 @@ export default function AdminBlogCategories() {
|
|||||||
<Edit3 className="h-3.5 w-3.5" />
|
<Edit3 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
{canDelete ? (
|
{canDelete ? (
|
||||||
<Button size="sm" variant="outline" className="text-destructive hover:text-destructive" onClick={() => void deleteCategory(category)}>
|
<ConfirmAction
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
title="حذف دستهبندی"
|
||||||
</Button>
|
description={`آیا از حذف دستهبندی «${category.name}» مطمئن هستید؟`}
|
||||||
|
onConfirm={() => deleteCategory(category)}
|
||||||
|
trigger={
|
||||||
|
<Button size="sm" variant="outline" className="text-destructive hover:text-destructive">
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { AlertTriangle, ArrowLeft, ArrowRight, FolderUp, ImageUp, Loader2, Save, Send, Trash2 } from "lucide-react";
|
import { AlertTriangle, ArrowLeft, ArrowRight, FolderUp, ImageUp, Loader2, Save, Send, Trash2 } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import ConfirmAction from "@/components/ConfirmAction";
|
||||||
import Markdown from "@/components/Markdown";
|
import Markdown from "@/components/Markdown";
|
||||||
import MarkdownEditor, { type MarkdownDirectionMode } from "@/components/MarkdownEditor";
|
import MarkdownEditor, { type MarkdownDirectionMode } from "@/components/MarkdownEditor";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
@@ -384,10 +385,18 @@ export default function AdminBlogEditor({ postId }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap justify-end gap-2">
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
{post?.featured_image || post?.absolute_featured_image_url ? (
|
{post?.featured_image || post?.absolute_featured_image_url ? (
|
||||||
<Button variant="outline" onClick={deleteFeaturedImage} disabled={uploadingFeatured}>
|
<ConfirmAction
|
||||||
|
title="حذف تصویر شاخص"
|
||||||
|
description="آیا از حذف تصویر شاخص این نوشته مطمئن هستید؟"
|
||||||
|
onConfirm={deleteFeaturedImage}
|
||||||
|
disabled={uploadingFeatured}
|
||||||
|
trigger={
|
||||||
|
<Button variant="outline" disabled={uploadingFeatured}>
|
||||||
<Trash2 className="ml-2 h-4 w-4" />
|
<Trash2 className="ml-2 h-4 w-4" />
|
||||||
حذف تصویر
|
حذف تصویر
|
||||||
</Button>
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<Button variant="secondary" onClick={() => featuredInputRef.current?.click()} disabled={uploadingFeatured || saving}>
|
<Button variant="secondary" onClick={() => featuredInputRef.current?.click()} disabled={uploadingFeatured || saving}>
|
||||||
{uploadingFeatured ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : <ImageUp className="ml-2 h-4 w-4" />}
|
{uploadingFeatured ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : <ImageUp className="ml-2 h-4 w-4" />}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Edit3, Loader2, Plus, RotateCcw, Trash2 } from "lucide-react";
|
import { Edit3, Loader2, Plus, RotateCcw, Trash2 } from "lucide-react";
|
||||||
|
import ConfirmAction from "@/components/ConfirmAction";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type * as Types from "@/lib/types";
|
import type * as Types from "@/lib/types";
|
||||||
@@ -98,7 +99,6 @@ export default function AdminBlogTags() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const deleteTag = async (tag: Types.AdminTagSchema) => {
|
const deleteTag = async (tag: Types.AdminTagSchema) => {
|
||||||
if (!window.confirm(`برچسب «${tag.name}» حذف شود؟`)) return;
|
|
||||||
try {
|
try {
|
||||||
await api.deleteTag(tag.id);
|
await api.deleteTag(tag.id);
|
||||||
toast({ title: "برچسب حذف شد", variant: "success" });
|
toast({ title: "برچسب حذف شد", variant: "success" });
|
||||||
@@ -172,9 +172,16 @@ export default function AdminBlogTags() {
|
|||||||
<Edit3 className="h-3.5 w-3.5" />
|
<Edit3 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
{canDelete ? (
|
{canDelete ? (
|
||||||
<Button size="sm" variant="outline" className="text-destructive hover:text-destructive" onClick={() => void deleteTag(tag)}>
|
<ConfirmAction
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
title="حذف برچسب"
|
||||||
</Button>
|
description={`آیا از حذف برچسب «${tag.name}» مطمئن هستید؟`}
|
||||||
|
onConfirm={() => deleteTag(tag)}
|
||||||
|
trigger={
|
||||||
|
<Button size="sm" variant="outline" className="text-destructive hover:text-destructive">
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
250
src/views/AdminCoupons.tsx
Normal file
250
src/views/AdminCoupons.tsx
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Edit3, Plus, Trash2 } from "lucide-react";
|
||||||
|
import AdminDateTimeField from "@/components/AdminDateTimeField";
|
||||||
|
import ConfirmAction from "@/components/ConfirmAction";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import type { DiscountCodeSchema, DiscountCodeWriteSchema } from "@/lib/types";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { formatNumberPersian, resolveErrorMessage } from "@/lib/utils";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
const emptyForm: DiscountCodeWriteSchema = {
|
||||||
|
code: "",
|
||||||
|
type: "percent",
|
||||||
|
value: 0,
|
||||||
|
max_discount: null,
|
||||||
|
is_active: true,
|
||||||
|
starts_at: null,
|
||||||
|
ends_at: null,
|
||||||
|
usage_limit_total: null,
|
||||||
|
usage_limit_per_user: null,
|
||||||
|
min_amount: null,
|
||||||
|
applicable_event_ids: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminCoupons() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [search, setSearch] = React.useState("");
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = React.useState("");
|
||||||
|
const [page, setPage] = React.useState(1);
|
||||||
|
const [editing, setEditing] = React.useState<DiscountCodeSchema | null>(null);
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const [form, setForm] = React.useState<DiscountCodeWriteSchema>(emptyForm);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const timer = window.setTimeout(() => setDebouncedSearch(search.trim()), 300);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ["admin", "coupons", debouncedSearch, page],
|
||||||
|
queryFn: () => api.listDiscountCodes({ search: debouncedSearch || undefined, limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: () => (editing ? api.updateDiscountCode(editing.id, form) : api.createDiscountCode(form)),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["admin", "coupons"] });
|
||||||
|
setOpen(false);
|
||||||
|
toast({ title: "کد تخفیف ذخیره شد", variant: "success" });
|
||||||
|
},
|
||||||
|
onError: (error) => toast({ title: "خطا", description: resolveErrorMessage(error), variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => api.deleteDiscountCode(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["admin", "coupons"] });
|
||||||
|
toast({ title: "کد تخفیف حذف شد", variant: "success" });
|
||||||
|
},
|
||||||
|
onError: (error) => toast({ title: "خطا", description: resolveErrorMessage(error), variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditing(null);
|
||||||
|
setForm(emptyForm);
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (item: DiscountCodeSchema) => {
|
||||||
|
setEditing(item);
|
||||||
|
setForm({
|
||||||
|
code: item.code,
|
||||||
|
type: item.type,
|
||||||
|
value: item.value,
|
||||||
|
max_discount: item.max_discount,
|
||||||
|
is_active: item.is_active,
|
||||||
|
starts_at: item.starts_at,
|
||||||
|
ends_at: item.ends_at,
|
||||||
|
usage_limit_total: item.usage_limit_total,
|
||||||
|
usage_limit_per_user: item.usage_limit_per_user,
|
||||||
|
min_amount: item.min_amount,
|
||||||
|
applicable_event_ids: item.applicable_event_ids,
|
||||||
|
});
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const items = query.data?.results ?? [];
|
||||||
|
const count = query.data?.count ?? 0;
|
||||||
|
const hasMore = page * PAGE_SIZE < count;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">کدهای تخفیف</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">مدیریت کدهای تخفیف رویدادها</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={openCreate}>
|
||||||
|
<Plus className="ml-2 h-4 w-4" />
|
||||||
|
افزودن
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>فهرست کدها</CardTitle>
|
||||||
|
<CardDescription>جستجو و مدیریت وضعیت کدهای تخفیف</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => {
|
||||||
|
setSearch(event.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
placeholder="جستجو بر اساس کد..."
|
||||||
|
className="max-w-md"
|
||||||
|
/>
|
||||||
|
<div className="overflow-x-auto rounded-2xl border">
|
||||||
|
<table className="w-full min-w-[760px] text-sm">
|
||||||
|
<thead className="bg-muted/40 text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-right">کد</th>
|
||||||
|
<th className="px-4 py-3 text-right">نوع</th>
|
||||||
|
<th className="px-4 py-3 text-right">مقدار</th>
|
||||||
|
<th className="px-4 py-3 text-right">استفاده</th>
|
||||||
|
<th className="px-4 py-3 text-right">وضعیت</th>
|
||||||
|
<th className="px-4 py-3 text-left"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{query.isLoading ? (
|
||||||
|
<tr><td colSpan={6} className="px-4 py-6 text-center text-muted-foreground">در حال بارگذاری...</td></tr>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<tr><td colSpan={6} className="px-4 py-6 text-center text-muted-foreground">کدی یافت نشد.</td></tr>
|
||||||
|
) : (
|
||||||
|
items.map((item) => (
|
||||||
|
<tr key={item.id} className="border-t hover:bg-muted/40">
|
||||||
|
<td className="px-4 py-3 font-mono font-bold">{item.code}</td>
|
||||||
|
<td className="px-4 py-3">{item.type === "percent" ? "درصدی" : "مبلغ ثابت"}</td>
|
||||||
|
<td className="px-4 py-3">{formatNumberPersian(item.value)}</td>
|
||||||
|
<td className="px-4 py-3">{formatNumberPersian(item.usage_count)}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Badge variant={item.is_active ? "default" : "outline"}>{item.is_active ? "فعال" : "غیرفعال"}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button size="icon" variant="outline" onClick={() => openEdit(item)} aria-label="ویرایش">
|
||||||
|
<Edit3 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<ConfirmAction
|
||||||
|
title="حذف کد تخفیف"
|
||||||
|
description={`آیا از حذف کد «${item.code}» مطمئن هستید؟ این کد دیگر در لیستهای عادی نمایش داده نمیشود.`}
|
||||||
|
onConfirm={() => deleteMutation.mutate(item.id)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
trigger={
|
||||||
|
<Button size="icon" variant="outline" className="text-destructive hover:text-destructive" disabled={deleteMutation.isPending} aria-label="حذف">
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span>صفحه {formatNumberPersian(page)} از {formatNumberPersian(Math.max(1, Math.ceil(count / PAGE_SIZE)))}</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" variant="outline" disabled={page === 1} onClick={() => setPage((current) => Math.max(1, current - 1))}>قبلی</Button>
|
||||||
|
<Button size="sm" variant="outline" disabled={!hasMore} onClick={() => setPage((current) => current + 1)}>بعدی</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="max-h-[90vh] overflow-y-auto" dir="rtl">
|
||||||
|
<DialogHeader className="text-right">
|
||||||
|
<DialogTitle>{editing ? "ویرایش کد تخفیف" : "افزودن کد تخفیف"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-2 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>کد</Label>
|
||||||
|
<Input dir="ltr" value={form.code} onChange={(event) => setForm((current) => ({ ...current, code: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>نوع</Label>
|
||||||
|
<Select value={form.type} onValueChange={(value) => setForm((current) => ({ ...current, type: value as "percent" | "fixed" }))}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="percent">درصدی</SelectItem>
|
||||||
|
<SelectItem value="fixed">مبلغ ثابت</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>مقدار</Label>
|
||||||
|
<Input type="number" value={form.value} onChange={(event) => setForm((current) => ({ ...current, value: Number(event.target.value) }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>حداکثر تخفیف</Label>
|
||||||
|
<Input type="number" value={form.max_discount ?? ""} onChange={(event) => setForm((current) => ({ ...current, max_discount: event.target.value ? Number(event.target.value) : null }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>حداقل مبلغ</Label>
|
||||||
|
<Input type="number" value={form.min_amount ?? ""} onChange={(event) => setForm((current) => ({ ...current, min_amount: event.target.value ? Number(event.target.value) : null }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>محدودیت کل</Label>
|
||||||
|
<Input type="number" value={form.usage_limit_total ?? ""} onChange={(event) => setForm((current) => ({ ...current, usage_limit_total: event.target.value ? Number(event.target.value) : null }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>محدودیت هر کاربر</Label>
|
||||||
|
<Input type="number" value={form.usage_limit_per_user ?? ""} onChange={(event) => setForm((current) => ({ ...current, usage_limit_per_user: event.target.value ? Number(event.target.value) : null }))} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between rounded-xl border px-3 py-2">
|
||||||
|
<Label>فعال</Label>
|
||||||
|
<Switch checked={form.is_active ?? true} onCheckedChange={(checked) => setForm((current) => ({ ...current, is_active: checked }))} />
|
||||||
|
</div>
|
||||||
|
<AdminDateTimeField label="شروع اعتبار" value={form.starts_at} onChange={(value) => setForm((current) => ({ ...current, starts_at: value }))} />
|
||||||
|
<AdminDateTimeField label="پایان اعتبار" value={form.ends_at} onChange={(value) => setForm((current) => ({ ...current, ends_at: value }))} />
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="gap-2 sm:justify-start">
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>انصراف</Button>
|
||||||
|
<Button disabled={saveMutation.isPending || !form.code.trim() || form.value <= 0} onClick={() => saveMutation.mutate()}>
|
||||||
|
ذخیره
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1369
src/views/AdminDashboard.tsx
Normal file
1369
src/views/AdminDashboard.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,216 +1,277 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from "react";
|
||||||
import { useParams, Link, Navigate } from '@/lib/router';
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { CheckCircle2, Clock3, XCircle } from "lucide-react";
|
||||||
import { api } from '@/lib/api';
|
import AsyncSearchableCombobox from "@/components/AsyncSearchableCombobox";
|
||||||
import { Badge } from '@/components/ui/badge';
|
import Markdown from "@/components/Markdown";
|
||||||
import { Button } from '@/components/ui/button';
|
import ProgressiveImage from "@/components/ProgressiveImage";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { Input } from '@/components/ui/input';
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { formatJalali, formatToman, resolveErrorMessage, toPersianDigits } from '@/lib/utils';
|
import { Input } from "@/components/ui/input";
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Link, Navigate, useParams } from "@/lib/router";
|
||||||
|
import type { RegistrationAdminSchema } from "@/lib/types";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { formatJalali, formatToman, getEventCardImageUrl, resolveErrorMessage, toPersianDigits } from "@/lib/utils";
|
||||||
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
const registrationStatusOptions = [
|
|
||||||
{ value: 'confirmed', label: 'تایید شده' },
|
|
||||||
{ value: 'pending', label: 'در انتظار' },
|
|
||||||
{ value: 'cancelled', label: 'لغو شده' },
|
|
||||||
{ value: 'attended', label: 'حضور یافته' },
|
|
||||||
] as const;
|
|
||||||
const REGISTRATIONS_PAGE_SIZE = 10;
|
const REGISTRATIONS_PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: "all", label: "همه" },
|
||||||
|
{ value: "confirmed", label: "تایید شده" },
|
||||||
|
{ value: "pending", label: "در انتظار" },
|
||||||
|
{ value: "cancelled", label: "لغو شده" },
|
||||||
|
{ value: "attended", label: "حضور یافته" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function initials(registration: RegistrationAdminSchema) {
|
||||||
|
const text = [registration.user.first_name, registration.user.last_name].filter(Boolean).join(" ") || registration.user.username;
|
||||||
|
return text.slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusIcon(status: RegistrationAdminSchema["status"]) {
|
||||||
|
if (status === "confirmed" || status === "attended") return <CheckCircle2 className="h-4 w-4 text-emerald-500" />;
|
||||||
|
if (status === "pending") return <Clock3 className="h-4 w-4 text-amber-500" />;
|
||||||
|
return <XCircle className="h-4 w-4 text-destructive" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RegistrationDialog({
|
||||||
|
registration,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
registration: RegistrationAdminSchema | null;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog open={Boolean(registration)} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto" dir="rtl">
|
||||||
|
<DialogHeader className="text-right">
|
||||||
|
<DialogTitle>جزئیات ثبتنام</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{registration ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3 rounded-2xl border p-4">
|
||||||
|
<Avatar className="h-14 w-14">
|
||||||
|
<AvatarImage src={registration.user.profile_picture_thumbnail_url || registration.user.profile_picture || undefined} />
|
||||||
|
<AvatarFallback>{initials(registration)}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="min-w-0 text-right">
|
||||||
|
<div className="font-bold">{registration.user.first_name} {registration.user.last_name}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{registration.user.mobile || registration.user.email}</div>
|
||||||
|
</div>
|
||||||
|
<Badge className="mr-auto" variant={registration.status === "cancelled" ? "destructive" : "secondary"}>
|
||||||
|
{registration.status_label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 md:grid-cols-2">
|
||||||
|
<Info label="موبایل" value={registration.user.mobile} />
|
||||||
|
<Info label="ایمیل" value={registration.user.email} />
|
||||||
|
<Info label="دانشگاه" value={registration.user.university} />
|
||||||
|
<Info label="رشته" value={registration.user.major} />
|
||||||
|
<Info label="شماره دانشجویی" value={registration.user.student_id} />
|
||||||
|
<Info label="کد بلیت" value={registration.ticket_id} />
|
||||||
|
<Info label="تاریخ ثبتنام" value={formatJalali(registration.registered_at)} />
|
||||||
|
<Info label="مبلغ نهایی" value={formatToman(registration.final_price ?? 0)} />
|
||||||
|
<Info label="تخفیف" value={formatToman(registration.discount_amount ?? 0)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="font-semibold">پرداختها</div>
|
||||||
|
{registration.payments.length ? registration.payments.map((payment) => (
|
||||||
|
<div key={payment.id} className="rounded-2xl border bg-muted/20 p-3 text-sm">
|
||||||
|
<div className="grid gap-2 md:grid-cols-2">
|
||||||
|
<Info label="وضعیت" value={payment.status_label} />
|
||||||
|
<Info label="مبلغ" value={formatToman(payment.amount)} />
|
||||||
|
<Info label="کد رهگیری" value={payment.ref_id} />
|
||||||
|
<Info label="Authority" value={payment.authority} />
|
||||||
|
<Info label="کارت" value={payment.card_pan} />
|
||||||
|
<Info label="کد تخفیف" value={payment.discount_code} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)) : <p className="text-sm text-muted-foreground">پرداختی ثبت نشده است.</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Info({ label, value }: { label: string; value?: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-3 rounded-xl bg-background px-3 py-2">
|
||||||
|
<span className="text-muted-foreground">{label}</span>
|
||||||
|
<span className="text-left font-medium">{value || "—"}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminEventDetail() {
|
export default function AdminEventDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { user, isAuthenticated, loading } = useAuth();
|
const { user, isAuthenticated, loading } = useAuth();
|
||||||
const [statusFilter, setStatusFilter] = React.useState<typeof registrationStatusOptions[number]['value'] | 'all'>('all');
|
|
||||||
const [search, setSearch] = React.useState('');
|
|
||||||
const [regPage, setRegPage] = React.useState(1);
|
|
||||||
|
|
||||||
const eventId = Number(id);
|
const eventId = Number(id);
|
||||||
|
const [statusFilter, setStatusFilter] = React.useState("all");
|
||||||
|
const [search, setSearch] = React.useState("");
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = React.useState("");
|
||||||
|
const [university, setUniversity] = React.useState<string | null>(null);
|
||||||
|
const [major, setMajor] = React.useState<string | null>(null);
|
||||||
|
const [regPage, setRegPage] = React.useState(1);
|
||||||
|
const [selectedRegistration, setSelectedRegistration] = React.useState<RegistrationAdminSchema | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const timer = window.setTimeout(() => setDebouncedSearch(search.trim()), 300);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
const detailQuery = useQuery({
|
const detailQuery = useQuery({
|
||||||
queryKey: ['admin', 'event-detail', eventId],
|
queryKey: ["admin", "event-detail", eventId],
|
||||||
queryFn: () => api.getEventAdminDetail(eventId),
|
queryFn: () => api.getEventAdminDetail(eventId),
|
||||||
enabled: Number.isFinite(eventId),
|
enabled: Number.isFinite(eventId),
|
||||||
});
|
});
|
||||||
|
|
||||||
const registrationsQuery = useQuery({
|
const registrationsQuery = useQuery({
|
||||||
queryKey: ['admin', 'event', eventId, 'registrations', statusFilter, search, regPage],
|
queryKey: ["admin", "event", eventId, "registrations", statusFilter, debouncedSearch, university, major, regPage],
|
||||||
enabled: Number.isFinite(eventId),
|
enabled: Number.isFinite(eventId),
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api.listEventRegistrationsAdmin(eventId, {
|
api.listEventRegistrationsAdmin(eventId, {
|
||||||
statuses:
|
statuses: statusFilter === "all" ? undefined : [statusFilter],
|
||||||
statusFilter === 'all'
|
search: debouncedSearch || undefined,
|
||||||
? registrationStatusOptions.map((s) => s.value)
|
university: university || undefined,
|
||||||
: [statusFilter],
|
major: major || undefined,
|
||||||
search: search || undefined,
|
|
||||||
limit: REGISTRATIONS_PAGE_SIZE,
|
limit: REGISTRATIONS_PAGE_SIZE,
|
||||||
offset: (regPage - 1) * REGISTRATIONS_PAGE_SIZE,
|
offset: (regPage - 1) * REGISTRATIONS_PAGE_SIZE,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (detailQuery.error) {
|
const error = detailQuery.error || registrationsQuery.error;
|
||||||
toast({ title: 'خطا در دریافت جزئیات رویداد', description: resolveErrorMessage(detailQuery.error), variant: 'destructive' });
|
if (error) toast({ title: "خطا در دریافت اطلاعات رویداد", description: resolveErrorMessage(error), variant: "destructive" });
|
||||||
}
|
}, [detailQuery.error, registrationsQuery.error, toast]);
|
||||||
}, [detailQuery.error, toast]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
const loadMajors = React.useCallback(async (params: { search: string; limit: number; offset: number }) => {
|
||||||
if (registrationsQuery.error) {
|
const data = await api.getMajorsPaged(params);
|
||||||
toast({ title: 'خطا در ثبتنامها', description: resolveErrorMessage(registrationsQuery.error), variant: 'destructive' });
|
return { count: data.count, results: data.results.map((item) => ({ value: item.code, label: item.label })) };
|
||||||
}
|
}, []);
|
||||||
}, [registrationsQuery.error, toast]);
|
|
||||||
|
|
||||||
if (loading) {
|
const loadUniversities = React.useCallback(async (params: { search: string; limit: number; offset: number }) => {
|
||||||
return <div className="min-h-screen flex items-center justify-center text-muted-foreground" dir="rtl">در حال بارگذاری...</div>;
|
const data = await api.getUniversitiesPaged(params);
|
||||||
}
|
return { count: data.count, results: data.results.map((item) => ({ value: item.code, label: item.label })) };
|
||||||
if (!isAuthenticated || !(user?.is_staff || user?.is_superuser)) {
|
}, []);
|
||||||
return <Navigate to="/" replace />;
|
|
||||||
}
|
if (loading) return <div className="flex min-h-screen items-center justify-center text-muted-foreground" dir="rtl">در حال بارگذاری...</div>;
|
||||||
if (!Number.isFinite(eventId)) {
|
if (!isAuthenticated || !(user?.is_staff || user?.is_superuser)) return <Navigate to="/" replace />;
|
||||||
return <div className="min-h-screen flex items-center justify-center" dir="rtl">شناسه رویداد معتبر نیست.</div>;
|
if (!Number.isFinite(eventId)) return <div className="flex min-h-screen items-center justify-center" dir="rtl">شناسه رویداد معتبر نیست.</div>;
|
||||||
}
|
|
||||||
|
|
||||||
const event = detailQuery.data;
|
const event = detailQuery.data;
|
||||||
const paged = registrationsQuery.data;
|
const paged = registrationsQuery.data;
|
||||||
const registrationPageCount = paged ? Math.max(1, Math.ceil(paged.count / REGISTRATIONS_PAGE_SIZE)) : 1;
|
const registrationPageCount = paged ? Math.max(1, Math.ceil(paged.count / REGISTRATIONS_PAGE_SIZE)) : 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background" dir="rtl">
|
<div className="space-y-6" dir="rtl">
|
||||||
<div className="container mx-auto px-4 py-6 space-y-6">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
<div>
|
||||||
<div>
|
<h1 className="text-2xl font-black">{event?.title ?? "جزئیات رویداد"}</h1>
|
||||||
<h1 className="text-2xl font-bold">{event?.title ?? 'جزئیات رویداد'}</h1>
|
{event ? <p className="mt-1 text-sm text-muted-foreground">شروع: {formatJalali(event.start_time)} · ثبتنامها: {toPersianDigits(event.registration_count)}</p> : null}
|
||||||
{event && (
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground mt-1">
|
|
||||||
<Badge variant="secondary">{event.status_label ?? event.status}</Badge>
|
|
||||||
{event.start_time ? <span>شروع: {formatJalali(event.start_time)}</span> : null}
|
|
||||||
{event.event_type ? <span>نوع: {event.event_type_label ?? event.event_type}</span> : null}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button asChild>
|
|
||||||
<Link to={`/admin/events/${eventId}/edit`}>ویرایش پیشرفته</Link>
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" asChild>
|
|
||||||
<Link to="/admin/events">بازگشت</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button asChild><Link to={`/admin/events/${eventId}/edit`}>ویرایش</Link></Button>
|
||||||
|
<Button variant="outline" asChild><Link to="/admin/events">بازگشت</Link></Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{event && (
|
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<main className="space-y-6">
|
||||||
<Card>
|
{event ? (
|
||||||
<CardHeader>
|
<>
|
||||||
<CardTitle>وضعیت</CardTitle>
|
<ProgressiveImage
|
||||||
<CardDescription>اطلاعات پایه رویداد</CardDescription>
|
src={getEventCardImageUrl(event)}
|
||||||
</CardHeader>
|
alt={event.title}
|
||||||
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
wrapperClassName="aspect-video overflow-hidden rounded-3xl bg-muted shadow-sm"
|
||||||
<div>ظرفیت: {event.capacity ?? 'نامحدود'}</div>
|
className="h-full w-full object-cover"
|
||||||
<div>ثبتنامها: {toPersianDigits(event.registration_count ?? 0)}</div>
|
/>
|
||||||
<div>قیمت: {formatToman(event.price)}</div>
|
<Card>
|
||||||
</CardContent>
|
<CardContent className="grid gap-3 p-5 text-sm md:grid-cols-2">
|
||||||
</Card>
|
<Info label="نوع" value={event.event_type} />
|
||||||
<Card className="md:col-span-2">
|
<Info label="وضعیت" value={event.status} />
|
||||||
<CardHeader>
|
<Info label="ظرفیت" value={event.capacity ?? "نامحدود"} />
|
||||||
<CardTitle>توضیحات</CardTitle>
|
<Info label="قیمت" value={Number(event.price || 0) === 0 ? "رایگان" : formatToman(event.price)} />
|
||||||
</CardHeader>
|
<Info label="شروع ثبتنام" value={event.registration_start_date ? formatJalali(event.registration_start_date) : "—"} />
|
||||||
<CardContent className="text-sm text-muted-foreground leading-6">
|
<Info label="پایان ثبتنام" value={event.registration_end_date ? formatJalali(event.registration_end_date) : "—"} />
|
||||||
{event.description || 'توضیحی ثبت نشده است.'}
|
<Info label="آدرس" value={event.address} />
|
||||||
</CardContent>
|
<Info label="لینک آنلاین" value={event.online_link} />
|
||||||
</Card>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
)}
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<Markdown content={event.description || "توضیحی ثبت نشده است."} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">در حال بارگذاری جزئیات...</p>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
<Card>
|
<aside className="space-y-4 lg:sticky lg:top-24 lg:self-start">
|
||||||
<CardHeader>
|
<Card>
|
||||||
<CardTitle>ثبتنامها و پرداختها</CardTitle>
|
<CardHeader>
|
||||||
<CardDescription>لیست ثبتنامهای مرتبط با این رویداد</CardDescription>
|
<CardTitle>اطلاعات ثبتنام</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-3">
|
||||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
<Input placeholder="جستجو نام، موبایل یا ایمیل..." value={search} onChange={(event) => { setSearch(event.target.value); setRegPage(1); }} />
|
||||||
<div className="flex flex-wrap gap-2">
|
<Select value={statusFilter} onValueChange={(value) => { setStatusFilter(value); setRegPage(1); }}>
|
||||||
<Select value={statusFilter} onValueChange={(value) => { setStatusFilter(value as typeof statusFilter); setRegPage(1); }}>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectTrigger className="w-full md:w-40">
|
|
||||||
<SelectValue placeholder="وضعیت">وضعیت: {statusFilter === 'all' ? 'همه' : statusFilter}</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">همه</SelectItem>
|
{statusOptions.map((option) => <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>)}
|
||||||
{registrationStatusOptions.map((s) => (
|
|
||||||
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
<AsyncSearchableCombobox value={university} onChange={(value) => { setUniversity(value); setRegPage(1); }} loadOptions={loadUniversities} placeholder="دانشگاه" />
|
||||||
<Input
|
<AsyncSearchableCombobox value={major} onChange={(value) => { setMajor(value); setRegPage(1); }} loadOptions={loadMajors} placeholder="رشته" />
|
||||||
className="md:w-64"
|
|
||||||
placeholder="جستجو نام/ایمیل/نامکاربری"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => { setSearch(e.target.value); setRegPage(1); }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{registrationsQuery.isLoading ? (
|
<div className="space-y-2">
|
||||||
<p className="text-sm text-muted-foreground">در حال بارگذاری ثبتنامها...</p>
|
{registrationsQuery.isLoading ? (
|
||||||
) : !paged || paged.results.length === 0 ? (
|
<p className="text-sm text-muted-foreground">در حال بارگذاری...</p>
|
||||||
<p className="text-sm text-muted-foreground">ثبتنامی یافت نشد.</p>
|
) : !paged || paged.results.length === 0 ? (
|
||||||
) : (
|
<p className="text-sm text-muted-foreground">ثبتنامی یافت نشد.</p>
|
||||||
<ScrollArea className="rounded-md border max-h-[70vh]">
|
) : paged.results.map((registration) => (
|
||||||
<div className="divide-y">
|
<button
|
||||||
{paged.results.map((registration) => (
|
key={registration.id}
|
||||||
<div key={registration.id} className="p-4">
|
type="button"
|
||||||
<div className="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
|
className="flex w-full items-center gap-3 rounded-2xl border bg-background p-3 text-right transition hover:bg-muted/40"
|
||||||
<div>
|
onClick={() => setSelectedRegistration(registration)}
|
||||||
<div className="font-semibold">{registration.user.first_name} {registration.user.last_name}</div>
|
>
|
||||||
<div className="text-xs text-muted-foreground">{registration.user.email}</div>
|
<Avatar className="h-10 w-10">
|
||||||
</div>
|
<AvatarImage src={registration.user.profile_picture_thumbnail_url || registration.user.profile_picture || undefined} />
|
||||||
<Badge variant={registration.status === 'confirmed' ? 'default' : 'outline'}>
|
<AvatarFallback>{initials(registration)}</AvatarFallback>
|
||||||
{registration.status_label}
|
</Avatar>
|
||||||
</Badge>
|
<span className="min-w-0 flex-1">
|
||||||
</div>
|
<span className="block truncate text-sm font-semibold">{registration.user.first_name} {registration.user.last_name}</span>
|
||||||
<div className="mt-2 grid gap-1 text-xs text-muted-foreground md:grid-cols-2 lg:grid-cols-3">
|
<span className="block truncate text-xs text-muted-foreground">{registration.user.mobile || registration.user.email}</span>
|
||||||
<div>نامکاربری: {registration.user.username}</div>
|
</span>
|
||||||
<div>کد بلیت: {registration.ticket_id}</div>
|
{statusIcon(registration.status)}
|
||||||
<div>تاریخ ثبتنام: {formatJalali(registration.registered_at)}</div>
|
</button>
|
||||||
<div>مبلغ پرداختی: {formatToman(registration.final_price ?? 0)}</div>
|
))}
|
||||||
<div>تخفیف: {formatToman(registration.discount_amount ?? 0)}</div>
|
|
||||||
</div>
|
|
||||||
{registration.payments.length > 0 && (
|
|
||||||
<div className="mt-2 space-y-1 text-xs">
|
|
||||||
<div className="font-medium">پرداختها</div>
|
|
||||||
{registration.payments.map((payment) => (
|
|
||||||
<div key={payment.id} className="flex flex-wrap items-center justify-between gap-2 rounded border px-2 py-1">
|
|
||||||
<span className="text-muted-foreground">{payment.status_label}</span>
|
|
||||||
<span>{formatToman(payment.amount)}</span>
|
|
||||||
<span className="text-muted-foreground text-[11px]">Ref: {payment.ref_id ?? '—'}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
||||||
<span>صفحه {toPersianDigits(regPage)} از {toPersianDigits(registrationPageCount)}</span>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button size="sm" variant="outline" disabled={regPage <= 1} onClick={() => setRegPage((p) => Math.max(1, p - 1))}>
|
|
||||||
قبلی
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="outline" disabled={regPage >= registrationPageCount} onClick={() => setRegPage((p) => p + 1)}>
|
|
||||||
بعدی
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</CardContent>
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
</Card>
|
<span>صفحه {toPersianDigits(regPage)} از {toPersianDigits(registrationPageCount)}</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" variant="outline" disabled={regPage <= 1} onClick={() => setRegPage((page) => Math.max(1, page - 1))}>قبلی</Button>
|
||||||
|
<Button size="sm" variant="outline" disabled={regPage >= registrationPageCount} onClick={() => setRegPage((page) => page + 1)}>بعدی</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<RegistrationDialog registration={selectedRegistration} onOpenChange={(open) => { if (!open) setSelectedRegistration(null); }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,284 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from 'react';
|
import AdminEventForm from "@/views/AdminEventForm";
|
||||||
import { useNavigate, useParams, Navigate } from '@/lib/router';
|
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
|
||||||
import { api } from '@/lib/api';
|
|
||||||
import type { EventAdminDetailSchema, EventUpdateSchema } from '@/lib/types';
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import { useToast } from '@/hooks/use-toast';
|
|
||||||
import { resolveErrorMessage } from '@/lib/utils';
|
|
||||||
|
|
||||||
const statusOptions = [
|
|
||||||
{ value: 'draft', label: 'پیشنویس' },
|
|
||||||
{ value: 'published', label: 'منتشر شده' },
|
|
||||||
{ value: 'cancelled', label: 'لغو شده' },
|
|
||||||
{ value: 'completed', label: 'برگزار شده' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const typeOptions = [
|
|
||||||
{ value: 'online', label: 'آنلاین' },
|
|
||||||
{ value: 'on_site', label: 'حضوری' },
|
|
||||||
{ value: 'hybrid', label: 'ترکیبی' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const toInputDateTime = (iso?: string | null) => {
|
|
||||||
if (!iso) return '';
|
|
||||||
const d = new Date(iso);
|
|
||||||
return `${d.getFullYear().toString().padStart(4, '0')}-${(d.getMonth() + 1)
|
|
||||||
.toString()
|
|
||||||
.padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}T${d
|
|
||||||
.getHours()
|
|
||||||
.toString()
|
|
||||||
.padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function AdminEventEdit() {
|
export default function AdminEventEdit() {
|
||||||
const { user, isAuthenticated, loading } = useAuth();
|
return <AdminEventForm mode="edit" />;
|
||||||
const { id } = useParams<{ id: string }>();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const eventId = Number(id);
|
|
||||||
const { toast } = useToast();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const detailQuery = useQuery({
|
|
||||||
queryKey: ['admin', 'edit-event', eventId],
|
|
||||||
queryFn: () => api.getEventAdminDetail(eventId),
|
|
||||||
enabled: Boolean(eventId) && isAuthenticated,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [formData, setFormData] = React.useState({
|
|
||||||
title: '',
|
|
||||||
status: 'draft' as NonNullable<EventUpdateSchema['status']>,
|
|
||||||
event_type: 'online' as NonNullable<EventUpdateSchema['event_type']>,
|
|
||||||
price: '',
|
|
||||||
capacity: '',
|
|
||||||
start_time: '',
|
|
||||||
end_time: '',
|
|
||||||
registration_start_date: '',
|
|
||||||
registration_end_date: '',
|
|
||||||
location: '',
|
|
||||||
address: '',
|
|
||||||
online_link: '',
|
|
||||||
description: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (detailQuery.data) {
|
|
||||||
const d: EventAdminDetailSchema = detailQuery.data;
|
|
||||||
setFormData({
|
|
||||||
title: d.title || '',
|
|
||||||
status: d.status || 'draft',
|
|
||||||
event_type: d.event_type || 'online',
|
|
||||||
price: d.price ? Math.floor(Number(d.price) / 10).toString() : '',
|
|
||||||
capacity: d.capacity != null ? String(d.capacity) : '',
|
|
||||||
start_time: toInputDateTime(d.start_time),
|
|
||||||
end_time: toInputDateTime(d.end_time),
|
|
||||||
registration_start_date: toInputDateTime(d.registration_start_date),
|
|
||||||
registration_end_date: toInputDateTime(d.registration_end_date),
|
|
||||||
location: d.location || '',
|
|
||||||
address: d.address || '',
|
|
||||||
online_link: d.online_link || '',
|
|
||||||
description: d.description || '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [detailQuery.data]);
|
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
|
||||||
mutationFn: (payload: EventUpdateSchema) => api.updateEvent(eventId, payload),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({ title: 'رویداد بهروزرسانی شد', variant: 'success' });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin', 'edit-event', eventId] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin', 'events'] });
|
|
||||||
navigate(`/admin/events/${eventId}`);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast({
|
|
||||||
variant: 'destructive',
|
|
||||||
title: 'خطا در ذخیرهسازی رویداد',
|
|
||||||
description: resolveErrorMessage(error),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (detailQuery.error) {
|
|
||||||
toast({
|
|
||||||
variant: 'destructive',
|
|
||||||
title: 'خطا در دریافت رویداد',
|
|
||||||
description: resolveErrorMessage(detailQuery.error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [detailQuery.error, toast]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
|
||||||
<p className="text-muted-foreground">در حال بررسی دسترسی...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAuthenticated || !(user?.is_staff || user?.is_superuser)) {
|
|
||||||
return <Navigate to="/" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-background" dir="rtl">
|
|
||||||
<div className="container mx-auto px-4 py-10">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>ویرایش رویداد</CardTitle>
|
|
||||||
<CardDescription>فرم کامل برای ویرایش جزئیات رویداد</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{detailQuery.isLoading ? (
|
|
||||||
<p className="text-sm text-muted-foreground">در حال بارگذاری جزئیات...</p>
|
|
||||||
) : detailQuery.data ? (
|
|
||||||
<form
|
|
||||||
className="space-y-4"
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
updateMutation.mutate({
|
|
||||||
title: formData.title,
|
|
||||||
status: formData.status,
|
|
||||||
event_type: formData.event_type,
|
|
||||||
price: formData.price ? Number(formData.price) * 10 : 0,
|
|
||||||
capacity: formData.capacity ? Number(formData.capacity) : null,
|
|
||||||
start_time: formData.start_time || undefined,
|
|
||||||
end_time: formData.end_time || null,
|
|
||||||
registration_start_date: formData.registration_start_date || null,
|
|
||||||
registration_end_date: formData.registration_end_date || null,
|
|
||||||
location: formData.location || null,
|
|
||||||
address: formData.address || null,
|
|
||||||
online_link: formData.online_link || null,
|
|
||||||
description: formData.description || '',
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
|
||||||
<Input
|
|
||||||
placeholder="عنوان رویداد"
|
|
||||||
value={formData.title}
|
|
||||||
onChange={(e) => setFormData((p) => ({ ...p, title: e.target.value }))}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
value={formData.status}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setFormData((p) => ({
|
|
||||||
...p,
|
|
||||||
status: value as NonNullable<EventUpdateSchema['status']>,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="وضعیت" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{statusOptions.map((opt) => (
|
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Select
|
|
||||||
value={formData.event_type}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setFormData((p) => ({
|
|
||||||
...p,
|
|
||||||
event_type: value as NonNullable<EventUpdateSchema['event_type']>,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="نوع رویداد" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{typeOptions.map((opt) => (
|
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Input
|
|
||||||
placeholder="قیمت (تومان)"
|
|
||||||
value={formData.price}
|
|
||||||
onChange={(e) => setFormData((p) => ({ ...p, price: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
placeholder="ظرفیت"
|
|
||||||
value={formData.capacity}
|
|
||||||
onChange={(e) => setFormData((p) => ({ ...p, capacity: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="datetime-local"
|
|
||||||
placeholder="تاریخ شروع"
|
|
||||||
value={formData.start_time}
|
|
||||||
onChange={(e) => setFormData((p) => ({ ...p, start_time: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="datetime-local"
|
|
||||||
placeholder="تاریخ پایان"
|
|
||||||
value={formData.end_time}
|
|
||||||
onChange={(e) => setFormData((p) => ({ ...p, end_time: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="datetime-local"
|
|
||||||
placeholder="شروع ثبتنام"
|
|
||||||
value={formData.registration_start_date}
|
|
||||||
onChange={(e) => setFormData((p) => ({ ...p, registration_start_date: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="datetime-local"
|
|
||||||
placeholder="پایان ثبتنام"
|
|
||||||
value={formData.registration_end_date}
|
|
||||||
onChange={(e) => setFormData((p) => ({ ...p, registration_end_date: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
placeholder="محل برگزاری"
|
|
||||||
value={formData.location}
|
|
||||||
onChange={(e) => setFormData((p) => ({ ...p, location: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
placeholder="آدرس دقیق"
|
|
||||||
value={formData.address}
|
|
||||||
onChange={(e) => setFormData((p) => ({ ...p, address: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
placeholder="لینک آنلاین"
|
|
||||||
value={formData.online_link}
|
|
||||||
onChange={(e) => setFormData((p) => ({ ...p, online_link: e.target.value }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Textarea
|
|
||||||
placeholder="توضیحات رویداد"
|
|
||||||
value={formData.description}
|
|
||||||
onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value }))}
|
|
||||||
rows={8}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-wrap gap-2 justify-end">
|
|
||||||
<Button type="button" variant="outline" onClick={() => navigate(-1)}>
|
|
||||||
بازگشت
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={updateMutation.isPending}>
|
|
||||||
ذخیره
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-destructive">امکان دریافت رویداد وجود ندارد.</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
387
src/views/AdminEventForm.tsx
Normal file
387
src/views/AdminEventForm.tsx
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { ImagePlus, Trash2, Upload } from "lucide-react";
|
||||||
|
import AdminDateTimeField from "@/components/AdminDateTimeField";
|
||||||
|
import ConfirmAction from "@/components/ConfirmAction";
|
||||||
|
import Markdown from "@/components/Markdown";
|
||||||
|
import MarkdownEditor from "@/components/MarkdownEditor";
|
||||||
|
import ProgressiveImage from "@/components/ProgressiveImage";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Link, Navigate, useNavigate, useParams } from "@/lib/router";
|
||||||
|
import type { EventCreateSchema, EventDetailSchema, EventGalleryItem } from "@/lib/types";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { getEventCardImageUrl, resolveErrorMessage } from "@/lib/utils";
|
||||||
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
type Mode = "create" | "edit";
|
||||||
|
|
||||||
|
const emptyForm = {
|
||||||
|
title: "",
|
||||||
|
slug: "",
|
||||||
|
status: "draft" as EventCreateSchema["status"],
|
||||||
|
event_type: "on_site" as EventCreateSchema["event_type"],
|
||||||
|
price: "",
|
||||||
|
capacity: "",
|
||||||
|
start_time: null as string | null,
|
||||||
|
end_time: null as string | null,
|
||||||
|
registration_start_date: null as string | null,
|
||||||
|
registration_end_date: null as string | null,
|
||||||
|
location: "",
|
||||||
|
address: "",
|
||||||
|
online_link: "",
|
||||||
|
description: "",
|
||||||
|
registration_success_markdown: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
function toForm(event: EventDetailSchema) {
|
||||||
|
return {
|
||||||
|
title: event.title || "",
|
||||||
|
slug: event.slug || "",
|
||||||
|
status: event.status || "draft",
|
||||||
|
event_type: event.event_type || "on_site",
|
||||||
|
price: event.price ? String(Math.floor(Number(event.price) / 10)) : "",
|
||||||
|
capacity: event.capacity != null ? String(event.capacity) : "",
|
||||||
|
start_time: event.start_time || null,
|
||||||
|
end_time: event.end_time || null,
|
||||||
|
registration_start_date: event.registration_start_date || null,
|
||||||
|
registration_end_date: event.registration_end_date || null,
|
||||||
|
location: event.location || "",
|
||||||
|
address: event.address || "",
|
||||||
|
online_link: event.online_link || "",
|
||||||
|
description: event.description || "",
|
||||||
|
registration_success_markdown: event.registration_success_markdown || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminEventForm({ mode }: { mode: Mode }) {
|
||||||
|
const { user, isAuthenticated, loading } = useAuth();
|
||||||
|
const { id } = useParams<{ id?: string }>();
|
||||||
|
const eventId = Number(id);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [form, setForm] = React.useState(emptyForm);
|
||||||
|
const [previewMode, setPreviewMode] = React.useState<"editor" | "preview">("editor");
|
||||||
|
const [galleryPreview, setGalleryPreview] = React.useState<EventGalleryItem | null>(null);
|
||||||
|
|
||||||
|
const detailQuery = useQuery({
|
||||||
|
queryKey: ["admin", "edit-event", eventId],
|
||||||
|
queryFn: () => api.getEventAdminDetail(eventId),
|
||||||
|
enabled: mode === "edit" && Number.isFinite(eventId) && isAuthenticated,
|
||||||
|
});
|
||||||
|
|
||||||
|
const galleryQuery = useQuery({
|
||||||
|
queryKey: ["admin", "event", eventId, "gallery"],
|
||||||
|
queryFn: () => api.listEventGallery(eventId),
|
||||||
|
enabled: mode === "edit" && Number.isFinite(eventId) && isAuthenticated,
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (detailQuery.data) setForm(toForm(detailQuery.data));
|
||||||
|
}, [detailQuery.data]);
|
||||||
|
|
||||||
|
const makePayload = (): EventCreateSchema => ({
|
||||||
|
title: form.title,
|
||||||
|
slug: form.slug || null,
|
||||||
|
status: form.status,
|
||||||
|
event_type: form.event_type,
|
||||||
|
price: form.price ? Number(form.price) * 10 : 0,
|
||||||
|
capacity: form.capacity ? Number(form.capacity) : null,
|
||||||
|
start_time: form.start_time || new Date().toISOString(),
|
||||||
|
end_time: form.end_time || form.start_time || new Date().toISOString(),
|
||||||
|
registration_start_date: form.registration_start_date,
|
||||||
|
registration_end_date: form.registration_end_date,
|
||||||
|
location: form.location || null,
|
||||||
|
address: form.address || null,
|
||||||
|
online_link: form.online_link || null,
|
||||||
|
description: form.description || "",
|
||||||
|
registration_success_markdown: form.registration_success_markdown || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const payload = makePayload();
|
||||||
|
return mode === "edit" ? api.updateEvent(eventId, payload) : api.createEvent(payload);
|
||||||
|
},
|
||||||
|
onSuccess: (event) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["admin", "events"] });
|
||||||
|
toast({ title: "رویداد ذخیره شد", variant: "success" });
|
||||||
|
navigate(`/admin/events/${event.id}/edit`);
|
||||||
|
},
|
||||||
|
onError: (error) => toast({ title: "خطا در ذخیره رویداد", description: resolveErrorMessage(error), variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const posterMutation = useMutation({
|
||||||
|
mutationFn: (file: File) => api.uploadEventFeaturedImage(eventId, file),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["admin", "edit-event", eventId] });
|
||||||
|
toast({ title: "پوستر ذخیره شد", variant: "success" });
|
||||||
|
},
|
||||||
|
onError: (error) => toast({ title: "خطا در آپلود پوستر", description: resolveErrorMessage(error), variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const galleryUploadMutation = useMutation({
|
||||||
|
mutationFn: (file: File) => api.uploadEventGalleryImage(eventId, file, { title: file.name }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["admin", "event", eventId, "gallery"] });
|
||||||
|
toast({ title: "تصویر گالری افزوده شد", variant: "success" });
|
||||||
|
},
|
||||||
|
onError: (error) => toast({ title: "خطا در آپلود گالری", description: resolveErrorMessage(error), variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const galleryDeleteMutation = useMutation({
|
||||||
|
mutationFn: (imageId: number) => api.deleteEventGalleryImage(eventId, imageId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["admin", "event", eventId, "gallery"] });
|
||||||
|
toast({ title: "تصویر حذف شد", variant: "success" });
|
||||||
|
},
|
||||||
|
onError: (error) => toast({ title: "خطا در حذف تصویر", description: resolveErrorMessage(error), variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loading) return <div className="py-10 text-center text-muted-foreground">در حال بررسی دسترسی...</div>;
|
||||||
|
if (!isAuthenticated || !(user?.is_staff || user?.is_superuser)) return <Navigate to="/" />;
|
||||||
|
if (mode === "edit" && !Number.isFinite(eventId)) return <div className="py-10 text-center">شناسه رویداد معتبر نیست.</div>;
|
||||||
|
|
||||||
|
const event = detailQuery.data;
|
||||||
|
const gallery = galleryQuery.data ?? event?.gallery_images ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6" dir="rtl">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-black">{mode === "edit" ? "ویرایش رویداد" : "افزودن رویداد"}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">فرم کامل رویداد با توضیحات Markdown، زمانبندی، پوستر و گالری</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button variant="outline" asChild><Link to="/admin/events">بازگشت</Link></Button>
|
||||||
|
<Button disabled={saveMutation.isPending || !form.title.trim()} onClick={() => saveMutation.mutate()}>ذخیره</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{detailQuery.isLoading ? <p className="text-sm text-muted-foreground">در حال بارگذاری...</p> : null}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>اطلاعات اصلی</CardTitle>
|
||||||
|
<CardDescription>عنوان، وضعیت، نوع رویداد، ظرفیت و هزینه</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>عنوان</Label>
|
||||||
|
<Input value={form.title} onChange={(event) => setForm((current) => ({ ...current, title: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>اسلاگ</Label>
|
||||||
|
<Input dir="ltr" value={form.slug} onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>وضعیت</Label>
|
||||||
|
<Select value={form.status} onValueChange={(value) => setForm((current) => ({ ...current, status: value as typeof form.status }))}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="draft">پیشنویس</SelectItem>
|
||||||
|
<SelectItem value="published">منتشر شده</SelectItem>
|
||||||
|
<SelectItem value="cancelled">لغو شده</SelectItem>
|
||||||
|
<SelectItem value="completed">برگزار شده</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>نوع</Label>
|
||||||
|
<Select value={form.event_type} onValueChange={(value) => setForm((current) => ({ ...current, event_type: value as typeof form.event_type }))}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="on_site">حضوری</SelectItem>
|
||||||
|
<SelectItem value="online">آنلاین</SelectItem>
|
||||||
|
<SelectItem value="hybrid">ترکیبی</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>قیمت (تومان)</Label>
|
||||||
|
<Input type="number" value={form.price} onChange={(event) => setForm((current) => ({ ...current, price: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>ظرفیت</Label>
|
||||||
|
<Input type="number" value={form.capacity} onChange={(event) => setForm((current) => ({ ...current, capacity: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>محل / مختصات</Label>
|
||||||
|
<Input value={form.location} onChange={(event) => setForm((current) => ({ ...current, location: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>آدرس</Label>
|
||||||
|
<Input value={form.address} onChange={(event) => setForm((current) => ({ ...current, address: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<Label>لینک آنلاین</Label>
|
||||||
|
<Input dir="ltr" value={form.online_link} onChange={(event) => setForm((current) => ({ ...current, online_link: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>زمانبندی</CardTitle>
|
||||||
|
<CardDescription>تاریخ شمسی و زمان جداگانه نمایش داده میشود.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||||
|
<AdminDateTimeField label="شروع رویداد" value={form.start_time} required onChange={(value) => setForm((current) => ({ ...current, start_time: value }))} />
|
||||||
|
<AdminDateTimeField label="پایان رویداد" value={form.end_time} required onChange={(value) => setForm((current) => ({ ...current, end_time: value }))} />
|
||||||
|
<AdminDateTimeField label="شروع ثبتنام" value={form.registration_start_date} onChange={(value) => setForm((current) => ({ ...current, registration_start_date: value }))} />
|
||||||
|
<AdminDateTimeField label="پایان ثبتنام" value={form.registration_end_date} onChange={(value) => setForm((current) => ({ ...current, registration_end_date: value }))} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>پوستر</CardTitle>
|
||||||
|
<CardDescription>تصویر شاخص رویداد</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{event ? (
|
||||||
|
<ProgressiveImage
|
||||||
|
src={getEventCardImageUrl(event)}
|
||||||
|
alt={event.title}
|
||||||
|
wrapperClassName="aspect-video max-w-xl overflow-hidden rounded-2xl bg-muted"
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex aspect-video max-w-xl items-center justify-center rounded-2xl border bg-muted/30 text-muted-foreground">
|
||||||
|
<ImagePlus className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{mode === "edit" ? (
|
||||||
|
<label className="inline-flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm hover:bg-muted">
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
آپلود پوستر
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(event) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (file) posterMutation.mutate(file);
|
||||||
|
event.currentTarget.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">پس از ذخیره اولیه، امکان آپلود پوستر فعال میشود.</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>متن رویداد</CardTitle>
|
||||||
|
<CardDescription>ویرایشگر Markdown و پیشنمایش</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex gap-2 md:hidden">
|
||||||
|
<Button variant={previewMode === "editor" ? "default" : "outline"} size="sm" onClick={() => setPreviewMode("editor")}>ویرایش</Button>
|
||||||
|
<Button variant={previewMode === "preview" ? "default" : "outline"} size="sm" onClick={() => setPreviewMode("preview")}>پیشنمایش</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<div className={previewMode === "preview" ? "hidden lg:block" : ""}>
|
||||||
|
<MarkdownEditor value={form.description} onChange={(value) => setForm((current) => ({ ...current, description: value }))} minHeight="520px" onSave={() => saveMutation.mutate()} />
|
||||||
|
</div>
|
||||||
|
<div className={previewMode === "editor" ? "hidden lg:block" : ""}>
|
||||||
|
<div className="min-h-[520px] rounded-2xl border bg-background p-5">
|
||||||
|
<Markdown content={form.description || "هنوز متنی وارد نشده است."} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>پیام موفقیت ثبتنام</CardTitle>
|
||||||
|
<CardDescription>متنی که بعد از ثبتنام موفق به کاربر نمایش داده میشود.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Textarea
|
||||||
|
value={form.registration_success_markdown}
|
||||||
|
onChange={(event) => setForm((current) => ({ ...current, registration_success_markdown: event.target.value }))}
|
||||||
|
rows={6}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>گالری رویداد</CardTitle>
|
||||||
|
<CardDescription>تصاویر مرتبط با رویداد</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{mode === "edit" ? (
|
||||||
|
<label className="inline-flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm hover:bg-muted">
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
آپلود تصویر
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(event) => {
|
||||||
|
const files = Array.from(event.target.files ?? []);
|
||||||
|
files.forEach((file) => galleryUploadMutation.mutate(file));
|
||||||
|
event.currentTarget.value = "";
|
||||||
|
}}
|
||||||
|
multiple
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">پس از ذخیره اولیه، امکان آپلود گالری فعال میشود.</p>
|
||||||
|
)}
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{gallery.map((item) => (
|
||||||
|
<div key={item.id} className="overflow-hidden rounded-2xl border bg-card">
|
||||||
|
<button type="button" className="block w-full" onClick={() => setGalleryPreview(item)}>
|
||||||
|
<ProgressiveImage
|
||||||
|
src={item.absolute_image_preview_url || item.absolute_image_url}
|
||||||
|
alt={item.title}
|
||||||
|
wrapperClassName="aspect-video w-full bg-muted"
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center justify-between gap-2 p-3 text-sm">
|
||||||
|
<span className="truncate">{item.title}</span>
|
||||||
|
<ConfirmAction
|
||||||
|
title="حذف تصویر گالری"
|
||||||
|
description={`آیا از حذف «${item.title}» از گالری رویداد مطمئن هستید؟`}
|
||||||
|
onConfirm={() => galleryDeleteMutation.mutate(item.id)}
|
||||||
|
disabled={galleryDeleteMutation.isPending}
|
||||||
|
trigger={
|
||||||
|
<Button size="icon" variant="ghost" className="text-destructive" disabled={galleryDeleteMutation.isPending}>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{galleryPreview ? (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4" onClick={() => setGalleryPreview(null)}>
|
||||||
|
<img
|
||||||
|
src={galleryPreview.absolute_image_preview_url || galleryPreview.absolute_image_url || ""}
|
||||||
|
alt={galleryPreview.title}
|
||||||
|
className="max-h-[90vh] max-w-full rounded-2xl object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,70 +1,80 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Link, useNavigate } from '@/lib/router';
|
import { Edit3, Eye, Plus, Trash2 } from "lucide-react";
|
||||||
import type { EventListItemSchema } from '@/lib/types';
|
import { Link, useNavigate } from "@/lib/router";
|
||||||
import { api } from '@/lib/api';
|
import type { EventListItemSchema } from "@/lib/types";
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { api } from "@/lib/api";
|
||||||
import { Button } from '@/components/ui/button';
|
import {
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
AlertDialog,
|
||||||
import { Input } from '@/components/ui/input';
|
AlertDialogAction,
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
AlertDialogCancel,
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
AlertDialogContent,
|
||||||
import ProgressiveImage from '@/components/ProgressiveImage';
|
AlertDialogDescription,
|
||||||
import { useToast } from '@/hooks/use-toast';
|
AlertDialogFooter,
|
||||||
import { formatJalali, formatToman, getEventCardImageUrl, resolveErrorMessage, toPersianDigits } from '@/lib/utils';
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import ProgressiveImage from "@/components/ProgressiveImage";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { formatJalali, formatToman, getEventCardImageUrl, resolveErrorMessage, toPersianDigits } from "@/lib/utils";
|
||||||
|
|
||||||
const EVENTS_PAGE_SIZE = 30;
|
const EVENTS_PAGE_SIZE = 30;
|
||||||
|
|
||||||
const eventStatusOptions = [
|
const eventStatusOptions = [
|
||||||
{ value: 'all', label: 'همه وضعیتها' },
|
{ value: "all", label: "همه وضعیتها" },
|
||||||
{ value: 'draft', label: 'پیشنویس' },
|
{ value: "draft", label: "پیشنویس" },
|
||||||
{ value: 'published', label: 'منتشر شده' },
|
{ value: "published", label: "منتشر شده" },
|
||||||
{ value: 'cancelled', label: 'لغو شده' },
|
{ value: "cancelled", label: "لغو شده" },
|
||||||
{ value: 'completed', label: 'برگزار شده' },
|
{ value: "completed", label: "برگزار شده" },
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
const statusConfig: Record<
|
const statusConfig: Record<
|
||||||
EventListItemSchema['status'],
|
EventListItemSchema["status"],
|
||||||
{ label: string; variant: 'outline' | 'default' | 'destructive' | 'secondary' }
|
{ label: string; variant: "outline" | "default" | "destructive" | "secondary" }
|
||||||
> = {
|
> = {
|
||||||
draft: { label: 'پیشنویس', variant: 'outline' },
|
draft: { label: "پیشنویس", variant: "outline" },
|
||||||
published: { label: 'منتشر شده', variant: 'default' },
|
published: { label: "منتشر شده", variant: "default" },
|
||||||
cancelled: { label: 'لغو شده', variant: 'destructive' },
|
cancelled: { label: "لغو شده", variant: "destructive" },
|
||||||
completed: { label: 'برگزار شده', variant: 'secondary' },
|
completed: { label: "برگزار شده", variant: "secondary" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const eventSortOptions = [
|
const eventSortOptions = [
|
||||||
{ value: 'newest', label: 'جدیدترین شروع' },
|
{ value: "newest", label: "جدیدترین شروع" },
|
||||||
{ value: 'oldest', label: 'قدیمیترین شروع' },
|
{ value: "oldest", label: "قدیمیترین شروع" },
|
||||||
{ value: 'priceAsc', label: 'قیمت صعودی' },
|
{ value: "priceAsc", label: "قیمت صعودی" },
|
||||||
{ value: 'priceDesc', label: 'قیمت نزولی' },
|
{ value: "priceDesc", label: "قیمت نزولی" },
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
const AdminEventsPage: React.FC = () => {
|
function priceLabel(price?: number | null) {
|
||||||
|
return Number(price || 0) === 0 ? "رایگان" : formatToman(price);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminEventsPage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [filters, setFilters] = React.useState({
|
const [filters, setFilters] = React.useState({
|
||||||
search: '',
|
search: "",
|
||||||
status: 'all' as 'all' | EventListItemSchema['status'],
|
status: "all" as "all" | EventListItemSchema["status"],
|
||||||
type: 'all' as 'all' | EventListItemSchema['event_type'],
|
type: "all" as "all" | EventListItemSchema["event_type"],
|
||||||
sort: 'newest' as (typeof eventSortOptions)[number]['value'],
|
sort: "newest" as (typeof eventSortOptions)[number]["value"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const eventsQuery = useQuery({
|
const eventsQuery = useQuery({
|
||||||
queryKey: ['admin', 'events', filters],
|
queryKey: ["admin", "events", filters],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api.getEvents({
|
api.getEvents({
|
||||||
statuses:
|
statuses: filters.status === "all" ? undefined : [filters.status],
|
||||||
filters.status === 'all'
|
event_type: filters.type === "all" ? undefined : filters.type,
|
||||||
? undefined
|
|
||||||
: [filters.status as EventListItemSchema['status']],
|
|
||||||
event_type:
|
|
||||||
filters.type === 'all'
|
|
||||||
? undefined
|
|
||||||
: (filters.type as EventListItemSchema['event_type']),
|
|
||||||
search: filters.search || undefined,
|
search: filters.search || undefined,
|
||||||
limit: EVENTS_PAGE_SIZE,
|
limit: EVENTS_PAGE_SIZE,
|
||||||
}),
|
}),
|
||||||
@@ -73,39 +83,95 @@ const AdminEventsPage: React.FC = () => {
|
|||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (eventId: number) => api.deleteEvent(eventId),
|
mutationFn: (eventId: number) => api.deleteEvent(eventId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin', 'events'] });
|
queryClient.invalidateQueries({ queryKey: ["admin", "events"] });
|
||||||
toast({ title: 'رویداد حذف شد', variant: 'success' });
|
toast({ title: "رویداد حذف شد", variant: "success" });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast({
|
toast({ title: "خطا", description: resolveErrorMessage(error), variant: "destructive" });
|
||||||
title: 'خطا',
|
|
||||||
description: resolveErrorMessage(error),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const sortedEvents = React.useMemo(() => {
|
const sortedEvents = React.useMemo(() => {
|
||||||
const list = (eventsQuery.data ?? []).slice();
|
const list = (eventsQuery.data ?? []).slice();
|
||||||
switch (filters.sort) {
|
switch (filters.sort) {
|
||||||
case 'newest':
|
case "newest":
|
||||||
return list.sort((a, b) => new Date(b.start_time).getTime() - new Date(a.start_time).getTime());
|
return list.sort((a, b) => new Date(b.start_time).getTime() - new Date(a.start_time).getTime());
|
||||||
case 'oldest':
|
case "oldest":
|
||||||
return list.sort((a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime());
|
return list.sort((a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime());
|
||||||
case 'priceAsc':
|
case "priceAsc":
|
||||||
return list.sort((a, b) => Number(a.price) - Number(b.price));
|
return list.sort((a, b) => Number(a.price) - Number(b.price));
|
||||||
case 'priceDesc':
|
case "priceDesc":
|
||||||
return list.sort((a, b) => Number(b.price) - Number(a.price));
|
return list.sort((a, b) => Number(b.price) - Number(a.price));
|
||||||
default:
|
default:
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
}, [eventsQuery.data, filters.sort]);
|
}, [eventsQuery.data, filters.sort]);
|
||||||
|
|
||||||
|
const renderEventActions = (event: EventListItemSchema) => (
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button size="icon" variant="outline" onClick={() => navigate(`/admin/events/${event.id}`)} aria-label="جزئیات">
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>جزئیات</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button size="icon" variant="outline" asChild aria-label="ویرایش">
|
||||||
|
<Link to={`/admin/events/${event.id}/edit`}>
|
||||||
|
<Edit3 className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>ویرایش</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<AlertDialog>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button size="icon" variant="destructive" disabled={deleteMutation.isPending} aria-label="حذف">
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>حذف</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<AlertDialogContent dir="rtl">
|
||||||
|
<AlertDialogHeader className="text-right">
|
||||||
|
<AlertDialogTitle>حذف رویداد</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
آیا از حذف رویداد «{event.title}» مطمئن هستید؟ این عملیات رویداد را از لیستهای عادی حذف میکند.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter className="gap-2 sm:justify-start">
|
||||||
|
<AlertDialogCancel>انصراف</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
onClick={() => deleteMutation.mutate(event.id)}
|
||||||
|
>
|
||||||
|
حذف
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<h2 className="text-xl font-semibold">رویدادها</h2>
|
<div className="flex flex-col gap-1">
|
||||||
<p className="text-sm text-muted-foreground">مدیریت رویدادها، ثبتنامها و وضعیت انتشار</p>
|
<h2 className="text-xl font-semibold">رویدادها</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">مدیریت رویدادها، ثبتنامها و وضعیت انتشار</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild>
|
||||||
|
<Link to="/admin/events/create">
|
||||||
|
<Plus className="ml-2 h-4 w-4" />
|
||||||
|
افزودن رویداد
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
@@ -113,55 +179,23 @@ const AdminEventsPage: React.FC = () => {
|
|||||||
<CardTitle>فیلترها</CardTitle>
|
<CardTitle>فیلترها</CardTitle>
|
||||||
<CardDescription>پیدا کردن سریع رویدادها</CardDescription>
|
<CardDescription>پیدا کردن سریع رویدادها</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent>
|
||||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
<Input
|
<Input
|
||||||
placeholder="عنوان رویداد..."
|
placeholder="عنوان رویداد..."
|
||||||
value={filters.search}
|
value={filters.search}
|
||||||
onChange={(event) => setFilters((prev) => ({ ...prev, search: event.target.value }))}
|
onChange={(event) => setFilters((prev) => ({ ...prev, search: event.target.value }))}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select value={filters.status} onValueChange={(value) => setFilters((prev) => ({ ...prev, status: value as typeof filters.status }))}>
|
||||||
value={filters.status}
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
onValueChange={(value) =>
|
|
||||||
setFilters((prev) => ({
|
|
||||||
...prev,
|
|
||||||
status: value as 'all' | EventListItemSchema['status'],
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue>
|
|
||||||
{eventStatusOptions.find((option) => option.value === filters.status)?.label ||
|
|
||||||
'وضعیت'}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{eventStatusOptions.map((option) => (
|
{eventStatusOptions.map((option) => (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select
|
<Select value={filters.type} onValueChange={(value) => setFilters((prev) => ({ ...prev, type: value as typeof filters.type }))}>
|
||||||
value={filters.type}
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
onValueChange={(value) =>
|
|
||||||
setFilters((prev) => ({
|
|
||||||
...prev,
|
|
||||||
type: value as 'all' | EventListItemSchema['event_type'],
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue>
|
|
||||||
{{
|
|
||||||
all: 'همه انواع',
|
|
||||||
online: 'آنلاین',
|
|
||||||
on_site: 'حضوری',
|
|
||||||
hybrid: 'ترکیبی',
|
|
||||||
}[filters.type]}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">همه انواع</SelectItem>
|
<SelectItem value="all">همه انواع</SelectItem>
|
||||||
<SelectItem value="online">آنلاین</SelectItem>
|
<SelectItem value="online">آنلاین</SelectItem>
|
||||||
@@ -169,26 +203,11 @@ const AdminEventsPage: React.FC = () => {
|
|||||||
<SelectItem value="hybrid">ترکیبی</SelectItem>
|
<SelectItem value="hybrid">ترکیبی</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select
|
<Select value={filters.sort} onValueChange={(value) => setFilters((prev) => ({ ...prev, sort: value as typeof filters.sort }))}>
|
||||||
value={filters.sort}
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
onValueChange={(value) =>
|
|
||||||
setFilters((prev) => ({
|
|
||||||
...prev,
|
|
||||||
sort: value as (typeof eventSortOptions)[number]['value'],
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue>
|
|
||||||
{eventSortOptions.find((option) => option.value === filters.sort)?.label ||
|
|
||||||
'مرتبسازی'}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{eventSortOptions.map((option) => (
|
{eventSortOptions.map((option) => (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -207,97 +226,75 @@ const AdminEventsPage: React.FC = () => {
|
|||||||
) : sortedEvents.length === 0 ? (
|
) : sortedEvents.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">رویدادی یافت نشد.</p>
|
<p className="text-sm text-muted-foreground">رویدادی یافت نشد.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<>
|
||||||
<div className="hidden md:block">
|
<div className="hidden overflow-x-auto rounded-md border md:block">
|
||||||
<ScrollArea className="rounded-md border">
|
<table dir="rtl" className="w-full min-w-[860px] text-sm">
|
||||||
<table dir="rtl" className="w-full min-w-[780px] text-sm">
|
<thead className="text-xs uppercase text-muted-foreground">
|
||||||
<thead className="text-xs uppercase text-muted-foreground">
|
<tr>
|
||||||
<tr>
|
<th className="w-36 px-3 py-2 text-right">پوستر</th>
|
||||||
<th className="px-3 py-2 text-right">پوستر</th>
|
<th className="px-3 py-2 text-right">عنوان</th>
|
||||||
<th className="px-3 py-2 text-right">عنوان</th>
|
<th className="px-3 py-2 text-right">وضعیت</th>
|
||||||
<th className="px-3 py-2 text-right">وضعیت</th>
|
<th className="px-3 py-2 text-right">تاریخ شروع</th>
|
||||||
<th className="px-3 py-2 text-right">تاریخ شروع</th>
|
<th className="px-3 py-2 text-right">ثبتنامها</th>
|
||||||
<th className="px-3 py-2 text-right">ثبتنامها</th>
|
<th className="px-3 py-2 text-right">قیمت</th>
|
||||||
<th className="px-3 py-2 text-right">قیمت (تومان)</th>
|
<th className="px-3 py-2 text-right"></th>
|
||||||
<th className="px-3 py-2 text-right">عملیات</th>
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sortedEvents.map((event) => (
|
||||||
|
<tr key={event.id} className="border-b last:border-0 hover:bg-muted/50">
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<ProgressiveImage
|
||||||
|
src={getEventCardImageUrl(event)}
|
||||||
|
alt={event.title}
|
||||||
|
wrapperClassName="aspect-video w-28 overflow-hidden rounded-lg bg-muted"
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="cursor-pointer px-3 py-2 text-right font-medium" onClick={() => navigate(`/admin/events/${event.id}`)}>
|
||||||
|
{event.title}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
<Badge variant={statusConfig[event.status].variant}>{statusConfig[event.status].label}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right">{formatJalali(event.start_time)}</td>
|
||||||
|
<td className="px-3 py-2 text-right">{toPersianDigits(event.registration_count)}</td>
|
||||||
|
<td className="px-3 py-2 text-right">{priceLabel(event.price)}</td>
|
||||||
|
<td className="px-3 py-2 text-left">{renderEventActions(event)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
))}
|
||||||
<tbody>
|
</tbody>
|
||||||
{sortedEvents.map((event) => (
|
</table>
|
||||||
<tr key={event.id} className="border-b last:border-0 hover:bg-muted/50">
|
|
||||||
<td className="px-3 py-2 text-right">
|
|
||||||
<ProgressiveImage
|
|
||||||
src={getEventCardImageUrl(event)}
|
|
||||||
alt={event.title}
|
|
||||||
wrapperClassName="h-12 w-12 rounded"
|
|
||||||
className="h-12 w-12 rounded object-cover"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-right cursor-pointer" onClick={() => navigate(`/admin/events/${event.id}`)}>
|
|
||||||
{event.title}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-center">
|
|
||||||
<Badge variant={statusConfig[event.status].variant}>
|
|
||||||
{statusConfig[event.status].label}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-right">{formatJalali(event.start_time)}</td>
|
|
||||||
<td className="px-3 py-2 text-right">{toPersianDigits(event.registration_count)}</td>
|
|
||||||
<td className="px-3 py-2 text-right">{formatToman(event.price)}</td>
|
|
||||||
<td className="px-3 py-2 text-left flex items-center gap-1">
|
|
||||||
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/events/${event.id}`)}>
|
|
||||||
جزئیات
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="outline" asChild>
|
|
||||||
<Link to={`/admin/events/${event.id}/edit`}>ویرایش</Link>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => deleteMutation.mutate(event.id)}
|
|
||||||
>
|
|
||||||
حذف
|
|
||||||
</Button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 md:hidden">
|
<div className="grid gap-3 md:hidden">
|
||||||
{sortedEvents.map((event) => (
|
{sortedEvents.map((event) => (
|
||||||
<div key={event.id} className="rounded-lg border p-3 space-y-2 bg-card">
|
<div key={event.id} className="space-y-3 rounded-lg border bg-card p-3">
|
||||||
|
<ProgressiveImage
|
||||||
|
src={getEventCardImageUrl(event)}
|
||||||
|
alt={event.title}
|
||||||
|
wrapperClassName="aspect-video w-full overflow-hidden rounded-lg bg-muted"
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="font-semibold text-right">{event.title}</div>
|
<button className="text-right font-semibold" onClick={() => navigate(`/admin/events/${event.id}`)}>
|
||||||
|
{event.title}
|
||||||
|
</button>
|
||||||
<Badge variant={statusConfig[event.status].variant}>{statusConfig[event.status].label}</Badge>
|
<Badge variant={statusConfig[event.status].variant}>{statusConfig[event.status].label}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground text-right space-y-1">
|
<div className="space-y-1 text-right text-xs text-muted-foreground">
|
||||||
<div>تاریخ شروع: {formatJalali(event.start_time)}</div>
|
<div>تاریخ شروع: {formatJalali(event.start_time)}</div>
|
||||||
<div>ثبتنامها: {toPersianDigits(event.registration_count)}</div>
|
<div>ثبتنامها: {toPersianDigits(event.registration_count)}</div>
|
||||||
<div>قیمت: {formatToman(event.price)}</div>
|
<div>قیمت: {priceLabel(event.price)}</div>
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 justify-end">
|
|
||||||
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/events/${event.id}`)}>
|
|
||||||
جزئیات
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="outline" asChild>
|
|
||||||
<Link to={`/admin/events/${event.id}/edit`}>ویرایش</Link>
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="destructive" onClick={() => deleteMutation.mutate(event.id)}>
|
|
||||||
حذف
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
{renderEventActions(event)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default AdminEventsPage;
|
|
||||||
|
|||||||
@@ -1,53 +1,76 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
Building2,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
FileText,
|
FileText,
|
||||||
FolderTree,
|
FolderTree,
|
||||||
|
GraduationCap,
|
||||||
|
LayoutDashboard,
|
||||||
|
Menu,
|
||||||
PanelRightClose,
|
PanelRightClose,
|
||||||
PanelRightOpen,
|
PanelRightOpen,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Tags,
|
Tags,
|
||||||
|
TicketPercent,
|
||||||
UsersRound,
|
UsersRound,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Navigate, NavLink, useLocation } from "@/lib/router";
|
import { Navigate, NavLink, useLocation } from "@/lib/router";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Sheet, SheetClose, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const navItems = [
|
const navGroups = [
|
||||||
{ to: "/admin/users", label: "کاربران", icon: UsersRound, visibility: "staff" },
|
{
|
||||||
{ to: "/admin/events", label: "رویدادها", icon: CalendarDays, visibility: "staff" },
|
key: "dashboard",
|
||||||
{ to: "/admin/blog", label: "نوشتههای بلاگ", icon: FileText, visibility: "blog" },
|
label: "داشبورد",
|
||||||
{ to: "/admin/blog/categories", label: "دستهبندیها", icon: FolderTree, visibility: "taxonomy" },
|
items: [
|
||||||
{ to: "/admin/blog/tags", label: "برچسبها", icon: Tags, visibility: "taxonomy" },
|
{ to: "/admin/dashboard", label: "داشبورد", icon: LayoutDashboard, visibility: "staff" },
|
||||||
{ to: "/admin/authorizations", label: "دسترسیها", icon: ShieldCheck, visibility: "superuser" },
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "users",
|
||||||
|
label: "کاربران",
|
||||||
|
items: [
|
||||||
|
{ to: "/admin/users", label: "کاربران", icon: UsersRound, visibility: "staff" },
|
||||||
|
{ to: "/admin/universities", label: "دانشگاهها", icon: Building2, visibility: "staff" },
|
||||||
|
{ to: "/admin/majors", label: "رشتهها", icon: GraduationCap, visibility: "staff" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "events",
|
||||||
|
label: "رویدادها",
|
||||||
|
items: [
|
||||||
|
{ to: "/admin/events", label: "رویدادها", icon: CalendarDays, visibility: "staff" },
|
||||||
|
{ to: "/admin/coupons", label: "کدهای تخفیف", icon: TicketPercent, visibility: "staff" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "blog",
|
||||||
|
label: "بلاگ و دسترسی",
|
||||||
|
items: [
|
||||||
|
{ to: "/admin/blog", label: "نوشتههای بلاگ", icon: FileText, visibility: "blog" },
|
||||||
|
{ to: "/admin/blog/categories", label: "دستهبندیها", icon: FolderTree, visibility: "taxonomy" },
|
||||||
|
{ to: "/admin/blog/tags", label: "برچسبها", icon: Tags, visibility: "taxonomy" },
|
||||||
|
{ to: "/admin/authorizations", label: "دسترسیها", icon: ShieldCheck, visibility: "superuser" },
|
||||||
|
],
|
||||||
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
type NavItem = (typeof navGroups)[number]["items"][number];
|
||||||
|
|
||||||
export default function AdminLayout({ children }: { children: ReactNode }) {
|
export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { user, isAuthenticated, loading } = useAuth();
|
const { user, isAuthenticated, loading } = useAuth();
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
const canAccessAdmin = useMemo(
|
const canAccessAdmin = useMemo(
|
||||||
() => isAuthenticated && Boolean(user?.is_staff || user?.is_superuser || user?.can_access_blog_admin),
|
() => isAuthenticated && Boolean(user?.is_staff || user?.is_superuser || user?.can_access_blog_admin),
|
||||||
[isAuthenticated, user?.can_access_blog_admin, user?.is_staff, user?.is_superuser],
|
[isAuthenticated, user?.can_access_blog_admin, user?.is_staff, user?.is_superuser],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const saved = window.localStorage.getItem("admin-sidebar-collapsed");
|
|
||||||
if (saved) setCollapsed(saved === "true");
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleCollapsed = () => {
|
|
||||||
setCollapsed((current) => {
|
|
||||||
const next = !current;
|
|
||||||
window.localStorage.setItem("admin-sidebar-collapsed", String(next));
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center text-muted-foreground" dir="rtl">
|
<div className="min-h-screen flex items-center justify-center text-muted-foreground" dir="rtl">
|
||||||
@@ -60,18 +83,25 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
|
|||||||
return <Navigate to="/" replace />;
|
return <Navigate to="/" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleNavItems = navItems.filter((item) => {
|
const canSeeItem = (item: NavItem) => {
|
||||||
if (item.visibility === "staff") return Boolean(user?.is_staff || user?.is_superuser);
|
if (item.visibility === "staff") return Boolean(user?.is_staff || user?.is_superuser);
|
||||||
if (item.visibility === "taxonomy") return Boolean(user?.is_staff || user?.is_superuser || user?.can_review_blog_posts);
|
if (item.visibility === "taxonomy") return Boolean(user?.is_staff || user?.is_superuser || user?.can_review_blog_posts);
|
||||||
if (item.visibility === "superuser") return Boolean(user?.is_superuser);
|
if (item.visibility === "superuser") return Boolean(user?.is_superuser);
|
||||||
return Boolean(user?.is_staff || user?.is_superuser || user?.can_access_blog_admin);
|
return Boolean(user?.is_staff || user?.is_superuser || user?.can_access_blog_admin);
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const visibleGroups = navGroups
|
||||||
|
.map((group) => ({ ...group, items: group.items.filter(canSeeItem) }))
|
||||||
|
.filter((group) => group.items.length > 0);
|
||||||
|
|
||||||
const isItemActive = (to: string) => {
|
const isItemActive = (to: string) => {
|
||||||
if (location.pathname === to) return true;
|
if (location.pathname === to) return true;
|
||||||
if (to === "/admin/blog") {
|
if (to === "/admin/blog") {
|
||||||
return /^\/admin\/blog\/(new|\d+)/.test(location.pathname ?? "");
|
return /^\/admin\/blog\/(new|\d+)/.test(location.pathname ?? "");
|
||||||
}
|
}
|
||||||
|
if (to === "/admin/events") {
|
||||||
|
return /^\/admin\/events(\/(create|\d+))?/.test(location.pathname ?? "");
|
||||||
|
}
|
||||||
return Boolean(location.pathname?.startsWith(`${to}/`));
|
return Boolean(location.pathname?.startsWith(`${to}/`));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -80,89 +110,129 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
|
|||||||
<div className="flex min-h-screen">
|
<div className="flex min-h-screen">
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"sticky top-0 hidden h-screen shrink-0 border-l bg-background/95 shadow-sm backdrop-blur transition-[width] duration-300 ease-in-out lg:flex lg:flex-col",
|
"sticky top-0 hidden h-screen shrink-0 border-l bg-background/95 shadow-sm backdrop-blur transition-[width] duration-300 lg:flex lg:flex-col",
|
||||||
collapsed ? "w-20" : "w-72",
|
sidebarCollapsed ? "w-20" : "w-72",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2 border-b p-4">
|
<div className={cn("border-b p-4", sidebarCollapsed ? "text-center" : "text-right")}>
|
||||||
{!collapsed ? (
|
<div className={cn("flex items-center gap-2", sidebarCollapsed ? "justify-center" : "justify-start")}>
|
||||||
<div className="text-right">
|
<Button
|
||||||
<h1 className="text-lg font-bold">پنل مدیریت</h1>
|
type="button"
|
||||||
<p className="text-xs text-muted-foreground">انجمن علمی مهندسی کامپیوتر</p>
|
variant="ghost"
|
||||||
</div>
|
size="icon"
|
||||||
) : null}
|
className="h-9 w-9 shrink-0 rounded-2xl"
|
||||||
<Button variant="ghost" size="icon" onClick={toggleCollapsed} aria-label="باز و بسته کردن منوی مدیریت">
|
onClick={() => setSidebarCollapsed((value) => !value)}
|
||||||
{collapsed ? <PanelRightOpen className="h-5 w-5" /> : <PanelRightClose className="h-5 w-5" />}
|
aria-label={sidebarCollapsed ? "باز کردن منوی مدیریت" : "جمع کردن منوی مدیریت"}
|
||||||
</Button>
|
title={sidebarCollapsed ? "باز کردن منو" : "جمع کردن منو"}
|
||||||
|
>
|
||||||
|
{sidebarCollapsed ? <PanelRightOpen className="h-4 w-4" /> : <PanelRightClose className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
{!sidebarCollapsed ? (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="text-lg font-bold">پنل مدیریت</h1>
|
||||||
|
<p className="text-xs text-muted-foreground">انجمن علمی مهندسی کامپیوتر</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex-1 space-y-2 p-3">
|
<nav className="flex-1 space-y-3 p-3">
|
||||||
{visibleNavItems.map((item) => {
|
{visibleGroups.map((group) => (
|
||||||
const Icon = item.icon;
|
<div key={group.key} className="space-y-2">
|
||||||
const active = isItemActive(item.to);
|
<p
|
||||||
return (
|
|
||||||
<NavLink
|
|
||||||
key={item.to}
|
|
||||||
to={item.to}
|
|
||||||
title={collapsed ? item.label : undefined}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 rounded-2xl px-3 py-3 text-sm transition",
|
"px-3 py-2 text-xs font-semibold text-muted-foreground transition-opacity",
|
||||||
collapsed ? "justify-center" : "justify-start",
|
sidebarCollapsed && "sr-only",
|
||||||
active
|
|
||||||
? "bg-primary text-primary-foreground shadow"
|
|
||||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="h-5 w-5 shrink-0" />
|
{group.label}
|
||||||
{!collapsed ? <span className="font-medium">{item.label}</span> : null}
|
</p>
|
||||||
</NavLink>
|
{group.items.map((item) => {
|
||||||
);
|
const Icon = item.icon;
|
||||||
})}
|
const active = isItemActive(item.to);
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
title={sidebarCollapsed ? item.label : undefined}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center rounded-2xl px-3 py-3 text-sm transition",
|
||||||
|
sidebarCollapsed ? "justify-center" : "gap-3",
|
||||||
|
active
|
||||||
|
? "bg-primary text-primary-foreground shadow"
|
||||||
|
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5 shrink-0" />
|
||||||
|
<span className={cn("font-medium", sidebarCollapsed && "sr-only")}>{item.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="border-b bg-background/90 lg:hidden">
|
<div className="border-b bg-background/90 lg:hidden">
|
||||||
<div className="px-4 py-3 text-right">
|
<div className="flex items-center justify-between gap-3 px-4 py-3">
|
||||||
<h1 className="text-lg font-bold">پنل مدیریت</h1>
|
<div className="text-right">
|
||||||
<p className="text-xs text-muted-foreground">مدیریت بخشهای سامانه</p>
|
<h1 className="text-lg font-bold">پنل مدیریت</h1>
|
||||||
|
<p className="text-xs text-muted-foreground">مدیریت بخشهای سامانه</p>
|
||||||
|
</div>
|
||||||
|
<Sheet>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className="gap-2 rounded-2xl">
|
||||||
|
<Menu className="h-4 w-4" />
|
||||||
|
منو
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent
|
||||||
|
side="bottom"
|
||||||
|
className="max-h-[82vh] overflow-y-auto rounded-t-[2rem] border-t p-4 pb-[calc(env(safe-area-inset-bottom)+1rem)]"
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
<SheetHeader className="mt-6 text-right">
|
||||||
|
<SheetTitle>بخشهای پنل مدیریت</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<nav className="mt-5 space-y-5">
|
||||||
|
{visibleGroups.map((group) => (
|
||||||
|
<div key={group.key} className="space-y-2">
|
||||||
|
<p className="px-2 text-xs font-semibold text-muted-foreground">{group.label}</p>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{group.items.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const active = isItemActive(item.to);
|
||||||
|
return (
|
||||||
|
<SheetClose asChild key={item.to}>
|
||||||
|
<NavLink
|
||||||
|
to={item.to}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 rounded-2xl border px-3 py-3 text-sm transition",
|
||||||
|
active
|
||||||
|
? "border-primary bg-primary text-primary-foreground shadow"
|
||||||
|
: "bg-background text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||||
|
)}
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5 shrink-0" />
|
||||||
|
<span className="font-medium">{item.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
</SheetClose>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="container mx-auto min-w-0 px-3 pb-28 pt-4 sm:px-4 lg:py-6">
|
<div className="container mx-auto min-w-0 px-3 pb-8 pt-4 sm:px-4 lg:py-6">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
className="fixed inset-x-0 z-50 px-4 lg:hidden"
|
|
||||||
style={{ bottom: "calc(env(safe-area-inset-bottom) + 0.9rem)" }}
|
|
||||||
>
|
|
||||||
<nav
|
|
||||||
aria-label="Admin mobile navigation"
|
|
||||||
className="mx-auto flex w-full max-w-sm items-center justify-between rounded-[1.75rem] border border-white/20 bg-background/70 px-2 py-2 shadow-[0_18px_60px_rgba(15,23,42,0.18)] backdrop-blur-2xl dark:border-white/10 dark:bg-slate-950/65"
|
|
||||||
dir="rtl"
|
|
||||||
>
|
|
||||||
{visibleNavItems.map((item) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
const active = isItemActive(item.to);
|
|
||||||
return (
|
|
||||||
<NavLink
|
|
||||||
key={item.to}
|
|
||||||
to={item.to}
|
|
||||||
className={cn(
|
|
||||||
"flex min-w-0 flex-1 flex-col items-center justify-center gap-1 rounded-2xl px-2 py-2 text-[10px] font-medium transition-all",
|
|
||||||
active
|
|
||||||
? "bg-primary text-primary-foreground shadow-sm"
|
|
||||||
: "text-muted-foreground hover:bg-white/30 hover:text-foreground dark:hover:bg-white/10",
|
|
||||||
)}
|
|
||||||
aria-current={active ? "page" : undefined}
|
|
||||||
>
|
|
||||||
<Icon className={cn("h-5 w-5", active ? "scale-105" : "")} />
|
|
||||||
<span className="max-w-full truncate">{item.label}</span>
|
|
||||||
</NavLink>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
214
src/views/AdminMetaOptions.tsx
Normal file
214
src/views/AdminMetaOptions.tsx
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Edit3, Plus, Trash2 } from "lucide-react";
|
||||||
|
import ConfirmAction from "@/components/ConfirmAction";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import type { MetaOptionSchema, MetaOptionWriteSchema } from "@/lib/types";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { formatNumberPersian, resolveErrorMessage } from "@/lib/utils";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
type Kind = "majors" | "universities";
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
majors: {
|
||||||
|
title: "رشتهها",
|
||||||
|
description: "مدیریت رشتههای قابل انتخاب کاربران",
|
||||||
|
list: api.listAdminMajors.bind(api),
|
||||||
|
create: api.createMajor.bind(api),
|
||||||
|
update: api.updateMajor.bind(api),
|
||||||
|
delete: api.deleteMajor.bind(api),
|
||||||
|
},
|
||||||
|
universities: {
|
||||||
|
title: "دانشگاهها",
|
||||||
|
description: "مدیریت دانشگاههای قابل انتخاب کاربران",
|
||||||
|
list: api.listAdminUniversities.bind(api),
|
||||||
|
create: api.createUniversity.bind(api),
|
||||||
|
update: api.updateUniversity.bind(api),
|
||||||
|
delete: api.deleteUniversity.bind(api),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminMetaOptions({ kind }: { kind: Kind }) {
|
||||||
|
const spec = config[kind];
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [search, setSearch] = React.useState("");
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = React.useState("");
|
||||||
|
const [page, setPage] = React.useState(1);
|
||||||
|
const [editing, setEditing] = React.useState<MetaOptionSchema | null>(null);
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const [form, setForm] = React.useState<MetaOptionWriteSchema>({ code: "", name: "" });
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const timer = window.setTimeout(() => setDebouncedSearch(search.trim()), 300);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ["admin", kind, debouncedSearch, page],
|
||||||
|
queryFn: () => spec.list({ search: debouncedSearch || undefined, limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: () => (editing ? spec.update(editing.id, form) : spec.create(form)),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["admin", kind] });
|
||||||
|
setOpen(false);
|
||||||
|
toast({ title: "ذخیره شد", variant: "success" });
|
||||||
|
},
|
||||||
|
onError: (error) => toast({ title: "خطا", description: resolveErrorMessage(error), variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => spec.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["admin", kind] });
|
||||||
|
toast({ title: "حذف شد", variant: "success" });
|
||||||
|
},
|
||||||
|
onError: (error) => toast({ title: "خطا", description: resolveErrorMessage(error), variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditing(null);
|
||||||
|
setForm({ code: "", name: "" });
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (item: MetaOptionSchema) => {
|
||||||
|
setEditing(item);
|
||||||
|
setForm({ code: item.code, name: item.label });
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const items = query.data?.results ?? [];
|
||||||
|
const count = query.data?.count ?? 0;
|
||||||
|
const hasMore = page * PAGE_SIZE < count;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">{spec.title}</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">{spec.description}</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={openCreate}>
|
||||||
|
<Plus className="ml-2 h-4 w-4" />
|
||||||
|
افزودن
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>فهرست</CardTitle>
|
||||||
|
<CardDescription>جستجو، ویرایش و حذف نرم موارد</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => {
|
||||||
|
setSearch(event.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
placeholder="جستجو..."
|
||||||
|
className="max-w-md"
|
||||||
|
/>
|
||||||
|
<div className="overflow-x-auto rounded-2xl border">
|
||||||
|
<table className="w-full min-w-[520px] text-sm">
|
||||||
|
<thead className="bg-muted/40 text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-right">نام</th>
|
||||||
|
<th className="px-4 py-3 text-right">کد</th>
|
||||||
|
<th className="px-4 py-3 text-right">کاربران</th>
|
||||||
|
<th className="px-4 py-3 text-left"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{query.isLoading ? (
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-6 text-center text-muted-foreground" colSpan={4}>در حال بارگذاری...</td>
|
||||||
|
</tr>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-6 text-center text-muted-foreground" colSpan={4}>موردی یافت نشد.</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
items.map((item) => (
|
||||||
|
<tr key={item.id} className="border-t hover:bg-muted/40">
|
||||||
|
<td className="px-4 py-3 font-medium">{item.label}</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground">{item.code}</td>
|
||||||
|
<td className="px-4 py-3">{formatNumberPersian(item.user_count ?? 0)}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button size="icon" variant="outline" onClick={() => openEdit(item)} aria-label="ویرایش">
|
||||||
|
<Edit3 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<ConfirmAction
|
||||||
|
title="حذف مورد"
|
||||||
|
description={`آیا از حذف «${item.label}» مطمئن هستید؟ این عملیات رکورد را از لیستهای عادی حذف میکند.`}
|
||||||
|
onConfirm={() => deleteMutation.mutate(item.id)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
trigger={
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
aria-label="حذف"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span>صفحه {formatNumberPersian(page)} از {formatNumberPersian(Math.max(1, Math.ceil(count / PAGE_SIZE)))}</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" variant="outline" disabled={page === 1} onClick={() => setPage((current) => Math.max(1, current - 1))}>قبلی</Button>
|
||||||
|
<Button size="sm" variant="outline" disabled={!hasMore} onClick={() => setPage((current) => current + 1)}>بعدی</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent dir="rtl">
|
||||||
|
<DialogHeader className="text-right">
|
||||||
|
<DialogTitle>{editing ? "ویرایش" : "افزودن"} {spec.title}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>نام</Label>
|
||||||
|
<Input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>کد</Label>
|
||||||
|
<Input dir="ltr" value={form.code} onChange={(event) => setForm((current) => ({ ...current, code: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="gap-2 sm:justify-start">
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>انصراف</Button>
|
||||||
|
<Button disabled={saveMutation.isPending || !form.name.trim() || !form.code.trim()} onClick={() => saveMutation.mutate()}>
|
||||||
|
ذخیره
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,183 +1,238 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from "react";
|
||||||
import {
|
import { useQuery } from "@tanstack/react-query";
|
||||||
useQuery,
|
import { Mail, Phone, UserRound } from "lucide-react";
|
||||||
} from '@tanstack/react-query';
|
import AsyncSearchableCombobox from "@/components/AsyncSearchableCombobox";
|
||||||
import type { UserListSchema } from '@/lib/types';
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { api } from '@/lib/api';
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Button } from "@/components/ui/button";
|
||||||
import { Button } from '@/components/ui/button';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import {
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
Card,
|
import { Input } from "@/components/ui/input";
|
||||||
CardContent,
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
CardDescription,
|
import type { UserListSchema, UserProfileSchema } from "@/lib/types";
|
||||||
CardHeader,
|
import { api } from "@/lib/api";
|
||||||
CardTitle,
|
import { formatJalali, formatNumberPersian, resolveErrorMessage } from "@/lib/utils";
|
||||||
} from '@/components/ui/card';
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
||||||
import { useToast } from '@/hooks/use-toast';
|
|
||||||
import {
|
|
||||||
formatJalali,
|
|
||||||
formatNumberPersian,
|
|
||||||
resolveErrorMessage,
|
|
||||||
} from '@/lib/utils';
|
|
||||||
|
|
||||||
const USERS_PAGE_SIZE = 25;
|
const USERS_PAGE_SIZE = 25;
|
||||||
|
|
||||||
const AdminUsersPage: React.FC = () => {
|
function fullName(user: Pick<UserListSchema, "first_name" | "last_name" | "username">) {
|
||||||
|
return [user.first_name, user.last_name].filter(Boolean).join(" ") || user.username;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initials(user: Pick<UserListSchema, "first_name" | "last_name" | "username">) {
|
||||||
|
const base = [user.first_name, user.last_name].filter(Boolean).join(" ") || user.username;
|
||||||
|
return base.slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoRow({ label, value }: { label: string; value?: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-3 rounded-xl bg-muted/35 px-3 py-2 text-sm">
|
||||||
|
<span className="text-muted-foreground">{label}</span>
|
||||||
|
<span className="text-left font-medium">{value || "—"}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserDetailDialog({
|
||||||
|
user,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
user: UserProfileSchema | null;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
if (!user) return null;
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-h-[90vh] max-w-3xl overflow-y-auto" dir="rtl">
|
||||||
|
<DialogHeader className="text-right">
|
||||||
|
<DialogTitle>جزئیات کاربر</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex items-center gap-3 rounded-2xl border bg-muted/20 p-4">
|
||||||
|
<Avatar className="h-16 w-16">
|
||||||
|
<AvatarImage src={user.profile_picture_thumbnail_url || user.profile_picture || undefined} />
|
||||||
|
<AvatarFallback>{initials(user)}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="min-w-0 text-right">
|
||||||
|
<h3 className="truncate text-lg font-bold">{fullName(user)}</h3>
|
||||||
|
<p className="truncate text-sm text-muted-foreground">{user.username}</p>
|
||||||
|
</div>
|
||||||
|
<div className="mr-auto flex flex-wrap gap-2">
|
||||||
|
<Badge variant={user.is_active ? "default" : "outline"}>{user.is_active ? "فعال" : "غیرفعال"}</Badge>
|
||||||
|
{user.is_staff ? <Badge variant="secondary">Staff</Badge> : null}
|
||||||
|
{user.is_superuser ? <Badge variant="destructive">Superuser</Badge> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2 md:grid-cols-2">
|
||||||
|
<InfoRow label="نام" value={user.first_name} />
|
||||||
|
<InfoRow label="نام خانوادگی" value={user.last_name} />
|
||||||
|
<InfoRow label="موبایل" value={user.mobile} />
|
||||||
|
<InfoRow label="ایمیل" value={user.email} />
|
||||||
|
<InfoRow label="شماره دانشجویی" value={user.student_id} />
|
||||||
|
<InfoRow label="سال ورود" value={user.year_of_study ? formatNumberPersian(user.year_of_study) : null} />
|
||||||
|
<InfoRow label="دانشگاه" value={user.university} />
|
||||||
|
<InfoRow label="رشته" value={user.major} />
|
||||||
|
<InfoRow label="تاریخ عضویت" value={formatJalali(user.date_joined)} />
|
||||||
|
<InfoRow label="حذف شده" value={user.is_deleted ? "بله" : "خیر"} />
|
||||||
|
<InfoRow label="تایید موبایل" value={user.is_mobile_verified ? "بله" : "خیر"} />
|
||||||
|
<InfoRow label="تایید ایمیل" value={user.is_email_verified ? "بله" : "خیر"} />
|
||||||
|
<InfoRow label="دسترسی بلاگ" value={user.can_access_blog_admin ? "دارد" : "ندارد"} />
|
||||||
|
<InfoRow label="نوشتن بلاگ" value={user.can_write_blog_posts ? "دارد" : "ندارد"} />
|
||||||
|
<InfoRow label="بازبینی بلاگ" value={user.can_review_blog_posts ? "دارد" : "ندارد"} />
|
||||||
|
<InfoRow label="اتصال گوگل" value={user.has_google_link ? "دارد" : "ندارد"} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user.bio ? (
|
||||||
|
<div className="rounded-2xl border bg-background p-4 text-right text-sm leading-7">
|
||||||
|
<div className="mb-2 font-semibold">بیوگرافی</div>
|
||||||
|
{user.bio}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminUsersPage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [filters, setFilters] = React.useState({
|
const [filters, setFilters] = React.useState({
|
||||||
search: '',
|
search: "",
|
||||||
studentId: '',
|
studentId: "",
|
||||||
university: 'all',
|
university: null as string | null,
|
||||||
major: 'all',
|
major: null as string | null,
|
||||||
isActive: 'all',
|
isActive: "all",
|
||||||
});
|
});
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = React.useState("");
|
||||||
const [page, setPage] = React.useState(1);
|
const [page, setPage] = React.useState(1);
|
||||||
|
const [selectedUserId, setSelectedUserId] = React.useState<number | null>(null);
|
||||||
|
|
||||||
const majorsQuery = useQuery({
|
React.useEffect(() => {
|
||||||
queryKey: ['majors'],
|
const timer = window.setTimeout(() => setDebouncedSearch(filters.search.trim()), 300);
|
||||||
queryFn: () => api.getMajors(),
|
return () => window.clearTimeout(timer);
|
||||||
});
|
}, [filters.search]);
|
||||||
const universitiesQuery = useQuery({
|
|
||||||
queryKey: ['universities'],
|
|
||||||
queryFn: () => api.getUniversities(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const usersQuery = useQuery({
|
const usersQuery = useQuery({
|
||||||
queryKey: ['admin', 'users', filters, page],
|
queryKey: ["admin", "users", filters, debouncedSearch, page],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api.listUsers({
|
api.listUsers({
|
||||||
search: filters.search || undefined,
|
search: debouncedSearch || undefined,
|
||||||
student_id: filters.studentId || undefined,
|
student_id: filters.studentId || undefined,
|
||||||
university: filters.university === 'all' ? undefined : filters.university,
|
university: filters.university || undefined,
|
||||||
major: filters.major === 'all' ? undefined : filters.major,
|
major: filters.major || undefined,
|
||||||
is_active:
|
is_active:
|
||||||
filters.isActive === 'all'
|
filters.isActive === "all"
|
||||||
? undefined
|
? undefined
|
||||||
: filters.isActive === 'active'
|
: filters.isActive === "active"
|
||||||
? 'true'
|
? "true"
|
||||||
: 'false',
|
: "false",
|
||||||
limit: USERS_PAGE_SIZE,
|
limit: USERS_PAGE_SIZE,
|
||||||
offset: (page - 1) * USERS_PAGE_SIZE,
|
offset: (page - 1) * USERS_PAGE_SIZE,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const users = usersQuery.data ?? [];
|
const selectedUserQuery = useQuery({
|
||||||
const hasMore = users.length === USERS_PAGE_SIZE;
|
queryKey: ["admin", "users", selectedUserId, "detail"],
|
||||||
|
queryFn: () => api.getUserDetail(selectedUserId as number),
|
||||||
|
enabled: selectedUserId != null,
|
||||||
|
});
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (usersQuery.error) {
|
if (usersQuery.error) {
|
||||||
toast({
|
toast({
|
||||||
title: 'خطا در بارگذاری کاربران',
|
title: "خطا در بارگذاری کاربران",
|
||||||
description: resolveErrorMessage(usersQuery.error),
|
description: resolveErrorMessage(usersQuery.error),
|
||||||
variant: 'destructive',
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [usersQuery.error, toast]);
|
}, [usersQuery.error, toast]);
|
||||||
|
|
||||||
const handleFilterChange = (field: keyof typeof filters, value: string) => {
|
const users = usersQuery.data ?? [];
|
||||||
setFilters((prev) => ({ ...prev, [field]: value }));
|
const hasMore = users.length === USERS_PAGE_SIZE;
|
||||||
|
|
||||||
|
const handleFilterChange = (field: keyof typeof filters, value: string | null) => {
|
||||||
|
setFilters((prev) => ({ ...prev, [field]: value ?? "" }));
|
||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadMajors = React.useCallback(async (params: { search: string; limit: number; offset: number }) => {
|
||||||
|
const data = await api.getMajorsPaged(params);
|
||||||
|
return {
|
||||||
|
count: data.count,
|
||||||
|
results: data.results.map((item) => ({ value: item.code, label: item.label })),
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadUniversities = React.useCallback(async (params: { search: string; limit: number; offset: number }) => {
|
||||||
|
const data = await api.getUniversitiesPaged(params);
|
||||||
|
return {
|
||||||
|
count: data.count,
|
||||||
|
results: data.results.map((item) => ({ value: item.code, label: item.label })),
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold">کاربران</h2>
|
<h2 className="text-xl font-semibold">کاربران</h2>
|
||||||
<p className="text-sm text-muted-foreground mt-1">مدیریت و جستجوی کاربران سامانه</p>
|
<p className="mt-1 text-sm text-muted-foreground">مدیریت و جستجوی کاربران سامانه</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>فیلترها</CardTitle>
|
<CardTitle>فیلترها</CardTitle>
|
||||||
<CardDescription>جستجو و محدود کردن نتایج</CardDescription>
|
<CardDescription>جستجو با نام، ایمیل، موبایل، دانشگاه یا رشته</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
<Input
|
<Input
|
||||||
placeholder="نام، نامکاربری یا ایمیل..."
|
placeholder="نام، نامکاربری، ایمیل یا موبایل..."
|
||||||
value={filters.search}
|
value={filters.search}
|
||||||
onChange={(event) => handleFilterChange('search', event.target.value)}
|
onChange={(event) => handleFilterChange("search", event.target.value)}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
placeholder="شماره دانشجویی"
|
placeholder="شماره دانشجویی"
|
||||||
value={filters.studentId}
|
value={filters.studentId}
|
||||||
onChange={(event) => handleFilterChange('studentId', event.target.value)}
|
onChange={(event) => handleFilterChange("studentId", event.target.value)}
|
||||||
/>
|
/>
|
||||||
<Select value={filters.isActive} onValueChange={(value) => handleFilterChange('isActive', value)}>
|
<Select value={filters.isActive} onValueChange={(value) => handleFilterChange("isActive", value)}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="وضعیت">
|
<SelectValue />
|
||||||
{{
|
|
||||||
all: 'همه وضعیتها',
|
|
||||||
active: 'فعال',
|
|
||||||
inactive: 'غیرفعال',
|
|
||||||
}[filters.isActive]}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">همه</SelectItem>
|
<SelectItem value="all">همه وضعیتها</SelectItem>
|
||||||
<SelectItem value="active">فعال</SelectItem>
|
<SelectItem value="active">فعال</SelectItem>
|
||||||
<SelectItem value="inactive">غیرفعال</SelectItem>
|
<SelectItem value="inactive">غیرفعال</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
<AsyncSearchableCombobox
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
|
||||||
<Select
|
|
||||||
value={filters.university}
|
value={filters.university}
|
||||||
onValueChange={(value) => handleFilterChange('university', value)}
|
onChange={(value) => handleFilterChange("university", value)}
|
||||||
>
|
loadOptions={loadUniversities}
|
||||||
<SelectTrigger>
|
placeholder="دانشگاه"
|
||||||
<SelectValue placeholder="دانشگاه">
|
/>
|
||||||
{filters.university === 'all'
|
</div>
|
||||||
? 'همه'
|
<div className="max-w-xl">
|
||||||
: universitiesQuery.data?.find((item) => item.code === filters.university)?.label}
|
<AsyncSearchableCombobox
|
||||||
</SelectValue>
|
value={filters.major}
|
||||||
</SelectTrigger>
|
onChange={(value) => handleFilterChange("major", value)}
|
||||||
<SelectContent>
|
loadOptions={loadMajors}
|
||||||
<SelectItem value="all">همه</SelectItem>
|
placeholder="رشته"
|
||||||
{universitiesQuery.data?.map((item) => (
|
/>
|
||||||
<SelectItem key={item.code} value={item.code}>
|
|
||||||
{item.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Select value={filters.major} onValueChange={(value) => handleFilterChange('major', value)}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="رشته">
|
|
||||||
{filters.major === 'all'
|
|
||||||
? 'همه'
|
|
||||||
: majorsQuery.data?.find((item) => item.code === filters.major)?.label}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">همه</SelectItem>
|
|
||||||
{majorsQuery.data?.map((item) => (
|
|
||||||
<SelectItem key={item.code} value={item.code}>
|
|
||||||
{item.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-0 md:pb-2">
|
<CardHeader>
|
||||||
<CardTitle>لیست کاربران</CardTitle>
|
<CardTitle>لیست کاربران</CardTitle>
|
||||||
<CardDescription>نمایش کاربران مطابق فیلترهای انتخابی</CardDescription>
|
<CardDescription>برای مشاهده جزئیات، روی هر ردیف کلیک کنید.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{usersQuery.isLoading ? (
|
{usersQuery.isLoading ? (
|
||||||
@@ -185,91 +240,86 @@ const AdminUsersPage: React.FC = () => {
|
|||||||
) : users.length === 0 ? (
|
) : users.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">کاربری یافت نشد.</p>
|
<p className="text-sm text-muted-foreground">کاربری یافت نشد.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="overflow-x-auto rounded-2xl border">
|
||||||
<ScrollArea className="rounded-md border hidden md:block">
|
<table dir="rtl" className="w-full min-w-[620px] text-sm">
|
||||||
<table dir="rtl" className="w-full min-w-[700px] text-sm">
|
<thead className="bg-muted/40 text-muted-foreground">
|
||||||
<thead className="text-xs uppercase text-muted-foreground">
|
<tr>
|
||||||
<tr>
|
<th className="px-4 py-3 text-right">کاربر</th>
|
||||||
<th className="px-3 py-2 text-right">نام کامل</th>
|
<th className="px-4 py-3 text-right">موبایل</th>
|
||||||
<th className="px-3 py-2 text-right">نام کاربری</th>
|
<th className="px-4 py-3 text-right">ایمیل</th>
|
||||||
<th className="px-3 py-2 text-right">ایمیل</th>
|
</tr>
|
||||||
<th className="px-3 py-2 text-right">دانشگاه / گرایش</th>
|
</thead>
|
||||||
<th className="px-3 py-2 text-right">وضعیت</th>
|
<tbody>
|
||||||
<th className="px-3 py-2 text-right">تاریخ عضویت</th>
|
{users.map((user) => (
|
||||||
|
<tr
|
||||||
|
key={user.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className="cursor-pointer border-t transition hover:bg-muted/50"
|
||||||
|
onClick={() => setSelectedUserId(user.id)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") setSelectedUserId(user.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar className="h-11 w-11">
|
||||||
|
<AvatarImage src={user.profile_picture_thumbnail_url || user.profile_picture || undefined} />
|
||||||
|
<AvatarFallback>{initials(user)}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="min-w-0 text-right">
|
||||||
|
<div className="truncate font-semibold">{fullName(user)}</div>
|
||||||
|
<div className="truncate text-xs text-muted-foreground">{user.username}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<Phone className="h-4 w-4 text-muted-foreground" />
|
||||||
|
{user.mobile || "—"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||||
|
{user.email || "—"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
))}
|
||||||
<tbody>
|
</tbody>
|
||||||
{users.map((user) => (
|
</table>
|
||||||
<tr key={user.id} className="border-b last:border-0 hover:bg-muted/50">
|
|
||||||
<td className="px-3 py-2 text-right">
|
|
||||||
{(() => {
|
|
||||||
const parts = [user.first_name, user.last_name].filter(Boolean);
|
|
||||||
if (parts.length) return parts.join(' ');
|
|
||||||
return user.username;
|
|
||||||
})()}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-right">{user.username}</td>
|
|
||||||
<td className="px-3 py-2 text-right">{user.email}</td>
|
|
||||||
<td className="px-3 py-2 text-right">
|
|
||||||
{user.major || '—'} · {user.university || '—'}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-right">
|
|
||||||
<Badge variant={user.is_active ? 'default' : 'outline'}>
|
|
||||||
{user.is_active ? 'فعال' : 'غیرفعال'}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-right">
|
|
||||||
{formatJalali(user.date_joined)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</ScrollArea>
|
|
||||||
|
|
||||||
<div className="grid gap-3 md:hidden">
|
|
||||||
{users.map((user) => (
|
|
||||||
<div key={user.id} className="rounded-lg border p-3 space-y-2 bg-card">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<div className="font-semibold text-right">{user.first_name || user.last_name ? `${user.first_name || ''} ${user.last_name || ''}`.trim() : user.username}</div>
|
|
||||||
<Badge variant={user.is_active ? 'default' : 'outline'}>{user.is_active ? 'فعال' : 'غیرفعال'}</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground text-right space-y-1">
|
|
||||||
<div>نام کاربری: {user.username}</div>
|
|
||||||
<div>ایمیل: {user.email}</div>
|
|
||||||
<div>دانشگاه / گرایش: {user.university || '—'} · {user.major || '—'}</div>
|
|
||||||
<div>تاریخ عضویت: {formatJalali(user.date_joined)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
<span>صفحه {formatNumberPersian(page)}</span>
|
<span>صفحه {formatNumberPersian(page)}</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button size="sm" variant="outline" disabled={page === 1} onClick={() => setPage((prev) => Math.max(1, prev - 1))}>
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
disabled={page === 1}
|
|
||||||
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
|
|
||||||
>
|
|
||||||
قبلی
|
قبلی
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button size="sm" variant="outline" disabled={!hasMore} onClick={() => setPage((prev) => prev + 1)}>
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
disabled={!hasMore}
|
|
||||||
onClick={() => setPage((prev) => prev + 1)}
|
|
||||||
>
|
|
||||||
بعدی
|
بعدی
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<UserDetailDialog
|
||||||
|
user={selectedUserQuery.data ?? null}
|
||||||
|
open={selectedUserId != null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setSelectedUserId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{selectedUserQuery.isFetching && selectedUserId ? (
|
||||||
|
<div className="fixed inset-x-0 bottom-24 z-50 mx-auto flex w-fit items-center gap-2 rounded-full border bg-background px-4 py-2 text-sm shadow-lg">
|
||||||
|
<UserRound className="h-4 w-4 animate-pulse" />
|
||||||
|
در حال بارگذاری جزئیات...
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default AdminUsersPage;
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
@@ -10,7 +9,7 @@ import {
|
|||||||
MessageSquareMore,
|
MessageSquareMore,
|
||||||
Smartphone,
|
Smartphone,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import SearchableCombobox from "@/components/SearchableCombobox";
|
import AsyncSearchableCombobox from "@/components/AsyncSearchableCombobox";
|
||||||
import OtpCodeField from "@/components/OtpCodeField";
|
import OtpCodeField from "@/components/OtpCodeField";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
@@ -96,25 +95,23 @@ export default function Auth() {
|
|||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const { data: majors = [], isLoading: majorsLoading } = useQuery({
|
const loadMajors = useCallback(async (params: { search: string; limit: number; offset: number }) => {
|
||||||
queryKey: ["majors"],
|
const data = await api.getMajorsPaged(params);
|
||||||
queryFn: () => api.getMajors(),
|
return {
|
||||||
staleTime: 7 * 24 * 60 * 60 * 1000,
|
count: data.count,
|
||||||
});
|
results: data.results.map((major) => ({ value: String(major.code), label: major.label })),
|
||||||
const { data: universities = [], isLoading: universitiesLoading } = useQuery({
|
};
|
||||||
queryKey: ["universities"],
|
}, []);
|
||||||
queryFn: () => api.getUniversities(),
|
|
||||||
staleTime: 7 * 24 * 60 * 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const majorItems = useMemo(
|
const loadUniversities = useCallback(async (params: { search: string; limit: number; offset: number }) => {
|
||||||
() => majors.map((major) => ({ value: String(major.code), label: major.label })),
|
const data = await api.getUniversitiesPaged(params);
|
||||||
[majors],
|
return {
|
||||||
);
|
count: data.count,
|
||||||
const universityItems = useMemo(
|
results: data.results.map((university) => ({ value: String(university.code), label: university.label })),
|
||||||
() => universities.map((university) => ({ value: String(university.code), label: university.label })),
|
};
|
||||||
[universities],
|
}, []);
|
||||||
);
|
const majorsLoading = false;
|
||||||
|
const universitiesLoading = false;
|
||||||
|
|
||||||
const stepMeta = useMemo(() => {
|
const stepMeta = useMemo(() => {
|
||||||
switch (step) {
|
switch (step) {
|
||||||
@@ -666,14 +663,14 @@ export default function Auth() {
|
|||||||
{universitiesLoading ? (
|
{universitiesLoading ? (
|
||||||
<div className="h-12 animate-pulse rounded-2xl bg-muted" />
|
<div className="h-12 animate-pulse rounded-2xl bg-muted" />
|
||||||
) : (
|
) : (
|
||||||
<SearchableCombobox
|
<AsyncSearchableCombobox
|
||||||
items={universityItems}
|
loadOptions={loadUniversities}
|
||||||
value={registerForm.university}
|
value={registerForm.university}
|
||||||
onChange={(value) => updateRegisterForm("university", value)}
|
onChange={(value) => updateRegisterForm("university", value)}
|
||||||
placeholder="انتخاب دانشگاه"
|
placeholder="انتخاب دانشگاه"
|
||||||
searchPlaceholder="نام دانشگاه را بنویسید..."
|
searchPlaceholder="نام دانشگاه را بنویسید..."
|
||||||
emptyText="دانشگاهی پیدا نشد"
|
emptyText="دانشگاهی پیدا نشد"
|
||||||
dir="rtl"
|
className="h-12 rounded-2xl"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -684,14 +681,14 @@ export default function Auth() {
|
|||||||
{majorsLoading ? (
|
{majorsLoading ? (
|
||||||
<div className="h-12 animate-pulse rounded-2xl bg-muted" />
|
<div className="h-12 animate-pulse rounded-2xl bg-muted" />
|
||||||
) : (
|
) : (
|
||||||
<SearchableCombobox
|
<AsyncSearchableCombobox
|
||||||
items={majorItems}
|
loadOptions={loadMajors}
|
||||||
value={registerForm.major}
|
value={registerForm.major}
|
||||||
onChange={(value) => updateRegisterForm("major", value)}
|
onChange={(value) => updateRegisterForm("major", value)}
|
||||||
placeholder="انتخاب رشته"
|
placeholder="انتخاب رشته"
|
||||||
searchPlaceholder="نام رشته را بنویسید..."
|
searchPlaceholder="نام رشته را بنویسید..."
|
||||||
emptyText="رشتهای پیدا نشد"
|
emptyText="رشتهای پیدا نشد"
|
||||||
dir="rtl"
|
className="h-12 rounded-2xl"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { AlertTriangle, ArrowLeft, CheckCircle2, Loader2 } from "lucide-react";
|
import { AlertTriangle, ArrowLeft, CheckCircle2, Loader2 } from "lucide-react";
|
||||||
import SearchableCombobox from "@/components/SearchableCombobox";
|
import AsyncSearchableCombobox from "@/components/AsyncSearchableCombobox";
|
||||||
import OtpCodeField from "@/components/OtpCodeField";
|
import OtpCodeField from "@/components/OtpCodeField";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
@@ -47,27 +46,21 @@ export default function GoogleAuthCallback() {
|
|||||||
});
|
});
|
||||||
const [claimCode, setClaimCode] = useState("");
|
const [claimCode, setClaimCode] = useState("");
|
||||||
|
|
||||||
const { data: majors = [] } = useQuery({
|
const loadMajors = useCallback(async (params: { search: string; limit: number; offset: number }) => {
|
||||||
queryKey: ["majors"],
|
const data = await api.getMajorsPaged(params);
|
||||||
queryFn: () => api.getMajors(),
|
return {
|
||||||
staleTime: 7 * 24 * 60 * 60 * 1000,
|
count: data.count,
|
||||||
enabled: step === "collect_profile",
|
results: data.results.map((major) => ({ value: String(major.code), label: major.label })),
|
||||||
});
|
};
|
||||||
const { data: universities = [] } = useQuery({
|
}, []);
|
||||||
queryKey: ["universities"],
|
|
||||||
queryFn: () => api.getUniversities(),
|
|
||||||
staleTime: 7 * 24 * 60 * 60 * 1000,
|
|
||||||
enabled: step === "collect_profile",
|
|
||||||
});
|
|
||||||
|
|
||||||
const majorItems = useMemo(
|
const loadUniversities = useCallback(async (params: { search: string; limit: number; offset: number }) => {
|
||||||
() => majors.map((major) => ({ value: String(major.code), label: major.label })),
|
const data = await api.getUniversitiesPaged(params);
|
||||||
[majors],
|
return {
|
||||||
);
|
count: data.count,
|
||||||
const universityItems = useMemo(
|
results: data.results.map((university) => ({ value: String(university.code), label: university.label })),
|
||||||
() => universities.map((university) => ({ value: String(university.code), label: university.label })),
|
};
|
||||||
[universities],
|
}, []);
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (otpCooldown <= 0) {
|
if (otpCooldown <= 0) {
|
||||||
@@ -323,26 +316,26 @@ export default function GoogleAuthCallback() {
|
|||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-2 block text-right">دانشگاه</Label>
|
<Label className="mb-2 block text-right">دانشگاه</Label>
|
||||||
<SearchableCombobox
|
<AsyncSearchableCombobox
|
||||||
items={universityItems}
|
loadOptions={loadUniversities}
|
||||||
value={profileForm.university}
|
value={profileForm.university}
|
||||||
onChange={(value) => setProfileForm((current) => ({ ...current, university: value }))}
|
onChange={(value) => setProfileForm((current) => ({ ...current, university: value }))}
|
||||||
placeholder="انتخاب دانشگاه"
|
placeholder="انتخاب دانشگاه"
|
||||||
searchPlaceholder="نام دانشگاه را بنویسید..."
|
searchPlaceholder="نام دانشگاه را بنویسید..."
|
||||||
emptyText="دانشگاهی پیدا نشد"
|
emptyText="دانشگاهی پیدا نشد"
|
||||||
dir="rtl"
|
className="h-12 rounded-2xl"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-2 block text-right">رشته تحصیلی</Label>
|
<Label className="mb-2 block text-right">رشته تحصیلی</Label>
|
||||||
<SearchableCombobox
|
<AsyncSearchableCombobox
|
||||||
items={majorItems}
|
loadOptions={loadMajors}
|
||||||
value={profileForm.major}
|
value={profileForm.major}
|
||||||
onChange={(value) => setProfileForm((current) => ({ ...current, major: value }))}
|
onChange={(value) => setProfileForm((current) => ({ ...current, major: value }))}
|
||||||
placeholder="انتخاب رشته"
|
placeholder="انتخاب رشته"
|
||||||
searchPlaceholder="نام رشته را بنویسید..."
|
searchPlaceholder="نام رشته را بنویسید..."
|
||||||
emptyText="رشتهای پیدا نشد"
|
emptyText="رشتهای پیدا نشد"
|
||||||
dir="rtl"
|
className="h-12 rounded-2xl"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ import {
|
|||||||
UserRound,
|
UserRound,
|
||||||
XCircle,
|
XCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import AsyncSearchableCombobox from "@/components/AsyncSearchableCombobox";
|
||||||
import BlogThumbnail from "@/components/BlogThumbnail";
|
import BlogThumbnail from "@/components/BlogThumbnail";
|
||||||
|
import ConfirmAction from "@/components/ConfirmAction";
|
||||||
import Markdown from "@/components/Markdown";
|
import Markdown from "@/components/Markdown";
|
||||||
import { Helmet } from "@/lib/helmet";
|
import { Helmet } from "@/lib/helmet";
|
||||||
import { Link, Navigate } from "@/lib/router";
|
import { Link, Navigate } from "@/lib/router";
|
||||||
@@ -30,6 +32,7 @@ import {
|
|||||||
formatJalali,
|
formatJalali,
|
||||||
formatNumberPersian,
|
formatNumberPersian,
|
||||||
getBlogCardImageUrl,
|
getBlogCardImageUrl,
|
||||||
|
getEventCardImageUrl,
|
||||||
resolveErrorMessage,
|
resolveErrorMessage,
|
||||||
toPersianDigits,
|
toPersianDigits,
|
||||||
} from "@/lib/utils";
|
} from "@/lib/utils";
|
||||||
@@ -39,7 +42,6 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
type EventTab = "confirmed" | "pending" | "cancelled";
|
type EventTab = "confirmed" | "pending" | "cancelled";
|
||||||
@@ -146,6 +148,22 @@ export default function Profile() {
|
|||||||
staleTime: 7 * 24 * 60 * 60 * 1000,
|
staleTime: 7 * 24 * 60 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const loadMajors = useCallback(async (params: { search: string; limit: number; offset: number }) => {
|
||||||
|
const data = await api.getMajorsPaged(params);
|
||||||
|
return {
|
||||||
|
count: data.count,
|
||||||
|
results: data.results.map((major) => ({ value: String(major.code), label: major.label })),
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadUniversities = useCallback(async (params: { search: string; limit: number; offset: number }) => {
|
||||||
|
const data = await api.getUniversitiesPaged(params);
|
||||||
|
return {
|
||||||
|
count: data.count,
|
||||||
|
results: data.results.map((university) => ({ value: String(university.code), label: university.label })),
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: blogActivity,
|
data: blogActivity,
|
||||||
isLoading: blogActivityLoading,
|
isLoading: blogActivityLoading,
|
||||||
@@ -378,26 +396,32 @@ export default function Profile() {
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{me?.profile_picture ? (
|
{me?.profile_picture ? (
|
||||||
<span
|
<ConfirmAction
|
||||||
|
title="حذف تصویر پروفایل"
|
||||||
|
description="آیا از حذف تصویر پروفایل خود مطمئن هستید؟"
|
||||||
|
onConfirm={onDeletePicture}
|
||||||
|
disabled={uploading}
|
||||||
|
trigger={
|
||||||
|
<span
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
void onDeletePicture();
|
|
||||||
}}
|
}}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
void onDeletePicture();
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="absolute bottom-1 right-1 flex h-9 w-9 items-center justify-center rounded-full bg-destructive text-destructive-foreground shadow-lg"
|
className="absolute bottom-1 right-1 flex h-9 w-9 items-center justify-center rounded-full bg-destructive text-destructive-foreground shadow-lg"
|
||||||
aria-label="حذف تصویر پروفایل"
|
aria-label="حذف تصویر پروفایل"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -405,19 +429,30 @@ export default function Profile() {
|
|||||||
const renderRegistrationRow = (registration: Types.MyEventRegistrationSchema) => {
|
const renderRegistrationRow = (registration: Types.MyEventRegistrationSchema) => {
|
||||||
const eventData = registration.event as Types.EventListItemSchema & { start_date?: string };
|
const eventData = registration.event as Types.EventListItemSchema & { start_date?: string };
|
||||||
const rawDate = eventData.start_date ?? eventData.start_time;
|
const rawDate = eventData.start_date ?? eventData.start_time;
|
||||||
|
const eventImage = getEventCardImageUrl(eventData);
|
||||||
|
const eventImageUrl = eventImage === "/placeholder.svg" ? eventImage : toAbsoluteUrl(eventImage, apiBaseUrl);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={registration.id} className="rounded-2xl border border-border/70 bg-background/80 p-4">
|
<div key={registration.id} className="rounded-2xl border border-border/70 bg-background/80 p-4 text-right">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row-reverse sm:items-center sm:justify-between">
|
<div className="flex flex-row gap-4">
|
||||||
<div className="text-right">
|
<Link to={`/events/${registration.event.slug}`} className="block w-24 shrink-0 overflow-hidden rounded-2xl sm:w-28">
|
||||||
<p className="font-medium">{registration.event.title}</p>
|
<img
|
||||||
<div className="mt-1 flex flex-wrap items-center justify-end gap-2 text-xs text-muted-foreground">
|
src={eventImageUrl}
|
||||||
|
alt={registration.event.title}
|
||||||
|
className="aspect-video h-full w-full rounded-2xl object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<Link to={`/events/${registration.event.slug}`} className="font-medium text-primary hover:underline">
|
||||||
|
{registration.event.title}
|
||||||
|
</Link>
|
||||||
|
<p className="mt-2 line-clamp-2 text-sm text-muted-foreground">{registration.event.description || "بدون توضیح"}</p>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center justify-end gap-2 text-xs text-muted-foreground">
|
||||||
<span>{statusLabels[registration.status] ?? registration.status}</span>
|
<span>{statusLabels[registration.status] ?? registration.status}</span>
|
||||||
{rawDate ? <span>• {formatJalali(rawDate)}</span> : null}
|
{rawDate ? <span>• {formatJalali(rawDate)}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" asChild>
|
|
||||||
<Link to={`/events/${registration.event.slug}`}>مشاهده رویداد</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -430,7 +465,7 @@ export default function Profile() {
|
|||||||
<BlogThumbnail
|
<BlogThumbnail
|
||||||
post={post}
|
post={post}
|
||||||
imageUrl={toAbsoluteUrl(getBlogCardImageUrl(post), apiBaseUrl)}
|
imageUrl={toAbsoluteUrl(getBlogCardImageUrl(post), apiBaseUrl)}
|
||||||
className="aspect-square rounded-2xl"
|
className="aspect-video rounded-2xl"
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -504,21 +539,21 @@ export default function Profile() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="university" className="mb-2 block text-right">دانشگاه</Label>
|
<Label htmlFor="university" className="mb-2 block text-right">دانشگاه</Label>
|
||||||
<Select value={formData.university || ""} onValueChange={(value) => setFormData((prev) => ({ ...prev, university: value }))}>
|
<AsyncSearchableCombobox
|
||||||
<SelectTrigger id="university"><SelectValue placeholder="انتخاب دانشگاه" /></SelectTrigger>
|
value={formData.university || null}
|
||||||
<SelectContent>
|
onChange={(value) => setFormData((prev) => ({ ...prev, university: value }))}
|
||||||
{universities?.map((item) => <SelectItem key={item.code} value={item.code}>{item.label}</SelectItem>)}
|
loadOptions={loadUniversities}
|
||||||
</SelectContent>
|
placeholder="انتخاب دانشگاه"
|
||||||
</Select>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="major" className="mb-2 block text-right">رشته</Label>
|
<Label htmlFor="major" className="mb-2 block text-right">رشته</Label>
|
||||||
<Select value={formData.major || ""} onValueChange={(value) => setFormData((prev) => ({ ...prev, major: value }))}>
|
<AsyncSearchableCombobox
|
||||||
<SelectTrigger id="major"><SelectValue placeholder="انتخاب رشته" /></SelectTrigger>
|
value={formData.major || null}
|
||||||
<SelectContent>
|
onChange={(value) => setFormData((prev) => ({ ...prev, major: value }))}
|
||||||
{majors?.map((item) => <SelectItem key={item.code} value={item.code}>{item.label}</SelectItem>)}
|
loadOptions={loadMajors}
|
||||||
</SelectContent>
|
placeholder="انتخاب رشته"
|
||||||
</Select>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="student_id" className="mb-2 block text-right">شماره دانشجویی</Label>
|
<Label htmlFor="student_id" className="mb-2 block text-right">شماره دانشجویی</Label>
|
||||||
|
|||||||
Reference in New Issue
Block a user