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>
<Link
href="/settings/developer/api-keys"
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>
{canAccessDeveloper && (
<Link
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 */}
......
This diff is collapsed.
"use client";
import React, { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Play, CheckCircle2, XCircle, Clock, Loader2 } from "lucide-react";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { useLanguage } from "@/providers/language-provider";
import { useTriggerCronJob } from "@/hooks/use-cron";
import {
triggerCronJobFormSchema,
TriggerCronJobFormInput,
} from "@/schemas/cron.schema";
import { CronJob, CronJobExecutionResult } from "@/types/cron";
interface CronTriggerDialogProps {
job: CronJob | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function CronTriggerDialog({
job,
open,
onOpenChange,
}: CronTriggerDialogProps) {
const { t } = useLanguage();
const triggerMutation = useTriggerCronJob();
const [executionResult, setExecutionResult] =
useState<CronJobExecutionResult | null>(null);
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<TriggerCronJobFormInput>({
resolver: zodResolver(triggerCronJobFormSchema),
defaultValues: {
paramsJson: "",
},
});
const handleClose = () => {
reset();
setExecutionResult(null);
onOpenChange(false);
};
const onSubmit = async (data: TriggerCronJobFormInput) => {
if (!job) return;
let parsedParams: Record<string, unknown> | undefined = undefined;
if (data.paramsJson && data.paramsJson.trim() !== "") {
try {
parsedParams = JSON.parse(data.paramsJson.trim());
} catch {
return;
}
}
try {
const result = await triggerMutation.mutateAsync({
name: job.name,
params: parsedParams,
});
setExecutionResult(result);
toast.success(
`${t.cron.toast.triggerSuccess}: ${job.name} (${result.durationMs}ms)`,
);
} catch (err: unknown) {
const errorMsg =
err instanceof Error ? err.message : t.cron.toast.triggerError;
toast.error(errorMsg);
}
};
if (!job) return null;
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-xl rounded-3xl border border-emerald-500/15 bg-card/95 p-6 shadow-2xl backdrop-blur-xl">
<DialogHeader className="space-y-2">
<div className="flex items-center gap-2">
<div className="flex h-9 w-9 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<Play className="h-4 w-4" />
</div>
<DialogTitle className="text-lg font-bold text-foreground">
{t.cron.triggerModal.title}
</DialogTitle>
</div>
<DialogDescription className="text-xs text-muted-foreground">
{t.cron.triggerModal.desc}
</DialogDescription>
</DialogHeader>
{/* Thông tin Job đang chọn */}
<div className="rounded-2xl border border-emerald-500/15 bg-emerald-500/5 p-3.5 space-y-1.5 text-xs">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">
{t.cron.triggerModal.jobLabel}
</span>
<span className="font-mono font-bold text-emerald-700 dark:text-emerald-300">
{job.name}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">
{t.cron.triggerModal.cronLabel}
</span>
<Badge
variant="outline"
className="font-mono text-[11px] bg-background/80 border-border/80"
>
{job.cron}
</Badge>
</div>
</div>
{/* Form nhập parameters JSON */}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 pt-2">
<div className="space-y-1.5">
<label className="text-xs font-semibold text-foreground flex items-center justify-between">
<span>{t.cron.triggerModal.paramsLabel}</span>
<span className="text-[11px] text-muted-foreground font-normal">
{t.cron.triggerModal.paramsHint}
</span>
</label>
<textarea
{...register("paramsJson")}
placeholder={t.cron.triggerModal.paramsPlaceholder}
rows={4}
disabled={triggerMutation.isPending}
className="w-full rounded-2xl border border-border/80 bg-background/80 p-3 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 focus:border-emerald-500/50 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 transition-all"
/>
{errors.paramsJson && (
<p className="text-[11px] text-red-500">
{errors.paramsJson.message}
</p>
)}
</div>
{/* Kết quả sau khi chạy */}
{executionResult && (
<div className="rounded-2xl border border-border/70 bg-background/90 p-3.5 space-y-2 text-xs animate-in fade-in-50 duration-200">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 font-semibold">
{executionResult.success ? (
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
) : (
<XCircle className="h-4 w-4 text-red-500" />
)}
<span>
{executionResult.success
? t.cron.triggerModal.resultSuccess
: t.cron.triggerModal.resultFailed}
</span>
</div>
<div className="flex items-center gap-1 text-[11px] text-muted-foreground">
<Clock className="h-3.5 w-3.5" />
<span>{executionResult.durationMs}ms</span>
</div>
</div>
{executionResult.data && (
<div className="mt-2 rounded-xl bg-muted/40 p-2.5 font-mono text-[11px] overflow-x-auto max-h-40">
<pre className="text-foreground/90">
{JSON.stringify(executionResult.data, null, 2)}
</pre>
</div>
)}
{executionResult.error && (
<p className="text-red-500 text-[11px]">
{executionResult.error}
</p>
)}
</div>
)}
<DialogFooter className="flex items-center justify-end gap-2 pt-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleClose}
disabled={triggerMutation.isPending}
className="rounded-2xl border-border/80 text-xs cursor-pointer"
>
{t.cron.triggerModal.closeBtn}
</Button>
<Button
type="submit"
size="sm"
disabled={triggerMutation.isPending}
className="rounded-2xl bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-semibold px-4 shadow-sm shadow-emerald-600/20 cursor-pointer transition-all hover:scale-[1.02]"
>
{triggerMutation.isPending ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />
{t.cron.triggerModal.executing}
</>
) : (
<>
<Play className="h-3.5 w-3.5 mr-1.5" />
{t.cron.triggerModal.runBtn}
</>
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
......@@ -10,6 +10,8 @@ import {
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
......
This diff is collapsed.
"use client";
import React, { useState, useEffect } from "react";
import { RotateCcw, X, ShieldCheck, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useLanguage } from "@/providers/language-provider";
import { RoleItem } from "@/types/role";
import { useResetRoleQuota } from "@/hooks/use-roles";
interface RoleResetQuotaDialogProps {
isOpen: boolean;
onClose: () => void;
role: RoleItem | null;
}
export function RoleResetQuotaDialog({
isOpen,
onClose,
role,
}: RoleResetQuotaDialogProps) {
const { t } = useLanguage();
const resetMutation = useResetRoleQuota();
const [syncLimits, setSyncLimits] = useState(false);
useEffect(() => {
if (isOpen) {
setSyncLimits(false);
}
}, [isOpen]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen || !role) return null;
const handleConfirm = async () => {
await resetMutation.mutateAsync({
id: role.id,
payload: { syncLimits },
});
onClose();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm animate-in fade-in-0 duration-200">
<div className="relative w-full max-w-md rounded-3xl border border-emerald-500/20 bg-card p-6 shadow-2xl shadow-emerald-950/20 space-y-4">
{/* Header */}
<div className="flex items-center justify-between pb-3 border-b border-border/60">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<RotateCcw className="h-5 w-5" />
</div>
<div>
<h2 className="text-base font-bold text-foreground">
{t.roles.dialogResetQuota.title}
</h2>
</div>
</div>
<button
onClick={onClose}
className="rounded-xl p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Target Role Card */}
<div className="flex items-center gap-3 p-3 rounded-2xl border border-emerald-500/20 bg-emerald-500/5">
<ShieldCheck className="h-5 w-5 text-emerald-600 dark:text-emerald-400 shrink-0" />
<div className="min-w-0">
<p className="text-[11px] text-muted-foreground">
{t.roles.dialogResetQuota.targetRole}
</p>
<p className="text-sm font-bold text-foreground truncate">
{role.name} ({role.slug})
</p>
{role.userCount !== undefined && (
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 font-medium">
{role.userCount} {t.roles.table.membersUnit}
</p>
)}
</div>
</div>
{/* Description */}
<p className="text-xs text-muted-foreground leading-relaxed">
{t.roles.dialogResetQuota.desc}
</p>
{/* Sync limits checkbox */}
<label className="flex items-start gap-3 p-3.5 rounded-2xl border border-border/70 bg-muted/30 cursor-pointer transition-colors hover:border-emerald-500/30">
<input
type="checkbox"
checked={syncLimits}
onChange={(e) => setSyncLimits(e.target.checked)}
className="mt-0.5 rounded border-border/80 text-emerald-600 focus:ring-emerald-500"
/>
<span className="text-xs text-foreground font-medium leading-relaxed">
{t.roles.dialogResetQuota.syncLimits}
</span>
</label>
{/* Action Buttons */}
<div className="flex items-center justify-end gap-2.5 pt-3 border-t border-border/60">
<Button
type="button"
variant="outline"
onClick={onClose}
className="rounded-2xl text-xs cursor-pointer"
>
{t.roles.dialogResetQuota.cancel}
</Button>
<Button
type="button"
disabled={resetMutation.isPending}
onClick={handleConfirm}
className="rounded-2xl bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-semibold px-4 cursor-pointer shadow-sm shadow-emerald-600/20"
>
{resetMutation.isPending
? t.roles.dialogResetQuota.resetting
: t.roles.dialogResetQuota.confirm}
</Button>
</div>
</div>
</div>
);
}
......@@ -10,26 +10,43 @@ import {
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,25 +487,47 @@ export function RolesManagementView({
)}
</div>
) : viewMode === "grid" ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{roles.map((role) => (
<RoleCard
key={role.id}
role={role}
onEdit={(r) => setEditingRole(r)}
onOpenMatrix={(r) => setMatrixRole(r)}
onViewUsers={(r) => setUsersModalRole(r)}
onDelete={(r) => setDeletingRole(r)}
<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
key={role.id}
role={role}
onEdit={(r) => setEditingRole(r)}
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,18 +395,32 @@ export function SystemConfigsManagementView({
{!isLoading && !isError && sortedItems.length > 0 && (
<>
{viewMode === "grid" ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{sortedItems.map((item) => (
<SystemConfigCard
key={item.id}
config={item}
canManage={canManage}
onEdit={handleOpenEdit}
onDelete={handleOpenDelete}
onToggle={handleToggle}
isToggling={togglingKey === item.key}
<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
key={item.id}
config={item}
canManage={canManage}
onEdit={handleOpenEdit}
onDelete={handleOpenDelete}
onToggle={handleToggle}
isToggling={togglingKey === item.key}
/>
))}
</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
......
......@@ -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]">
ID: {user.id.substring(0, 8)}...
</span>
<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}`);
},
});
}
This diff is collapsed.
......@@ -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