tema ac259ce902 Enhance SIM management service with payment processing and API integration
- 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.
2025-09-06 13:57:18 +09:00

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>
);
}