Implement SIM features update functionality and enhance UI components

- Added new SimFeaturesUpdateRequest interface to handle optional SIM feature updates.
- Implemented updateSimFeatures method in SimManagementService to process feature updates including voicemail, call waiting, international roaming, and network type.
- Expanded SubscriptionsController with a new endpoint for updating SIM features.
- Introduced SimFeatureToggles component for managing service options in the UI.
- Enhanced DataUsageChart and SimDetailsCard components to support embedded rendering and improved styling.
- Updated layout and design for better user experience in the SIM management section.
This commit is contained in:
tema 2025-09-05 15:39:43 +09:00
parent d9f7c5c8b2
commit 735828cf32
17 changed files with 1372 additions and 224 deletions

View File

@ -28,6 +28,13 @@ export interface SimTopUpHistoryRequest {
toDate: string; // YYYYMMDD
}
export interface SimFeaturesUpdateRequest {
voiceMailEnabled?: boolean;
callWaitingEnabled?: boolean;
internationalRoamingEnabled?: boolean;
networkType?: '4G' | '5G';
}
@Injectable()
export class SimManagementService {
constructor(
@ -327,6 +334,41 @@ export class SimManagementService {
}
}
/**
* Update SIM features (voicemail, call waiting, roaming, network type)
*/
async updateSimFeatures(
userId: string,
subscriptionId: number,
request: SimFeaturesUpdateRequest
): Promise<void> {
try {
const { account } = await this.validateSimSubscription(userId, subscriptionId);
// Validate network type if provided
if (request.networkType && !['4G', '5G'].includes(request.networkType)) {
throw new BadRequestException('networkType must be either "4G" or "5G"');
}
await this.freebititService.updateSimFeatures(account, request);
this.logger.log(`Updated SIM features for subscription ${subscriptionId}`, {
userId,
subscriptionId,
account,
...request,
});
} catch (error) {
this.logger.error(`Failed to update SIM features for subscription ${subscriptionId}`, {
error: getErrorMessage(error),
userId,
subscriptionId,
...request,
});
throw error;
}
}
/**
* Cancel SIM service
*/

View File

@ -388,4 +388,38 @@ export class SubscriptionsController {
await this.simManagementService.reissueEsimProfile(req.user.id, subscriptionId);
return { success: true, message: "eSIM profile reissue completed successfully" };
}
@Post(":id/sim/features")
@ApiOperation({
summary: "Update SIM features",
description: "Enable/disable voicemail, call waiting, international roaming, and switch network type (4G/5G)",
})
@ApiParam({ name: "id", type: Number, description: "Subscription ID" })
@ApiBody({
description: "Features update request",
schema: {
type: "object",
properties: {
voiceMailEnabled: { type: "boolean" },
callWaitingEnabled: { type: "boolean" },
internationalRoamingEnabled: { type: "boolean" },
networkType: { type: "string", enum: ["4G", "5G"] },
},
},
})
@ApiResponse({ status: 200, description: "Features update successful" })
async updateSimFeatures(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number,
@Body()
body: {
voiceMailEnabled?: boolean;
callWaitingEnabled?: boolean;
internationalRoamingEnabled?: boolean;
networkType?: '4G' | '5G';
}
) {
await this.simManagementService.updateSimFeatures(req.user.id, subscriptionId, body);
return { success: true, message: "SIM features updated successfully" };
}
}

View File

@ -24,7 +24,9 @@ import {
SimDetails,
SimUsage,
SimTopUpHistory,
FreebititError
FreebititError,
FreebititAddSpecRequest,
FreebititAddSpecResponse
} from './interfaces/freebit.types';
@Injectable()
@ -476,6 +478,55 @@ export class FreebititService {
}
}
/**
* Update SIM optional features (voicemail, call waiting, international roaming, network type)
* Uses AddSpec endpoint for immediate changes
*/
async updateSimFeatures(account: string, features: {
voiceMailEnabled?: boolean;
callWaitingEnabled?: boolean;
internationalRoamingEnabled?: boolean;
networkType?: string; // '4G' | '5G'
}): Promise<void> {
try {
const request: Omit<FreebititAddSpecRequest, 'authKey'> = {
account,
};
if (typeof features.voiceMailEnabled === 'boolean') {
request.voiceMail = features.voiceMailEnabled ? '10' as const : '20' as const;
request.voicemail = request.voiceMail; // include alternate casing for compatibility
}
if (typeof features.callWaitingEnabled === 'boolean') {
request.callWaiting = features.callWaitingEnabled ? '10' as const : '20' as const;
request.callwaiting = request.callWaiting;
}
if (typeof features.internationalRoamingEnabled === 'boolean') {
request.worldWing = features.internationalRoamingEnabled ? '10' as const : '20' as const;
request.worldwing = request.worldWing;
}
if (features.networkType) {
request.contractLine = features.networkType;
}
await this.makeAuthenticatedRequest<FreebititAddSpecResponse>('/master/addSpec/', request);
this.logger.log(`Updated SIM features for account ${account}`, {
account,
voiceMailEnabled: features.voiceMailEnabled,
callWaitingEnabled: features.callWaitingEnabled,
internationalRoamingEnabled: features.internationalRoamingEnabled,
networkType: features.networkType,
});
} catch (error: any) {
this.logger.error(`Failed to update SIM features for account ${account}`, {
error: error.message,
account,
});
throw error;
}
}
/**
* Cancel SIM service
*/

View File

