Commit f048c91d authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/quota-management-profile-and-cron-ui' into 'develop'

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

See merge request !16
parents e0be2658 8d925115
......@@ -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.
- **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.
- **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() {
</div>
{/* 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 */}
<div id="tasks">
......
"use client";
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 { useLanguage } from "@/providers/language-provider";
import {
......@@ -20,6 +20,32 @@ export default function ApiKeysPage() {
const revokeApiKeyMutation = useRevokeApiKey();
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) => {
toggleApiKeyMutation.mutate({ id: key.id, isActive: !key.isActive });
};
......@@ -160,6 +186,72 @@ export default function ApiKeysPage() {
</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 */}
<CreateApiKeyModal
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";
import React from "react";
import { Terminal, Code2 } from "lucide-react";
import { useLanguage } from "@/providers/language-provider";
import { CopyButton } from "@/components/common/copy-button";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
export default function DeveloperDocsPage() {
const { t } = useLanguage();
const router = useRouter();
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);`;
useEffect(() => {
router.replace("/settings/developer/api-keys");
}, [router]);
return (
<div className="space-y-6 animate-in fade-in-50 duration-200">
<div>
<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 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>
);
}
import { redirect } from "next/navigation";
"use client";
interface DeveloperPageProps {
searchParams: Promise<{ tab?: string }>;
}
import { useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
export default async function DeveloperIndexPage({ searchParams }: DeveloperPageProps) {
const { tab } = await searchParams;
export default function DeveloperIndexPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { hasPermission, role, isAdmin, isLoading } = useAuth();
if (tab === "users") {
redirect("/settings/developer/users");
}
useEffect(() => {
if (isLoading) return;
if (tab === "webhooks") {
redirect("/settings/developer/webhooks");
const tab = searchParams.get("tab");
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") {
redirect("/settings/developer/docs");
// Default to first permitted tab
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";
import { useAuth } from "@/hooks/use-auth";
import { formatDate } from "@/lib/utils";
import { toast } from "sonner";
import { QuotaUsageWidget } from "@/components/dashboard/quota-usage-widget";
import {
useUserProfile,
useUserUsage,
......@@ -389,38 +390,17 @@ export default function ProfileSettingsPage() {
</Button>
</div>
</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>
{/* 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="pb-4 border-b border-border/60">
<h2 className="text-base font-bold text-foreground">
......
......@@ -13,11 +13,45 @@ import { useLanguage } from "@/providers/language-provider";
import { Button } from "@/components/ui/button";
export function UserMenu() {
const { user, isAuthenticated, isLoading, role, logout } = useAuth();
const { user, isAuthenticated, isLoading, role, isAdmin, hasPermission, logout } = useAuth();
const { t } = useLanguage();
const [isOpen, setIsOpen] = useState(false);
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
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
......@@ -143,14 +177,16 @@ export function UserMenu() {
<span>{t.auth.userMenu.profile}</span>
</Link>
{canAccessDeveloper && (
<Link
href="/settings/developer/api-keys"
href={developerHref}
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"
>
<Code2 className="h-3.5 w-3.5 text-emerald-500" />
<span>{t.auth.userMenu.developer}</span>
</Link>
)}
</div>
{/* 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";
import React from "react";
import React, { useState } from "react";
import { UserUsageDto } from "@/types/auth";
import { useUserUsage } from "@/hooks/use-usage";
import { useLanguage } from "@/providers/language-provider";
import { useAuth } from "@/hooks/use-auth";
import { useResetUserQuota } from "@/hooks/use-users";
import {
AlertTriangle,
Clock,
......@@ -12,17 +14,36 @@ import {
Layers,
Sparkles,
Zap,
Calendar,
RotateCcw,
CheckCircle2,
X,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn, formatNumber } from "@/lib/utils";
interface QuotaUsageWidgetProps {
initialUsage?: UserUsageDto;
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 { 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
const usageData = initialUsage || fetchedUsage;
......@@ -31,6 +52,8 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr
maxPagesLimit: 1000,
maxJobsPerDayLimit: 50,
maxConcurrentJobsLimit: 5,
maxPagesPerMonthLimit: 10000,
maxJobsPerMonthLimit: 500,
};
const usage = usageData?.usage || {
......@@ -39,13 +62,23 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr
concurrentJobsRunning: 0,
concurrentJobsAvailable: 5,
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(
100,
quota.maxPagesLimit > 0
? Math.round((usage.totalPagesCrawled / quota.maxPagesLimit) * 100)
? Math.round((pagesCrawledToday / quota.maxPagesLimit) * 100)
: 0
);
......@@ -63,9 +96,39 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr
: 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%
const isCritical = jobsDailyPercent >= 100 || concurrentPercent >= 100 || pagesPercent >= 100;
const isWarning = !isCritical && (jobsDailyPercent >= 80 || concurrentPercent >= 80 || pagesPercent >= 80);
const isCritical =
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)
const formatResetTime = (isoString?: string) => {
......@@ -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 (
<div
className={cn(
......@@ -95,13 +181,15 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr
<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">
<Gauge className="h-3.5 w-3.5" />
<span>{t.quota.title}</span>
<span>{title || t.quota.title}</span>
</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>
{/* Reset At Badge */}
<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">
{/* Reset Badges & Quick Reset Action */}
<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" />
<span suppressHydrationWarning>
{t.quota.resetCountdown}:{" "}
......@@ -110,11 +198,39 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr
</strong>
</span>
</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>
{/* Visual Threshold Alert Banner (Biophilic Alert Box) */}
{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" />
<div className="space-y-1">
<div className="flex items-center gap-2">
......@@ -126,7 +242,7 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr
</div>
</div>
) : 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" />
<div className="space-y-1">
<div className="flex items-center gap-2">
......@@ -140,10 +256,22 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr
</div>
) : 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">
{/* Card 1: Số trang tối đa được cào */}
<div className="rounded-2xl border border-border/70 bg-card/60 p-4 space-y-3 transition-colors hover:border-emerald-500/30">
{/* 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"
title={`${t.quota.pagesAllTime}: ${formatNumber(usage.totalPagesCrawled)}`}
>
<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-emerald-500" />
......@@ -159,7 +287,7 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr
className="text-xl font-extrabold text-foreground"
suppressHydrationWarning
>
{isLoading ? "..." : formatNumber(usage.totalPagesCrawled)}
{isLoading ? "..." : formatNumber(pagesCrawledToday)}
</span>
<span
className="text-xs text-muted-foreground"
......@@ -208,7 +336,11 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr
className="text-xs text-muted-foreground"
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>
</div>
......@@ -258,7 +390,11 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr
className="text-xs text-muted-foreground"
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>
</div>
......@@ -282,12 +418,194 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr
</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 */}
<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" />
<span>{t.quota.exemptionNote}</span>
</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>
);
}
......@@ -7,11 +7,13 @@ import {
Code2,
KeyRound,
Webhook,
Terminal,
Users,
Shield,
History,
Sliders,
CalendarClock,
ShieldAlert,
ArrowLeft,
} from "lucide-react";
import { useLanguage } from "@/providers/language-provider";
import { useAuth } from "@/hooks/use-auth";
......@@ -19,6 +21,8 @@ import { useApiKeysList, useWebhookConfigsList } from "@/hooks/use-developer";
import { useUsersList } from "@/hooks/use-users";
import { useRolesList } from "@/hooks/use-roles";
import { useSystemConfigsList } from "@/hooks/use-system-config";
import { useCronJobsList } from "@/hooks/use-cron";
import { Button } from "@/components/ui/button";
interface DeveloperShellProps {
children: React.ReactNode;
......@@ -26,26 +30,44 @@ interface DeveloperShellProps {
export function DeveloperShell({ children }: DeveloperShellProps) {
const { t } = useLanguage();
const { role, hasPermission } = useAuth();
const { role, isAdmin, hasPermission } = useAuth();
const pathname = usePathname();
const canManageUsers = hasPermission("users.read") || role === "ADMIN";
const canManageRoles = hasPermission("roles.read") || role === "ADMIN";
const canReadAuditLogs = hasPermission("audit_logs.read") || role === "ADMIN";
const canManageConfigs =
hasPermission("system_configs.read") || role === "ADMIN";
// Counts for badges
const { data: apiKeys = [] } = useApiKeysList();
const { data: webhooks = [] } = useWebhookConfigsList();
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;
// 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: rolesData } = useRolesList({ limit: 1 }, { enabled: canManageRoles });
const { data: configsData } = useSystemConfigsList(
{ limit: 1 },
{ enabled: canManageConfigs },
);
const { data: cronJobs = [] } = useCronJobsList(undefined, {
enabled: canManageCron,
});
const totalUsers = usersData?.meta?.total || 0;
const totalRoles = rolesData?.meta?.total ?? rolesData?.items?.length ?? 0;
const totalConfigs = configsData?.total || 0;
const totalCronJobs = cronJobs.length;
const isUsersTab = pathname.startsWith("/settings/developer/users");
const isRolesTab =
......@@ -58,10 +80,60 @@ export function DeveloperShell({ children }: DeveloperShellProps) {
pathname === "/settings/developer/api-keys" ||
pathname === "/settings/developer";
const isWebhooksTab = pathname.startsWith("/settings/developer/webhooks");
const isDocsTab = pathname.startsWith("/settings/developer/docs");
const isAuditLogsTab =
pathname.startsWith("/settings/developer/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 (
<div className="min-h-screen bg-background pb-16">
......@@ -82,7 +154,7 @@ export function DeveloperShell({ children }: DeveloperShellProps) {
</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">
{/* 1. Tab Người Dùng (RBAC Permission) */}
{canManageUsers && (
......@@ -124,7 +196,8 @@ export function DeveloperShell({ children }: DeveloperShellProps) {
</Link>
)}
{/* 2. Tab Khóa API */}
{/* 3. Tab Khóa API */}
{canViewApiKeys && (
<Link
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 ${
......@@ -139,8 +212,10 @@ export function DeveloperShell({ children }: DeveloperShellProps) {
{apiKeys.length}
</span>
</Link>
)}
{/* 3. Tab Webhooks */}
{/* 4. Tab Webhooks */}
{canViewWebhooks && (
<Link
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 ${
......@@ -155,19 +230,7 @@ export function DeveloperShell({ children }: DeveloperShellProps) {
{webhooks.length}
</span>
</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) */}
{canReadAuditLogs && (
......@@ -184,7 +247,7 @@ export function DeveloperShell({ children }: DeveloperShellProps) {
</Link>
)}
{/* 6. Tab Cấu Hình Hệ Thống & Feature Flags */}
{/* 7. Tab Cấu Hình Hệ Thống & Feature Flags */}
{canManageConfigs && (
<Link
href="/settings/developer/system-configs"
......@@ -203,10 +266,59 @@ export function DeveloperShell({ children }: DeveloperShellProps) {
)}
</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>
{/* 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>
);
......
......@@ -10,6 +10,8 @@ import {
Trash2,
Lock,
Sparkles,
RotateCcw,
Gauge,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useLanguage } from "@/providers/language-provider";
......@@ -22,6 +24,7 @@ interface RoleCardProps {
onOpenMatrix: (role: RoleItem) => void;
onViewUsers: (role: RoleItem) => void;
onDelete: (role: RoleItem) => void;
onResetQuota?: (role: RoleItem) => void;
}
export function RoleCard({
......@@ -30,6 +33,7 @@ export function RoleCard({
onOpenMatrix,
onViewUsers,
onDelete,
onResetQuota,
}: RoleCardProps) {
const { t } = useLanguage();
const { role: userRole, hasPermission } = useAuth();
......@@ -99,6 +103,22 @@ export function RoleCard({
<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."}
</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>
{/* Middle Stats & Badges */}
......@@ -153,6 +173,18 @@ export function RoleCard({
)}
<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 */}
{canEdit && (
<button
......
"use client";
import React, { useEffect } from "react";
import React, { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
......@@ -21,6 +21,8 @@ interface RoleFormModalProps {
roleToEdit?: RoleItem | null;
}
type RoleFormData = CreateRoleInput & { syncUsersQuota?: boolean };
export function RoleFormModal({
isOpen,
onClose,
......@@ -31,6 +33,7 @@ export function RoleFormModal({
const updateMutation = useUpdateRole();
const isEditing = !!roleToEdit;
const [showQuotaSection, setShowQuotaSection] = useState(true);
const {
register,
......@@ -39,12 +42,18 @@ export function RoleFormModal({
watch,
reset,
formState: { errors, isSubmitting },
} = useForm<CreateRoleInput>({
resolver: zodResolver(createRoleSchema),
} = useForm<RoleFormData>({
resolver: zodResolver(createRoleSchema) as any,
defaultValues: {
name: "",
slug: "",
description: "",
maxPagesLimit: 100,
maxJobsPerDayLimit: 10,
maxConcurrentJobsLimit: 3,
maxPagesPerMonthLimit: 1000,
maxJobsPerMonthLimit: 100,
syncUsersQuota: false,
},
});
......@@ -58,12 +67,24 @@ export function RoleFormModal({
name: roleToEdit.name,
slug: roleToEdit.slug,
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 {
reset({
name: "",
slug: "",
description: "",
maxPagesLimit: 100,
maxJobsPerDayLimit: 10,
maxConcurrentJobsLimit: 3,
maxPagesPerMonthLimit: 1000,
maxJobsPerMonthLimit: 100,
syncUsersQuota: false,
});
}
}
......@@ -96,13 +117,26 @@ export function RoleFormModal({
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) {
await updateMutation.mutateAsync({
id: roleToEdit.id,
dto: {
name: data.name,
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 {
......@@ -110,6 +144,11 @@ export function RoleFormModal({
name: data.name,
slug: data.slug.toLowerCase().trim(),
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();
......@@ -119,9 +158,9 @@ export function RoleFormModal({
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-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 */}
<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 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 ? (
......@@ -151,8 +190,11 @@ export function RoleFormModal({
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 pt-4">
{/* Form Body with scrolling */}
<form
onSubmit={handleSubmit(onSubmit)}
className="flex-1 overflow-y-auto py-4 space-y-4 pr-1"
>
{/* Role Name */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-foreground">
......@@ -216,7 +258,7 @@ export function RoleFormModal({
</Label>
<textarea
{...register("description")}
rows={3}
rows={2}
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"
/>
......@@ -227,8 +269,111 @@ export function RoleFormModal({
)}
</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 */}
<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
type="button"
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 {
Trash2,
Lock,
Sparkles,
RotateCcw,
Gauge,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useLanguage } from "@/providers/language-provider";
import { useAuth } from "@/hooks/use-auth";
import { RoleItem } from "@/types/role";
import { TablePagination } from "@/components/common/table-pagination";
interface RoleTableProps {
roles: RoleItem[];
total?: number;
page?: number;
limit?: number;
totalPages?: number;
onPageChange?: (page: number) => void;
onLimitChange?: (limit: number) => void;
onEdit: (role: RoleItem) => void;
onOpenMatrix: (role: RoleItem) => void;
onViewUsers: (role: RoleItem) => void;
onDelete: (role: RoleItem) => void;
onResetQuota?: (role: RoleItem) => void;
}
export function RoleTable({
roles,
total,
page,
limit,
totalPages,
onPageChange,
onLimitChange,
onEdit,
onOpenMatrix,
onViewUsers,
onDelete,
onResetQuota,
}: RoleTableProps) {
const { t } = useLanguage();
const { role: userRole, hasPermission } = useAuth();
......@@ -83,10 +100,24 @@ export function RoleTable({
{role.name}
</span>
{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}
</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>
</td>
......@@ -174,6 +205,17 @@ export function RoleTable({
</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 && (
<button
type="button"
......@@ -220,6 +262,17 @@ export function RoleTable({
})}
</tbody>
</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>
);
}
......@@ -28,6 +28,8 @@ import { RoleFormModal } from "./role-form-modal";
import { PermissionMatrixDialog } from "./permission-matrix-dialog";
import { RoleUsersModal } from "./role-users-modal";
import { RoleDeleteDialog } from "./role-delete-dialog";
import { RoleResetQuotaDialog } from "./role-reset-quota-dialog";
import { TablePagination } from "@/components/common/table-pagination";
interface RolesManagementViewProps {
showHeader?: boolean;
......@@ -50,6 +52,8 @@ export function RolesManagementView({
"ALL" | "SYSTEM" | "CUSTOM"
>("ALL");
const [viewMode, setViewMode] = useState<"grid" | "table">("grid");
const [page, setPage] = useState(1);
const [limit, setLimit] = useState(10);
// Dropdown states
const [isStatusOpen, setIsStatusOpen] = useState(false);
......@@ -63,6 +67,7 @@ export function RolesManagementView({
const [matrixRole, setMatrixRole] = useState<RoleItem | null>(null);
const [usersModalRole, setUsersModalRole] = useState<RoleItem | null>(null);
const [deletingRole, setDeletingRole] = useState<RoleItem | null>(null);
const [resettingRole, setResettingRole] = useState<RoleItem | null>(null);
// Debounce search
useEffect(() => {
......@@ -72,6 +77,10 @@ export function RolesManagementView({
return () => clearTimeout(timer);
}, [search]);
useEffect(() => {
setPage(1);
}, [debouncedSearch, selectedStatus, selectedType]);
// Click outside dropdowns
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
......@@ -106,13 +115,17 @@ export function RolesManagementView({
: selectedType === "CUSTOM"
? false
: undefined,
limit: 100,
page,
limit,
});
// Fetch Permissions Catalog for overall count
const { data: allPermissions = [] } = usePermissionsList();
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
const stats = useMemo(() => {
......@@ -474,6 +487,7 @@ export function RolesManagementView({
)}
</div>
) : viewMode === "grid" ? (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{roles.map((role) => (
<RoleCard
......@@ -483,16 +497,37 @@ export function RolesManagementView({
onOpenMatrix={(r) => setMatrixRole(r)}
onViewUsers={(r) => setUsersModalRole(r)}
onDelete={(r) => setDeletingRole(r)}
onResetQuota={(r) => setResettingRole(r)}
/>
))}
</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
roles={roles}
total={totalRoles}
page={page}
limit={limit}
totalPages={totalPages}
onPageChange={setPage}
onLimitChange={setLimit}
onEdit={(r) => setEditingRole(r)}
onOpenMatrix={(r) => setMatrixRole(r)}
onViewUsers={(r) => setUsersModalRole(r)}
onDelete={(r) => setDeletingRole(r)}
onResetQuota={(r) => setResettingRole(r)}
/>
)}
......@@ -535,6 +570,14 @@ export function RolesManagementView({
role={deletingRole}
/>
)}
{resettingRole && (
<RoleResetQuotaDialog
isOpen={!!resettingRole}
onClose={() => setResettingRole(null)}
role={resettingRole}
/>
)}
</div>
);
}
......@@ -36,6 +36,7 @@ import { SystemConfigTable } from "./system-config-table";
import { SystemConfigFormModal } from "./system-config-form-modal";
import { SystemConfigDeleteDialog } from "./system-config-delete-dialog";
import { StatCard } from "@/components/common/stat-card";
import { TablePagination } from "@/components/common/table-pagination";
interface SystemConfigsManagementViewProps {
showHeader?: boolean;
......@@ -394,6 +395,7 @@ export function SystemConfigsManagementView({
{!isLoading && !isError && sortedItems.length > 0 && (
<>
{viewMode === "grid" ? (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{sortedItems.map((item) => (
<SystemConfigCard
......@@ -407,6 +409,19 @@ export function SystemConfigsManagementView({
/>
))}
</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
configs={sortedItems}
......
......@@ -84,6 +84,8 @@ export function UserCreateModal({ isOpen, onClose }: UserCreateModalProps) {
maxPagesLimit: 1000,
maxJobsPerDayLimit: 50,
maxConcurrentJobsLimit: 5,
maxPagesPerMonthLimit: 10000,
maxJobsPerMonthLimit: 500,
},
});
......@@ -100,6 +102,8 @@ export function UserCreateModal({ isOpen, onClose }: UserCreateModalProps) {
maxPagesLimit: 1000,
maxJobsPerDayLimit: 50,
maxConcurrentJobsLimit: 5,
maxPagesPerMonthLimit: 10000,
maxJobsPerMonthLimit: 500,
});
setShowAdvanced(false);
setShowPassword(false);
......@@ -137,6 +141,13 @@ export function UserCreateModal({ isOpen, onClose }: UserCreateModalProps) {
if (!isOpen) return null;
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({
email: data.email.trim(),
password: data.password,
......@@ -145,6 +156,8 @@ export function UserCreateModal({ isOpen, onClose }: UserCreateModalProps) {
maxPagesLimit: data.maxPagesLimit,
maxJobsPerDayLimit: data.maxJobsPerDayLimit,
maxConcurrentJobsLimit: data.maxConcurrentJobsLimit,
maxPagesPerMonthLimit: parseOptionalNumber(data.maxPagesPerMonthLimit),
maxJobsPerMonthLimit: parseOptionalNumber(data.maxJobsPerMonthLimit),
});
onClose();
};
......@@ -378,6 +391,31 @@ export function UserCreateModal({ isOpen, onClose }: UserCreateModalProps) {
/>
</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>
......
......@@ -55,6 +55,8 @@ export function UserEditModal({
maxPagesLimit: 1000,
maxJobsPerDayLimit: 50,
maxConcurrentJobsLimit: 5,
maxPagesPerMonthLimit: 10000,
maxJobsPerMonthLimit: 500,
},
});
......@@ -72,6 +74,8 @@ export function UserEditModal({
maxPagesLimit: user.maxPagesLimit,
maxJobsPerDayLimit: user.maxJobsPerDayLimit,
maxConcurrentJobsLimit: user.maxConcurrentJobsLimit,
maxPagesPerMonthLimit: user.maxPagesPerMonthLimit ?? 10000,
maxJobsPerMonthLimit: user.maxJobsPerMonthLimit ?? 500,
});
setShowAdvanced(false);
}
......@@ -101,6 +105,8 @@ export function UserEditModal({
maxPagesLimit: data.maxPagesLimit,
maxJobsPerDayLimit: data.maxJobsPerDayLimit,
maxConcurrentJobsLimit: data.maxConcurrentJobsLimit,
maxPagesPerMonthLimit: data.maxPagesPerMonthLimit,
maxJobsPerMonthLimit: data.maxJobsPerMonthLimit,
},
});
onClose();
......@@ -284,6 +290,31 @@ export function UserEditModal({
/>
</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>
......
"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 {
Clock,
Mail,
UserCheck,
RotateCcw,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { UserItem } from "@/types/user";
......@@ -31,6 +32,7 @@ interface UserTableProps {
onManageRoles: (user: UserItem) => void;
onDelete: (user: UserItem) => void;
onToggleStatus: (user: UserItem) => void;
onResetQuota?: (user: UserItem) => void;
onCreateNew: () => void;
currentUserId?: string;
isTogglingStatus?: boolean;
......@@ -54,6 +56,7 @@ export function UserTable({
onManageRoles,
onDelete,
onToggleStatus,
onResetQuota,
onCreateNew,
currentUserId,
isTogglingStatus,
......@@ -329,9 +332,14 @@ export function UserTable({
</span>
)}
</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)}...
</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>
</td>
......@@ -426,6 +434,17 @@ export function UserTable({
<Edit3 className="h-4 w-4" />
</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 */}
<button
onClick={() => onDelete(user)}
......
......@@ -25,6 +25,7 @@ import { UserCreateModal } from "./user-create-modal";
import { UserEditModal } from "./user-edit-modal";
import { UserRolesModal } from "./user-roles-modal";
import { UserDeleteDialog } from "./user-delete-dialog";
import { UserResetQuotaDialog } from "./user-reset-quota-dialog";
interface UsersManagementViewProps {
showHeader?: boolean;
......@@ -68,6 +69,7 @@ export function UsersManagementView({
const [editingUser, setEditingUser] = useState<UserItem | null>(null);
const [rolesUser, setRolesUser] = useState<UserItem | null>(null);
const [deletingUser, setDeletingUser] = useState<UserItem | null>(null);
const [resettingUserQuota, setResettingUserQuota] = useState<UserItem | null>(null);
// Quick toggle status mutation
const updateMutation = useUpdateUser();
......@@ -495,6 +497,7 @@ export function UsersManagementView({
onManageRoles={(u) => setRolesUser(u)}
onDelete={(u) => setDeletingUser(u)}
onToggleStatus={handleToggleStatus}
onResetQuota={(u) => setResettingUserQuota(u)}
onCreateNew={() => setIsCreateModalOpen(true)}
currentUserId={currentUser?.id}
isTogglingStatus={updateMutation.isPending}
......@@ -546,6 +549,14 @@ export function UsersManagementView({
user={deletingUser}
/>
)}
{resettingUserQuota && (
<UserResetQuotaDialog
isOpen={!!resettingUserQuota}
onClose={() => setResettingUserQuota(null)}
user={resettingUserQuota}
/>
)}
</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 = {
};
// API Keys Hooks
export function useApiKeysList() {
export function useApiKeysList(options?: { enabled?: boolean }) {
return useQuery({
queryKey: DEVELOPER_KEYS.apiKeys,
queryFn: () => developerService.listKeys(),
staleTime: 30000,
enabled: options?.enabled,
});
}
......@@ -76,11 +77,12 @@ export function useRevokeApiKey() {
}
// Webhooks Hooks
export function useWebhookConfigsList() {
export function useWebhookConfigsList(options?: { enabled?: boolean }) {
return useQuery({
queryKey: DEVELOPER_KEYS.webhooks,
queryFn: () => developerService.listWebhookConfigs(),
staleTime: 30000,
enabled: options?.enabled,
});
}
......
......@@ -142,3 +142,32 @@ export function useRoleUsers(roleId: string, options?: { enabled?: boolean }) {
!!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() {
},
});
}
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 = {
roles: "Vai Trò & Phân Quyền",
auditLogs: "Nhật Ký Hệ Thống",
systemConfigs: "Cấu Hình Hệ Thống",
cron: "Tác Vụ Định Kỳ",
statusActive: "Động cơ Crawler Đang Chạy",
reload: "Tải lại trang",
copyright: "Data Crawler © 2026 - Code by @hnihTyoB",
......@@ -113,8 +114,9 @@ export const translations = {
quota: {
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",
pagesLimit: "Số trang tối đa",
pagesUsed: "Trang đã cào",
pagesLimit: "Số trang trong ngày",
pagesUsed: "Trang trong ngày",
pagesAllTime: "Tổng số trang tích lũy",
pagesProgress: "Tiến độ cào",
dailyJobs: "Tác vụ trong ngày",
jobsUsed: "Đã chạy hôm nay",
......@@ -134,6 +136,18 @@ export const translations = {
slotsUnit: "tác vụ",
slotRunning: "Slot đang chạy",
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
health: {
......@@ -658,6 +672,7 @@ export const translations = {
webhooks: "Webhooks",
docs: "Tích Hợp Nhanh",
systemConfigs: "Cấu Hình & Cờ Tính Năng",
cron: "Tác Vụ Định Kỳ",
},
apiKeys: {
title: "Quản Lý Khóa API",
......@@ -792,11 +807,14 @@ export const translations = {
},
quota: {
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ụ",
maxJobsDay: "Tác vụ tối đa / Ngày",
concurrentLimit: "Tác vụ chạy song song",
usedToday: "Đã dùng hôm nay",
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: {
title: "Đổi Mật Khẩu",
......@@ -900,9 +918,11 @@ export const translations = {
passwordPlaceholder: "Tối thiểu 8 ký tự",
role: "Vai trò ban đầu",
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ụ",
maxJobsPerDay: "Giới hạn số tác vụ tối đa mỗi ngà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ụ 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ỏ",
submit: "Tạo Người Dùng",
submitting: "Đang tạo...",
......@@ -921,6 +941,11 @@ export const translations = {
statusSwitchLocked: "Bị khóa",
role: "Vai trò chính",
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ỏ",
submit: "Lưu Thay Đổi",
submitting: "Đang lưu...",
......@@ -947,11 +972,22 @@ export const translations = {
confirm: "Xóa Vĩnh Viễn",
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: {
edit: "Chỉnh sửa thông tin",
assignRoles: "Phân vai trò",
lock: "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",
},
},
......@@ -1020,6 +1056,14 @@ export const translations = {
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...",
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ỏ",
save: "Lưu Vai Trò",
saving: "Đang lưu...",
......@@ -1065,10 +1109,21 @@ export const translations = {
confirm: "Xác Nhận 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: {
edit: "Chỉnh sửa",
matrix: "Phân quyền",
viewUsers: "Xem thành viên",
resetQuota: "Đặt lại hạn ngạch vai trò",
delete: "Xóa vai trò",
},
},
......@@ -1347,6 +1402,69 @@ export const translations = {
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: {
// Navigation & Common
......@@ -1364,6 +1482,7 @@ export const translations = {
roles: "Roles & Permissions",
auditLogs: "System Audit Logs",
systemConfigs: "System Configurations",
cron: "Cron Jobs",
statusActive: "Crawler Engine Active",
reload: "Reload page",
copyright: "Data Crawler © 2026 - Code by @hnihTyoB",
......@@ -1460,8 +1579,9 @@ export const translations = {
quota: {
title: "Resource Quota & Usage",
subtitle: "Monitor scraping capacity limits and account resource consumption",
pagesLimit: "Max Pages Limit",
pagesUsed: "Pages Crawled",
pagesLimit: "Daily Pages Quota",
pagesUsed: "Pages Today",
pagesAllTime: "Total All-Time Pages",
pagesProgress: "Crawl Progress",
dailyJobs: "Daily Jobs Quota",
jobsUsed: "Used Today",
......@@ -1481,6 +1601,18 @@ export const translations = {
slotsUnit: "slots",
slotRunning: "Slot currently running",
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
health: {
......@@ -2005,6 +2137,7 @@ export const translations = {
webhooks: "Webhooks",
docs: "Integration Guide",
systemConfigs: "Configurations & Flags",
cron: "Cron Jobs",
},
apiKeys: {
title: "API Keys Management",
......@@ -2139,11 +2272,14 @@ export const translations = {
},
quota: {
title: "Account Quota & Limits",
subtitle: "Monitor your daily and monthly crawling resource limits and utilization",
maxPages: "Max Pages / Job",
maxJobsDay: "Max Jobs / Day",
concurrentLimit: "Concurrent Jobs",
usedToday: "Used Today",
remaining: "Remaining Today",
maxPagesPerMonth: "Max Pages / Month",
maxJobsPerMonth: "Max Jobs / Month",
},
password: {
title: "Change Password",
......@@ -2250,6 +2386,8 @@ export const translations = {
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",
submit: "Create User",
submitting: "Creating...",
......@@ -2268,6 +2406,11 @@ export const translations = {
statusSwitchLocked: "Locked",
role: "Primary Role",
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",
submit: "Save Changes",
submitting: "Saving...",
......@@ -2294,11 +2437,22 @@ export const translations = {
confirm: "Delete Permanently",
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: {
edit: "Edit Profile & Status",
assignRoles: "Assign Roles",
lock: "Lock Account",
unlock: "Unlock Account",
resetQuota: "Reset Resource Quota",
delete: "Delete User",
},
},
......@@ -2367,6 +2521,14 @@ export const translations = {
descLabel: "Detailed Description",
descPlaceholder: "Describe responsibilities and scope of this role...",
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",
save: "Save Role",
saving: "Saving...",
......@@ -2412,10 +2574,21 @@ export const translations = {
confirm: "Confirm Delete",
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: {
edit: "Edit",
matrix: "Permissions",
viewUsers: "View Members",
resetQuota: "Reset Role Quotas",
delete: "Delete Role",
},
},
......@@ -2694,6 +2867,69 @@ export const translations = {
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 = [
"/settings/developer/roles",
"/settings/developer/audit-logs",
"/settings/developer/system-configs",
"/settings/developer/cron",
"/cron",
];
// 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";
export * from "./profile.schema";
export * from "./user.schema";
export * from "./role.schema";
export * from "./cron.schema";
......@@ -19,6 +19,38 @@ export const createRoleSchema = z.object({
.optional()
.or(z.literal("")),
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>;
......@@ -34,6 +66,39 @@ export const updateRoleSchema = z.object({
.optional()
.or(z.literal("")),
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>;
......
......@@ -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.")
.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 CreateUserInput = z.infer<typeof createUserSchema>;
......@@ -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.")
.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 UpdateUserInput = z.infer<typeof updateUserSchema>;
......
......@@ -82,6 +82,8 @@ const DEFAULT_FALLBACK_STATS: DashboardStats = {
concurrentJobsRunning: 1,
concurrentJobsAvailable: 4,
totalPagesCrawled: 167,
pagesCrawledToday: 24,
pagesRemainingToday: 976,
},
resetAt: new Date(Date.now() + 86400000).toISOString(),
},
......@@ -139,6 +141,8 @@ export const crawlerService = {
concurrentJobsRunning: runningCount,
concurrentJobsAvailable: Math.max(0, 5 - runningCount),
totalPagesCrawled: totalPages || 167,
pagesCrawledToday: totalPages || 0,
pagesRemainingToday: Math.max(0, 1000 - (totalPages || 0)),
},
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 {
}
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();
......
......@@ -131,6 +131,23 @@ export class UserService {
}
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();
......
......@@ -12,6 +12,9 @@ export interface User {
maxPagesLimit: number;
maxJobsPerDayLimit: number;
maxConcurrentJobsLimit: number;
maxPagesPerMonthLimit?: number | null;
maxJobsPerMonthLimit?: number | null;
quotaResetAt?: string | null;
createdAt: string;
updatedAt: string;
}
......@@ -53,6 +56,8 @@ export interface UserQuota {
maxPagesLimit: number;
maxJobsPerDayLimit: number;
maxConcurrentJobsLimit: number;
maxPagesPerMonthLimit?: number | null;
maxJobsPerMonthLimit?: number | null;
}
export interface UserUsage {
......@@ -61,12 +66,20 @@ export interface UserUsage {
concurrentJobsRunning: number;
concurrentJobsAvailable: number;
totalPagesCrawled: number;
pagesCrawledToday: number;
pagesRemainingToday: number;
jobsUsedThisMonth?: number;
jobsRemainingThisMonth?: number | null;
pagesCrawledThisMonth?: number;
pagesRemainingThisMonth?: number | null;
}
export interface UserUsageDto {
quota: UserQuota;
usage: UserUsage;
resetAt: string;
monthlyResetAt?: string;
quotaResetAt?: string | null;
}
export interface RolePermissionItemDto {
......@@ -84,6 +97,11 @@ export interface Role {
description: string | null;
isSystem: boolean;
isActive: boolean;
maxPagesLimit?: number;
maxJobsPerDayLimit?: number;
maxConcurrentJobsLimit?: number;
maxPagesPerMonthLimit?: number | null;
maxJobsPerMonthLimit?: number | null;
createdAt: string;
updatedAt: string;
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";
export * from "./user";
export * from "./role";
export * from "./permission";
export * from "./cron";
......@@ -21,6 +21,11 @@ export interface RoleItem {
description: string | null;
isSystem: boolean;
isActive: boolean;
maxPagesLimit?: number;
maxJobsPerDayLimit?: number;
maxConcurrentJobsLimit?: number;
maxPagesPerMonthLimit?: number | null;
maxJobsPerMonthLimit?: number | null;
createdAt: string;
updatedAt: string;
permissions?: RolePermissionItem[];
......@@ -32,12 +37,27 @@ export interface CreateRoleDto {
slug: string;
description?: string;
permissionIds?: string[];
maxPagesLimit?: number;
maxJobsPerDayLimit?: number;
maxConcurrentJobsLimit?: number;
maxPagesPerMonthLimit?: number | null;
maxJobsPerMonthLimit?: number | null;
}
export interface UpdateRoleDto {
name?: string;
description?: string;
isActive?: boolean;
maxPagesLimit?: number;
maxJobsPerDayLimit?: number;
maxConcurrentJobsLimit?: number;
maxPagesPerMonthLimit?: number | null;
maxJobsPerMonthLimit?: number | null;
syncUsersQuota?: boolean;
}
export interface ResetRoleQuotaPayload {
syncLimits?: boolean;
}
export interface AssignRolePermissionsDto {
......
......@@ -19,6 +19,9 @@ export interface UserItem {
maxPagesLimit: number;
maxJobsPerDayLimit: number;
maxConcurrentJobsLimit: number;
maxPagesPerMonthLimit?: number | null;
maxJobsPerMonthLimit?: number | null;
quotaResetAt?: string | null;
createdAt: string;
updatedAt: string;
roles?: UserAssignedRole[];
......@@ -33,6 +36,8 @@ export interface CreateUserDto {
maxPagesLimit?: number;
maxJobsPerDayLimit?: number;
maxConcurrentJobsLimit?: number;
maxPagesPerMonthLimit?: number | null;
maxJobsPerMonthLimit?: number | null;
}
export interface UpdateUserDto {
......@@ -44,6 +49,12 @@ export interface UpdateUserDto {
maxPagesLimit?: number;
maxJobsPerDayLimit?: number;
maxConcurrentJobsLimit?: number;
maxPagesPerMonthLimit?: number | null;
maxJobsPerMonthLimit?: number | null;
}
export interface ResetUserQuotaPayload {
resetLimitsToRole?: boolean;
}
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