first commit
This commit is contained in:
commit
16cd65c4da
|
|
@ -0,0 +1,42 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
.idea
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
# ---------- 1?? Dependencies ----------
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
# ---------- 2?? Build ----------
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# ---------- 3?? Production ----------
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# ??? ???????? ????
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "start"]
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
networks:
|
||||
houshan-network:
|
||||
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
container_name: nextjs_app
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
networks:
|
||||
- houshan-network
|
||||
restart: unless-stopped
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "api.houshan.ai",
|
||||
pathname: "**",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"name": "houshan-panel",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/nextjs-registry": "^1.0.2",
|
||||
"@tanstack/react-query": "^5.64.2",
|
||||
"antd": "^5.23.0",
|
||||
"apexcharts": "^4.3.0",
|
||||
"axios": "^1.7.9",
|
||||
"iconsax-react": "^0.0.8",
|
||||
"next": "15.1.4",
|
||||
"react": "^19.0.0",
|
||||
"react-apexcharts": "^1.7.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwind-merge": "^3.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.1.4",
|
||||
"postcss": "^8",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.9",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 11V17L11 15" stroke="#6f8192" stroke-width="1.5" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
<path d="M9 17L7 15" stroke="#6f8192" stroke-width="1.5" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
<path d="M22 10V15C22 20 20 22 15 22H9C4 22 2 20 2 15V9C2 4 4 2 9 2H14" stroke="#6f8192"
|
||||
stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M22 10H18C15 10 14 9 14 6V2L22 10Z" stroke="#6f8192" stroke-width="1.5"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 657 B |
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
|
|
@ -0,0 +1,309 @@
|
|||
"use client";
|
||||
|
||||
import type { AssistantDetail } from "@/types/assistant";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { API_ROUTES, request } from "@/services/api";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import TextArea from "antd/es/input/TextArea";
|
||||
import {
|
||||
Avatar,
|
||||
Breadcrumb,
|
||||
Button,
|
||||
GetProp,
|
||||
Image,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Spin,
|
||||
Switch,
|
||||
Upload,
|
||||
UploadProps,
|
||||
Select, // <--- اضافه شده
|
||||
message as antdMessage,
|
||||
} from "antd";
|
||||
import TokenService from "@/services/token";
|
||||
|
||||
type FileType = Parameters<GetProp<UploadProps, "beforeUpload">>[0];
|
||||
|
||||
const getBase64 = (img: FileType, callback: (url: string) => void) => {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", () => callback(reader.result as string));
|
||||
reader.readAsDataURL(img);
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { id } = useParams();
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [imageUrl, setImageUrl] = useState<string>();
|
||||
const [cost, setCost] = useState<number | null>(null);
|
||||
const [allowedLevels, setAllowedLevels] = useState<number[]>([1]);
|
||||
const [messageApi, contextHolder] = antdMessage.useMessage();
|
||||
|
||||
const fetchData = async () => {
|
||||
const res = await request.get(API_ROUTES.ASSISTANT.ONE(id as string));
|
||||
const assistant = res.data.assistant as AssistantDetail;
|
||||
|
||||
setCost(res.data.assistant.cost);
|
||||
setAllowedLevels(res.data.assistant.allowed_levels ?? [1]);
|
||||
if (assistant.image)
|
||||
setImageUrl(process.env.NEXT_PUBLIC_API_BASE_URL + assistant.image);
|
||||
|
||||
return assistant;
|
||||
};
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["assistant", id],
|
||||
queryFn: fetchData,
|
||||
});
|
||||
|
||||
const confirmBot = async () => {
|
||||
const res = await request.put(API_ROUTES.ASSISTANT.CONFIRM(id as string), {
|
||||
cost: cost ?? 0,
|
||||
allowed_levels: allowedLevels,
|
||||
message,
|
||||
});
|
||||
|
||||
return res.data;
|
||||
};
|
||||
|
||||
const rejectBot = async () => {
|
||||
const res = await request.put(API_ROUTES.ASSISTANT.REJECT(id as string), {
|
||||
message,
|
||||
});
|
||||
|
||||
return res.data;
|
||||
};
|
||||
|
||||
const confirmMutation = useMutation({
|
||||
mutationFn: confirmBot,
|
||||
onSuccess: async (data) => {
|
||||
await messageApi.success(data.detail, 1);
|
||||
router.push("/assistant");
|
||||
},
|
||||
});
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: rejectBot,
|
||||
onSuccess: async (data) => {
|
||||
await messageApi.success(data.detail, 1);
|
||||
router.push("/assistant");
|
||||
},
|
||||
});
|
||||
|
||||
const handleImageChange: UploadProps["onChange"] = (info) => {
|
||||
if (info.file.status === "uploading") {
|
||||
setLoading(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (info.file.status === "done") {
|
||||
// Get this url from response in real world.
|
||||
getBase64(info.file.originFileObj as FileType, (url) => {
|
||||
setLoading(false);
|
||||
setImageUrl(url);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<Spin />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{ title: <Link href="/assistant">دستیارها</Link> },
|
||||
{ title: data?.name },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-6">
|
||||
<div className="self-center">
|
||||
<Upload
|
||||
name="file"
|
||||
method="PUT"
|
||||
listType="picture-circle"
|
||||
showUploadList={false}
|
||||
onChange={handleImageChange}
|
||||
headers={{ Authorization: `Bearer ${TokenService.get()}` }}
|
||||
action={`${process.env.NEXT_PUBLIC_API_BASE_URL}/admin/bot/assistant/${id}/image`}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image alt="avatar" src={imageUrl} preview={false} />
|
||||
) : (
|
||||
<div>{loading ? "در حال بارگذاری" : "بارگذاری"}</div>
|
||||
)}
|
||||
</Upload>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex w-fit cursor-pointer items-center gap-1"
|
||||
onClick={() => {
|
||||
router.push(`/user/${data?.user.username}`);
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
shape="square"
|
||||
style={{ background: "#f56a00" }}
|
||||
src={
|
||||
process.env.NEXT_PUBLIC_AVATAR_BASE_URL +
|
||||
`?seed=${data?.user.username}`
|
||||
}
|
||||
/>
|
||||
<div className="text-sm">{data?.user.username}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-sm">نام دستیار</div>
|
||||
<Input variant="filled" disabled value={data?.name} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-sm">هزینه (بر حسب سکه)</div>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={100}
|
||||
variant="outlined"
|
||||
value={cost}
|
||||
onChange={setCost}
|
||||
className="!w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* فیلد جدید: تعیین سطح دستیار */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-sm">سطوح کاربری مجاز</div>
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
placeholder="سطوح دسترسی را انتخاب کنید"
|
||||
value={allowedLevels}
|
||||
onChange={(value) => setAllowedLevels(value)}
|
||||
options={[
|
||||
{ label: "بالای 30 سال", value: 1 },
|
||||
{ label: "بین 18 تا 30 سال", value: 2 },
|
||||
{ label: "زیر 18 سال", value: 3 },
|
||||
]}
|
||||
className="!w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-sm">توضیحات</div>
|
||||
<TextArea
|
||||
variant="filled"
|
||||
disabled
|
||||
rows={4}
|
||||
value={data?.description}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-sm">پرامپت</div>
|
||||
<TextArea variant="filled" disabled rows={4} value={data?.prompt} />
|
||||
</div>
|
||||
|
||||
{data && data.links && data.links.length > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-sm">لینکها</div>
|
||||
{data.links.map((link) => {
|
||||
return (
|
||||
<Link
|
||||
key={link}
|
||||
href={link}
|
||||
target="_blank"
|
||||
style={{ direction: "ltr" }}
|
||||
className="break-words rounded-md border border-[#d9d9d9] px-[11px] py-1 text-sm"
|
||||
>
|
||||
{link}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && data.docs && data.docs.length > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-sm">فایلها</div>
|
||||
{data.docs.map((doc) => {
|
||||
return (
|
||||
<Link
|
||||
key={doc}
|
||||
style={{ direction: "ltr" }}
|
||||
href={process.env.NEXT_PUBLIC_API_BASE_URL + doc}
|
||||
className="rounded-md border border-[#d9d9d9] px-[11px] py-1 text-sm"
|
||||
>
|
||||
{doc.split("/").at(-1)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm">انتشار عمومی</div>
|
||||
<Switch disabled checked={data?.public} />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-self-end">
|
||||
<Button
|
||||
color="yellow"
|
||||
variant="solid"
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={confirmMutation.isPending || rejectMutation.isPending}
|
||||
>
|
||||
ویرایش وضعیت
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title="ویرایش وضعیت دستیار"
|
||||
open={open}
|
||||
onCancel={() => setOpen(false)}
|
||||
footer={[
|
||||
data?.status !== "confirmed" && (
|
||||
<Button
|
||||
color="cyan"
|
||||
variant="solid"
|
||||
onClick={() => confirmMutation.mutate()}
|
||||
disabled={confirmMutation.isPending || rejectMutation.isPending}
|
||||
>
|
||||
انتشار دستیار
|
||||
</Button>
|
||||
),
|
||||
|
||||
data?.status !== "rejected" && (
|
||||
<Button
|
||||
color="danger"
|
||||
variant="solid"
|
||||
onClick={() => rejectMutation.mutate()}
|
||||
disabled={confirmMutation.isPending || rejectMutation.isPending}
|
||||
>
|
||||
رد دستیار
|
||||
</Button>
|
||||
),
|
||||
]}
|
||||
>
|
||||
<TextArea
|
||||
variant="outlined"
|
||||
rows={4}
|
||||
placeholder="یادداشتی برای کاربر"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
"use client";
|
||||
|
||||
import type { Category } from "@/types/category";
|
||||
import { Assistant, AssistantStatus } from "@/types/assistant";
|
||||
import {
|
||||
Badge,
|
||||
Input,
|
||||
Select,
|
||||
Tabs,
|
||||
Button,
|
||||
Modal,
|
||||
message as antdMessage,
|
||||
} from "antd";
|
||||
|
||||
// فقط IMPORT کنید (اکسپورت ممنوع)
|
||||
import { Assistants } from "@/components/assistant";
|
||||
|
||||
import { Clock, CloseCircle, TickCircle } from "iconsax-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { API_ROUTES, request } from "@/services/api";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import TextArea from "antd/es/input/TextArea";
|
||||
|
||||
// تنها تابعی که اکسپورت دیفالت میشود اینجاست
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [total, setTotal] = useState(0);
|
||||
const [category, setCategory] = useState<number>();
|
||||
const [selected, setSelected] = useState<Assistant[]>([]);
|
||||
const [q, setQuery] = useState("");
|
||||
const [page, setPage] = useState(+(searchParams.get("page") ?? 1));
|
||||
const [activeKey, setActiveKey] = useState(searchParams.get("tab") ?? "1");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [messageApi, contextHolder] = antdMessage.useMessage();
|
||||
|
||||
const createQueryString = useCallback(
|
||||
(name: string, value: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set(name, value);
|
||||
if (name === "tab") params.set("page", "1");
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
},
|
||||
[router, searchParams],
|
||||
);
|
||||
|
||||
const fetchData = async () => {
|
||||
const res = await request.get(API_ROUTES.ASSISTANT.ALL, {
|
||||
q,
|
||||
page,
|
||||
category,
|
||||
status: AssistantStatus[+activeKey - 1],
|
||||
});
|
||||
setTotal(res.data.total_count);
|
||||
return res.data.assistants;
|
||||
};
|
||||
|
||||
const fetchCategories = async () => {
|
||||
const res = await request.get(API_ROUTES.CATEGORY.ALL);
|
||||
const categories = res.data.categories as Category[];
|
||||
return categories.map((category) => ({
|
||||
label: category.name,
|
||||
value: category.id,
|
||||
}));
|
||||
};
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ["assistants", q, page, category, activeKey],
|
||||
queryFn: fetchData,
|
||||
});
|
||||
|
||||
const { data: categories } = useQuery({
|
||||
queryKey: ["categories"],
|
||||
queryFn: fetchCategories,
|
||||
});
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
setOpen(false);
|
||||
setMessage("");
|
||||
for (const assistant of selected) {
|
||||
await request.put(API_ROUTES.ASSISTANT.REJECT(assistant.id.toString()), {
|
||||
message,
|
||||
});
|
||||
}
|
||||
return { detail: "انجام شد" };
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await messageApi.success("دستیارها رد شدند");
|
||||
refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
return await request.delete(`/admin/bot/assistant/${id}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
messageApi.success("دستیار با موفقیت حذف شد");
|
||||
refetch();
|
||||
},
|
||||
onError: () => {
|
||||
messageApi.error("خطا در حذف دستیار");
|
||||
},
|
||||
});
|
||||
|
||||
const List = useMemo(() => {
|
||||
return function List() {
|
||||
return (
|
||||
<Assistants
|
||||
onDelete={(id) => deleteMutation.mutate(id)}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
selectionConfig={
|
||||
activeKey === "2"
|
||||
? {
|
||||
setSelectedItems: setSelected,
|
||||
initialSelecteds: selected,
|
||||
}
|
||||
: null
|
||||
}
|
||||
page={page}
|
||||
total={total}
|
||||
setPage={(p) => {
|
||||
setPage(p);
|
||||
createQueryString("page", p.toString());
|
||||
}}
|
||||
loading={isLoading}
|
||||
data={data}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}, [page, total, isLoading, data, selected, activeKey, deleteMutation, createQueryString]);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
activeKey={activeKey}
|
||||
onChange={(tab) => {
|
||||
setPage(1);
|
||||
setActiveKey(tab);
|
||||
createQueryString("tab", tab);
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
key: "1",
|
||||
label: "تایید شده",
|
||||
icon: <TickCircle className="inline stroke-current" size="20" />,
|
||||
children: <List />,
|
||||
},
|
||||
{
|
||||
key: "2",
|
||||
label: (
|
||||
<div className="inline-flex items-center gap-3">
|
||||
<div>در انتظار تایید</div>
|
||||
<Badge className="!text-inherit" count={12} overflowCount={10} />
|
||||
</div>
|
||||
),
|
||||
icon: <Clock className="inline stroke-current" size="20" />,
|
||||
children: (
|
||||
<div>
|
||||
{contextHolder}
|
||||
<List />
|
||||
<Modal
|
||||
title="رد دستیارها"
|
||||
open={open}
|
||||
onCancel={() => setOpen(false)}
|
||||
footer={[
|
||||
<Button
|
||||
key="reject"
|
||||
color="danger"
|
||||
variant="solid"
|
||||
onClick={() => rejectMutation.mutate()}
|
||||
disabled={rejectMutation.isPending}
|
||||
>
|
||||
رد دستیارها
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<TextArea
|
||||
variant="outlined"
|
||||
rows={4}
|
||||
placeholder="یادداشتی برای کاربران"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "3",
|
||||
label: "رد شده",
|
||||
icon: <CloseCircle className="inline stroke-current" size="20" />,
|
||||
children: <List />,
|
||||
},
|
||||
]}
|
||||
tabBarExtraContent={{
|
||||
right: (
|
||||
<div className="flex items-center gap-2">
|
||||
{activeKey === "2" && (
|
||||
<Button
|
||||
color="red"
|
||||
variant="solid"
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={rejectMutation.isPending || selected.length === 0}
|
||||
>
|
||||
رد دستیارها
|
||||
</Button>
|
||||
)}
|
||||
<Input.Search
|
||||
rootClassName="!w-36 sm:!w-48"
|
||||
placeholder="جستجو"
|
||||
allowClear
|
||||
onSearch={setQuery}
|
||||
/>
|
||||
<Select
|
||||
className="w-28 sm:w-32"
|
||||
allowClear
|
||||
placeholder="دستهبندی"
|
||||
value={category}
|
||||
options={categories}
|
||||
onChange={setCategory}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page() {
|
||||
return <div></div>;
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
@import url("https://cdn.jsdelivr.net/gh/rastikerdar/vazirmatn@33.003/misc/Farsi-Digits/Vazirmatn-FD-font-face.css");
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
* {
|
||||
font-family: "Vazirmatn FD", serif !important;
|
||||
}
|
||||
|
||||
.ltr {
|
||||
direction: ltr !important;
|
||||
}
|
||||
|
||||
.ant-btn-icon {
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.apexcharts-menu,
|
||||
.apexcharts-legend-series {
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
.apexcharts-menu {
|
||||
left: 8px !important;
|
||||
right: unset !important;
|
||||
}
|
||||
|
||||
.apexcharts-toolbar {
|
||||
top: -61px !important;
|
||||
left: 3px !important;
|
||||
right: unset !important;
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import type { Metadata } from "next";
|
||||
|
||||
import "./globals.css";
|
||||
import "@ant-design/v5-patch-for-react-19";
|
||||
|
||||
import { ConfigProvider } from "antd";
|
||||
import { Header } from "@/components/header";
|
||||
import { Container } from "@/components/container";
|
||||
import { AntdRegistry } from "@ant-design/nextjs-registry";
|
||||
import { EmptyState } from "@/components/empty";
|
||||
import ReactQueryClientProvider from "@/services/api/provider";
|
||||
import { Suspense } from "react";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "پنل مدیریت هوشان",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="fa" dir="rtl">
|
||||
<body>
|
||||
<AntdRegistry>
|
||||
<ConfigProvider direction="rtl" renderEmpty={EmptyState}>
|
||||
<ReactQueryClientProvider>
|
||||
<Header />
|
||||
<main className="py-6">
|
||||
<Container>
|
||||
<Suspense fallback={<div>در حال بارگیری</div>}>
|
||||
{children}
|
||||
</Suspense>
|
||||
</Container>
|
||||
</main>
|
||||
</ReactQueryClientProvider>
|
||||
</ConfigProvider>
|
||||
</AntdRegistry>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import Image from "next/image";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="flex justify-center py-20">
|
||||
<Image width={360} height={600} src="/owl.webp" alt="هوشان" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
"use client";
|
||||
|
||||
import { Chart } from "@/components/chart";
|
||||
import { Card } from "antd";
|
||||
|
||||
export default function Page() {
|
||||
const cardProps = {
|
||||
className: "w-full",
|
||||
classNames: { body: "!px-3" },
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<Card {...cardProps} title="تفکیک استفاده کاربران">
|
||||
<Chart
|
||||
type="area"
|
||||
height={240}
|
||||
series={[
|
||||
{
|
||||
name: "سکه رایگان",
|
||||
data: [100, 82],
|
||||
},
|
||||
{
|
||||
name: "سکه پولی",
|
||||
data: [11, 32],
|
||||
},
|
||||
]}
|
||||
options={{
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
},
|
||||
stroke: {
|
||||
curve: "smooth",
|
||||
},
|
||||
xaxis: {
|
||||
categories: ["10 دی", "11 دی"],
|
||||
},
|
||||
yaxis: {
|
||||
title: {
|
||||
text: "سکههای مصرفی",
|
||||
rotate: 90,
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
show: true,
|
||||
position: "bottom",
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card {...cardProps} title="وضعیت کاربران">
|
||||
<Chart
|
||||
type="radialBar"
|
||||
height={240}
|
||||
series={[60, 10]}
|
||||
options={{
|
||||
plotOptions: {
|
||||
radialBar: {
|
||||
endAngle: 270,
|
||||
dataLabels: {
|
||||
name: {
|
||||
fontSize: "14px",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
show: true,
|
||||
position: "bottom",
|
||||
},
|
||||
labels: ["کاربران فعال", "کاربران جدید"],
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card {...cardProps} title="تعداد درخواستها">
|
||||
<Chart
|
||||
type="line"
|
||||
height={240}
|
||||
series={[
|
||||
{
|
||||
name: "درخواستها",
|
||||
data: [100, 82, 10, 60, 25, 11, 91],
|
||||
},
|
||||
]}
|
||||
options={{
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
},
|
||||
stroke: {
|
||||
curve: "smooth",
|
||||
},
|
||||
xaxis: {
|
||||
categories: [
|
||||
"10 دی",
|
||||
"دی 11",
|
||||
"دی 12",
|
||||
"دی 13",
|
||||
"دی 14",
|
||||
"دی 15",
|
||||
"دی 16",
|
||||
],
|
||||
},
|
||||
yaxis: {
|
||||
title: {
|
||||
text: "درخواستها",
|
||||
rotate: 90,
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
show: true,
|
||||
position: "bottom",
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Card {...cardProps} title="گزارش مصرف مدلهای زبانی">
|
||||
<Chart
|
||||
type="bar"
|
||||
height={320}
|
||||
series={[
|
||||
{
|
||||
name: "پیامها",
|
||||
data: [1, 22, 10, 28, 16, 21, 13],
|
||||
},
|
||||
]}
|
||||
options={{
|
||||
plotOptions: {
|
||||
bar: {
|
||||
columnWidth: "75%",
|
||||
distributed: false,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
},
|
||||
xaxis: {
|
||||
categories: [
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
"o1",
|
||||
"o1-mini",
|
||||
"gemini-1.5-flash",
|
||||
"gemini-1.5-pro",
|
||||
"claude-3.5-sonnet",
|
||||
],
|
||||
},
|
||||
yaxis: {
|
||||
title: {
|
||||
text: "پیامها",
|
||||
rotate: 90,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card {...cardProps} title="گزارش مصرف دستیارها">
|
||||
<Chart
|
||||
type="bar"
|
||||
height={320}
|
||||
series={[
|
||||
{
|
||||
name: "پیامها",
|
||||
data: [1, 22, 10, 28, 16, 21, 13],
|
||||
},
|
||||
]}
|
||||
options={{
|
||||
plotOptions: {
|
||||
bar: {
|
||||
columnWidth: "75%",
|
||||
distributed: false,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
},
|
||||
xaxis: {
|
||||
categories: [
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
"o1",
|
||||
"o1-mini",
|
||||
"gemini-1.5-flash",
|
||||
"gemini-1.5-pro",
|
||||
"claude-3.5-sonnet",
|
||||
],
|
||||
},
|
||||
yaxis: {
|
||||
title: {
|
||||
text: "پیامها",
|
||||
rotate: 90,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
"use client";
|
||||
|
||||
import { formatPersianDate } from "@/helpers/utils";
|
||||
import { Table, Tag } from "antd";
|
||||
import { useState } from "react";
|
||||
|
||||
const { Column } = Table;
|
||||
|
||||
interface DataType {
|
||||
key: React.Key;
|
||||
name: string;
|
||||
mobile_number: string;
|
||||
income: number;
|
||||
created_at: Date;
|
||||
confirmed: boolean;
|
||||
}
|
||||
|
||||
const data: DataType[] = [
|
||||
{
|
||||
key: "1",
|
||||
name: "غلامی",
|
||||
mobile_number: "09139000000",
|
||||
income: 4200,
|
||||
created_at: new Date(),
|
||||
confirmed: true,
|
||||
},
|
||||
{
|
||||
key: "2",
|
||||
name: "غلامی",
|
||||
mobile_number: "09139000000",
|
||||
income: 4200,
|
||||
created_at: new Date(),
|
||||
confirmed: true,
|
||||
},
|
||||
{
|
||||
key: "3",
|
||||
name: "غلامی",
|
||||
mobile_number: "09139000000",
|
||||
income: 4200,
|
||||
created_at: new Date(),
|
||||
confirmed: false,
|
||||
},
|
||||
];
|
||||
|
||||
export default function Page() {
|
||||
const [total] = useState(1);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
return (
|
||||
<Table<DataType>
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
position: ["bottomCenter"],
|
||||
pageSize: 10,
|
||||
total,
|
||||
current: page,
|
||||
disabled: loading,
|
||||
showSizeChanger: false,
|
||||
onChange: (page) => {
|
||||
setLoading(true);
|
||||
setPage(page);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Column title="نام" dataIndex="name" key="name" />
|
||||
|
||||
<Column
|
||||
title="شماره موبایل"
|
||||
dataIndex="mobile_number"
|
||||
key="mobile_number"
|
||||
/>
|
||||
|
||||
<Column title="درآمد (سکه)" dataIndex="income" key="income" />
|
||||
|
||||
<Column
|
||||
title="تاریخ ایجاد"
|
||||
dataIndex="created_at"
|
||||
key="created_at"
|
||||
render={(date: Date) => formatPersianDate(date)}
|
||||
/>
|
||||
|
||||
<Column
|
||||
title="وضعیت"
|
||||
dataIndex="confirmed"
|
||||
key="confirmed"
|
||||
render={(status: boolean) => (
|
||||
<Tag color={status ? "success" : "warning"}>
|
||||
{status ? "تسویه" : "در انتظار"}
|
||||
</Tag>
|
||||
)}
|
||||
/>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
"use client";
|
||||
|
||||
import type { TicketHistory } from "@/types/ticket";
|
||||
import { API_ROUTES, request } from "@/services/api";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Button, Spin, message as antdMessage } from "antd";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { detectDir, formatPersianDate } from "@/helpers/utils";
|
||||
import { Send } from "iconsax-react";
|
||||
import { Container } from "@/components/container";
|
||||
import TextArea from "antd/es/input/TextArea";
|
||||
import { useState } from "react";
|
||||
import { FilePreview } from "@/components/file";
|
||||
|
||||
export default function Page() {
|
||||
const { id } = useParams();
|
||||
const [message, setMessage] = useState("");
|
||||
const [messageApi, contextHolder] = antdMessage.useMessage();
|
||||
|
||||
const submitMessage = async () => {
|
||||
const formData = new FormData();
|
||||
formData.append("text", message);
|
||||
formData.append("user_id", id as string);
|
||||
|
||||
const res = await request.post(API_ROUTES.TICKET.ALL, formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
|
||||
return res.data;
|
||||
};
|
||||
|
||||
const fetchData = async () => {
|
||||
const res = await request.get(API_ROUTES.TICKET.ONE(id as string));
|
||||
return res.data as TicketHistory;
|
||||
};
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ["ticket", id],
|
||||
queryFn: fetchData,
|
||||
});
|
||||
|
||||
const submitMessageMutation = useMutation({
|
||||
mutationFn: submitMessage,
|
||||
onSuccess: async () => {
|
||||
messageApi.success("پیام با موفقیت ارسال شد");
|
||||
setMessage("");
|
||||
await refetch();
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<Spin />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
|
||||
<div className="pb-14">
|
||||
<div className="flex flex-col gap-4">
|
||||
{data &&
|
||||
Object.keys(data).map((key) => {
|
||||
return (
|
||||
<div key={key} className="flex flex-col gap-2">
|
||||
<div className="self-center rounded-lg bg-sky-100 px-2 py-1 text-sm">
|
||||
{key}
|
||||
</div>
|
||||
|
||||
{data[key].map((message) => {
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={twMerge(
|
||||
"flex max-w-[92%] flex-col items-end gap-0.5",
|
||||
message.role === "user" ? "self-end" : "self-start",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
"overflow-hidden rounded-lg border",
|
||||
message.role === "user"
|
||||
? "border-rose-100 bg-rose-100"
|
||||
: "border-indigo-100 bg-indigo-100",
|
||||
)}
|
||||
>
|
||||
{message.file && <FilePreview url={message.file} />}
|
||||
|
||||
{message.text && (
|
||||
<div
|
||||
className="min-w-12 px-2 py-1"
|
||||
dir={detectDir(message.text)}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs">
|
||||
{formatPersianDate(
|
||||
new Date(message.created_at),
|
||||
false,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="fixed bottom-0 right-0 w-full">
|
||||
<Container>
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-slate-100 p-3">
|
||||
<Button
|
||||
color="primary"
|
||||
variant="text"
|
||||
icon={<Send size="24" className="stroke-current" />}
|
||||
onClick={() => submitMessageMutation.mutate()}
|
||||
disabled={submitMessageMutation.isPending || !message}
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
autoSize
|
||||
variant="borderless"
|
||||
value={message}
|
||||
onChange={(e) => {
|
||||
setMessage(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
"use client";
|
||||
|
||||
import { formatPersianDate } from "@/helpers/utils";
|
||||
import { API_ROUTES, request } from "@/services/api";
|
||||
import { Ticket } from "@/types/ticket";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Avatar, Badge, List } from "antd";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(1);
|
||||
|
||||
const fetchData = async () => {
|
||||
const res = await request.get(API_ROUTES.TICKET.ALL, {
|
||||
page,
|
||||
});
|
||||
|
||||
setTotal(res.data.total_count);
|
||||
return res.data.users as Ticket[];
|
||||
};
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["tickets", page],
|
||||
queryFn: fetchData,
|
||||
});
|
||||
|
||||
return (
|
||||
<List
|
||||
loading={isLoading}
|
||||
dataSource={data}
|
||||
pagination={{
|
||||
position: "bottom",
|
||||
align: "center",
|
||||
pageSize: 10,
|
||||
total,
|
||||
current: page,
|
||||
disabled: isLoading,
|
||||
showSizeChanger: false,
|
||||
onChange: (page) => {
|
||||
setPage(page);
|
||||
},
|
||||
}}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
className="cursor-pointer"
|
||||
onClick={() => router.push(`/ticket/${item.id}`)}
|
||||
actions={[
|
||||
<div key="list-date" className="flex items-center gap-1.5">
|
||||
{item.last_ticket.role === "user" && (
|
||||
<Badge key="list-date" dot />
|
||||
)}
|
||||
|
||||
<div>
|
||||
{formatPersianDate(new Date(item.last_ticket.created_at))}
|
||||
</div>
|
||||
</div>,
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
<Avatar
|
||||
src={
|
||||
item.image
|
||||
? process.env.NEXT_PUBLIC_API_BASE_URL + item.image
|
||||
: process.env.NEXT_PUBLIC_AVATAR_BASE_URL +
|
||||
`?seed=${item.username}`
|
||||
}
|
||||
/>
|
||||
}
|
||||
title={item.username}
|
||||
description={item.last_ticket.text || "فایل"}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Table, Tag, Input, Select, Card, Button } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { Receipt, SearchNormal1 } from "iconsax-react";
|
||||
|
||||
import { API_ROUTES, request } from "@/services/api";
|
||||
import { Container } from "@/components/container";
|
||||
import type { Transaction } from "@/types/transaction";
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
export default function TransactionsPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
// filters
|
||||
const [search, setSearch] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState<string | undefined>(undefined);
|
||||
|
||||
const fetchData = async () => {
|
||||
const res = await request.get(API_ROUTES.TRANSACTION.ALL, {
|
||||
page,
|
||||
limit: 10,
|
||||
q: search,
|
||||
type: typeFilter,
|
||||
});
|
||||
|
||||
setTotal(res.data.total_count || 0);
|
||||
return res.data.transactions as Transaction[];
|
||||
};
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["transactions", page, search, typeFilter],
|
||||
queryFn: fetchData,
|
||||
});
|
||||
|
||||
const columns: ColumnsType<Transaction> = [
|
||||
{
|
||||
title: "کاربر",
|
||||
dataIndex: "user",
|
||||
key: "user",
|
||||
render: (user) =>
|
||||
user ? (
|
||||
<Link
|
||||
href={`/user/${user.username}`}
|
||||
className="text-blue-600 font-medium hover:underline"
|
||||
>
|
||||
{user.username}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-gray-400">سیستمی / حذف شده</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "مقدار",
|
||||
dataIndex: "amount",
|
||||
key: "amount",
|
||||
render: (amount: number) => (
|
||||
<span className="font-bold">{amount.toLocaleString()}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "نوع",
|
||||
dataIndex: "type",
|
||||
key: "type",
|
||||
render: (type: string) => {
|
||||
let color: "default" | "green" | "blue" | "gold" = "default";
|
||||
let text = type;
|
||||
|
||||
switch (type) {
|
||||
case "gift":
|
||||
color = "green";
|
||||
text = "هدیه";
|
||||
break;
|
||||
case "credit":
|
||||
color = "blue";
|
||||
text = "اعتبار";
|
||||
break;
|
||||
case "free":
|
||||
color = "gold";
|
||||
text = "رایگان";
|
||||
break;
|
||||
}
|
||||
|
||||
return <Tag color={color}>{text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "توضیحات",
|
||||
dataIndex: "description",
|
||||
key: "description",
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: "تاریخ",
|
||||
dataIndex: "created_at",
|
||||
key: "created_at",
|
||||
render: (date: string) => (
|
||||
<span dir="ltr" className="text-sm text-gray-500">
|
||||
{new Date(date).toLocaleDateString("fa-IR", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="flex flex-col gap-6 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Receipt size={28} className="text-indigo-600" />
|
||||
<h1 className="text-xl font-bold">لیست کلی تراکنشها</h1>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card size="small" className="shadow-sm">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<Input
|
||||
prefix={<SearchNormal1 size={18} className="text-gray-400" />}
|
||||
placeholder="جستجو (نام کاربری، توضیحات...)"
|
||||
className="md:w-1/3"
|
||||
allowClear
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
|
||||
<Select
|
||||
placeholder="نوع تراکنش"
|
||||
allowClear
|
||||
className="md:w-48"
|
||||
value={typeFilter}
|
||||
onChange={(value) => setTypeFilter(value)}
|
||||
>
|
||||
<Option value="gift">هدیه (Gift)</Option>
|
||||
<Option value="credit">اعتبار (Credit)</Option>
|
||||
<Option value="free">رایگان (Free)</Option>
|
||||
</Select>
|
||||
|
||||
{(search || typeFilter) && (
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
onClick={() => {
|
||||
setSearch("");
|
||||
setTypeFilter(undefined);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
حذف فیلترها
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Table */}
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: 10,
|
||||
total,
|
||||
onChange: setPage,
|
||||
showSizeChanger: false,
|
||||
position: ["bottomCenter"],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Table, Tag, Button, Card } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { ArrowRight2, EmptyWallet } from "iconsax-react";
|
||||
|
||||
import { API_ROUTES, request } from "@/services/api";
|
||||
import { Container } from "@/components/container";
|
||||
import type { Transaction } from "@/types/transaction";
|
||||
|
||||
export default function UserHistoryPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const userId = params.id as string;
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
const fetchData = async () => {
|
||||
const res = await request.get(API_ROUTES.USER.HISTORY(userId), {
|
||||
page,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
setTotal(res.data.total_count || 0);
|
||||
return res.data.transactions as Transaction[];
|
||||
};
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["user-history", userId, page],
|
||||
queryFn: fetchData,
|
||||
});
|
||||
|
||||
const columns: ColumnsType<Transaction> = [
|
||||
{
|
||||
title: "تاریخ",
|
||||
dataIndex: "created_at",
|
||||
key: "created_at",
|
||||
render: (date: string) => (
|
||||
<span dir="ltr">
|
||||
{new Date(date).toLocaleDateString("fa-IR", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "نوع",
|
||||
dataIndex: "type",
|
||||
key: "type",
|
||||
render: (type: string) => {
|
||||
let color: "default" | "green" | "blue" | "gold" = "default";
|
||||
let text = type;
|
||||
|
||||
switch (type) {
|
||||
case "gift":
|
||||
color = "green";
|
||||
text = "هدیه";
|
||||
break;
|
||||
case "credit":
|
||||
color = "blue";
|
||||
text = "اعتبار";
|
||||
break;
|
||||
case "free":
|
||||
color = "gold";
|
||||
text = "رایگان";
|
||||
break;
|
||||
}
|
||||
|
||||
return <Tag color={color}>{text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "مقدار",
|
||||
dataIndex: "amount",
|
||||
key: "amount",
|
||||
render: (amount: number) => (
|
||||
<span className="font-bold text-lg">
|
||||
{amount.toLocaleString()}{" "}
|
||||
<span className="text-xs font-normal text-gray-500">امتیاز</span>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "توضیحات",
|
||||
dataIndex: "description",
|
||||
key: "description",
|
||||
ellipsis: true,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="flex flex-col gap-6 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowRight2 size={20} />}
|
||||
onClick={() => router.back()}
|
||||
/>
|
||||
<h1 className="text-xl font-bold flex items-center gap-2">
|
||||
<EmptyWallet size={24} className="text-blue-600" />
|
||||
تاریخچه تراکنشهای کاربر
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<Card className="shadow-sm">
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: 10,
|
||||
total,
|
||||
onChange: setPage,
|
||||
position: ["bottomCenter"],
|
||||
showSizeChanger: false,
|
||||
}}
|
||||
locale={{
|
||||
emptyText: "هیچ تراکنشی یافت نشد",
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,294 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation"; // useRouter حذف شد
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { API_ROUTES, request } from "@/services/api";
|
||||
import {
|
||||
Avatar,
|
||||
Breadcrumb,
|
||||
Button,
|
||||
Card,
|
||||
Descriptions,
|
||||
Divider,
|
||||
message as antdMessage,
|
||||
Select,
|
||||
Spin,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
} from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { UserOutlined, RobotOutlined, TeamOutlined,HistoryOutlined } from "@ant-design/icons";
|
||||
// تعریف تایپها
|
||||
interface SubUser {
|
||||
id: string;
|
||||
username: string;
|
||||
name: string | null;
|
||||
mobile_number: string | null;
|
||||
created_at: string;
|
||||
level: number;
|
||||
}
|
||||
|
||||
// تعریف تایپ بات برای جلوگیری از خطای any
|
||||
interface Bot {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
image: string | null;
|
||||
}
|
||||
|
||||
interface UserDetail {
|
||||
id: string;
|
||||
username: string;
|
||||
name: string | null;
|
||||
mobile_number: string | null;
|
||||
email: string | null;
|
||||
image: string | null;
|
||||
level: number;
|
||||
credit: number;
|
||||
income: number;
|
||||
created_at: string;
|
||||
sub_users: SubUser[];
|
||||
bots?: Bot[]; // استفاده از تایپ Bot به جای any
|
||||
}
|
||||
|
||||
export default function UserPage() {
|
||||
const { id } = useParams();
|
||||
const [messageApi, contextHolder] = antdMessage.useMessage();
|
||||
const [selectedLevel, setSelectedLevel] = useState<number>(1);
|
||||
|
||||
// 1. دریافت اطلاعات کامل کاربر
|
||||
const { data: user, isLoading, refetch } = useQuery({
|
||||
queryKey: ["user", id],
|
||||
queryFn: async () => {
|
||||
const res = await request.get(API_ROUTES.USER.ONE(id as string));
|
||||
return res.data.user as UserDetail;
|
||||
},
|
||||
});
|
||||
|
||||
// 2. سینک کردن لول انتخابی
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setSelectedLevel(user.level || 1);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
// 3. تابع آپدیت لول
|
||||
const updateLevelMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!user?.id) throw new Error("User ID not found");
|
||||
return await request.put(`/admin/user/${user.id}/level`, {
|
||||
level: selectedLevel,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
messageApi.success("سطح کاربر با موفقیت بروزرسانی شد");
|
||||
refetch();
|
||||
},
|
||||
onError: () => {
|
||||
messageApi.error("خطا در تغییر سطح کاربر");
|
||||
},
|
||||
});
|
||||
|
||||
// ستونهای جدول زیرمجموعهها
|
||||
const subUsersColumns: ColumnsType<SubUser> = [
|
||||
{
|
||||
title: "نام کاربری",
|
||||
dataIndex: "username",
|
||||
key: "username",
|
||||
render: (text) => (
|
||||
<Link href={`/user/${text}`} className="text-blue-600 hover:underline">
|
||||
{text}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "نام",
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
render: (text) => text || <span className="text-gray-400">---</span>,
|
||||
},
|
||||
{
|
||||
title: "شماره موبایل",
|
||||
dataIndex: "mobile_number",
|
||||
key: "mobile_number",
|
||||
render: (text) => <span dir="ltr">{text}</span>,
|
||||
},
|
||||
{
|
||||
title: "سطح",
|
||||
dataIndex: "level",
|
||||
key: "level",
|
||||
render: (level) => (
|
||||
<Tag color={level > 1 ? "gold" : "blue"}>سطح {level}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "تاریخ عضویت",
|
||||
dataIndex: "created_at",
|
||||
key: "created_at",
|
||||
render: (date) => new Date(date).toLocaleDateString("fa-IR"),
|
||||
},
|
||||
];
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<div className="flex h-screen w-full items-center justify-center">
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!user)
|
||||
return (
|
||||
<div className="flex h-screen w-full items-center justify-center text-red-500">
|
||||
کاربر یافت نشد
|
||||
</div>
|
||||
);
|
||||
|
||||
const items = [
|
||||
{
|
||||
key: "1",
|
||||
label: (
|
||||
<span>
|
||||
<RobotOutlined />
|
||||
دستیارها
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<div className="p-4">
|
||||
{user.bots && user.bots.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{user.bots.map((bot: Bot) => (
|
||||
<Card key={bot.id} title={bot.name} size="small" extra={<Link href={`/assistant/${bot.id}`}>مشاهده</Link>}>
|
||||
<p className="truncate text-gray-500">{bot.description}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center">این کاربر دستیاری ندارد.</p>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "2",
|
||||
label: (
|
||||
<span>
|
||||
<TeamOutlined />
|
||||
زیرمجموعهها ({user.sub_users ? user.sub_users.length : 0})
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<Table
|
||||
columns={subUsersColumns}
|
||||
dataSource={user.sub_users || []}
|
||||
rowKey="id"
|
||||
pagination={{ pageSize: 5 }}
|
||||
locale={{ emptyText: "زیرمجموعهای یافت نشد" }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{ title: <Link href="/dashboard">داشبورد</Link> },
|
||||
{ title: <Link href="/user">کاربران</Link> },
|
||||
{ title: user.username },
|
||||
]}
|
||||
className="mb-6"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<Card bordered={false} className="shadow-sm">
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
<div className="flex flex-col items-center gap-4 border-l pl-6 md:w-1/4">
|
||||
<Avatar
|
||||
size={100}
|
||||
src={
|
||||
user.image
|
||||
? `${process.env.NEXT_PUBLIC_API_BASE_URL}${user.image}`
|
||||
: `${process.env.NEXT_PUBLIC_AVATAR_BASE_URL}?seed=${user.username}`
|
||||
}
|
||||
icon={<UserOutlined />}
|
||||
/>
|
||||
<div className="text-center">
|
||||
<Typography.Title level={4} className="!mb-0">
|
||||
{user.name || "کاربر ناشناس"}
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary" copyable>@{user.username}</Typography.Text>
|
||||
</div>
|
||||
|
||||
<Tag color="purple" className="px-3 py-1 text-sm mt-2">
|
||||
سطح فعلی: {user.level}
|
||||
</Tag>
|
||||
|
||||
{/* ۲. دکمه جدید برای مشاهده تاریخچه تراکنشها */}
|
||||
<Link href={`/user/${user.id}/history`} className="w-full">
|
||||
<Button
|
||||
block
|
||||
icon={<HistoryOutlined />}
|
||||
className="mt-2 text-blue-600 border-blue-200 bg-blue-50"
|
||||
>
|
||||
تاریخچه تراکنشها
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<Descriptions title="مشخصات فردی" layout="vertical" column={{ xs: 1, sm: 2, md: 3 }}>
|
||||
<Descriptions.Item label="ایمیل">{user.email || "---"}</Descriptions.Item>
|
||||
<Descriptions.Item label="شماره موبایل">{user.mobile_number || "---"}</Descriptions.Item>
|
||||
<Descriptions.Item label="تاریخ ثبت نام">
|
||||
{new Date(user.created_at).toLocaleDateString("fa-IR")}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="موجودی حساب">
|
||||
{user.credit.toLocaleString()} سکه
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="درآمد">
|
||||
{user.income.toLocaleString()} تومان
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div className="flex items-end gap-4 bg-blue-50 p-4 rounded-lg border border-blue-100">
|
||||
<div className="w-full md:w-1/3">
|
||||
<label className="block text-xs text-gray-500 mb-1">تعیین سطح جدید</label>
|
||||
<Select
|
||||
className="w-full"
|
||||
value={selectedLevel}
|
||||
onChange={(val) => setSelectedLevel(val)}
|
||||
options={[
|
||||
{ label: "بالای 30 سال", value: 1 },
|
||||
{ label: "بین 18 تا 30 سال", value: 2 },
|
||||
{ label: "زیر 18 سال", value: 3 },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => updateLevelMutation.mutate()}
|
||||
loading={updateLevelMutation.isPending}
|
||||
disabled={selectedLevel === user.level}
|
||||
>
|
||||
بروزرسانی سطح
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card bordered={false} className="shadow-sm">
|
||||
<Tabs defaultActiveKey="2" items={items} />
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
"use client";
|
||||
|
||||
import type { User } from "@/types/user";
|
||||
import { Avatar, Input, List } from "antd";
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { API_ROUTES, request } from "@/services/api";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [total, setTotal] = useState(0);
|
||||
const [q, setQuery] = useState("");
|
||||
const [page, setPage] = useState(+(searchParams.get("page") ?? 1));
|
||||
|
||||
const fetchData = async () => {
|
||||
const res = await request.get(API_ROUTES.USER.ALL, {
|
||||
q,
|
||||
page,
|
||||
});
|
||||
|
||||
setTotal(res.data.total_count);
|
||||
return res.data.users as User[];
|
||||
};
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["users", q, page],
|
||||
queryFn: fetchData,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input.Search
|
||||
rootClassName="!w-36 sm:!w-48 self-end"
|
||||
placeholder="جستجو"
|
||||
allowClear
|
||||
onSearch={(value) => {
|
||||
setQuery(value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<List
|
||||
loading={isLoading}
|
||||
dataSource={data}
|
||||
pagination={{
|
||||
position: "bottom",
|
||||
align: "center",
|
||||
pageSize: 10,
|
||||
total,
|
||||
current: page,
|
||||
disabled: isLoading,
|
||||
showSizeChanger: false,
|
||||
onChange: (page) => {
|
||||
setPage(page);
|
||||
},
|
||||
}}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
key={item.id}
|
||||
className="cursor-pointer"
|
||||
onClick={() => router.push(`/user/${item.username}`)}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
<Avatar
|
||||
src={
|
||||
item.image
|
||||
? process.env.NEXT_PUBLIC_API_BASE_URL + item.image
|
||||
: process.env.NEXT_PUBLIC_AVATAR_BASE_URL +
|
||||
`?seed=${item.username}`
|
||||
}
|
||||
/>
|
||||
}
|
||||
title={item.username}
|
||||
description={item.name}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button, Card, Form, Input, InputNumber, message } from "antd";
|
||||
import { Container } from "@/components/container";
|
||||
import { API_ROUTES, request } from "@/services/api";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowRight2 } from "iconsax-react";
|
||||
|
||||
interface GroupScoreFormValues {
|
||||
users: string;
|
||||
coins: number;
|
||||
price: number;
|
||||
}
|
||||
|
||||
export default function GroupScorePage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const [form] = Form.useForm<GroupScoreFormValues>();
|
||||
|
||||
const onFinish = async (values: GroupScoreFormValues) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const userIds = values.users
|
||||
.split(/[\n,]+/)
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (userIds.length === 0) {
|
||||
message.error("لطفا شناسه کاربران را وارد کنید");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await request.post(API_ROUTES.USER.SCORE, {
|
||||
user_ids: userIds,
|
||||
coins: values.coins,
|
||||
price: values.price,
|
||||
});
|
||||
|
||||
message.success("امتیازدهی با موفقیت انجام شد");
|
||||
form.resetFields();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error("خطایی در ثبت امتیاز رخ داد");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="flex flex-col gap-6 py-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowRight2 size={20} />}
|
||||
onClick={() => router.back()}
|
||||
/>
|
||||
<h1 className="text-xl font-bold">امتیازدهی گروهی</h1>
|
||||
</div>
|
||||
|
||||
<Card className="max-w-2xl w-full self-center">
|
||||
<Form<GroupScoreFormValues>
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={onFinish}
|
||||
>
|
||||
<Form.Item
|
||||
name="users"
|
||||
label="شناسه کاربران (User IDs)"
|
||||
help="شناسه کاربران را با کاما (,) یا خط جدید وارد کنید."
|
||||
rules={[{ required: true, message: "لطفا کاربران را مشخص کنید" }]}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={5}
|
||||
placeholder="مثال:\nuser_123\nuser_456"
|
||||
className="font-mono"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Form.Item
|
||||
name="coins"
|
||||
label="تعداد سکه"
|
||||
rules={[{ required: true, message: "لطفا تعداد سکه را وارد کنید" }]}
|
||||
>
|
||||
<InputNumber<number>
|
||||
className="w-full"
|
||||
min={1}
|
||||
placeholder="مثال: 100"
|
||||
formatter={(value) => `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}
|
||||
parser={(value) => value?.replace(/\$\s?|(,*)/g, "") as unknown as number}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="price"
|
||||
label="مبلغ (تومان)"
|
||||
rules={[{ required: true, message: "لطفا مبلغ را وارد کنید" }]}
|
||||
>
|
||||
<InputNumber<number>
|
||||
className="w-full"
|
||||
min={0}
|
||||
placeholder="مثال: 50000"
|
||||
formatter={(value) => `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}
|
||||
parser={(value) => value?.replace(/\$\s?|(,*)/g, "") as unknown as number}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={loading} block>
|
||||
ثبت امتیاز
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Table, Button, Space, Popconfirm, Tooltip, Avatar, Tag } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { Assistant } from "@/types/assistant";
|
||||
import { Trash, Eye, User } from "iconsax-react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface SelectionConfig {
|
||||
setSelectedItems: (items: Assistant[]) => void;
|
||||
initialSelecteds: Assistant[];
|
||||
}
|
||||
|
||||
interface AssistantsProps {
|
||||
data: Assistant[];
|
||||
loading: boolean;
|
||||
page: number;
|
||||
total: number;
|
||||
setPage: (page: number) => void;
|
||||
selectionConfig?: SelectionConfig | null;
|
||||
onDelete?: (id: number) => void;
|
||||
isDeleting?: boolean;
|
||||
}
|
||||
|
||||
// این کامپوننت باید اکسپورت شود تا در فایل پیج استفاده شود
|
||||
export const Assistants = ({
|
||||
data,
|
||||
loading,
|
||||
page,
|
||||
total,
|
||||
setPage,
|
||||
selectionConfig,
|
||||
onDelete,
|
||||
isDeleting,
|
||||
}: AssistantsProps) => {
|
||||
|
||||
const columns: ColumnsType<Assistant> = [
|
||||
{
|
||||
title: "شناسه",
|
||||
dataIndex: "id",
|
||||
key: "id",
|
||||
width: 60,
|
||||
align: "center",
|
||||
},
|
||||
{
|
||||
title: "تصویر",
|
||||
dataIndex: "image",
|
||||
key: "image",
|
||||
width: 70,
|
||||
align: "center",
|
||||
render: (img) => (
|
||||
<Avatar
|
||||
shape="square"
|
||||
src={img ? `${process.env.NEXT_PUBLIC_API_BASE_URL}${img}` : undefined}
|
||||
icon={<User size="20"/>}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "نام دستیار",
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
},
|
||||
{
|
||||
title: "دستهبندی",
|
||||
dataIndex: ["category", "name"],
|
||||
key: "category",
|
||||
render: (text) => (
|
||||
text ? <Tag color="cyan">{text}</Tag> : <span className="text-gray-400">---</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "سازنده",
|
||||
dataIndex: ["user", "username"],
|
||||
key: "creator",
|
||||
render: (text) => text || "---",
|
||||
},
|
||||
{
|
||||
title: "وضعیت",
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
render: (status) => {
|
||||
const color = status === "confirmed" ? "green" : status === "rejected" ? "red" : "gold";
|
||||
return <Tag color={color}>{status}</Tag>;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "تاریخ",
|
||||
dataIndex: "created_at",
|
||||
key: "created_at",
|
||||
render: (date) => new Date(date).toLocaleDateString("fa-IR"),
|
||||
},
|
||||
{
|
||||
title: "عملیات",
|
||||
key: "action",
|
||||
width: 120,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Link href={`/assistant/${record.id}`}>
|
||||
<Tooltip title="مشاهده">
|
||||
<Button icon={<Eye size="18" />} size="small" />
|
||||
</Tooltip>
|
||||
</Link>
|
||||
|
||||
{onDelete && (
|
||||
<Popconfirm
|
||||
title="حذف دستیار"
|
||||
description="آیا از حذف این دستیار مطمئن هستید؟"
|
||||
onConfirm={() => onDelete(record.id)}
|
||||
okText="بله"
|
||||
cancelText="خیر"
|
||||
okButtonProps={{ danger: true, loading: isDeleting }}
|
||||
>
|
||||
<Tooltip title="حذف">
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
icon={<Trash size="16" color="#fff"/>}
|
||||
size="small"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
total: total,
|
||||
onChange: setPage,
|
||||
pageSize: 20,
|
||||
showSizeChanger: false
|
||||
}}
|
||||
rowSelection={
|
||||
selectionConfig
|
||||
? {
|
||||
selectedRowKeys: selectionConfig.initialSelecteds?.map((i) => i.id),
|
||||
onChange: (_: React.Key[], selectedRows: Assistant[]) => {
|
||||
selectionConfig.setSelectedItems(selectedRows);
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
scroll={{ x: 900 }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import type { Props } from "react-apexcharts";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const ReactApexChart = dynamic(() => import("react-apexcharts"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export const Chart = ({
|
||||
series,
|
||||
options,
|
||||
height,
|
||||
type,
|
||||
width = "100%",
|
||||
}: Props) => {
|
||||
return (
|
||||
<ReactApexChart
|
||||
type={type}
|
||||
height={height}
|
||||
width={width}
|
||||
series={series}
|
||||
options={{
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
colors: ["#1677FF", "#AC1269"],
|
||||
...options,
|
||||
chart: {
|
||||
toolbar: {
|
||||
show: true,
|
||||
tools: {
|
||||
download: "<img src='/document-download.svg' />",
|
||||
reset: false,
|
||||
zoom: false,
|
||||
zoomin: false,
|
||||
zoomout: false,
|
||||
pan: false,
|
||||
},
|
||||
},
|
||||
defaultLocale: "fa",
|
||||
locales: [FaLocale],
|
||||
...options?.chart,
|
||||
},
|
||||
xaxis: {
|
||||
axisTicks: { show: false },
|
||||
labels: { rotate: -45 },
|
||||
...options?.xaxis,
|
||||
},
|
||||
grid: {
|
||||
borderColor: "#f2f2f2",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const FaLocale = {
|
||||
name: "fa",
|
||||
options: {
|
||||
months: [
|
||||
"فروردین",
|
||||
"اردیبهشت",
|
||||
"خرداد",
|
||||
"تیر",
|
||||
"مرداد",
|
||||
"شهریور",
|
||||
"مهر",
|
||||
"آبان",
|
||||
"آذر",
|
||||
"دی",
|
||||
"بهمن",
|
||||
"اسفند",
|
||||
],
|
||||
shortMonths: [
|
||||
"فرو",
|
||||
"ارد",
|
||||
"خرد",
|
||||
"تیر",
|
||||
"مرد",
|
||||
"شهر",
|
||||
"مهر",
|
||||
"آبا",
|
||||
"آذر",
|
||||
"دی",
|
||||
"بهمـ",
|
||||
"اسفـ",
|
||||
],
|
||||
days: [
|
||||
"یکشنبه",
|
||||
"دوشنبه",
|
||||
"سه شنبه",
|
||||
"چهارشنبه",
|
||||
"پنجشنبه",
|
||||
"جمعه",
|
||||
"شنبه",
|
||||
],
|
||||
shortDays: ["ی", "د", "س", "چ", "پ", "ج", "ش"],
|
||||
toolbar: {
|
||||
exportToSVG: "دانلود SVG",
|
||||
exportToPNG: "دانلود PNG",
|
||||
exportToCSV: "دانلود CSV",
|
||||
menu: "منو",
|
||||
selection: "انتخاب",
|
||||
selectionZoom: "بزرگنمایی انتخابی",
|
||||
zoomIn: "بزرگنمایی",
|
||||
zoomOut: "کوچکنمایی",
|
||||
pan: "پیمایش",
|
||||
reset: "بازنشانی بزرگنمایی",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
type Props = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const Container = ({ children }: Props) => {
|
||||
return <div className="container px-4 sm:px-6">{children}</div>;
|
||||
};
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { Empty } from "antd";
|
||||
|
||||
export const EmptyState = () => {
|
||||
return (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="موردی یافت نشد" />
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { Image } from "antd";
|
||||
import { DocumentDownload } from "iconsax-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export const FilePreview = ({ url }: { url: string }) => {
|
||||
// Extract file extension
|
||||
const getFileExtension = (url: string) => {
|
||||
return url.split(".").at(-1) as string;
|
||||
};
|
||||
|
||||
const fileExtension = getFileExtension(url);
|
||||
|
||||
// Define supported file types
|
||||
const audioExtensions = ["mp3", "wav", "ogg", "aac", "flac"];
|
||||
const imageExtensions = ["jpg", "jpeg", "png", "gif", "webp", "bmp", "svg"];
|
||||
|
||||
const isAudio = audioExtensions.includes(fileExtension);
|
||||
const isImage = imageExtensions.includes(fileExtension);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col text-[0]">
|
||||
{isAudio ? (
|
||||
<audio controls className="self-center px-2 py-1">
|
||||
<source
|
||||
src={process.env.NEXT_PUBLIC_API_BASE_URL + url}
|
||||
type={`audio/${fileExtension}`}
|
||||
/>
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
) : isImage ? (
|
||||
<Image
|
||||
alt=""
|
||||
width={240}
|
||||
rootClassName="self-center"
|
||||
src={process.env.NEXT_PUBLIC_API_BASE_URL + url}
|
||||
/>
|
||||
) : (
|
||||
<Link
|
||||
download
|
||||
href={process.env.NEXT_PUBLIC_API_BASE_URL + url}
|
||||
className="m-1 flex items-center gap-2 self-end rounded-lg bg-slate-50 p-1 text-sm"
|
||||
>
|
||||
{url.split("/").at(-1)}
|
||||
<DocumentDownload size="20" className="stroke-current" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,218 @@
|
|||
"use client";
|
||||
|
||||
import { Button, Drawer, Dropdown, Input } from "antd";
|
||||
import type { MenuProps } from "antd";
|
||||
import { Container } from "./container";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ArrowRight2,
|
||||
Chart1,
|
||||
Coin,
|
||||
Direct,
|
||||
EmojiNormal,
|
||||
HambergerMenu,
|
||||
Home2,
|
||||
Profile2User,
|
||||
User,
|
||||
Gift,
|
||||
Receipt,
|
||||
} from "iconsax-react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import TokenService from "@/services/token";
|
||||
|
||||
export const Header = () => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const u = TokenService.getUser();
|
||||
if (u) setUsername(u);
|
||||
}, []);
|
||||
|
||||
const isLoggedIn = TokenService.get();
|
||||
|
||||
const showDrawer = () => {
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const items: MenuProps["items"] = [
|
||||
{
|
||||
label: isLoggedIn ? (
|
||||
<div>{username}</div>
|
||||
) : (
|
||||
<Input
|
||||
placeholder={"username"}
|
||||
onInput={(v) => setUsername((v.target as HTMLInputElement).value)}
|
||||
/>
|
||||
),
|
||||
key: "0",
|
||||
},
|
||||
{
|
||||
label: isLoggedIn ? (
|
||||
<div></div>
|
||||
) : (
|
||||
<Input.Password
|
||||
placeholder={"password"}
|
||||
onInput={(v) => setPassword((v.target as HTMLInputElement).value)}
|
||||
/>
|
||||
),
|
||||
key: "1",
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (isLoggedIn) TokenService.delete();
|
||||
else await TokenService.request(username, password);
|
||||
setLoginOpen(false);
|
||||
}}
|
||||
>
|
||||
{isLoggedIn ? "خروج" : "ورود"}
|
||||
</Button>
|
||||
),
|
||||
key: "2",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="flex h-14 w-full items-center border-b border-stone-100">
|
||||
<Container>
|
||||
<div className="grid grid-cols-3 items-center">
|
||||
<div className="justify-self-start">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<HambergerMenu className="stroke-black" size="20" />}
|
||||
onClick={showDrawer}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="justify-self-center">
|
||||
<Image
|
||||
src="/houshan-icon.png"
|
||||
alt="هوشان"
|
||||
width={40}
|
||||
height={40}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="justify-self-end">
|
||||
<Dropdown menu={{ items }} open={loginOpen}>
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => setLoginOpen((prev) => !prev)}
|
||||
icon={<User className="stroke-current" size="20" />}
|
||||
>
|
||||
{isLoggedIn ? (
|
||||
<div className="hidden sm:block">{username}</div>
|
||||
) : (
|
||||
<div>ورود</div>
|
||||
)}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</header>
|
||||
|
||||
<Drawer
|
||||
title="پنل مدیریت هوشان"
|
||||
placement="right"
|
||||
closable={true}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
closeIcon={<ArrowRight2 className="stroke-current" size="20" />}
|
||||
footer={<div className="text-center">رهانش 1.0.0</div>}
|
||||
classNames={{
|
||||
wrapper: "!shadow-none !w-60",
|
||||
header: "h-14 !border-none",
|
||||
content: "!bg-stone-100",
|
||||
body: "flex flex-col gap-2",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
block
|
||||
type={pathname === "/" ? "primary" : "text"}
|
||||
icon={<Home2 className="stroke-current" size="20" />}
|
||||
onClick={() => router.push("/")}
|
||||
>
|
||||
<div className="w-20 text-right">خانه</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
block
|
||||
type={pathname === "/report" ? "primary" : "text"}
|
||||
icon={<Chart1 className="stroke-current" size="20" />}
|
||||
onClick={() => router.push("/report")}
|
||||
>
|
||||
<div className="w-20 text-right">گزارشها</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
block
|
||||
type={pathname.includes("assistant") ? "primary" : "text"}
|
||||
icon={<EmojiNormal className="stroke-current" size="20" />}
|
||||
onClick={() => router.push("/assistant")}
|
||||
>
|
||||
<div className="w-20 text-right">دستیارها</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
block
|
||||
type={pathname.includes("/ticket") ? "primary" : "text"}
|
||||
icon={<Direct className="stroke-current" size="20" />}
|
||||
onClick={() => router.push("/ticket")}
|
||||
>
|
||||
<div className="w-20 text-right">تیکتها</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
block
|
||||
type={pathname === "/settlement" ? "primary" : "text"}
|
||||
icon={<Coin className="stroke-current" size="20" />}
|
||||
onClick={() => router.push("/settlement")}
|
||||
>
|
||||
<div className="w-20 text-right">تسویه حساب</div>
|
||||
</Button>
|
||||
|
||||
|
||||
<Button
|
||||
block
|
||||
type={pathname === "/transaction" ? "primary" : "text"}
|
||||
icon={<Receipt className="stroke-current" size="20" />}
|
||||
onClick={() => router.push("/transaction")}
|
||||
>
|
||||
<div className="w-20 text-right">تراکنشها</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
block
|
||||
type={pathname.includes("/user") ? "primary" : "text"}
|
||||
icon={<Profile2User className="stroke-current" size="20" />}
|
||||
onClick={() => router.push("/user")}
|
||||
>
|
||||
<div className="w-20 text-right">کاربران</div>
|
||||
</Button>
|
||||
|
||||
{/* --- دکمه جدید اضافه شده --- */}
|
||||
<Button
|
||||
block
|
||||
type={pathname === "/user/score" ? "primary" : "text"}
|
||||
icon={<Gift className="stroke-current" size="20" />}
|
||||
onClick={() => router.push("/user/score")}
|
||||
>
|
||||
<div className="w-20 text-right">امتیازدهی</div>
|
||||
</Button>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
export const formatPersianDate = (date: Date, dateStyle: boolean = true) => {
|
||||
const formatter = new Intl.DateTimeFormat("fa-IR", {
|
||||
dateStyle: dateStyle ? "medium" : undefined,
|
||||
timeStyle: "short",
|
||||
});
|
||||
|
||||
return formatter.format(date);
|
||||
};
|
||||
|
||||
export const detectDir = (text: string) =>
|
||||
/^[\u0600-\u06FF]/.test(text) ? "rtl" : "ltr";
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import axios, { CancelToken } from "axios";
|
||||
import API_ROUTES from "./routes";
|
||||
import TokenService from "../token";
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL,
|
||||
});
|
||||
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = TokenService.get();
|
||||
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
} else {
|
||||
console.log("Unauthorized");
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error),
|
||||
);
|
||||
|
||||
type OptionalObject = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
const request = {
|
||||
get: (url: string, params?: OptionalObject, cancelToken?: CancelToken) =>
|
||||
api.get(url, { params, cancelToken }),
|
||||
post: (
|
||||
url: string,
|
||||
data?: OptionalObject | FormData,
|
||||
config?: OptionalObject,
|
||||
) => api.post(url, data, config),
|
||||
put: (
|
||||
url: string,
|
||||
data?: OptionalObject | FormData,
|
||||
config?: OptionalObject,
|
||||
) => api.put(url, data, config),
|
||||
delete: (url: string, data?: OptionalObject) => api.delete(url, data),
|
||||
};
|
||||
|
||||
export { request, API_ROUTES };
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
"use client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
function ReactQueryClientProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReactQueryClientProvider;
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
const API_ROUTES = {
|
||||
ASSISTANT: {
|
||||
ALL: "/admin/bot/assistant",
|
||||
ONE: (id: string) => `/admin/bot/assistant/${id}`,
|
||||
CONFIRM: (id: string) => `/admin/bot/assistant/${id}/confirm`,
|
||||
REJECT: (id: string) => `/admin/bot/assistant/${id}/reject`,
|
||||
},
|
||||
CATEGORY: {
|
||||
ALL: "/category/",
|
||||
},
|
||||
TICKET: {
|
||||
ALL: "/admin/ticket/",
|
||||
ONE: (id: string) => `/admin/ticket/${id}`,
|
||||
},
|
||||
TRANSACTION: {
|
||||
ALL: "/admin/transaction/", // اندپوینت دریافت همه تراکنشها
|
||||
},
|
||||
USER: {
|
||||
ALL: "/admin/user/",
|
||||
ONE: (id: string) => `/admin/user/${id}`,
|
||||
SCORE: "/admin/user/groupscore",
|
||||
HISTORY: (id: string) => `/admin/user/transections/${id}`,
|
||||
},
|
||||
};
|
||||
|
||||
export default API_ROUTES;
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
const TokenService = {
|
||||
set: (token: string) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem("token", token);
|
||||
}
|
||||
},
|
||||
|
||||
get: () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem("token");
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
getUser: () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem("username");
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
delete: () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem("token");
|
||||
}
|
||||
},
|
||||
|
||||
request: async (username: string, password: string) => {
|
||||
const response: { detail: string; access_token: string; token_type: string } = await (
|
||||
await fetch('https://basa.houshan.ai/admin/user/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
}),
|
||||
})
|
||||
).json();
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('token', response.access_token);
|
||||
localStorage.setItem('username', username);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default TokenService;
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import type { Category } from "./category";
|
||||
import { User } from "./user";
|
||||
|
||||
export type Assistant = {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
image: string | null;
|
||||
created_at: Date;
|
||||
category: Category;
|
||||
status: "confirmed" | "pending" | "rejected";
|
||||
};
|
||||
|
||||
export type AssistantDetail = Assistant & {
|
||||
prompt: string;
|
||||
cost: number;
|
||||
user: User;
|
||||
public: boolean;
|
||||
links: string[] | null;
|
||||
docs: string[] | null;
|
||||
allowed_levels: number[] | null;
|
||||
};
|
||||
|
||||
export enum AssistantStatus {
|
||||
confirmed,
|
||||
pending,
|
||||
rejected,
|
||||
}
|
||||
|
||||
export type AssistantStats = {
|
||||
id: number;
|
||||
name: string;
|
||||
created_at: string;
|
||||
user: { // اطلاعات سازنده
|
||||
id: string;
|
||||
username: string;
|
||||
name?: string;
|
||||
};
|
||||
usage: { // اطلاعات مصرف
|
||||
total_messages: number; // تعداد کل پیامها
|
||||
total_tokens: number; // میزان مصرف (توکن یا اعتبار)
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export type Category = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
export type Ticket = {
|
||||
id: string;
|
||||
image: string;
|
||||
name?: string;
|
||||
username: string;
|
||||
mobile_number?: string;
|
||||
last_ticket: Message;
|
||||
};
|
||||
|
||||
export type Message = {
|
||||
id: number;
|
||||
text: string;
|
||||
role: "user" | "admin";
|
||||
file?: string;
|
||||
created_at: Date;
|
||||
};
|
||||
|
||||
export type TicketHistory = {
|
||||
[date: string]: Message[];
|
||||
};
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
// panel-houshan/src/types/transaction.ts
|
||||
|
||||
export type TransactionType = "gift" | "credit" | "free";
|
||||
|
||||
export type Transaction = {
|
||||
id: string;
|
||||
amount: number;
|
||||
type: TransactionType;
|
||||
description: string;
|
||||
created_at: string;
|
||||
// این فیلد برای نمایش نام کاربر در لیست کلی اضافه میشود
|
||||
user?: {
|
||||
id: string;
|
||||
username: string;
|
||||
name?: string;
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { Assistant } from "./assistant";
|
||||
|
||||
type Notifs = {
|
||||
title: string;
|
||||
message: string;
|
||||
created_at: Date;
|
||||
};
|
||||
|
||||
export type User = {
|
||||
id: string;
|
||||
name?: string;
|
||||
username: string;
|
||||
mobile_number?: string;
|
||||
email?: string;
|
||||
image?: string;
|
||||
level: number;
|
||||
sub_users: User[] | null;
|
||||
};
|
||||
|
||||
export type UserDetail = User & {
|
||||
bots: Assistant[];
|
||||
notifications: Notifs[];
|
||||
};
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import type { Config } from "tailwindcss";
|
||||
|
||||
export default {
|
||||
content: [
|
||||
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
background: "var(--background)",
|
||||
foreground: "var(--foreground)",
|
||||
},
|
||||
container: {
|
||||
center: true,
|
||||
screens: {
|
||||
xl: "1280px",
|
||||
},
|
||||
},
|
||||
screens: {
|
||||
md: "900px",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
} satisfies Config;
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Loading…
Reference in New Issue