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 toDate: string; // YYYYMMDD
} }
export interface SimFeaturesUpdateRequest {
voiceMailEnabled?: boolean;
callWaitingEnabled?: boolean;
internationalRoamingEnabled?: boolean;
networkType?: '4G' | '5G';
}
@Injectable() @Injectable()
export class SimManagementService { export class SimManagementService {
constructor( 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 * Cancel SIM service
*/ */

View File

@ -388,4 +388,38 @@ export class SubscriptionsController {
await this.simManagementService.reissueEsimProfile(req.user.id, subscriptionId); await this.simManagementService.reissueEsimProfile(req.user.id, subscriptionId);
return { success: true, message: "eSIM profile reissue completed successfully" }; 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, SimDetails,
SimUsage, SimUsage,
SimTopUpHistory, SimTopUpHistory,
FreebititError FreebititError,
FreebititAddSpecRequest,
FreebititAddSpecResponse
} from './interfaces/freebit.types'; } from './interfaces/freebit.types';
@Injectable() @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 * 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 { export interface FreebititQuotaHistoryRequest {
authKey: string; authKey: string;
account: string; account: string;

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useState } from "react";
import { useParams } from "next/navigation"; import { useParams, useSearchParams } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { DashboardLayout } from "@/components/layout/dashboard-layout"; import { DashboardLayout } from "@/components/layout/dashboard-layout";
import { import {
@ -22,8 +22,11 @@ import { SimManagementSection } from "@/features/sim-management";
export default function SubscriptionDetailPage() { export default function SubscriptionDetailPage() {
const params = useParams(); const params = useParams();
const searchParams = useSearchParams();
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10; const itemsPerPage = 10;
const [showInvoices, setShowInvoices] = useState(true);
const [showSimManagement, setShowSimManagement] = useState(false);
const subscriptionId = parseInt(params.id as string); const subscriptionId = parseInt(params.id as string);
const { data: subscription, isLoading, error } = useSubscription(subscriptionId); const { data: subscription, isLoading, error } = useSubscription(subscriptionId);
@ -36,6 +39,31 @@ export default function SubscriptionDetailPage() {
const invoices = invoiceData?.invoices || []; const invoices = invoiceData?.invoices || [];
const pagination = invoiceData?.pagination; 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) => { const getStatusIcon = (status: string) => {
switch (status) { switch (status) {
case "Active": case "Active":
@ -175,7 +203,7 @@ export default function SubscriptionDetailPage() {
return ( return (
<DashboardLayout> <DashboardLayout>
<div className="py-6"> <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 */} {/* Header */}
<div className="mb-8"> <div className="mb-8">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -191,6 +219,7 @@ export default function SubscriptionDetailPage() {
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
@ -247,12 +276,51 @@ export default function SubscriptionDetailPage() {
</div> </div>
</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') && ( {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} /> <SimManagementSection subscriptionId={subscriptionId} />
)} )}
{/* Related Invoices */} {/* Related Invoices (hidden when viewing SIM management directly) */}
{showInvoices && (
<div className="bg-white shadow rounded-lg"> <div className="bg-white shadow rounded-lg">
<div className="px-6 py-4 border-b border-gray-200"> <div className="px-6 py-4 border-b border-gray-200">
<div className="flex items-center"> <div className="flex items-center">
@ -427,6 +495,7 @@ export default function SubscriptionDetailPage() {
</> </>
)} )}
</div> </div>
)}
</div> </div>
</div> </div>
</DashboardLayout> </DashboardLayout>

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect, useMemo } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useAuthStore } from "@/lib/auth/store"; import { useAuthStore } from "@/lib/auth/store";
@ -18,6 +18,8 @@ import {
Squares2X2Icon, Squares2X2Icon,
ClipboardDocumentListIcon, ClipboardDocumentListIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { useActiveSubscriptions } from "@/hooks/useSubscriptions";
import type { Subscription } from "@customer-portal/shared";
interface DashboardLayoutProps { interface DashboardLayoutProps {
children: React.ReactNode; children: React.ReactNode;
@ -37,7 +39,7 @@ interface NavigationItem {
isLogout?: boolean; isLogout?: boolean;
} }
const navigation = [ const baseNavigation: NavigationItem[] = [
{ name: "Dashboard", href: "/dashboard", icon: HomeIcon }, { name: "Dashboard", href: "/dashboard", icon: HomeIcon },
{ name: "Orders", href: "/orders", icon: ClipboardDocumentListIcon }, { name: "Orders", href: "/orders", icon: ClipboardDocumentListIcon },
{ {
@ -48,7 +50,12 @@ const navigation = [
{ name: "Payment Methods", href: "/billing/payments" }, { 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: "Catalog", href: "/catalog", icon: Squares2X2Icon },
{ {
name: "Support", name: "Support",
@ -78,6 +85,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
const { user, isAuthenticated, checkAuth } = useAuthStore(); const { user, isAuthenticated, checkAuth } = useAuthStore();
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const { data: activeSubscriptions } = useActiveSubscriptions();
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
@ -91,6 +99,13 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
} }
}, [mounted, isAuthenticated, router]); }, [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) => { const toggleExpanded = (itemName: string) => {
setExpandedItems(prev => setExpandedItems(prev =>
prev.includes(itemName) ? prev.filter(name => name !== itemName) : [...prev, itemName] prev.includes(itemName) ? prev.filter(name => name !== itemName) : [...prev, itemName]
@ -129,7 +144,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
</button> </button>
</div> </div>
<MobileSidebar <MobileSidebar
navigation={navigation} navigation={computeNavigation(activeSubscriptions)}
pathname={pathname} pathname={pathname}
expandedItems={expandedItems} expandedItems={expandedItems}
toggleExpanded={toggleExpanded} toggleExpanded={toggleExpanded}
@ -142,7 +157,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
<div className="hidden md:flex md:flex-shrink-0"> <div className="hidden md:flex md:flex-shrink-0">
<div className="flex flex-col w-64"> <div className="flex flex-col w-64">
<DesktopSidebar <DesktopSidebar
navigation={navigation} navigation={computeNavigation(activeSubscriptions)}
pathname={pathname} pathname={pathname}
expandedItems={expandedItems} expandedItems={expandedItems}
toggleExpanded={toggleExpanded} 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({ function DesktopSidebar({
navigation, navigation,
pathname, pathname,
@ -287,7 +343,7 @@ function NavigationItem({
const hasChildren = item.children && item.children.length > 0; const hasChildren = item.children && item.children.length > 0;
const isActive = hasChildren 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 : item.href
? pathname === item.href ? pathname === item.href
: false; : false;
@ -331,7 +387,7 @@ function NavigationItem({
key={child.name} key={child.name}
href={child.href} href={child.href}
className={` 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 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; remainingQuotaMb: number;
isLoading?: boolean; isLoading?: boolean;
error?: string | null; 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) => { const formatUsage = (usageMb: number) => {
if (usageMb >= 1024) { if (usageMb >= 1024) {
return `${(usageMb / 1024).toFixed(2)} GB`; return `${(usageMb / 1024).toFixed(1)} GB`;
} }
return `${usageMb.toFixed(0)} MB`; return `${usageMb.toFixed(0)} MB`;
}; };
@ -49,7 +50,7 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error }: Da
if (isLoading) { if (isLoading) {
return ( 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="animate-pulse">
<div className="h-6 bg-gray-200 rounded w-1/3 mb-4"></div> <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> <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) { if (error) {
return ( return (
<div className="bg-white shadow rounded-lg p-6"> <div className={`${embedded ? '' : 'bg-white shadow rounded-lg '}p-6`}>
<div className="text-center"> <div className="text-center">
<ExclamationTriangleIcon className="h-12 w-12 text-red-400 mx-auto mb-4" /> <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> <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; const usagePercentage = totalQuota > 0 ? (totalRecentUsage / totalQuota) * 100 : 0;
return ( 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 */} {/* 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"> <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> <div>
<h3 className="text-lg font-medium text-gray-900">Data Usage</h3> <h3 className="text-xl font-semibold text-gray-900">Data Usage</h3>
<p className="text-sm text-gray-500">Current month usage and remaining quota</p> <p className="text-sm text-gray-600">Current month usage and remaining quota</p>
</div> </div>
</div> </div>
</div> </div>
{/* Content */} {/* Content */}
<div className="px-6 py-4"> <div className={`${embedded ? '' : 'px-6 lg:px-8 py-6'}`}>
{/* Current Usage Overview */} {/* Current Usage Overview */}
<div className="mb-6"> <div className="mb-6">
<div className="flex justify-between items-center mb-2"> <div className="flex justify-between items-center mb-2">
@ -122,19 +125,37 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error }: Da
</div> </div>
{/* Today's Usage */} {/* Today's Usage */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-8">
<div className="bg-blue-50 rounded-lg p-4"> <div className="bg-gradient-to-br from-blue-50 to-blue-100 rounded-xl p-6 border border-blue-200">
<div className="text-2xl font-bold text-blue-600"> <div className="flex items-center justify-between">
{formatUsage(usage.todayUsageMb)} <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>
<div className="text-sm text-blue-800">Used today</div>
</div> </div>
<div className="bg-green-50 rounded-lg p-4"> <div className="bg-gradient-to-br from-green-50 to-green-100 rounded-xl p-6 border border-green-200">
<div className="text-2xl font-bold text-green-600"> <div className="flex items-center justify-between">
{formatUsage(remainingQuotaMb)} <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>
<div className="text-sm text-green-800">Remaining</div>
</div> </div>
</div> </div>

View File

@ -9,6 +9,7 @@ import {
CheckCircleIcon CheckCircleIcon
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { TopUpModal } from './TopUpModal'; import { TopUpModal } from './TopUpModal';
import { ChangePlanModal } from './ChangePlanModal';
import { authenticatedApi } from '@/lib/api'; import { authenticatedApi } from '@/lib/api';
interface SimActionsProps { interface SimActionsProps {
@ -19,6 +20,7 @@ interface SimActionsProps {
onPlanChangeSuccess?: () => void; onPlanChangeSuccess?: () => void;
onCancelSuccess?: () => void; onCancelSuccess?: () => void;
onReissueSuccess?: () => void; onReissueSuccess?: () => void;
embedded?: boolean; // when true, render content without card container
} }
export function SimActions({ export function SimActions({
@ -28,7 +30,8 @@ export function SimActions({
onTopUpSuccess, onTopUpSuccess,
onPlanChangeSuccess, onPlanChangeSuccess,
onCancelSuccess, onCancelSuccess,
onReissueSuccess onReissueSuccess,
embedded = false
}: SimActionsProps) { }: SimActionsProps) {
const [showTopUpModal, setShowTopUpModal] = useState(false); const [showTopUpModal, setShowTopUpModal] = useState(false);
const [showCancelConfirm, setShowCancelConfirm] = useState(false); const [showCancelConfirm, setShowCancelConfirm] = useState(false);
@ -36,6 +39,7 @@ export function SimActions({
const [loading, setLoading] = useState<string | null>(null); const [loading, setLoading] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null); const [success, setSuccess] = useState<string | null>(null);
const [showChangePlanModal, setShowChangePlanModal] = useState(false);
const isActive = status === 'active'; const isActive = status === 'active';
const canTopUp = isActive; const canTopUp = isActive;
@ -85,18 +89,28 @@ export function SimActions({
}, 5000); }, 5000);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
return;
}, [success, error]); }, [success, error]);
return ( 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 */} {/* 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'}`}>
<h3 className="text-lg font-medium text-gray-900">SIM Management Actions</h3> <div className="flex items-center">
<p className="text-sm text-gray-500 mt-1">Manage your SIM service</p> <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> </div>
{/* Content */} {/* Content */}
<div className="px-6 py-4"> <div className={`${embedded ? '' : 'px-6 lg:px-8 py-6'}`}>
{/* Status Messages */} {/* Status Messages */}
{success && ( {success && (
<div className="mb-4 bg-green-50 border border-green-200 rounded-lg p-4"> <div className="mb-4 bg-green-50 border border-green-200 rounded-lg p-4">
@ -128,19 +142,23 @@ export function SimActions({
)} )}
{/* Action Buttons */} {/* Action Buttons */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className={`grid gap-4 ${embedded ? 'grid-cols-1' : 'grid-cols-2'}`}>
{/* Top Up Data */} {/* Top Up Data - Primary Action */}
<button <button
onClick={() => setShowTopUpModal(true)} onClick={() => setShowTopUpModal(true)}
disabled={!canTopUp || loading !== null} 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 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-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-100 cursor-not-allowed' : 'text-gray-400 bg-gray-50 border-gray-200 cursor-not-allowed'
}`} }`}
> >
<PlusIcon className="h-4 w-4 mr-2" /> <div className="flex items-center">
{loading === 'topup' ? 'Processing...' : 'Top Up Data'} <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> </button>
{/* Reissue eSIM (only for eSIMs) */} {/* Reissue eSIM (only for eSIMs) */}
@ -148,29 +166,57 @@ export function SimActions({
<button <button
onClick={() => setShowReissueConfirm(true)} onClick={() => setShowReissueConfirm(true)}
disabled={!canReissue || loading !== null} 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 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-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-100 cursor-not-allowed' : 'text-gray-400 bg-gray-50 border-gray-200 cursor-not-allowed'
}`} }`}
> >
<ArrowPathIcon className="h-4 w-4 mr-2" /> <div className="flex items-center">
{loading === 'reissue' ? 'Processing...' : 'Reissue eSIM'} <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> </button>
)} )}
{/* Cancel SIM */} {/* Cancel SIM - Destructive Action */}
<button <button
onClick={() => setShowCancelConfirm(true)} onClick={() => setShowCancelConfirm(true)}
disabled={!canCancel || loading !== null} 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 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-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-100 cursor-not-allowed' : 'text-gray-400 bg-gray-50 border-gray-200 cursor-not-allowed'
}`} }`}
> >
<XMarkIcon className="h-4 w-4 mr-2" /> <div className="flex items-center">
{loading === 'cancel' ? 'Processing...' : 'Cancel SIM'} <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> </button>
</div> </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. <strong>Cancel SIM:</strong> Permanently cancel your SIM service. This action cannot be undone and will terminate your service immediately.
</div> </div>
</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>
</div> </div>
@ -215,6 +270,8 @@ export function SimActions({
/> />
)} )}
{/* Change Plan handled in Feature Toggles */}
{/* Reissue eSIM Confirmation */} {/* Reissue eSIM Confirmation */}
{showReissueConfirm && ( {showReissueConfirm && (
<div className="fixed inset-0 z-50 overflow-y-auto"> <div className="fixed inset-0 z-50 overflow-y-auto">

View File

@ -42,9 +42,11 @@ interface SimDetailsCardProps {
simDetails: SimDetails; simDetails: SimDetails;
isLoading?: boolean; isLoading?: boolean;
error?: string | null; 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) => { const getStatusIcon = (status: string) => {
switch (status) { switch (status) {
case 'active': case 'active':
@ -96,33 +98,36 @@ export function SimDetailsCard({ simDetails, isLoading, error }: SimDetailsCardP
}; };
if (isLoading) { if (isLoading) {
return ( const Skeleton = (
<div className="bg-white shadow rounded-lg p-6"> <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="animate-pulse">
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<div className="rounded-full bg-gray-200 h-12 w-12"></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-2"> <div className="flex-1 space-y-3">
<div className="h-4 bg-gray-200 rounded w-3/4"></div> <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-gray-200 rounded w-1/2"></div> <div className="h-4 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-1/2"></div>
</div> </div>
</div> </div>
<div className="mt-6 space-y-3"> <div className="mt-8 space-y-4">
<div className="h-4 bg-gray-200 rounded"></div> <div className="h-4 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg"></div>
<div className="h-4 bg-gray-200 rounded w-5/6"></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-gray-200 rounded w-4/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> </div>
</div> </div>
); );
return Skeleton;
} }
if (error) { if (error) {
return ( 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"> <div className="text-center">
<ExclamationTriangleIcon className="h-12 w-12 text-red-400 mx-auto mb-4" /> <div className="bg-red-50 rounded-full p-3 w-16 h-16 mx-auto mb-4">
<h3 className="text-lg font-medium text-gray-900 mb-2">Error Loading SIM Details</h3> <ExclamationTriangleIcon className="h-10 w-10 text-red-500 mx-auto" />
<p className="text-red-600">{error}</p> </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>
</div> </div>
); );
@ -131,56 +136,81 @@ export function SimDetailsCard({ simDetails, isLoading, error }: SimDetailsCardP
// Specialized, minimal eSIM details view // Specialized, minimal eSIM details view
if (simDetails.simType === 'esim') { if (simDetails.simType === 'esim') {
return ( 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'}`}>
<div className="px-6 py-4 border-b border-gray-200"> {/* Header */}
<div className="flex items-center justify-between"> <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"> <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> <div>
<h3 className="text-lg font-medium text-gray-900">eSIM Details</h3> <h3 className="text-xl font-semibold text-gray-900">eSIM Details</h3>
<p className="text-sm text-gray-500">Current Plan: {simDetails.planCode}</p> <p className="text-sm text-gray-600 font-medium">Current Plan: {simDetails.planCode}</p>
</div> </div>
</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)} {simDetails.status.charAt(0).toUpperCase() + simDetails.status.slice(1)}
</span> </span>
</div> </div>
</div> </div>
<div className="px-6 py-4"> <div className={`${embedded ? '' : 'px-6 lg:px-8 py-6'}`}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div> <div className="space-y-6">
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3">SIM Information</h4> <div>
<div className="space-y-3"> <h4 className="text-sm font-semibold text-gray-700 uppercase tracking-wider mb-4 flex items-center">
<div> <DevicePhoneMobileIcon className="h-4 w-4 mr-2 text-blue-500" />
<label className="text-xs text-gray-500">Phone Number</label> SIM Information
<p className="text-sm font-medium text-gray-900">{simDetails.msisdn}</p> </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>
<div> <div>
<label className="text-xs text-gray-500">Data Remaining</label> <label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Data Remaining</label>
<p className="text-lg font-semibold text-green-600">{formatQuota(simDetails.remainingQuotaMb)}</p> <p className="text-2xl font-bold text-green-600 mt-1">{formatQuota(simDetails.remainingQuotaMb)}</p>
</div>
</div> </div>
</div> </div>
<div> {showFeaturesSummary && (
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3">Service Features</h4> <div>
<div className="space-y-2 text-sm"> <h4 className="text-sm font-semibold text-gray-700 uppercase tracking-wider mb-4 flex items-center">
<div className="flex justify-between"><span>Voice Mail (¥300/month)</span><span className="font-medium">{simDetails.voiceMailEnabled ? 'Enabled' : 'Disabled'}</span></div> <CheckCircleIcon className="h-4 w-4 mr-2 text-green-500" />
<div className="flex justify-between"><span>Call Waiting (¥300/month)</span><span className="font-medium">{simDetails.callWaitingEnabled ? 'Enabled' : 'Disabled'}</span></div> Service Features
<div className="flex justify-between"><span>International Roaming</span><span className="font-medium">{simDetails.internationalRoamingEnabled ? 'Enabled' : 'Disabled'}</span></div> </h4>
<div className="flex justify-between"><span>4G/5G</span><span className="font-medium">{simDetails.networkType || '5G'}</span></div> <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> )}
</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> </div>
</div> </div>
@ -188,20 +218,18 @@ export function SimDetailsCard({ simDetails, isLoading, error }: SimDetailsCardP
} }
return ( return (
<div className="bg-white shadow rounded-lg"> <div className={`${embedded ? '' : 'bg-white shadow rounded-lg'}`}>
{/* Header */} {/* 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 justify-between">
<div className="flex items-center"> <div className="flex items-center">
<div className="text-2xl mr-3"> <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>
<div> <div>
<h3 className="text-lg font-medium text-gray-900"> <h3 className="text-lg font-medium text-gray-900">Physical SIM Details</h3>
{simDetails.simType === 'esim' ? 'eSIM Details' : 'Physical SIM Details'}
</h3>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
{simDetails.planCode} {simDetails.simType === 'physical' ? `${simDetails.size} SIM` : 'eSIM'} {simDetails.planCode} {`${simDetails.size} SIM`}
</p> </p>
</div> </div>
</div> </div>
@ -215,7 +243,7 @@ export function SimDetailsCard({ simDetails, isLoading, error }: SimDetailsCardP
</div> </div>
{/* Content */} {/* 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"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* SIM Information */} {/* SIM Information */}
<div> <div>
@ -259,46 +287,48 @@ export function SimDetailsCard({ simDetails, isLoading, error }: SimDetailsCardP
</div> </div>
{/* Service Features */} {/* Service Features */}
<div> {showFeaturesSummary && (
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3"> <div>
Service Features <h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3">
</h4> Service Features
<div className="space-y-3"> </h4>
<div> <div className="space-y-3">
<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> <div>
<label className="text-xs text-gray-500">IP Address</label> <label className="text-xs text-gray-500">Data Remaining</label>
<div className="space-y-1"> <p className="text-lg font-semibold text-green-600">{formatQuota(simDetails.remainingQuotaMb)}</p>
{simDetails.ipv4 && ( </div>
<p className="text-sm font-mono text-gray-900">IPv4: {simDetails.ipv4}</p>
)} <div className="flex items-center space-x-4">
{simDetails.ipv6 && ( <div className="flex items-center">
<p className="text-sm font-mono text-gray-900">IPv6: {simDetails.ipv6}</p> <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>
</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> )}
</div> </div>
{/* Pending Operations */} {/* 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 { DataUsageChart, type SimUsage } from './DataUsageChart';
import { SimActions } from './SimActions'; import { SimActions } from './SimActions';
import { authenticatedApi } from '@/lib/api'; import { authenticatedApi } from '@/lib/api';
import { SimFeatureToggles } from './SimFeatureToggles';
interface SimManagementSectionProps { interface SimManagementSectionProps {
subscriptionId: number; subscriptionId: number;
@ -63,16 +64,25 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
if (loading) { if (loading) {
return ( return (
<div className="space-y-6"> <div className="space-y-8">
<div className="bg-white shadow rounded-lg p-6"> <div className="bg-white shadow-lg rounded-xl border border-gray-100 p-8">
<div className="flex items-center mb-4"> <div className="flex items-center mb-6">
<DevicePhoneMobileIcon className="h-6 w-6 text-blue-600 mr-3" /> <div className="bg-blue-50 rounded-xl p-2 mr-4">
<h2 className="text-xl font-semibold text-gray-900">SIM Management</h2> <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>
<div className="animate-pulse space-y-4"> <div className="animate-pulse space-y-6">
<div className="h-4 bg-gray-200 rounded w-3/4"></div> <div className="h-6 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-3/4"></div>
<div className="h-4 bg-gray-200 rounded w-1/2"></div> <div className="h-5 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-1/2"></div>
<div className="h-32 bg-gray-200 rounded"></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> </div>
</div> </div>
@ -81,20 +91,27 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
if (error) { if (error) {
return ( return (
<div className="bg-white shadow rounded-lg p-6"> <div className="bg-white shadow-lg rounded-xl border border-red-100 p-8">
<div className="flex items-center mb-4"> <div className="flex items-center mb-6">
<DevicePhoneMobileIcon className="h-6 w-6 text-blue-600 mr-3" /> <div className="bg-blue-50 rounded-xl p-2 mr-4">
<h2 className="text-xl font-semibold text-gray-900">SIM Management</h2> <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>
<div className="text-center py-8"> <div className="text-center py-12">
<ExclamationTriangleIcon className="h-12 w-12 text-red-400 mx-auto mb-4" /> <div className="bg-red-50 rounded-full p-4 w-20 h-20 mx-auto mb-6">
<h3 className="text-lg font-medium text-gray-900 mb-2">Unable to Load SIM Information</h3> <ExclamationTriangleIcon className="h-12 w-12 text-red-500 mx-auto" />
<p className="text-gray-600 mb-4">{error}</p> </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 <button
onClick={handleRefresh} 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 Retry
</button> </button>
</div> </div>
@ -107,65 +124,101 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
} }
return ( return (
<div className="space-y-6"> <div id="sim-management" className="space-y-8">
{/* Section Header */} {/* SIM Details and Usage - Main Content */}
<div className="bg-white shadow rounded-lg p-6"> <div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
<div className="flex items-center justify-between"> {/* Main Content Area - Actions and Settings (Left Side) */}
<div className="flex items-center"> <div className="xl:col-span-2 xl:order-1 space-y-8">
<DevicePhoneMobileIcon className="h-6 w-6 text-blue-600 mr-3" /> {/* SIM Management Actions */}
<div> <SimActions
<h2 className="text-xl font-semibold text-gray-900">SIM Management</h2> subscriptionId={subscriptionId}
<p className="text-gray-600">Manage your SIM service and data usage</p> 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> </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> </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>
</div>
{/* SIM Details */} {/* Sidebar - Compact Info (Right Side) */}
<SimDetailsCard <div className="xl:order-2 space-y-8">
simDetails={simInfo.details} {/* Important Information Card */}
isLoading={false} <div className="bg-gradient-to-br from-blue-50 to-blue-100 border border-blue-200 rounded-xl p-6">
error={null} <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 */} <SimDetailsCard
<DataUsageChart simDetails={simInfo.details}
usage={simInfo.usage} isLoading={false}
remainingQuotaMb={simInfo.details.remainingQuotaMb} error={null}
isLoading={false} embedded={true}
error={null} showFeaturesSummary={false}
/> />
{/* SIM Actions */} <DataUsageChart
<SimActions usage={simInfo.usage}
subscriptionId={subscriptionId} remainingQuotaMb={simInfo.details.remainingQuotaMb}
simType={simInfo.details.simType} isLoading={false}
status={simInfo.details.status} error={null}
onTopUpSuccess={handleActionSuccess} embedded={true}
onPlanChangeSuccess={handleActionSuccess} />
onCancelSuccess={handleActionSuccess} </div>
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>
</div> </div>
</div> </div>
); );

View File

@ -3,6 +3,7 @@ export { SimDetailsCard } from './components/SimDetailsCard';
export { DataUsageChart } from './components/DataUsageChart'; export { DataUsageChart } from './components/DataUsageChart';
export { SimActions } from './components/SimActions'; export { SimActions } from './components/SimActions';
export { TopUpModal } from './components/TopUpModal'; export { TopUpModal } from './components/TopUpModal';
export { SimFeatureToggles } from './components/SimFeatureToggles';
export type { SimDetails } from './components/SimDetailsCard'; export type { SimDetails } from './components/SimDetailsCard';
export type { SimUsage } from './components/DataUsageChart'; 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. 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 **Last Updated**: January 2025
**Implementation Status**: ✅ Complete and Deployed **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 ## 🏗️ Implementation Summary
@ -31,6 +36,10 @@ This document outlines the complete implementation of Freebit SIM management fea
- ✅ Integrated into subscription detail page - ✅ Integrated into subscription detail page
- ✅ **Fixed**: Updated all components to use `authenticatedApi` utility - ✅ **Fixed**: Updated all components to use `authenticatedApi` utility
- ✅ **Fixed**: Proper API routing to BFF (port 4000) instead of frontend (port 3000) - ✅ **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** 3. **Features Implemented**
- ✅ View SIM details (ICCID, MSISDN, plan, status) - ✅ View SIM details (ICCID, MSISDN, plan, status)
@ -124,10 +133,33 @@ apps/portal/src/features/sim-management/
│ ├── SimDetailsCard.tsx # SIM information display │ ├── SimDetailsCard.tsx # SIM information display
│ ├── DataUsageChart.tsx # Usage visualization │ ├── DataUsageChart.tsx # Usage visualization
│ ├── SimActions.tsx # Action buttons and confirmations │ ├── SimActions.tsx # Action buttons and confirmations
│ ├── SimFeatureToggles.tsx # Service options (Voice Mail, Call Waiting, etc.)
│ └── TopUpModal.tsx # Data top-up interface │ └── TopUpModal.tsx # Data top-up interface
└── index.ts # Exports └── 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 ### Features
- **Responsive Design**: Works on desktop and mobile - **Responsive Design**: Works on desktop and mobile
- **Real-time Updates**: Automatic refresh after actions - **Real-time Updates**: Automatic refresh after actions
@ -135,6 +167,57 @@ apps/portal/src/features/sim-management/
- **Error Handling**: Comprehensive error messages and recovery - **Error Handling**: Comprehensive error messages and recovery
- **Accessibility**: Proper ARIA labels and keyboard navigation - **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 ## 🗄️ Required Salesforce Custom Fields
To enable proper SIM data tracking in Salesforce, add these 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.