Update worktree setup and enhance BFF with SupportModule integration
- Changed worktree setup command from npm to pnpm for improved package management. - Added SupportModule to app.module.ts and router.config.ts for better support case handling. - Refactored OrderEventsService to utilize OrderUpdateEventPayload for improved type safety. - Updated InvoicesList component to use INVOICE_STATUS for status filtering and improved type definitions. - Enhanced SimActions and SimDetailsCard components to utilize SimStatus for better state management. - Refactored Subscription components to leverage new utility functions for status handling and billing cycle labels. - Improved SupportCasesView with better state management and error handling. - Updated API query keys to include support cases for better data retrieval.
This commit is contained in:
parent
1220f219e4
commit
8c89109213
@ -1,5 +1,5 @@
|
||||
{
|
||||
"setup-worktree": [
|
||||
"npm install"
|
||||
"pnpm install"
|
||||
]
|
||||
}
|
||||
|
||||
@ -34,6 +34,7 @@ import { OrdersModule } from "@bff/modules/orders/orders.module";
|
||||
import { InvoicesModule } from "@bff/modules/invoices/invoices.module";
|
||||
import { SubscriptionsModule } from "@bff/modules/subscriptions/subscriptions.module";
|
||||
import { CurrencyModule } from "@bff/modules/currency/currency.module";
|
||||
import { SupportModule } from "@bff/modules/support/support.module";
|
||||
|
||||
// System Modules
|
||||
import { HealthModule } from "@bff/modules/health/health.module";
|
||||
@ -85,6 +86,7 @@ import { HealthModule } from "@bff/modules/health/health.module";
|
||||
InvoicesModule,
|
||||
SubscriptionsModule,
|
||||
CurrencyModule,
|
||||
SupportModule,
|
||||
|
||||
// === SYSTEM MODULES ===
|
||||
HealthModule,
|
||||
|
||||
@ -8,6 +8,7 @@ import { InvoicesModule } from "@bff/modules/invoices/invoices.module";
|
||||
import { SubscriptionsModule } from "@bff/modules/subscriptions/subscriptions.module";
|
||||
import { CurrencyModule } from "@bff/modules/currency/currency.module";
|
||||
import { SecurityModule } from "@bff/core/security/security.module";
|
||||
import { SupportModule } from "@bff/modules/support/support.module";
|
||||
|
||||
export const apiRoutes: Routes = [
|
||||
{
|
||||
@ -21,6 +22,7 @@ export const apiRoutes: Routes = [
|
||||
{ path: "", module: InvoicesModule },
|
||||
{ path: "", module: SubscriptionsModule },
|
||||
{ path: "", module: CurrencyModule },
|
||||
{ path: "", module: SupportModule },
|
||||
{ path: "", module: SecurityModule },
|
||||
],
|
||||
},
|
||||
|
||||
@ -1,18 +1,7 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import type { MessageEvent } from "@nestjs/common";
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
export interface OrderUpdateEvent {
|
||||
orderId: string;
|
||||
status?: string;
|
||||
activationStatus?: string | null;
|
||||
message?: string;
|
||||
reason?: string;
|
||||
stage?: "started" | "in_progress" | "completed" | "failed";
|
||||
source?: string;
|
||||
timestamp: string;
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
import type { OrderUpdateEventPayload } from "@customer-portal/domain/orders";
|
||||
|
||||
interface InternalObserver {
|
||||
next: (event: MessageEvent) => void;
|
||||
@ -74,7 +63,7 @@ export class OrderEventsService {
|
||||
});
|
||||
}
|
||||
|
||||
publish(orderId: string, update: OrderUpdateEvent): void {
|
||||
publish(orderId: string, update: OrderUpdateEventPayload): void {
|
||||
const currentObservers = this.observers.get(orderId);
|
||||
if (!currentObservers || currentObservers.size === 0) {
|
||||
this.logger.debug("No active listeners for order update", { orderId });
|
||||
|
||||
3
apps/bff/src/modules/support/index.ts
Normal file
3
apps/bff/src/modules/support/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from "./support.module";
|
||||
export * from "./support.controller";
|
||||
export * from "./support.service";
|
||||
24
apps/bff/src/modules/support/support.controller.ts
Normal file
24
apps/bff/src/modules/support/support.controller.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { Controller, Get, Query, Request } from "@nestjs/common";
|
||||
import { SupportService } from "./support.service";
|
||||
import { ZodValidationPipe } from "@customer-portal/validation/nestjs";
|
||||
import {
|
||||
supportCaseFilterSchema,
|
||||
type SupportCaseFilter,
|
||||
type SupportCaseList,
|
||||
} from "@customer-portal/domain/support";
|
||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types";
|
||||
|
||||
@Controller("support")
|
||||
export class SupportController {
|
||||
constructor(private readonly supportService: SupportService) {}
|
||||
|
||||
@Get("cases")
|
||||
async listCases(
|
||||
@Request() _req: RequestWithUser,
|
||||
@Query(new ZodValidationPipe(supportCaseFilterSchema))
|
||||
filters: SupportCaseFilter
|
||||
): Promise<SupportCaseList> {
|
||||
void _req;
|
||||
return this.supportService.listCases(filters);
|
||||
}
|
||||
}
|
||||
10
apps/bff/src/modules/support/support.module.ts
Normal file
10
apps/bff/src/modules/support/support.module.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { SupportController } from "./support.controller";
|
||||
import { SupportService } from "./support.service";
|
||||
|
||||
@Module({
|
||||
controllers: [SupportController],
|
||||
providers: [SupportService],
|
||||
exports: [SupportService],
|
||||
})
|
||||
export class SupportModule {}
|
||||
140
apps/bff/src/modules/support/support.service.ts
Normal file
140
apps/bff/src/modules/support/support.service.ts
Normal file
@ -0,0 +1,140 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import {
|
||||
SUPPORT_CASE_CATEGORY,
|
||||
SUPPORT_CASE_PRIORITY,
|
||||
SUPPORT_CASE_STATUS,
|
||||
supportCaseFilterSchema,
|
||||
supportCaseListSchema,
|
||||
type SupportCase,
|
||||
type SupportCaseFilter,
|
||||
type SupportCaseList,
|
||||
type SupportCasePriority,
|
||||
type SupportCaseStatus,
|
||||
} from "@customer-portal/domain/support";
|
||||
|
||||
const OPEN_STATUSES: SupportCaseStatus[] = [
|
||||
SUPPORT_CASE_STATUS.OPEN,
|
||||
SUPPORT_CASE_STATUS.IN_PROGRESS,
|
||||
SUPPORT_CASE_STATUS.WAITING_ON_CUSTOMER,
|
||||
];
|
||||
|
||||
const RESOLVED_STATUSES: SupportCaseStatus[] = [
|
||||
SUPPORT_CASE_STATUS.RESOLVED,
|
||||
SUPPORT_CASE_STATUS.CLOSED,
|
||||
];
|
||||
|
||||
const HIGH_PRIORITIES: SupportCasePriority[] = [
|
||||
SUPPORT_CASE_PRIORITY.HIGH,
|
||||
SUPPORT_CASE_PRIORITY.CRITICAL,
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class SupportService {
|
||||
// Placeholder dataset until Salesforce integration is ready
|
||||
private readonly cases: SupportCase[] = [
|
||||
{
|
||||
id: 12001,
|
||||
subject: "VPS Performance Issues",
|
||||
status: SUPPORT_CASE_STATUS.IN_PROGRESS,
|
||||
priority: SUPPORT_CASE_PRIORITY.HIGH,
|
||||
category: SUPPORT_CASE_CATEGORY.TECHNICAL,
|
||||
createdAt: "2025-08-14T10:30:00Z",
|
||||
updatedAt: "2025-08-15T14:20:00Z",
|
||||
lastReply: "2025-08-15T14:20:00Z",
|
||||
description: "Experiencing slow response times on VPS server, CPU usage appears high.",
|
||||
assignedTo: "Technical Support Team",
|
||||
},
|
||||
{
|
||||
id: 12002,
|
||||
subject: "Billing Question - Invoice #12345",
|
||||
status: SUPPORT_CASE_STATUS.WAITING_ON_CUSTOMER,
|
||||
priority: SUPPORT_CASE_PRIORITY.MEDIUM,
|
||||
category: SUPPORT_CASE_CATEGORY.BILLING,
|
||||
createdAt: "2025-08-13T16:45:00Z",
|
||||
updatedAt: "2025-08-14T09:30:00Z",
|
||||
lastReply: "2025-08-14T09:30:00Z",
|
||||
description: "Need clarification on charges in recent invoice.",
|
||||
assignedTo: "Billing Department",
|
||||
},
|
||||
{
|
||||
id: 12003,
|
||||
subject: "SSL Certificate Installation",
|
||||
status: SUPPORT_CASE_STATUS.RESOLVED,
|
||||
priority: SUPPORT_CASE_PRIORITY.LOW,
|
||||
category: SUPPORT_CASE_CATEGORY.TECHNICAL,
|
||||
createdAt: "2025-08-12T08:15:00Z",
|
||||
updatedAt: "2025-08-12T15:45:00Z",
|
||||
lastReply: "2025-08-12T15:45:00Z",
|
||||
description: "Request assistance with SSL certificate installation on shared hosting.",
|
||||
assignedTo: "Technical Support Team",
|
||||
},
|
||||
{
|
||||
id: 12004,
|
||||
subject: "Feature Request: Control Panel Enhancement",
|
||||
status: SUPPORT_CASE_STATUS.OPEN,
|
||||
priority: SUPPORT_CASE_PRIORITY.LOW,
|
||||
category: SUPPORT_CASE_CATEGORY.FEATURE_REQUEST,
|
||||
createdAt: "2025-08-11T13:20:00Z",
|
||||
updatedAt: "2025-08-11T13:20:00Z",
|
||||
description: "Would like to see improved backup management in the control panel.",
|
||||
assignedTo: "Development Team",
|
||||
},
|
||||
{
|
||||
id: 12005,
|
||||
subject: "Server Migration Assistance",
|
||||
status: SUPPORT_CASE_STATUS.CLOSED,
|
||||
priority: SUPPORT_CASE_PRIORITY.MEDIUM,
|
||||
category: SUPPORT_CASE_CATEGORY.TECHNICAL,
|
||||
createdAt: "2025-08-10T11:00:00Z",
|
||||
updatedAt: "2025-08-11T17:30:00Z",
|
||||
lastReply: "2025-08-11T17:30:00Z",
|
||||
description: "Need help migrating website from old server to new VPS.",
|
||||
assignedTo: "Migration Team",
|
||||
},
|
||||
];
|
||||
|
||||
async listCases(rawFilters?: SupportCaseFilter): Promise<SupportCaseList> {
|
||||
const filters = supportCaseFilterSchema.parse(rawFilters ?? {});
|
||||
const filteredCases = this.applyFilters(this.cases, filters);
|
||||
const result = {
|
||||
cases: filteredCases,
|
||||
summary: this.buildSummary(filteredCases),
|
||||
};
|
||||
return supportCaseListSchema.parse(result);
|
||||
}
|
||||
|
||||
private applyFilters(cases: SupportCase[], filters: SupportCaseFilter): SupportCase[] {
|
||||
const search = filters.search?.toLowerCase().trim();
|
||||
return cases.filter(supportCase => {
|
||||
if (filters.status && supportCase.status !== filters.status) {
|
||||
return false;
|
||||
}
|
||||
if (filters.priority && supportCase.priority !== filters.priority) {
|
||||
return false;
|
||||
}
|
||||
if (filters.category && supportCase.category !== filters.category) {
|
||||
return false;
|
||||
}
|
||||
if (search) {
|
||||
const haystack = `${supportCase.subject} ${supportCase.description} ${supportCase.id}`.toLowerCase();
|
||||
if (!haystack.includes(search)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private buildSummary(cases: SupportCase[]): SupportCaseList["summary"] {
|
||||
const open = cases.filter(c => OPEN_STATUSES.includes(c.status)).length;
|
||||
const highPriority = cases.filter(c => HIGH_PRIORITIES.includes(c.priority)).length;
|
||||
const resolved = cases.filter(c => RESOLVED_STATUSES.includes(c.status)).length;
|
||||
|
||||
return {
|
||||
total: cases.length,
|
||||
open,
|
||||
highPriority,
|
||||
resolved,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -9,7 +9,7 @@ import { PaginationBar } from "@/components/molecules/PaginationBar/PaginationBa
|
||||
import { InvoiceTable } from "@/features/billing/components/InvoiceTable/InvoiceTable";
|
||||
import { useInvoices } from "@/features/billing/hooks/useBilling";
|
||||
import { useSubscriptionInvoices } from "@/features/subscriptions/hooks/useSubscriptions";
|
||||
import type { Invoice, InvoiceStatus } from "@customer-portal/domain/billing";
|
||||
import { INVOICE_STATUS, type Invoice, type InvoiceStatus } from "@customer-portal/domain/billing";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface InvoicesListProps {
|
||||
@ -20,6 +20,8 @@ interface InvoicesListProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const INVOICE_STATUS_OPTIONS = Object.values(INVOICE_STATUS) as InvoiceStatus[];
|
||||
|
||||
export function InvoicesList({
|
||||
subscriptionId,
|
||||
pageSize = 10,
|
||||
@ -28,7 +30,7 @@ export function InvoicesList({
|
||||
className,
|
||||
}: InvoicesListProps) {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [statusFilter, setStatusFilter] = useState<InvoiceStatus | "all">("all");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const isSubscriptionMode = typeof subscriptionId === "number" && !Number.isNaN(subscriptionId);
|
||||
@ -70,13 +72,16 @@ export function InvoicesList({
|
||||
});
|
||||
}, [invoices, searchTerm]);
|
||||
|
||||
const statusFilterOptions = [
|
||||
{ value: "all", label: "All Status" },
|
||||
{ value: "Unpaid", label: "Unpaid" },
|
||||
{ value: "Paid", label: "Paid" },
|
||||
{ value: "Overdue", label: "Overdue" },
|
||||
{ value: "Cancelled", label: "Cancelled" },
|
||||
];
|
||||
const statusFilterOptions = useMemo(
|
||||
() => [
|
||||
{ value: "all" as const, label: "All Status" },
|
||||
...INVOICE_STATUS_OPTIONS.map(status => ({
|
||||
value: status,
|
||||
label: status,
|
||||
})),
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
if (isLoading || error) {
|
||||
return (
|
||||
@ -126,7 +131,8 @@ export function InvoicesList({
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={e => {
|
||||
setStatusFilter(e.target.value);
|
||||
const nextValue = e.target.value as InvoiceStatus | "all";
|
||||
setStatusFilter(nextValue);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="block w-36 pl-3 pr-8 py-2.5 text-sm border border-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400 rounded-lg appearance-none bg-white/50 cursor-pointer transition-all duration-200"
|
||||
|
||||
@ -3,23 +3,10 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { resolveBaseUrl } from "@/lib/api";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
export interface OrderStreamEvent<T extends string = string, P = unknown> {
|
||||
event: T;
|
||||
data: P;
|
||||
}
|
||||
|
||||
export interface OrderUpdateEventPayload {
|
||||
orderId: string;
|
||||
status?: string;
|
||||
activationStatus?: string | null;
|
||||
message?: string;
|
||||
reason?: string;
|
||||
stage?: "started" | "in_progress" | "completed" | "failed";
|
||||
source?: string;
|
||||
timestamp: string;
|
||||
payload?: Record<string, unknown> | null;
|
||||
}
|
||||
import {
|
||||
type OrderStreamEvent,
|
||||
type OrderUpdateEventPayload,
|
||||
} from "@customer-portal/domain/orders";
|
||||
|
||||
type OrderUpdateHandler = (event: OrderUpdateEventPayload) => void;
|
||||
|
||||
|
||||
@ -22,10 +22,7 @@ import {
|
||||
import { StatusPill } from "@/components/atoms/status-pill";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { ordersService } from "@/features/orders/services/orders.service";
|
||||
import {
|
||||
useOrderUpdates,
|
||||
type OrderUpdateEventPayload,
|
||||
} from "@/features/orders/hooks/useOrderUpdates";
|
||||
import { useOrderUpdates } from "@/features/orders/hooks/useOrderUpdates";
|
||||
import {
|
||||
calculateOrderTotals,
|
||||
deriveOrderStatusDescriptor,
|
||||
@ -37,7 +34,7 @@ import {
|
||||
type OrderDisplayItemCategory,
|
||||
type OrderDisplayItemCharge,
|
||||
} from "@/features/orders/utils/order-display";
|
||||
import type { OrderDetails } from "@customer-portal/domain/orders";
|
||||
import type { OrderDetails, OrderUpdateEventPayload } from "@customer-portal/domain/orders";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
|
||||
const STATUS_PILL_VARIANT: Record<
|
||||
|
||||
@ -12,11 +12,17 @@ import {
|
||||
import { TopUpModal } from "./TopUpModal";
|
||||
import { ChangePlanModal } from "./ChangePlanModal";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import {
|
||||
canTopUpSim,
|
||||
canReissueEsim,
|
||||
canCancelSim,
|
||||
type SimStatus,
|
||||
} from "@customer-portal/domain/sim";
|
||||
|
||||
interface SimActionsProps {
|
||||
subscriptionId: number;
|
||||
simType: "physical" | "esim";
|
||||
status: string;
|
||||
status: SimStatus;
|
||||
onTopUpSuccess?: () => void;
|
||||
onPlanChangeSuccess?: () => void;
|
||||
onCancelSuccess?: () => void;
|
||||
@ -48,10 +54,10 @@ export function SimActions({
|
||||
"topup" | "reissue" | "cancel" | "changePlan" | null
|
||||
>(null);
|
||||
|
||||
const isActive = status === "active";
|
||||
const canTopUp = isActive;
|
||||
const canReissue = isActive && simType === "esim";
|
||||
const canCancel = isActive;
|
||||
const isActiveStatus = canTopUpSim(status);
|
||||
const canTopUp = isActiveStatus;
|
||||
const canReissue = simType === "esim" && canReissueEsim(status);
|
||||
const canCancel = canCancelSim(status);
|
||||
|
||||
const handleReissueEsim = async () => {
|
||||
setLoading("reissue");
|
||||
@ -154,7 +160,7 @@ export function SimActions({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isActive && (
|
||||
{!isActiveStatus && (
|
||||
<div className="mb-4 bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div className="flex items-center">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 text-yellow-500 mr-2" />
|
||||
|
||||
@ -9,7 +9,7 @@ import {
|
||||
XCircleIcon,
|
||||
ClockIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import type { SimDetails } from "@customer-portal/domain/sim";
|
||||
import type { SimDetails, SimStatus } from "@customer-portal/domain/sim";
|
||||
|
||||
interface SimDetailsCardProps {
|
||||
simDetails: SimDetails;
|
||||
@ -19,14 +19,14 @@ interface SimDetailsCardProps {
|
||||
showFeaturesSummary?: boolean;
|
||||
}
|
||||
|
||||
const statusIconMap: Record<string, React.ReactNode> = {
|
||||
const STATUS_ICON_MAP: Record<SimStatus, React.ReactNode> = {
|
||||
active: <CheckCircleIcon className="h-5 w-5 text-green-500" />,
|
||||
suspended: <ExclamationTriangleIcon className="h-5 w-5 text-yellow-500" />,
|
||||
cancelled: <XCircleIcon className="h-5 w-5 text-red-500" />,
|
||||
pending: <ClockIcon className="h-5 w-5 text-blue-500" />,
|
||||
};
|
||||
|
||||
const statusBadgeClass: Record<string, string> = {
|
||||
const STATUS_BADGE_CLASS_MAP: Record<SimStatus, string> = {
|
||||
active: "bg-green-100 text-green-800",
|
||||
suspended: "bg-yellow-100 text-yellow-800",
|
||||
cancelled: "bg-red-100 text-red-800",
|
||||
@ -107,11 +107,10 @@ export function SimDetailsCard({
|
||||
}
|
||||
|
||||
const planName = simDetails.planName || formatPlanShort(simDetails.planCode) || "SIM Plan";
|
||||
const normalizedStatus = simDetails.status?.toLowerCase() ?? "unknown";
|
||||
const statusIcon = statusIconMap[normalizedStatus] ?? (
|
||||
const statusIcon = STATUS_ICON_MAP[simDetails.status] ?? (
|
||||
<DevicePhoneMobileIcon className="h-5 w-5 text-gray-500" />
|
||||
);
|
||||
const statusClass = statusBadgeClass[normalizedStatus] ?? "bg-gray-100 text-gray-800";
|
||||
const statusClass = STATUS_BADGE_CLASS_MAP[simDetails.status] ?? "bg-gray-100 text-gray-800";
|
||||
const containerClasses = embedded ? "" : "bg-white shadow-lg rounded-xl border border-gray-100";
|
||||
|
||||
return (
|
||||
|
||||
@ -2,14 +2,7 @@
|
||||
|
||||
import { forwardRef } from "react";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ClockIcon,
|
||||
XCircleIcon,
|
||||
CalendarIcon,
|
||||
ArrowTopRightOnSquareIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { CalendarIcon, ArrowTopRightOnSquareIcon } from "@heroicons/react/24/outline";
|
||||
import { StatusPill } from "@/components/atoms/status-pill";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
||||
@ -17,6 +10,11 @@ import type { Subscription } from "@customer-portal/domain/subscriptions";
|
||||
|
||||
import { useFormatCurrency } from "@/lib/hooks/useFormatCurrency";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getBillingCycleLabel,
|
||||
getSubscriptionStatusIcon,
|
||||
getSubscriptionStatusVariant,
|
||||
} from "@/features/subscriptions/utils/status-presenters";
|
||||
|
||||
interface SubscriptionCardProps {
|
||||
subscription: Subscription;
|
||||
@ -26,38 +24,6 @@ interface SubscriptionCardProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case "Active":
|
||||
return <CheckCircleIcon className="h-5 w-5 text-green-500" />;
|
||||
case "Suspended":
|
||||
return <ExclamationTriangleIcon className="h-5 w-5 text-yellow-500" />;
|
||||
case "Pending":
|
||||
return <ClockIcon className="h-5 w-5 text-blue-500" />;
|
||||
case "Cancelled":
|
||||
case "Terminated":
|
||||
return <XCircleIcon className="h-5 w-5 text-gray-500" />;
|
||||
default:
|
||||
return <ClockIcon className="h-5 w-5 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusVariant = (status: string) => {
|
||||
switch (status) {
|
||||
case "Active":
|
||||
return "success" as const;
|
||||
case "Suspended":
|
||||
return "warning" as const;
|
||||
case "Pending":
|
||||
return "info" as const;
|
||||
case "Cancelled":
|
||||
case "Terminated":
|
||||
return "neutral" as const;
|
||||
default:
|
||||
return "neutral" as const;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string | undefined) => {
|
||||
if (!dateString) return "N/A";
|
||||
try {
|
||||
@ -67,12 +33,6 @@ const formatDate = (dateString: string | undefined) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getBillingCycleLabel = (cycle: string) => {
|
||||
const name = cycle.toLowerCase();
|
||||
const looksLikeActivation = name.includes("activation") || name.includes("setup");
|
||||
return looksLikeActivation ? "One-time" : cycle;
|
||||
};
|
||||
|
||||
export const SubscriptionCard = forwardRef<HTMLDivElement, SubscriptionCardProps>(
|
||||
({ subscription, variant = "list", showActions = true, onViewClick, className }, ref) => {
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
@ -90,7 +50,7 @@ export const SubscriptionCard = forwardRef<HTMLDivElement, SubscriptionCardProps
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
{getStatusIcon(subscription.status)}
|
||||
{getSubscriptionStatusIcon(subscription.status)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
||||
{subscription.productName}
|
||||
@ -100,7 +60,7 @@ export const SubscriptionCard = forwardRef<HTMLDivElement, SubscriptionCardProps
|
||||
</div>
|
||||
<StatusPill
|
||||
label={subscription.status}
|
||||
variant={getStatusVariant(subscription.status)}
|
||||
variant={getSubscriptionStatusVariant(subscription.status)}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
@ -149,7 +109,7 @@ export const SubscriptionCard = forwardRef<HTMLDivElement, SubscriptionCardProps
|
||||
<SubCard ref={ref} className={cn("hover:shadow-md transition-all duration-200", className)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4 min-w-0 flex-1">
|
||||
{getStatusIcon(subscription.status)}
|
||||
{getSubscriptionStatusIcon(subscription.status)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center space-x-3">
|
||||
<h3 className="text-base font-semibold text-gray-900 truncate">
|
||||
@ -157,7 +117,7 @@ export const SubscriptionCard = forwardRef<HTMLDivElement, SubscriptionCardProps
|
||||
</h3>
|
||||
<StatusPill
|
||||
label={subscription.status}
|
||||
variant={getStatusVariant(subscription.status)}
|
||||
variant={getSubscriptionStatusVariant(subscription.status)}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -4,10 +4,6 @@ import { forwardRef } from "react";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
ServerIcon,
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ClockIcon,
|
||||
XCircleIcon,
|
||||
CalendarIcon,
|
||||
CurrencyYenIcon,
|
||||
IdentificationIcon,
|
||||
@ -19,6 +15,11 @@ import type { Subscription } from "@customer-portal/domain/subscriptions";
|
||||
|
||||
import { useFormatCurrency } from "@/lib/hooks/useFormatCurrency";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getBillingCycleLabel,
|
||||
getSubscriptionStatusIcon,
|
||||
getSubscriptionStatusVariant,
|
||||
} from "@/features/subscriptions/utils/status-presenters";
|
||||
|
||||
interface SubscriptionDetailsProps {
|
||||
subscription: Subscription;
|
||||
@ -26,40 +27,6 @@ interface SubscriptionDetailsProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case "Active":
|
||||
return <CheckCircleIcon className="h-6 w-6 text-green-500" />;
|
||||
case "Suspended":
|
||||
return <ExclamationTriangleIcon className="h-6 w-6 text-yellow-500" />;
|
||||
case "Terminated":
|
||||
return <XCircleIcon className="h-6 w-6 text-red-500" />;
|
||||
case "Cancelled":
|
||||
return <XCircleIcon className="h-6 w-6 text-gray-500" />;
|
||||
case "Pending":
|
||||
return <ClockIcon className="h-6 w-6 text-blue-500" />;
|
||||
default:
|
||||
return <ServerIcon className="h-6 w-6 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusVariant = (status: string) => {
|
||||
switch (status) {
|
||||
case "Active":
|
||||
return "success" as const;
|
||||
case "Suspended":
|
||||
return "warning" as const;
|
||||
case "Terminated":
|
||||
return "error" as const;
|
||||
case "Cancelled":
|
||||
return "neutral" as const;
|
||||
case "Pending":
|
||||
return "info" as const;
|
||||
default:
|
||||
return "neutral" as const;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string | undefined) => {
|
||||
if (!dateString) return "N/A";
|
||||
try {
|
||||
@ -69,25 +36,6 @@ const formatDate = (dateString: string | undefined) => {
|
||||
}
|
||||
};
|
||||
|
||||
const formatBillingLabel = (cycle: string) => {
|
||||
switch (cycle) {
|
||||
case "Monthly":
|
||||
return "Monthly Billing";
|
||||
case "Annually":
|
||||
return "Annual Billing";
|
||||
case "Quarterly":
|
||||
return "Quarterly Billing";
|
||||
case "Semi-Annually":
|
||||
return "Semi-Annual Billing";
|
||||
case "Biennially":
|
||||
return "Biennial Billing";
|
||||
case "Triennially":
|
||||
return "Triennial Billing";
|
||||
default:
|
||||
return "One-time Payment";
|
||||
}
|
||||
};
|
||||
|
||||
const isSimService = (productName: string) => {
|
||||
return productName.toLowerCase().includes("sim");
|
||||
};
|
||||
@ -112,7 +60,7 @@ export const SubscriptionDetails = forwardRef<HTMLDivElement, SubscriptionDetail
|
||||
header={
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
{getStatusIcon(subscription.status)}
|
||||
{getSubscriptionStatusIcon(subscription.status)}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Subscription Details</h3>
|
||||
<p className="text-sm text-gray-500">Service subscription information</p>
|
||||
@ -120,7 +68,7 @@ export const SubscriptionDetails = forwardRef<HTMLDivElement, SubscriptionDetail
|
||||
</div>
|
||||
<StatusPill
|
||||
label={subscription.status}
|
||||
variant={getStatusVariant(subscription.status)}
|
||||
variant={getSubscriptionStatusVariant(subscription.status)}
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
@ -138,7 +86,7 @@ export const SubscriptionDetails = forwardRef<HTMLDivElement, SubscriptionDetail
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{formatCurrency(subscription.amount)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">{formatBillingLabel(subscription.cycle)}</p>
|
||||
<p className="text-sm text-gray-500">{getBillingCycleLabel(subscription.cycle)}</p>
|
||||
</div>
|
||||
|
||||
{/* Next Due Date */}
|
||||
|
||||
@ -1,21 +1,22 @@
|
||||
"use client";
|
||||
|
||||
export function SubscriptionStatusBadge({ status }: { status: string }) {
|
||||
const color = (() => {
|
||||
switch (status) {
|
||||
case "Active":
|
||||
return "bg-green-100 text-green-800";
|
||||
case "Suspended":
|
||||
return "bg-yellow-100 text-yellow-800";
|
||||
case "Pending":
|
||||
return "bg-blue-100 text-blue-800";
|
||||
case "Cancelled":
|
||||
case "Terminated":
|
||||
return "bg-gray-100 text-gray-800";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800";
|
||||
}
|
||||
})();
|
||||
import {
|
||||
SUBSCRIPTION_STATUS,
|
||||
type SubscriptionStatus,
|
||||
} from "@customer-portal/domain/subscriptions";
|
||||
|
||||
const STATUS_COLOR_MAP: Record<SubscriptionStatus, string> = {
|
||||
[SUBSCRIPTION_STATUS.ACTIVE]: "bg-green-100 text-green-800",
|
||||
[SUBSCRIPTION_STATUS.INACTIVE]: "bg-gray-100 text-gray-800",
|
||||
[SUBSCRIPTION_STATUS.PENDING]: "bg-blue-100 text-blue-800",
|
||||
[SUBSCRIPTION_STATUS.SUSPENDED]: "bg-yellow-100 text-yellow-800",
|
||||
[SUBSCRIPTION_STATUS.CANCELLED]: "bg-gray-100 text-gray-800",
|
||||
[SUBSCRIPTION_STATUS.TERMINATED]: "bg-red-100 text-red-800",
|
||||
[SUBSCRIPTION_STATUS.COMPLETED]: "bg-green-100 text-green-800",
|
||||
};
|
||||
|
||||
export function SubscriptionStatusBadge({ status }: { status: SubscriptionStatus }) {
|
||||
const color = STATUS_COLOR_MAP[status] ?? "bg-gray-100 text-gray-800";
|
||||
|
||||
return (
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${color}`}>
|
||||
|
||||
@ -0,0 +1,59 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ClockIcon,
|
||||
XCircleIcon,
|
||||
ServerIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import {
|
||||
SUBSCRIPTION_STATUS,
|
||||
SUBSCRIPTION_CYCLE,
|
||||
type SubscriptionStatus,
|
||||
type SubscriptionCycle,
|
||||
} from "@customer-portal/domain/subscriptions";
|
||||
|
||||
export type SubscriptionStatusVariant = "success" | "info" | "warning" | "neutral" | "error";
|
||||
|
||||
const STATUS_ICON_MAP: Record<SubscriptionStatus, ReactNode> = {
|
||||
[SUBSCRIPTION_STATUS.ACTIVE]: <CheckCircleIcon className="h-6 w-6 text-green-500" />,
|
||||
[SUBSCRIPTION_STATUS.INACTIVE]: <ServerIcon className="h-6 w-6 text-gray-500" />,
|
||||
[SUBSCRIPTION_STATUS.PENDING]: <ClockIcon className="h-6 w-6 text-blue-500" />,
|
||||
[SUBSCRIPTION_STATUS.SUSPENDED]: <ExclamationTriangleIcon className="h-6 w-6 text-yellow-500" />,
|
||||
[SUBSCRIPTION_STATUS.TERMINATED]: <XCircleIcon className="h-6 w-6 text-red-500" />,
|
||||
[SUBSCRIPTION_STATUS.CANCELLED]: <XCircleIcon className="h-6 w-6 text-gray-500" />,
|
||||
[SUBSCRIPTION_STATUS.COMPLETED]: <CheckCircleIcon className="h-6 w-6 text-green-500" />,
|
||||
};
|
||||
|
||||
const STATUS_VARIANT_MAP: Record<SubscriptionStatus, SubscriptionStatusVariant> = {
|
||||
[SUBSCRIPTION_STATUS.ACTIVE]: "success",
|
||||
[SUBSCRIPTION_STATUS.INACTIVE]: "neutral",
|
||||
[SUBSCRIPTION_STATUS.PENDING]: "info",
|
||||
[SUBSCRIPTION_STATUS.SUSPENDED]: "warning",
|
||||
[SUBSCRIPTION_STATUS.TERMINATED]: "error",
|
||||
[SUBSCRIPTION_STATUS.CANCELLED]: "neutral",
|
||||
[SUBSCRIPTION_STATUS.COMPLETED]: "success",
|
||||
};
|
||||
|
||||
export function getSubscriptionStatusIcon(status: SubscriptionStatus): ReactNode {
|
||||
return STATUS_ICON_MAP[status] ?? <ServerIcon className="h-6 w-6 text-gray-500" />;
|
||||
}
|
||||
|
||||
export function getSubscriptionStatusVariant(status: SubscriptionStatus): SubscriptionStatusVariant {
|
||||
return STATUS_VARIANT_MAP[status] ?? "neutral";
|
||||
}
|
||||
|
||||
const BILLING_LABEL_MAP: Partial<Record<SubscriptionCycle, string>> = {
|
||||
[SUBSCRIPTION_CYCLE.MONTHLY]: "Monthly Billing",
|
||||
[SUBSCRIPTION_CYCLE.QUARTERLY]: "Quarterly Billing",
|
||||
[SUBSCRIPTION_CYCLE.SEMI_ANNUALLY]: "Semi-Annual Billing",
|
||||
[SUBSCRIPTION_CYCLE.ANNUALLY]: "Annual Billing",
|
||||
[SUBSCRIPTION_CYCLE.BIENNIALLY]: "Biennial Billing",
|
||||
[SUBSCRIPTION_CYCLE.TRIENNIALLY]: "Triennial Billing",
|
||||
[SUBSCRIPTION_CYCLE.ONE_TIME]: "One-time Payment",
|
||||
[SUBSCRIPTION_CYCLE.FREE]: "Free Plan",
|
||||
};
|
||||
|
||||
export function getBillingCycleLabel(cycle: SubscriptionCycle): string {
|
||||
return BILLING_LABEL_MAP[cycle] ?? cycle;
|
||||
}
|
||||
@ -9,10 +9,7 @@ import Link from "next/link";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ServerIcon,
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ClockIcon,
|
||||
XCircleIcon,
|
||||
CalendarIcon,
|
||||
DocumentTextIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
@ -23,6 +20,11 @@ import { Formatting } from "@customer-portal/domain/toolkit";
|
||||
|
||||
const { formatCurrency: sharedFormatCurrency } = Formatting;
|
||||
import { SimManagementSection } from "@/features/sim-management";
|
||||
import {
|
||||
getBillingCycleLabel,
|
||||
getSubscriptionStatusIcon,
|
||||
getSubscriptionStatusVariant,
|
||||
} from "@/features/subscriptions/utils/status-presenters";
|
||||
|
||||
export function SubscriptionDetailContainer() {
|
||||
const params = useParams();
|
||||
@ -54,23 +56,6 @@ export function SubscriptionDetailContainer() {
|
||||
return;
|
||||
}, [searchParams]);
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case "Active":
|
||||
return <CheckCircleIcon className="h-6 w-6 text-green-500" />;
|
||||
case "Suspended":
|
||||
return <ExclamationTriangleIcon className="h-6 w-6 text-yellow-500" />;
|
||||
case "Terminated":
|
||||
return <XCircleIcon className="h-6 w-6 text-red-500" />;
|
||||
case "Cancelled":
|
||||
return <XCircleIcon className="h-6 w-6 text-gray-500" />;
|
||||
case "Pending":
|
||||
return <ClockIcon className="h-6 w-6 text-blue-500" />;
|
||||
default:
|
||||
return <ServerIcon className="h-6 w-6 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string | undefined) => {
|
||||
if (!dateString) return "N/A";
|
||||
try {
|
||||
@ -153,17 +138,10 @@ export function SubscriptionDetailContainer() {
|
||||
<DetailHeader
|
||||
title="Subscription Details"
|
||||
subtitle="Service subscription information"
|
||||
leftIcon={getStatusIcon(subscription.status)}
|
||||
leftIcon={getSubscriptionStatusIcon(subscription.status)}
|
||||
status={{
|
||||
label: subscription.status,
|
||||
variant:
|
||||
subscription.status === "Active"
|
||||
? "success"
|
||||
: subscription.status === "Suspended"
|
||||
? "warning"
|
||||
: ["Cancelled", "Terminated"].includes(subscription.status)
|
||||
? "neutral"
|
||||
: "info",
|
||||
variant: getSubscriptionStatusVariant(subscription.status),
|
||||
}}
|
||||
/>
|
||||
<div className="pt-4">
|
||||
@ -176,7 +154,9 @@ export function SubscriptionDetailContainer() {
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{formatCurrency(subscription.amount)}
|
||||
</p>
|
||||
<span className="text-sm text-gray-500">{subscription.cycle}</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
{getBillingCycleLabel(subscription.cycle)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@ -11,11 +11,17 @@ import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
|
||||
import { SubscriptionTable } from "@/features/subscriptions/components/SubscriptionTable";
|
||||
import { ServerIcon, CheckCircleIcon, XCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { useSubscriptions, useSubscriptionStats } from "@/features/subscriptions/hooks";
|
||||
import type { Subscription } from "@customer-portal/domain/subscriptions";
|
||||
import {
|
||||
SUBSCRIPTION_STATUS,
|
||||
type Subscription,
|
||||
type SubscriptionStatus,
|
||||
} from "@customer-portal/domain/subscriptions";
|
||||
|
||||
const SUBSCRIPTION_STATUS_OPTIONS = Object.values(SUBSCRIPTION_STATUS) as SubscriptionStatus[];
|
||||
|
||||
export function SubscriptionsListContainer() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [statusFilter, setStatusFilter] = useState<SubscriptionStatus | "all">("all");
|
||||
|
||||
const {
|
||||
data: subscriptionData,
|
||||
@ -40,12 +46,16 @@ export function SubscriptionsListContainer() {
|
||||
});
|
||||
}, [subscriptions, searchTerm]);
|
||||
|
||||
const statusFilterOptions = [
|
||||
{ value: "all", label: "All Status" },
|
||||
{ value: "Active", label: "Active" },
|
||||
{ value: "Completed", label: "Completed" },
|
||||
{ value: "Cancelled", label: "Cancelled" },
|
||||
];
|
||||
const statusFilterOptions = useMemo(
|
||||
() => [
|
||||
{ value: "all" as const, label: "All Status" },
|
||||
...SUBSCRIPTION_STATUS_OPTIONS.map(status => ({
|
||||
value: status,
|
||||
label: status,
|
||||
})),
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
if (isLoading || error) {
|
||||
return (
|
||||
@ -142,7 +152,7 @@ export function SubscriptionsListContainer() {
|
||||
onSearchChange={setSearchTerm}
|
||||
searchPlaceholder="Search subscriptions..."
|
||||
filterValue={statusFilter}
|
||||
onFilterChange={setStatusFilter}
|
||||
onFilterChange={value => setStatusFilter(value as SubscriptionStatus | "all")}
|
||||
filterOptions={statusFilterOptions}
|
||||
filterLabel="Filter by status"
|
||||
/>
|
||||
|
||||
24
apps/portal/src/features/support/hooks/useSupportCases.ts
Normal file
24
apps/portal/src/features/support/hooks/useSupportCases.ts
Normal file
@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthSession } from "@/features/auth/services/auth.store";
|
||||
import { apiClient, getDataOrThrow, queryKeys } from "@/lib/api";
|
||||
import type { SupportCaseFilter, SupportCaseList } from "@customer-portal/domain/support";
|
||||
|
||||
export function useSupportCases(filters?: SupportCaseFilter) {
|
||||
const { isAuthenticated } = useAuthSession();
|
||||
const queryFilters = useMemo(() => filters ?? {}, [filters]);
|
||||
|
||||
return useQuery<SupportCaseList>({
|
||||
queryKey: queryKeys.support.cases(queryFilters),
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.GET<SupportCaseList>("/api/support/cases", {
|
||||
params: Object.keys(queryFilters).length > 0 ? { query: queryFilters } : undefined,
|
||||
});
|
||||
return getDataOrThrow(response, "Failed to load support cases");
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDeferredValue, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ChatBubbleLeftRightIcon,
|
||||
@ -14,162 +14,106 @@ import {
|
||||
UserIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { format } from "date-fns";
|
||||
|
||||
interface SupportCase {
|
||||
id: number;
|
||||
subject: string;
|
||||
status: "Open" | "In Progress" | "Waiting on Customer" | "Resolved" | "Closed";
|
||||
priority: "Low" | "Medium" | "High" | "Critical";
|
||||
category: "Technical" | "Billing" | "General" | "Feature Request";
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastReply?: string;
|
||||
description: string;
|
||||
assignedTo?: string;
|
||||
}
|
||||
import { useSupportCases } from "@/features/support/hooks/useSupportCases";
|
||||
import {
|
||||
SUPPORT_CASE_PRIORITY,
|
||||
SUPPORT_CASE_STATUS,
|
||||
type SupportCaseFilter,
|
||||
type SupportCasePriority,
|
||||
type SupportCaseStatus,
|
||||
} from "@customer-portal/domain/support";
|
||||
|
||||
export function SupportCasesView() {
|
||||
const [cases, setCases] = useState<SupportCase[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [priorityFilter, setPriorityFilter] = useState("all");
|
||||
const [statusFilter, setStatusFilter] = useState<SupportCaseStatus | "all">("all");
|
||||
const [priorityFilter, setPriorityFilter] = useState<SupportCasePriority | "all">("all");
|
||||
const deferredSearchTerm = useDeferredValue(searchTerm);
|
||||
|
||||
// Mock data - would normally come from API
|
||||
useEffect(() => {
|
||||
const mockCases: SupportCase[] = [
|
||||
{
|
||||
id: 12001,
|
||||
subject: "VPS Performance Issues",
|
||||
status: "In Progress",
|
||||
priority: "High",
|
||||
category: "Technical",
|
||||
createdAt: "2025-08-14T10:30:00Z",
|
||||
updatedAt: "2025-08-15T14:20:00Z",
|
||||
lastReply: "2025-08-15T14:20:00Z",
|
||||
description: "Experiencing slow response times on VPS server, CPU usage appears high.",
|
||||
assignedTo: "Technical Support Team",
|
||||
},
|
||||
{
|
||||
id: 12002,
|
||||
subject: "Billing Question - Invoice #12345",
|
||||
status: "Waiting on Customer",
|
||||
priority: "Medium",
|
||||
category: "Billing",
|
||||
createdAt: "2025-08-13T16:45:00Z",
|
||||
updatedAt: "2025-08-14T09:30:00Z",
|
||||
lastReply: "2025-08-14T09:30:00Z",
|
||||
description: "Need clarification on charges in recent invoice.",
|
||||
assignedTo: "Billing Department",
|
||||
},
|
||||
{
|
||||
id: 12003,
|
||||
subject: "SSL Certificate Installation",
|
||||
status: "Resolved",
|
||||
priority: "Low",
|
||||
category: "Technical",
|
||||
createdAt: "2025-08-12T08:15:00Z",
|
||||
updatedAt: "2025-08-12T15:45:00Z",
|
||||
lastReply: "2025-08-12T15:45:00Z",
|
||||
description: "Request assistance with SSL certificate installation on shared hosting.",
|
||||
assignedTo: "Technical Support Team",
|
||||
},
|
||||
{
|
||||
id: 12004,
|
||||
subject: "Feature Request: Control Panel Enhancement",
|
||||
status: "Open",
|
||||
priority: "Low",
|
||||
category: "Feature Request",
|
||||
createdAt: "2025-08-11T13:20:00Z",
|
||||
updatedAt: "2025-08-11T13:20:00Z",
|
||||
description: "Would like to see improved backup management in the control panel.",
|
||||
assignedTo: "Development Team",
|
||||
},
|
||||
{
|
||||
id: 12005,
|
||||
subject: "Server Migration Assistance",
|
||||
status: "Closed",
|
||||
priority: "Medium",
|
||||
category: "Technical",
|
||||
createdAt: "2025-08-10T11:00:00Z",
|
||||
updatedAt: "2025-08-11T17:30:00Z",
|
||||
lastReply: "2025-08-11T17:30:00Z",
|
||||
description: "Need help migrating website from old server to new VPS.",
|
||||
assignedTo: "Migration Team",
|
||||
},
|
||||
];
|
||||
const queryFilters = useMemo(() => {
|
||||
const nextFilters: SupportCaseFilter = {};
|
||||
if (statusFilter !== "all") {
|
||||
nextFilters.status = statusFilter;
|
||||
}
|
||||
if (priorityFilter !== "all") {
|
||||
nextFilters.priority = priorityFilter;
|
||||
}
|
||||
if (deferredSearchTerm.trim()) {
|
||||
nextFilters.search = deferredSearchTerm.trim();
|
||||
}
|
||||
return nextFilters;
|
||||
}, [statusFilter, priorityFilter, deferredSearchTerm]);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
setCases(mockCases);
|
||||
setLoading(false);
|
||||
}, 500);
|
||||
const { data, isLoading, error } = useSupportCases(queryFilters);
|
||||
const cases = data?.cases ?? [];
|
||||
const summary = data?.summary ?? { total: 0, open: 0, highPriority: 0, resolved: 0 };
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, []);
|
||||
const statusFilterOptions = useMemo(
|
||||
() => [
|
||||
{ value: "all" as const, label: "All Statuses" },
|
||||
...Object.values(SUPPORT_CASE_STATUS).map(status => ({ value: status, label: status })),
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
// Filter cases based on search, status, and priority
|
||||
const filteredCases = cases.filter(supportCase => {
|
||||
const matchesSearch =
|
||||
supportCase.subject.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
supportCase.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
supportCase.id.toString().includes(searchTerm);
|
||||
const matchesStatus =
|
||||
statusFilter === "all" ||
|
||||
supportCase.status.toLowerCase().replace(/\s+/g, "").includes(statusFilter.toLowerCase());
|
||||
const matchesPriority =
|
||||
priorityFilter === "all" ||
|
||||
supportCase.priority.toLowerCase() === priorityFilter.toLowerCase();
|
||||
return matchesSearch && matchesStatus && matchesPriority;
|
||||
});
|
||||
const priorityFilterOptions = useMemo(
|
||||
() => [
|
||||
{ value: "all" as const, label: "All Priorities" },
|
||||
...Object.values(SUPPORT_CASE_PRIORITY).map(priority => ({
|
||||
value: priority,
|
||||
label: priority,
|
||||
})),
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
const getStatusIcon = (status: SupportCaseStatus) => {
|
||||
switch (status) {
|
||||
case "Resolved":
|
||||
case "Closed":
|
||||
case SUPPORT_CASE_STATUS.RESOLVED:
|
||||
case SUPPORT_CASE_STATUS.CLOSED:
|
||||
return <CheckCircleIcon className="h-5 w-5 text-green-500" />;
|
||||
case "In Progress":
|
||||
case SUPPORT_CASE_STATUS.IN_PROGRESS:
|
||||
return <ClockIcon className="h-5 w-5 text-blue-500" />;
|
||||
case "Waiting on Customer":
|
||||
case SUPPORT_CASE_STATUS.WAITING_ON_CUSTOMER:
|
||||
return <ExclamationTriangleIcon className="h-5 w-5 text-yellow-500" />;
|
||||
case "Open":
|
||||
case SUPPORT_CASE_STATUS.OPEN:
|
||||
return <ChatBubbleLeftRightIcon className="h-5 w-5 text-gray-500" />;
|
||||
default:
|
||||
return <ChatBubbleLeftRightIcon className="h-5 w-5 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const getStatusColor = (status: SupportCaseStatus) => {
|
||||
switch (status) {
|
||||
case "Resolved":
|
||||
case "Closed":
|
||||
case SUPPORT_CASE_STATUS.RESOLVED:
|
||||
case SUPPORT_CASE_STATUS.CLOSED:
|
||||
return "bg-green-100 text-green-800";
|
||||
case "In Progress":
|
||||
case SUPPORT_CASE_STATUS.IN_PROGRESS:
|
||||
return "bg-blue-100 text-blue-800";
|
||||
case "Waiting on Customer":
|
||||
case SUPPORT_CASE_STATUS.WAITING_ON_CUSTOMER:
|
||||
return "bg-yellow-100 text-yellow-800";
|
||||
case "Open":
|
||||
case SUPPORT_CASE_STATUS.OPEN:
|
||||
return "bg-gray-100 text-gray-800";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800";
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
const getPriorityColor = (priority: SupportCasePriority) => {
|
||||
switch (priority) {
|
||||
case "Critical":
|
||||
case SUPPORT_CASE_PRIORITY.CRITICAL:
|
||||
return "bg-red-100 text-red-800";
|
||||
case "High":
|
||||
case SUPPORT_CASE_PRIORITY.HIGH:
|
||||
return "bg-orange-100 text-orange-800";
|
||||
case "Medium":
|
||||
case SUPPORT_CASE_PRIORITY.MEDIUM:
|
||||
return "bg-yellow-100 text-yellow-800";
|
||||
case "Low":
|
||||
case SUPPORT_CASE_PRIORITY.LOW:
|
||||
return "bg-green-100 text-green-800";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800";
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
@ -201,6 +145,12 @@ export function SupportCasesView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-800">
|
||||
{error instanceof Error ? error.message : "Failed to load support cases"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4 mb-8">
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
@ -212,7 +162,7 @@ export function SupportCasesView() {
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">Total Cases</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">{cases.length}</dd>
|
||||
<dd className="text-lg font-medium text-gray-900">{summary.total}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
@ -228,13 +178,7 @@ export function SupportCasesView() {
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">Open Cases</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">
|
||||
{
|
||||
cases.filter(caseItem =>
|
||||
["Open", "In Progress", "Waiting on Customer"].includes(caseItem.status)
|
||||
).length
|
||||
}
|
||||
</dd>
|
||||
<dd className="text-lg font-medium text-gray-900">{summary.open}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
@ -250,12 +194,7 @@ export function SupportCasesView() {
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">High Priority</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">
|
||||
{
|
||||
cases.filter(caseItem => ["High", "Critical"].includes(caseItem.priority))
|
||||
.length
|
||||
}
|
||||
</dd>
|
||||
<dd className="text-lg font-medium text-gray-900">{summary.highPriority}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
@ -271,12 +210,7 @@ export function SupportCasesView() {
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">Resolved</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">
|
||||
{
|
||||
cases.filter(caseItem => ["Resolved", "Closed"].includes(caseItem.status))
|
||||
.length
|
||||
}
|
||||
</dd>
|
||||
<dd className="text-lg font-medium text-gray-900">{summary.resolved}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
@ -306,15 +240,16 @@ export function SupportCasesView() {
|
||||
<div className="relative">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={event => setStatusFilter(event.target.value)}
|
||||
onChange={event =>
|
||||
setStatusFilter(event.target.value as SupportCaseStatus | "all")
|
||||
}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md leading-5 bg-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="open">Open</option>
|
||||
<option value="inprogress">In Progress</option>
|
||||
<option value="waiting">Waiting on Customer</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
<option value="closed">Closed</option>
|
||||
{statusFilterOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@ -322,14 +257,16 @@ export function SupportCasesView() {
|
||||
<div className="relative">
|
||||
<select
|
||||
value={priorityFilter}
|
||||
onChange={event => setPriorityFilter(event.target.value)}
|
||||
onChange={event =>
|
||||
setPriorityFilter(event.target.value as SupportCasePriority | "all")
|
||||
}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md leading-5 bg-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="all">All Priorities</option>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="high">High</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="low">Low</option>
|
||||
{priorityFilterOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@ -339,7 +276,7 @@ export function SupportCasesView() {
|
||||
{/* Cases List */}
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-md">
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{filteredCases.map(supportCase => (
|
||||
{cases.map(supportCase => (
|
||||
<li key={supportCase.id}>
|
||||
<Link
|
||||
href={`/support/cases/${supportCase.id}`}
|
||||
@ -400,7 +337,7 @@ export function SupportCasesView() {
|
||||
</ul>
|
||||
|
||||
{/* Empty State */}
|
||||
{filteredCases.length === 0 && (
|
||||
{cases.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<ChatBubbleLeftRightIcon className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No support cases found</h3>
|
||||
|
||||
@ -102,6 +102,9 @@ export const queryKeys = {
|
||||
list: () => ["orders", "list"] as const,
|
||||
detail: (id: string | number) => ["orders", "detail", String(id)] as const,
|
||||
},
|
||||
support: {
|
||||
cases: (params?: Record<string, unknown>) => ["support", "cases", params] as const,
|
||||
},
|
||||
currency: {
|
||||
default: () => ["currency", "default"] as const,
|
||||
},
|
||||
|
||||
@ -59,7 +59,7 @@ export class OrderEventsService {
|
||||
});
|
||||
}
|
||||
|
||||
publish(orderId: string, update: OrderUpdateEvent): void {
|
||||
publish(orderId: string, update: OrderUpdateEventPayload): void {
|
||||
const event = this.buildEvent("order.update", update);
|
||||
currentObservers.forEach(observer => observer.next(event));
|
||||
}
|
||||
@ -155,4 +155,3 @@ WebSockets provide bidirectional communication but are more complex to implement
|
||||
**Status**: Phase 1 & 2 Complete, Phase 3 (monitoring) in progress
|
||||
**Last Updated**: November 2025
|
||||
**Owner**: Engineering Team
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@ export * as Billing from "./billing/index";
|
||||
export * as Subscriptions from "./subscriptions/index";
|
||||
export * as Payments from "./payments/index";
|
||||
export * as Sim from "./sim/index";
|
||||
export * as Support from "./support/index";
|
||||
export * as Orders from "./orders/index";
|
||||
export * as Catalog from "./catalog/index";
|
||||
export * as Common from "./common/index";
|
||||
|
||||
26
packages/domain/orders/events.ts
Normal file
26
packages/domain/orders/events.ts
Normal file
@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Order Events - Shared Contracts
|
||||
*
|
||||
* Shared event payloads for Server-Sent Events used by both the BFF
|
||||
* and the frontend. Keeping these definitions in the domain package
|
||||
* guarantees both layers stay in sync when the payload evolves.
|
||||
*/
|
||||
|
||||
export type OrderUpdateStage = "started" | "in_progress" | "completed" | "failed";
|
||||
|
||||
export interface OrderStreamEvent<T extends string = string, P = unknown> {
|
||||
event: T;
|
||||
data: P;
|
||||
}
|
||||
|
||||
export interface OrderUpdateEventPayload {
|
||||
orderId: string;
|
||||
status?: string;
|
||||
activationStatus?: string | null;
|
||||
message?: string;
|
||||
reason?: string;
|
||||
stage?: OrderUpdateStage;
|
||||
source?: string;
|
||||
timestamp: string;
|
||||
payload?: Record<string, unknown> | null;
|
||||
}
|
||||
@ -40,6 +40,7 @@ export * from "./validation";
|
||||
|
||||
// Utilities
|
||||
export * from "./utils";
|
||||
export * from "./events";
|
||||
export {
|
||||
buildSimOrderConfigurations,
|
||||
normalizeBillingCycle,
|
||||
|
||||
18
packages/domain/sim/helpers.ts
Normal file
18
packages/domain/sim/helpers.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { SIM_STATUS } from "./contract";
|
||||
import type { SimStatus } from "./schema";
|
||||
|
||||
export function canManageActiveSim(status: SimStatus): boolean {
|
||||
return status === SIM_STATUS.ACTIVE;
|
||||
}
|
||||
|
||||
export function canReissueEsim(status: SimStatus): boolean {
|
||||
return canManageActiveSim(status);
|
||||
}
|
||||
|
||||
export function canCancelSim(status: SimStatus): boolean {
|
||||
return canManageActiveSim(status);
|
||||
}
|
||||
|
||||
export function canTopUpSim(status: SimStatus): boolean {
|
||||
return canManageActiveSim(status);
|
||||
}
|
||||
@ -15,6 +15,7 @@ export * from "./lifecycle";
|
||||
|
||||
// Validation functions
|
||||
export * from "./validation";
|
||||
export * from "./helpers";
|
||||
|
||||
// Re-export types for convenience
|
||||
export type {
|
||||
|
||||
27
packages/domain/support/contract.ts
Normal file
27
packages/domain/support/contract.ts
Normal file
@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Support Domain - Contract
|
||||
*
|
||||
* Constants for support case statuses, priorities, and categories.
|
||||
*/
|
||||
|
||||
export const SUPPORT_CASE_STATUS = {
|
||||
OPEN: "Open",
|
||||
IN_PROGRESS: "In Progress",
|
||||
WAITING_ON_CUSTOMER: "Waiting on Customer",
|
||||
RESOLVED: "Resolved",
|
||||
CLOSED: "Closed",
|
||||
} as const;
|
||||
|
||||
export const SUPPORT_CASE_PRIORITY = {
|
||||
LOW: "Low",
|
||||
MEDIUM: "Medium",
|
||||
HIGH: "High",
|
||||
CRITICAL: "Critical",
|
||||
} as const;
|
||||
|
||||
export const SUPPORT_CASE_CATEGORY = {
|
||||
TECHNICAL: "Technical",
|
||||
BILLING: "Billing",
|
||||
GENERAL: "General",
|
||||
FEATURE_REQUEST: "Feature Request",
|
||||
} as const;
|
||||
7
packages/domain/support/index.ts
Normal file
7
packages/domain/support/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export {
|
||||
SUPPORT_CASE_STATUS,
|
||||
SUPPORT_CASE_PRIORITY,
|
||||
SUPPORT_CASE_CATEGORY,
|
||||
} from "./contract";
|
||||
|
||||
export * from "./schema";
|
||||
74
packages/domain/support/schema.ts
Normal file
74
packages/domain/support/schema.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
SUPPORT_CASE_STATUS,
|
||||
SUPPORT_CASE_PRIORITY,
|
||||
SUPPORT_CASE_CATEGORY,
|
||||
} from "./contract";
|
||||
|
||||
const supportCaseStatusValues = [
|
||||
SUPPORT_CASE_STATUS.OPEN,
|
||||
SUPPORT_CASE_STATUS.IN_PROGRESS,
|
||||
SUPPORT_CASE_STATUS.WAITING_ON_CUSTOMER,
|
||||
SUPPORT_CASE_STATUS.RESOLVED,
|
||||
SUPPORT_CASE_STATUS.CLOSED,
|
||||
] as const;
|
||||
|
||||
const supportCasePriorityValues = [
|
||||
SUPPORT_CASE_PRIORITY.LOW,
|
||||
SUPPORT_CASE_PRIORITY.MEDIUM,
|
||||
SUPPORT_CASE_PRIORITY.HIGH,
|
||||
SUPPORT_CASE_PRIORITY.CRITICAL,
|
||||
] as const;
|
||||
|
||||
const supportCaseCategoryValues = [
|
||||
SUPPORT_CASE_CATEGORY.TECHNICAL,
|
||||
SUPPORT_CASE_CATEGORY.BILLING,
|
||||
SUPPORT_CASE_CATEGORY.GENERAL,
|
||||
SUPPORT_CASE_CATEGORY.FEATURE_REQUEST,
|
||||
] as const;
|
||||
|
||||
export const supportCaseStatusSchema = z.enum(supportCaseStatusValues);
|
||||
export const supportCasePrioritySchema = z.enum(supportCasePriorityValues);
|
||||
export const supportCaseCategorySchema = z.enum(supportCaseCategoryValues);
|
||||
|
||||
export const supportCaseSchema = z.object({
|
||||
id: z.number().int().positive(),
|
||||
subject: z.string().min(1),
|
||||
status: supportCaseStatusSchema,
|
||||
priority: supportCasePrioritySchema,
|
||||
category: supportCaseCategorySchema,
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
lastReply: z.string().optional(),
|
||||
description: z.string(),
|
||||
assignedTo: z.string().optional(),
|
||||
});
|
||||
|
||||
export const supportCaseSummarySchema = z.object({
|
||||
total: z.number().int().nonnegative(),
|
||||
open: z.number().int().nonnegative(),
|
||||
highPriority: z.number().int().nonnegative(),
|
||||
resolved: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
export const supportCaseListSchema = z.object({
|
||||
cases: z.array(supportCaseSchema),
|
||||
summary: supportCaseSummarySchema,
|
||||
});
|
||||
|
||||
export const supportCaseFilterSchema = z
|
||||
.object({
|
||||
status: supportCaseStatusSchema.optional(),
|
||||
priority: supportCasePrioritySchema.optional(),
|
||||
category: supportCaseCategorySchema.optional(),
|
||||
search: z.string().trim().min(1).optional(),
|
||||
})
|
||||
.default({});
|
||||
|
||||
export type SupportCaseStatus = z.infer<typeof supportCaseStatusSchema>;
|
||||
export type SupportCasePriority = z.infer<typeof supportCasePrioritySchema>;
|
||||
export type SupportCaseCategory = z.infer<typeof supportCaseCategorySchema>;
|
||||
export type SupportCase = z.infer<typeof supportCaseSchema>;
|
||||
export type SupportCaseSummary = z.infer<typeof supportCaseSummarySchema>;
|
||||
export type SupportCaseList = z.infer<typeof supportCaseListSchema>;
|
||||
export type SupportCaseFilter = z.infer<typeof supportCaseFilterSchema>;
|
||||
Loading…
x
Reference in New Issue
Block a user