Browse Source

feat: enhance my usage feature with expiration and provider group details

- Added new labels and sections for key and user expiration information in multiple languages.
- Introduced provider group details in the UI, including inherited group information.
- Updated the MyUsage interface to include user and key provider group attributes.
- Enhanced the MyUsageHeader and QuotaCards components to display expiration countdowns and provider group information.
- Improved usage logs section with a date range picker for better filtering options.
ding113 3 months ago
parent
commit
a5f8709fc6

+ 22 - 2
messages/en/myUsage.json

@@ -2,7 +2,12 @@
   "header": {
   "header": {
     "title": "My Usage",
     "title": "My Usage",
     "subtitle": "View your quotas and usage logs",
     "subtitle": "View your quotas and usage logs",
-    "logout": "Logout"
+    "logout": "Logout",
+    "keyLabel": "Key",
+    "userLabel": "User",
+    "providerGroup": "Provider Group",
+    "noProviderGroup": "Default",
+    "inherited": "(inherited)"
   },
   },
   "quota": {
   "quota": {
     "5h": "5h Quota",
     "5h": "5h Quota",
@@ -39,7 +44,7 @@
       "model": "Model",
       "model": "Model",
       "status": "Status",
       "status": "Status",
       "allModels": "All models",
       "allModels": "All models",
-      "allStatus": "All status",
+      "allStatus": "All statuses",
       "apply": "Apply",
       "apply": "Apply",
       "reset": "Reset"
       "reset": "Reset"
     },
     },
@@ -58,5 +63,20 @@
     "noLogs": "No logs",
     "noLogs": "No logs",
     "unknownModel": "Unknown model",
     "unknownModel": "Unknown model",
     "billingModel": "Billing: {model}"
     "billingModel": "Billing: {model}"
+  },
+  "expiration": {
+    "title": "Expiration",
+    "keyExpires": "Key Expires",
+    "userExpires": "User Expires",
+    "neverExpires": "Never",
+    "expired": "Expired",
+    "expiresIn": "in {time}",
+    "expiringWarning": "Expiring Soon"
+  },
+  "providerGroup": {
+    "keyGroup": "Key Group",
+    "userGroup": "User Group",
+    "allProviders": "All Providers",
+    "inheritedFromUser": "Inherited from User"
   }
   }
 }
 }

+ 21 - 1
messages/ja/myUsage.json

@@ -2,7 +2,12 @@
   "header": {
   "header": {
     "title": "マイ利用状況",
     "title": "マイ利用状況",
     "subtitle": "クォータと利用ログを確認",
     "subtitle": "クォータと利用ログを確認",
-    "logout": "ログアウト"
+    "logout": "ログアウト",
+    "keyLabel": "キー",
+    "userLabel": "ユーザー",
+    "providerGroup": "プロバイダーグループ",
+    "noProviderGroup": "デフォルト",
+    "inherited": "(継承)"
   },
   },
   "quota": {
   "quota": {
     "5h": "5時間クォータ",
     "5h": "5時間クォータ",
@@ -58,5 +63,20 @@
     "noLogs": "ログがありません",
     "noLogs": "ログがありません",
     "unknownModel": "不明なモデル",
     "unknownModel": "不明なモデル",
     "billingModel": "課金: {model}"
     "billingModel": "課金: {model}"
+  },
+  "expiration": {
+    "title": "有効期限",
+    "keyExpires": "キーの期限",
+    "userExpires": "ユーザーの期限",
+    "neverExpires": "期限なし",
+    "expired": "期限切れ",
+    "expiresIn": "{time} で期限",
+    "expiringWarning": "まもなく期限"
+  },
+  "providerGroup": {
+    "keyGroup": "キーグループ",
+    "userGroup": "ユーザーグループ",
+    "allProviders": "すべてのプロバイダー",
+    "inheritedFromUser": "ユーザーから継承"
   }
   }
 }
 }

+ 22 - 2
messages/ru/myUsage.json

@@ -2,7 +2,12 @@
   "header": {
   "header": {
     "title": "Мои расходы",
     "title": "Мои расходы",
     "subtitle": "Лимиты и журналы использования",
     "subtitle": "Лимиты и журналы использования",
-    "logout": "Выйти"
+    "logout": "Выйти",
+    "keyLabel": "Ключ",
+    "userLabel": "Пользователь",
+    "providerGroup": "Группа провайдеров",
+    "noProviderGroup": "По умолчанию",
+    "inherited": "(наследовано)"
   },
   },
   "quota": {
   "quota": {
     "5h": "Лимит 5 часов",
     "5h": "Лимит 5 часов",
@@ -50,7 +55,7 @@
       "tokens": "Токены (вх/вых)",
       "tokens": "Токены (вх/вых)",
       "cost": "Стоимость {currency}",
       "cost": "Стоимость {currency}",
       "status": "Статус",
       "status": "Статус",
-      "endpoint": "Endpoint"
+      "endpoint": "API Endpoint"
     },
     },
     "pagination": "Показано {from}-{to} из {total}",
     "pagination": "Показано {from}-{to} из {total}",
     "prev": "Назад",
     "prev": "Назад",
