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:
parent
d9f7c5c8b2
commit
735828cf32
@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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" };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
53
apps/bff/src/vendors/freebit/freebit.service.ts
vendored
53
apps/bff/src/vendors/freebit/freebit.service.ts
vendored
@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
apps/portal/src/features/service-management/index.ts
Normal file
1
apps/portal/src/features/service-management/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { ServiceManagementSection } from './components/ServiceManagementSection';
|
||||||
@ -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">​</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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 */}
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
46
docs/SUBSCRIPTION-SERVICE-MANAGEMENT.md
Normal file
46
docs/SUBSCRIPTION-SERVICE-MANAGEMENT.md
Normal 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.
|
||||||
Loading…
x
Reference in New Issue
Block a user