diff --git a/internal/handler/admin.go b/internal/handler/admin.go index 403cd08e..57acb9e2 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -825,6 +825,28 @@ func (h *AdminHandler) handleRoutingStrategies(w http.ResponseWriter, r *http.Re } } +// parseTimeQuery parses a time query parameter as either a 13-digit millisecond timestamp or RFC3339. +func parseTimeQuery(raw, name string) (*time.Time, error) { + if raw == "" { + return nil, nil + } + if millis, err := strconv.ParseInt(raw, 10, 64); err == nil { + digits := len(strings.TrimLeft(raw, "-")) + if digits != 13 { + return nil, errors.New("invalid " + name + ": expected 13-digit millisecond timestamp or RFC3339") + } + t := time.UnixMilli(millis).UTC() + return &t, nil + } + for _, format := range []string{time.RFC3339Nano, time.RFC3339} { + if t, err := time.Parse(format, raw); err == nil { + utc := t.UTC() + return &utc, nil + } + } + return nil, errors.New("invalid " + name + ": expected millisecond timestamp or RFC3339") +} + // ProxyRequest handlers // Routes: /admin/requests, /admin/requests/count, /admin/requests/active, /admin/requests/{id}, /admin/requests/{id}/attempts, /admin/requests/{id}/recalculate-cost func (h *AdminHandler) handleProxyRequests(w http.ResponseWriter, r *http.Request, id uint64, parts []string) { @@ -882,8 +904,10 @@ func (h *AdminHandler) handleProxyRequests(w http.ResponseWriter, r *http.Reques statusStr := r.URL.Query().Get("status") apiTokenIDStr := r.URL.Query().Get("apiTokenId") projectIDStr := r.URL.Query().Get("projectId") + startTimeStr := r.URL.Query().Get("startTime") + endTimeStr := r.URL.Query().Get("endTime") - if providerIDStr != "" || statusStr != "" || apiTokenIDStr != "" || projectIDStr != "" { + if providerIDStr != "" || statusStr != "" || apiTokenIDStr != "" || projectIDStr != "" || startTimeStr != "" || endTimeStr != "" { filter = &repository.ProxyRequestFilter{} if providerIDStr != "" { providerID, err := strconv.ParseUint(providerIDStr, 10, 64) @@ -912,6 +936,26 @@ func (h *AdminHandler) handleProxyRequests(w http.ResponseWriter, r *http.Reques } filter.ProjectID = &projectID } + if startTimeStr != "" { + startTime, err := parseTimeQuery(startTimeStr, "startTime") + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + filter.StartTime = startTime + } + if endTimeStr != "" { + endTime, err := parseTimeQuery(endTimeStr, "endTime") + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + filter.EndTime = endTime + } + if filter.StartTime != nil && filter.EndTime != nil && filter.EndTime.Before(*filter.StartTime) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "endTime must be greater than or equal to startTime"}) + return + } } result, err := h.svc.GetProxyRequestsCursor(tenantID, limit, before, after, filter) @@ -941,8 +985,10 @@ func (h *AdminHandler) handleProxyRequestsCount(w http.ResponseWriter, r *http.R statusStr := r.URL.Query().Get("status") apiTokenIDStr := r.URL.Query().Get("apiTokenId") projectIDStr := r.URL.Query().Get("projectId") + startTimeStr := r.URL.Query().Get("startTime") + endTimeStr := r.URL.Query().Get("endTime") - if providerIDStr != "" || statusStr != "" || apiTokenIDStr != "" || projectIDStr != "" { + if providerIDStr != "" || statusStr != "" || apiTokenIDStr != "" || projectIDStr != "" || startTimeStr != "" || endTimeStr != "" { filter = &repository.ProxyRequestFilter{} if providerIDStr != "" { providerID, err := strconv.ParseUint(providerIDStr, 10, 64) @@ -971,6 +1017,26 @@ func (h *AdminHandler) handleProxyRequestsCount(w http.ResponseWriter, r *http.R } filter.ProjectID = &projectID } + if startTimeStr != "" { + startTime, err := parseTimeQuery(startTimeStr, "startTime") + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + filter.StartTime = startTime + } + if endTimeStr != "" { + endTime, err := parseTimeQuery(endTimeStr, "endTime") + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + filter.EndTime = endTime + } + if filter.StartTime != nil && filter.EndTime != nil && filter.EndTime.Before(*filter.StartTime) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "endTime must be greater than or equal to startTime"}) + return + } } count, err := h.svc.GetProxyRequestsCountWithFilter(tenantID, filter) diff --git a/internal/repository/interfaces.go b/internal/repository/interfaces.go index 5655ed27..71de322d 100644 --- a/internal/repository/interfaces.go +++ b/internal/repository/interfaces.go @@ -122,6 +122,21 @@ type ProxyRequestFilter struct { Status *string // 状态,nil 表示不过滤 APITokenID *uint64 // API Token ID,nil 表示不过滤 ProjectID *uint64 // Project ID,nil 表示不过滤 + StartTime *time.Time + EndTime *time.Time +} + +func (f *ProxyRequestFilter) IsEmpty() bool { + if f == nil { + return true + } + return f.TenantID == nil && + f.ProviderID == nil && + f.Status == nil && + f.APITokenID == nil && + f.ProjectID == nil && + f.StartTime == nil && + f.EndTime == nil } type ProxyRequestRepository interface { diff --git a/internal/repository/sqlite/migrations.go b/internal/repository/sqlite/migrations.go index 9eb56d31..ffbb1953 100644 --- a/internal/repository/sqlite/migrations.go +++ b/internal/repository/sqlite/migrations.go @@ -486,6 +486,46 @@ var migrations = []Migration{ } }, }, + { + Version: 16, + Description: "Add proxy request created_at index for time-range search", + Up: func(db *gorm.DB) error { + var rowCount int64 + if err := db.Raw("SELECT COUNT(*) FROM proxy_requests").Scan(&rowCount).Error; err != nil { + log.Printf("[Migration v16] could not count proxy_requests (%v); skipping index build. Apply manually if needed.", err) + return nil + } + + if rowCount > detailCleanupIndexRowThreshold { + log.Printf("[Migration v16] SKIPPING idx_proxy_requests_created_at_id build: proxy_requests has %d rows (> %d threshold). "+ + "Apply manually during a maintenance window: CREATE INDEX idx_proxy_requests_created_at_id ON proxy_requests(created_at, id);", + rowCount, detailCleanupIndexRowThreshold) + return nil + } + + switch db.Dialector.Name() { + case "mysql": + err := db.Exec("CREATE INDEX idx_proxy_requests_created_at_id ON proxy_requests(created_at, id)").Error + if isMySQLDuplicateIndexError(err) { + return nil + } + return err + default: + return db.Exec("CREATE INDEX IF NOT EXISTS idx_proxy_requests_created_at_id ON proxy_requests(created_at, id)").Error + } + }, + Down: func(db *gorm.DB) error { + switch db.Dialector.Name() { + case "mysql": + if err := db.Exec("DROP INDEX idx_proxy_requests_created_at_id ON proxy_requests").Error; err != nil && !isMySQLMissingIndexError(err) { + log.Printf("[Migration] Warning: rollback v16 failed: %v", err) + } + return nil + default: + return db.Exec("DROP INDEX IF EXISTS idx_proxy_requests_created_at_id").Error + } + }, + }, } // runDetailClearedColumnMigration 显式添加 detail_cleared 列到两张大表,带 threshold-skip。 diff --git a/internal/repository/sqlite/proxy_request.go b/internal/repository/sqlite/proxy_request.go index 27c6ba5e..8d9cb05d 100644 --- a/internal/repository/sqlite/proxy_request.go +++ b/internal/repository/sqlite/proxy_request.go @@ -121,6 +121,12 @@ func (r *ProxyRequestRepository) ListCursor(tenantID uint64, limit int, before, if filter.ProjectID != nil { baseQuery = baseQuery.Where("project_id = ?", *filter.ProjectID) } + if filter.StartTime != nil { + baseQuery = baseQuery.Where("created_at >= ?", toTimestamp(*filter.StartTime)) + } + if filter.EndTime != nil { + baseQuery = baseQuery.Where("created_at <= ?", toTimestamp(*filter.EndTime)) + } } orderBy := "id DESC" @@ -163,7 +169,7 @@ func (r *ProxyRequestRepository) Count(tenantID uint64) (int64, error) { // CountWithFilter 带过滤条件的计数 func (r *ProxyRequestRepository) CountWithFilter(tenantID uint64, filter *repository.ProxyRequestFilter) (int64, error) { // 如果没有过滤条件且没有 tenantID 过滤,使用缓存的总数 - if tenantID == domain.TenantIDAll && (filter == nil || (filter.ProviderID == nil && filter.Status == nil && filter.APITokenID == nil && filter.ProjectID == nil)) { + if tenantID == domain.TenantIDAll && (filter == nil || filter.IsEmpty()) { return atomic.LoadInt64(&r.count), nil } @@ -183,6 +189,12 @@ func (r *ProxyRequestRepository) CountWithFilter(tenantID uint64, filter *reposi if filter.ProjectID != nil { query = query.Where("project_id = ?", *filter.ProjectID) } + if filter.StartTime != nil { + query = query.Where("created_at >= ?", toTimestamp(*filter.StartTime)) + } + if filter.EndTime != nil { + query = query.Where("created_at <= ?", toTimestamp(*filter.EndTime)) + } } if err := query.Count(&count).Error; err != nil { return 0, err diff --git a/internal/repository/sqlite/proxy_request_test.go b/internal/repository/sqlite/proxy_request_test.go index 0f194607..d9bcc61e 100644 --- a/internal/repository/sqlite/proxy_request_test.go +++ b/internal/repository/sqlite/proxy_request_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/awsl-project/maxx/internal/domain" + "github.com/awsl-project/maxx/internal/repository" ) func TestDetailCleanupBatchParams(t *testing.T) { @@ -187,6 +188,57 @@ func TestProxyRequestListCursorBeforeCursorDoesNotRepeatOrSkipRecords(t *testing } } +func TestProxyRequestListCursorAndCountFilterByCreatedAtRange(t *testing.T) { + db, err := NewDBWithDSN("sqlite://:memory:") + if err != nil { + t.Fatalf("Failed to create DB: %v", err) + } + defer db.Close() + + repo := NewProxyRequestRepository(db) + base := time.Date(2026, 6, 4, 10, 0, 0, 0, time.UTC) + requests := []*domain.ProxyRequest{ + buildTestProxyRequest("COMPLETED", 1), + buildTestProxyRequest("COMPLETED", 2), + buildTestProxyRequest("COMPLETED", 3), + buildTestProxyRequest("COMPLETED", 4), + } + + for i, request := range requests { + if err := repo.Create(request); err != nil { + t.Fatalf("Failed to create request: %v", err) + } + createdAt := base.Add(time.Duration(i) * time.Hour).UnixMilli() + if err := db.gorm.Model(&ProxyRequest{}).Where("id = ?", request.ID).Update("created_at", createdAt).Error; err != nil { + t.Fatalf("Failed to update created_at: %v", err) + } + } + + start := base.Add(1 * time.Hour) + end := base.Add(2 * time.Hour) + filter := &repository.ProxyRequestFilter{ + StartTime: &start, + EndTime: &end, + } + + items, err := repo.ListCursor(1, 10, 0, 0, filter) + if err != nil { + t.Fatalf("ListCursor failed: %v", err) + } + expected := []uint64{requests[2].ID, requests[1].ID} + if got := collectRequestIDs(items); fmt.Sprint(got) != fmt.Sprint(expected) { + t.Fatalf("expected filtered ids %v, got %v", expected, got) + } + + count, err := repo.CountWithFilter(1, filter) + if err != nil { + t.Fatalf("CountWithFilter failed: %v", err) + } + if count != int64(len(expected)) { + t.Fatalf("expected filtered count %d, got %d", len(expected), count) + } +} + // TestProxyRequestUpdatePreservesRequestInfo 锁定 OOM 优化后的写入不变量: // - Update 永不重写 request_info(Create 后不变),状态类 Update 不能把它清空; // - Update 仅在 ResponseInfo 非 nil 时写 response_info;ResponseInfo 为 nil 时 diff --git a/web/package.json b/web/package.json index 4e5983e0..e9fc18d2 100644 --- a/web/package.json +++ b/web/package.json @@ -46,6 +46,7 @@ "lucide-react": "^0.562.0", "postcss": "^8.5.10", "react": "^19.2.0", + "react-day-picker": "^10.0.1", "react-dom": "^19.2.0", "react-i18next": "^16.5.3", "react-resizable-panels": "^2.1.7", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index a0e5a48d..ff275088 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: react: specifier: ^19.2.0 version: 19.2.3 + react-day-picker: + specifier: ^10.0.1 + version: 10.0.1(@types/react@19.2.8)(react@19.2.3) react-dom: specifier: ^19.2.0 version: 19.2.3(react@19.2.3) @@ -327,6 +330,9 @@ packages: '@types/react': optional: true + '@date-fns/tz@1.5.0': + resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} + '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} peerDependencies: @@ -2424,6 +2430,16 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + react-day-picker@10.0.1: + resolution: {integrity: sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': '>=16.8.0' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + react-dom@19.2.3: resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} peerDependencies: @@ -3212,6 +3228,8 @@ snapshots: optionalDependencies: '@types/react': 19.2.8 + '@date-fns/tz@1.5.0': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.3)': dependencies: react: 19.2.3 @@ -5120,6 +5138,14 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 + react-day-picker@10.0.1(@types/react@19.2.8)(react@19.2.3): + dependencies: + '@date-fns/tz': 1.5.0 + date-fns: 4.1.0 + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.8 + react-dom@19.2.3(react@19.2.3): dependencies: react: 19.2.3 diff --git a/web/src/components/ui/button.tsx b/web/src/components/ui/button.tsx index 1a732859..76a87dd7 100644 --- a/web/src/components/ui/button.tsx +++ b/web/src/components/ui/button.tsx @@ -1,49 +1,49 @@ -import { Button as ButtonPrimitive } from '@base-ui/react/button'; -import { cva, type VariantProps } from 'class-variance-authority'; +import { Button as ButtonPrimitive } from "@base-ui/react/button" +import { cva, type VariantProps } from "class-variance-authority" -import { cn } from '@/lib/utils'; +import { cn } from "@/lib/utils" const buttonVariants = cva( - "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none", + "group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { - default: 'bg-primary text-primary-foreground hover:bg-primary/80', + default: "bg-primary text-primary-foreground hover:bg-primary/80", outline: - 'border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground shadow-xs', + "border-border bg-background shadow-xs hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", secondary: - 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground', + "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", ghost: - 'hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground', + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", destructive: - 'bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30', - link: 'text-primary underline-offset-4 hover:underline', + "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", + link: "text-primary underline-offset-4 hover:underline", }, size: { default: - 'h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2', + "h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", - sm: 'h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5', - lg: 'h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3', - icon: 'size-9', - 'icon-xs': + sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5", + lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + icon: "size-9", + "icon-xs": "size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3", - 'icon-sm': - 'size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md', - 'icon-lg': 'size-10', + "icon-sm": + "size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md", + "icon-lg": "size-10", }, }, defaultVariants: { - variant: 'default', - size: 'default', + variant: "default", + size: "default", }, - }, -); + } +) function Button({ className, - variant = 'default', - size = 'default', + variant = "default", + size = "default", ...props }: ButtonPrimitive.Props & VariantProps) { return ( @@ -52,7 +52,7 @@ function Button({ className={cn(buttonVariants({ variant, size, className }))} {...props} /> - ); + ) } -export { Button, buttonVariants }; +export { Button, buttonVariants } diff --git a/web/src/components/ui/calendar.tsx b/web/src/components/ui/calendar.tsx new file mode 100644 index 00000000..2833bf2e --- /dev/null +++ b/web/src/components/ui/calendar.tsx @@ -0,0 +1,219 @@ +import * as React from "react" +import { + DayPicker, + getDefaultClassNames, + type DayButton, + type Locale, +} from "react-day-picker" + +import { cn } from "@/lib/utils" +import { Button, buttonVariants } from "@/components/ui/button" +import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react" + +function Calendar({ + className, + classNames, + showOutsideDays = true, + captionLayout = "label", + buttonVariant = "ghost", + locale, + formatters, + components, + ...props +}: React.ComponentProps & { + buttonVariant?: React.ComponentProps["variant"] +}) { + const defaultClassNames = getDefaultClassNames() + + return ( + svg]:rotate-180`, + String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, + className + )} + captionLayout={captionLayout} + locale={locale} + formatters={{ + formatMonthDropdown: (date) => + date.toLocaleString(locale?.code, { month: "short" }), + ...formatters, + }} + classNames={{ + root: cn("w-fit", defaultClassNames.root), + months: cn( + "relative flex flex-col gap-4 md:flex-row", + defaultClassNames.months + ), + month: cn("flex w-full flex-col gap-4", defaultClassNames.month), + nav: cn( + "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", + defaultClassNames.nav + ), + button_previous: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) p-0 select-none aria-disabled:opacity-50", + defaultClassNames.button_previous + ), + button_next: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) p-0 select-none aria-disabled:opacity-50", + defaultClassNames.button_next + ), + month_caption: cn( + "flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)", + defaultClassNames.month_caption + ), + dropdowns: cn( + "flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium", + defaultClassNames.dropdowns + ), + dropdown_root: cn( + "relative rounded-(--cell-radius)", + defaultClassNames.dropdown_root + ), + dropdown: cn( + "absolute inset-0 bg-popover opacity-0", + defaultClassNames.dropdown + ), + caption_label: cn( + "font-medium select-none", + captionLayout === "label" + ? "text-sm" + : "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground", + defaultClassNames.caption_label + ), + month_grid: "w-full border-collapse", + weekdays: cn("flex", defaultClassNames.weekdays), + weekday: cn( + "flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none", + defaultClassNames.weekday + ), + week: cn("mt-2 flex w-full", defaultClassNames.week), + week_number_header: cn( + "w-(--cell-size) select-none", + defaultClassNames.week_number_header + ), + week_number: cn( + "text-[0.8rem] text-muted-foreground select-none", + defaultClassNames.week_number + ), + day: cn( + "group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)", + props.showWeekNumber + ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)" + : "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)", + defaultClassNames.day + ), + range_start: cn( + "relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted", + defaultClassNames.range_start + ), + range_middle: cn("rounded-none", defaultClassNames.range_middle), + range_end: cn( + "relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted", + defaultClassNames.range_end + ), + today: cn( + "rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none", + defaultClassNames.today + ), + outside: cn( + "text-muted-foreground aria-selected:text-muted-foreground", + defaultClassNames.outside + ), + disabled: cn( + "text-muted-foreground opacity-50", + defaultClassNames.disabled + ), + hidden: cn("invisible", defaultClassNames.hidden), + ...classNames, + }} + components={{ + Root: ({ className, rootRef, ...props }) => { + return ( +
+ ) + }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === "left") { + return ( + + ) + } + + if (orientation === "right") { + return ( + + ) + } + + return ( + + ) + }, + DayButton: ({ ...props }) => ( + + ), + WeekNumber: ({ children, ...props }) => { + return ( + +
+ {children} +
+ + ) + }, + ...components, + }} + {...props} + /> + ) +} + +function CalendarDayButton({ + className, + day, + modifiers, + locale, + ...props +}: React.ComponentProps & { locale?: Partial }) { + const defaultClassNames = getDefaultClassNames() + + const ref = React.useRef(null) + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus() + }, [modifiers.focused]) + + return ( + + )} +
+ ); +} + // Status Filter Component using Select function StatusFilter({ selectedStatus,