@@ -58,5 +63,20 @@
     "noLogs": "Нет записей",
     "noLogs": "Нет записей",
     "unknownModel": "Неизвестная модель",
     "unknownModel": "Неизвестная модель",
     "billingModel": "Биллинг: {model}"
     "billingModel": "Биллинг: {model}"
+  },
+  "expiration": {
+    "title": "Срок действия",
+    "keyExpires": "Срок ключа",
+    "userExpires": "Срок пользователя",
+    "neverExpires": "Бессрочно",
+    "expired": "Истёк",
+    "expiresIn": "через {time}",
+    "expiringWarning": "Скоро истечёт"
+  },
+  "providerGroup": {
+    "keyGroup": "Группа ключа",
+    "userGroup": "Группа пользователя",
+    "allProviders": "Все провайдеры",
+    "inheritedFromUser": "Наследовано от пользователя"
   }
   }
 }
 }

+ 22 - 2
messages/zh-CN/myUsage.json

@@ -2,7 +2,12 @@
   "header": {
   "header": {
     "title": "我的用量",
     "title": "我的用量",
     "subtitle": "查看额度与使用记录",
     "subtitle": "查看额度与使用记录",
-    "logout": "退出登录"
+    "logout": "退出登录",
+    "keyLabel": "密钥",
+    "userLabel": "用户",
+    "providerGroup": "供应商分组",
+    "noProviderGroup": "默认",
+    "inherited": "(继承)"
   },
   },
   "quota": {
   "quota": {
     "5h": "5小时额度",
     "5h": "5小时额度",
@@ -50,7 +55,7 @@
       "tokens": "Tokens (入/出)",
       "tokens": "Tokens (入/出)",
       "cost": "{currency} 消耗",
       "cost": "{currency} 消耗",
       "status": "状态",
       "status": "状态",
-      "endpoint": "Endpoint"
+      "endpoint": "API 端点"
     },
     },
     "pagination": "显示 {from}-{to} / {total}",
     "pagination": "显示 {from}-{to} / {total}",
     "prev": "上一页",
     "prev": "上一页",
@@ -58,5 +63,20 @@
     "noLogs": "暂无日志",
     "noLogs": "暂无日志",
     "unknownModel": "未知模型",
     "unknownModel": "未知模型",
     "billingModel": "计费:{model}"
     "billingModel": "计费:{model}"
+  },
+  "expiration": {
+    "title": "过期时间",
+    "keyExpires": "密钥过期",
+    "userExpires": "用户过期",
+    "neverExpires": "永不过期",
+    "expired": "已过期",
+    "expiresIn": "剩余 {time}",
+    "expiringWarning": "即将过期"
+  },
+  "providerGroup": {
+    "keyGroup": "密钥分组",
+    "userGroup": "用户分组",
+    "allProviders": "全部供应商",
+    "inheritedFromUser": "继承自用户"
   }
   }
 }
 }

+ 22 - 2
messages/zh-TW/myUsage.json

@@ -2,7 +2,12 @@
   "header": {
   "header": {
     "title": "我的用量",
     "title": "我的用量",
     "subtitle": "查看額度與使用記錄",
     "subtitle": "查看額度與使用記錄",
-    "logout": "登出"
+    "logout": "登出",
+    "keyLabel": "金鑰",
+    "userLabel": "使用者",
+    "providerGroup": "供應商分組",
+    "noProviderGroup": "預設",
+    "inherited": "(繼承)"
   },
   },
   "quota": {
   "quota": {
     "5h": "5小時額度",
     "5h": "5小時額度",
@@ -50,7 +55,7 @@
       "tokens": "Tokens (入/出)",
       "tokens": "Tokens (入/出)",
       "cost": "{currency} 花費",
       "cost": "{currency} 花費",
       "status": "狀態",
       "status": "狀態",
-      "endpoint": "Endpoint"
+      "endpoint": "API 端點"
     },
     },
     "pagination": "顯示 {from}-{to} / {total}",
     "pagination": "顯示 {from}-{to} / {total}",
     "prev": "上一頁",
     "prev": "上一頁",
@@ -58,5 +63,20 @@
     "noLogs": "暫無日誌",
     "noLogs": "暫無日誌",
     "unknownModel": "未知模型",
     "unknownModel": "未知模型",
     "billingModel": "計費:{model}"
     "billingModel": "計費:{model}"
+  },
+  "expiration": {
+    "title": "到期時間",
+    "keyExpires": "金鑰到期",
+    "userExpires": "使用者到期",
+    "neverExpires": "永不過期",
+    "expired": "已過期",
+    "expiresIn": "剩餘 {time}",
+    "expiringWarning": "即將到期"
+  },
+  "providerGroup": {
+    "keyGroup": "金鑰分組",
+    "userGroup": "使用者分組",
+    "allProviders": "全部供應商",
+    "inheritedFromUser": "繼承自使用者"
   }
   }
 }
 }

+ 26 - 5
src/actions/my-usage.ts

