- Implemented WHMCS invoice creation and payment capture in SimManagementService for top-ups. - Updated top-up logic to calculate costs based on GB input, with pricing set at 500 JPY per GB. - Simplified the Top Up Modal interface, removing unnecessary fields and improving user experience. - Added new methods in WhmcsService for invoice and payment operations. - Enhanced error handling for payment failures and added transaction logging for audit purposes. - Updated documentation to reflect changes in the SIM management flow and API interactions.
349 lines
12 KiB
TypeScript
349 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useMemo } from "react";
|
|
import Link from "next/link";
|
|
import { PageLayout } from "@/components/layout/page-layout";
|
|
import { DataTable } from "@/components/ui/data-table";
|
|
import { StatusPill } from "@/components/ui/status-pill";
|
|
import { SubCard } from "@/components/ui/sub-card";
|
|
import { SearchFilterBar } from "@/components/ui/search-filter-bar";
|
|
import {
|
|
ServerIcon,
|
|
CheckCircleIcon,
|
|
ExclamationTriangleIcon,
|
|
ClockIcon,
|
|
XCircleIcon,
|
|
CalendarIcon,
|
|
ArrowTopRightOnSquareIcon,
|
|
} from "@heroicons/react/24/outline";
|
|
// (duplicate SubCard import removed)
|
|
import { format } from "date-fns";
|
|
import { useSubscriptions, useSubscriptionStats } from "@/features/subscriptions/hooks";
|
|
import { formatCurrency, getCurrencyLocale } from "@/utils/currency";
|
|
import type { Subscription } from "@customer-portal/shared";
|
|
// Removed unused SubscriptionStatusBadge in favor of StatusPill
|
|
|
|
export default function SubscriptionsPage() {
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [statusFilter, setStatusFilter] = useState("all");
|
|
|
|
// Fetch subscriptions and stats from API
|
|
const {
|
|
data: subscriptionData,
|
|
isLoading,
|
|
error,
|
|
} = useSubscriptions({
|
|
status: statusFilter === "all" ? undefined : statusFilter,
|
|
});
|
|
const { data: stats } = useSubscriptionStats();
|
|
|
|
// Handle both SubscriptionList and Subscription[] response types
|
|
const subscriptions = useMemo((): Subscription[] => {
|
|
if (!subscriptionData) return [];
|
|
if (Array.isArray(subscriptionData)) return subscriptionData;
|
|
return subscriptionData.subscriptions;
|
|
}, [subscriptionData]);
|
|
|
|
// Filter subscriptions based on search and status
|
|
const filteredSubscriptions = useMemo(() => {
|
|
if (!searchTerm) return subscriptions;
|
|
return subscriptions.filter(subscription => {
|
|
const matchesSearch =
|
|
subscription.productName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
subscription.serviceId.toString().includes(searchTerm);
|
|
return matchesSearch;
|
|
});
|
|
}, [subscriptions, searchTerm]);
|
|
|
|
const getStatusIcon = (status: string) => {
|
|
switch (status) {
|
|
case "Active":
|
|
return <CheckCircleIcon className="h-5 w-5 text-green-500" />;
|
|
case "Suspended":
|
|
return <ExclamationTriangleIcon className="h-5 w-5 text-yellow-500" />;
|
|
case "Pending":
|
|
return <ClockIcon className="h-5 w-5 text-blue-500" />;
|
|
case "Cancelled":
|
|
case "Terminated":
|
|
return <XCircleIcon className="h-5 w-5 text-gray-500" />;
|
|
default:
|
|
return <ClockIcon className="h-5 w-5 text-gray-500" />;
|
|
}
|
|
};
|
|
|
|
const formatDate = (dateString: string) => {
|
|
try {
|
|
return format(new Date(dateString), "MMM d, yyyy");
|
|
} catch {
|
|
return "Invalid date";
|
|
}
|
|
};
|
|
|
|
const statusFilterOptions = [
|
|
{ value: "all", label: "All Status" },
|
|
{ value: "Active", label: "Active" },
|
|
{ value: "Suspended", label: "Suspended" },
|
|
{ value: "Pending", label: "Pending" },
|
|
{ value: "Cancelled", label: "Cancelled" },
|
|
{ value: "Terminated", label: "Terminated" },
|
|
];
|
|
|
|
const getStatusVariant = (status: string) => {
|
|
switch (status) {
|
|
case "Active":
|
|
return "success" as const;
|
|
case "Suspended":
|
|
return "warning" as const;
|
|
case "Pending":
|
|
return "info" as const;
|
|
case "Cancelled":
|
|
case "Terminated":
|
|
return "neutral" as const;
|
|
default:
|
|
return "neutral" as const;
|
|
}
|
|
};
|
|
|
|
const subscriptionColumns = [
|
|
{
|
|
key: "service",
|
|
header: "Service",
|
|
render: (subscription: Subscription) => (
|
|
<div className="flex items-center">
|
|
{getStatusIcon(subscription.status)}
|
|
<div className="ml-3">
|
|
<div className="text-sm font-medium text-gray-900">{subscription.productName}</div>
|
|
<div className="text-sm text-gray-500">Service ID: {subscription.serviceId}</div>
|
|
</div>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: "status",
|
|
header: "Status",
|
|
render: (subscription: Subscription) => (
|
|
<StatusPill label={subscription.status} variant={getStatusVariant(subscription.status)} />
|
|
),
|
|
},
|
|
{
|
|
key: "cycle",
|
|
header: "Billing Cycle",
|
|
render: (subscription: Subscription) => {
|
|
const name = (subscription.productName || '').toLowerCase();
|
|
const looksLikeActivation =
|
|
name.includes('activation fee') || name.includes('activation') || name.includes('setup');
|
|
const displayCycle = looksLikeActivation ? 'One-time' : subscription.cycle;
|
|
return <span className="text-sm text-gray-900">{displayCycle}</span>;
|
|
},
|
|
},
|
|
{
|
|
key: "price",
|
|
header: "Price",
|
|
render: (subscription: Subscription) => (
|
|
<div>
|
|
<span className="text-sm font-medium text-gray-900">
|
|
{formatCurrency(subscription.amount, {
|
|
currency: "JPY",
|
|
locale: getCurrencyLocale("JPY"),
|
|
})}
|
|
</span>
|
|
<div className="text-xs text-gray-500">
|
|
{subscription.cycle === "Monthly"
|
|
? "per month"
|
|
: subscription.cycle === "Annually"
|
|
? "per year"
|
|
: subscription.cycle === "Quarterly"
|
|
? "per quarter"
|
|
: subscription.cycle === "Semi-Annually"
|
|
? "per 6 months"
|
|
: subscription.cycle === "Biennially"
|
|
? "per 2 years"
|
|
: subscription.cycle === "Triennially"
|
|
? "per 3 years"
|
|
: subscription.cycle === "One-time"
|
|
? "one-time"
|
|
: "one-time"}
|
|
</div>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: "nextDue",
|
|
header: "Next Due",
|
|
render: (subscription: Subscription) => (
|
|
<div className="flex items-center">
|
|
<CalendarIcon className="h-4 w-4 text-gray-400 mr-2" />
|
|
<span className="text-sm text-gray-500">
|
|
{subscription.nextDue ? formatDate(subscription.nextDue) : "N/A"}
|
|
</span>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: "actions",
|
|
header: "",
|
|
className: "relative",
|
|
render: (subscription: Subscription) => (
|
|
<div className="flex items-center justify-end space-x-2">
|
|
<Link
|
|
href={`/subscriptions/${subscription.id}`}
|
|
className="text-blue-600 hover:text-blue-900 text-sm cursor-pointer"
|
|
>
|
|
View
|
|
</Link>
|
|
<ArrowTopRightOnSquareIcon className="h-4 w-4 text-gray-400" />
|
|
</div>
|
|
),
|
|
},
|
|
];
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<PageLayout
|
|
icon={<ServerIcon />}
|
|
title="Subscriptions"
|
|
description="Manage your active services and subscriptions"
|
|
>
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="text-center">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
|
<p className="mt-4 text-gray-600">Loading subscriptions...</p>
|
|
</div>
|
|
</div>
|
|
</PageLayout>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<PageLayout
|
|
icon={<ServerIcon />}
|
|
title="Subscriptions"
|
|
description="Manage your active services and subscriptions"
|
|
>
|
|
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
|
<div className="flex">
|
|
<div className="flex-shrink-0">
|
|
<ExclamationTriangleIcon className="h-5 w-5 text-red-400" />
|
|
</div>
|
|
<div className="ml-3">
|
|
<h3 className="text-sm font-medium text-red-800">Error loading subscriptions</h3>
|
|
<div className="mt-2 text-sm text-red-700">
|
|
{error instanceof Error ? error.message : "An unexpected error occurred"}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</PageLayout>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<PageLayout
|
|
icon={<ServerIcon />}
|
|
title="Subscriptions"
|
|
description="Manage your active services and subscriptions"
|
|
actions={
|
|
<Link
|
|
href="/catalog"
|
|
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 whitespace-nowrap"
|
|
>
|
|
Order Services
|
|
</Link>
|
|
}
|
|
>
|
|
{/* Stats Cards */}
|
|
{/* Stats Cards */}
|
|
{stats && (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
|
<SubCard>
|
|
<div className="flex items-center">
|
|
<div className="flex-shrink-0">
|
|
<CheckCircleIcon className="h-8 w-8 text-green-600" />
|
|
</div>
|
|
<div className="ml-5 w-0 flex-1">
|
|
<dl>
|
|
<dt className="text-sm font-medium text-gray-500 truncate">Active</dt>
|
|
<dd className="text-lg font-medium text-gray-900">{stats.active}</dd>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
</SubCard>
|
|
|
|
<SubCard>
|
|
<div className="flex items-center">
|
|
<div className="flex-shrink-0">
|
|
<ExclamationTriangleIcon className="h-8 w-8 text-yellow-600" />
|
|
</div>
|
|
<div className="ml-5 w-0 flex-1">
|
|
<dl>
|
|
<dt className="text-sm font-medium text-gray-500 truncate">Suspended</dt>
|
|
<dd className="text-lg font-medium text-gray-900">{stats.suspended}</dd>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
</SubCard>
|
|
|
|
<SubCard>
|
|
<div className="flex items-center">
|
|
<div className="flex-shrink-0">
|
|
<ClockIcon className="h-8 w-8 text-blue-600" />
|
|
</div>
|
|
<div className="ml-5 w-0 flex-1">
|
|
<dl>
|
|
<dt className="text-sm font-medium text-gray-500 truncate">Pending</dt>
|
|
<dd className="text-lg font-medium text-gray-900">{stats.pending}</dd>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
</SubCard>
|
|
|
|
<SubCard>
|
|
<div className="flex items-center">
|
|
<div className="flex-shrink-0">
|
|
<XCircleIcon className="h-8 w-8 text-gray-600" />
|
|
</div>
|
|
<div className="ml-5 w-0 flex-1">
|
|
<dl>
|
|
<dt className="text-sm font-medium text-gray-500 truncate">Cancelled</dt>
|
|
<dd className="text-lg font-medium text-gray-900">{stats.cancelled}</dd>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
</SubCard>
|
|
</div>
|
|
)}
|
|
|
|
{/* Subscriptions Table with integrated header + CTA */}
|
|
<SubCard
|
|
header={
|
|
<SearchFilterBar
|
|
searchValue={searchTerm}
|
|
onSearchChange={setSearchTerm}
|
|
searchPlaceholder="Search subscriptions..."
|
|
filterValue={statusFilter}
|
|
onFilterChange={setStatusFilter}
|
|
filterOptions={statusFilterOptions}
|
|
filterLabel="Filter by status"
|
|
/>
|
|
}
|
|
headerClassName="bg-gray-50 rounded md:p-2 p-1 mb-1"
|
|
>
|
|
<DataTable
|
|
data={filteredSubscriptions}
|
|
columns={subscriptionColumns}
|
|
emptyState={{
|
|
icon: <ServerIcon className="h-12 w-12" />,
|
|
title: "No subscriptions found",
|
|
description:
|
|
searchTerm || statusFilter !== "all"
|
|
? "Try adjusting your search or filter criteria."
|
|
: "No active subscriptions at this time.",
|
|
}}
|
|
onRowClick={subscription => (window.location.href = `/subscriptions/${subscription.id}`)}
|
|
/>
|
|
</SubCard>
|
|
</PageLayout>
|
|
);
|
|
}
|