Commit 8d925115 authored by ThinhNC's avatar ThinhNC

feat(quota): integrate monthly & role quotas, reset dialogs, profile usage widget, and cron UI

parent 21546737
...@@ -168,5 +168,11 @@ Tất cả các bảng dữ liệu trong hệ thống (Tác vụ cào, Nhật k ...@@ -168,5 +168,11 @@ Tất cả các bảng dữ liệu trong hệ thống (Tác vụ cào, Nhật k
- Ngăn ngừa hiện tượng menu dropdown của bộ lọc bị bảng dữ liệu bên dưới che khuất. - Ngăn ngừa hiện tượng menu dropdown của bộ lọc bị bảng dữ liệu bên dưới che khuất.
- **Tối Giản Bố Cục Màn Hình Phân Hệ Quản Trị / Nhật Ký**: - **Tối Giản Bố Cục Màn Hình Phân Hệ Quản Trị / Nhật Ký**:
- Không đặt các ô thống kê KPI rườm rà (Tổng số, Tạo mới, Cập nhật, Xóa) trên màn hình Nhật ký kiểm toán (`/audit-logs`) để tập trung tối đa không gian cho bảng dữ liệu dòng thời gian và bộ lọc nâng cao. - Không đặt các ô thống kê KPI rườm rà (Tổng số, Tạo mới, Cập nhật, Xóa) trên màn hình Nhật ký kiểm toán (`/audit-logs`) để tập trung tối đa không gian cho bảng dữ liệu dòng thời gian và bộ lọc nâng cao.
- **Bắt Buộc Có Phân Trang Cho CẢ 2 Chế Độ Xem (Chế Độ Thẻ / Grid & Chế Độ Hàng / Table) - QUY TẮC BẤT DI BẤT DỊCH**:
- Đối với các phân hệ dữ liệu hỗ trợ chuyển đổi linh hoạt giữa 2 chế độ hiển thị thẻ (`grid`) và hàng (`table`):
- **Chế độ Hàng (`table`)**: Bắt buộc có thanh phân trang `TablePagination` ở chân bảng bên trong Unified Table Card.
- **Chế độ Thẻ (`grid`)**: **BẮT BUỘC PHẢI CÓ THANH PHÂN TRANG `TablePagination`** ngay bên dưới lưới các thẻ (được bao bọc trang nhã trong container bo tròn `overflow-hidden rounded-3xl border border-emerald-500/15 bg-card/60 shadow-sm backdrop-blur-sm`).
- **TUYỆT ĐỐI KHÔNG ĐƯỢC BỎ QUÊN** phân trang khi người dùng đang ở chế độ xem thẻ, đảm bảo trải nghiệm duyệt dữ liệu đồng nhất 100% giữa cả 2 chế độ.
- Khi người dùng thay đổi bộ lọc tìm kiếm hoặc tiêu chí lọc, luôn reset `page` về `1`.
import { redirect } from "next/navigation";
export default function CronRedirectPage() {
redirect("/settings/developer/cron");
}
...@@ -196,7 +196,7 @@ export default function DashboardPage() { ...@@ -196,7 +196,7 @@ export default function DashboardPage() {
</div> </div>
{/* 4. Quota & Usage Widget (Hiển thị Hạn ngạch & Mức độ sử dụng) */} {/* 4. Quota & Usage Widget (Hiển thị Hạn ngạch & Mức độ sử dụng) */}
<QuotaUsageWidget initialUsage={stats?.quotaAndUsage} /> <QuotaUsageWidget initialUsage={stats?.quotaAndUsage} showMonthly={false} />
{/* 5. Recent Crawl Tasks Widget */} {/* 5. Recent Crawl Tasks Widget */}
<div id="tasks"> <div id="tasks">
......
"use client"; "use client";
import React, { useState } from "react"; import React, { useState } from "react";
import { KeyRound, Plus, Trash2 } from "lucide-react"; import { KeyRound, Plus, Trash2, Terminal, Code2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useLanguage } from "@/providers/language-provider"; import { useLanguage } from "@/providers/language-provider";
import { import {
...@@ -20,6 +20,32 @@ export default function ApiKeysPage() { ...@@ -20,6 +20,32 @@ export default function ApiKeysPage() {
const revokeApiKeyMutation = useRevokeApiKey(); const revokeApiKeyMutation = useRevokeApiKey();
const [isKeyModalOpen, setIsKeyModalOpen] = useState(false); const [isKeyModalOpen, setIsKeyModalOpen] = useState(false);
const curlSnippet = `curl -X POST "https://api.datacrawler.io/api/v1/crawl-jobs" \\
-H "X-API-Key: dc_live_your_secret_key_here" \\
-H "Content-Type: application/json" \\
-d '{
"startUrl": "https://vnexpress.net/thoi-su",
"mode": "CRAWL",
"maxPages": 20
}'`;
const nodeSnippet = `import axios from 'axios';
const client = axios.create({
baseURL: 'https://api.datacrawler.io/api/v1',
headers: {
'X-API-Key': 'dc_live_your_secret_key_here',
'Content-Type': 'application/json'
}
});
const response = await client.post('/crawl-jobs', {
startUrl: 'https://vnexpress.net/thoi-su',
mode: 'CRAWL',
maxPages: 20
});
console.log('Job Created:', response.data);`;
const handleToggleKey = (key: ApiKey) => { const handleToggleKey = (key: ApiKey) => {
toggleApiKeyMutation.mutate({ id: key.id, isActive: !key.isActive }); toggleApiKeyMutation.mutate({ id: key.id, isActive: !key.isActive });
}; };
...@@ -160,6 +186,72 @@ export default function ApiKeysPage() { ...@@ -160,6 +186,72 @@ export default function ApiKeysPage() {
</div> </div>
)} )}
{/* Quick Integration Guide (Tích Hợp Nhanh) */}
<div className="pt-8 border-t border-border/60 space-y-4">
<div>
<div className="inline-flex items-center gap-1.5 rounded-full bg-emerald-500/10 px-2.5 py-0.5 text-[11px] font-semibold text-emerald-600 dark:text-emerald-400 border border-emerald-500/20 mb-1.5">
<Terminal className="h-3 w-3" />
<span>{t.developer.tabs.docs}</span>
</div>
<h4 className="text-sm sm:text-base font-bold text-foreground">
{t.developer.snippets.title}
</h4>
<p className="text-xs text-muted-foreground">
{t.developer.snippets.desc}
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
{/* cURL Snippet */}
<div className="rounded-3xl border border-emerald-500/20 bg-card/60 p-5 space-y-3 backdrop-blur-sm shadow-sm flex flex-col justify-between">
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-foreground flex items-center gap-1.5">
<Terminal className="h-4 w-4 text-emerald-500" />
cURL
</span>
<CopyButton
variant="badge"
text={curlSnippet}
label={locale === "vi" ? "Sao chép cURL" : "Copy cURL"}
copiedLabel={locale === "vi" ? "Đã sao chép!" : "Copied!"}
title="Sao chép lệnh cURL"
showToast={true}
toastMessage={locale === "vi" ? "Đã sao chép lệnh cURL vào bộ nhớ tạm!" : "Copied cURL command to clipboard!"}
/>
</div>
<pre className="p-4 rounded-2xl bg-[#070e17] border border-slate-800/80 font-mono text-xs text-emerald-400 overflow-x-auto selection:bg-emerald-500/30">
{curlSnippet}
</pre>
</div>
</div>
{/* Node.js Snippet */}
<div className="rounded-3xl border border-emerald-500/20 bg-card/60 p-5 space-y-3 backdrop-blur-sm shadow-sm flex flex-col justify-between">
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-foreground flex items-center gap-1.5">
<Code2 className="h-4 w-4 text-emerald-500" />
Node.js / Axios
</span>
<CopyButton
variant="badge"
text={nodeSnippet}
label={locale === "vi" ? "Sao chép Node.js" : "Copy Node.js"}
copiedLabel={locale === "vi" ? "Đã sao chép!" : "Copied!"}
title="Sao chép đoạn mã Node.js"
showToast={true}
toastMessage={locale === "vi" ? "Đã sao chép mã nguồn Node.js vào bộ nhớ tạm!" : "Copied Node.js code to clipboard!"}
/>
</div>
<pre className="p-4 rounded-2xl bg-[#070e17] border border-slate-800/80 font-mono text-xs text-cyan-300 overflow-x-auto selection:bg-cyan-500/30">
{nodeSnippet}
</pre>
</div>
</div>
</div>
</div>
{/* Modal */} {/* Modal */}
<CreateApiKeyModal <CreateApiKeyModal
isOpen={isKeyModalOpen} isOpen={isKeyModalOpen}
......
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Tác Vụ Định Kỳ",
description:
"Quản lý và giám sát các tác vụ nền định kỳ, lịch chạy tự động BullMQ và dọn dẹp hệ thống trong Data Crawler.",
};
export default function CronLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}
import type { Metadata } from "next";
import { CronJobsView } from "@/components/cron/cron-jobs-view";
export const metadata: Metadata = {
title: "Tác Vụ Định Kỳ | Nhà Phát Triển",
description:
"Quản lý và giám sát các tác vụ nền định kỳ, lịch chạy tự động BullMQ và dọn dẹp hệ thống trong Data Crawler.",
};
export default function DeveloperCronPage() {
return (
<div className="space-y-6 animate-in fade-in-50 duration-200">
<CronJobsView />
</div>
);
}
"use client"; "use client";
import React from "react"; import { useEffect } from "react";
import { Terminal, Code2 } from "lucide-react"; import { useRouter } from "next/navigation";
import { useLanguage } from "@/providers/language-provider";
import { CopyButton } from "@/components/common/copy-button";
export default function DeveloperDocsPage() { export default function DeveloperDocsPage() {
const { t } = useLanguage(); const router = useRouter();
const curlSnippet = `curl -X POST "https://api.datacrawler.io/api/v1/crawl-jobs" \\ useEffect(() => {
-H "X-API-Key: dc_live_your_secret_key_here" \\ router.replace("/settings/developer/api-keys");
-H "Content-Type: application/json" \\ }, [router]);
-d '{
"startUrl": "https://vnexpress.net/thoi-su",
"mode": "CRAWL",
"maxPages": 20
}'`;
const nodeSnippet = `import axios from 'axios';
const client = axios.create({
baseURL: 'https://api.datacrawler.io/api/v1',
headers: {
'X-API-Key': 'dc_live_your_secret_key_here',
'Content-Type': 'application/json'
}
});
const response = await client.post('/crawl-jobs', {
startUrl: 'https://vnexpress.net/thoi-su',
mode: 'CRAWL',
maxPages: 20
});
console.log('Job Created:', response.data);`;
return ( return (
<div className="space-y-6 animate-in fade-in-50 duration-200"> <div className="flex h-32 items-center justify-center">
<div> <div className="h-6 w-6 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
<h3 className="text-base font-bold text-foreground">
{t.developer.snippets.title}
</h3>
<p className="text-xs text-muted-foreground">
{t.developer.snippets.desc}
</p>
</div>
{/* cURL Snippet */}
<div className="rounded-3xl border border-emerald-500/20 bg-card p-5 space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-foreground flex items-center gap-1.5">
<Terminal className="h-4 w-4 text-emerald-500" />
cURL
</span>
<CopyButton
variant="badge"
text={curlSnippet}
label="Sao chép cURL"
copiedLabel="Đã sao chép!"
title="Sao chép lệnh cURL"
showToast={true}
toastMessage="Đã sao chép lệnh cURL vào bộ nhớ tạm!"
/>
</div>
<pre className="p-4 rounded-2xl bg-[#070e17] border border-slate-800/80 font-mono text-xs text-emerald-400 overflow-x-auto selection:bg-emerald-500/30">
{curlSnippet}
</pre>
</div>
{/* Node.js Snippet */}
<div className="rounded-3xl border border-emerald-500/20 bg-card p-5 space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-foreground flex items-center gap-1.5">
<Code2 className="h-4 w-4 text-emerald-500" />
Node.js / Axios
</span>
<CopyButton
variant="badge"
text={nodeSnippet}
label="Sao chép Node.js"
copiedLabel="Đã sao chép!"
title="Sao chép đoạn mã Node.js"
showToast={true}
toastMessage="Đã sao chép mã nguồn Node.js vào bộ nhớ tạm!"
/>
</div>
<pre className="p-4 rounded-2xl bg-[#070e17] border border-slate-800/80 font-mono text-xs text-cyan-300 overflow-x-auto selection:bg-cyan-500/30">
{nodeSnippet}
</pre>
</div>
</div> </div>
); );
} }
import { redirect } from "next/navigation"; "use client";
interface DeveloperPageProps { import { useEffect } from "react";
searchParams: Promise<{ tab?: string }>; import { useRouter, useSearchParams } from "next/navigation";
} import { useAuth } from "@/hooks/use-auth";
export default async function DeveloperIndexPage({ searchParams }: DeveloperPageProps) { export default function DeveloperIndexPage() {
const { tab } = await searchParams; const router = useRouter();
const searchParams = useSearchParams();
const { hasPermission, role, isAdmin, isLoading } = useAuth();
if (tab === "users") { useEffect(() => {
redirect("/settings/developer/users"); if (isLoading) return;
}
if (tab === "webhooks") { const tab = searchParams.get("tab");
redirect("/settings/developer/webhooks"); if (tab === "users" && (hasPermission("users.read") || role === "ADMIN" || isAdmin)) {
router.replace("/settings/developer/users");
return;
}
if (tab === "roles" && (hasPermission("roles.read") || role === "ADMIN" || isAdmin)) {
router.replace("/settings/developer/roles");
return;
}
if (tab === "api-keys" && (hasPermission("api_keys.read") || role === "ADMIN" || isAdmin)) {
router.replace("/settings/developer/api-keys");
return;
}
if (tab === "webhooks" && (hasPermission("webhooks.read") || role === "ADMIN" || isAdmin)) {
router.replace("/settings/developer/webhooks");
return;
}
if (tab === "docs" && (hasPermission("api_keys.read") || role === "ADMIN" || isAdmin)) {
router.replace("/settings/developer/api-keys");
return;
}
if (tab === "audit-logs" && (hasPermission("audit_logs.read") || role === "ADMIN" || isAdmin)) {
router.replace("/settings/developer/audit-logs");
return;
}
if (tab === "system-configs" && (hasPermission("system_configs.read") || role === "ADMIN" || isAdmin)) {
router.replace("/settings/developer/system-configs");
return;
}
if (tab === "cron" && (hasPermission("cron_jobs.read") || role === "ADMIN" || isAdmin)) {
router.replace("/settings/developer/cron");
return;
} }
if (tab === "docs") { // Default to first permitted tab
redirect("/settings/developer/docs"); if (isAdmin || role === "ADMIN" || hasPermission("users.read")) {
router.replace("/settings/developer/users");
} else if (hasPermission("roles.read")) {
router.replace("/settings/developer/roles");
} else if (hasPermission("api_keys.read")) {
router.replace("/settings/developer/api-keys");
} else if (hasPermission("webhooks.read")) {
router.replace("/settings/developer/webhooks");
} else if (hasPermission("audit_logs.read")) {
router.replace("/settings/developer/audit-logs");
} else if (hasPermission("system_configs.read")) {
router.replace("/settings/developer/system-configs");
} else if (hasPermission("cron_jobs.read")) {
router.replace("/settings/developer/cron");
} else {
router.replace("/");
} }
}, [isLoading, searchParams, hasPermission, role, isAdmin, router]);
redirect("/settings/developer/api-keys"); return (
<div className="flex h-32 items-center justify-center">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
</div>
);
} }
...@@ -28,6 +28,7 @@ import { useLanguage } from "@/providers/language-provider"; ...@@ -28,6 +28,7 @@ import { useLanguage } from "@/providers/language-provider";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { formatDate } from "@/lib/utils"; import { formatDate } from "@/lib/utils";
import { toast } from "sonner"; import { toast } from "sonner";
import { QuotaUsageWidget } from "@/components/dashboard/quota-usage-widget";
import { import {
useUserProfile, useUserProfile,
useUserUsage, useUserUsage,
...@@ -389,38 +390,17 @@ export default function ProfileSettingsPage() { ...@@ -389,38 +390,17 @@ export default function ProfileSettingsPage() {
</Button> </Button>
</div> </div>
</form> </form>
{/* Quota & Usage Stats */}
{usageData && (
<div className="pt-4 border-t border-border/60 space-y-3">
<p className="text-xs font-bold text-foreground">
{t.profile.quota.title}
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div className="p-3 rounded-2xl border border-emerald-500/15 bg-muted/30">
<span className="text-[11px] text-muted-foreground">{t.profile.quota.maxPages}</span>
<p className="text-base font-bold text-foreground mt-0.5">
{usageData.quota.maxPagesLimit}
</p>
</div>
<div className="p-3 rounded-2xl border border-emerald-500/15 bg-muted/30">
<span className="text-[11px] text-muted-foreground">{t.profile.quota.maxJobsDay}</span>
<p className="text-base font-bold text-foreground mt-0.5">
{usageData.usage.jobsUsedToday} / {usageData.quota.maxJobsPerDayLimit}
</p>
</div>
<div className="p-3 rounded-2xl border border-emerald-500/15 bg-muted/30">
<span className="text-[11px] text-muted-foreground">{t.profile.quota.concurrentLimit}</span>
<p className="text-base font-bold text-foreground mt-0.5">
{usageData.usage.concurrentJobsRunning} / {usageData.quota.maxConcurrentJobsLimit}
</p>
</div>
</div>
</div>
)}
</div> </div>
{/* SECTION 2: CHANGE PASSWORD */} {/* SECTION 2: RESOURCE QUOTA & USAGE */}
<QuotaUsageWidget
initialUsage={usageData}
title={t.profile.quota.title}
subtitle={t.profile.quota.subtitle}
showMonthly={true}
/>
{/* SECTION 3: CHANGE PASSWORD */}
<div className="rounded-3xl border border-emerald-500/15 bg-card/80 p-6 sm:p-8 shadow-sm space-y-6"> <div className="rounded-3xl border border-emerald-500/15 bg-card/80 p-6 sm:p-8 shadow-sm space-y-6">
<div className="pb-4 border-b border-border/60"> <div className="pb-4 border-b border-border/60">
<h2 className="text-base font-bold text-foreground"> <h2 className="text-base font-bold text-foreground">
......
...@@ -13,11 +13,45 @@ import { useLanguage } from "@/providers/language-provider"; ...@@ -13,11 +13,45 @@ import { useLanguage } from "@/providers/language-provider";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
export function UserMenu() { export function UserMenu() {
const { user, isAuthenticated, isLoading, role, logout } = useAuth(); const { user, isAuthenticated, isLoading, role, isAdmin, hasPermission, logout } = useAuth();
const { t } = useLanguage(); const { t } = useLanguage();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
const canManageUsers = isAdmin || hasPermission("users.read");
const canManageRoles = isAdmin || hasPermission("roles.read");
const canViewApiKeys = isAdmin || hasPermission("api_keys.read");
const canViewWebhooks = isAdmin || hasPermission("webhooks.read");
const canReadAuditLogs = isAdmin || hasPermission("audit_logs.read");
const canManageConfigs = isAdmin || hasPermission("system_configs.read");
const canManageCron = isAdmin || hasPermission("cron_jobs.read");
const canAccessDeveloper =
isAdmin ||
canManageUsers ||
canManageRoles ||
canViewApiKeys ||
canViewWebhooks ||
canReadAuditLogs ||
canManageConfigs ||
canManageCron;
const developerHref = canManageUsers
? "/settings/developer/users"
: canManageRoles
? "/settings/developer/roles"
: canViewApiKeys
? "/settings/developer/api-keys"
: canViewWebhooks
? "/settings/developer/webhooks"
: canReadAuditLogs
? "/settings/developer/audit-logs"
: canManageConfigs
? "/settings/developer/system-configs"
: canManageCron
? "/settings/developer/cron"
: "/settings/developer/api-keys";
// Close dropdown on click outside // Close dropdown on click outside
useEffect(() => { useEffect(() => {
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: MouseEvent) {
...@@ -143,14 +177,16 @@ export function UserMenu() { ...@@ -143,14 +177,16 @@ export function UserMenu() {
<span>{t.auth.userMenu.profile}</span> <span>{t.auth.userMenu.profile}</span>
</Link> </Link>
{canAccessDeveloper && (
<Link <Link
href="/settings/developer/api-keys" href={developerHref}
onClick={() => setIsOpen(false)} onClick={() => setIsOpen(false)}
className="flex items-center gap-2.5 rounded-xl px-3 py-2 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors" className="flex items-center gap-2.5 rounded-xl px-3 py-2 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
> >
<Code2 className="h-3.5 w-3.5 text-emerald-500" /> <Code2 className="h-3.5 w-3.5 text-emerald-500" />
<span>{t.auth.userMenu.developer}</span> <span>{t.auth.userMenu.developer}</span>
</Link> </Link>
)}
</div> </div>
{/* Divider */} {/* Divider */}
......
This diff is collapsed.
"use client";
import React, { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Play, CheckCircle2, XCircle, Clock, Loader2 } from "lucide-react";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { useLanguage } from "@/providers/language-provider";
import { useTriggerCronJob } from "@/hooks/use-cron";
import {
triggerCronJobFormSchema,
TriggerCronJobFormInput,
} from "@/schemas/cron.schema";
import { CronJob, CronJobExecutionResult } from "@/types/cron";
interface CronTriggerDialogProps {
job: CronJob | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function CronTriggerDialog({
job,
open,
onOpenChange,
}: CronTriggerDialogProps) {
const { t } = useLanguage();
const triggerMutation = useTriggerCronJob();
const [executionResult, setExecutionResult] =
useState<CronJobExecutionResult | null>(null);
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<TriggerCronJobFormInput>({
resolver: zodResolver(triggerCronJobFormSchema),
defaultValues: {
paramsJson: "",
},
});
const handleClose = () => {
reset();
setExecutionResult(null);
onOpenChange(false);
};
const onSubmit = async (data: TriggerCronJobFormInput) => {
if (!job) return;
let parsedParams: Record<string, unknown> | undefined = undefined;
if (data.paramsJson && data.paramsJson.trim() !== "") {
try {
parsedParams = JSON.parse(data.paramsJson.trim());
} catch {
return;
}
}
try {
const result = await triggerMutation.mutateAsync({
name: job.name,
params: parsedParams,
});
setExecutionResult(result);
toast.success(
`${t.cron.toast.triggerSuccess}: ${job.name} (${result.durationMs}ms)`,
);
} catch (err: unknown) {
const errorMsg =
err instanceof Error ? err.message : t.cron.toast.triggerError;
toast.error(errorMsg);
}
};
if (!job) return null;
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-xl rounded-3xl border border-emerald-500/15 bg-card/95 p-6 shadow-2xl backdrop-blur-xl">
<DialogHeader className="space-y-2">
<div className="flex items-center gap-2">
<div className="flex h-9 w-9 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<Play className="h-4 w-4" />
</div>
<DialogTitle className="text-lg font-bold text-foreground">
{t.cron.triggerModal.title}
</DialogTitle>
</div>
<DialogDescription className="text-xs text-muted-foreground">
{t.cron.triggerModal.desc}
</DialogDescription>
</DialogHeader>
{/* Thông tin Job đang chọn */}
<div className="rounded-2xl border border-emerald-500/15 bg-emerald-500/5 p-3.5 space-y-1.5 text-xs">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">
{t.cron.triggerModal.jobLabel}
</span>
<span className="font-mono font-bold text-emerald-700 dark:text-emerald-300">
{job.name}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">
{t.cron.triggerModal.cronLabel}
</span>
<Badge
variant="outline"
className="font-mono text-[11px] bg-background/80 border-border/80"
>
{job.cron}
</Badge>
</div>
</div>
{/* Form nhập parameters JSON */}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 pt-2">
<div className="space-y-1.5">
<label className="text-xs font-semibold text-foreground flex items-center justify-between">
<span>{t.cron.triggerModal.paramsLabel}</span>
<span className="text-[11px] text-muted-foreground font-normal">
{t.cron.triggerModal.paramsHint}
</span>
</label>
<textarea
{...register("paramsJson")}
placeholder={t.cron.triggerModal.paramsPlaceholder}
rows={4}
disabled={triggerMutation.isPending}
className="w-full rounded-2xl border border-border/80 bg-background/80 p-3 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 focus:border-emerald-500/50 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 transition-all"
/>
{errors.paramsJson && (
<p className="text-[11px] text-red-500">
{errors.paramsJson.message}
</p>
)}
</div>
{/* Kết quả sau khi chạy */}
{executionResult && (
<div className="rounded-2xl border border-border/70 bg-background/90 p-3.5 space-y-2 text-xs animate-in fade-in-50 duration-200">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 font-semibold">
{executionResult.success ? (
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
) : (
<XCircle className="h-4 w-4 text-red-500" />
)}
<span>
{executionResult.success
? t.cron.triggerModal.resultSuccess
: t.cron.triggerModal.resultFailed}
</span>
</div>
<div className="flex items-center gap-1 text-[11px] text-muted-foreground">
<Clock className="h-3.5 w-3.5" />
<span>{executionResult.durationMs}ms</span>
</div>
</div>
{executionResult.data && (
<div className="mt-2 rounded-xl bg-muted/40 p-2.5 font-mono text-[11px] overflow-x-auto max-h-40">
<pre className="text-foreground/90">
{JSON.stringify(executionResult.data, null, 2)}
</pre>
</div>
)}
{executionResult.error && (
<p className="text-red-500 text-[11px]">
{executionResult.error}
</p>
)}
</div>
)}
<DialogFooter className="flex items-center justify-end gap-2 pt-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleClose}
disabled={triggerMutation.isPending}
className="rounded-2xl border-border/80 text-xs cursor-pointer"
>
{t.cron.triggerModal.closeBtn}
</Button>
<Button
type="submit"
size="sm"
disabled={triggerMutation.isPending}
className="rounded-2xl bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-semibold px-4 shadow-sm shadow-emerald-600/20 cursor-pointer transition-all hover:scale-[1.02]"
>
{triggerMutation.isPending ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />
{t.cron.triggerModal.executing}
</>
) : (
<>
<Play className="h-3.5 w-3.5 mr-1.5" />
{t.cron.triggerModal.runBtn}
</>
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
...@@ -10,6 +10,8 @@ import { ...@@ -10,6 +10,8 @@ import {
Trash2, Trash2,
Lock, Lock,
Sparkles, Sparkles,
RotateCcw,
Gauge,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useLanguage } from "@/providers/language-provider"; import { useLanguage } from "@/providers/language-provider";
...@@ -22,6 +24,7 @@ interface RoleCardProps { ...@@ -22,6 +24,7 @@ interface RoleCardProps {
onOpenMatrix: (role: RoleItem) => void; onOpenMatrix: (role: RoleItem) => void;
onViewUsers: (role: RoleItem) => void; onViewUsers: (role: RoleItem) => void;
onDelete: (role: RoleItem) => void; onDelete: (role: RoleItem) => void;
onResetQuota?: (role: RoleItem) => void;
} }
export function RoleCard({ export function RoleCard({
...@@ -30,6 +33,7 @@ export function RoleCard({ ...@@ -30,6 +33,7 @@ export function RoleCard({
onOpenMatrix, onOpenMatrix,
onViewUsers, onViewUsers,
onDelete, onDelete,
onResetQuota,
}: RoleCardProps) { }: RoleCardProps) {
const { t } = useLanguage(); const { t } = useLanguage();
const { role: userRole, hasPermission } = useAuth(); const { role: userRole, hasPermission } = useAuth();
...@@ -99,6 +103,22 @@ export function RoleCard({ ...@@ -99,6 +103,22 @@ export function RoleCard({
<p className="text-xs text-muted-foreground line-clamp-2 min-h-[32px] leading-relaxed"> <p className="text-xs text-muted-foreground line-clamp-2 min-h-[32px] leading-relaxed">
{role.description || "Không có mô tả cho vai trò này."} {role.description || "Không có mô tả cho vai trò này."}
</p> </p>
{/* Quota Badges */}
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<span className="inline-flex items-center gap-1 rounded-lg bg-emerald-500/10 px-2 py-0.5 text-[11px] font-medium text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<Gauge className="h-3 w-3" />
{role.maxPagesLimit ?? 100} trang/job
</span>
<span className="inline-flex items-center gap-1 rounded-lg bg-cyan-500/10 px-2 py-0.5 text-[11px] font-medium text-cyan-600 dark:text-cyan-400 border border-cyan-500/20">
{role.maxJobsPerDayLimit ?? 10} job/ngày
</span>
{role.maxPagesPerMonthLimit && (
<span className="inline-flex items-center gap-1 rounded-lg bg-teal-500/10 px-2 py-0.5 text-[11px] font-medium text-teal-600 dark:text-teal-400 border border-teal-500/20">
{role.maxPagesPerMonthLimit} trang/tháng
</span>
)}
</div>
</div> </div>
{/* Middle Stats & Badges */} {/* Middle Stats & Badges */}
...@@ -153,6 +173,18 @@ export function RoleCard({ ...@@ -153,6 +173,18 @@ export function RoleCard({
)} )}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{/* Reset Quota Button */}
{canEdit && onResetQuota && (
<button
type="button"
onClick={() => onResetQuota(role)}
className="p-1.5 rounded-xl text-muted-foreground hover:bg-emerald-500/10 hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors cursor-pointer"
title={t.roles.actions.resetQuota || "Đặt lại hạn ngạch"}
>
<RotateCcw className="h-4 w-4" />
</button>
)}
{/* Edit Button */} {/* Edit Button */}
{canEdit && ( {canEdit && (
<button <button
......
This diff is collapsed.
"use client";
import React, { useState, useEffect } from "react";
import { RotateCcw, X, ShieldCheck, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useLanguage } from "@/providers/language-provider";
import { RoleItem } from "@/types/role";
import { useResetRoleQuota } from "@/hooks/use-roles";
interface RoleResetQuotaDialogProps {
isOpen: boolean;
onClose: () => void;
role: RoleItem | null;
}
export function RoleResetQuotaDialog({
isOpen,
onClose,
role,
}: RoleResetQuotaDialogProps) {
const { t } = useLanguage();
const resetMutation = useResetRoleQuota();
const [syncLimits, setSyncLimits] = useState(false);
useEffect(() => {
if (isOpen) {
setSyncLimits(false);
}
}, [isOpen]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen || !role) return null;
const handleConfirm = async () => {
await resetMutation.mutateAsync({
id: role.id,
payload: { syncLimits },
});
onClose();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm animate-in fade-in-0 duration-200">
<div className="relative w-full max-w-md rounded-3xl border border-emerald-500/20 bg-card p-6 shadow-2xl shadow-emerald-950/20 space-y-4">
{/* Header */}
<div className="flex items-center justify-between pb-3 border-b border-border/60">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<RotateCcw className="h-5 w-5" />
</div>
<div>
<h2 className="text-base font-bold text-foreground">
{t.roles.dialogResetQuota.title}
</h2>
</div>
</div>
<button
onClick={onClose}
className="rounded-xl p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Target Role Card */}
<div className="flex items-center gap-3 p-3 rounded-2xl border border-emerald-500/20 bg-emerald-500/5">
<ShieldCheck className="h-5 w-5 text-emerald-600 dark:text-emerald-400 shrink-0" />
<div className="min-w-0">
<p className="text-[11px] text-muted-foreground">
{t.roles.dialogResetQuota.targetRole}
</p>
<p className="text-sm font-bold text-foreground truncate">
{role.name} ({role.slug})
</p>
{role.userCount !== undefined && (
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 font-medium">
{role.userCount} {t.roles.table.membersUnit}
</p>
)}
</div>
</div>
{/* Description */}
<p className="text-xs text-muted-foreground leading-relaxed">
{t.roles.dialogResetQuota.desc}
</p>
{/* Sync limits checkbox */}
<label className="flex items-start gap-3 p-3.5 rounded-2xl border border-border/70 bg-muted/30 cursor-pointer transition-colors hover:border-emerald-500/30">
<input
type="checkbox"
checked={syncLimits}
onChange={(e) => setSyncLimits(e.target.checked)}
className="mt-0.5 rounded border-border/80 text-emerald-600 focus:ring-emerald-500"
/>
<span className="text-xs text-foreground font-medium leading-relaxed">
{t.roles.dialogResetQuota.syncLimits}
</span>
</label>
{/* Action Buttons */}
<div className="flex items-center justify-end gap-2.5 pt-3 border-t border-border/60">
<Button
type="button"
variant="outline"
onClick={onClose}
className="rounded-2xl text-xs cursor-pointer"
>
{t.roles.dialogResetQuota.cancel}
</Button>
<Button
type="button"
disabled={resetMutation.isPending}
onClick={handleConfirm}
className="rounded-2xl bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-semibold px-4 cursor-pointer shadow-sm shadow-emerald-600/20"
>
{resetMutation.isPending
? t.roles.dialogResetQuota.resetting
: t.roles.dialogResetQuota.confirm}
</Button>
</div>
</div>
</div>
);
}
...@@ -10,26 +10,43 @@ import { ...@@ -10,26 +10,43 @@ import {
Trash2, Trash2,
Lock, Lock,
Sparkles, Sparkles,
RotateCcw,
Gauge,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useLanguage } from "@/providers/language-provider"; import { useLanguage } from "@/providers/language-provider";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { RoleItem } from "@/types/role"; import { RoleItem } from "@/types/role";
import { TablePagination } from "@/components/common/table-pagination";
interface RoleTableProps { interface RoleTableProps {
roles: RoleItem[]; roles: RoleItem[];
total?: number;
page?: number;
limit?: number;
totalPages?: number;
onPageChange?: (page: number) => void;
onLimitChange?: (limit: number) => void;
onEdit: (role: RoleItem) => void; onEdit: (role: RoleItem) => void;
onOpenMatrix: (role: RoleItem) => void; onOpenMatrix: (role: RoleItem) => void;
onViewUsers: (role: RoleItem) => void; onViewUsers: (role: RoleItem) => void;
onDelete: (role: RoleItem) => void; onDelete: (role: RoleItem) => void;
onResetQuota?: (role: RoleItem) => void;
} }
export function RoleTable({ export function RoleTable({
roles, roles,
total,
page,
limit,
totalPages,
onPageChange,
onLimitChange,
onEdit, onEdit,
onOpenMatrix, onOpenMatrix,
onViewUsers, onViewUsers,
onDelete, onDelete,
onResetQuota,
}: RoleTableProps) { }: RoleTableProps) {
const { t } = useLanguage(); const { t } = useLanguage();
const { role: userRole, hasPermission } = useAuth(); const { role: userRole, hasPermission } = useAuth();
...@@ -83,10 +100,24 @@ export function RoleTable({ ...@@ -83,10 +100,24 @@ export function RoleTable({
{role.name} {role.name}
</span> </span>
{role.description && ( {role.description && (
<span className="text-[11px] text-muted-foreground line-clamp-1 max-w-xs"> <span className="text-[11px] text-muted-foreground line-clamp-1 max-w-xs block">
{role.description} {role.description}
</span> </span>
)} )}
<div className="flex flex-wrap items-center gap-1.5 pt-1">
<span className="inline-flex items-center gap-1 rounded-md bg-emerald-500/10 px-1.5 py-0.2 text-[10px] font-medium text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<Gauge className="h-2.5 w-2.5" />
{role.maxPagesLimit ?? 100} trang/job
</span>
<span className="inline-flex items-center gap-1 rounded-md bg-cyan-500/10 px-1.5 py-0.2 text-[10px] font-medium text-cyan-600 dark:text-cyan-400 border border-cyan-500/20">
{role.maxJobsPerDayLimit ?? 10} job/ngày
</span>
{role.maxPagesPerMonthLimit && (
<span className="inline-flex items-center gap-1 rounded-md bg-teal-500/10 px-1.5 py-0.2 text-[10px] font-medium text-teal-600 dark:text-teal-400 border border-teal-500/20">
{role.maxPagesPerMonthLimit} trang/tháng
</span>
)}
</div>
</div> </div>
</div> </div>
</td> </td>
...@@ -174,6 +205,17 @@ export function RoleTable({ ...@@ -174,6 +205,17 @@ export function RoleTable({
</Button> </Button>
)} )}
{canEdit && onResetQuota && (
<button
type="button"
onClick={() => onResetQuota(role)}
className="p-1.5 rounded-xl text-muted-foreground hover:bg-emerald-500/10 hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors cursor-pointer"
title={t.roles.actions.resetQuota || "Đặt lại hạn ngạch vai trò"}
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
)}
{canEdit && ( {canEdit && (
<button <button
type="button" type="button"
...@@ -220,6 +262,17 @@ export function RoleTable({ ...@@ -220,6 +262,17 @@ export function RoleTable({
})} })}
</tbody> </tbody>
</table> </table>
{total !== undefined && onPageChange && onLimitChange && (
<TablePagination
page={page ?? 1}
totalPages={totalPages ?? (Math.ceil(total / (limit ?? 10)) || 1)}
total={total}
limit={limit ?? 10}
onPageChange={onPageChange}
onLimitChange={onLimitChange}
/>
)}
</div> </div>
); );
} }
...@@ -28,6 +28,8 @@ import { RoleFormModal } from "./role-form-modal"; ...@@ -28,6 +28,8 @@ import { RoleFormModal } from "./role-form-modal";
import { PermissionMatrixDialog } from "./permission-matrix-dialog"; import { PermissionMatrixDialog } from "./permission-matrix-dialog";
import { RoleUsersModal } from "./role-users-modal"; import { RoleUsersModal } from "./role-users-modal";
import { RoleDeleteDialog } from "./role-delete-dialog"; import { RoleDeleteDialog } from "./role-delete-dialog";
import { RoleResetQuotaDialog } from "./role-reset-quota-dialog";
import { TablePagination } from "@/components/common/table-pagination";
interface RolesManagementViewProps { interface RolesManagementViewProps {
showHeader?: boolean; showHeader?: boolean;
...@@ -50,6 +52,8 @@ export function RolesManagementView({ ...@@ -50,6 +52,8 @@ export function RolesManagementView({
"ALL" | "SYSTEM" | "CUSTOM" "ALL" | "SYSTEM" | "CUSTOM"
>("ALL"); >("ALL");
const [viewMode, setViewMode] = useState<"grid" | "table">("grid"); const [viewMode, setViewMode] = useState<"grid" | "table">("grid");
const [page, setPage] = useState(1);
const [limit, setLimit] = useState(10);
// Dropdown states // Dropdown states
const [isStatusOpen, setIsStatusOpen] = useState(false); const [isStatusOpen, setIsStatusOpen] = useState(false);
...@@ -63,6 +67,7 @@ export function RolesManagementView({ ...@@ -63,6 +67,7 @@ export function RolesManagementView({
const [matrixRole, setMatrixRole] = useState<RoleItem | null>(null); const [matrixRole, setMatrixRole] = useState<RoleItem | null>(null);
const [usersModalRole, setUsersModalRole] = useState<RoleItem | null>(null); const [usersModalRole, setUsersModalRole] = useState<RoleItem | null>(null);
const [deletingRole, setDeletingRole] = useState<RoleItem | null>(null); const [deletingRole, setDeletingRole] = useState<RoleItem | null>(null);
const [resettingRole, setResettingRole] = useState<RoleItem | null>(null);
// Debounce search // Debounce search
useEffect(() => { useEffect(() => {
...@@ -72,6 +77,10 @@ export function RolesManagementView({ ...@@ -72,6 +77,10 @@ export function RolesManagementView({
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [search]); }, [search]);
useEffect(() => {
setPage(1);
}, [debouncedSearch, selectedStatus, selectedType]);
// Click outside dropdowns // Click outside dropdowns
useEffect(() => { useEffect(() => {
function handleClickOutside(e: MouseEvent) { function handleClickOutside(e: MouseEvent) {
...@@ -106,13 +115,17 @@ export function RolesManagementView({ ...@@ -106,13 +115,17 @@ export function RolesManagementView({
: selectedType === "CUSTOM" : selectedType === "CUSTOM"
? false ? false
: undefined, : undefined,
limit: 100, page,
limit,
}); });
// Fetch Permissions Catalog for overall count // Fetch Permissions Catalog for overall count
const { data: allPermissions = [] } = usePermissionsList(); const { data: allPermissions = [] } = usePermissionsList();
const roles = rolesResponse?.items || []; const roles = rolesResponse?.items || [];
const totalRoles = rolesResponse?.meta?.total ?? rolesResponse?.items?.length ?? 0;
const totalPages =
rolesResponse?.meta?.totalPages ?? (Math.ceil(totalRoles / limit) || 1);
// Stats calculation // Stats calculation
const stats = useMemo(() => { const stats = useMemo(() => {
...@@ -474,6 +487,7 @@ export function RolesManagementView({ ...@@ -474,6 +487,7 @@ export function RolesManagementView({
)} )}
</div> </div>
) : viewMode === "grid" ? ( ) : viewMode === "grid" ? (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{roles.map((role) => ( {roles.map((role) => (
<RoleCard <RoleCard
...@@ -483,16 +497,37 @@ export function RolesManagementView({ ...@@ -483,16 +497,37 @@ export function RolesManagementView({
onOpenMatrix={(r) => setMatrixRole(r)} onOpenMatrix={(r) => setMatrixRole(r)}
onViewUsers={(r) => setUsersModalRole(r)} onViewUsers={(r) => setUsersModalRole(r)}
onDelete={(r) => setDeletingRole(r)} onDelete={(r) => setDeletingRole(r)}
onResetQuota={(r) => setResettingRole(r)}
/> />
))} ))}
</div> </div>
{/* Unified Table Pagination for Grid / Card Mode */}
<div className="overflow-hidden rounded-3xl border border-emerald-500/15 bg-card/60 shadow-sm backdrop-blur-sm">
<TablePagination
page={page}
totalPages={totalPages}
total={totalRoles}
limit={limit}
onPageChange={setPage}
onLimitChange={setLimit}
/>
</div>
</div>
) : ( ) : (
<RoleTable <RoleTable
roles={roles} roles={roles}
total={totalRoles}
page={page}
limit={limit}
totalPages={totalPages}
onPageChange={setPage}
onLimitChange={setLimit}
onEdit={(r) => setEditingRole(r)} onEdit={(r) => setEditingRole(r)}
onOpenMatrix={(r) => setMatrixRole(r)} onOpenMatrix={(r) => setMatrixRole(r)}
onViewUsers={(r) => setUsersModalRole(r)} onViewUsers={(r) => setUsersModalRole(r)}
onDelete={(r) => setDeletingRole(r)} onDelete={(r) => setDeletingRole(r)}
onResetQuota={(r) => setResettingRole(r)}
/> />
)} )}
...@@ -535,6 +570,14 @@ export function RolesManagementView({ ...@@ -535,6 +570,14 @@ export function RolesManagementView({
role={deletingRole} role={deletingRole}
/> />
)} )}
{resettingRole && (
<RoleResetQuotaDialog
isOpen={!!resettingRole}
onClose={() => setResettingRole(null)}
role={resettingRole}
/>
)}
</div> </div>
); );
} }
...@@ -36,6 +36,7 @@ import { SystemConfigTable } from "./system-config-table"; ...@@ -36,6 +36,7 @@ import { SystemConfigTable } from "./system-config-table";
import { SystemConfigFormModal } from "./system-config-form-modal"; import { SystemConfigFormModal } from "./system-config-form-modal";
import { SystemConfigDeleteDialog } from "./system-config-delete-dialog"; import { SystemConfigDeleteDialog } from "./system-config-delete-dialog";
import { StatCard } from "@/components/common/stat-card"; import { StatCard } from "@/components/common/stat-card";
import { TablePagination } from "@/components/common/table-pagination";
interface SystemConfigsManagementViewProps { interface SystemConfigsManagementViewProps {
showHeader?: boolean; showHeader?: boolean;
...@@ -394,6 +395,7 @@ export function SystemConfigsManagementView({ ...@@ -394,6 +395,7 @@ export function SystemConfigsManagementView({
{!isLoading && !isError && sortedItems.length > 0 && ( {!isLoading && !isError && sortedItems.length > 0 && (
<> <>
{viewMode === "grid" ? ( {viewMode === "grid" ? (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{sortedItems.map((item) => ( {sortedItems.map((item) => (
<SystemConfigCard <SystemConfigCard
...@@ -407,6 +409,19 @@ export function SystemConfigsManagementView({ ...@@ -407,6 +409,19 @@ export function SystemConfigsManagementView({
/> />
))} ))}
</div> </div>
{/* Unified Table Pagination for Grid / Card Mode */}
<div className="overflow-hidden rounded-3xl border border-emerald-500/15 bg-card/60 shadow-sm backdrop-blur-sm">
<TablePagination
page={page}
totalPages={Math.ceil(totalConfigs / limit) || 1}
total={totalConfigs}
limit={limit}
onPageChange={setPage}
onLimitChange={setLimit}
/>
</div>
</div>
) : ( ) : (
<SystemConfigTable <SystemConfigTable
configs={sortedItems} configs={sortedItems}
......
...@@ -84,6 +84,8 @@ export function UserCreateModal({ isOpen, onClose }: UserCreateModalProps) { ...@@ -84,6 +84,8 @@ export function UserCreateModal({ isOpen, onClose }: UserCreateModalProps) {
maxPagesLimit: 1000, maxPagesLimit: 1000,
maxJobsPerDayLimit: 50, maxJobsPerDayLimit: 50,
maxConcurrentJobsLimit: 5, maxConcurrentJobsLimit: 5,
maxPagesPerMonthLimit: 10000,
maxJobsPerMonthLimit: 500,
}, },
}); });
...@@ -100,6 +102,8 @@ export function UserCreateModal({ isOpen, onClose }: UserCreateModalProps) { ...@@ -100,6 +102,8 @@ export function UserCreateModal({ isOpen, onClose }: UserCreateModalProps) {
maxPagesLimit: 1000, maxPagesLimit: 1000,
maxJobsPerDayLimit: 50, maxJobsPerDayLimit: 50,
maxConcurrentJobsLimit: 5, maxConcurrentJobsLimit: 5,
maxPagesPerMonthLimit: 10000,
maxJobsPerMonthLimit: 500,
}); });
setShowAdvanced(false); setShowAdvanced(false);
setShowPassword(false); setShowPassword(false);
...@@ -137,6 +141,13 @@ export function UserCreateModal({ isOpen, onClose }: UserCreateModalProps) { ...@@ -137,6 +141,13 @@ export function UserCreateModal({ isOpen, onClose }: UserCreateModalProps) {
if (!isOpen) return null; if (!isOpen) return null;
const onSubmit = async (data: CreateUserInput) => { const onSubmit = async (data: CreateUserInput) => {
const parseOptionalNumber = (val: any) => {
if (val === "" || val === null || val === undefined || isNaN(Number(val))) {
return null;
}
return Number(val);
};
await createMutation.mutateAsync({ await createMutation.mutateAsync({
email: data.email.trim(), email: data.email.trim(),
password: data.password, password: data.password,
...@@ -145,6 +156,8 @@ export function UserCreateModal({ isOpen, onClose }: UserCreateModalProps) { ...@@ -145,6 +156,8 @@ export function UserCreateModal({ isOpen, onClose }: UserCreateModalProps) {
maxPagesLimit: data.maxPagesLimit, maxPagesLimit: data.maxPagesLimit,
maxJobsPerDayLimit: data.maxJobsPerDayLimit, maxJobsPerDayLimit: data.maxJobsPerDayLimit,
maxConcurrentJobsLimit: data.maxConcurrentJobsLimit, maxConcurrentJobsLimit: data.maxConcurrentJobsLimit,
maxPagesPerMonthLimit: parseOptionalNumber(data.maxPagesPerMonthLimit),
maxJobsPerMonthLimit: parseOptionalNumber(data.maxJobsPerMonthLimit),
}); });
onClose(); onClose();
}; };
...@@ -378,6 +391,31 @@ export function UserCreateModal({ isOpen, onClose }: UserCreateModalProps) { ...@@ -378,6 +391,31 @@ export function UserCreateModal({ isOpen, onClose }: UserCreateModalProps) {
/> />
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-2 pt-1 border-t border-emerald-500/15">
<div className="space-y-1">
<Label className="text-[11px] text-muted-foreground">
{t.users.modalCreate.maxPagesPerMonth}
</Label>
<Input
type="number"
{...register("maxPagesPerMonthLimit", { valueAsNumber: true })}
placeholder="10000"
className="rounded-xl border-border/80 text-xs bg-background/80"
/>
</div>
<div className="space-y-1">
<Label className="text-[11px] text-muted-foreground">
{t.users.modalCreate.maxJobsPerMonth}
</Label>
<Input
type="number"
{...register("maxJobsPerMonthLimit", { valueAsNumber: true })}
placeholder="500"
className="rounded-xl border-border/80 text-xs bg-background/80"
/>
</div>
</div>
</div> </div>
)} )}
</div> </div>
......
...@@ -55,6 +55,8 @@ export function UserEditModal({ ...@@ -55,6 +55,8 @@ export function UserEditModal({
maxPagesLimit: 1000, maxPagesLimit: 1000,
maxJobsPerDayLimit: 50, maxJobsPerDayLimit: 50,
maxConcurrentJobsLimit: 5, maxConcurrentJobsLimit: 5,
maxPagesPerMonthLimit: 10000,
maxJobsPerMonthLimit: 500,
}, },
}); });
...@@ -72,6 +74,8 @@ export function UserEditModal({ ...@@ -72,6 +74,8 @@ export function UserEditModal({
maxPagesLimit: user.maxPagesLimit, maxPagesLimit: user.maxPagesLimit,
maxJobsPerDayLimit: user.maxJobsPerDayLimit, maxJobsPerDayLimit: user.maxJobsPerDayLimit,
maxConcurrentJobsLimit: user.maxConcurrentJobsLimit, maxConcurrentJobsLimit: user.maxConcurrentJobsLimit,
maxPagesPerMonthLimit: user.maxPagesPerMonthLimit ?? 10000,
maxJobsPerMonthLimit: user.maxJobsPerMonthLimit ?? 500,
}); });
setShowAdvanced(false); setShowAdvanced(false);
} }
...@@ -101,6 +105,8 @@ export function UserEditModal({ ...@@ -101,6 +105,8 @@ export function UserEditModal({
maxPagesLimit: data.maxPagesLimit, maxPagesLimit: data.maxPagesLimit,
maxJobsPerDayLimit: data.maxJobsPerDayLimit, maxJobsPerDayLimit: data.maxJobsPerDayLimit,
maxConcurrentJobsLimit: data.maxConcurrentJobsLimit, maxConcurrentJobsLimit: data.maxConcurrentJobsLimit,
maxPagesPerMonthLimit: data.maxPagesPerMonthLimit,
maxJobsPerMonthLimit: data.maxJobsPerMonthLimit,
}, },
}); });
onClose(); onClose();
...@@ -284,6 +290,31 @@ export function UserEditModal({ ...@@ -284,6 +290,31 @@ export function UserEditModal({
/> />
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-2 pt-1 border-t border-emerald-500/15">
<div className="space-y-1">
<Label className="text-[11px] text-muted-foreground">
{t.users.modalCreate.maxPagesPerMonth}
</Label>
<Input
type="number"
{...register("maxPagesPerMonthLimit", { valueAsNumber: true })}
placeholder="10000"
className="rounded-xl border-border/80 text-xs bg-background/80"
/>
</div>
<div className="space-y-1">
<Label className="text-[11px] text-muted-foreground">
{t.users.modalCreate.maxJobsPerMonth}
</Label>
<Input
type="number"
{...register("maxJobsPerMonthLimit", { valueAsNumber: true })}
placeholder="500"
className="rounded-xl border-border/80 text-xs bg-background/80"
/>
</div>
</div>
</div> </div>
)} )}
</div> </div>
......
"use client";
import React, { useState, useEffect } from "react";
import { RotateCcw, X, User } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useLanguage } from "@/providers/language-provider";
import { UserItem } from "@/types/user";
import { useResetUserQuota } from "@/hooks/use-users";
interface UserResetQuotaDialogProps {
isOpen: boolean;
onClose: () => void;
user: UserItem | null;
}
export function UserResetQuotaDialog({
isOpen,
onClose,
user,
}: UserResetQuotaDialogProps) {
const { t } = useLanguage();
const resetMutation = useResetUserQuota();
const [resetLimitsToRole, setResetLimitsToRole] = useState(false);
useEffect(() => {
if (isOpen) {
setResetLimitsToRole(false);
}
}, [isOpen]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen || !user) return null;
const handleConfirm = async () => {
await resetMutation.mutateAsync({
id: user.id,
payload: { resetLimitsToRole },
});
onClose();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm animate-in fade-in-0 duration-200">
<div className="relative w-full max-w-md rounded-3xl border border-emerald-500/20 bg-card p-6 shadow-2xl shadow-emerald-950/20 space-y-4">
{/* Header */}
<div className="flex items-center justify-between pb-3 border-b border-border/60">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<RotateCcw className="h-5 w-5" />
</div>
<div>
<h2 className="text-base font-bold text-foreground">
{t.users.dialogResetQuota.title}
</h2>
</div>
</div>
<button
onClick={onClose}
className="rounded-xl p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Target User Info */}
<div className="flex items-center gap-3 p-3 rounded-2xl border border-emerald-500/20 bg-emerald-500/5">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-teal-700 text-white font-bold text-xs shrink-0">
{user.fullName ? user.fullName.charAt(0).toUpperCase() : user.email.charAt(0).toUpperCase()}
</div>
<div className="min-w-0">
<p className="text-[11px] text-muted-foreground">
{t.users.dialogResetQuota.targetUser}
</p>
<p className="text-sm font-bold text-foreground truncate">
{user.fullName || user.email}
</p>
<p className="text-xs text-muted-foreground font-mono truncate">
{user.email}
</p>
</div>
</div>
{/* Description */}
<p className="text-xs text-muted-foreground leading-relaxed">
{t.users.dialogResetQuota.desc}
</p>
{/* Checkbox restore limits to role */}
<label className="flex items-start gap-3 p-3.5 rounded-2xl border border-border/70 bg-muted/30 cursor-pointer transition-colors hover:border-emerald-500/30">
<input
type="checkbox"
checked={resetLimitsToRole}
onChange={(e) => setResetLimitsToRole(e.target.checked)}
className="mt-0.5 rounded border-border/80 text-emerald-600 focus:ring-emerald-500"
/>
<span className="text-xs text-foreground font-medium leading-relaxed">
{t.users.dialogResetQuota.resetLimitsToRole}
</span>
</label>
{/* Action Buttons */}
<div className="flex items-center justify-end gap-2.5 pt-3 border-t border-border/60">
<Button
type="button"
variant="outline"
onClick={onClose}
className="rounded-2xl text-xs cursor-pointer"
>
{t.users.dialogResetQuota.cancel}
</Button>
<Button
type="button"
disabled={resetMutation.isPending}
onClick={handleConfirm}
className="rounded-2xl bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-semibold px-4 cursor-pointer shadow-sm shadow-emerald-600/20"
>
{resetMutation.isPending
? t.users.dialogResetQuota.resetting
: t.users.dialogResetQuota.confirm}
</Button>
</div>
</div>
</div>
);
}
...@@ -14,6 +14,7 @@ import { ...@@ -14,6 +14,7 @@ import {
Clock, Clock,
Mail, Mail,
UserCheck, UserCheck,
RotateCcw,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { UserItem } from "@/types/user"; import { UserItem } from "@/types/user";
...@@ -31,6 +32,7 @@ interface UserTableProps { ...@@ -31,6 +32,7 @@ interface UserTableProps {
onManageRoles: (user: UserItem) => void; onManageRoles: (user: UserItem) => void;
onDelete: (user: UserItem) => void; onDelete: (user: UserItem) => void;
onToggleStatus: (user: UserItem) => void; onToggleStatus: (user: UserItem) => void;
onResetQuota?: (user: UserItem) => void;
onCreateNew: () => void; onCreateNew: () => void;
currentUserId?: string; currentUserId?: string;
isTogglingStatus?: boolean; isTogglingStatus?: boolean;
...@@ -54,6 +56,7 @@ export function UserTable({ ...@@ -54,6 +56,7 @@ export function UserTable({
onManageRoles, onManageRoles,
onDelete, onDelete,
onToggleStatus, onToggleStatus,
onResetQuota,
onCreateNew, onCreateNew,
currentUserId, currentUserId,
isTogglingStatus, isTogglingStatus,
...@@ -329,9 +332,14 @@ export function UserTable({ ...@@ -329,9 +332,14 @@ export function UserTable({
</span> </span>
)} )}
</div> </div>
<span className="text-[11px] text-muted-foreground font-mono truncate max-w-[160px]"> <div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[11px] text-muted-foreground font-mono truncate max-w-[120px]">
ID: {user.id.substring(0, 8)}... ID: {user.id.substring(0, 8)}...
</span> </span>
<span className="text-[10px] px-1.5 py-0.5 rounded-md bg-muted/60 text-muted-foreground font-medium">
{user.maxPagesLimit.toLocaleString()} tr/ngày • {(user.maxPagesPerMonthLimit ?? 10000).toLocaleString()} tr/tháng
</span>
</div>
</div> </div>
</div> </div>
</td> </td>
...@@ -426,6 +434,17 @@ export function UserTable({ ...@@ -426,6 +434,17 @@ export function UserTable({
<Edit3 className="h-4 w-4" /> <Edit3 className="h-4 w-4" />
</button> </button>
{/* Reset Quota */}
{onResetQuota && (
<button
onClick={() => onResetQuota(user)}
title={t.users.actions.resetQuota}
className="flex h-8 w-8 items-center justify-center rounded-xl text-muted-foreground hover:bg-amber-500/10 hover:text-amber-600 dark:hover:text-amber-400 border border-transparent hover:border-amber-500/20 transition-all cursor-pointer"
>
<RotateCcw className="h-4 w-4" />
</button>
)}
{/* Xóa người dùng */} {/* Xóa người dùng */}
<button <button
onClick={() => onDelete(user)} onClick={() => onDelete(user)}
......
...@@ -25,6 +25,7 @@ import { UserCreateModal } from "./user-create-modal"; ...@@ -25,6 +25,7 @@ import { UserCreateModal } from "./user-create-modal";
import { UserEditModal } from "./user-edit-modal"; import { UserEditModal } from "./user-edit-modal";
import { UserRolesModal } from "./user-roles-modal"; import { UserRolesModal } from "./user-roles-modal";
import { UserDeleteDialog } from "./user-delete-dialog"; import { UserDeleteDialog } from "./user-delete-dialog";
import { UserResetQuotaDialog } from "./user-reset-quota-dialog";
interface UsersManagementViewProps { interface UsersManagementViewProps {
showHeader?: boolean; showHeader?: boolean;
...@@ -68,6 +69,7 @@ export function UsersManagementView({ ...@@ -68,6 +69,7 @@ export function UsersManagementView({
const [editingUser, setEditingUser] = useState<UserItem | null>(null); const [editingUser, setEditingUser] = useState<UserItem | null>(null);
const [rolesUser, setRolesUser] = useState<UserItem | null>(null); const [rolesUser, setRolesUser] = useState<UserItem | null>(null);
const [deletingUser, setDeletingUser] = useState<UserItem | null>(null); const [deletingUser, setDeletingUser] = useState<UserItem | null>(null);
const [resettingUserQuota, setResettingUserQuota] = useState<UserItem | null>(null);
// Quick toggle status mutation // Quick toggle status mutation
const updateMutation = useUpdateUser(); const updateMutation = useUpdateUser();
...@@ -495,6 +497,7 @@ export function UsersManagementView({ ...@@ -495,6 +497,7 @@ export function UsersManagementView({
onManageRoles={(u) => setRolesUser(u)} onManageRoles={(u) => setRolesUser(u)}
onDelete={(u) => setDeletingUser(u)} onDelete={(u) => setDeletingUser(u)}
onToggleStatus={handleToggleStatus} onToggleStatus={handleToggleStatus}
onResetQuota={(u) => setResettingUserQuota(u)}
onCreateNew={() => setIsCreateModalOpen(true)} onCreateNew={() => setIsCreateModalOpen(true)}
currentUserId={currentUser?.id} currentUserId={currentUser?.id}
isTogglingStatus={updateMutation.isPending} isTogglingStatus={updateMutation.isPending}
...@@ -546,6 +549,14 @@ export function UsersManagementView({ ...@@ -546,6 +549,14 @@ export function UsersManagementView({
user={deletingUser} user={deletingUser}
/> />
)} )}
{resettingUserQuota && (
<UserResetQuotaDialog
isOpen={!!resettingUserQuota}
onClose={() => setResettingUserQuota(null)}
user={resettingUserQuota}
/>
)}
</div> </div>
); );
} }
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { cronService } from "@/services/cron.service";
import { CronJob } from "@/types/cron";
export const CRON_QUERY_KEYS = {
all: ["cron-jobs"] as const,
list: (search?: string) => ["cron-jobs", "list", search] as const,
};
/**
* Hook lấy danh sách các tác vụ định kỳ
*/
export function useCronJobsList(
search?: string,
options?: { enabled?: boolean },
) {
return useQuery({
queryKey: CRON_QUERY_KEYS.list(search),
queryFn: () => cronService.listJobs(search),
enabled: options?.enabled ?? true,
refetchInterval: 30000, // Tự động làm mới mỗi 30s
});
}
/**
* Hook bật / tắt kích hoạt tự động theo lịch của tác vụ
*/
export function useToggleCronJob() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ name, enabled }: { name: string; enabled: boolean }) =>
cronService.toggleJob(name, enabled),
onMutate: async ({ name, enabled }) => {
// Hủy bỏ các refetches đang diễn ra để tránh ghi đè dữ liệu optimistic
await queryClient.cancelQueries({ queryKey: CRON_QUERY_KEYS.all });
const previousQueries = queryClient.getQueriesData<CronJob[]>({
queryKey: CRON_QUERY_KEYS.all,
});
// Optimistic update
queryClient.setQueriesData<CronJob[]>(
{ queryKey: CRON_QUERY_KEYS.all },
(old) => {
if (!old) return old;
return old.map((job) =>
job.name === name ? { ...job, isEnabled: enabled } : job,
);
},
);
return { previousQueries };
},
onError: (_err, _variables, context) => {
// Rollback nếu phát sinh lỗi
if (context?.previousQueries) {
context.previousQueries.forEach(([queryKey, data]) => {
queryClient.setQueryData(queryKey, data);
});
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: CRON_QUERY_KEYS.all });
},
});
}
/**
* Hook kích hoạt chạy ngay một tác vụ thủ công
*/
export function useTriggerCronJob() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
name,
params,
}: {
name: string;
params?: Record<string, unknown>;
}) => cronService.triggerJob(name, params),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: CRON_QUERY_KEYS.all });
},
});
}
...@@ -17,11 +17,12 @@ export const DEVELOPER_KEYS = { ...@@ -17,11 +17,12 @@ export const DEVELOPER_KEYS = {
}; };
// API Keys Hooks // API Keys Hooks
export function useApiKeysList() { export function useApiKeysList(options?: { enabled?: boolean }) {
return useQuery({ return useQuery({
queryKey: DEVELOPER_KEYS.apiKeys, queryKey: DEVELOPER_KEYS.apiKeys,
queryFn: () => developerService.listKeys(), queryFn: () => developerService.listKeys(),
staleTime: 30000, staleTime: 30000,
enabled: options?.enabled,
}); });
} }
...@@ -76,11 +77,12 @@ export function useRevokeApiKey() { ...@@ -76,11 +77,12 @@ export function useRevokeApiKey() {
} }
// Webhooks Hooks // Webhooks Hooks
export function useWebhookConfigsList() { export function useWebhookConfigsList(options?: { enabled?: boolean }) {
return useQuery({ return useQuery({
queryKey: DEVELOPER_KEYS.webhooks, queryKey: DEVELOPER_KEYS.webhooks,
queryFn: () => developerService.listWebhookConfigs(), queryFn: () => developerService.listWebhookConfigs(),
staleTime: 30000, staleTime: 30000,
enabled: options?.enabled,
}); });
} }
......
...@@ -142,3 +142,32 @@ export function useRoleUsers(roleId: string, options?: { enabled?: boolean }) { ...@@ -142,3 +142,32 @@ export function useRoleUsers(roleId: string, options?: { enabled?: boolean }) {
!!roleId && (options?.enabled !== undefined ? options.enabled : true), !!roleId && (options?.enabled !== undefined ? options.enabled : true),
}); });
} }
export function useResetRoleQuota() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
payload,
}: {
id: string;
payload?: import("@/types/role").ResetRoleQuotaPayload;
}) => roleService.resetRoleQuota(id, payload),
onSuccess: (data) => {
toast.success(
`Đã đặt lại hạn ngạch cho ${data.affectedUsers} người dùng thuộc vai trò!`
);
queryClient.invalidateQueries({ queryKey: ROLES_QUERY_KEYS.all });
queryClient.invalidateQueries({ queryKey: ["users"] });
queryClient.invalidateQueries({ queryKey: ["auth"] });
},
onError: (error: unknown) => {
const message =
error instanceof Error
? error.message
: "Không thể đặt lại hạn ngạch cho vai trò";
toast.error(message);
},
});
}
...@@ -159,3 +159,26 @@ export function useRevokeSingleRole() { ...@@ -159,3 +159,26 @@ export function useRevokeSingleRole() {
}, },
}); });
} }
export function useResetUserQuota() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
payload,
}: {
id: string;
payload?: import("@/types/user").ResetUserQuotaPayload;
}) => userService.resetQuota(id, payload),
onSuccess: (updatedUser, { id }) => {
toast.success(`Đã đặt lại hạn ngạch cho tài khoản ${updatedUser.email}!`);
queryClient.invalidateQueries({ queryKey: USERS_QUERY_KEYS.all });
queryClient.invalidateQueries({ queryKey: USERS_QUERY_KEYS.detail(id) });
queryClient.invalidateQueries({ queryKey: ["auth"] });
},
onError: (error: Error) => {
toast.error(`Không thể đặt lại hạn ngạch: ${error.message}`);
},
});
}
This diff is collapsed.
...@@ -21,6 +21,8 @@ const ADMIN_ROUTES = [ ...@@ -21,6 +21,8 @@ const ADMIN_ROUTES = [
"/settings/developer/roles", "/settings/developer/roles",
"/settings/developer/audit-logs", "/settings/developer/audit-logs",
"/settings/developer/system-configs", "/settings/developer/system-configs",
"/settings/developer/cron",
"/cron",
]; ];
// Open public pages that do NOT require login (e.g. 403 forbidden, 404 not-found) // Open public pages that do NOT require login (e.g. 403 forbidden, 404 not-found)
......
import { z } from "zod";
export const triggerCronJobFormSchema = z.object({
paramsJson: z.string().refine(
(val) => {
if (!val || val.trim() === "") return true;
try {
const parsed = JSON.parse(val);
return (
typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
);
} catch {
return false;
}
},
{
message: "Tham số phải là định dạng JSON hợp lệ dạng Object",
},
),
});
export type TriggerCronJobFormInput = z.infer<typeof triggerCronJobFormSchema>;
...@@ -8,3 +8,5 @@ export * from "./developer.schema"; ...@@ -8,3 +8,5 @@ export * from "./developer.schema";
export * from "./profile.schema"; export * from "./profile.schema";
export * from "./user.schema"; export * from "./user.schema";
export * from "./role.schema"; export * from "./role.schema";
export * from "./cron.schema";
...@@ -19,6 +19,38 @@ export const createRoleSchema = z.object({ ...@@ -19,6 +19,38 @@ export const createRoleSchema = z.object({
.optional() .optional()
.or(z.literal("")), .or(z.literal("")),
permissionIds: z.array(z.string()).optional(), permissionIds: z.array(z.string()).optional(),
maxPagesLimit: z
.number()
.int()
.min(1, "Giới hạn trang tối thiểu là 1.")
.max(100000, "Giới hạn trang tối đa là 100,000.")
.optional(),
maxJobsPerDayLimit: z
.number()
.int()
.min(1, "Giới hạn job/ngày tối thiểu là 1.")
.max(10000, "Giới hạn job/ngày tối đa là 10,000.")
.optional(),
maxConcurrentJobsLimit: z
.number()
.int()
.min(1, "Giới hạn chạy đồng thời tối thiểu là 1.")
.max(100, "Giới hạn chạy đồng thời tối đa là 100.")
.optional(),
maxPagesPerMonthLimit: z
.number()
.int()
.min(1, "Giới hạn trang/tháng tối thiểu là 1.")
.max(1000000, "Giới hạn trang/tháng tối đa là 1,000,000.")
.nullable()
.optional(),
maxJobsPerMonthLimit: z
.number()
.int()
.min(1, "Giới hạn job/tháng tối thiểu là 1.")
.max(100000, "Giới hạn job/tháng tối đa là 100,000.")
.nullable()
.optional(),
}); });
export type CreateRoleInput = z.infer<typeof createRoleSchema>; export type CreateRoleInput = z.infer<typeof createRoleSchema>;
...@@ -34,6 +66,39 @@ export const updateRoleSchema = z.object({ ...@@ -34,6 +66,39 @@ export const updateRoleSchema = z.object({
.optional() .optional()
.or(z.literal("")), .or(z.literal("")),
isActive: z.boolean().optional(), isActive: z.boolean().optional(),
maxPagesLimit: z
.number()
.int()
.min(1, "Giới hạn trang tối thiểu là 1.")
.max(100000, "Giới hạn trang tối đa là 100,000.")
.optional(),
maxJobsPerDayLimit: z
.number()
.int()
.min(1, "Giới hạn job/ngày tối thiểu là 1.")
.max(10000, "Giới hạn job/ngày tối đa là 10,000.")
.optional(),
maxConcurrentJobsLimit: z
.number()
.int()
.min(1, "Giới hạn chạy đồng thời tối thiểu là 1.")
.max(100, "Giới hạn chạy đồng thời tối đa là 100.")
.optional(),
maxPagesPerMonthLimit: z
.number()
.int()
.min(1, "Giới hạn trang/tháng tối thiểu là 1.")
.max(1000000, "Giới hạn trang/tháng tối đa là 1,000,000.")
.nullable()
.optional(),
maxJobsPerMonthLimit: z
.number()
.int()
.min(1, "Giới hạn job/tháng tối thiểu là 1.")
.max(100000, "Giới hạn job/tháng tối đa là 100,000.")
.nullable()
.optional(),
syncUsersQuota: z.boolean().optional(),
}); });
export type UpdateRoleInput = z.infer<typeof updateRoleSchema>; export type UpdateRoleInput = z.infer<typeof updateRoleSchema>;
......
...@@ -33,6 +33,20 @@ export const createUserSchema = z.object({ ...@@ -33,6 +33,20 @@ export const createUserSchema = z.object({
.min(1, "Giới hạn chạy đồng thời tối thiểu là 1.") .min(1, "Giới hạn chạy đồng thời tối thiểu là 1.")
.max(100, "Giới hạn chạy đồng thời tối đa là 100.") .max(100, "Giới hạn chạy đồng thời tối đa là 100.")
.optional(), .optional(),
maxPagesPerMonthLimit: z
.number()
.int()
.min(1, "Giới hạn trang/tháng tối thiểu là 1.")
.max(1000000, "Giới hạn trang/tháng tối đa là 1,000,000.")
.nullable()
.optional(),
maxJobsPerMonthLimit: z
.number()
.int()
.min(1, "Giới hạn job/tháng tối thiểu là 1.")
.max(100000, "Giới hạn job/tháng tối đa là 100,000.")
.nullable()
.optional(),
}); });
export type CreateUserInput = z.infer<typeof createUserSchema>; export type CreateUserInput = z.infer<typeof createUserSchema>;
...@@ -68,6 +82,20 @@ export const updateUserSchema = z.object({ ...@@ -68,6 +82,20 @@ export const updateUserSchema = z.object({
.min(1, "Giới hạn chạy đồng thời tối thiểu là 1.") .min(1, "Giới hạn chạy đồng thời tối thiểu là 1.")
.max(100, "Giới hạn chạy đồng thời tối đa là 100.") .max(100, "Giới hạn chạy đồng thời tối đa là 100.")
.optional(), .optional(),
maxPagesPerMonthLimit: z
.number()
.int()
.min(1, "Giới hạn trang/tháng tối thiểu là 1.")
.max(1000000, "Giới hạn trang/tháng tối đa là 1,000,000.")
.nullable()
.optional(),
maxJobsPerMonthLimit: z
.number()
.int()
.min(1, "Giới hạn job/tháng tối thiểu là 1.")
.max(100000, "Giới hạn job/tháng tối đa là 100,000.")
.nullable()
.optional(),
}); });
export type UpdateUserInput = z.infer<typeof updateUserSchema>; export type UpdateUserInput = z.infer<typeof updateUserSchema>;
......
...@@ -82,6 +82,8 @@ const DEFAULT_FALLBACK_STATS: DashboardStats = { ...@@ -82,6 +82,8 @@ const DEFAULT_FALLBACK_STATS: DashboardStats = {
concurrentJobsRunning: 1, concurrentJobsRunning: 1,
concurrentJobsAvailable: 4, concurrentJobsAvailable: 4,
totalPagesCrawled: 167, totalPagesCrawled: 167,
pagesCrawledToday: 24,
pagesRemainingToday: 976,
}, },
resetAt: new Date(Date.now() + 86400000).toISOString(), resetAt: new Date(Date.now() + 86400000).toISOString(),
}, },
...@@ -139,6 +141,8 @@ export const crawlerService = { ...@@ -139,6 +141,8 @@ export const crawlerService = {
concurrentJobsRunning: runningCount, concurrentJobsRunning: runningCount,
concurrentJobsAvailable: Math.max(0, 5 - runningCount), concurrentJobsAvailable: Math.max(0, 5 - runningCount),
totalPagesCrawled: totalPages || 167, totalPagesCrawled: totalPages || 167,
pagesCrawledToday: totalPages || 0,
pagesRemainingToday: Math.max(0, 1000 - (totalPages || 0)),
}, },
resetAt: new Date(Date.now() + 86400000).toISOString(), resetAt: new Date(Date.now() + 86400000).toISOString(),
}, },
......
import { apiClient } from "@/lib/api-client";
import { ApiResponse } from "@/types/api";
import { CronJob, CronJobExecutionResult } from "@/types/cron";
export const cronService = {
/**
* Lấy danh sách toàn bộ các tác vụ định kỳ
* GET /api/proxy/cron/jobs
*/
async listJobs(search?: string): Promise<CronJob[]> {
const response = await apiClient.get<ApiResponse<CronJob[]>>("/cron/jobs", {
params: search ? { search } : undefined,
});
return response.data.data;
},
/**
* Kích hoạt chạy ngay một tác vụ thủ công
* POST /api/proxy/cron/jobs/:name/trigger
*/
async triggerJob(
name: string,
params?: Record<string, unknown>,
): Promise<CronJobExecutionResult> {
const response = await apiClient.post<ApiResponse<CronJobExecutionResult>>(
`/cron/jobs/${encodeURIComponent(name)}/trigger`,
{ params },
);
return response.data.data;
},
/**
* Bật hoặc tắt kích hoạt tự động theo lịch của tác vụ
* PATCH /api/proxy/cron/jobs/:name/toggle
*/
async toggleJob(name: string, enabled: boolean): Promise<CronJob> {
const response = await apiClient.patch<ApiResponse<CronJob>>(
`/cron/jobs/${encodeURIComponent(name)}/toggle`,
{ enabled },
);
return response.data.data;
},
};
export default cronService;
...@@ -113,6 +113,22 @@ export class RoleService { ...@@ -113,6 +113,22 @@ export class RoleService {
} }
return []; return [];
} }
/**
* 9. POST /roles/:id/reset-quota - Đặt lại hạn ngạch cho toàn bộ người dùng thuộc vai trò
*/
async resetRoleQuota(
id: string,
payload?: import("@/types/role").ResetRoleQuotaPayload
): Promise<{ affectedUsers: number }> {
const response = await apiClient.post<
ApiResponse<{ affectedUsers: number }>
>(`/roles/${id}/reset-quota`, payload || {});
if (response.data?.data) {
return response.data.data;
}
throw new Error("Không thể đặt lại hạn ngạch vai trò");
}
} }
export const roleService = new RoleService(); export const roleService = new RoleService();
......
...@@ -131,6 +131,23 @@ export class UserService { ...@@ -131,6 +131,23 @@ export class UserService {
} }
throw new Error("Không thể thu hồi vai trò"); throw new Error("Không thể thu hồi vai trò");
} }
/**
* 10. POST /users/:id/reset-quota - Đặt lại hạn ngạch (quota) của người dùng
*/
async resetQuota(
id: string,
payload?: import("@/types/user").ResetUserQuotaPayload
): Promise<UserItem> {
const response = await apiClient.post<ApiResponse<UserItem>>(
`/users/${id}/reset-quota`,
payload || {}
);
if (response.data?.data) {
return response.data.data;
}
throw new Error("Không thể đặt lại hạn ngạch người dùng");
}
} }
export const userService = new UserService(); export const userService = new UserService();
......
...@@ -12,6 +12,9 @@ export interface User { ...@@ -12,6 +12,9 @@ export interface User {
maxPagesLimit: number; maxPagesLimit: number;
maxJobsPerDayLimit: number; maxJobsPerDayLimit: number;
maxConcurrentJobsLimit: number; maxConcurrentJobsLimit: number;
maxPagesPerMonthLimit?: number | null;
maxJobsPerMonthLimit?: number | null;
quotaResetAt?: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
...@@ -53,6 +56,8 @@ export interface UserQuota { ...@@ -53,6 +56,8 @@ export interface UserQuota {
maxPagesLimit: number; maxPagesLimit: number;
maxJobsPerDayLimit: number; maxJobsPerDayLimit: number;
maxConcurrentJobsLimit: number; maxConcurrentJobsLimit: number;
maxPagesPerMonthLimit?: number | null;
maxJobsPerMonthLimit?: number | null;
} }
export interface UserUsage { export interface UserUsage {
...@@ -61,12 +66,20 @@ export interface UserUsage { ...@@ -61,12 +66,20 @@ export interface UserUsage {
concurrentJobsRunning: number; concurrentJobsRunning: number;
concurrentJobsAvailable: number; concurrentJobsAvailable: number;
totalPagesCrawled: number; totalPagesCrawled: number;
pagesCrawledToday: number;
pagesRemainingToday: number;
jobsUsedThisMonth?: number;
jobsRemainingThisMonth?: number | null;
pagesCrawledThisMonth?: number;
pagesRemainingThisMonth?: number | null;
} }
export interface UserUsageDto { export interface UserUsageDto {
quota: UserQuota; quota: UserQuota;
usage: UserUsage; usage: UserUsage;
resetAt: string; resetAt: string;
monthlyResetAt?: string;
quotaResetAt?: string | null;
} }
export interface RolePermissionItemDto { export interface RolePermissionItemDto {
...@@ -84,6 +97,11 @@ export interface Role { ...@@ -84,6 +97,11 @@ export interface Role {
description: string | null; description: string | null;
isSystem: boolean; isSystem: boolean;
isActive: boolean; isActive: boolean;
maxPagesLimit?: number;
maxJobsPerDayLimit?: number;
maxConcurrentJobsLimit?: number;
maxPagesPerMonthLimit?: number | null;
maxJobsPerMonthLimit?: number | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
permissions?: RolePermissionItemDto[]; permissions?: RolePermissionItemDto[];
......
export type CronJobStatus = "READY" | "RUNNING" | "SUCCESS" | "FAILED";
export interface CronJob {
name: string;
cron: string;
description: string;
isEnabled: boolean;
lastRun?: string;
lastStatus?: CronJobStatus;
lastDurationMs?: number;
}
export interface CronJobExecutionResult {
jobName: string;
success: boolean;
durationMs: number;
data?: Record<string, unknown>;
error?: string;
}
export interface TriggerJobInput {
params?: Record<string, unknown>;
}
export interface ToggleJobInput {
enabled: boolean;
}
...@@ -11,3 +11,5 @@ export * from "./developer"; ...@@ -11,3 +11,5 @@ export * from "./developer";
export * from "./user"; export * from "./user";
export * from "./role"; export * from "./role";
export * from "./permission"; export * from "./permission";
export * from "./cron";
...@@ -21,6 +21,11 @@ export interface RoleItem { ...@@ -21,6 +21,11 @@ export interface RoleItem {
description: string | null; description: string | null;
isSystem: boolean; isSystem: boolean;
isActive: boolean; isActive: boolean;
maxPagesLimit?: number;
maxJobsPerDayLimit?: number;
maxConcurrentJobsLimit?: number;
maxPagesPerMonthLimit?: number | null;
maxJobsPerMonthLimit?: number | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
permissions?: RolePermissionItem[]; permissions?: RolePermissionItem[];
...@@ -32,12 +37,27 @@ export interface CreateRoleDto { ...@@ -32,12 +37,27 @@ export interface CreateRoleDto {
slug: string; slug: string;
description?: string; description?: string;
permissionIds?: string[]; permissionIds?: string[];
maxPagesLimit?: number;
maxJobsPerDayLimit?: number;
maxConcurrentJobsLimit?: number;
maxPagesPerMonthLimit?: number | null;
maxJobsPerMonthLimit?: number | null;
} }
export interface UpdateRoleDto { export interface UpdateRoleDto {
name?: string; name?: string;
description?: string; description?: string;
isActive?: boolean; isActive?: boolean;
maxPagesLimit?: number;
maxJobsPerDayLimit?: number;
maxConcurrentJobsLimit?: number;
maxPagesPerMonthLimit?: number | null;
maxJobsPerMonthLimit?: number | null;
syncUsersQuota?: boolean;
}
export interface ResetRoleQuotaPayload {
syncLimits?: boolean;
} }
export interface AssignRolePermissionsDto { export interface AssignRolePermissionsDto {
......
...@@ -19,6 +19,9 @@ export interface UserItem { ...@@ -19,6 +19,9 @@ export interface UserItem {
maxPagesLimit: number; maxPagesLimit: number;
maxJobsPerDayLimit: number; maxJobsPerDayLimit: number;
maxConcurrentJobsLimit: number; maxConcurrentJobsLimit: number;
maxPagesPerMonthLimit?: number | null;
maxJobsPerMonthLimit?: number | null;
quotaResetAt?: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
roles?: UserAssignedRole[]; roles?: UserAssignedRole[];
...@@ -33,6 +36,8 @@ export interface CreateUserDto { ...@@ -33,6 +36,8 @@ export interface CreateUserDto {
maxPagesLimit?: number; maxPagesLimit?: number;
maxJobsPerDayLimit?: number; maxJobsPerDayLimit?: number;
maxConcurrentJobsLimit?: number; maxConcurrentJobsLimit?: number;
maxPagesPerMonthLimit?: number | null;
maxJobsPerMonthLimit?: number | null;
} }
export interface UpdateUserDto { export interface UpdateUserDto {
...@@ -44,6 +49,12 @@ export interface UpdateUserDto { ...@@ -44,6 +49,12 @@ export interface UpdateUserDto {
maxPagesLimit?: number; maxPagesLimit?: number;
maxJobsPerDayLimit?: number; maxJobsPerDayLimit?: number;
maxConcurrentJobsLimit?: number; maxConcurrentJobsLimit?: number;
maxPagesPerMonthLimit?: number | null;
maxJobsPerMonthLimit?: number | null;
}
export interface ResetUserQuotaPayload {
resetLimitsToRole?: boolean;
} }
export interface UserQueryParams { export interface UserQueryParams {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment