feat(auth): update migration and password set flows with improved redirect handling
feat(billing): enhance invoice list with filtering and summary stats feat(components): add ClearFiltersButton, FilterDropdown, and DetailStatsGrid components for better UI consistency fix(get-started): refactor button links to use new Button component for consistency style(services): update ServicesOverviewContent to conditionally render hero section refactor(orders): simplify OrderDetail and OrdersList views with new stat grid and filtering components chore: add useInvoicesFilter hook for managing invoice filtering logic
This commit is contained in:
parent
8d9c954230
commit
5c6bd00346
@ -1,5 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { MigrateAccountView } from "@/features/auth/views/MigrateAccountView";
|
||||
|
||||
export default function MigrateAccountPage() {
|
||||
redirect("/auth/get-started");
|
||||
return <MigrateAccountView />;
|
||||
}
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import { cn } from "@/shared/utils";
|
||||
|
||||
export interface ClearFiltersButtonProps {
|
||||
/** Callback when button is clicked */
|
||||
onClick: () => void;
|
||||
/** Whether the button should be visible (typically when filters are active) */
|
||||
show?: boolean;
|
||||
/** Optional label text (default: "Clear") */
|
||||
label?: string;
|
||||
/** Optional additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ClearFiltersButton - Reusable clear filters button with consistent styling.
|
||||
*
|
||||
* Used across list pages (Orders, Support, Invoices) to reset filters.
|
||||
* Only renders when `show` is true (defaults to true).
|
||||
*/
|
||||
export function ClearFiltersButton({
|
||||
onClick,
|
||||
show = true,
|
||||
label = "Clear",
|
||||
className,
|
||||
}: ClearFiltersButtonProps) {
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-3 py-2 text-sm",
|
||||
"text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
"rounded-lg transition-colors",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<XMarkIcon className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export { ClearFiltersButton, type ClearFiltersButtonProps } from "./ClearFiltersButton";
|
||||
@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/shared/utils";
|
||||
|
||||
export interface StatGridItem {
|
||||
/** Optional icon to display */
|
||||
icon?: React.ReactNode;
|
||||
/** Label for the stat */
|
||||
label: string;
|
||||
/** Value to display (can be string, number, or React node like StatusPill) */
|
||||
value: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface DetailStatsGridProps {
|
||||
/** Array of stat items to display */
|
||||
items: StatGridItem[];
|
||||
/** Number of columns (default: 4 on md screens) */
|
||||
columns?: 2 | 3 | 4;
|
||||
/** Optional additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const COLUMN_CLASSES: Record<2 | 3 | 4, string> = {
|
||||
2: "grid-cols-2",
|
||||
3: "grid-cols-2 md:grid-cols-3",
|
||||
4: "grid-cols-2 md:grid-cols-4",
|
||||
};
|
||||
|
||||
/**
|
||||
* DetailStatsGrid - Reusable stats grid for detail pages.
|
||||
*
|
||||
* Used across detail pages (Orders, Subscriptions) to display key stats
|
||||
* in a consistent grid layout with individual card styling.
|
||||
*/
|
||||
export function DetailStatsGrid({ items, columns = 4, className }: DetailStatsGridProps) {
|
||||
return (
|
||||
<div className={cn("grid gap-4", COLUMN_CLASSES[columns], className)}>
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-card rounded-xl border border-border p-4 shadow-[var(--cp-shadow-1)]"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{item.icon && (
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-muted/50 text-muted-foreground flex-shrink-0">
|
||||
{item.icon}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-muted-foreground">{item.label}</p>
|
||||
<div className="font-semibold text-foreground">{item.value}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export { DetailStatsGrid, type DetailStatsGridProps, type StatGridItem } from "./DetailStatsGrid";
|
||||
@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { FunnelIcon } from "@heroicons/react/24/outline";
|
||||
import { cn } from "@/shared/utils";
|
||||
|
||||
export interface FilterOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface FilterDropdownProps {
|
||||
/** Current selected value */
|
||||
value: string;
|
||||
/** Callback when value changes */
|
||||
onChange: (value: string) => void;
|
||||
/** Array of filter options */
|
||||
options: FilterOption[];
|
||||
/** Accessible label for the dropdown */
|
||||
label: string;
|
||||
/** Optional width class (default: "w-40") */
|
||||
width?: string;
|
||||
/** Optional additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FilterDropdown - Reusable filter dropdown component with consistent styling.
|
||||
*
|
||||
* Used across list pages (Orders, Support, Invoices) for filtering by status, type, priority, etc.
|
||||
*/
|
||||
export function FilterDropdown({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
label,
|
||||
width = "w-40",
|
||||
className,
|
||||
}: FilterDropdownProps) {
|
||||
return (
|
||||
<div className={cn("relative", className)}>
|
||||
<select
|
||||
value={value}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
className={cn(
|
||||
"block pl-3 pr-8 py-2.5 text-sm border border-border",
|
||||
"focus:outline-none focus:ring-2 focus:ring-ring focus:border-primary",
|
||||
"rounded-lg appearance-none bg-card text-foreground",
|
||||
"shadow-sm cursor-pointer transition-colors",
|
||||
width
|
||||
)}
|
||||
aria-label={label}
|
||||
>
|
||||
{options.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-2.5 pointer-events-none">
|
||||
<FunnelIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export { FilterDropdown, type FilterDropdownProps, type FilterOption } from "./FilterDropdown";
|
||||
@ -23,6 +23,9 @@ export * from "./SubCard/SubCard";
|
||||
export * from "./AnimatedCard/AnimatedCard";
|
||||
export * from "./ServiceCard/ServiceCard";
|
||||
export * from "./SummaryStats";
|
||||
export * from "./FilterDropdown";
|
||||
export * from "./ClearFiltersButton";
|
||||
export * from "./DetailStatsGrid";
|
||||
|
||||
// Loading skeleton molecules
|
||||
export * from "./LoadingSkeletons";
|
||||
|
||||
@ -5,13 +5,17 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { AuthLayout } from "../components";
|
||||
import { LinkWhmcsForm } from "@/features/auth/components";
|
||||
import { getSafeRedirect } from "@/features/auth/utils/route-protection";
|
||||
import { MIGRATION_TRANSFER_ITEMS, MIGRATION_STEPS } from "@customer-portal/domain/auth";
|
||||
|
||||
export function MigrateAccountView() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const initialEmail = searchParams.get("email") ?? undefined;
|
||||
const redirectTo = getSafeRedirect(searchParams.get("redirect"), "/account");
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
@ -40,11 +44,16 @@ export function MigrateAccountView() {
|
||||
Use your previous Assist Solutions portal email and password.
|
||||
</p>
|
||||
<LinkWhmcsForm
|
||||
initialEmail={initialEmail}
|
||||
onTransferred={result => {
|
||||
if (result.needsPasswordSet) {
|
||||
router.push(`/auth/set-password?email=${encodeURIComponent(result.user.email)}`);
|
||||
const params = new URLSearchParams({
|
||||
email: result.user.email,
|
||||
redirect: redirectTo,
|
||||
});
|
||||
router.push(`/auth/set-password?${params.toString()}`);
|
||||
} else {
|
||||
router.push("/account");
|
||||
router.push(redirectTo);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -5,13 +5,14 @@ import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { AuthLayout } from "../components";
|
||||
import { SetPasswordForm } from "@/features/auth/components";
|
||||
import { getSafeRedirect } from "@/features/auth/utils/route-protection";
|
||||
import { LoadingOverlay } from "@/components/atoms";
|
||||
|
||||
function SetPasswordContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const email = searchParams.get("email") ?? "";
|
||||
const redirect = searchParams.get("redirect");
|
||||
const redirectTo = getSafeRedirect(searchParams.get("redirect"), "/account");
|
||||
|
||||
useEffect(() => {
|
||||
if (!email) {
|
||||
@ -20,11 +21,7 @@ function SetPasswordContent() {
|
||||
}, [email, router]);
|
||||
|
||||
const handlePasswordSetSuccess = () => {
|
||||
if (redirect) {
|
||||
router.push(redirect);
|
||||
return;
|
||||
}
|
||||
router.push("/account");
|
||||
router.push(redirectTo);
|
||||
};
|
||||
|
||||
if (!email) {
|
||||
|
||||
@ -1,10 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { MagnifyingGlassIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
DocumentTextIcon,
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ClockIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { Spinner } from "@/components/atoms";
|
||||
import { SummaryStats, ClearFiltersButton } from "@/components/molecules";
|
||||
import type { StatItem } from "@/components/molecules/SummaryStats";
|
||||
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
|
||||
import { PaginationBar } from "@/components/molecules/PaginationBar/PaginationBar";
|
||||
import { SearchFilterBar } from "@/components/molecules/SearchFilterBar/SearchFilterBar";
|
||||
import { InvoiceTable } from "@/features/billing/components/InvoiceTable/InvoiceTable";
|
||||
import { useInvoices } from "@/features/billing/hooks/useBilling";
|
||||
import { useSubscriptionInvoices } from "@/features/subscriptions/hooks/useSubscriptions";
|
||||
@ -19,7 +27,13 @@ interface InvoicesListProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const INVOICE_STATUS_OPTIONS = Object.values(INVOICE_STATUS) as InvoiceStatus[];
|
||||
const INVOICE_STATUS_OPTIONS = [
|
||||
{ value: "all", label: "All Statuses" },
|
||||
...Object.values(INVOICE_STATUS).map(status => ({
|
||||
value: status,
|
||||
label: status,
|
||||
})),
|
||||
];
|
||||
|
||||
export function InvoicesList({
|
||||
subscriptionId,
|
||||
@ -61,6 +75,7 @@ export function InvoicesList({
|
||||
const invoices = useMemo(() => rawInvoices ?? [], [rawInvoices]);
|
||||
const pagination = data?.pagination;
|
||||
|
||||
// Client-side search filtering on loaded invoices
|
||||
const filtered = useMemo(() => {
|
||||
if (!searchTerm) return invoices;
|
||||
const term = searchTerm.toLowerCase();
|
||||
@ -72,17 +87,60 @@ export function InvoicesList({
|
||||
});
|
||||
}, [invoices, searchTerm]);
|
||||
|
||||
const statusFilterOptions = useMemo(
|
||||
// Compute stats from loaded invoices
|
||||
const stats = useMemo(() => {
|
||||
const result = { total: 0, paid: 0, unpaid: 0, overdue: 0 };
|
||||
if (!invoices || invoices.length === 0) return result;
|
||||
|
||||
for (const invoice of invoices) {
|
||||
result.total++;
|
||||
if (invoice.status === "Paid") result.paid++;
|
||||
else if (invoice.status === "Unpaid") result.unpaid++;
|
||||
else if (invoice.status === "Overdue") result.overdue++;
|
||||
}
|
||||
return result;
|
||||
}, [invoices]);
|
||||
|
||||
const summaryStatsItems = useMemo<StatItem[]>(
|
||||
() => [
|
||||
{ value: "all" as const, label: "All Status" },
|
||||
...INVOICE_STATUS_OPTIONS.map(status => ({
|
||||
value: status,
|
||||
label: status,
|
||||
})),
|
||||
{
|
||||
icon: <DocumentTextIcon className="h-4 w-4" />,
|
||||
label: "Total",
|
||||
value: pagination?.totalItems ?? stats.total,
|
||||
tone: "muted",
|
||||
},
|
||||
{
|
||||
icon: <CheckCircleIcon className="h-4 w-4" />,
|
||||
label: "Paid",
|
||||
value: stats.paid,
|
||||
tone: "success",
|
||||
},
|
||||
{
|
||||
icon: <ClockIcon className="h-4 w-4" />,
|
||||
label: "Unpaid",
|
||||
value: stats.unpaid,
|
||||
tone: "warning",
|
||||
show: stats.unpaid > 0,
|
||||
},
|
||||
{
|
||||
icon: <ExclamationTriangleIcon className="h-4 w-4" />,
|
||||
label: "Overdue",
|
||||
value: stats.overdue,
|
||||
tone: "warning",
|
||||
show: stats.overdue > 0,
|
||||
},
|
||||
],
|
||||
[]
|
||||
[pagination?.totalItems, stats]
|
||||
);
|
||||
|
||||
const hasActiveFilters = searchTerm.trim() !== "" || statusFilter !== "all";
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchTerm("");
|
||||
setStatusFilter("all");
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// Loading state - show centered spinner
|
||||
if (isPending) {
|
||||
return (
|
||||
@ -106,86 +164,55 @@ export function InvoicesList({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)] overflow-hidden",
|
||||
className
|
||||
<div className={cn("space-y-4", className)}>
|
||||
{/* Summary Stats (inline variant) */}
|
||||
{showFilters && invoices.length > 0 && (
|
||||
<SummaryStats variant="inline" items={summaryStatsItems} />
|
||||
)}
|
||||
>
|
||||
{/* Search/Filter Header */}
|
||||
|
||||
{/* Search & Filters */}
|
||||
{showFilters && (
|
||||
<div className="px-6 py-4 border-b border-border">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
{/* Search Input */}
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<MagnifyingGlassIcon className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="block w-full pl-10 pr-3 py-2.5 text-sm border border-border rounded-lg bg-card shadow-sm placeholder-muted-foreground text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-primary transition-colors"
|
||||
placeholder="Search invoices..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<SearchFilterBar
|
||||
searchValue={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
searchPlaceholder="Search by invoice number..."
|
||||
{...(!isSubscriptionMode && {
|
||||
filterValue: statusFilter,
|
||||
onFilterChange: (value: string) => {
|
||||
setStatusFilter(value as InvoiceStatus | "all");
|
||||
setCurrentPage(1);
|
||||
},
|
||||
filterOptions: INVOICE_STATUS_OPTIONS,
|
||||
filterLabel: "Filter by status",
|
||||
})}
|
||||
>
|
||||
{/* Clear filters button */}
|
||||
<ClearFiltersButton onClick={clearFilters} show={hasActiveFilters} />
|
||||
</SearchFilterBar>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Total Count */}
|
||||
{pagination?.totalItems && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{pagination.totalItems} invoice{pagination.totalItems === 1 ? "" : "s"}
|
||||
</span>
|
||||
)}
|
||||
{/* Invoice Table Card */}
|
||||
<div className="bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)] overflow-hidden">
|
||||
{/* Invoice Table */}
|
||||
<InvoiceTable
|
||||
invoices={filtered}
|
||||
loading={isLoading}
|
||||
compact={compact}
|
||||
className="border-0 rounded-none shadow-none"
|
||||
/>
|
||||
|
||||
{/* Filter Dropdown */}
|
||||
{!isSubscriptionMode && (
|
||||
<div className="relative">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={e => {
|
||||
const nextValue = e.target.value as InvoiceStatus | "all";
|
||||
setStatusFilter(nextValue);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="block w-40 pl-3 pr-8 py-2.5 text-sm border border-border focus:outline-none focus:ring-2 focus:ring-ring focus:border-primary rounded-lg appearance-none bg-card shadow-sm text-foreground cursor-pointer transition-colors"
|
||||
>
|
||||
{statusFilterOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-2.5 pointer-events-none">
|
||||
<ChevronDownIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Pagination */}
|
||||
{pagination && filtered.length > 0 && (
|
||||
<div className="border-t border-border px-6 py-4">
|
||||
<PaginationBar
|
||||
currentPage={currentPage}
|
||||
pageSize={pageSize}
|
||||
totalItems={pagination?.totalItems || 0}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Invoice Table */}
|
||||
<InvoiceTable
|
||||
invoices={filtered}
|
||||
loading={isLoading}
|
||||
compact={compact}
|
||||
className="border-0 rounded-none shadow-none"
|
||||
/>
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination && filtered.length > 0 && (
|
||||
<div className="border-t border-border px-6 py-4">
|
||||
<PaginationBar
|
||||
currentPage={currentPage}
|
||||
pageSize={pageSize}
|
||||
totalItems={pagination?.totalItems || 0}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./useBilling";
|
||||
export * from "./usePaymentRefresh";
|
||||
export * from "./useInvoicesFilter";
|
||||
|
||||
102
apps/portal/src/features/billing/hooks/useInvoicesFilter.ts
Normal file
102
apps/portal/src/features/billing/hooks/useInvoicesFilter.ts
Normal file
@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useCallback } from "react";
|
||||
import type { Invoice, InvoiceStatus } from "@customer-portal/domain/billing";
|
||||
|
||||
export type InvoiceStatusFilter = InvoiceStatus | "all";
|
||||
|
||||
export interface InvoicesSummaryStats {
|
||||
total: number;
|
||||
paid: number;
|
||||
unpaid: number;
|
||||
overdue: number;
|
||||
}
|
||||
|
||||
export interface UseInvoicesFilterOptions {
|
||||
invoices: Invoice[] | undefined;
|
||||
}
|
||||
|
||||
export interface UseInvoicesFilterResult {
|
||||
// Filter state
|
||||
searchTerm: string;
|
||||
setSearchTerm: (value: string) => void;
|
||||
statusFilter: InvoiceStatusFilter;
|
||||
setStatusFilter: (value: InvoiceStatusFilter) => void;
|
||||
|
||||
// Computed values
|
||||
filteredInvoices: Invoice[];
|
||||
stats: InvoicesSummaryStats;
|
||||
hasActiveFilters: boolean;
|
||||
|
||||
// Actions
|
||||
clearFilters: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* useInvoicesFilter - Hook for filtering and computing stats on invoices.
|
||||
*
|
||||
* Provides search, status filtering, summary statistics, and clear functionality
|
||||
* for invoice list pages.
|
||||
*/
|
||||
export function useInvoicesFilter({ invoices }: UseInvoicesFilterOptions): UseInvoicesFilterResult {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<InvoiceStatusFilter>("all");
|
||||
|
||||
const hasActiveFilters = searchTerm.trim() !== "" || statusFilter !== "all";
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setSearchTerm("");
|
||||
setStatusFilter("all");
|
||||
}, []);
|
||||
|
||||
const stats = useMemo<InvoicesSummaryStats>(() => {
|
||||
if (!invoices || invoices.length === 0) {
|
||||
return { total: 0, paid: 0, unpaid: 0, overdue: 0 };
|
||||
}
|
||||
|
||||
return invoices.reduce(
|
||||
(acc, invoice) => {
|
||||
acc.total++;
|
||||
if (invoice.status === "Paid") {
|
||||
acc.paid++;
|
||||
} else if (invoice.status === "Unpaid") {
|
||||
acc.unpaid++;
|
||||
} else if (invoice.status === "Overdue") {
|
||||
acc.overdue++;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ total: 0, paid: 0, unpaid: 0, overdue: 0 }
|
||||
);
|
||||
}, [invoices]);
|
||||
|
||||
const filteredInvoices = useMemo<Invoice[]>(() => {
|
||||
if (!invoices) return [];
|
||||
|
||||
return invoices.filter(invoice => {
|
||||
// Search filter - match invoice number or description
|
||||
if (searchTerm.trim()) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
const matchesNumber = invoice.number.toLowerCase().includes(term);
|
||||
const matchesDescription = invoice.description?.toLowerCase().includes(term);
|
||||
if (!matchesNumber && !matchesDescription) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Status filter
|
||||
return statusFilter === "all" || invoice.status === statusFilter;
|
||||
});
|
||||
}, [invoices, searchTerm, statusFilter]);
|
||||
|
||||
return {
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
filteredInvoices,
|
||||
stats,
|
||||
hasActiveFilters,
|
||||
clearFilters,
|
||||
};
|
||||
}
|
||||
@ -55,7 +55,7 @@ export function InvoiceDetailContainer() {
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<LoadingCard />
|
||||
<div className="bg-card text-card-foreground rounded-2xl border border-border p-6 space-y-4 shadow-[var(--cp-shadow-1)]">
|
||||
<div className="bg-card text-card-foreground rounded-xl border border-border p-6 space-y-4 shadow-[var(--cp-shadow-1)]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-5 w-40" />
|
||||
@ -113,7 +113,7 @@ export function InvoiceDetailContainer() {
|
||||
]}
|
||||
>
|
||||
<div>
|
||||
<div className="bg-card text-card-foreground rounded-2xl shadow-[var(--cp-shadow-1)] border border-border overflow-hidden">
|
||||
<div className="bg-card text-card-foreground rounded-xl shadow-[var(--cp-shadow-1)] border border-border overflow-hidden">
|
||||
<InvoiceSummaryBar
|
||||
invoice={invoice}
|
||||
loadingDownload={loadingDownload}
|
||||
|
||||
@ -10,7 +10,6 @@
|
||||
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/atoms";
|
||||
import {
|
||||
@ -80,12 +79,14 @@ export function AccountStatusStep() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link href={loginUrl}>
|
||||
<Button className="w-full h-11">
|
||||
Go to Login
|
||||
<ArrowRightIcon className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
as="a"
|
||||
href={loginUrl}
|
||||
className="w-full h-11"
|
||||
rightIcon={<ArrowRightIcon className="h-4 w-4" />}
|
||||
>
|
||||
Go to Login
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -148,12 +149,14 @@ export function AccountStatusStep() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link href={migrateUrl}>
|
||||
<Button className="w-full h-11">
|
||||
Link My Account
|
||||
<ArrowRightIcon className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
as="a"
|
||||
href={migrateUrl}
|
||||
className="w-full h-11"
|
||||
rightIcon={<ArrowRightIcon className="h-4 w-4" />}
|
||||
>
|
||||
Link My Account
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -223,9 +226,12 @@ export function AccountStatusStep() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button onClick={() => goToStep("complete-account")} className="w-full h-11">
|
||||
<Button
|
||||
onClick={() => goToStep("complete-account")}
|
||||
className="w-full h-11"
|
||||
rightIcon={<ArrowRightIcon className="h-4 w-4" />}
|
||||
>
|
||||
Continue
|
||||
<ArrowRightIcon className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@ -247,9 +253,12 @@ export function AccountStatusStep() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button onClick={() => goToStep("complete-account")} className="w-full h-11">
|
||||
<Button
|
||||
onClick={() => goToStep("complete-account")}
|
||||
className="w-full h-11"
|
||||
rightIcon={<ArrowRightIcon className="h-4 w-4" />}
|
||||
>
|
||||
Continue
|
||||
<ArrowRightIcon className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -4,7 +4,6 @@
|
||||
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/atoms";
|
||||
import { CheckCircleIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
|
||||
import { useGetStartedStore } from "../../../stores/get-started.store";
|
||||
@ -34,19 +33,19 @@ export function SuccessStep() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Link href={effectiveRedirectTo}>
|
||||
<Button className="w-full h-11">
|
||||
{isDefaultRedirect ? "Go to Dashboard" : "Continue"}
|
||||
<ArrowRightIcon className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
as="a"
|
||||
href={effectiveRedirectTo}
|
||||
className="w-full h-11"
|
||||
rightIcon={<ArrowRightIcon className="h-4 w-4" />}
|
||||
>
|
||||
{isDefaultRedirect ? "Go to Dashboard" : "Continue"}
|
||||
</Button>
|
||||
|
||||
{isDefaultRedirect && (
|
||||
<Link href="/services/internet">
|
||||
<Button variant="outline" className="w-full h-11">
|
||||
Check Internet Availability
|
||||
</Button>
|
||||
</Link>
|
||||
<Button as="a" href="/services/internet" variant="outline" className="w-full h-11">
|
||||
Check Internet Availability
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -18,6 +18,7 @@ import {
|
||||
DocumentTextIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { StatusPill } from "@/components/atoms/status-pill";
|
||||
import { DetailStatsGrid, type StatGridItem } from "@/components/molecules";
|
||||
import { ordersService } from "@/features/orders/api/orders.api";
|
||||
import { useOrderUpdates } from "@/features/orders/hooks/useOrderUpdates";
|
||||
import {
|
||||
@ -126,34 +127,6 @@ const describeCharge = (charge: OrderDisplayItemCharge): string => {
|
||||
return charge.label.toLowerCase();
|
||||
};
|
||||
|
||||
interface StatCardProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function StatCard({ icon, label, value, className }: StatCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-card rounded-xl border border-border p-4 shadow-[var(--cp-shadow-1)]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-muted/50 text-muted-foreground flex-shrink-0">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
||||
<div className="font-semibold text-foreground">{value}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OrderDetailContainer() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const searchParams = useSearchParams();
|
||||
@ -325,34 +298,38 @@ export function OrderDetailContainer() {
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Stats Cards Section */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
icon={<DocumentTextIcon className="h-5 w-5" />}
|
||||
label="Order Status"
|
||||
value={
|
||||
statusDescriptor ? (
|
||||
<StatusPill label={statusDescriptor.label} variant={statusPillVariant} />
|
||||
) : (
|
||||
"—"
|
||||
)
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<CurrencyYenIcon className="h-5 w-5" />}
|
||||
label="Monthly"
|
||||
value={totals.monthlyTotal > 0 ? Formatting.formatCurrency(totals.monthlyTotal) : "—"}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<CurrencyYenIcon className="h-5 w-5" />}
|
||||
label="One-Time"
|
||||
value={totals.oneTimeTotal > 0 ? Formatting.formatCurrency(totals.oneTimeTotal) : "—"}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<CalendarDaysIcon className="h-5 w-5" />}
|
||||
label="Order Date"
|
||||
value={placedDateShort}
|
||||
/>
|
||||
</div>
|
||||
<DetailStatsGrid
|
||||
items={
|
||||
[
|
||||
{
|
||||
icon: <DocumentTextIcon className="h-5 w-5" />,
|
||||
label: "Order Status",
|
||||
value: statusDescriptor ? (
|
||||
<StatusPill label={statusDescriptor.label} variant={statusPillVariant} />
|
||||
) : (
|
||||
"—"
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: <CurrencyYenIcon className="h-5 w-5" />,
|
||||
label: "Monthly",
|
||||
value:
|
||||
totals.monthlyTotal > 0 ? Formatting.formatCurrency(totals.monthlyTotal) : "—",
|
||||
},
|
||||
{
|
||||
icon: <CurrencyYenIcon className="h-5 w-5" />,
|
||||
label: "One-Time",
|
||||
value:
|
||||
totals.oneTimeTotal > 0 ? Formatting.formatCurrency(totals.oneTimeTotal) : "—",
|
||||
},
|
||||
{
|
||||
icon: <CalendarDaysIcon className="h-5 w-5" />,
|
||||
label: "Order Date",
|
||||
value: placedDateShort,
|
||||
},
|
||||
] satisfies StatGridItem[]
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Progress Timeline Section */}
|
||||
<div className="bg-card rounded-xl border border-border p-5 shadow-[var(--cp-shadow-1)]">
|
||||
@ -366,7 +343,7 @@ export function OrderDetailContainer() {
|
||||
</div>
|
||||
|
||||
{/* Main Content Card */}
|
||||
<div className="rounded-3xl border border-border bg-card text-card-foreground shadow-[var(--cp-shadow-1)]">
|
||||
<div className="rounded-xl border border-border bg-card text-card-foreground shadow-[var(--cp-shadow-1)]">
|
||||
{/* Header Section */}
|
||||
<div className="border-b border-border bg-card px-6 py-6 sm:px-8">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
|
||||
@ -6,12 +6,15 @@ import {
|
||||
ClipboardDocumentListIcon,
|
||||
ClockIcon,
|
||||
CheckCircleIcon,
|
||||
XMarkIcon,
|
||||
FunnelIcon,
|
||||
ArrowPathIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { PageLayout } from "@/components/templates/PageLayout";
|
||||
import { AnimatedCard, SummaryStats } from "@/components/molecules";
|
||||
import {
|
||||
AnimatedCard,
|
||||
SummaryStats,
|
||||
FilterDropdown,
|
||||
ClearFiltersButton,
|
||||
} from "@/components/molecules";
|
||||
import type { StatItem } from "@/components/molecules/SummaryStats";
|
||||
import { SearchFilterBar } from "@/components/molecules/SearchFilterBar/SearchFilterBar";
|
||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||
@ -194,34 +197,16 @@ export function OrdersListContainer() {
|
||||
filterLabel="Filter by status"
|
||||
>
|
||||
{/* Type filter as additional child */}
|
||||
<div className="relative">
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={event => setTypeFilter(event.target.value as OrderTypeFilter)}
|
||||
className="block w-36 pl-3 pr-8 py-2.5 text-sm border border-border focus:outline-none focus:ring-2 focus:ring-ring focus:border-primary rounded-lg appearance-none bg-card text-foreground shadow-sm cursor-pointer transition-colors"
|
||||
aria-label="Filter by type"
|
||||
>
|
||||
{TYPE_FILTER_OPTIONS.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-2.5 pointer-events-none">
|
||||
<FunnelIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<FilterDropdown
|
||||
value={typeFilter}
|
||||
onChange={value => setTypeFilter(value as OrderTypeFilter)}
|
||||
options={TYPE_FILTER_OPTIONS}
|
||||
label="Filter by type"
|
||||
width="w-36"
|
||||
/>
|
||||
|
||||
{/* Clear filters button */}
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="flex items-center gap-1 px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-muted rounded-lg transition-colors"
|
||||
>
|
||||
<XMarkIcon className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Clear</span>
|
||||
</button>
|
||||
)}
|
||||
<ClearFiltersButton onClick={clearFilters} show={hasActiveFilters} />
|
||||
</SearchFilterBar>
|
||||
|
||||
{/* Showing X of Y count */}
|
||||
|
||||
@ -18,6 +18,8 @@ import { ServicesHero } from "@/features/services/components/base/ServicesHero";
|
||||
interface ServicesOverviewContentProps {
|
||||
/** Base path for service links ("/services" or "/account/services") */
|
||||
basePath: "/services" | "/account/services";
|
||||
/** Whether to show the hero section (default: true) */
|
||||
showHero?: boolean;
|
||||
/** Whether to show the CTA section (default: true) */
|
||||
showCta?: boolean;
|
||||
}
|
||||
@ -30,41 +32,46 @@ interface ServicesOverviewContentProps {
|
||||
*/
|
||||
export function ServicesOverviewContent({
|
||||
basePath,
|
||||
showHero = true,
|
||||
showCta = true,
|
||||
}: ServicesOverviewContentProps) {
|
||||
return (
|
||||
<div className="space-y-12 pb-16">
|
||||
{/* Hero */}
|
||||
<ServicesHero
|
||||
title="Our Services"
|
||||
description="Connectivity and support solutions for Japan's international community."
|
||||
eyebrow={
|
||||
<span className="inline-flex items-center gap-2 rounded-full bg-primary/8 border border-primary/15 px-4 py-2 text-sm text-primary font-medium normal-case tracking-normal">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Full English Support
|
||||
</span>
|
||||
}
|
||||
animated
|
||||
/>
|
||||
{showHero && (
|
||||
<>
|
||||
<ServicesHero
|
||||
title="Our Services"
|
||||
description="Connectivity and support solutions for Japan's international community."
|
||||
eyebrow={
|
||||
<span className="inline-flex items-center gap-2 rounded-full bg-primary/8 border border-primary/15 px-4 py-2 text-sm text-primary font-medium normal-case tracking-normal">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Full English Support
|
||||
</span>
|
||||
}
|
||||
animated
|
||||
/>
|
||||
|
||||
{/* Value Props - Compact */}
|
||||
<section
|
||||
className="flex flex-wrap justify-center gap-6 text-sm animate-in fade-in slide-in-from-bottom-8 duration-700"
|
||||
style={{ animationDelay: "300ms" }}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Globe className="h-4 w-4 text-primary" />
|
||||
<span>One provider, all services</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Headphones className="h-4 w-4 text-success" />
|
||||
<span>English support</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<CheckCircle2 className="h-4 w-4 text-info" />
|
||||
<span>No hidden fees</span>
|
||||
</div>
|
||||
</section>
|
||||
{/* Value Props - Compact */}
|
||||
<section
|
||||
className="flex flex-wrap justify-center gap-6 text-sm animate-in fade-in slide-in-from-bottom-8 duration-700"
|
||||
style={{ animationDelay: "300ms" }}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Globe className="h-4 w-4 text-primary" />
|
||||
<span>One provider, all services</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Headphones className="h-4 w-4 text-success" />
|
||||
<span>English support</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<CheckCircle2 className="h-4 w-4 text-info" />
|
||||
<span>No hidden fees</span>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* All Services - Clean Grid with staggered animations */}
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 cp-stagger-children">
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { Squares2X2Icon } from "@heroicons/react/24/outline";
|
||||
import { PageLayout } from "@/components/templates/PageLayout";
|
||||
import { ServicesOverviewContent } from "@/features/services/components/common/ServicesOverviewContent";
|
||||
|
||||
/**
|
||||
@ -5,13 +7,16 @@ import { ServicesOverviewContent } from "@/features/services/components/common/S
|
||||
*
|
||||
* Shows available services for the logged-in user with the same rich
|
||||
* design as the public page, linking to account-specific service pages.
|
||||
* Uses PageLayout for consistent account page header and navigation.
|
||||
*/
|
||||
export function AccountServicesOverview() {
|
||||
return (
|
||||
<div className="py-8">
|
||||
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-4 sm:px-6 md:px-8">
|
||||
<ServicesOverviewContent basePath="/account/services" />
|
||||
</div>
|
||||
</div>
|
||||
<PageLayout
|
||||
icon={<Squares2X2Icon />}
|
||||
title="Services"
|
||||
description="Browse and order connectivity services"
|
||||
>
|
||||
<ServicesOverviewContent basePath="/account/services" showHero={false} />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -117,7 +117,7 @@ export function SubscriptionDetailContainer() {
|
||||
{subscription ? (
|
||||
<div className="space-y-6">
|
||||
{/* Subscription Stats */}
|
||||
<div className="bg-card border border-border rounded-2xl shadow-sm overflow-hidden">
|
||||
<div className="bg-card border border-border rounded-xl shadow-[var(--cp-shadow-1)] overflow-hidden">
|
||||
<div className="px-6 py-5 grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
|
||||
@ -9,13 +9,16 @@ import {
|
||||
ClockIcon,
|
||||
ChevronRightIcon,
|
||||
TicketIcon,
|
||||
XMarkIcon,
|
||||
FunnelIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import type { StatItem } from "@/components/molecules/SummaryStats";
|
||||
import { ChatBubbleLeftRightIcon as ChatBubbleLeftRightIconSolid } from "@heroicons/react/24/solid";
|
||||
import { PageLayout } from "@/components/templates/PageLayout";
|
||||
import { AnimatedCard, SummaryStats } from "@/components/molecules";
|
||||
import {
|
||||
AnimatedCard,
|
||||
SummaryStats,
|
||||
FilterDropdown,
|
||||
ClearFiltersButton,
|
||||
} from "@/components/molecules";
|
||||
import { SearchFilterBar } from "@/components/molecules/SearchFilterBar/SearchFilterBar";
|
||||
import { Button } from "@/components/atoms";
|
||||
import { EmptyState, SearchEmptyState } from "@/components/atoms/empty-state";
|
||||
@ -60,7 +63,8 @@ export function SupportCasesView() {
|
||||
// Show loading until we have data or an error
|
||||
const showLoading = !data && !error;
|
||||
|
||||
const hasActiveFilters = statusFilter !== "all" || priorityFilter !== "all" || searchTerm.trim();
|
||||
const hasActiveFilters =
|
||||
statusFilter !== "all" || priorityFilter !== "all" || searchTerm.trim() !== "";
|
||||
|
||||
const statusFilterOptions = useMemo(
|
||||
() => [
|
||||
@ -147,34 +151,16 @@ export function SupportCasesView() {
|
||||
filterLabel="Filter by status"
|
||||
>
|
||||
{/* Priority filter as additional child */}
|
||||
<div className="relative">
|
||||
<select
|
||||
value={priorityFilter}
|
||||
onChange={event => setPriorityFilter(event.target.value)}
|
||||
className="block w-40 pl-3 pr-8 py-2.5 text-sm border border-border focus:outline-none focus:ring-2 focus:ring-ring focus:border-primary rounded-lg appearance-none bg-card text-foreground shadow-sm cursor-pointer transition-colors"
|
||||
aria-label="Filter by priority"
|
||||
>
|
||||
{priorityFilterOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-2.5 pointer-events-none">
|
||||
<FunnelIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<FilterDropdown
|
||||
value={priorityFilter}
|
||||
onChange={setPriorityFilter}
|
||||
options={priorityFilterOptions}
|
||||
label="Filter by priority"
|
||||
width="w-40"
|
||||
/>
|
||||
|
||||
{/* Clear filters button */}
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="flex items-center gap-1 px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-muted rounded-lg transition-colors"
|
||||
>
|
||||
<XMarkIcon className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Clear</span>
|
||||
</button>
|
||||
)}
|
||||
<ClearFiltersButton onClick={clearFilters} show={hasActiveFilters} />
|
||||
</SearchFilterBar>
|
||||
|
||||
{/* Cases List */}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user