@@ -44,6 +44,16 @@ export interface MyUsageQuota {
   userCurrentTotalUsd: number;
   userCurrentTotalUsd: number;
   userCurrentConcurrentSessions: number;
   userCurrentConcurrentSessions: number;
 
 
+  userLimitDailyUsd: number | null;
+  userExpiresAt: Date | null;
+  userProviderGroup: string | null;
+  userName: string;
+  userIsEnabled: boolean;
+
+  keyProviderGroup: string | null;
+  keyName: string;
+  keyIsEnabled: boolean;
+
   expiresAt: Date | null;
   expiresAt: Date | null;
   dailyResetMode: "fixed" | "rolling";
   dailyResetMode: "fixed" | "rolling";
   dailyResetTime: string;
   dailyResetTime: string;
@@ -194,9 +204,19 @@ export async function getMyQuota(): Promise<ActionResult<MyUsageQuota>> {
       userCurrentTotalUsd: userTotalCost,
       userCurrentTotalUsd: userTotalCost,
       userCurrentConcurrentSessions: userKeyConcurrent,
       userCurrentConcurrentSessions: userKeyConcurrent,
 
 
+      userLimitDailyUsd: user.dailyQuota ?? null,
+      userExpiresAt: user.expiresAt ?? null,
+      userProviderGroup: user.providerGroup ?? null,
+      userName: user.name,
+      userIsEnabled: user.isEnabled ?? true,
+
+      keyProviderGroup: key.providerGroup ?? null,
+      keyName: key.name,
+      keyIsEnabled: key.isEnabled ?? true,
+
       expiresAt: key.expiresAt ?? null,
       expiresAt: key.expiresAt ?? null,
-      dailyResetMode: key.dailyResetMode,
-      dailyResetTime: key.dailyResetTime,
+      dailyResetMode: key.dailyResetMode ?? "fixed",
+      dailyResetTime: key.dailyResetTime ?? "00:00",
     };
     };
 
 
     return { ok: true, data: quota };
     return { ok: true, data: quota };