@ -115,6 +115,28 @@ export interface FreebititTopUpResponse {
};
}
// AddSpec request for updating SIM options/features immediately
export interface FreebititAddSpecRequest {
authKey: string;
account: string;
// Feature flags: 10 = enabled, 20 = disabled
voiceMail?: '10' | '20';
voicemail?: '10' | '20';
callWaiting?: '10' | '20';
callwaiting?: '10' | '20';
worldWing?: '10' | '20';
worldwing?: '10' | '20';
contractLine?: string; // '4G' or '5G'
}
export interface FreebititAddSpecResponse {
resultCode: string;
status: {
message: string;
statusCode: string;
};
}
export interface FreebititQuotaHistoryRequest {
authKey: string;
account: string;

View File

@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { useParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useParams, useSearchParams } from "next/navigation";
import Link from "next/link";
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import {
@ -22,8 +22,11 @@ import { SimManagementSection } from "@/features/sim-management";
export default function SubscriptionDetailPage() {
const params = useParams();
const searchParams = useSearchParams();
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
const [showInvoices, setShowInvoices] = useState(true);
const [showSimManagement, setShowSimManagement] = useState(false);
const subscriptionId = parseInt(params.id as string);
const { data: subscription, isLoading, error } = useSubscription(subscriptionId);
@ -36,6 +39,31 @@ export default function SubscriptionDetailPage() {
const invoices = invoiceData?.invoices || [];
const pagination = invoiceData?.pagination;
// Control what sections to show based on URL hash
useEffect(() => {
const updateVisibility = () => {
const hash = typeof window !== 'undefined' ? window.location.hash : '';
const service = (searchParams.get('service') || '').toLowerCase();
const isSimContext = hash.includes('sim-management') || service === 'sim';
if (isSimContext) {
// Show only SIM management, hide invoices
setShowInvoices(false);
setShowSimManagement(true);
} else {
// Show only invoices, hide SIM management
setShowInvoices(true);
setShowSimManagement(false);
}
};
updateVisibility();
if (typeof window !== 'undefined') {
window.addEventListener('hashchange', updateVisibility);
return () => window.removeEventListener('hashchange', updateVisibility);
}
return;
}, [searchParams]);
const getStatusIcon = (status: string) => {
switch (status) {
case "Active":
@ -175,7 +203,7 @@ export default function SubscriptionDetailPage() {
return (
<DashboardLayout>
<div className="py-6">
<div className="max-w-4xl mx-auto px-4 sm:px-6 md:px-8">
<div className="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
@ -191,6 +219,7 @@ export default function SubscriptionDetailPage() {
</div>
</div>
</div>
</div>
</div>
@ -247,12 +276,51 @@ export default function SubscriptionDetailPage() {
</div>
</div>
{/* SIM Management Section - Only show for SIM services */}
{/* Navigation tabs for SIM services - More visible and mobile-friendly */}
{subscription.productName.toLowerCase().includes('sim') && (
<div className="mb-8">
<div className="bg-white shadow-lg rounded-xl border border-gray-100 p-6">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between space-y-4 lg:space-y-0">
<div>
<h3 className="text-xl font-semibold text-gray-900">Service Management</h3>
<p className="text-sm text-gray-600 mt-1">Switch between billing and SIM management views</p>
</div>
<div className="flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-2 bg-gray-100 rounded-xl p-2">
<Link
href={`/subscriptions/${subscriptionId}#sim-management`}
className={`px-6 py-3 text-sm font-semibold rounded-lg transition-all duration-200 min-w-[140px] text-center ${
showSimManagement
? 'bg-white text-blue-600 shadow-md hover:shadow-lg'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-200'
}`}
>
<ServerIcon className="h-4 w-4 inline mr-2" />
SIM Management
</Link>
<Link
href={`/subscriptions/${subscriptionId}`}
className={`px-6 py-3 text-sm font-semibold rounded-lg transition-all duration-200 min-w-[120px] text-center ${
showInvoices
? 'bg-white text-blue-600 shadow-md hover:shadow-lg'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-200'
}`}
>
<DocumentTextIcon className="h-4 w-4 inline mr-2" />
Billing
</Link>
</div>
</div>
</div>
</div>
)}
{/* SIM Management Section - Only show when in SIM context and for SIM services */}
{showSimManagement && subscription.productName.toLowerCase().includes('sim') && (
<SimManagementSection subscriptionId={subscriptionId} />
)}
{/* Related Invoices */}
{/* Related Invoices (hidden when viewing SIM management directly) */}
{showInvoices && (
<div className="bg-white shadow rounded-lg">
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex items-center">
@ -427,6 +495,7 @@ export default function SubscriptionDetailPage() {
</>
)}
</div>
)}
</div>
</div>
</DashboardLayout>

View File

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useAuthStore } from "@/lib/auth/store";
@ -18,6 +18,8 @@ import {
Squares2X2Icon,
ClipboardDocumentListIcon,
} from "@heroicons/react/24/outline";
import { useActiveSubscriptions } from "@/hooks/useSubscriptions";
import type { Subscription } from "@customer-portal/shared";
interface DashboardLayoutProps {
children: React.ReactNode;
@ -37,7 +39,7 @@ interface NavigationItem {
isLogout?: boolean;
}
const navigation = [
const baseNavigation: NavigationItem[] = [
{ name: "Dashboard", href: "/dashboard", icon: HomeIcon },
{ name: "Orders", href: "/orders", icon: ClipboardDocumentListIcon },
{
@ -48,7 +50,12 @@ const navigation = [
{ name: "Payment Methods", href: "/billing/payments" },
],
},
{ name: "Subscriptions", href: "/subscriptions", icon: ServerIcon },
{
name: "Subscriptions",
icon: ServerIcon,
// Children are added dynamically based on user subscriptions; default child keeps access to list
children: [{ name: "All Subscriptions", href: "/subscriptions" }],
},
{ name: "Catalog", href: "/catalog", icon: Squares2X2Icon },
{
name: "Support",
@ -78,6 +85,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
const { user, isAuthenticated, checkAuth } = useAuthStore();
const pathname = usePathname();
const router = useRouter();
const { data: activeSubscriptions } = useActiveSubscriptions();
useEffect(() => {
setMounted(true);
@ -91,6 +99,13 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
}
}, [mounted, isAuthenticated, router]);
// Auto-expand Subscriptions when browsing subscription routes
useEffect(() => {
if (pathname.startsWith("/subscriptions") && !expandedItems.includes("Subscriptions")) {
setExpandedItems(prev => [...prev, "Subscriptions"]);
}
}, [pathname, expandedItems]);
const toggleExpanded = (itemName: string) => {
setExpandedItems(prev =>
prev.includes(itemName) ? prev.filter(name => name !== itemName) : [...prev, itemName]
@ -129,7 +144,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
</button>
</div>
<MobileSidebar
navigation={navigation}
navigation={computeNavigation(activeSubscriptions)}
pathname={pathname}
expandedItems={expandedItems}
toggleExpanded={toggleExpanded}
@ -142,7 +157,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
<div className="hidden md:flex md:flex-shrink-0">
<div className="flex flex-col w-64">
<DesktopSidebar
navigation={navigation}
navigation={computeNavigation(activeSubscriptions)}
pathname={pathname}
expandedItems={expandedItems}
toggleExpanded={toggleExpanded}
@ -199,6 +214,47 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
);
}
function computeNavigation(activeSubscriptions?: Subscription[]): NavigationItem[] {
// Clone base structure
const nav: NavigationItem[] = baseNavigation.map(item => ({
...item,
children: item.children ? [...item.children] : undefined,
}));
// Inject dynamic submenu under Subscriptions
const subIdx = nav.findIndex(n => n.name === "Subscriptions");
if (subIdx >= 0) {
const baseChildren = nav[subIdx].children ?? [];
const dynamicChildren: NavigationChild[] = (activeSubscriptions || []).map(sub => {
const hrefBase = `/subscriptions/${sub.id}`;
// Link to the main subscription page - users can use the tabs to navigate to SIM management
const href = hrefBase;
return {
name: truncate(sub.productName || `Subscription ${sub.id}`, 28),
href,
} as NavigationChild;
});
nav[subIdx] = {
...nav[subIdx],
children: [
// Keep the list entry first
{ name: "All Subscriptions", href: "/subscriptions" },
// Divider-like label is avoided; we just list items
...dynamicChildren,
],
};
}
return nav;
}
function truncate(text: string, max: number): string {
if (text.length <= max) return text;
return text.slice(0, Math.max(0, max - 1)) + "…";
}
function DesktopSidebar({
navigation,
pathname,
@ -287,7 +343,7 @@ function NavigationItem({
const hasChildren = item.children && item.children.length > 0;
const isActive = hasChildren
? item.children?.some((child: NavigationChild) => pathname.startsWith(child.href)) || false
? item.children?.some((child: NavigationChild) => pathname.startsWith((child.href || "").split(/[?#]/)[0])) || false
: item.href
? pathname === item.href
: false;
@ -331,7 +387,7 @@ function NavigationItem({
key={child.name}
href={child.href}
className={`
${pathname === child.href ? "bg-blue-50 text-blue-700 border-r-2 border-blue-700" : "text-gray-600 hover:bg-gray-50 hover:text-gray-900"}
${pathname === (child.href || "").split(/[?#]/)[0] ? "bg-blue-50 text-blue-700 border-r-2 border-blue-700" : "text-gray-600 hover:bg-gray-50 hover:text-gray-900"}
group w-full flex items-center pl-11 pr-2 py-2 text-sm rounded-md
`}
>

View File

@ -0,0 +1,137 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import {
WrenchScrewdriverIcon,
LockClosedIcon,
GlobeAltIcon,
DevicePhoneMobileIcon,
ShieldCheckIcon,
} from "@heroicons/react/24/outline";
import { SimManagementSection } from "@/features/sim-management";
interface ServiceManagementSectionProps {
subscriptionId: number;
productName: string;
}
type ServiceKey = "SIM" | "INTERNET" | "NETGEAR" | "VPN";
export function ServiceManagementSection({
subscriptionId,
productName,
}: ServiceManagementSectionProps) {
const isSimService = useMemo(
() => productName?.toLowerCase().includes("sim"),
[productName]
);
const [selectedService, setSelectedService] = useState<ServiceKey>(
isSimService ? "SIM" : "INTERNET"
);
const searchParams = useSearchParams();
useEffect(() => {
const s = (searchParams.get("service") || "").toLowerCase();
if (s === "sim") setSelectedService("SIM");
else if (s === "internet") setSelectedService("INTERNET");
else if (s === "netgear") setSelectedService("NETGEAR");
else if (s === "vpn") setSelectedService("VPN");
}, [searchParams]);
const renderHeader = () => (
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<div className="flex items-center">
<WrenchScrewdriverIcon className="h-6 w-6 text-blue-600 mr-2" />
<div>
<h3 className="text-lg font-medium text-gray-900">Service Management</h3>
<p className="text-sm text-gray-500">Manage settings for your subscription</p>
</div>
</div>
<div className="flex items-center space-x-2">
<label htmlFor="service-selector" className="text-sm text-gray-600">
Service
</label>
<select
id="service-selector"
className="block w-48 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
value={selectedService}
onChange={(e) => setSelectedService(e.target.value as ServiceKey)}
>
<option value="SIM">SIM</option>
<option value="INTERNET">Internet (coming soon)</option>
<option value="NETGEAR">Netgear (coming soon)</option>
<option value="VPN">VPN (coming soon)</option>
</select>
</div>
</div>
);
const ComingSoon = ({
icon: Icon,
title,
description,
}: {
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
title: string;
description: string;
}) => (
<div className="px-6 py-10 text-center text-gray-600">
<Icon className="mx-auto h-12 w-12 text-gray-400" />
<h4 className="mt-4 text-base font-medium text-gray-900">{title}</h4>
<p className="mt-2 text-sm">{description}</p>
<span className="mt-3 inline-flex items-center px-2 py-1 text-xs font-medium rounded-full bg-gray-100 text-gray-700">
Coming soon
</span>
</div>
);
return (
<div className="space-y-6 mb-6">
<div className="bg-white shadow rounded-lg">{renderHeader()}</div>
{selectedService === "SIM" ? (
isSimService ? (
<SimManagementSection subscriptionId={subscriptionId} />
) : (
<div className="bg-white shadow rounded-lg p-6 text-center">
<DevicePhoneMobileIcon className="mx-auto h-12 w-12 text-gray-400" />
<h4 className="mt-2 text-sm font-medium text-gray-900">
SIM management not available
</h4>
<p className="mt-1 text-sm text-gray-500">
This subscription is not a SIM service.
</p>
</div>
)
) : selectedService === "INTERNET" ? (
<div className="bg-white shadow rounded-lg">
<ComingSoon
icon={GlobeAltIcon}
title="Internet Service Management"
description="Monitor bandwidth, change plans, and manage router settings."
/>
</div>
) : selectedService === "NETGEAR" ? (
<div className="bg-white shadow rounded-lg">
<ComingSoon
icon={ShieldCheckIcon}
title="Netgear Device Management"
description="View device status, firmware updates, and troubleshoot connectivity."
/>
</div>
) : (
<div className="bg-white shadow rounded-lg">
<ComingSoon
icon={LockClosedIcon}
title="VPN Service Management"
description="Manage VPN profiles, devices, and secure connection settings."
/>
</div>
)}
</div>
);
}

View File

@ -0,0 +1 @@
export { ServiceManagementSection } from './components/ServiceManagementSection';

View File

@ -0,0 +1,116 @@
"use client";
import React, { useState } from "react";
import { authenticatedApi } from "@/lib/api";
import { XMarkIcon } from "@heroicons/react/24/outline";
interface ChangePlanModalProps {
subscriptionId: number;
onClose: () => void;
onSuccess: () => void;
onError: (message: string) => void;
}
export function ChangePlanModal({ subscriptionId, onClose, onSuccess, onError }: ChangePlanModalProps) {
const [newPlanCode, setNewPlanCode] = useState("");
const [assignGlobalIp, setAssignGlobalIp] = useState(false);
const [scheduledAt, setScheduledAt] = useState(""); // YYYY-MM-DD
const [loading, setLoading] = useState(false);
const submit = async () => {
if (!newPlanCode.trim()) {
onError("Please enter a new plan code");
return;
}
setLoading(true);
try {
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/change-plan`, {
newPlanCode: newPlanCode.trim(),
assignGlobalIp,
scheduledAt: scheduledAt ? scheduledAt.replaceAll("-", "") : undefined,
});
onSuccess();
} catch (e: any) {
onError(e instanceof Error ? e.message : "Failed to change plan");
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
<div className="flex items-center justify-between">
<h3 className="text-lg leading-6 font-medium text-gray-900">Change SIM Plan</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<XMarkIcon className="h-5 w-5" />
</button>
</div>
<div className="mt-4 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">New Plan Code</label>
<input
type="text"
value={newPlanCode}
onChange={(e) => setNewPlanCode(e.target.value)}
placeholder="e.g. LTE3G_P01"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
/>
</div>
<div className="flex items-center">
<input
id="assignGlobalIp"
type="checkbox"
checked={assignGlobalIp}
onChange={(e) => setAssignGlobalIp(e.target.checked)}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<label htmlFor="assignGlobalIp" className="ml-2 block text-sm text-gray-700">
Assign global IP address
</label>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Schedule Date (optional)</label>
<input
type="date"
value={scheduledAt}
onChange={(e) => setScheduledAt(e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
/>
<p className="mt-1 text-xs text-gray-500">If empty, the plan change is processed immediately.</p>
</div>
</div>
</div>
</div>
</div>
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
type="button"
onClick={submit}
disabled={loading}
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50"
>
{loading ? "Processing..." : "Change Plan"}
</button>
<button
type="button"
onClick={onClose}
disabled={loading}
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
Cancel
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -23,12 +23,13 @@ interface DataUsageChartProps {
remainingQuotaMb: number;
isLoading?: boolean;
error?: string | null;
embedded?: boolean; // when true, render content without card container
}
export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error }: DataUsageChartProps) {
export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embedded = false }: DataUsageChartProps) {
const formatUsage = (usageMb: number) => {
if (usageMb >= 1024) {
return `${(usageMb / 1024).toFixed(2)} GB`;
return `${(usageMb / 1024).toFixed(1)} GB`;
}
return `${usageMb.toFixed(0)} MB`;
};
@ -49,7 +50,7 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error }: Da
if (isLoading) {
return (
<div className="bg-white shadow rounded-lg p-6">
<div className={`${embedded ? '' : 'bg-white shadow rounded-lg '}p-6`}>
<div className="animate-pulse">
<div className="h-6 bg-gray-200 rounded w-1/3 mb-4"></div>
<div className="h-4 bg-gray-200 rounded w-full mb-2"></div>
@ -65,7 +66,7 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error }: Da
if (error) {
return (
<div className="bg-white shadow rounded-lg p-6">
<div className={`${embedded ? '' : 'bg-white shadow rounded-lg '}p-6`}>
<div className="text-center">
<ExclamationTriangleIcon className="h-12 w-12 text-red-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">Error Loading Usage Data</h3>
@ -81,20 +82,22 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error }: Da
const usagePercentage = totalQuota > 0 ? (totalRecentUsage / totalQuota) * 100 : 0;
return (
<div className="bg-white shadow rounded-lg">
<div className={`${embedded ? '' : 'bg-white shadow-lg rounded-xl border border-gray-100 hover:shadow-xl transition-shadow duration-300'}`}>
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200">
<div className={`${embedded ? '' : 'px-6 lg:px-8 py-5 border-b border-gray-200'}`}>
<div className="flex items-center">
<ChartBarIcon className="h-6 w-6 text-blue-600 mr-3" />
<div className="bg-blue-50 rounded-xl p-2 mr-4">
<ChartBarIcon className="h-6 w-6 text-blue-600" />
</div>
<div>
<h3 className="text-lg font-medium text-gray-900">Data Usage</h3>
<p className="text-sm text-gray-500">Current month usage and remaining quota</p>
<h3 className="text-xl font-semibold text-gray-900">Data Usage</h3>
<p className="text-sm text-gray-600">Current month usage and remaining quota</p>
</div>
</div>
</div>
{/* Content */}
<div className="px-6 py-4">
<div className={`${embedded ? '' : 'px-6 lg:px-8 py-6'}`}>
{/* Current Usage Overview */}
<div className="mb-6">
<div className="flex justify-between items-center mb-2">
@ -122,19 +125,37 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error }: Da
</div>
{/* Today's Usage */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div className="bg-blue-50 rounded-lg p-4">
<div className="text-2xl font-bold text-blue-600">
{formatUsage(usage.todayUsageMb)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-8">
<div className="bg-gradient-to-br from-blue-50 to-blue-100 rounded-xl p-6 border border-blue-200">
<div className="flex items-center justify-between">
<div>
<div className="text-3xl font-bold text-blue-600">
{formatUsage(usage.todayUsageMb)}
</div>
<div className="text-sm font-medium text-blue-700 mt-1">Used today</div>
</div>
<div className="bg-blue-200 rounded-full p-3">
<svg className="h-6 w-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
</div>
</div>
<div className="text-sm text-blue-800">Used today</div>
</div>
<div className="bg-green-50 rounded-lg p-4">
<div className="text-2xl font-bold text-green-600">
{formatUsage(remainingQuotaMb)}
<div className="bg-gradient-to-br from-green-50 to-green-100 rounded-xl p-6 border border-green-200">
<div className="flex items-center justify-between">
<div>
<div className="text-3xl font-bold text-green-600">
{formatUsage(remainingQuotaMb)}
</div>
<div className="text-sm font-medium text-green-700 mt-1">Remaining</div>
</div>
<div className="bg-green-200 rounded-full p-3">
<svg className="h-6 w-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4m16 0l-4 4m4-4l-4-4" />
</svg>
</div>
</div>
<div className="text-sm text-green-800">Remaining</div>
</div>
</div>

View File

@ -9,6 +9,7 @@ import {
CheckCircleIcon
} from '@heroicons/react/24/outline';
import { TopUpModal } from './TopUpModal';
import { ChangePlanModal } from './ChangePlanModal';
import { authenticatedApi } from '@/lib/api';
interface SimActionsProps {
@ -19,6 +20,7 @@ interface SimActionsProps {
onPlanChangeSuccess?: () => void;
onCancelSuccess?: () => void;
onReissueSuccess?: () => void;
embedded?: boolean; // when true, render content without card container
}
export function SimActions({
@ -28,7 +30,8 @@ export function SimActions({
onTopUpSuccess,
onPlanChangeSuccess,
onCancelSuccess,
onReissueSuccess
onReissueSuccess,
embedded = false
}: SimActionsProps) {
const [showTopUpModal, setShowTopUpModal] = useState(false);
const [showCancelConfirm, setShowCancelConfirm] = useState(false);
@ -36,6 +39,7 @@ export function SimActions({
const [loading, setLoading] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [showChangePlanModal, setShowChangePlanModal] = useState(false);
const isActive = status === 'active';
const canTopUp = isActive;
@ -85,18 +89,28 @@ export function SimActions({
}, 5000);
return () => clearTimeout(timer);
}
return;
}, [success, error]);
return (
<div id="sim-actions" className="bg-white shadow rounded-lg">
<div id="sim-actions" className={`${embedded ? '' : 'bg-white shadow-lg rounded-xl border border-gray-100 hover:shadow-xl transition-shadow duration-300'}`}>
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">SIM Management Actions</h3>
<p className="text-sm text-gray-500 mt-1">Manage your SIM service</p>
<div className={`${embedded ? '' : 'px-6 lg:px-8 py-5 border-b border-gray-200'}`}>
<div className="flex items-center">
<div className="bg-blue-50 rounded-xl p-2 mr-4">
<svg className="h-6 w-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4" />
</svg>
</div>
<div>
<h3 className="text-xl font-semibold text-gray-900">SIM Management Actions</h3>
<p className="text-sm text-gray-600 mt-1">Manage your SIM service</p>
</div>
</div>
</div>
{/* Content */}
<div className="px-6 py-4">
<div className={`${embedded ? '' : 'px-6 lg:px-8 py-6'}`}>
{/* Status Messages */}
{success && (
<div className="mb-4 bg-green-50 border border-green-200 rounded-lg p-4">
@ -128,19 +142,23 @@ export function SimActions({
)}
{/* Action Buttons */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Top Up Data */}
<div className={`grid gap-4 ${embedded ? 'grid-cols-1' : 'grid-cols-2'}`}>
{/* Top Up Data - Primary Action */}
<button
onClick={() => setShowTopUpModal(true)}
disabled={!canTopUp || loading !== null}
className={`flex items-center justify-center px-4 py-3 border border-transparent rounded-lg text-sm font-medium transition-colors ${
className={`group relative flex items-center justify-center px-6 py-4 border rounded-xl text-sm font-semibold transition-all duration-200 ${
canTopUp && loading === null
? 'text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500'
: 'text-gray-400 bg-gray-100 cursor-not-allowed'
? 'text-blue-700 bg-blue-50 border-blue-200 hover:bg-blue-100 hover:border-blue-300 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500'
: 'text-gray-400 bg-gray-50 border-gray-200 cursor-not-allowed'
}`}
>
<PlusIcon className="h-4 w-4 mr-2" />
{loading === 'topup' ? 'Processing...' : 'Top Up Data'}
<div className="flex items-center">
<div className="bg-blue-100 rounded-lg p-1 mr-3">
<PlusIcon className="h-5 w-5 text-blue-600" />
</div>
<span>{loading === 'topup' ? 'Processing...' : 'Top Up Data'}</span>
</div>
</button>
{/* Reissue eSIM (only for eSIMs) */}
@ -148,29 +166,57 @@ export function SimActions({
<button
onClick={() => setShowReissueConfirm(true)}
disabled={!canReissue || loading !== null}
className={`flex items-center justify-center px-4 py-3 border border-transparent rounded-lg text-sm font-medium transition-colors ${
className={`group relative flex items-center justify-center px-6 py-4 border rounded-xl text-sm font-semibold transition-all duration-200 ${
canReissue && loading === null
? 'text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500'
: 'text-gray-400 bg-gray-100 cursor-not-allowed'
? 'text-green-700 bg-green-50 border-green-200 hover:bg-green-100 hover:border-green-300 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500'
: 'text-gray-400 bg-gray-50 border-gray-200 cursor-not-allowed'
}`}
>
<ArrowPathIcon className="h-4 w-4 mr-2" />
{loading === 'reissue' ? 'Processing...' : 'Reissue eSIM'}
<div className="flex items-center">
<div className="bg-green-100 rounded-lg p-1 mr-3">
<ArrowPathIcon className="h-5 w-5 text-green-600" />
</div>
<span>{loading === 'reissue' ? 'Processing...' : 'Reissue eSIM'}</span>
</div>
</button>
)}
{/* Cancel SIM */}
{/* Cancel SIM - Destructive Action */}
<button
onClick={() => setShowCancelConfirm(true)}
disabled={!canCancel || loading !== null}
className={`flex items-center justify-center px-4 py-3 border border-transparent rounded-lg text-sm font-medium transition-colors ${
className={`group relative flex items-center justify-center px-6 py-4 border rounded-xl text-sm font-semibold transition-all duration-200 ${
canCancel && loading === null
? 'text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500'
: 'text-gray-400 bg-gray-100 cursor-not-allowed'
? 'text-red-700 bg-red-50 border-red-200 hover:bg-red-100 hover:border-red-300 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500'
: 'text-gray-400 bg-gray-50 border-gray-200 cursor-not-allowed'
}`}
>
<XMarkIcon className="h-4 w-4 mr-2" />
{loading === 'cancel' ? 'Processing...' : 'Cancel SIM'}
<div className="flex items-center">
<div className="bg-red-100 rounded-lg p-1 mr-3">
<XMarkIcon className="h-5 w-5 text-red-600" />
</div>
<span>{loading === 'cancel' ? 'Processing...' : 'Cancel SIM'}</span>
</div>
</button>
{/* Change Plan - Secondary Action */}
<button
onClick={() => {/* Add change plan functionality */}}
disabled={!isActive || loading !== null}
className={`group relative flex items-center justify-center px-6 py-4 border-2 border-dashed rounded-xl text-sm font-semibold transition-all duration-200 ${
isActive && loading === null
? 'text-purple-700 bg-purple-50 border-purple-300 hover:bg-purple-100 hover:border-purple-400 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500'
: 'text-gray-400 bg-gray-50 border-gray-300 cursor-not-allowed'
}`}
>
<div className="flex items-center">
<div className="bg-purple-100 rounded-lg p-1 mr-3">
<svg className="h-5 w-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
</div>
<span>Change Plan</span>
</div>
</button>
</div>
@ -198,6 +244,15 @@ export function SimActions({
<strong>Cancel SIM:</strong> Permanently cancel your SIM service. This action cannot be undone and will terminate your service immediately.
</div>
</div>
<div className="flex items-start">
<svg className="h-4 w-4 text-purple-500 mr-2 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
<div>
<strong>Change Plan:</strong> Switch to a different data plan. <span className="text-red-600 font-medium">Important: Plan changes must be requested before the 25th of the month. Changes will take effect on the 1st of the following month.</span>
</div>
</div>
</div>
</div>
@ -215,6 +270,8 @@ export function SimActions({
/>
)}
{/* Change Plan handled in Feature Toggles */}
{/* Reissue eSIM Confirmation */}
{showReissueConfirm && (
<div className="fixed inset-0 z-50 overflow-y-auto">

View File

@ -42,9 +42,11 @@ interface SimDetailsCardProps {
simDetails: SimDetails;
isLoading?: boolean;
error?: string | null;
embedded?: boolean; // when true, render content without card container
showFeaturesSummary?: boolean; // show the right-side Service Features summary
}
export function SimDetailsCard({ simDetails, isLoading, error }: SimDetailsCardProps) {
export function SimDetailsCard({ simDetails, isLoading, error, embedded = false, showFeaturesSummary = true }: SimDetailsCardProps) {
const getStatusIcon = (status: string) => {
switch (status) {
case 'active':
@ -96,33 +98,36 @@ export function SimDetailsCard({ simDetails, isLoading, error }: SimDetailsCardP
};
if (isLoading) {
return (
<div className="bg-white shadow rounded-lg p-6">
const Skeleton = (
<div className={`${embedded ? '' : 'bg-white shadow-lg rounded-xl border border-gray-100 hover:shadow-xl transition-shadow duration-300 '}p-6 lg:p-8`}>
<div className="animate-pulse">
<div className="flex items-center space-x-4">
<div className="rounded-full bg-gray-200 h-12 w-12"></div>
<div className="flex-1 space-y-2">
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
<div className="rounded-full bg-gradient-to-br from-blue-200 to-blue-300 h-14 w-14"></div>
<div className="flex-1 space-y-3">
<div className="h-5 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-3/4"></div>
<div className="h-4 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-1/2"></div>
</div>
</div>
<div className="mt-6 space-y-3">
<div className="h-4 bg-gray-200 rounded"></div>
<div className="h-4 bg-gray-200 rounded w-5/6"></div>
<div className="h-4 bg-gray-200 rounded w-4/6"></div>
<div className="mt-8 space-y-4">
<div className="h-4 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg"></div>
<div className="h-4 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-5/6"></div>
<div className="h-4 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-4/6"></div>
</div>
</div>
</div>
);
return Skeleton;
}
if (error) {
return (
<div className="bg-white shadow rounded-lg p-6">
<div className={`${embedded ? '' : 'bg-white shadow-lg rounded-xl border border-red-100 '}p-6 lg:p-8`}>
<div className="text-center">
<ExclamationTriangleIcon className="h-12 w-12 text-red-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">Error Loading SIM Details</h3>
<p className="text-red-600">{error}</p>
<div className="bg-red-50 rounded-full p-3 w-16 h-16 mx-auto mb-4">
<ExclamationTriangleIcon className="h-10 w-10 text-red-500 mx-auto" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Error Loading SIM Details</h3>
<p className="text-red-600 text-sm">{error}</p>
</div>
</div>
);
@ -131,56 +136,81 @@ export function SimDetailsCard({ simDetails, isLoading, error }: SimDetailsCardP
// Specialized, minimal eSIM details view
if (simDetails.simType === 'esim') {
return (
<div className="bg-white shadow rounded-lg">
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex items-center justify-between">
<div className={`${embedded ? '' : 'bg-white shadow-lg rounded-xl border border-gray-100 hover:shadow-xl transition-shadow duration-300'}`}>
{/* Header */}
<div className={`${embedded ? '' : 'px-6 lg:px-8 py-5 border-b border-gray-200'}`}>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between space-y-3 sm:space-y-0">
<div className="flex items-center">
<WifiIcon className="h-8 w-8 text-blue-600 mr-3" />
<div className="bg-blue-50 rounded-xl p-2 mr-4">
<WifiIcon className="h-8 w-8 text-blue-600" />
</div>
<div>
<h3 className="text-lg font-medium text-gray-900">eSIM Details</h3>
<p className="text-sm text-gray-500">Current Plan: {simDetails.planCode}</p>
<h3 className="text-xl font-semibold text-gray-900">eSIM Details</h3>
<p className="text-sm text-gray-600 font-medium">Current Plan: {simDetails.planCode}</p>
</div>
</div>
<span className={`inline-flex px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(simDetails.status)}`}>
<span className={`inline-flex px-4 py-2 text-sm font-semibold rounded-full ${getStatusColor(simDetails.status)} self-start sm:self-auto`}>
{simDetails.status.charAt(0).toUpperCase() + simDetails.status.slice(1)}
</span>
</div>
</div>
<div className="px-6 py-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3">SIM Information</h4>
<div className="space-y-3">
<div>
<label className="text-xs text-gray-500">Phone Number</label>
<p className="text-sm font-medium text-gray-900">{simDetails.msisdn}</p>
<div className={`${embedded ? '' : 'px-6 lg:px-8 py-6'}`}>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="space-y-6">
<div>
<h4 className="text-sm font-semibold text-gray-700 uppercase tracking-wider mb-4 flex items-center">
<DevicePhoneMobileIcon className="h-4 w-4 mr-2 text-blue-500" />
SIM Information
</h4>
<div className="bg-gray-50 rounded-lg p-4">
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Phone Number</label>
<p className="text-lg font-semibold text-gray-900 mt-1">{simDetails.msisdn}</p>
</div>
</div>
</div>
<div>
<label className="text-xs text-gray-500">Data Remaining</label>
<p className="text-lg font-semibold text-green-600">{formatQuota(simDetails.remainingQuotaMb)}</p>
</div>
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Data Remaining</label>
<p className="text-2xl font-bold text-green-600 mt-1">{formatQuota(simDetails.remainingQuotaMb)}</p>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3">Service Features</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between"><span>Voice Mail (¥300/month)</span><span className="font-medium">{simDetails.voiceMailEnabled ? 'Enabled' : 'Disabled'}</span></div>
<div className="flex justify-between"><span>Call Waiting (¥300/month)</span><span className="font-medium">{simDetails.callWaitingEnabled ? 'Enabled' : 'Disabled'}</span></div>
<div className="flex justify-between"><span>International Roaming</span><span className="font-medium">{simDetails.internationalRoamingEnabled ? 'Enabled' : 'Disabled'}</span></div>
<div className="flex justify-between"><span>4G/5G</span><span className="font-medium">{simDetails.networkType || '5G'}</span></div>
{showFeaturesSummary && (
<div>
<h4 className="text-sm font-semibold text-gray-700 uppercase tracking-wider mb-4 flex items-center">
<CheckCircleIcon className="h-4 w-4 mr-2 text-green-500" />
Service Features
</h4>
<div className="space-y-3">
<div className="flex justify-between items-center py-2 px-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-700">Voice Mail (¥300/month)</span>
<span className={`text-sm font-semibold px-2 py-1 rounded-full ${simDetails.voiceMailEnabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'}`}>
{simDetails.voiceMailEnabled ? 'Enabled' : 'Disabled'}
</span>
</div>
<div className="flex justify-between items-center py-2 px-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-700">Call Waiting (¥300/month)</span>
<span className={`text-sm font-semibold px-2 py-1 rounded-full ${simDetails.callWaitingEnabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'}`}>
{simDetails.callWaitingEnabled ? 'Enabled' : 'Disabled'}
</span>
</div>
<div className="flex justify-between items-center py-2 px-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-700">International Roaming</span>
<span className={`text-sm font-semibold px-2 py-1 rounded-full ${simDetails.internationalRoamingEnabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'}`}>
{simDetails.internationalRoamingEnabled ? 'Enabled' : 'Disabled'}
</span>
</div>
<div className="flex justify-between items-center py-2 px-3 bg-blue-50 rounded-lg">
<span className="text-sm text-gray-700">4G/5G</span>
<span className="text-sm font-semibold px-2 py-1 rounded-full bg-blue-100 text-blue-800">
{simDetails.networkType || '5G'}
</span>
</div>
</div>
</div>
</div>
</div>
{/* Plan quick action */}
<div className="mt-6 pt-4 border-t border-gray-200 flex items-center justify-between">
<div className="text-sm text-gray-600">Current Plan: <span className="font-medium text-gray-900">{simDetails.planCode}</span></div>
<a href="#sim-actions" className="inline-flex items-center px-3 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50">
Change Plan
</a>
)}
</div>
</div>
</div>
@ -188,20 +218,18 @@ export function SimDetailsCard({ simDetails, isLoading, error }: SimDetailsCardP
}
return (
<div className="bg-white shadow rounded-lg">
<div className={`${embedded ? '' : 'bg-white shadow rounded-lg'}`}>
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200">
<div className={`${embedded ? '' : 'px-6 py-4 border-b border-gray-200'}`}>
<div className="flex items-center justify-between">
<div className="flex items-center">
<div className="text-2xl mr-3">
{simDetails.simType === 'esim' ? <WifiIcon className="h-8 w-8 text-blue-600" /> : <DevicePhoneMobileIcon className="h-8 w-8 text-blue-600" />}
<DevicePhoneMobileIcon className="h-8 w-8 text-blue-600" />
</div>
<div>
<h3 className="text-lg font-medium text-gray-900">
{simDetails.simType === 'esim' ? 'eSIM Details' : 'Physical SIM Details'}
</h3>
<h3 className="text-lg font-medium text-gray-900">Physical SIM Details</h3>
<p className="text-sm text-gray-500">
{simDetails.planCode} {simDetails.simType === 'physical' ? `${simDetails.size} SIM` : 'eSIM'}
{simDetails.planCode} {`${simDetails.size} SIM`}
</p>
</div>
</div>
@ -215,7 +243,7 @@ export function SimDetailsCard({ simDetails, isLoading, error }: SimDetailsCardP
</div>
{/* Content */}
<div className="px-6 py-4">
<div className={`${embedded ? '' : 'px-6 py-4'}`}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* SIM Information */}
<div>
@ -259,46 +287,48 @@ export function SimDetailsCard({ simDetails, isLoading, error }: SimDetailsCardP
</div>
{/* Service Features */}
<div>
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3">
Service Features
</h4>
<div className="space-y-3">
<div>
<label className="text-xs text-gray-500">Data Remaining</label>
<p className="text-lg font-semibold text-green-600">{formatQuota(simDetails.remainingQuotaMb)}</p>
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center">
<SignalIcon className={`h-4 w-4 mr-1 ${simDetails.hasVoice ? 'text-green-500' : 'text-gray-400'}`} />
<span className={`text-sm ${simDetails.hasVoice ? 'text-green-600' : 'text-gray-500'}`}>
Voice {simDetails.hasVoice ? 'Enabled' : 'Disabled'}
</span>
</div>
<div className="flex items-center">
<DevicePhoneMobileIcon className={`h-4 w-4 mr-1 ${simDetails.hasSms ? 'text-green-500' : 'text-gray-400'}`} />
<span className={`text-sm ${simDetails.hasSms ? 'text-green-600' : 'text-gray-500'}`}>
SMS {simDetails.hasSms ? 'Enabled' : 'Disabled'}
</span>
</div>
</div>
{(simDetails.ipv4 || simDetails.ipv6) && (
{showFeaturesSummary && (
<div>
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3">
Service Features
</h4>
<div className="space-y-3">
<div>
<label className="text-xs text-gray-500">IP Address</label>
<div className="space-y-1">
{simDetails.ipv4 && (
<p className="text-sm font-mono text-gray-900">IPv4: {simDetails.ipv4}</p>
)}
{simDetails.ipv6 && (
<p className="text-sm font-mono text-gray-900">IPv6: {simDetails.ipv6}</p>
)}
<label className="text-xs text-gray-500">Data Remaining</label>
<p className="text-lg font-semibold text-green-600">{formatQuota(simDetails.remainingQuotaMb)}</p>
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center">
<SignalIcon className={`h-4 w-4 mr-1 ${simDetails.hasVoice ? 'text-green-500' : 'text-gray-400'}`} />
<span className={`text-sm ${simDetails.hasVoice ? 'text-green-600' : 'text-gray-500'}`}>
Voice {simDetails.hasVoice ? 'Enabled' : 'Disabled'}
</span>
</div>
<div className="flex items-center">
<DevicePhoneMobileIcon className={`h-4 w-4 mr-1 ${simDetails.hasSms ? 'text-green-500' : 'text-gray-400'}`} />
<span className={`text-sm ${simDetails.hasSms ? 'text-green-600' : 'text-gray-500'}`}>
SMS {simDetails.hasSms ? 'Enabled' : 'Disabled'}
</span>
</div>
</div>
)}
{(simDetails.ipv4 || simDetails.ipv6) && (
<div>
<label className="text-xs text-gray-500">IP Address</label>
<div className="space-y-1">
{simDetails.ipv4 && (
<p className="text-sm font-mono text-gray-900">IPv4: {simDetails.ipv4}</p>
)}
{simDetails.ipv6 && (
<p className="text-sm font-mono text-gray-900">IPv6: {simDetails.ipv6}</p>
)}
</div>
</div>
)}
</div>
</div>
</div>
)}
</div>
{/* Pending Operations */}

View File

@ -0,0 +1,329 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import { authenticatedApi } from "@/lib/api";
import type { SimPlan } from "@/shared/types/catalog.types";
interface SimFeatureTogglesProps {
subscriptionId: number;
voiceMailEnabled?: boolean;
callWaitingEnabled?: boolean;
internationalRoamingEnabled?: boolean;
networkType?: string; // '4G' | '5G'
currentPlanCode?: string;
onChanged?: () => void;
}
export function SimFeatureToggles({
subscriptionId,
voiceMailEnabled,
callWaitingEnabled,
internationalRoamingEnabled,
networkType,
currentPlanCode,
onChanged,
}: SimFeatureTogglesProps) {
// Initial values
const initial = useMemo(() => ({
vm: !!voiceMailEnabled,
cw: !!callWaitingEnabled,
ir: !!internationalRoamingEnabled,
nt: networkType === '5G' ? '5G' : '4G',
plan: currentPlanCode || '',
}), [voiceMailEnabled, callWaitingEnabled, internationalRoamingEnabled, networkType, currentPlanCode]);
// Working values
const [vm, setVm] = useState(initial.vm);
const [cw, setCw] = useState(initial.cw);
const [ir, setIr] = useState(initial.ir);
const [nt, setNt] = useState<'4G' | '5G'>(initial.nt as '4G' | '5G');
const [plan, setPlan] = useState(initial.plan);
// Plans list
const [plans, setPlans] = useState<SimPlan[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
useEffect(() => {
setVm(initial.vm);
setCw(initial.cw);
setIr(initial.ir);
setNt(initial.nt as '4G' | '5G');
setPlan(initial.plan);
}, [initial.vm, initial.cw, initial.ir, initial.nt, initial.plan]);
useEffect(() => {
let ignore = false;
(async () => {
try {
const data = await authenticatedApi.get<SimPlan[]>("/catalog/sim/plans");
if (!ignore) setPlans(data);
} catch (e) {
// silent; leave plans empty
}
})();
return () => { ignore = true; };
}, []);
const reset = () => {
setVm(initial.vm);
setCw(initial.cw);
setIr(initial.ir);
setNt(initial.nt as '4G' | '5G');
setPlan(initial.plan);
setError(null);
setSuccess(null);
};
const applyChanges = async () => {
setLoading(true);
setError(null);
setSuccess(null);
try {
const featurePayload: any = {};
if (vm !== initial.vm) featurePayload.voiceMailEnabled = vm;
if (cw !== initial.cw) featurePayload.callWaitingEnabled = cw;
if (ir !== initial.ir) featurePayload.internationalRoamingEnabled = ir;
if (nt !== initial.nt) featurePayload.networkType = nt;
if (Object.keys(featurePayload).length > 0) {
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/features`, featurePayload);
}
if (plan && plan !== initial.plan) {
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/change-plan`, { newPlanCode: plan });
}
setSuccess('Changes submitted successfully');
onChanged?.();
} catch (e: any) {
setError(e instanceof Error ? e.message : 'Failed to submit changes');
} finally {
setLoading(false);
setTimeout(() => setSuccess(null), 3000);
}
};
return (
<div className="space-y-6">
{/* Service Options */}
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div className="p-6 space-y-6">
{/* Voice Mail */}
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between space-y-4 lg:space-y-0 p-4 bg-gray-50 rounded-lg border border-gray-200">
<div className="flex-1">
<div className="flex items-center space-x-3">
<div className="bg-blue-100 rounded-lg p-2">
<svg className="h-4 w-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
</div>
<div>
<div className="text-sm font-semibold text-gray-900">Voice Mail</div>
<div className="text-xs text-gray-600">¥300/month</div>
</div>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="text-sm">
<span className="text-gray-500">Current: </span>
<span className={`font-medium ${initial.vm ? 'text-green-600' : 'text-gray-600'}`}>
{initial.vm ? 'Enabled' : 'Disabled'}
</span>
</div>
<div className="text-gray-400"></div>
<select
value={vm ? 'Enabled' : 'Disabled'}
onChange={(e) => setVm(e.target.value === 'Enabled')}
className="block w-32 rounded-lg border border-gray-200 bg-white text-sm text-gray-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none transition-colors hover:border-gray-300"
>
<option>Disabled</option>
<option>Enabled</option>
</select>
</div>
</div>
{/* Call Waiting */}
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between space-y-4 lg:space-y-0 p-4 bg-gray-50 rounded-lg border border-gray-200">
<div className="flex-1">
<div className="flex items-center space-x-3">
<div className="bg-purple-100 rounded-lg p-2">
<svg className="h-4 w-4 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<div className="text-sm font-semibold text-gray-900">Call Waiting</div>
<div className="text-xs text-gray-600">¥300/month</div>
</div>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="text-sm">
<span className="text-gray-500">Current: </span>
<span className={`font-medium ${initial.cw ? 'text-green-600' : 'text-gray-600'}`}>
{initial.cw ? 'Enabled' : 'Disabled'}
</span>
</div>
<div className="text-gray-400"></div>
<select
value={cw ? 'Enabled' : 'Disabled'}
onChange={(e) => setCw(e.target.value === 'Enabled')}
className="block w-32 rounded-lg border border-gray-200 bg-white text-sm text-gray-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none transition-colors hover:border-gray-300"
>
<option>Disabled</option>
<option>Enabled</option>
</select>
</div>
</div>
{/* International Roaming */}
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between space-y-4 lg:space-y-0 p-4 bg-gray-50 rounded-lg border border-gray-200">
<div className="flex-1">
<div className="flex items-center space-x-3">
<div className="bg-green-100 rounded-lg p-2">
<svg className="h-4 w-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<div className="text-sm font-semibold text-gray-900">International Roaming</div>
<div className="text-xs text-gray-600">Global connectivity</div>
</div>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="text-sm">
<span className="text-gray-500">Current: </span>
<span className={`font-medium ${initial.ir ? 'text-green-600' : 'text-gray-600'}`}>
{initial.ir ? 'Enabled' : 'Disabled'}
</span>
</div>
<div className="text-gray-400"></div>
<select
value={ir ? 'Enabled' : 'Disabled'}
onChange={(e) => setIr(e.target.value === 'Enabled')}
className="block w-32 rounded-lg border border-gray-200 bg-white text-sm text-gray-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none transition-colors hover:border-gray-300"
>
<option>Disabled</option>
<option>Enabled</option>
</select>
</div>
</div>
{/* Network Type */}
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between space-y-4 lg:space-y-0 p-4 bg-gray-50 rounded-lg border border-gray-200">
<div className="flex-1">
<div className="flex items-center space-x-3">
<div className="bg-orange-100 rounded-lg p-2">
<svg className="h-4 w-4 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
</svg>
</div>
<div>
<div className="text-sm font-semibold text-gray-900">Network Type</div>
<div className="text-xs text-gray-600">4G/5G connectivity</div>
</div>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="text-sm">
<span className="text-gray-500">Current: </span>
<span className="font-medium text-blue-600">{initial.nt}</span>
</div>
<div className="text-gray-400"></div>
<select
value={nt}
onChange={(e) => setNt(e.target.value as '4G' | '5G')}
className="block w-20 rounded-lg border border-gray-200 bg-white text-sm text-gray-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none transition-colors hover:border-gray-300"
>
<option value="4G">4G</option>
<option value="5G">5G</option>
</select>
</div>
</div>
</div>
</div>
{/* Notes and Actions */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
<div className="flex items-start">
<svg className="h-5 w-5 text-yellow-600 mt-0.5 mr-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="space-y-2 text-sm text-yellow-800">
<p><strong>Important Notes:</strong></p>
<ul className="list-disc list-inside space-y-1 ml-4">
<li>Changes will take effect instantaneously (approx. 30min)</li>
<li>May require smartphone/device restart after changes are applied</li>
<li>5G requires a compatible smartphone/device. Will not function on 4G devices</li>
<li>Changes to Voice Mail / Call Waiting must be requested before the 25th of the month</li>
</ul>
</div>
</div>
</div>
{success && (
<div className="mb-4 bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-center">
<svg className="h-5 w-5 text-green-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-sm font-medium text-green-800">{success}</p>
</div>
</div>
)}
{error && (
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-center">
<svg className="h-5 w-5 text-red-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<p className="text-sm font-medium text-red-800">{error}</p>
</div>
</div>
)}
<div className="flex flex-col sm:flex-row gap-3">
<button
onClick={applyChanges}
disabled={loading}
className="flex-1 inline-flex items-center justify-center px-6 py-3 border border-transparent rounded-lg text-sm font-semibold text-white bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
>
{loading ? (
<>
<svg className="animate-spin -ml-1 mr-3 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" className="opacity-25"></circle>
<path fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" className="opacity-75"></path>
</svg>
Applying Changes...
</>
) : (
<>
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Apply Changes
</>
)}
</button>
<button
onClick={reset}
disabled={loading}
className="inline-flex items-center justify-center px-6 py-3 border border-gray-300 rounded-lg text-sm font-semibold text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Reset
</button>
</div>
</div>
</div>
);
}

View File

@ -10,6 +10,7 @@ import { SimDetailsCard, type SimDetails } from './SimDetailsCard';
import { DataUsageChart, type SimUsage } from './DataUsageChart';
import { SimActions } from './SimActions';
import { authenticatedApi } from '@/lib/api';
import { SimFeatureToggles } from './SimFeatureToggles';
interface SimManagementSectionProps {
subscriptionId: number;
@ -63,16 +64,25 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
if (loading) {
return (
<div className="space-y-6">
<div className="bg-white shadow rounded-lg p-6">
<div className="flex items-center mb-4">
<DevicePhoneMobileIcon className="h-6 w-6 text-blue-600 mr-3" />
<h2 className="text-xl font-semibold text-gray-900">SIM Management</h2>
<div className="space-y-8">
<div className="bg-white shadow-lg rounded-xl border border-gray-100 p-8">
<div className="flex items-center mb-6">
<div className="bg-blue-50 rounded-xl p-2 mr-4">
<DevicePhoneMobileIcon className="h-6 w-6 text-blue-600" />
</div>
<div>
<h2 className="text-2xl font-bold text-gray-900">SIM Management</h2>
<p className="text-gray-600 mt-1">Loading your SIM service details...</p>
</div>
</div>
<div className="animate-pulse space-y-4">
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
<div className="h-32 bg-gray-200 rounded"></div>
<div className="animate-pulse space-y-6">
<div className="h-6 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-3/4"></div>
<div className="h-5 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-1/2"></div>
<div className="h-48 bg-gradient-to-r from-gray-200 to-gray-300 rounded-xl"></div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="h-32 bg-gradient-to-r from-gray-200 to-gray-300 rounded-xl"></div>
<div className="h-32 bg-gradient-to-r from-gray-200 to-gray-300 rounded-xl"></div>
</div>
</div>
</div>
</div>
@ -81,20 +91,27 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
if (error) {
return (
<div className="bg-white shadow rounded-lg p-6">
<div className="flex items-center mb-4">
<DevicePhoneMobileIcon className="h-6 w-6 text-blue-600 mr-3" />
<h2 className="text-xl font-semibold text-gray-900">SIM Management</h2>
<div className="bg-white shadow-lg rounded-xl border border-red-100 p-8">
<div className="flex items-center mb-6">
<div className="bg-blue-50 rounded-xl p-2 mr-4">
<DevicePhoneMobileIcon className="h-6 w-6 text-blue-600" />
</div>
<div>
<h2 className="text-2xl font-bold text-gray-900">SIM Management</h2>
<p className="text-gray-600 mt-1">Unable to load SIM information</p>
</div>
</div>
<div className="text-center py-8">
<ExclamationTriangleIcon className="h-12 w-12 text-red-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">Unable to Load SIM Information</h3>
<p className="text-gray-600 mb-4">{error}</p>
<div className="text-center py-12">
<div className="bg-red-50 rounded-full p-4 w-20 h-20 mx-auto mb-6">
<ExclamationTriangleIcon className="h-12 w-12 text-red-500 mx-auto" />
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-3">Unable to Load SIM Information</h3>
<p className="text-gray-600 mb-8 max-w-md mx-auto">{error}</p>
<button
onClick={handleRefresh}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
className="inline-flex items-center px-6 py-3 border border-transparent text-sm font-semibold rounded-xl text-white bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 hover:shadow-lg hover:scale-105 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200"
>
<ArrowPathIcon className="h-4 w-4 mr-2" />
<ArrowPathIcon className="h-5 w-5 mr-2" />
Retry
</button>
</div>
@ -107,65 +124,101 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
}
return (
<div className="space-y-6">
{/* Section Header */}
<div className="bg-white shadow rounded-lg p-6">
<div className="flex items-center justify-between">
<div className="flex items-center">
<DevicePhoneMobileIcon className="h-6 w-6 text-blue-600 mr-3" />
<div>
<h2 className="text-xl font-semibold text-gray-900">SIM Management</h2>
<p className="text-gray-600">Manage your SIM service and data usage</p>
<div id="sim-management" className="space-y-8">
{/* SIM Details and Usage - Main Content */}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
{/* Main Content Area - Actions and Settings (Left Side) */}
<div className="xl:col-span-2 xl:order-1 space-y-8">
{/* SIM Management Actions */}
<SimActions
subscriptionId={subscriptionId}
simType={simInfo.details.simType}
status={simInfo.details.status}
onTopUpSuccess={handleActionSuccess}
onPlanChangeSuccess={handleActionSuccess}
onCancelSuccess={handleActionSuccess}
onReissueSuccess={handleActionSuccess}
embedded={false}
/>
{/* Plan Settings Card */}
<div className="bg-white shadow-lg rounded-xl border border-gray-100 p-6">
<div className="flex items-center mb-6">
<div className="bg-green-50 rounded-xl p-2 mr-3">
<svg className="h-5 w-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">Plan Settings</h3>
<p className="text-sm text-gray-600">Modify service options</p>
</div>
</div>
<SimFeatureToggles
subscriptionId={subscriptionId}
voiceMailEnabled={simInfo.details.voiceMailEnabled}
callWaitingEnabled={simInfo.details.callWaitingEnabled}
internationalRoamingEnabled={simInfo.details.internationalRoamingEnabled}
networkType={simInfo.details.networkType}
currentPlanCode={simInfo.details.planCode}
onChanged={handleActionSuccess}
/>
</div>
<button
onClick={handleRefresh}
disabled={loading}
className="inline-flex items-center px-3 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
<ArrowPathIcon className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
</div>
{/* SIM Details */}
<SimDetailsCard
simDetails={simInfo.details}
isLoading={false}
error={null}
/>
{/* Sidebar - Compact Info (Right Side) */}
<div className="xl:order-2 space-y-8">
{/* Important Information Card */}
<div className="bg-gradient-to-br from-blue-50 to-blue-100 border border-blue-200 rounded-xl p-6">
<div className="flex items-center mb-4">
<div className="bg-blue-200 rounded-lg p-2 mr-3">
<svg className="h-5 w-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-blue-900">Important Information</h3>
</div>
<ul className="space-y-2 text-sm text-blue-800">
<li className="flex items-start">
<span className="inline-block w-1.5 h-1.5 bg-blue-600 rounded-full mt-2 mr-3 flex-shrink-0"></span>
Data usage is updated in real-time and may take a few minutes to reflect recent activity
</li>
<li className="flex items-start">
<span className="inline-block w-1.5 h-1.5 bg-blue-600 rounded-full mt-2 mr-3 flex-shrink-0"></span>
Top-up data will be available immediately after successful processing
</li>
<li className="flex items-start">
<span className="inline-block w-1.5 h-1.5 bg-blue-600 rounded-full mt-2 mr-3 flex-shrink-0"></span>
SIM cancellation is permanent and cannot be undone
</li>
{simInfo.details.simType === 'esim' && (
<li className="flex items-start">
<span className="inline-block w-1.5 h-1.5 bg-blue-600 rounded-full mt-2 mr-3 flex-shrink-0"></span>
eSIM profile reissue will provide a new QR code for activation
</li>
)}
</ul>
</div>
{/* Data Usage */}
<DataUsageChart
usage={simInfo.usage}
remainingQuotaMb={simInfo.details.remainingQuotaMb}
isLoading={false}
error={null}
/>
{/* SIM Actions */}
<SimActions
subscriptionId={subscriptionId}
simType={simInfo.details.simType}
status={simInfo.details.status}
onTopUpSuccess={handleActionSuccess}
onPlanChangeSuccess={handleActionSuccess}
onCancelSuccess={handleActionSuccess}
onReissueSuccess={handleActionSuccess}
/>
{/* Additional Information */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 className="text-sm font-medium text-blue-800 mb-2">Important Information</h3>
<ul className="text-sm text-blue-700 space-y-1">
<li> Data usage is updated in real-time and may take a few minutes to reflect recent activity</li>
<li> Top-up data will be available immediately after successful processing</li>
<li> SIM cancellation is permanent and cannot be undone</li>
{simInfo.details.simType === 'esim' && (
<li> eSIM profile reissue will provide a new QR code for activation</li>
)}
</ul>
<SimDetailsCard
simDetails={simInfo.details}
isLoading={false}
error={null}
embedded={true}
showFeaturesSummary={false}
/>
<DataUsageChart
usage={simInfo.usage}
remainingQuotaMb={simInfo.details.remainingQuotaMb}
isLoading={false}
error={null}
embedded={true}
/>
</div>
</div>
</div>
);

View File

@ -3,6 +3,7 @@ export { SimDetailsCard } from './components/SimDetailsCard';
export { DataUsageChart } from './components/DataUsageChart';
export { SimActions } from './components/SimActions';
export { TopUpModal } from './components/TopUpModal';
export { SimFeatureToggles } from './components/SimFeatureToggles';
export type { SimDetails } from './components/SimDetailsCard';
export type { SimUsage } from './components/DataUsageChart';

View File

@ -6,9 +6,14 @@
This document outlines the complete implementation of Freebit SIM management features, including backend API integration, frontend UI components, and Salesforce data tracking requirements.
Where to find it in the portal:
- Subscriptions > [Subscription] > SIM Management section on the page
- Direct link from sidebar goes to `#sim-management` anchor
- Component: `apps/portal/src/features/sim-management/components/SimManagementSection.tsx`
**Last Updated**: January 2025
**Implementation Status**: ✅ Complete and Deployed
**Total Development Sessions**: 2 (GPT-4 + Claude Sonnet 4)
**Latest Updates**: Enhanced UI/UX design, improved layout, and streamlined interface
## 🏗️ Implementation Summary
@ -31,6 +36,10 @@ This document outlines the complete implementation of Freebit SIM management fea
- ✅ Integrated into subscription detail page
- ✅ **Fixed**: Updated all components to use `authenticatedApi` utility
- ✅ **Fixed**: Proper API routing to BFF (port 4000) instead of frontend (port 3000)
- ✅ **Enhanced**: Modern responsive layout with 2/3 + 1/3 grid structure
- ✅ **Enhanced**: Soft color scheme matching website design language
- ✅ **Enhanced**: Improved dropdown styling and form consistency
- ✅ **Enhanced**: Streamlined service options interface
3. **Features Implemented**
- ✅ View SIM details (ICCID, MSISDN, plan, status)
@ -124,10 +133,33 @@ apps/portal/src/features/sim-management/
│ ├── SimDetailsCard.tsx # SIM information display
│ ├── DataUsageChart.tsx # Usage visualization
│ ├── SimActions.tsx # Action buttons and confirmations
│ ├── SimFeatureToggles.tsx # Service options (Voice Mail, Call Waiting, etc.)
│ └── TopUpModal.tsx # Data top-up interface
└── index.ts # Exports
```
### Current Layout Structure
```
┌─────────────────────────────────────────────────────────────┐
│ Subscription Detail Page │
│ (max-w-7xl container) │
├─────────────────────────────────────────────────────────────┤
│ Left Side (2/3 width) │ Right Side (1/3 width) │
│ ┌─────────────────────────┐ │ ┌─────────────────────┐ │
│ │ SIM Management Actions │ │ │ Important Info │ │
│ │ (2x2 button grid) │ │ │ (notices & warnings)│ │
│ └─────────────────────────┘ │ └─────────────────────┘ │
│ ┌─────────────────────────┐ │ ┌─────────────────────┐ │
│ │ Plan Settings │ │ │ eSIM Details │ │
│ │ (Service Options) │ │ │ (compact view) │ │
│ └─────────────────────────┘ │ └─────────────────────┘ │
│ │ ┌─────────────────────┐ │
│ │ │ Data Usage Chart │ │
│ │ │ (compact view) │ │
│ │ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
### Features
- **Responsive Design**: Works on desktop and mobile
- **Real-time Updates**: Automatic refresh after actions
@ -135,6 +167,57 @@ apps/portal/src/features/sim-management/
- **Error Handling**: Comprehensive error messages and recovery
- **Accessibility**: Proper ARIA labels and keyboard navigation
## 🎨 Recent UI/UX Enhancements (January 2025)
### Layout Improvements
- **Wider Container**: Changed from `max-w-4xl` to `max-w-7xl` to match subscriptions page width
- **Optimized Grid Layout**: 2/3 + 1/3 responsive grid for better content distribution
- **Left Side (2/3 width)**: SIM Management Actions + Plan Settings (content-heavy sections)
- **Right Side (1/3 width)**: Important Information + eSIM Details + Data Usage (compact info)
- **Mobile-First Design**: Stacks vertically on smaller screens, horizontal on desktop
### Visual Design Updates
- **Soft Color Scheme**: Replaced solid gradients with website-consistent soft colors
- **Top Up Data**: Blue theme (`bg-blue-50`, `text-blue-700`, `border-blue-200`)
- **Reissue eSIM**: Green theme (`bg-green-50`, `text-green-700`, `border-green-200`)
- **Cancel SIM**: Red theme (`bg-red-50`, `text-red-700`, `border-red-200`)
- **Change Plan**: Purple theme (`bg-purple-50`, `text-purple-700`, `border-purple-300`)
- **Enhanced Dropdowns**: Consistent styling with subtle borders and focus states
- **Improved Cards**: Better shadows, spacing, and visual hierarchy
### Interface Streamlining
- **Removed Plan Management Section**: Consolidated plan change info into action descriptions
- **Removed Service Options Header**: Cleaner, more focused interface
- **Enhanced Action Descriptions**: Added important notices and timing information
- **Important Information Repositioned**: Moved to top of right sidebar for better visibility
### User Experience Improvements
- **2x2 Action Button Grid**: Better organization and space utilization
- **Consistent Icon Usage**: Color-coded icons with background containers
- **Better Information Hierarchy**: Important notices prominently displayed
- **Improved Form Styling**: Modern dropdowns and form elements
### Action Descriptions & Important Notices
The SIM Management Actions now include comprehensive descriptions with important timing information:
- **Top Up Data**: Add additional data quota with scheduling options
- **Reissue eSIM**: Generate new QR code for eSIM profile (eSIM only)
- **Cancel SIM**: Permanently cancel service (cannot be undone)
- **Change Plan**: Switch data plans with **important timing notice**:
- "Important: Plan changes must be requested before the 25th of the month. Changes will take effect on the 1st of the following month."
### Service Options Interface
The Plan Settings section includes streamlined service options:
- **Voice Mail** (¥300/month): Enable/disable with current status display
- **Call Waiting** (¥300/month): Enable/disable with current status display
- **International Roaming**: Global connectivity options
- **Network Type**: 4G/5G connectivity selection
Each option shows:
- Current status with color-coded indicators
- Clean dropdown for status changes
- Consistent styling with website design
## 🗄️ Required Salesforce Custom Fields
To enable proper SIM data tracking in Salesforce, add these custom fields:

View File

@ -0,0 +1,46 @@
# Subscription Service Management
Guidance for the unified Service Management area in the Subscriptions detail page. This area provides a dropdown to switch between different service types for a given subscription.
- Location: `Subscriptions > [Subscription] > Service Management`
- Selector: Service dropdown with options: `SIM`, `Internet`, `Netgear`, `VPN`
- Current status: `SIM` available now; others are placeholders (coming soon)
## UI Structure
```
apps/portal/src/features/service-management/
├── components/
│ └── ServiceManagementSection.tsx # Container with service dropdown
└── index.ts
```
- Header: Title + description, service dropdown selector
- Body: Renders the active service panel
- Default selection: `SIM` for SIM products; otherwise `Internet`
## Service Panels
- SIM: Renders the existing SIM management UI
- Source: `apps/portal/src/features/sim-management/components/SimManagementSection.tsx`
- Backend: `/api/subscriptions/{id}/sim/*`
- Internet: Placeholder (coming soon)
- Netgear: Placeholder (coming soon)
- VPN: Placeholder (coming soon)
## Integration
- Entry point: `apps/portal/src/app/subscriptions/[id]/page.tsx` renders `ServiceManagementSection`
- Detection: SIM availability is inferred from `subscription.productName` including `sim` (case-insensitive)
## Future Expansion
- Replace placeholders with actual feature modules per service type
- Gate options per subscription capabilities (disable/hide unsupported services)
- Deep-linking: support `?service=sim|internet|netgear|vpn` to preselect a panel
- Telemetry: track panel usage and feature adoption
## Notes
- This structure avoids breaking changes to the existing SIM workflow while preparing a clean surface for additional services.
- SIM documentation remains at `docs/FREEBIT-SIM-MANAGEMENT.md` and is unchanged functionally.