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