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 */}
......
"use client";
import React, { useState, useMemo } from "react";
import {
CalendarClock,
Zap,
PauseCircle,
Server,
Search,
RotateCcw,
Play,
CheckCircle2,
XCircle,
Clock,
AlertTriangle,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { TablePagination } from "@/components/common/table-pagination";
import { CronTriggerDialog } from "./cron-trigger-dialog";
import { useLanguage } from "@/providers/language-provider";
import { useCronJobsList, useToggleCronJob } from "@/hooks/use-cron";
import { formatDate } from "@/lib/utils";
import { CronJob } from "@/types/cron";
export function CronJobsView() {
const { t, locale } = useLanguage();
const [search, setSearch] = useState("");
const [selectedJob, setSelectedJob] = useState<CronJob | null>(null);
const [isTriggerOpen, setIsTriggerOpen] = useState(false);
// Pagination state
const [page, setPage] = useState(1);
const [limit, setLimit] = useState(10);
const {
data: jobs = [],
isLoading,
isError,
error,
refetch,
isFetching,
} = useCronJobsList(search);
const toggleMutation = useToggleCronJob();
// Metrics computation
const totalCount = jobs.length;
const activeCount = useMemo(
() => jobs.filter((j) => j.isEnabled).length,
[jobs],
);
const pausedCount = useMemo(
() => jobs.filter((j) => !j.isEnabled).length,
[jobs],
);
// Pagination computation
const totalPages = Math.ceil(totalCount / limit) || 1;
const paginatedJobs = useMemo(() => {
const start = (page - 1) * limit;
return jobs.slice(start, start + limit);
}, [jobs, page, limit]);
const handleToggle = async (job: CronJob) => {
const nextState = !job.isEnabled;
try {
await toggleMutation.mutateAsync({
name: job.name,
enabled: nextState,
});
toast.success(
nextState
? `${t.cron.toast.toggleEnabled} ${job.name}`
: `${t.cron.toast.toggleDisabled} ${job.name}`,
);
} catch {
toast.error(t.cron.toast.toggleError);
}
};
const handleOpenTrigger = (job: CronJob) => {
setSelectedJob(job);
setIsTriggerOpen(true);
};
const renderStatusBadge = (status?: string) => {
switch (status) {
case "SUCCESS":
return (
<span className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[11px] font-semibold bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<CheckCircle2 className="h-3 w-3" />
{t.cron.table.success}
</span>
);
case "FAILED":
return (
<span className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[11px] font-semibold bg-red-500/10 text-red-600 dark:text-red-400 border border-red-500/20">
<XCircle className="h-3 w-3" />
{t.cron.table.failed}
</span>
);
case "RUNNING":
return (
<span className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[11px] font-semibold bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 border border-cyan-500/20 animate-pulse">
<Clock className="h-3 w-3 animate-spin" />
{t.cron.table.running}
</span>
);
default:
return (
<span className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[11px] font-medium bg-muted text-muted-foreground border border-border/80">
<Clock className="h-3 w-3" />
{t.cron.table.ready}
</span>
);
}
};
return (
<div className="space-y-6">
{/* 1. Impact Metrics KPI Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Total Tasks */}
<div className="rounded-3xl border border-emerald-500/15 bg-card/60 p-5 shadow-sm backdrop-blur-sm transition-all hover:scale-[1.01] hover:border-emerald-500/30">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-muted-foreground">
{t.cron.stats.totalJobs}
</span>
<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">
<CalendarClock className="h-4 w-4" />
</div>
</div>
<div className="mt-3">
<div className="text-2xl font-extrabold text-foreground font-mono">
{totalCount}
</div>
<p className="text-[11px] text-muted-foreground mt-0.5">
{t.cron.stats.totalJobsDesc}
</p>
</div>
</div>
{/* Active Schedules */}
<div className="rounded-3xl border border-emerald-500/15 bg-card/60 p-5 shadow-sm backdrop-blur-sm transition-all hover:scale-[1.01] hover:border-emerald-500/30">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-muted-foreground">
{t.cron.stats.activeSchedules}
</span>
<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">
<Zap className="h-4 w-4" />
</div>
</div>
<div className="mt-3">
<div className="text-2xl font-extrabold text-emerald-600 dark:text-emerald-400 font-mono">
{activeCount}
</div>
<p className="text-[11px] text-muted-foreground mt-0.5">
{t.cron.stats.activeSchedulesDesc}
</p>
</div>
</div>
{/* Paused Schedules */}
<div className="rounded-3xl border border-emerald-500/15 bg-card/60 p-5 shadow-sm backdrop-blur-sm transition-all hover:scale-[1.01] hover:border-emerald-500/30">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-muted-foreground">
{t.cron.stats.pausedSchedules}
</span>
<div className="flex h-9 w-9 items-center justify-center rounded-2xl bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20">
<PauseCircle className="h-4 w-4" />
</div>
</div>
<div className="mt-3">
<div className="text-2xl font-extrabold text-amber-600 dark:text-amber-400 font-mono">
{pausedCount}
</div>
<p className="text-[11px] text-muted-foreground mt-0.5">
{t.cron.stats.pausedSchedulesDesc}
</p>
</div>
</div>
{/* Engine Status & Timezone */}
<div className="rounded-3xl border border-emerald-500/15 bg-card/60 p-5 shadow-sm backdrop-blur-sm transition-all hover:scale-[1.01] hover:border-emerald-500/30">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-muted-foreground">
{t.cron.stats.engineStatus}
</span>
<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">
<Server className="h-4 w-4" />
</div>
</div>
<div className="mt-3 flex flex-col gap-1.5">
<div className="flex items-center gap-1.5 text-xs font-bold text-emerald-600 dark:text-emerald-400">
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
<span>{t.cron.stats.engineActive}</span>
</div>
<Badge
variant="outline"
className="w-fit font-mono text-[10px] bg-emerald-500/10 border-emerald-500/25 text-emerald-700 dark:text-emerald-300"
>
🕒 {t.cron.timezoneBadge}
</Badge>
</div>
</div>
</div>
{/* 2. Search & Toolbar Controls */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 relative z-20">
<div className="relative w-full sm:max-w-md">
<Search className="absolute left-3.5 top-3 h-4 w-4 text-muted-foreground/60" />
<input
type="text"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
placeholder={t.cron.searchPlaceholder}
className="w-full rounded-2xl border border-border/80 bg-card/70 pl-10 pr-4 py-2 text-xs text-foreground placeholder:text-muted-foreground/60 focus:border-emerald-500/40 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 transition-colors backdrop-blur-sm"
/>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
size="sm"
variant="outline"
onClick={() => refetch()}
disabled={isFetching}
className="rounded-2xl border-border/80 text-xs gap-1.5 cursor-pointer hover:bg-muted/70"
>
<RotateCcw
className={`h-3.5 w-3.5 ${isFetching ? "animate-spin text-emerald-500" : ""}`}
/>
<span>{t.cron.errorRetry}</span>
</Button>
</div>
</div>
{/* 3. Unified Table Card */}
<div className="overflow-hidden rounded-3xl border border-emerald-500/15 bg-card/60 shadow-sm backdrop-blur-sm transition-colors relative z-10">
{/* Error State */}
{isError && (
<div className="p-12 text-center space-y-3">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-red-500/10 text-red-500 border border-red-500/20">
<AlertTriangle className="h-6 w-6" />
</div>
<h3 className="text-sm font-bold text-foreground">
{t.cron.errorTitle}
</h3>
<p className="text-xs text-muted-foreground max-w-sm mx-auto">
{error instanceof Error ? error.message : "Network error"}
</p>
<Button
size="sm"
onClick={() => refetch()}
className="rounded-2xl bg-emerald-600 hover:bg-emerald-700 text-white text-xs cursor-pointer"
>
{t.cron.errorRetry}
</Button>
</div>
)}
{/* Loading Skeleton */}
{isLoading && (
<div className="p-6 space-y-4">
{[1, 2, 3, 4, 5].map((i) => (
<div
key={i}
className="h-14 rounded-2xl bg-muted/40 animate-pulse"
/>
))}
</div>
)}
{/* Empty State */}
{!isLoading && !isError && jobs.length === 0 && (
<div className="p-12 text-center space-y-3">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
<CalendarClock className="h-6 w-6" />
</div>
<h3 className="text-sm font-bold text-foreground">
{t.cron.emptyTitle}
</h3>
<p className="text-xs text-muted-foreground max-w-sm mx-auto">
{t.cron.emptyDesc}
</p>
{search && (
<Button
size="sm"
variant="outline"
onClick={() => setSearch("")}
className="rounded-2xl text-xs cursor-pointer"
>
Xóa bộ lọc tìm kiếm
</Button>
)}
</div>
)}
{/* Data Table */}
{!isLoading && !isError && jobs.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse text-xs">
<thead>
<tr className="border-b border-border/70 bg-muted/30 text-muted-foreground font-semibold">
<th className="py-3.5 px-4">{t.cron.table.jobName}</th>
<th className="py-3.5 px-4">{t.cron.table.cronPattern}</th>
<th className="py-3.5 px-4">{t.cron.table.lastRun}</th>
<th className="py-3.5 px-4 text-center">
{t.cron.table.status}
</th>
<th className="py-3.5 px-4 text-center">
{t.cron.table.autoSchedule}
</th>
<th className="py-3.5 px-4 text-right">
{t.cron.table.actions}
</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{paginatedJobs.map((job) => (
<tr
key={job.name}
className="hover:bg-muted/30 transition-colors"
>
{/* Tên & Mô tả */}
<td className="py-3.5 px-4 max-w-sm">
<div className="font-mono font-bold text-foreground">
{job.name}
</div>
<div className="text-[11px] text-muted-foreground line-clamp-1 mt-0.5">
{job.description}
</div>
</td>
{/* Cron Expression */}
<td className="py-3.5 px-4 whitespace-nowrap">
<Badge
variant="outline"
className="font-mono text-[11px] bg-background/80 border-border/80 text-foreground"
>
{job.cron}
</Badge>
</td>
{/* Last Run & Duration */}
<td className="py-3.5 px-4 whitespace-nowrap text-muted-foreground">
{job.lastRun ? (
<div>
<div>{formatDate(job.lastRun, locale)}</div>
{typeof job.lastDurationMs === "number" && (
<div className="text-[10px] text-muted-foreground/70 font-mono">
{job.lastDurationMs}ms
</div>
)}
</div>
) : (
<span className="text-[11px] italic text-muted-foreground/50">
{t.cron.table.neverRun}
</span>
)}
</td>
{/* Status */}
<td className="py-3.5 px-4 whitespace-nowrap text-center">
{renderStatusBadge(job.lastStatus)}
</td>
{/* Switch Toggle */}
<td className="py-3.5 px-4 whitespace-nowrap text-center">
<div className="inline-flex items-center gap-1.5">
<button
type="button"
role="switch"
aria-checked={job.isEnabled}
title={
job.isEnabled
? t.cron.table.statusOn
: t.cron.table.statusOff
}
onClick={() => handleToggle(job)}
disabled={toggleMutation.isPending}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-emerald-500/30 ${
job.isEnabled
? "bg-emerald-600 dark:bg-emerald-500"
: "bg-muted-foreground/30"
}`}
>
<span
className={`pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow-md ring-0 transition duration-200 ease-in-out ${
job.isEnabled ? "translate-x-4" : "translate-x-0"
}`}
/>
</button>
<span className="text-[10px] font-semibold text-muted-foreground w-6 text-left">
{job.isEnabled
? t.cron.table.statusOn
: t.cron.table.statusOff}
</span>
</div>
</td>
{/* Action: Trigger Now */}
<td className="py-3.5 px-4 whitespace-nowrap text-right">
<Button
size="sm"
variant="outline"
onClick={() => handleOpenTrigger(job)}
className="rounded-xl border-emerald-500/25 bg-emerald-500/5 hover:bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 text-xs font-semibold px-2.5 h-8 gap-1.5 cursor-pointer transition-all hover:scale-[1.02]"
title={t.cron.table.triggerTooltip}
>
<Play className="h-3.5 w-3.5 fill-current" />
<span>{t.cron.table.triggerNow}</span>
</Button>
</td>
</tr>
))}
</tbody>
</table>
{/* Bottom Pagination */}
<TablePagination
page={page}
totalPages={totalPages}
total={totalCount}
limit={limit}
onPageChange={setPage}
onLimitChange={(newLimit) => {
setLimit(newLimit);
setPage(1);
}}
options={[5, 10, 20]}
/>
</div>
)}
</div>
{/* 4. Trigger Modal Dialog */}
<CronTriggerDialog
job={selectedJob}
open={isTriggerOpen}
onOpenChange={setIsTriggerOpen}
/>
</div>
);
}
"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>
);
}
"use client"; "use client";
import React from "react"; import React, { useState } from "react";
import { UserUsageDto } from "@/types/auth"; import { UserUsageDto } from "@/types/auth";
import { useUserUsage } from "@/hooks/use-usage"; import { useUserUsage } from "@/hooks/use-usage";
import { useLanguage } from "@/providers/language-provider"; import { useLanguage } from "@/providers/language-provider";
import { useAuth } from "@/hooks/use-auth";
import { useResetUserQuota } from "@/hooks/use-users";
import { import {
AlertTriangle, AlertTriangle,
Clock, Clock,
...@@ -12,17 +14,36 @@ import { ...@@ -12,17 +14,36 @@ import {
Layers, Layers,
Sparkles, Sparkles,
Zap, Zap,
Calendar,
RotateCcw,
CheckCircle2,
X,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn, formatNumber } from "@/lib/utils"; import { cn, formatNumber } from "@/lib/utils";
interface QuotaUsageWidgetProps { interface QuotaUsageWidgetProps {
initialUsage?: UserUsageDto; initialUsage?: UserUsageDto;
className?: string; className?: string;
title?: string;
subtitle?: string;
showMonthly?: boolean;
} }
export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetProps) { export function QuotaUsageWidget({
initialUsage,
className,
title,
subtitle,
showMonthly = false,
}: QuotaUsageWidgetProps) {
const { data: fetchedUsage, isLoading } = useUserUsage(); const { data: fetchedUsage, isLoading } = useUserUsage();
const { t } = useLanguage(); const { t } = useLanguage();
const { user } = useAuth();
const resetMutation = useResetUserQuota();
const [isResetConfirmOpen, setIsResetConfirmOpen] = useState(false);
const [resetLimitsToRole, setResetLimitsToRole] = useState(false);
// Ưu tiên dữ liệu truyền từ dashboard stats nếu có, fallback sang fetchedUsage // Ưu tiên dữ liệu truyền từ dashboard stats nếu có, fallback sang fetchedUsage
const usageData = initialUsage || fetchedUsage; const usageData = initialUsage || fetchedUsage;
...@@ -31,6 +52,8 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr ...@@ -31,6 +52,8 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr
maxPagesLimit: 1000, maxPagesLimit: 1000,
maxJobsPerDayLimit: 50, maxJobsPerDayLimit: 50,
maxConcurrentJobsLimit: 5, maxConcurrentJobsLimit: 5,
maxPagesPerMonthLimit: 10000,
maxJobsPerMonthLimit: 500,
}; };
const usage = usageData?.usage || { const usage = usageData?.usage || {
...@@ -39,13 +62,23 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr ...@@ -39,13 +62,23 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr
concurrentJobsRunning: 0, concurrentJobsRunning: 0,
concurrentJobsAvailable: 5, concurrentJobsAvailable: 5,
totalPagesCrawled: 0, totalPagesCrawled: 0,
pagesCrawledToday: 0,
pagesRemainingToday: 100,
jobsUsedThisMonth: 0,
jobsRemainingThisMonth: 500,
pagesCrawledThisMonth: 0,
pagesRemainingThisMonth: 10000,
}; };
// Tính tỷ lệ phần trăm sử dụng const pagesCrawledToday = usage.pagesCrawledToday ?? 0;
const pagesCrawledThisMonth = usage.pagesCrawledThisMonth ?? pagesCrawledToday;
const jobsUsedThisMonth = usage.jobsUsedThisMonth ?? usage.jobsUsedToday;
// Tính tỷ lệ phần trăm sử dụng trong ngày
const pagesPercent = Math.min( const pagesPercent = Math.min(
100, 100,
quota.maxPagesLimit > 0 quota.maxPagesLimit > 0
? Math.round((usage.totalPagesCrawled / quota.maxPagesLimit) * 100) ? Math.round((pagesCrawledToday / quota.maxPagesLimit) * 100)
: 0 : 0
); );
...@@ -63,9 +96,39 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr ...@@ -63,9 +96,39 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr
: 0 : 0
); );
// Tính tỷ lệ phần trăm sử dụng trong tháng (nếu có giới hạn)
const hasMonthlyQuota =
quota.maxPagesPerMonthLimit !== null ||
quota.maxJobsPerMonthLimit !== null ||
usage.pagesCrawledThisMonth !== undefined;
const monthlyPagesPercent =
quota.maxPagesPerMonthLimit && quota.maxPagesPerMonthLimit > 0
? Math.min(100, Math.round((pagesCrawledThisMonth / quota.maxPagesPerMonthLimit) * 100))
: null;
const monthlyJobsPercent =
quota.maxJobsPerMonthLimit && quota.maxJobsPerMonthLimit > 0
? Math.min(100, Math.round((jobsUsedThisMonth / quota.maxJobsPerMonthLimit) * 100))
: null;
const shouldShowMonthly = Boolean(showMonthly && hasMonthlyQuota);
// Ngưỡng cảnh báo: Cảnh báo khi chạm 80%, Báo động khi đạt 100% // Ngưỡng cảnh báo: Cảnh báo khi chạm 80%, Báo động khi đạt 100%
const isCritical = jobsDailyPercent >= 100 || concurrentPercent >= 100 || pagesPercent >= 100; const isCritical =
const isWarning = !isCritical && (jobsDailyPercent >= 80 || concurrentPercent >= 80 || pagesPercent >= 80); jobsDailyPercent >= 100 ||
concurrentPercent >= 100 ||
pagesPercent >= 100 ||
(shouldShowMonthly && monthlyPagesPercent !== null && monthlyPagesPercent >= 100) ||
(shouldShowMonthly && monthlyJobsPercent !== null && monthlyJobsPercent >= 100);
const isWarning =
!isCritical &&
(jobsDailyPercent >= 80 ||
concurrentPercent >= 80 ||
pagesPercent >= 80 ||
(shouldShowMonthly && monthlyPagesPercent !== null && monthlyPagesPercent >= 80) ||
(shouldShowMonthly && monthlyJobsPercent !== null && monthlyJobsPercent >= 80));
// Định dạng thời gian reset (deterministic hours:minutes để tránh hydration mismatch) // Định dạng thời gian reset (deterministic hours:minutes để tránh hydration mismatch)
const formatResetTime = (isoString?: string) => { const formatResetTime = (isoString?: string) => {
...@@ -80,6 +143,29 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr ...@@ -80,6 +143,29 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr
} }
}; };
const formatMonthlyResetDate = (isoString?: string) => {
if (!isoString) return "Đầu tháng tới";
try {
const date = new Date(isoString);
const day = String(date.getDate()).padStart(2, "0");
const month = String(date.getMonth() + 1).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${day}/${month} ${hours}:${minutes}`;
} catch {
return "Đầu tháng tới";
}
};
const handleResetQuota = async () => {
if (!user?.id) return;
await resetMutation.mutateAsync({
id: user.id,
payload: { resetLimitsToRole },
});
setIsResetConfirmOpen(false);
};
return ( return (
<div <div
className={cn( className={cn(
...@@ -95,13 +181,15 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr ...@@ -95,13 +181,15 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr
<div> <div>
<div className="inline-flex items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-0.5 text-xs font-semibold text-emerald-600 dark:text-emerald-400 mb-2"> <div className="inline-flex items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-0.5 text-xs font-semibold text-emerald-600 dark:text-emerald-400 mb-2">
<Gauge className="h-3.5 w-3.5" /> <Gauge className="h-3.5 w-3.5" />
<span>{t.quota.title}</span> <span>{title || t.quota.title}</span>
</div> </div>
<p className="text-xs text-muted-foreground">{t.quota.subtitle}</p> <p className="text-xs text-muted-foreground">{subtitle || t.quota.subtitle}</p>
</div> </div>
{/* Reset At Badge */} {/* Reset Badges & Quick Reset Action */}
<div className="inline-flex items-center gap-1.5 self-start sm:self-auto rounded-xl bg-muted/40 border border-border/60 px-3 py-1.5 text-xs text-muted-foreground"> <div className="flex flex-wrap items-center gap-2">
{/* Daily Reset Countdown */}
<div className="inline-flex items-center gap-1.5 rounded-xl bg-muted/40 border border-border/60 px-3 py-1.5 text-xs text-muted-foreground">
<Clock className="h-3.5 w-3.5 text-emerald-500" /> <Clock className="h-3.5 w-3.5 text-emerald-500" />
<span suppressHydrationWarning> <span suppressHydrationWarning>
{t.quota.resetCountdown}:{" "} {t.quota.resetCountdown}:{" "}
...@@ -110,11 +198,39 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr ...@@ -110,11 +198,39 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr
</strong> </strong>
</span> </span>
</div> </div>
{/* Monthly Reset Badge (if monthly exists and showMonthly is true) */}
{shouldShowMonthly && (
<div className="inline-flex items-center gap-1.5 rounded-xl bg-muted/40 border border-border/60 px-3 py-1.5 text-xs text-muted-foreground">
<Calendar className="h-3.5 w-3.5 text-teal-500" />
<span suppressHydrationWarning>
{t.quota.monthlyResetAt}:{" "}
<strong className="text-foreground font-semibold">
{formatMonthlyResetDate(usageData?.monthlyResetAt)}
</strong>
</span>
</div>
)}
{/* Reset Quota Button */}
{user && (
<Button
variant="outline"
size="sm"
onClick={() => setIsResetConfirmOpen(true)}
className="h-8 rounded-xl border-emerald-500/30 hover:bg-emerald-500/10 hover:text-emerald-600 dark:hover:text-emerald-400 text-xs font-medium gap-1.5 cursor-pointer shadow-2xs transition-all"
title={t.quota.resetBtn}
>
<RotateCcw className="h-3.5 w-3.5 text-emerald-500" />
<span>{t.quota.resetBtn}</span>
</Button>
)}
</div>
</div> </div>
{/* Visual Threshold Alert Banner (Biophilic Alert Box) */} {/* Visual Threshold Alert Banner (Biophilic Alert Box) */}
{isCritical ? ( {isCritical ? (
<div className="mb-6 flex items-start gap-3 rounded-2xl border border-rose-500/40 bg-rose-500/10 p-4 text-rose-600 dark:text-rose-400"> <div className="mb-6 flex items-start gap-3 rounded-2xl border border-rose-500/40 bg-rose-500/10 p-4 text-rose-600 dark:text-rose-400 animate-in fade-in-50 duration-200">
<AlertTriangle className="h-5 w-5 shrink-0 mt-0.5" /> <AlertTriangle className="h-5 w-5 shrink-0 mt-0.5" />
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
...@@ -126,7 +242,7 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr ...@@ -126,7 +242,7 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr
</div> </div>
</div> </div>
) : isWarning ? ( ) : isWarning ? (
<div className="mb-6 flex items-start gap-3 rounded-2xl border border-amber-500/40 bg-amber-500/10 p-4 text-amber-600 dark:text-amber-400"> <div className="mb-6 flex items-start gap-3 rounded-2xl border border-amber-500/40 bg-amber-500/10 p-4 text-amber-600 dark:text-amber-400 animate-in fade-in-50 duration-200">
<Info className="h-5 w-5 shrink-0 mt-0.5" /> <Info className="h-5 w-5 shrink-0 mt-0.5" />
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
...@@ -140,10 +256,22 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr ...@@ -140,10 +256,22 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr
</div> </div>
) : null} ) : null}
{/* 3 Main Quota Progress Cards */} {/* SECTION 1: HẠN NGẠCH TRONG NGÀY (Daily Quota) */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
<Clock className="h-3 w-3 text-emerald-500" />
{t.quota.dailyTitle}
</span>
<div className="flex-1 h-px bg-border/40" />
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Card 1: Số trang tối đa được cào */} {/* Card 1: Số trang cào trong ngày */}
<div className="rounded-2xl border border-border/70 bg-card/60 p-4 space-y-3 transition-colors hover:border-emerald-500/30"> <div
className="rounded-2xl border border-border/70 bg-card/60 p-4 space-y-3 transition-colors hover:border-emerald-500/30"
title={`${t.quota.pagesAllTime}: ${formatNumber(usage.totalPagesCrawled)}`}
>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground flex items-center gap-1.5"> <span className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-emerald-500" /> <Sparkles className="h-3.5 w-3.5 text-emerald-500" />
...@@ -159,7 +287,7 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr ...@@ -159,7 +287,7 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr
className="text-xl font-extrabold text-foreground" className="text-xl font-extrabold text-foreground"
suppressHydrationWarning suppressHydrationWarning
> >
{isLoading ? "..." : formatNumber(usage.totalPagesCrawled)} {isLoading ? "..." : formatNumber(pagesCrawledToday)}
</span> </span>
<span <span
className="text-xs text-muted-foreground" className="text-xs text-muted-foreground"
...@@ -208,7 +336,11 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr ...@@ -208,7 +336,11 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr
className="text-xs text-muted-foreground" className="text-xs text-muted-foreground"
suppressHydrationWarning suppressHydrationWarning
> >
/ {formatNumber(quota.maxJobsPerDayLimit)} ({t.quota.jobsRemaining}: <strong className="text-emerald-500 font-bold">{formatNumber(usage.jobsRemainingToday)}</strong>) / {formatNumber(quota.maxJobsPerDayLimit)} ({t.quota.jobsRemaining}:{" "}
<strong className="text-emerald-500 font-bold">
{formatNumber(usage.jobsRemainingToday)}
</strong>
)
</span> </span>
</div> </div>
...@@ -258,7 +390,11 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr ...@@ -258,7 +390,11 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr
className="text-xs text-muted-foreground" className="text-xs text-muted-foreground"
suppressHydrationWarning suppressHydrationWarning
> >
{t.quota.concurrentRunning}: <strong className="text-teal-500 font-bold">{formatNumber(usage.concurrentJobsRunning)}</strong> / {formatNumber(quota.maxConcurrentJobsLimit)} {t.quota.concurrentRunning}:{" "}
<strong className="text-teal-500 font-bold">
{formatNumber(usage.concurrentJobsRunning)}
</strong>{" "}
/ {formatNumber(quota.maxConcurrentJobsLimit)}
</span> </span>
</div> </div>
...@@ -282,12 +418,194 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr ...@@ -282,12 +418,194 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr
</div> </div>
</div> </div>
</div> </div>
</div>
{/* SECTION 2: HẠN NGẠCH TRONG THÁNG (Monthly Quota) - Hiển thị khi showMonthly = true */}
{shouldShowMonthly && (
<div className="mt-6 space-y-3">
<div className="flex items-center gap-2">
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
<Calendar className="h-3 w-3 text-teal-500" />
{t.quota.monthlyTitle}
</span>
<div className="flex-1 h-px bg-border/40" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Monthly Card 1: Số trang cào trong tháng */}
<div className="rounded-2xl border border-border/70 bg-card/60 p-4 space-y-3 transition-colors hover:border-teal-500/30">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-teal-500" />
{t.quota.monthlyPagesLimit}
</span>
<span className="text-xs font-bold text-foreground">
{monthlyPagesPercent !== null
? `${monthlyPagesPercent}%`
: t.quota.unlimited}
</span>
</div>
<div className="flex items-baseline justify-between">
<span
className="text-xl font-extrabold text-foreground"
suppressHydrationWarning
>
{isLoading ? "..." : formatNumber(pagesCrawledThisMonth)}
</span>
<span
className="text-xs text-muted-foreground"
suppressHydrationWarning
>
{quota.maxPagesPerMonthLimit
? `/ ${formatNumber(quota.maxPagesPerMonthLimit)} ${t.quota.monthlyPagesUsed.toLowerCase()}`
: `(${t.quota.unlimited})`}
</span>
</div>
{/* Monthly Progress Bar */}
<div className="h-2 w-full rounded-full bg-muted/60 overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all duration-500",
monthlyPagesPercent !== null && monthlyPagesPercent >= 90
? "bg-rose-500"
: monthlyPagesPercent !== null && monthlyPagesPercent >= 75
? "bg-amber-500"
: "bg-gradient-to-r from-teal-500 to-emerald-400"
)}
style={{ width: `${monthlyPagesPercent ?? 10}%` }}
/>
</div>
</div>
{/* Monthly Card 2: Số tác vụ cào trong tháng */}
<div className="rounded-2xl border border-border/70 bg-card/60 p-4 space-y-3 transition-colors hover:border-emerald-500/30">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
<Zap className="h-3.5 w-3.5 text-emerald-500" />
{t.quota.monthlyJobsLimit}
</span>
<span className="text-xs font-bold text-foreground">
{monthlyJobsPercent !== null
? `${monthlyJobsPercent}%`
: t.quota.unlimited}
</span>
</div>
<div className="flex items-baseline justify-between">
<span
className="text-xl font-extrabold text-foreground"
suppressHydrationWarning
>
{isLoading ? "..." : formatNumber(jobsUsedThisMonth)}
</span>
<span
className="text-xs text-muted-foreground"
suppressHydrationWarning
>
{quota.maxJobsPerMonthLimit ? (
<>
/ {formatNumber(quota.maxJobsPerMonthLimit)} (
{t.quota.monthlyJobsRemaining}:{" "}
<strong className="text-emerald-500 font-bold">
{formatNumber(
usage.jobsRemainingThisMonth ??
Math.max(0, quota.maxJobsPerMonthLimit - jobsUsedThisMonth)
)}
</strong>
)
</>
) : (
`(${t.quota.unlimited})`
)}
</span>
</div>
{/* Monthly Jobs Progress Bar */}
<div className="h-2 w-full rounded-full bg-muted/60 overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all duration-500",
monthlyJobsPercent !== null && monthlyJobsPercent >= 90
? "bg-rose-500"
: monthlyJobsPercent !== null && monthlyJobsPercent >= 75
? "bg-amber-500"
: "bg-gradient-to-r from-emerald-500 to-cyan-400"
)}
style={{ width: `${monthlyJobsPercent ?? 10}%` }}
/>
</div>
</div>
</div>
</div>
)}
{/* Quota Exemption Policy Note */} {/* Quota Exemption Policy Note */}
<div className="mt-4 flex items-center gap-2 rounded-2xl border border-emerald-500/20 bg-emerald-500/5 px-3 py-2 text-xs text-muted-foreground"> <div className="mt-4 flex items-center gap-2 rounded-2xl border border-emerald-500/20 bg-emerald-500/5 px-3 py-2 text-xs text-muted-foreground">
<Sparkles className="h-3.5 w-3.5 text-emerald-500 shrink-0" /> <Sparkles className="h-3.5 w-3.5 text-emerald-500 shrink-0" />
<span>{t.quota.exemptionNote}</span> <span>{t.quota.exemptionNote}</span>
</div> </div>
{/* Reset Quota Confirmation Modal */}
{isResetConfirmOpen && (
<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">
<div className="flex items-center justify-between pb-3 border-b border-border/60">
<div className="flex items-center gap-2.5">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<RotateCcw className="h-4 w-4" />
</div>
<h3 className="font-bold text-sm text-foreground">
{t.quota.resetConfirmTitle}
</h3>
</div>
<button
onClick={() => setIsResetConfirmOpen(false)}
className="rounded-lg p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
{t.quota.resetConfirmDesc}
</p>
<label className="flex items-start gap-2.5 p-3 rounded-2xl border border-border/70 bg-muted/30 cursor-pointer">
<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">
{t.users.dialogResetQuota?.resetLimitsToRole ||
"Đồng thời khôi phục hạn mức về cấu hình vai trò"}
</span>
</label>
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border/60">
<Button
variant="outline"
size="sm"
onClick={() => setIsResetConfirmOpen(false)}
className="rounded-xl text-xs cursor-pointer"
>
{t.users.dialogResetQuota.cancel}
</Button>
<Button
size="sm"
onClick={handleResetQuota}
disabled={resetMutation.isPending}
className="rounded-xl 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 ? "Đang xử lý..." : t.quota.resetBtn}
</Button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }
...@@ -7,11 +7,13 @@ import { ...@@ -7,11 +7,13 @@ import {
Code2, Code2,
KeyRound, KeyRound,
Webhook, Webhook,
Terminal,
Users, Users,
Shield, Shield,
History, History,
Sliders, Sliders,
CalendarClock,
ShieldAlert,
ArrowLeft,
} from "lucide-react"; } from "lucide-react";
import { useLanguage } from "@/providers/language-provider"; import { useLanguage } from "@/providers/language-provider";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
...@@ -19,6 +21,8 @@ import { useApiKeysList, useWebhookConfigsList } from "@/hooks/use-developer"; ...@@ -19,6 +21,8 @@ import { useApiKeysList, useWebhookConfigsList } from "@/hooks/use-developer";
import { useUsersList } from "@/hooks/use-users"; import { useUsersList } from "@/hooks/use-users";
import { useRolesList } from "@/hooks/use-roles"; import { useRolesList } from "@/hooks/use-roles";
import { useSystemConfigsList } from "@/hooks/use-system-config"; import { useSystemConfigsList } from "@/hooks/use-system-config";
import { useCronJobsList } from "@/hooks/use-cron";
import { Button } from "@/components/ui/button";
interface DeveloperShellProps { interface DeveloperShellProps {
children: React.ReactNode; children: React.ReactNode;
...@@ -26,26 +30,44 @@ interface DeveloperShellProps { ...@@ -26,26 +30,44 @@ interface DeveloperShellProps {
export function DeveloperShell({ children }: DeveloperShellProps) { export function DeveloperShell({ children }: DeveloperShellProps) {
const { t } = useLanguage(); const { t } = useLanguage();
const { role, hasPermission } = useAuth(); const { role, isAdmin, hasPermission } = useAuth();
const pathname = usePathname(); const pathname = usePathname();
const canManageUsers = hasPermission("users.read") || role === "ADMIN";
const canManageRoles = hasPermission("roles.read") || role === "ADMIN"; const canManageUsers = isAdmin || hasPermission("users.read");
const canReadAuditLogs = hasPermission("audit_logs.read") || role === "ADMIN"; const canManageRoles = isAdmin || hasPermission("roles.read");
const canManageConfigs = const canViewApiKeys = isAdmin || hasPermission("api_keys.read");
hasPermission("system_configs.read") || role === "ADMIN"; const canViewWebhooks = isAdmin || hasPermission("webhooks.read");
const canReadAuditLogs = isAdmin || hasPermission("audit_logs.read");
// Counts for badges const canManageConfigs = isAdmin || hasPermission("system_configs.read");
const { data: apiKeys = [] } = useApiKeysList(); const canManageCron = isAdmin || hasPermission("cron_jobs.read");
const { data: webhooks = [] } = useWebhookConfigsList();
const canAccessDeveloper =
isAdmin ||
canManageUsers ||
canManageRoles ||
canViewApiKeys ||
canViewWebhooks ||
canReadAuditLogs ||
canManageConfigs ||
canManageCron;
// Counts for badges - only queried when user has permission
const { data: apiKeys = [] } = useApiKeysList({ enabled: canViewApiKeys });
const { data: webhooks = [] } = useWebhookConfigsList({ enabled: canViewWebhooks });
const { data: usersData } = useUsersList({ limit: 1 }, { enabled: canManageUsers }); const { data: usersData } = useUsersList({ limit: 1 }, { enabled: canManageUsers });
const { data: rolesData } = useRolesList({ limit: 1 }, { enabled: canManageRoles }); const { data: rolesData } = useRolesList({ limit: 1 }, { enabled: canManageRoles });
const { data: configsData } = useSystemConfigsList( const { data: configsData } = useSystemConfigsList(
{ limit: 1 }, { limit: 1 },
{ enabled: canManageConfigs }, { enabled: canManageConfigs },
); );
const { data: cronJobs = [] } = useCronJobsList(undefined, {
enabled: canManageCron,
});
const totalUsers = usersData?.meta?.total || 0; const totalUsers = usersData?.meta?.total || 0;
const totalRoles = rolesData?.meta?.total ?? rolesData?.items?.length ?? 0; const totalRoles = rolesData?.meta?.total ?? rolesData?.items?.length ?? 0;
const totalConfigs = configsData?.total || 0; const totalConfigs = configsData?.total || 0;
const totalCronJobs = cronJobs.length;
const isUsersTab = pathname.startsWith("/settings/developer/users"); const isUsersTab = pathname.startsWith("/settings/developer/users");
const isRolesTab = const isRolesTab =
...@@ -58,10 +80,60 @@ export function DeveloperShell({ children }: DeveloperShellProps) { ...@@ -58,10 +80,60 @@ export function DeveloperShell({ children }: DeveloperShellProps) {
pathname === "/settings/developer/api-keys" || pathname === "/settings/developer/api-keys" ||
pathname === "/settings/developer"; pathname === "/settings/developer";
const isWebhooksTab = pathname.startsWith("/settings/developer/webhooks"); const isWebhooksTab = pathname.startsWith("/settings/developer/webhooks");
const isDocsTab = pathname.startsWith("/settings/developer/docs");
const isAuditLogsTab = const isAuditLogsTab =
pathname.startsWith("/settings/developer/audit-logs") || pathname.startsWith("/settings/developer/audit-logs") ||
pathname.startsWith("/audit-logs"); pathname.startsWith("/audit-logs");
const isCronTab =
pathname.startsWith("/settings/developer/cron") ||
pathname.startsWith("/cron");
const firstAllowedTab =
(canManageUsers && { href: "/settings/developer/users", label: t.users.title }) ||
(canManageRoles && { href: "/settings/developer/roles", label: t.roles.title }) ||
(canViewApiKeys && { href: "/settings/developer/api-keys", label: t.developer.tabs.apiKeys }) ||
(canViewWebhooks && { href: "/settings/developer/webhooks", label: t.developer.tabs.webhooks }) ||
(canReadAuditLogs && { href: "/settings/developer/audit-logs", label: t.nav.auditLogs }) ||
(canManageConfigs && { href: "/settings/developer/system-configs", label: t.developer.tabs.systemConfigs }) ||
(canManageCron && { href: "/settings/developer/cron", label: t.developer.tabs.cron }) ||
null;
const isCurrentTabUnauthorized =
(isUsersTab && !canManageUsers) ||
(isRolesTab && !canManageRoles) ||
(isApiKeysTab && !canViewApiKeys) ||
(isWebhooksTab && !canViewWebhooks) ||
(isAuditLogsTab && !canReadAuditLogs) ||
(isSystemConfigsTab && !canManageConfigs) ||
(isCronTab && !canManageCron);
// If user has NO permissions in Developer area at all
if (!canAccessDeveloper) {
return (
<div className="min-h-screen bg-background pb-16">
<div className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8 pt-16">
<div className="rounded-3xl border border-destructive/20 bg-card/60 p-8 sm:p-12 text-center shadow-sm backdrop-blur-sm space-y-4">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-destructive/10 text-destructive">
<ShieldAlert className="h-7 w-7" />
</div>
<h2 className="text-xl font-bold tracking-tight text-foreground">
Truy cập bị từ chối
</h2>
<p className="text-sm text-muted-foreground max-w-md mx-auto">
Tài khoản của bạn không có bất kỳ quyền hạn nào trong khu vực nhà phát triển. Vui lòng liên hệ quản trị viên để được cấp quyền.
</p>
<div className="pt-4">
<Link href="/">
<Button className="rounded-2xl bg-emerald-600 hover:bg-emerald-700 text-white cursor-pointer shadow-sm shadow-emerald-600/20">
<ArrowLeft className="mr-2 h-4 w-4" />
Về bảng điều khiển
</Button>
</Link>
</div>
</div>
</div>
</div>
);
}
return ( return (
<div className="min-h-screen bg-background pb-16"> <div className="min-h-screen bg-background pb-16">
...@@ -82,7 +154,7 @@ export function DeveloperShell({ children }: DeveloperShellProps) { ...@@ -82,7 +154,7 @@ export function DeveloperShell({ children }: DeveloperShellProps) {
</div> </div>
</div> </div>
{/* Tabs Bar with real URL Links */} {/* Tabs Bar with real URL Links - only allowed tabs rendered */}
<div className="flex border-b border-border/70 space-x-2 overflow-x-auto"> <div className="flex border-b border-border/70 space-x-2 overflow-x-auto">
{/* 1. Tab Người Dùng (RBAC Permission) */} {/* 1. Tab Người Dùng (RBAC Permission) */}
{canManageUsers && ( {canManageUsers && (
...@@ -124,7 +196,8 @@ export function DeveloperShell({ children }: DeveloperShellProps) { ...@@ -124,7 +196,8 @@ export function DeveloperShell({ children }: DeveloperShellProps) {
</Link> </Link>
)} )}
{/* 2. Tab Khóa API */} {/* 3. Tab Khóa API */}
{canViewApiKeys && (
<Link <Link
href="/settings/developer/api-keys" href="/settings/developer/api-keys"
className={`flex items-center gap-2 px-4 py-3 text-xs font-bold border-b-2 transition-all cursor-pointer whitespace-nowrap ${ className={`flex items-center gap-2 px-4 py-3 text-xs font-bold border-b-2 transition-all cursor-pointer whitespace-nowrap ${
...@@ -139,8 +212,10 @@ export function DeveloperShell({ children }: DeveloperShellProps) { ...@@ -139,8 +212,10 @@ export function DeveloperShell({ children }: DeveloperShellProps) {
{apiKeys.length} {apiKeys.length}
</span> </span>
</Link> </Link>
)}
{/* 3. Tab Webhooks */} {/* 4. Tab Webhooks */}
{canViewWebhooks && (
<Link <Link
href="/settings/developer/webhooks" href="/settings/developer/webhooks"
className={`flex items-center gap-2 px-4 py-3 text-xs font-bold border-b-2 transition-all cursor-pointer whitespace-nowrap ${ className={`flex items-center gap-2 px-4 py-3 text-xs font-bold border-b-2 transition-all cursor-pointer whitespace-nowrap ${
...@@ -155,19 +230,7 @@ export function DeveloperShell({ children }: DeveloperShellProps) { ...@@ -155,19 +230,7 @@ export function DeveloperShell({ children }: DeveloperShellProps) {
{webhooks.length} {webhooks.length}
</span> </span>
</Link> </Link>
)}
{/* 4. Tab Tích Hợp Nhanh (Docs) */}
<Link
href="/settings/developer/docs"
className={`flex items-center gap-2 px-4 py-3 text-xs font-bold border-b-2 transition-all cursor-pointer whitespace-nowrap ${
isDocsTab
? "border-emerald-500 text-emerald-600 dark:text-emerald-400"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<Terminal className="h-4 w-4" />
<span>{t.developer.tabs.docs}</span>
</Link>
{/* 5. Tab Nhật Ký Kiểm Toán (Audit Logs) */} {/* 5. Tab Nhật Ký Kiểm Toán (Audit Logs) */}
{canReadAuditLogs && ( {canReadAuditLogs && (
...@@ -184,7 +247,7 @@ export function DeveloperShell({ children }: DeveloperShellProps) { ...@@ -184,7 +247,7 @@ export function DeveloperShell({ children }: DeveloperShellProps) {
</Link> </Link>
)} )}
{/* 6. Tab Cấu Hình Hệ Thống & Feature Flags */} {/* 7. Tab Cấu Hình Hệ Thống & Feature Flags */}
{canManageConfigs && ( {canManageConfigs && (
<Link <Link
href="/settings/developer/system-configs" href="/settings/developer/system-configs"
...@@ -203,10 +266,59 @@ export function DeveloperShell({ children }: DeveloperShellProps) { ...@@ -203,10 +266,59 @@ export function DeveloperShell({ children }: DeveloperShellProps) {
)} )}
</Link> </Link>
)} )}
{/* 8. Tab Tác Vụ Định Kỳ (Cron Jobs) */}
{canManageCron && (
<Link
href="/settings/developer/cron"
className={`flex items-center gap-2 px-4 py-3 text-xs font-bold border-b-2 transition-all cursor-pointer whitespace-nowrap ${
isCronTab
? "border-emerald-500 text-emerald-600 dark:text-emerald-400"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<CalendarClock className="h-4 w-4" />
<span>{t.developer.tabs.cron}</span>
{totalCronJobs > 0 && (
<span className="rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 text-[10px] px-2 py-0.5 border border-emerald-500/20 font-semibold">
{totalCronJobs}
</span>
)}
</Link>
)}
</div> </div>
{/* Tab Page Content */} {/* Tab Page Content */}
<div className="pt-2">{children}</div> <div className="pt-2">
{isCurrentTabUnauthorized ? (
<div className="rounded-3xl border border-destructive/20 bg-card/60 p-8 sm:p-12 text-center shadow-sm backdrop-blur-sm space-y-4">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-destructive/10 text-destructive">
<ShieldAlert className="h-6 w-6" />
</div>
<h3 className="text-base font-bold text-foreground">
Không có quyền truy cập mục này
</h3>
<p className="text-xs text-muted-foreground max-w-md mx-auto">
Tài khoản của bạn thiếu quyền hạn cần thiết để xem nội dung mục này.
</p>
{firstAllowedTab && (
<div className="pt-2">
<Link href={firstAllowedTab.href}>
<Button
size="sm"
className="rounded-2xl bg-emerald-600 hover:bg-emerald-700 text-white cursor-pointer shadow-sm shadow-emerald-600/20 text-xs"
>
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
Chuyển tới {firstAllowedTab.label}
</Button>
</Link>
</div>
)}
</div>
) : (
children
)}
</div>
</div> </div>
</div> </div>
); );
......
...@@ -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
......
"use client"; "use client";
import React, { useEffect } from "react"; import React, { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { X, ShieldPlus, Edit3, Sparkles } from "lucide-react"; import { X, ShieldPlus, Edit3, Sparkles, Gauge, Sliders, ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
...@@ -21,6 +21,8 @@ interface RoleFormModalProps { ...@@ -21,6 +21,8 @@ interface RoleFormModalProps {
roleToEdit?: RoleItem | null; roleToEdit?: RoleItem | null;
} }
type RoleFormData = CreateRoleInput & { syncUsersQuota?: boolean };
export function RoleFormModal({ export function RoleFormModal({
isOpen, isOpen,
onClose, onClose,
...@@ -31,6 +33,7 @@ export function RoleFormModal({ ...@@ -31,6 +33,7 @@ export function RoleFormModal({
const updateMutation = useUpdateRole(); const updateMutation = useUpdateRole();
const isEditing = !!roleToEdit; const isEditing = !!roleToEdit;
const [showQuotaSection, setShowQuotaSection] = useState(true);
const { const {
register, register,
...@@ -39,12 +42,18 @@ export function RoleFormModal({ ...@@ -39,12 +42,18 @@ export function RoleFormModal({
watch, watch,
reset, reset,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
} = useForm<CreateRoleInput>({ } = useForm<RoleFormData>({
resolver: zodResolver(createRoleSchema), resolver: zodResolver(createRoleSchema) as any,
defaultValues: { defaultValues: {
name: "", name: "",
slug: "", slug: "",
description: "", description: "",
maxPagesLimit: 100,
maxJobsPerDayLimit: 10,
maxConcurrentJobsLimit: 3,
maxPagesPerMonthLimit: 1000,
maxJobsPerMonthLimit: 100,
syncUsersQuota: false,
}, },
}); });
...@@ -58,12 +67,24 @@ export function RoleFormModal({ ...@@ -58,12 +67,24 @@ export function RoleFormModal({
name: roleToEdit.name, name: roleToEdit.name,
slug: roleToEdit.slug, slug: roleToEdit.slug,
description: roleToEdit.description || "", description: roleToEdit.description || "",
maxPagesLimit: roleToEdit.maxPagesLimit ?? 100,
maxJobsPerDayLimit: roleToEdit.maxJobsPerDayLimit ?? 10,
maxConcurrentJobsLimit: roleToEdit.maxConcurrentJobsLimit ?? 3,
maxPagesPerMonthLimit: roleToEdit.maxPagesPerMonthLimit ?? 1000,
maxJobsPerMonthLimit: roleToEdit.maxJobsPerMonthLimit ?? 100,
syncUsersQuota: false,
}); });
} else { } else {
reset({ reset({
name: "", name: "",
slug: "", slug: "",
description: "", description: "",
maxPagesLimit: 100,
maxJobsPerDayLimit: 10,
maxConcurrentJobsLimit: 3,
maxPagesPerMonthLimit: 1000,
maxJobsPerMonthLimit: 100,
syncUsersQuota: false,
}); });
} }
} }
...@@ -96,13 +117,26 @@ export function RoleFormModal({ ...@@ -96,13 +117,26 @@ export function RoleFormModal({
if (!isOpen) return null; if (!isOpen) return null;
const onSubmit = async (data: CreateRoleInput) => { const onSubmit = async (data: RoleFormData) => {
const parseOptionalNumber = (val: any) => {
if (val === "" || val === null || val === undefined || isNaN(Number(val))) {
return null;
}
return Number(val);
};
if (isEditing && roleToEdit) { if (isEditing && roleToEdit) {
await updateMutation.mutateAsync({ await updateMutation.mutateAsync({
id: roleToEdit.id, id: roleToEdit.id,
dto: { dto: {
name: data.name, name: data.name,
description: data.description || undefined, description: data.description || undefined,
maxPagesLimit: data.maxPagesLimit ? Number(data.maxPagesLimit) : undefined,
maxJobsPerDayLimit: data.maxJobsPerDayLimit ? Number(data.maxJobsPerDayLimit) : undefined,
maxConcurrentJobsLimit: data.maxConcurrentJobsLimit ? Number(data.maxConcurrentJobsLimit) : undefined,
maxPagesPerMonthLimit: parseOptionalNumber(data.maxPagesPerMonthLimit),
maxJobsPerMonthLimit: parseOptionalNumber(data.maxJobsPerMonthLimit),
syncUsersQuota: data.syncUsersQuota,
}, },
}); });
} else { } else {
...@@ -110,6 +144,11 @@ export function RoleFormModal({ ...@@ -110,6 +144,11 @@ export function RoleFormModal({
name: data.name, name: data.name,
slug: data.slug.toLowerCase().trim(), slug: data.slug.toLowerCase().trim(),
description: data.description || undefined, description: data.description || undefined,
maxPagesLimit: data.maxPagesLimit ? Number(data.maxPagesLimit) : undefined,
maxJobsPerDayLimit: data.maxJobsPerDayLimit ? Number(data.maxJobsPerDayLimit) : undefined,
maxConcurrentJobsLimit: data.maxConcurrentJobsLimit ? Number(data.maxConcurrentJobsLimit) : undefined,
maxPagesPerMonthLimit: parseOptionalNumber(data.maxPagesPerMonthLimit),
maxJobsPerMonthLimit: parseOptionalNumber(data.maxJobsPerMonthLimit),
}); });
} }
onClose(); onClose();
...@@ -119,9 +158,9 @@ export function RoleFormModal({ ...@@ -119,9 +158,9 @@ export function RoleFormModal({
return ( 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="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-lg rounded-3xl border border-emerald-500/20 bg-card p-6 shadow-2xl shadow-emerald-950/20"> <div className="relative w-full max-w-lg max-h-[90vh] flex flex-col rounded-3xl border border-emerald-500/20 bg-card p-6 shadow-2xl shadow-emerald-950/20 overflow-hidden">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between pb-4 border-b border-border/60"> <div className="flex items-center justify-between pb-4 border-b border-border/60 shrink-0">
<div className="flex items-center gap-3"> <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"> <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">
{isEditing ? ( {isEditing ? (
...@@ -151,8 +190,11 @@ export function RoleFormModal({ ...@@ -151,8 +190,11 @@ export function RoleFormModal({
</button> </button>
</div> </div>
{/* Form */} {/* Form Body with scrolling */}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 pt-4"> <form
onSubmit={handleSubmit(onSubmit)}
className="flex-1 overflow-y-auto py-4 space-y-4 pr-1"
>
{/* Role Name */} {/* Role Name */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs font-semibold text-foreground"> <Label className="text-xs font-semibold text-foreground">
...@@ -216,7 +258,7 @@ export function RoleFormModal({ ...@@ -216,7 +258,7 @@ export function RoleFormModal({
</Label> </Label>
<textarea <textarea
{...register("description")} {...register("description")}
rows={3} rows={2}
placeholder={t.roles.modalForm.descPlaceholder} placeholder={t.roles.modalForm.descPlaceholder}
className="w-full rounded-2xl border border-border/80 bg-muted/30 p-3 text-xs focus:outline-hidden focus:border-emerald-500 text-foreground resize-none" className="w-full rounded-2xl border border-border/80 bg-muted/30 p-3 text-xs focus:outline-hidden focus:border-emerald-500 text-foreground resize-none"
/> />
...@@ -227,8 +269,111 @@ export function RoleFormModal({ ...@@ -227,8 +269,111 @@ export function RoleFormModal({
)} )}
</div> </div>
{/* Cấu hình Quota riêng cho phân quyền */}
<div className="rounded-2xl border border-emerald-500/20 bg-emerald-500/5 p-4 space-y-3">
<div
className="flex items-center justify-between cursor-pointer"
onClick={() => setShowQuotaSection(!showQuotaSection)}
>
<div className="flex items-center gap-2 text-emerald-700 dark:text-emerald-300">
<Gauge className="h-4 w-4" />
<span className="text-xs font-bold">
{t.roles.modalForm.quotaTitle}
</span>
</div>
<ChevronDown
className={`h-3.5 w-3.5 text-muted-foreground transition-transform ${
showQuotaSection ? "rotate-180" : ""
}`}
/>
</div>
{showQuotaSection && (
<div className="space-y-3 pt-1 animate-in fade-in-50 duration-150">
<p className="text-[11px] text-muted-foreground leading-relaxed">
{t.roles.modalForm.quotaDesc}
</p>
{/* Daily Quota Inputs */}
<div className="space-y-1">
<Label className="text-[11px] text-foreground font-medium">
{t.roles.modalForm.maxPages}
</Label>
<Input
type="number"
{...register("maxPagesLimit", { valueAsNumber: true })}
className="h-9 rounded-xl border-border/80 text-xs bg-background/80"
/>
</div>
<div className="grid grid-cols-2 gap-2.5">
<div className="space-y-1">
<Label className="text-[11px] text-foreground font-medium">
{t.roles.modalForm.maxJobsPerDay}
</Label>
<Input
type="number"
{...register("maxJobsPerDayLimit", { valueAsNumber: true })}
className="h-9 rounded-xl border-border/80 text-xs bg-background/80"
/>
</div>
<div className="space-y-1">
<Label className="text-[11px] text-foreground font-medium">
{t.roles.modalForm.maxConcurrentJobs}
</Label>
<Input
type="number"
{...register("maxConcurrentJobsLimit", { valueAsNumber: true })}
className="h-9 rounded-xl border-border/80 text-xs bg-background/80"
/>
</div>
</div>
{/* Monthly Quota Inputs */}
<div className="grid grid-cols-2 gap-2.5 pt-1 border-t border-emerald-500/15">
<div className="space-y-1">
<Label className="text-[11px] text-foreground font-medium">
{t.roles.modalForm.maxPagesPerMonth}
</Label>
<Input
type="number"
{...register("maxPagesPerMonthLimit", { valueAsNumber: true })}
placeholder="1000"
className="h-9 rounded-xl border-border/80 text-xs bg-background/80"
/>
</div>
<div className="space-y-1">
<Label className="text-[11px] text-foreground font-medium">
{t.roles.modalForm.maxJobsPerMonth}
</Label>
<Input
type="number"
{...register("maxJobsPerMonthLimit", { valueAsNumber: true })}
placeholder="100"
className="h-9 rounded-xl border-border/80 text-xs bg-background/80"
/>
</div>
</div>
{/* Sync Users Quota checkbox (only when editing) */}
{isEditing && (
<label className="flex items-start gap-2.5 pt-2 border-t border-emerald-500/15 cursor-pointer">
<input
type="checkbox"
{...register("syncUsersQuota")}
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.modalForm.syncUsersQuota}
</span>
</label>
)}
</div>
)}
</div>
{/* Buttons */} {/* Buttons */}
<div className="pt-4 flex items-center justify-end gap-2.5 border-t border-border/60"> <div className="pt-3 flex items-center justify-end gap-2.5 border-t border-border/60">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
......
"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}`);
},
});
}
...@@ -17,6 +17,7 @@ export const translations = { ...@@ -17,6 +17,7 @@ export const translations = {
roles: "Vai Trò & Phân Quyền", roles: "Vai Trò & Phân Quyền",
auditLogs: "Nhật Ký Hệ Thống", auditLogs: "Nhật Ký Hệ Thống",
systemConfigs: "Cấu Hình Hệ Thống", systemConfigs: "Cấu Hình Hệ Thống",
cron: "Tác Vụ Định Kỳ",
statusActive: "Động cơ Crawler Đang Chạy", statusActive: "Động cơ Crawler Đang Chạy",
reload: "Tải lại trang", reload: "Tải lại trang",
copyright: "Data Crawler © 2026 - Code by @hnihTyoB", copyright: "Data Crawler © 2026 - Code by @hnihTyoB",
...@@ -113,8 +114,9 @@ export const translations = { ...@@ -113,8 +114,9 @@ export const translations = {
quota: { quota: {
title: "Hạn Ngạch & Mức Độ Sử Dụng", title: "Hạn Ngạch & Mức Độ Sử Dụng",
subtitle: "Theo dõi tài nguyên cào dữ liệu và giới hạn định mức của tài khoản", subtitle: "Theo dõi tài nguyên cào dữ liệu và giới hạn định mức của tài khoản",
pagesLimit: "Số trang tối đa", pagesLimit: "Số trang trong ngày",
pagesUsed: "Trang đã cào", pagesUsed: "Trang trong ngày",
pagesAllTime: "Tổng số trang tích lũy",
pagesProgress: "Tiến độ cào", pagesProgress: "Tiến độ cào",
dailyJobs: "Tác vụ trong ngày", dailyJobs: "Tác vụ trong ngày",
jobsUsed: "Đã chạy hôm nay", jobsUsed: "Đã chạy hôm nay",
...@@ -134,6 +136,18 @@ export const translations = { ...@@ -134,6 +136,18 @@ export const translations = {
slotsUnit: "tác vụ", slotsUnit: "tác vụ",
slotRunning: "Slot đang chạy", slotRunning: "Slot đang chạy",
slotAvailable: "Slot khả dụng", slotAvailable: "Slot khả dụng",
dailyTitle: "Hạn ngạch trong ngày",
monthlyTitle: "Hạn ngạch trong tháng",
monthlyPagesLimit: "Số trang trong tháng",
monthlyPagesUsed: "Trang trong tháng",
monthlyJobsLimit: "Tác vụ trong tháng",
monthlyJobsUsed: "Đã chạy tháng này",
monthlyJobsRemaining: "Còn lại trong tháng",
monthlyResetAt: "Làm mới tháng lúc",
resetBtn: "Đặt lại hạn ngạch",
resetSuccess: "Đặt lại hạn ngạch thành công!",
resetConfirmTitle: "Đặt lại hạn ngạch tài nguyên?",
resetConfirmDesc: "Thao tác này sẽ đưa số trang và số tác vụ đã dùng hôm nay và tháng này về 0 ngay lập tức.",
}, },
// Service Health Monitoring // Service Health Monitoring
health: { health: {
...@@ -658,6 +672,7 @@ export const translations = { ...@@ -658,6 +672,7 @@ export const translations = {
webhooks: "Webhooks", webhooks: "Webhooks",
docs: "Tích Hợp Nhanh", docs: "Tích Hợp Nhanh",
systemConfigs: "Cấu Hình & Cờ Tính Năng", systemConfigs: "Cấu Hình & Cờ Tính Năng",
cron: "Tác Vụ Định Kỳ",
}, },
apiKeys: { apiKeys: {
title: "Quản Lý Khóa API", title: "Quản Lý Khóa API",
...@@ -792,11 +807,14 @@ export const translations = { ...@@ -792,11 +807,14 @@ export const translations = {
}, },
quota: { quota: {
title: "Hạn Mức Sử Dụng Tài Khoản", title: "Hạn Mức Sử Dụng Tài Khoản",
subtitle: "Theo dõi định mức tài nguyên cào dữ liệu theo ngày và tháng của bạn",
maxPages: "Trang tối đa / Tác vụ", maxPages: "Trang tối đa / Tác vụ",
maxJobsDay: "Tác vụ tối đa / Ngày", maxJobsDay: "Tác vụ tối đa / Ngày",
concurrentLimit: "Tác vụ chạy song song", concurrentLimit: "Tác vụ chạy song song",
usedToday: "Đã dùng hôm nay", usedToday: "Đã dùng hôm nay",
remaining: "Còn lại trong ngày", remaining: "Còn lại trong ngày",
maxPagesPerMonth: "Trang tối đa / Tháng",
maxJobsPerMonth: "Tác vụ tối đa / Tháng",
}, },
password: { password: {
title: "Đổi Mật Khẩu", title: "Đổi Mật Khẩu",
...@@ -900,9 +918,11 @@ export const translations = { ...@@ -900,9 +918,11 @@ export const translations = {
passwordPlaceholder: "Tối thiểu 8 ký tự", passwordPlaceholder: "Tối thiểu 8 ký tự",
role: "Vai trò ban đầu", role: "Vai trò ban đầu",
advanced: "Thiết lập hạn mức tài nguyên", advanced: "Thiết lập hạn mức tài nguyên",
maxPages: "Giới hạn số trang tối đa mỗi tác vụ", maxPages: "Số trang tối đa mỗi tác vụ",
maxJobsPerDay: "Giới hạn số tác vụ tối đa mỗi ngày", maxJobsPerDay: "Số tác vụ tối đa mỗi ngày",
maxConcurrentJobs: "Số tác vụ tối đa chạy cùng lúc", maxConcurrentJobs: "Số tác vụ tối đa chạy cùng lúc",
maxPagesPerMonth: "Số trang tối đa mỗi tháng",
maxJobsPerMonth: "Số tác vụ tối đa mỗi tháng",
cancel: "Hủy bỏ", cancel: "Hủy bỏ",
submit: "Tạo Người Dùng", submit: "Tạo Người Dùng",
submitting: "Đang tạo...", submitting: "Đang tạo...",
...@@ -921,6 +941,11 @@ export const translations = { ...@@ -921,6 +941,11 @@ export const translations = {
statusSwitchLocked: "Bị khóa", statusSwitchLocked: "Bị khóa",
role: "Vai trò chính", role: "Vai trò chính",
quotaTitle: "Hạn mức tài nguyên", quotaTitle: "Hạn mức tài nguyên",
maxPages: "Số trang tối đa mỗi tác vụ",
maxJobsPerDay: "Số tác vụ tối đa mỗi ngày",
maxConcurrentJobs: "Số tác vụ tối đa chạy cùng lúc",
maxPagesPerMonth: "Số trang tối đa mỗi tháng",
maxJobsPerMonth: "Số tác vụ tối đa mỗi tháng",
cancel: "Hủy bỏ", cancel: "Hủy bỏ",
submit: "Lưu Thay Đổi", submit: "Lưu Thay Đổi",
submitting: "Đang lưu...", submitting: "Đang lưu...",
...@@ -947,11 +972,22 @@ export const translations = { ...@@ -947,11 +972,22 @@ export const translations = {
confirm: "Xóa Vĩnh Viễn", confirm: "Xóa Vĩnh Viễn",
deleting: "Đang xóa...", deleting: "Đang xóa...",
}, },
dialogResetQuota: {
title: "Đặt Lại Hạn Ngạch Người Dùng",
desc: "Thao tác này sẽ đưa số trang và số tác vụ đã cào trong ngày và trong tháng của tài khoản này về 0 ngay lập tức.",
targetUser: "Tài khoản mục tiêu:",
resetLimitsToRole: "Đồng thời khôi phục hạn mức tối đa về giá trị mặc định theo Vai trò",
cancel: "Hủy Bỏ",
confirm: "Đặt Lại Hạn Ngạch",
resetting: "Đang đặt lại...",
success: "Đã đặt lại hạn ngạch cho người dùng thành công!",
},
actions: { actions: {
edit: "Chỉnh sửa thông tin", edit: "Chỉnh sửa thông tin",
assignRoles: "Phân vai trò", assignRoles: "Phân vai trò",
lock: "Khóa tài khoản", lock: "Khóa tài khoản",
unlock: "Mở khóa tài khoản", unlock: "Mở khóa tài khoản",
resetQuota: "Đặt lại hạn ngạch (Quota)",
delete: "Xóa người dùng", delete: "Xóa người dùng",
}, },
}, },
...@@ -1020,6 +1056,14 @@ export const translations = { ...@@ -1020,6 +1056,14 @@ export const translations = {
descLabel: "Mô tả chi tiết", descLabel: "Mô tả chi tiết",
descPlaceholder: "Nêu rõ trách nhiệm và phạm vi hoạt động của vai trò này...", descPlaceholder: "Nêu rõ trách nhiệm và phạm vi hoạt động của vai trò này...",
activeLabel: "Kích hoạt vai trò này", activeLabel: "Kích hoạt vai trò này",
quotaTitle: "Cấu hình Hạn Ngạch (Quota) Mặc Định Của Vai Trò",
quotaDesc: "Thiết lập định mức trang, job và luồng cào song song tự động áp dụng cho người dùng nhận vai trò này.",
maxPages: "Số trang tối đa mỗi tác vụ",
maxJobsPerDay: "Số tác vụ tối đa mỗi ngày",
maxConcurrentJobs: "Số tác vụ chạy cùng lúc",
maxPagesPerMonth: "Số trang tối đa mỗi tháng (Bỏ trống: Không giới hạn)",
maxJobsPerMonth: "Số tác vụ tối đa mỗi tháng (Bỏ trống: Không giới hạn)",
syncUsersQuota: "Áp dụng cập nhật hạn ngạch này cho tất cả người dùng hiện tại thuộc vai trò",
cancel: "Hủy bỏ", cancel: "Hủy bỏ",
save: "Lưu Vai Trò", save: "Lưu Vai Trò",
saving: "Đang lưu...", saving: "Đang lưu...",
...@@ -1065,10 +1109,21 @@ export const translations = { ...@@ -1065,10 +1109,21 @@ export const translations = {
confirm: "Xác Nhận Xóa", confirm: "Xác Nhận Xóa",
deleting: "Đang xóa...", deleting: "Đang xóa...",
}, },
dialogResetQuota: {
title: "Đặt Lại Hạn Ngạch Toàn Bộ Người Dùng Thuộc Vai Trò",
desc: "Thao tác này sẽ đưa số lượt cào và số trang đã dùng hôm nay và tháng này về 0 cho tất cả người dùng đang thuộc vai trò này.",
targetRole: "Vai trò mục tiêu:",
syncLimits: "Đồng thời cập nhật hạn mức tối đa của người dùng khớp với cấu hình vai trò này",
cancel: "Hủy Bỏ",
confirm: "Đặt Lại Hạn Ngạch",
resetting: "Đang xử lý...",
success: "Đã đặt lại hạn ngạch vai trò thành công!",
},
actions: { actions: {
edit: "Chỉnh sửa", edit: "Chỉnh sửa",
matrix: "Phân quyền", matrix: "Phân quyền",
viewUsers: "Xem thành viên", viewUsers: "Xem thành viên",
resetQuota: "Đặt lại hạn ngạch vai trò",
delete: "Xóa vai trò", delete: "Xóa vai trò",
}, },
}, },
...@@ -1347,6 +1402,69 @@ export const translations = { ...@@ -1347,6 +1402,69 @@ export const translations = {
error: "Đã có lỗi xảy ra, vui lòng thử lại", error: "Đã có lỗi xảy ra, vui lòng thử lại",
}, },
}, },
// Cron Jobs Module
cron: {
title: "Tác Vụ Định Kỳ",
subtitle: "Theo dõi lịch biểu, cấu hình tự động và kích hoạt các công việc nền vận hành bởi BullMQ.",
timezoneBadge: "Asia/Ho_Chi_Minh (UTC+7)",
searchPlaceholder: "Tìm kiếm tác vụ theo tên hoặc mô tả...",
emptyTitle: "Không tìm thấy tác vụ nào",
emptyDesc: "Không có tác vụ định kỳ nào khớp với từ khóa tìm kiếm của bạn.",
errorTitle: "Không thể tải danh sách tác vụ định kỳ",
errorRetry: "Tải lại",
stats: {
totalJobs: "Tổng Số Tác Vụ",
totalJobsDesc: "Tác vụ nền đã đăng ký",
activeSchedules: "Đang Kích Hoạt",
activeSchedulesDesc: "Tác vụ tự động theo lịch",
pausedSchedules: "Đã Tạm Dừng",
pausedSchedulesDesc: "Tác vụ đã tắt tự động",
engineStatus: "Động Cơ Scheduler",
engineStatusDesc: "Hàng đợi BullMQ & Redis",
engineActive: "Đang Hoạt Động",
engineDegraded: "Chế Độ Độc Lập",
},
table: {
jobName: "Tác vụ & Mô tả",
cronPattern: "Biểu thức Cron",
lastRun: "Lần chạy gần nhất",
status: "Trạng thái",
autoSchedule: "Lịch tự động",
actions: "Thao tác",
neverRun: "Chưa từng chạy",
running: "Đang chạy",
ready: "Sẵn sàng",
success: "Thành công",
failed: "Thất bại",
triggerNow: "Kích hoạt ngay",
triggerTooltip: "Chạy tác vụ này ngay lập tức",
statusOn: "Bật",
statusOff: "Tắt",
},
triggerModal: {
title: "Kích Hoạt Tác Vụ Thủ Công",
desc: "Kích hoạt chạy ngay tác vụ được chọn. Bạn có thể tùy biến các tham số đầu vào dạng JSON.",
jobLabel: "Tác vụ đang chọn:",
cronLabel: "Lịch biểu:",
paramsLabel: "Tham số tùy biến:",
paramsPlaceholder: '{\n "retentionDays": 30\n}',
paramsHint: "Để trống nếu muốn sử dụng tham số mặc định của hệ thống.",
executing: "Đang thực thi tác vụ...",
runBtn: "Bắt Đầu Chạy",
closeBtn: "Đóng",
resultTitle: "Kết quả thực thi:",
durationLabel: "Thời gian xử lý:",
resultSuccess: "Thực thi thành công",
resultFailed: "Thực thi thất bại",
},
toast: {
toggleEnabled: "Đã BẬT lịch chạy tự động cho",
toggleDisabled: "Đã TẮT lịch chạy tự động cho",
toggleError: "Không thể thay đổi trạng thái lịch chạy",
triggerSuccess: "Kích hoạt tác vụ thành công",
triggerError: "Kích hoạt tác vụ thất bại",
},
},
}, },
en: { en: {
// Navigation & Common // Navigation & Common
...@@ -1364,6 +1482,7 @@ export const translations = { ...@@ -1364,6 +1482,7 @@ export const translations = {
roles: "Roles & Permissions", roles: "Roles & Permissions",
auditLogs: "System Audit Logs", auditLogs: "System Audit Logs",
systemConfigs: "System Configurations", systemConfigs: "System Configurations",
cron: "Cron Jobs",
statusActive: "Crawler Engine Active", statusActive: "Crawler Engine Active",
reload: "Reload page", reload: "Reload page",
copyright: "Data Crawler © 2026 - Code by @hnihTyoB", copyright: "Data Crawler © 2026 - Code by @hnihTyoB",
...@@ -1460,8 +1579,9 @@ export const translations = { ...@@ -1460,8 +1579,9 @@ export const translations = {
quota: { quota: {
title: "Resource Quota & Usage", title: "Resource Quota & Usage",
subtitle: "Monitor scraping capacity limits and account resource consumption", subtitle: "Monitor scraping capacity limits and account resource consumption",
pagesLimit: "Max Pages Limit", pagesLimit: "Daily Pages Quota",
pagesUsed: "Pages Crawled", pagesUsed: "Pages Today",
pagesAllTime: "Total All-Time Pages",
pagesProgress: "Crawl Progress", pagesProgress: "Crawl Progress",
dailyJobs: "Daily Jobs Quota", dailyJobs: "Daily Jobs Quota",
jobsUsed: "Used Today", jobsUsed: "Used Today",
...@@ -1481,6 +1601,18 @@ export const translations = { ...@@ -1481,6 +1601,18 @@ export const translations = {
slotsUnit: "slots", slotsUnit: "slots",
slotRunning: "Slot currently running", slotRunning: "Slot currently running",
slotAvailable: "Slot available", slotAvailable: "Slot available",
dailyTitle: "Daily Quota",
monthlyTitle: "Monthly Quota",
monthlyPagesLimit: "Monthly Pages Quota",
monthlyPagesUsed: "Pages This Month",
monthlyJobsLimit: "Monthly Jobs Quota",
monthlyJobsUsed: "Used This Month",
monthlyJobsRemaining: "Remaining This Month",
monthlyResetAt: "Monthly reset at",
resetBtn: "Reset Quota",
resetSuccess: "Quota reset successfully!",
resetConfirmTitle: "Reset Resource Quota?",
resetConfirmDesc: "This will reset daily and monthly used page and job counters to 0 immediately.",
}, },
// Service Health Monitoring // Service Health Monitoring
health: { health: {
...@@ -2005,6 +2137,7 @@ export const translations = { ...@@ -2005,6 +2137,7 @@ export const translations = {
webhooks: "Webhooks", webhooks: "Webhooks",
docs: "Integration Guide", docs: "Integration Guide",
systemConfigs: "Configurations & Flags", systemConfigs: "Configurations & Flags",
cron: "Cron Jobs",
}, },
apiKeys: { apiKeys: {
title: "API Keys Management", title: "API Keys Management",
...@@ -2139,11 +2272,14 @@ export const translations = { ...@@ -2139,11 +2272,14 @@ export const translations = {
}, },
quota: { quota: {
title: "Account Quota & Limits", title: "Account Quota & Limits",
subtitle: "Monitor your daily and monthly crawling resource limits and utilization",
maxPages: "Max Pages / Job", maxPages: "Max Pages / Job",
maxJobsDay: "Max Jobs / Day", maxJobsDay: "Max Jobs / Day",
concurrentLimit: "Concurrent Jobs", concurrentLimit: "Concurrent Jobs",
usedToday: "Used Today", usedToday: "Used Today",
remaining: "Remaining Today", remaining: "Remaining Today",
maxPagesPerMonth: "Max Pages / Month",
maxJobsPerMonth: "Max Jobs / Month",
}, },
password: { password: {
title: "Change Password", title: "Change Password",
...@@ -2250,6 +2386,8 @@ export const translations = { ...@@ -2250,6 +2386,8 @@ export const translations = {
maxPages: "Pages per crawl task", maxPages: "Pages per crawl task",
maxJobsPerDay: "Crawl jobs per day", maxJobsPerDay: "Crawl jobs per day",
maxConcurrentJobs: "Concurrent running jobs", maxConcurrentJobs: "Concurrent running jobs",
maxPagesPerMonth: "Max pages per month",
maxJobsPerMonth: "Max jobs per month",
cancel: "Cancel", cancel: "Cancel",
submit: "Create User", submit: "Create User",
submitting: "Creating...", submitting: "Creating...",
...@@ -2268,6 +2406,11 @@ export const translations = { ...@@ -2268,6 +2406,11 @@ export const translations = {
statusSwitchLocked: "Locked", statusSwitchLocked: "Locked",
role: "Primary Role", role: "Primary Role",
quotaTitle: "Resource Quota Limits", quotaTitle: "Resource Quota Limits",
maxPages: "Pages per crawl task",
maxJobsPerDay: "Crawl jobs per day",
maxConcurrentJobs: "Concurrent running jobs",
maxPagesPerMonth: "Max pages per month",
maxJobsPerMonth: "Max jobs per month",
cancel: "Cancel", cancel: "Cancel",
submit: "Save Changes", submit: "Save Changes",
submitting: "Saving...", submitting: "Saving...",
...@@ -2294,11 +2437,22 @@ export const translations = { ...@@ -2294,11 +2437,22 @@ export const translations = {
confirm: "Delete Permanently", confirm: "Delete Permanently",
deleting: "Deleting...", deleting: "Deleting...",
}, },
dialogResetQuota: {
title: "Reset User Resource Quota",
desc: "This will immediately reset daily and monthly used page and job counters for this account to 0.",
targetUser: "Target Account:",
resetLimitsToRole: "Also restore maximum limits to Role default values",
cancel: "Cancel",
confirm: "Reset Quota",
resetting: "Resetting...",
success: "User quota reset successfully!",
},
actions: { actions: {
edit: "Edit Profile & Status", edit: "Edit Profile & Status",
assignRoles: "Assign Roles", assignRoles: "Assign Roles",
lock: "Lock Account", lock: "Lock Account",
unlock: "Unlock Account", unlock: "Unlock Account",
resetQuota: "Reset Resource Quota",
delete: "Delete User", delete: "Delete User",
}, },
}, },
...@@ -2367,6 +2521,14 @@ export const translations = { ...@@ -2367,6 +2521,14 @@ export const translations = {
descLabel: "Detailed Description", descLabel: "Detailed Description",
descPlaceholder: "Describe responsibilities and scope of this role...", descPlaceholder: "Describe responsibilities and scope of this role...",
activeLabel: "Enable this role immediately", activeLabel: "Enable this role immediately",
quotaTitle: "Role Resource Quota Configuration",
quotaDesc: "Set default page, job and concurrent limits automatically applied to users assigned this role.",
maxPages: "Max pages per crawl task",
maxJobsPerDay: "Max crawl jobs per day",
maxConcurrentJobs: "Max concurrent running jobs",
maxPagesPerMonth: "Max pages per month (Leave empty: Unlimited)",
maxJobsPerMonth: "Max crawl jobs per month (Leave empty: Unlimited)",
syncUsersQuota: "Apply this quota update to all members currently assigned to this role",
cancel: "Cancel", cancel: "Cancel",
save: "Save Role", save: "Save Role",
saving: "Saving...", saving: "Saving...",
...@@ -2412,10 +2574,21 @@ export const translations = { ...@@ -2412,10 +2574,21 @@ export const translations = {
confirm: "Confirm Delete", confirm: "Confirm Delete",
deleting: "Deleting...", deleting: "Deleting...",
}, },
dialogResetQuota: {
title: "Reset Quotas for Role Members",
desc: "This will reset used pages and crawl jobs back to 0 for all users currently assigned to this role.",
targetRole: "Target Role:",
syncLimits: "Also synchronize maximum limits of all assigned users to match this role's configuration",
cancel: "Cancel",
confirm: "Reset Quotas",
resetting: "Processing...",
success: "Role member quotas reset successfully!",
},
actions: { actions: {
edit: "Edit", edit: "Edit",
matrix: "Permissions", matrix: "Permissions",
viewUsers: "View Members", viewUsers: "View Members",
resetQuota: "Reset Role Quotas",
delete: "Delete Role", delete: "Delete Role",
}, },
}, },
...@@ -2694,6 +2867,69 @@ export const translations = { ...@@ -2694,6 +2867,69 @@ export const translations = {
error: "An error occurred, please try again", error: "An error occurred, please try again",
}, },
}, },
// Cron Jobs Module
cron: {
title: "Cron & Background Tasks",
subtitle: "Monitor recurring schedules, toggle automation, and trigger background jobs powered by BullMQ.",
timezoneBadge: "Asia/Ho_Chi_Minh (UTC+7)",
searchPlaceholder: "Search tasks by name or description...",
emptyTitle: "No tasks found",
emptyDesc: "No background tasks match your search criteria.",
errorTitle: "Failed to load cron tasks",
errorRetry: "Retry",
stats: {
totalJobs: "Total Tasks",
totalJobsDesc: "Registered background tasks",
activeSchedules: "Active Schedulers",
activeSchedulesDesc: "Automated recurring schedules",
pausedSchedules: "Paused Schedulers",
pausedSchedulesDesc: "Disabled auto schedules",
engineStatus: "Scheduler Engine",
engineStatusDesc: "BullMQ & Redis Queue",
engineActive: "Fully Active",
engineDegraded: "Degraded Mode",
},
table: {
jobName: "Task & Description",
cronPattern: "Cron Pattern",
lastRun: "Last Execution",
status: "Status",
autoSchedule: "Auto Schedule",
actions: "Actions",
neverRun: "Never run",
running: "Running",
ready: "Ready",
success: "Success",
failed: "Failed",
triggerNow: "Run Now",
triggerTooltip: "Execute this task immediately",
statusOn: "On",
statusOff: "Off",
},
triggerModal: {
title: "Manual Task Trigger",
desc: "Execute the selected task immediately. You can optionally supply custom parameters in JSON format.",
jobLabel: "Selected Task:",
cronLabel: "Cron Schedule:",
paramsLabel: "Custom Parameters:",
paramsPlaceholder: '{\n "retentionDays": 30\n}',
paramsHint: "Leave blank to use the system default parameters.",
executing: "Executing task...",
runBtn: "Execute Now",
closeBtn: "Close",
resultTitle: "Execution Result:",
durationLabel: "Processing Time:",
resultSuccess: "Execution Succeeded",
resultFailed: "Execution Failed",
},
toast: {
toggleEnabled: "Auto scheduler enabled for",
toggleDisabled: "Auto scheduler disabled for",
toggleError: "Failed to toggle scheduler status",
triggerSuccess: "Task triggered successfully",
triggerError: "Task trigger failed",
},
},
}, },
}; };
......
...@@ -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