@@ -215,7 +235,7 @@ export async function getMyTodayStats(): Promise<ActionResult<MyTodayStats>> {
     const billingModelSource = settings.billingModelSource;
     const billingModelSource = settings.billingModelSource;
     const currencyCode = settings.currencyDisplay;
     const currencyCode = settings.currencyDisplay;
 
 
-    const timezone = getEnvConfig().TZ;
+    const timezone = getEnvConfig().TZ || "UTC";
     const startOfDay = sql`(CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::date`;
     const startOfDay = sql`(CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::date`;
 
 
     const [aggregate] = await db
     const [aggregate] = await db
@@ -300,7 +320,8 @@ export async function getMyUsageLogs(
 
 
     const settings = await getSystemSettings();
     const settings = await getSystemSettings();
 
 
-    const pageSize = filters.pageSize && filters.pageSize > 0 ? filters.pageSize : 20;
+    const rawPageSize = filters.pageSize && filters.pageSize > 0 ? filters.pageSize : 20;
+    const pageSize = Math.min(rawPageSize, 100);
     const page = filters.page && filters.page > 0 ? filters.page : 1;
     const page = filters.page && filters.page > 0 ? filters.page : 1;
 
 
     const usageFilters: UsageLogFilters = {
     const usageFilters: UsageLogFilters = {
@@ -324,7 +345,7 @@ export async function getMyUsageLogs(
           : null;
           : null;
 
 
       const billingModel =
       const billingModel =
-        settings.billingModelSource === "original" ? log.originalModel : log.model;
+        (settings.billingModelSource === "original" ? log.originalModel : log.model) ?? null;
 
 
       return {
       return {
         id: log.id,
         id: log.id,

+ 89 - 0
src/app/[locale]/my-usage/_components/expiration-info.tsx

@@ -0,0 +1,89 @@
+"use client";
+
+import { useLocale, useTranslations } from "next-intl";
+import { QuotaCountdownCompact } from "@/components/quota/quota-countdown";
+import { useCountdown } from "@/hooks/useCountdown";
+import { cn } from "@/lib/utils";
+import { formatDate, getLocaleDateFormat } from "@/lib/utils/date-format";
+
+interface ExpirationInfoProps {
+  keyExpiresAt: Date | null;
+  userExpiresAt: Date | null;
+  className?: string;
+}
+
+const SEVEN_DAYS_IN_SECONDS = 7 * 24 * 60 * 60;
+const ONE_DAY_IN_SECONDS = 24 * 60 * 60;
+
+type ExpireStatus = "none" | "normal" | "warning" | "danger" | "expired";
+
+export function ExpirationInfo({ keyExpiresAt, userExpiresAt, className }: ExpirationInfoProps) {
+  const t = useTranslations("myUsage.expiration");
+  const locale = useLocale();
+
+  const keyCountdown = useCountdown(keyExpiresAt ?? null, Boolean(keyExpiresAt));
+  const userCountdown = useCountdown(userExpiresAt ?? null, Boolean(userExpiresAt));
+
+  const formatExpiry = (value: Date | null) => {
+    if (!value) return t("neverExpires");
+    const formatted = formatDate(value, getLocaleDateFormat(locale, "long"), locale);
+    return formatted;
+  };
+
+  const getStatus = (
+    value: Date | null,
+    countdownTotalSeconds: number,
+    isExpired: boolean
+  ): ExpireStatus => {
+    if (!value) return "none";
+    if (isExpired) return "expired";
+    if (countdownTotalSeconds <= ONE_DAY_IN_SECONDS) return "danger";
+    if (countdownTotalSeconds <= SEVEN_DAYS_IN_SECONDS) return "warning";
+    return "normal";
+  };
+
+  const statusStyles: Record<ExpireStatus, string> = {
+    none: "text-muted-foreground",
+    normal: "text-foreground",
+    warning: "text-amber-600 dark:text-amber-400",
+    danger: "text-red-600 dark:text-red-400",
+    expired: "text-destructive",
+  };
+
+  const renderItem = (
+    label: string,
+    value: Date | null,
+    countdown: ReturnType<typeof useCountdown>
+  ) => {
+    const status = getStatus(value, countdown.totalSeconds, countdown.isExpired);
+    const showCountdown =
+      value !== null &&
+      !countdown.isExpired &&
+      countdown.totalSeconds > 0 &&
+      countdown.totalSeconds <= SEVEN_DAYS_IN_SECONDS;
+
+    return (
+      <div className="space-y-2 rounded-md border border-border/60 bg-card/50 p-3">
+        <p className="text-xs font-medium text-muted-foreground">{label}</p>
+        <div className="flex items-center gap-2">
+          <span className={cn("text-sm font-semibold", statusStyles[status])}>
+            {status === "expired" ? t("expired") : formatExpiry(value)}
+          </span>
+        </div>
+        {showCountdown ? (
+          <div className="flex items-center gap-2 text-xs text-muted-foreground">
+            <span>{t("expiresIn", { time: countdown.shortFormatted })}</span>
+            <QuotaCountdownCompact resetAt={value} />
+          </div>
+        ) : null}
+      </div>
+    );
+  };
+
+  return (
+    <div className={cn("grid gap-3 sm:grid-cols-2", className)}>
+      {renderItem(t("keyExpires"), keyExpiresAt, keyCountdown)}
+      {renderItem(t("userExpires"), userExpiresAt, userCountdown)}
+    </div>
+  );
+}

+ 80 - 6
src/app/[locale]/my-usage/_components/my-usage-header.tsx

@@ -2,17 +2,73 @@
 
 
 import { LogOut } from "lucide-react";
 import { LogOut } from "lucide-react";
 import { useTranslations } from "next-intl";
 import { useTranslations } from "next-intl";
+import { QuotaCountdownCompact } from "@/components/quota/quota-countdown";
+import { Badge } from "@/components/ui/badge";
 import { Button } from "@/components/ui/button";
 import { Button } from "@/components/ui/button";
+import { useCountdown } from "@/hooks/useCountdown";
 import { useRouter } from "@/i18n/routing";
 import { useRouter } from "@/i18n/routing";
+import { cn } from "@/lib/utils";
 
 
 interface MyUsageHeaderProps {
 interface MyUsageHeaderProps {
   onLogout?: () => Promise<void> | void;
   onLogout?: () => Promise<void> | void;
+  keyName?: string;
+  userName?: string;
+  keyProviderGroup?: string | null;
+  userProviderGroup?: string | null;
+  keyExpiresAt?: Date | null;
+  userExpiresAt?: Date | null;
 }
 }
 
 
-export function MyUsageHeader({ onLogout }: MyUsageHeaderProps) {
-  const t = useTranslations("myUsage");
+export function MyUsageHeader({
+  onLogout,
+  keyName,
+  userName,
+  keyProviderGroup,
+  userProviderGroup,
+  keyExpiresAt,
+  userExpiresAt,
+}: MyUsageHeaderProps) {
+  const t = useTranslations("myUsage.header");
+  const tExpiration = useTranslations("myUsage.expiration");
   const router = useRouter();
   const router = useRouter();
 
 
+  const keyCountdown = useCountdown(keyExpiresAt ?? null, Boolean(keyExpiresAt));
+  const userCountdown = useCountdown(userExpiresAt ?? null, Boolean(userExpiresAt));
+
+  const groupLabel = (group: string | null | undefined, inherited = false) => (
+    <Badge variant="outline" className="gap-1 rounded-full bg-muted/50 text-xs font-medium">
+      <span className="text-muted-foreground">{t("providerGroup")}:</span>
+      <span className="text-foreground">{group || t("noProviderGroup")}</span>
+      {inherited ? <span className="text-muted-foreground">{t("inherited")}</span> : null}
+    </Badge>
+  );
+
+  const renderCountdownChip = (
+    label: string,
+    expiresAt: Date | null | undefined,
+    countdown: ReturnType<typeof useCountdown>
+  ) => {
+    if (!expiresAt || countdown.isExpired || countdown.totalSeconds > 7 * 24 * 60 * 60) return null;
+
+    const tone = countdown.totalSeconds <= 24 * 60 * 60 ? "danger" : "warning";
+    const toneClass =
+      tone === "danger"
+        ? "bg-red-100 text-red-800 dark:bg-red-500/15 dark:text-red-200"
+        : "bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-100";
+
+    return (
+      <span
+        className={cn(
+          "inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium",
+          toneClass
+        )}
+      >
+        <span>{label}</span>
+        <QuotaCountdownCompact resetAt={expiresAt} />
+      </span>
+    );
+  };
+
   const handleLogout = async () => {
   const handleLogout = async () => {
     if (onLogout) {
     if (onLogout) {
       await onLogout();
       await onLogout();
@@ -26,13 +82,31 @@ export function MyUsageHeader({ onLogout }: MyUsageHeaderProps) {
 
 
   return (
   return (
     <div className="flex items-center justify-between gap-4">
     <div className="flex items-center justify-between gap-4">
-      <div className="space-y-1">
-        <h1 className="text-xl font-semibold leading-tight">{t("header.title")}</h1>
-        <p className="text-sm text-muted-foreground">{t("header.subtitle")}</p>
+      <div className="space-y-2">
+        <div className="flex flex-wrap items-center gap-2">
+          <h1 className="text-xl font-semibold leading-tight">{t("title")}</h1>
+          {renderCountdownChip(tExpiration("keyExpires"), keyExpiresAt, keyCountdown)}
+          {renderCountdownChip(tExpiration("userExpires"), userExpiresAt, userCountdown)}
+        </div>
+        <div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
+          <span className="flex items-center gap-1">
+            <span className="text-foreground font-medium">{t("keyLabel")}:</span>
+            <span>{keyName ?? "—"}</span>
+          </span>
+          <span className="flex items-center gap-1">
+            <span className="text-foreground font-medium">{t("userLabel")}:</span>
+            <span>{userName ?? "—"}</span>
+          </span>
+        </div>
+        <div className="flex flex-wrap items-center gap-2">
+          {groupLabel(keyProviderGroup, !keyProviderGroup && !!userProviderGroup)}
+          {groupLabel(userProviderGroup, false)}
+        </div>
+        <p className="text-sm text-muted-foreground">{t("subtitle")}</p>
       </div>
       </div>
       <Button variant="outline" size="sm" onClick={handleLogout} className="gap-2">
       <Button variant="outline" size="sm" onClick={handleLogout} className="gap-2">
         <LogOut className="h-4 w-4" />
         <LogOut className="h-4 w-4" />
-        {t("header.logout")}
+        {t("logout")}
       </Button>
       </Button>
     </div>
     </div>
   );
   );

+ 44 - 0
src/app/[locale]/my-usage/_components/provider-group-info.tsx

@@ -0,0 +1,44 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import { Badge } from "@/components/ui/badge";
+import { cn } from "@/lib/utils";
+
+interface ProviderGroupInfoProps {
+  keyProviderGroup: string | null;
+  userProviderGroup: string | null;
+  className?: string;
+}
+
+export function ProviderGroupInfo({
+  keyProviderGroup,
+  userProviderGroup,
+  className,
+}: ProviderGroupInfoProps) {
+  const t = useTranslations("myUsage.providerGroup");
+
+  const keyDisplay = keyProviderGroup ?? userProviderGroup ?? t("allProviders");
+  const userDisplay = userProviderGroup ?? t("allProviders");
+  const inherited = !keyProviderGroup && !!userProviderGroup;
+
+  const badgeClass = "gap-1 rounded-full bg-card/60 text-xs font-medium";
+
+  return (
+    <div
+      className={cn(
+        "flex flex-wrap items-center gap-2 rounded-lg border bg-muted/40 p-3",
+        className
+      )}
+    >
+      <Badge variant="outline" className={badgeClass}>
+        <span className="text-muted-foreground">{t("keyGroup")}:</span>
+        <span className="text-foreground">{keyDisplay}</span>
+        {inherited ? <span className="text-muted-foreground">{t("inheritedFromUser")}</span> : null}
+      </Badge>
+      <Badge variant="outline" className={badgeClass}>
+        <span className="text-muted-foreground">{t("userGroup")}:</span>
+        <span className="text-foreground">{userDisplay}</span>
+      </Badge>
+    </div>
+  );
+}

+ 124 - 52
src/app/[locale]/my-usage/_components/quota-cards.tsx

@@ -3,18 +3,68 @@
 import { useTranslations } from "next-intl";
 import { useTranslations } from "next-intl";
 import { useMemo } from "react";
 import { useMemo } from "react";
 import type { MyUsageQuota } from "@/actions/my-usage";
 import type { MyUsageQuota } from "@/actions/my-usage";
+import { QuotaCountdownCompact } from "@/components/quota/quota-countdown";
 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
 import { Progress } from "@/components/ui/progress";
 import { Progress } from "@/components/ui/progress";
+import { useCountdown } from "@/hooks/useCountdown";
 import type { CurrencyCode } from "@/lib/utils";
 import type { CurrencyCode } from "@/lib/utils";
+import { cn } from "@/lib/utils";
 
 
 interface QuotaCardsProps {
 interface QuotaCardsProps {
   quota: MyUsageQuota | null;
   quota: MyUsageQuota | null;
   loading?: boolean;
   loading?: boolean;
   currencyCode?: CurrencyCode;
   currencyCode?: CurrencyCode;
+  keyExpiresAt?: Date | null;
+  userExpiresAt?: Date | null;
 }
 }
 
 
-export function QuotaCards({ quota, loading = false, currencyCode = "USD" }: QuotaCardsProps) {
+export function QuotaCards({
+  quota,
+  loading = false,
+  currencyCode = "USD",
+  keyExpiresAt,
+  userExpiresAt,
+}: QuotaCardsProps) {
   const t = useTranslations("myUsage.quota");
   const t = useTranslations("myUsage.quota");
+  const tExpiration = useTranslations("myUsage.expiration");
+
+  const resolvedKeyExpires = keyExpiresAt ?? quota?.expiresAt ?? null;
+  const resolvedUserExpires = userExpiresAt ?? quota?.userExpiresAt ?? null;
+
+  const keyCountdown = useCountdown(resolvedKeyExpires, Boolean(resolvedKeyExpires));
+  const userCountdown = useCountdown(resolvedUserExpires, Boolean(resolvedUserExpires));
+
+  const isExpiring = (countdown: ReturnType<typeof useCountdown>) =>
+    countdown.totalSeconds > 0 && countdown.totalSeconds <= 7 * 24 * 60 * 60;
+
+  const showKeyBadge = resolvedKeyExpires && !keyCountdown.isExpired && isExpiring(keyCountdown);
+  const showUserBadge =
+    resolvedUserExpires && !userCountdown.isExpired && isExpiring(userCountdown);
+
+  const renderExpireBadge = (
+    label: string,
+    resetAt: Date | null,
+    countdown: ReturnType<typeof useCountdown>
+  ) => {
+    if (!resetAt) return null;
+    const tone = countdown.totalSeconds <= 24 * 60 * 60 ? "danger" : "warning";
+    const toneClass =
+      tone === "danger"
+        ? "bg-red-100 text-red-800 dark:bg-red-500/15 dark:text-red-200"
+        : "bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-100";
+
+    return (
+      <span
+        className={cn(
+          "inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium",
+          toneClass
+        )}
+      >
+        <span>{label}</span>
+        <QuotaCountdownCompact resetAt={resetAt} />
+      </span>
+    );
+  };
 
 
   const items = useMemo(() => {
   const items = useMemo(() => {
     if (!quota) return [];
     if (!quota) return [];
@@ -33,7 +83,7 @@ export function QuotaCards({ quota, loading = false, currencyCode = "USD" }: Quo
         keyCurrent: quota.keyCurrentDailyUsd,
         keyCurrent: quota.keyCurrentDailyUsd,
         keyLimit: quota.keyLimitDailyUsd,
         keyLimit: quota.keyLimitDailyUsd,
         userCurrent: null,
         userCurrent: null,
-        userLimit: null,
+        userLimit: quota.userLimitDailyUsd,
       },
       },
       {
       {
         key: "weekly",
         key: "weekly",
@@ -71,66 +121,85 @@ export function QuotaCards({ quota, loading = false, currencyCode = "USD" }: Quo
   }, [quota, t]);
   }, [quota, t]);
 
 
   return (
   return (
-    <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
-      {items.map((item) => {
-        const keyPct = item.keyLimit
-          ? Math.min((item.keyCurrent / item.keyLimit) * 100, 999)
-          : null;
-        const userPct = item.userLimit
-          ? Math.min(((item.userCurrent ?? 0) / item.userLimit) * 100, 999)
-          : null;
-
-        const keyTone = getTone(keyPct);
-        const userTone = getTone(userPct);
-
-        return (
-          <Card key={item.key} className="border-border/70">
-            <CardHeader className="pb-3">
-              <CardTitle className="text-sm font-semibold text-muted-foreground">
-                {item.title}
-              </CardTitle>
-            </CardHeader>
-            <CardContent className="space-y-3">
-              <QuotaRow
-                label={t("keyLevel")}
-                current={item.keyCurrent}
-                limit={item.keyLimit}
-                percent={keyPct}
-                tone={keyTone}
-                currency={item.key === "concurrent" ? undefined : currencyCode}
-              />
-              {item.userLimit !== null || item.userCurrent !== null ? (
-                <QuotaRow
-                  label={t("userLevel")}
-                  current={item.userCurrent ?? 0}
-                  limit={item.userLimit}
-                  percent={userPct}
-                  tone={userTone}
-                  currency={item.key === "concurrent" ? undefined : currencyCode}
-                />
-              ) : null}
+    <div className="space-y-3">
+      {showKeyBadge || showUserBadge ? (
+        <div className="flex flex-wrap items-center gap-2 rounded-lg border border-dashed bg-muted/40 p-3">
+          <span className="text-xs font-medium text-muted-foreground">
+            {tExpiration("expiringWarning")}
+          </span>
+          {showKeyBadge
+            ? renderExpireBadge(tExpiration("keyExpires"), resolvedKeyExpires, keyCountdown)
+            : null}
+          {showUserBadge
+            ? renderExpireBadge(tExpiration("userExpires"), resolvedUserExpires, userCountdown)
+            : null}
+        </div>
+      ) : null}
+
+      <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
+        {items.map((item) => {
+          const keyPct = item.keyLimit
+            ? Math.min((item.keyCurrent / item.keyLimit) * 100, 999)
+            : null;
+          const userPct = item.userLimit
+            ? Math.min(((item.userCurrent ?? 0) / item.userLimit) * 100, 999)
+            : null;
+
+          const keyTone = getTone(keyPct);
+          const userTone = getTone(userPct);
+          const hasUserData = item.userLimit !== null || item.userCurrent !== null;
+
+          return (
+            <Card key={item.key} className="border-border/70">
+              <CardHeader className="pb-3">
+                <CardTitle className="text-sm font-semibold text-muted-foreground">
+                  {item.title}
+                </CardTitle>
+              </CardHeader>
+              <CardContent className="space-y-3">
+                <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
+                  <QuotaColumn
+                    label={t("keyLevel")}
+                    current={item.keyCurrent}
+                    limit={item.keyLimit}
+                    percent={keyPct}
+                    tone={keyTone}
+                    currency={item.key === "concurrent" ? undefined : currencyCode}
+                  />
+                  <QuotaColumn
+                    label={t("userLevel")}
+                    current={item.userCurrent ?? 0}
+                    limit={item.userLimit}
+                    percent={userPct}
+                    tone={userTone}
+                    currency={item.key === "concurrent" ? undefined : currencyCode}
+                    muted={!hasUserData}
+                  />
+                </div>
+              </CardContent>
+            </Card>
+          );
+        })}
+        {items.length === 0 && !loading ? (
+          <Card>
+            <CardContent className="py-6 text-center text-sm text-muted-foreground">
+              {t("empty")}
             </CardContent>
             </CardContent>
           </Card>
           </Card>
-        );
-      })}
-      {items.length === 0 && !loading ? (
-        <Card>
-          <CardContent className="py-6 text-center text-sm text-muted-foreground">
-            {t("empty")}
-          </CardContent>
-        </Card>
-      ) : null}
+        ) : null}
+      </div>
     </div>
     </div>
   );
   );
 }
 }
 
 
-function QuotaRow({
+function QuotaColumn({
   label,
   label,
   current,
   current,
   limit,
   limit,
   percent,
   percent,
   tone,
   tone,
   currency,
   currency,
+  muted = false,
 }: {
 }: {
   label: string;
   label: string;
   current: number;
   current: number;
@@ -138,6 +207,7 @@ function QuotaRow({
   percent: number | null;
   percent: number | null;
   tone: "default" | "warn" | "danger";
   tone: "default" | "warn" | "danger";
   currency?: string;
   currency?: string;
+  muted?: boolean;
 }) {
 }) {
   const t = useTranslations("myUsage.quota");
   const t = useTranslations("myUsage.quota");
   const formatValue = (value: number) =>
   const formatValue = (value: number) =>
@@ -151,8 +221,10 @@ function QuotaRow({
         : ""
         : ""
   }`;
   }`;
 
 
+  const ariaLabel = `${label}: ${formatValue(current)}${limit !== null ? ` / ${formatValue(limit)}` : ""}`;
+
   return (
   return (
-    <div className="space-y-1.5">
+    <div className={cn("space-y-1.5 rounded-md border bg-card/50 p-3", muted && "opacity-60")}>
       <div className="flex items-center justify-between text-xs text-muted-foreground">
       <div className="flex items-center justify-between text-xs text-muted-foreground">
         <span>{label}</span>
         <span>{label}</span>
         <span className="font-mono text-foreground">
         <span className="font-mono text-foreground">
@@ -160,7 +232,7 @@ function QuotaRow({
           {limit !== null ? ` / ${formatValue(limit)}` : ` / ${t("unlimited")}`}
           {limit !== null ? ` / ${formatValue(limit)}` : ` / ${t("unlimited")}`}
         </span>
         </span>
       </div>
       </div>
-      <Progress value={percent ?? 0} className={progressClass.trim()} />
+      <Progress value={percent ?? 0} className={progressClass.trim()} aria-label={ariaLabel} />
     </div>
     </div>
   );
   );
 }
 }

+ 13 - 15
src/app/[locale]/my-usage/_components/usage-logs-section.tsx

@@ -3,9 +3,9 @@
 import { useTranslations } from "next-intl";
 import { useTranslations } from "next-intl";
 import { useCallback, useEffect, useState, useTransition } from "react";
 import { useCallback, useEffect, useState, useTransition } from "react";
 import { getMyAvailableModels, getMyUsageLogs, type MyUsageLogsResult } from "@/actions/my-usage";
 import { getMyAvailableModels, getMyUsageLogs, type MyUsageLogsResult } from "@/actions/my-usage";
+import { LogsDateRangePicker } from "@/app/[locale]/dashboard/logs/_components/logs-date-range-picker";
 import { Button } from "@/components/ui/button";
 import { Button } from "@/components/ui/button";
 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { Input } from "@/components/ui/input";
 import { Label } from "@/components/ui/label";
 import { Label } from "@/components/ui/label";
 import {
 import {
   Select,
   Select,
@@ -81,6 +81,10 @@ export function UsageLogsSection({ initialData = null }: UsageLogsSectionProps)
     loadLogs(true);
     loadLogs(true);
   };
   };
 
 
+  const handleDateRangeChange = (range: { startDate?: string; endDate?: string }) => {
+    handleFilterChange(range);
+  };
+
   const handlePageChange = (page: number) => {
   const handlePageChange = (page: number) => {
     setFilters((prev) => ({ ...prev, page }));
     setFilters((prev) => ({ ...prev, page }));
     startTransition(async () => {
     startTransition(async () => {
@@ -99,20 +103,14 @@ export function UsageLogsSection({ initialData = null }: UsageLogsSectionProps)
       </CardHeader>
       </CardHeader>
       <CardContent className="space-y-4">
       <CardContent className="space-y-4">
         <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
         <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
-          <div className="space-y-1.5">
-            <Label>{t("filters.startDate")}</Label>
-            <Input
-              type="date"
-              value={filters.startDate ?? ""}
-              onChange={(e) => handleFilterChange({ startDate: e.target.value })}
-            />
-          </div>
-          <div className="space-y-1.5">
-            <Label>{t("filters.endDate")}</Label>
-            <Input
-              type="date"
-              value={filters.endDate ?? ""}
-              onChange={(e) => handleFilterChange({ endDate: e.target.value })}
+          <div className="space-y-1.5 sm:col-span-2 lg:col-span-2">
+            <Label>
+              {t("filters.startDate")} / {t("filters.endDate")}
+            </Label>
+            <LogsDateRangePicker
+              startDate={filters.startDate}
+              endDate={filters.endDate}
+              onDateRangeChange={handleDateRangeChange}
             />
             />
           </div>
           </div>
           <div className="space-y-1.5">
           <div className="space-y-1.5">

+ 29 - 3
src/app/[locale]/my-usage/page.tsx

@@ -8,7 +8,9 @@ import {
   type MyUsageLogsResult,
   type MyUsageLogsResult,
 } from "@/actions/my-usage";
 } from "@/actions/my-usage";
 import { useRouter } from "@/i18n/routing";
 import { useRouter } from "@/i18n/routing";
+import { ExpirationInfo } from "./_components/expiration-info";
 import { MyUsageHeader } from "./_components/my-usage-header";
 import { MyUsageHeader } from "./_components/my-usage-header";
+import { ProviderGroupInfo } from "./_components/provider-group-info";
 import { QuotaCards } from "./_components/quota-cards";
 import { QuotaCards } from "./_components/quota-cards";
 import { TodayUsageCard } from "./_components/today-usage-card";
 import { TodayUsageCard } from "./_components/today-usage-card";
 import { UsageLogsSection } from "./_components/usage-logs-section";
 import { UsageLogsSection } from "./_components/usage-logs-section";
@@ -22,6 +24,7 @@ export default function MyUsagePage() {
   );
   );
   const [logsData, setLogsData] = useState<MyUsageLogsResult | null>(null);
   const [logsData, setLogsData] = useState<MyUsageLogsResult | null>(null);
   const [isPending, startTransition] = useTransition();
   const [isPending, startTransition] = useTransition();
+  const [hasLoaded, setHasLoaded] = useState(false);
 
 
   const loadAll = useCallback(() => {
   const loadAll = useCallback(() => {
     startTransition(async () => {
     startTransition(async () => {
@@ -34,6 +37,7 @@ export default function MyUsagePage() {
       if (quotaResult.ok) setQuota(quotaResult);
       if (quotaResult.ok) setQuota(quotaResult);
       if (statsResult.ok) setTodayStats(statsResult);
       if (statsResult.ok) setTodayStats(statsResult);
       if (logsResult.ok) setLogsData(logsResult.data ?? null);
       if (logsResult.ok) setLogsData(logsResult.data ?? null);
+      setHasLoaded(true);
     });
     });
   }, []);
   }, []);
 
 
@@ -59,20 +63,42 @@ export default function MyUsagePage() {
 
 
   const quotaData = quota?.ok ? quota.data : null;
   const quotaData = quota?.ok ? quota.data : null;
   const todayData = todayStats?.ok ? todayStats.data : null;
   const todayData = todayStats?.ok ? todayStats.data : null;
+  const keyExpiresAt = quotaData?.expiresAt ?? null;
+  const userExpiresAt = quotaData?.userExpiresAt ?? null;
 
 
   return (
   return (
     <div className="space-y-6">
     <div className="space-y-6">
-      <MyUsageHeader onLogout={handleLogout} />
+      <MyUsageHeader
+        onLogout={handleLogout}
+        keyName={quotaData?.keyName}
+        userName={quotaData?.userName}
+        keyProviderGroup={quotaData?.keyProviderGroup ?? null}
+        userProviderGroup={quotaData?.userProviderGroup ?? null}
+        keyExpiresAt={keyExpiresAt}
+        userExpiresAt={userExpiresAt}
+      />
+
+      {quotaData ? (
+        <div className="space-y-3">
+          <ExpirationInfo keyExpiresAt={keyExpiresAt} userExpiresAt={userExpiresAt} />
+          <ProviderGroupInfo
+            keyProviderGroup={quotaData.keyProviderGroup}
+            userProviderGroup={quotaData.userProviderGroup}
+          />
+        </div>
+      ) : null}
 
 
       <QuotaCards
       <QuotaCards
         quota={quotaData}
         quota={quotaData}
-        loading={isPending}
+        loading={!hasLoaded || isPending}
         currencyCode={todayData?.currencyCode ?? "USD"}
         currencyCode={todayData?.currencyCode ?? "USD"}
+        keyExpiresAt={keyExpiresAt}
+        userExpiresAt={userExpiresAt}
       />
       />
 
 
       <TodayUsageCard
       <TodayUsageCard
         stats={todayData}
         stats={todayData}
-        loading={isPending}
+        loading={!hasLoaded || isPending}
         onRefresh={refreshToday}
         onRefresh={refreshToday}
         autoRefreshSeconds={30}
         autoRefreshSeconds={30}
       />
       />