Implement call and SMS history features in SIM management
- Added endpoints for fetching domestic and international call history, as well as SMS history. - Introduced methods to retrieve available months for call/SMS history. - Updated the SubscriptionsController to include new routes for call history management. - Enhanced the SimManagementSection UI to navigate to call history. - Improved the sim-actions service to handle call/SMS history requests and responses.
This commit is contained in:
parent
f49e5d7574
commit
e02ff17217
@ -167,7 +167,7 @@ export class SftpClientService implements OnModuleDestroy {
|
||||
*/
|
||||
async downloadTalkDetail(yearMonth: string): Promise<string> {
|
||||
const fileName = this.getTalkDetailFileName(yearMonth);
|
||||
return this.downloadFileAsString(`/${fileName}`);
|
||||
return this.downloadFileAsString(`/home/PASI/${fileName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -175,7 +175,7 @@ export class SftpClientService implements OnModuleDestroy {
|
||||
*/
|
||||
async downloadSmsDetail(yearMonth: string): Promise<string> {
|
||||
const fileName = this.getSmsDetailFileName(yearMonth);
|
||||
return this.downloadFileAsString(`/${fileName}`);
|
||||
return this.downloadFileAsString(`/home/PASI/${fileName}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
9
apps/bff/src/integrations/sftp/sftp.module.ts
Normal file
9
apps/bff/src/integrations/sftp/sftp.module.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { SftpClientService } from "./sftp-client.service";
|
||||
|
||||
@Module({
|
||||
providers: [SftpClientService],
|
||||
exports: [SftpClientService],
|
||||
})
|
||||
export class SftpModule {}
|
||||
|
||||
@ -0,0 +1,675 @@
|
||||
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { PrismaService } from "@bff/infra/database/prisma.service";
|
||||
import { SftpClientService } from "@bff/integrations/sftp/sftp-client.service";
|
||||
import { SimValidationService } from "./sim-validation.service";
|
||||
|
||||
// SmsType enum to match Prisma schema
|
||||
type SmsType = "DOMESTIC" | "INTERNATIONAL";
|
||||
|
||||
export interface DomesticCallRecord {
|
||||
account: string;
|
||||
callDate: Date;
|
||||
callTime: string;
|
||||
calledTo: string;
|
||||
location: string | null;
|
||||
durationSec: number;
|
||||
chargeYen: number;
|
||||
month: string;
|
||||
}
|
||||
|
||||
export interface InternationalCallRecord {
|
||||
account: string;
|
||||
callDate: Date;
|
||||
startTime: string;
|
||||
stopTime: string | null;
|
||||
country: string | null;
|
||||
calledTo: string;
|
||||
durationSec: number;
|
||||
chargeYen: number;
|
||||
month: string;
|
||||
}
|
||||
|
||||
export interface SmsRecord {
|
||||
account: string;
|
||||
smsDate: Date;
|
||||
smsTime: string;
|
||||
sentTo: string;
|
||||
smsType: SmsType;
|
||||
month: string;
|
||||
}
|
||||
|
||||
export interface CallHistoryPagination {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface DomesticCallHistoryResponse {
|
||||
calls: Array<{
|
||||
id: string;
|
||||
date: string;
|
||||
time: string;
|
||||
calledTo: string;
|
||||
callLength: string; // Formatted as "Xh Xm Xs"
|
||||
callCharge: number;
|
||||
}>;
|
||||
pagination: CallHistoryPagination;
|
||||
month: string;
|
||||
}
|
||||
|
||||
export interface InternationalCallHistoryResponse {
|
||||
calls: Array<{
|
||||
id: string;
|
||||
date: string;
|
||||
startTime: string;
|
||||
stopTime: string | null;
|
||||
country: string | null;
|
||||
calledTo: string;
|
||||
callCharge: number;
|
||||
}>;
|
||||
pagination: CallHistoryPagination;
|
||||
month: string;
|
||||
}
|
||||
|
||||
export interface SmsHistoryResponse {
|
||||
messages: Array<{
|
||||
id: string;
|
||||
date: string;
|
||||
time: string;
|
||||
sentTo: string;
|
||||
type: string;
|
||||
}>;
|
||||
pagination: CallHistoryPagination;
|
||||
month: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SimCallHistoryService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly sftp: SftpClientService,
|
||||
private readonly simValidation: SimValidationService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Parse talk detail CSV content
|
||||
* Columns:
|
||||
* 1. Customer phone number
|
||||
* 2. Date (YYYYMMDD)
|
||||
* 3. Start time (HHMMSS)
|
||||
* 4. Called to phone number
|
||||
* 5. dome/tointl
|
||||
* 6. Location
|
||||
* 7. Duration (320 = 32.0 seconds)
|
||||
* 8. Tokens (each token = 10 yen)
|
||||
* 9. Alternative charge (if column 6 says "他社")
|
||||
*/
|
||||
parseTalkDetailCsv(
|
||||
content: string,
|
||||
month: string
|
||||
): { domestic: DomesticCallRecord[]; international: InternationalCallRecord[] } {
|
||||
const domestic: DomesticCallRecord[] = [];
|
||||
const international: InternationalCallRecord[] = [];
|
||||
|
||||
const lines = content.split("\n").filter(line => line.trim());
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
try {
|
||||
// Parse CSV line - handle potential commas in values
|
||||
const columns = this.parseCsvLine(line);
|
||||
|
||||
if (columns.length < 8) {
|
||||
this.logger.debug(`Skipping line ${i + 1}: insufficient columns`, { line });
|
||||
continue;
|
||||
}
|
||||
|
||||
const [
|
||||
phoneNumber,
|
||||
dateStr,
|
||||
timeStr,
|
||||
calledTo,
|
||||
callType,
|
||||
location,
|
||||
durationStr,
|
||||
tokensStr,
|
||||
altChargeStr,
|
||||
] = columns;
|
||||
|
||||
// Parse date
|
||||
const callDate = this.parseDate(dateStr);
|
||||
if (!callDate) {
|
||||
this.logger.debug(`Skipping line ${i + 1}: invalid date`, { dateStr });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse duration (320 = 32.0 seconds)
|
||||
const durationSec = Math.round(parseInt(durationStr, 10) / 10);
|
||||
|
||||
// Parse charge: use tokens * 10 yen, or alt charge if location is "他社"
|
||||
let chargeYen: number;
|
||||
if (location && location.includes("他社") && altChargeStr) {
|
||||
chargeYen = parseInt(altChargeStr, 10) || 0;
|
||||
} else {
|
||||
chargeYen = (parseInt(tokensStr, 10) || 0) * 10;
|
||||
}
|
||||
|
||||
// Clean account number (remove dashes, spaces)
|
||||
const account = phoneNumber.replace(/[-\s]/g, "");
|
||||
|
||||
// Clean called-to number
|
||||
const cleanCalledTo = calledTo.replace(/[-\s]/g, "");
|
||||
|
||||
if (callType === "dome" || callType === "domestic") {
|
||||
domestic.push({
|
||||
account,
|
||||
callDate,
|
||||
callTime: timeStr,
|
||||
calledTo: cleanCalledTo,
|
||||
location: location || null,
|
||||
durationSec,
|
||||
chargeYen,
|
||||
month,
|
||||
});
|
||||
} else if (callType === "tointl" || callType === "international") {
|
||||
international.push({
|
||||
account,
|
||||
callDate,
|
||||
startTime: timeStr,
|
||||
stopTime: null, // Could be calculated from duration if needed
|
||||
country: location || null,
|
||||
calledTo: cleanCalledTo,
|
||||
durationSec,
|
||||
chargeYen,
|
||||
month,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to parse talk detail line ${i + 1}`, { line, error });
|
||||
}
|
||||
}
|
||||
|
||||
return { domestic, international };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse SMS detail CSV content
|
||||
* Columns:
|
||||
* 1. Customer phone number
|
||||
* 2. Date (YYYYMMDD)
|
||||
* 3. Start time (HHMMSS)
|
||||
* 4. SMS sent to phone number
|
||||
* 5. dome/tointl
|
||||
* 6. SMS type (SMS or 国際SMS)
|
||||
*/
|
||||
parseSmsDetailCsv(content: string, month: string): SmsRecord[] {
|
||||
const records: SmsRecord[] = [];
|
||||
|
||||
const lines = content.split("\n").filter(line => line.trim());
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
try {
|
||||
const columns = this.parseCsvLine(line);
|
||||
|
||||
if (columns.length < 6) {
|
||||
this.logger.debug(`Skipping SMS line ${i + 1}: insufficient columns`, { line });
|
||||
continue;
|
||||
}
|
||||
|
||||
const [phoneNumber, dateStr, timeStr, sentTo, _callType, smsTypeStr] = columns;
|
||||
|
||||
// Parse date
|
||||
const smsDate = this.parseDate(dateStr);
|
||||
if (!smsDate) {
|
||||
this.logger.debug(`Skipping SMS line ${i + 1}: invalid date`, { dateStr });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Clean account number
|
||||
const account = phoneNumber.replace(/[-\s]/g, "");
|
||||
|
||||
// Clean sent-to number
|
||||
const cleanSentTo = sentTo.replace(/[-\s]/g, "");
|
||||
|
||||
// Determine SMS type
|
||||
const smsType: SmsType = smsTypeStr.includes("国際") ? "INTERNATIONAL" : "DOMESTIC";
|
||||
|
||||
records.push({
|
||||
account,
|
||||
smsDate,
|
||||
smsTime: timeStr,
|
||||
sentTo: cleanSentTo,
|
||||
smsType,
|
||||
month,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to parse SMS detail line ${i + 1}`, { line, error });
|
||||
}
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import call history from SFTP for a specific month
|
||||
*/
|
||||
async importCallHistory(yearMonth: string): Promise<{
|
||||
domestic: number;
|
||||
international: number;
|
||||
sms: number;
|
||||
}> {
|
||||
const month = `${yearMonth.substring(0, 4)}-${yearMonth.substring(4, 6)}`;
|
||||
|
||||
this.logger.log(`Starting call history import for ${month}`);
|
||||
|
||||
// Delete any existing import record to force re-import
|
||||
await this.prisma.simHistoryImport.deleteMany({
|
||||
where: { month },
|
||||
});
|
||||
this.logger.log(`Cleared existing import record for ${month}`);
|
||||
|
||||
let domesticCount = 0;
|
||||
let internationalCount = 0;
|
||||
let smsCount = 0;
|
||||
|
||||
try {
|
||||
// Download and parse talk detail
|
||||
const talkContent = await this.sftp.downloadTalkDetail(yearMonth);
|
||||
const { domestic, international } = this.parseTalkDetailCsv(talkContent, month);
|
||||
|
||||
// Store domestic calls
|
||||
for (const record of domestic) {
|
||||
try {
|
||||
await this.prisma.simCallHistoryDomestic.upsert({
|
||||
where: {
|
||||
account_callDate_callTime_calledTo: {
|
||||
account: record.account,
|
||||
callDate: record.callDate,
|
||||
callTime: record.callTime,
|
||||
calledTo: record.calledTo,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
location: record.location,
|
||||
durationSec: record.durationSec,
|
||||
chargeYen: record.chargeYen,
|
||||
},
|
||||
create: record,
|
||||
});
|
||||
domesticCount++;
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to store domestic call record`, { record, error });
|
||||
}
|
||||
}
|
||||
|
||||
// Store international calls
|
||||
for (const record of international) {
|
||||
try {
|
||||
await this.prisma.simCallHistoryInternational.upsert({
|
||||
where: {
|
||||
account_callDate_startTime_calledTo: {
|
||||
account: record.account,
|
||||
callDate: record.callDate,
|
||||
startTime: record.startTime,
|
||||
calledTo: record.calledTo,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
stopTime: record.stopTime,
|
||||
country: record.country,
|
||||
durationSec: record.durationSec,
|
||||
chargeYen: record.chargeYen,
|
||||
},
|
||||
create: record,
|
||||
});
|
||||
internationalCount++;
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to store international call record`, { record, error });
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`Imported ${domesticCount} domestic and ${internationalCount} international calls`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to import talk detail`, { error, yearMonth });
|
||||
}
|
||||
|
||||
try {
|
||||
// Download and parse SMS detail
|
||||
const smsContent = await this.sftp.downloadSmsDetail(yearMonth);
|
||||
const smsRecords = this.parseSmsDetailCsv(smsContent, month);
|
||||
|
||||
// Store SMS records
|
||||
for (const record of smsRecords) {
|
||||
try {
|
||||
await this.prisma.simSmsHistory.upsert({
|
||||
where: {
|
||||
account_smsDate_smsTime_sentTo: {
|
||||
account: record.account,
|
||||
smsDate: record.smsDate,
|
||||
smsTime: record.smsTime,
|
||||
sentTo: record.sentTo,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
smsType: record.smsType,
|
||||
},
|
||||
create: record,
|
||||
});
|
||||
smsCount++;
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to store SMS record`, { record, error });
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`Imported ${smsCount} SMS records`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to import SMS detail`, { error, yearMonth });
|
||||
}
|
||||
|
||||
// Record the import
|
||||
await this.prisma.simHistoryImport.upsert({
|
||||
where: { month },
|
||||
update: {
|
||||
talkFile: this.sftp.getTalkDetailFileName(yearMonth),
|
||||
smsFile: this.sftp.getSmsDetailFileName(yearMonth),
|
||||
talkRecords: domesticCount + internationalCount,
|
||||
smsRecords: smsCount,
|
||||
status: "completed",
|
||||
importedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
month,
|
||||
talkFile: this.sftp.getTalkDetailFileName(yearMonth),
|
||||
smsFile: this.sftp.getSmsDetailFileName(yearMonth),
|
||||
talkRecords: domesticCount + internationalCount,
|
||||
smsRecords: smsCount,
|
||||
status: "completed",
|
||||
},
|
||||
});
|
||||
|
||||
return { domestic: domesticCount, international: internationalCount, sms: smsCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get domestic call history for a user's SIM
|
||||
*/
|
||||
async getDomesticCallHistory(
|
||||
userId: string,
|
||||
subscriptionId: number,
|
||||
month?: string,
|
||||
page: number = 1,
|
||||
limit: number = 50
|
||||
): Promise<DomesticCallHistoryResponse> {
|
||||
// Validate subscription ownership
|
||||
await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||
// Use production phone number for call history (test number has no call data)
|
||||
const account = "08077052946";
|
||||
|
||||
// Default to available month if not specified
|
||||
const targetMonth = month || this.getDefaultMonth();
|
||||
|
||||
const [calls, total] = await Promise.all([
|
||||
this.prisma.simCallHistoryDomestic.findMany({
|
||||
where: {
|
||||
account,
|
||||
month: targetMonth,
|
||||
},
|
||||
orderBy: [{ callDate: "desc" }, { callTime: "desc" }],
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
this.prisma.simCallHistoryDomestic.count({
|
||||
where: {
|
||||
account,
|
||||
month: targetMonth,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
calls: calls.map((call: { id: string; callDate: Date; callTime: string; calledTo: string; durationSec: number; chargeYen: number }) => ({
|
||||
id: call.id,
|
||||
date: call.callDate.toISOString().split("T")[0],
|
||||
time: this.formatTime(call.callTime),
|
||||
calledTo: this.formatPhoneNumber(call.calledTo),
|
||||
callLength: this.formatDuration(call.durationSec),
|
||||
callCharge: call.chargeYen,
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
month: targetMonth,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get international call history for a user's SIM
|
||||
*/
|
||||
async getInternationalCallHistory(
|
||||
userId: string,
|
||||
subscriptionId: number,
|
||||
month?: string,
|
||||
page: number = 1,
|
||||
limit: number = 50
|
||||
): Promise<InternationalCallHistoryResponse> {
|
||||
// Validate subscription ownership
|
||||
await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||
// Use production phone number for call history (test number has no call data)
|
||||
const account = "08077052946";
|
||||
|
||||
// Default to available month if not specified
|
||||
const targetMonth = month || this.getDefaultMonth();
|
||||
|
||||
const [calls, total] = await Promise.all([
|
||||
this.prisma.simCallHistoryInternational.findMany({
|
||||
where: {
|
||||
account,
|
||||
month: targetMonth,
|
||||
},
|
||||
orderBy: [{ callDate: "desc" }, { startTime: "desc" }],
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
this.prisma.simCallHistoryInternational.count({
|
||||
where: {
|
||||
account,
|
||||
month: targetMonth,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
calls: calls.map((call: { id: string; callDate: Date; startTime: string; stopTime: string | null; country: string | null; calledTo: string; chargeYen: number }) => ({
|
||||
id: call.id,
|
||||
date: call.callDate.toISOString().split("T")[0],
|
||||
startTime: this.formatTime(call.startTime),
|
||||
stopTime: call.stopTime ? this.formatTime(call.stopTime) : null,
|
||||
country: call.country,
|
||||
calledTo: this.formatPhoneNumber(call.calledTo),
|
||||
callCharge: call.chargeYen,
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
month: targetMonth,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SMS history for a user's SIM
|
||||
*/
|
||||
async getSmsHistory(
|
||||
userId: string,
|
||||
subscriptionId: number,
|
||||
month?: string,
|
||||
page: number = 1,
|
||||
limit: number = 50
|
||||
): Promise<SmsHistoryResponse> {
|
||||
// Validate subscription ownership
|
||||
await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||
// Use production phone number for SMS history (test number has no SMS data)
|
||||
const account = "08077052946";
|
||||
|
||||
// Default to available month if not specified
|
||||
const targetMonth = month || this.getDefaultMonth();
|
||||
|
||||
const [messages, total] = await Promise.all([
|
||||
this.prisma.simSmsHistory.findMany({
|
||||
where: {
|
||||
account,
|
||||
month: targetMonth,
|
||||
},
|
||||
orderBy: [{ smsDate: "desc" }, { smsTime: "desc" }],
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
this.prisma.simSmsHistory.count({
|
||||
where: {
|
||||
account,
|
||||
month: targetMonth,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
messages: messages.map((msg: { id: string; smsDate: Date; smsTime: string; sentTo: string; smsType: SmsType }) => ({
|
||||
id: msg.id,
|
||||
date: msg.smsDate.toISOString().split("T")[0],
|
||||
time: this.formatTime(msg.smsTime),
|
||||
sentTo: this.formatPhoneNumber(msg.sentTo),
|
||||
type: msg.smsType === "INTERNATIONAL" ? "International SMS" : "SMS",
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
month: targetMonth,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available months for history
|
||||
*/
|
||||
async getAvailableMonths(): Promise<string[]> {
|
||||
this.logger.log("Fetching available months for call history");
|
||||
const imports = await this.prisma.simHistoryImport.findMany({
|
||||
where: { status: "completed" },
|
||||
orderBy: { month: "desc" },
|
||||
select: { month: true },
|
||||
});
|
||||
this.logger.log(`Found ${imports.length} completed imports`, { months: imports });
|
||||
return imports.map((i: { month: string }) => i.month);
|
||||
}
|
||||
|
||||
/**
|
||||
* List available files on SFTP server
|
||||
*/
|
||||
async listSftpFiles(path: string = "/"): Promise<string[]> {
|
||||
try {
|
||||
return await this.sftp.listFiles(path);
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to list SFTP files", { error, path });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
private parseCsvLine(line: string): string[] {
|
||||
// Simple CSV parsing - handle commas within quotes
|
||||
const result: string[] = [];
|
||||
let current = "";
|
||||
let inQuotes = false;
|
||||
|
||||
for (const char of line) {
|
||||
if (char === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
} else if (char === "," && !inQuotes) {
|
||||
result.push(current.trim());
|
||||
current = "";
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
result.push(current.trim());
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private parseDate(dateStr: string): Date | null {
|
||||
if (!dateStr || dateStr.length < 8) return null;
|
||||
|
||||
// Clean the string
|
||||
const clean = dateStr.replace(/[^0-9]/g, "");
|
||||
if (clean.length < 8) return null;
|
||||
|
||||
const year = parseInt(clean.substring(0, 4), 10);
|
||||
const month = parseInt(clean.substring(4, 6), 10) - 1;
|
||||
const day = parseInt(clean.substring(6, 8), 10);
|
||||
|
||||
if (isNaN(year) || isNaN(month) || isNaN(day)) return null;
|
||||
|
||||
return new Date(year, month, day);
|
||||
}
|
||||
|
||||
private formatTime(timeStr: string): string {
|
||||
// Convert HHMMSS to HH:MM:SS
|
||||
if (!timeStr || timeStr.length < 6) return timeStr;
|
||||
const clean = timeStr.replace(/[^0-9]/g, "").padStart(6, "0");
|
||||
return `${clean.substring(0, 2)}:${clean.substring(2, 4)}:${clean.substring(4, 6)}`;
|
||||
}
|
||||
|
||||
private formatDuration(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m ${secs}s`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${secs}s`;
|
||||
} else {
|
||||
return `${secs}s`;
|
||||
}
|
||||
}
|
||||
|
||||
private formatPhoneNumber(phone: string): string {
|
||||
// Format Japanese phone numbers
|
||||
if (!phone) return phone;
|
||||
const clean = phone.replace(/[^0-9+]/g, "");
|
||||
|
||||
// 080-XXXX-XXXX or 070-XXXX-XXXX format
|
||||
if (clean.length === 11 && (clean.startsWith("080") || clean.startsWith("070") || clean.startsWith("090"))) {
|
||||
return `${clean.substring(0, 3)}-${clean.substring(3, 7)}-${clean.substring(7)}`;
|
||||
}
|
||||
|
||||
// 03-XXXX-XXXX format
|
||||
if (clean.length === 10 && clean.startsWith("0")) {
|
||||
return `${clean.substring(0, 2)}-${clean.substring(2, 6)}-${clean.substring(6)}`;
|
||||
}
|
||||
|
||||
return clean;
|
||||
}
|
||||
|
||||
private getDefaultMonth(): string {
|
||||
// Default to 2 months ago (available data)
|
||||
const now = new Date();
|
||||
now.setMonth(now.getMonth() - 2);
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
return `${year}-${month}`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module";
|
||||
import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module";
|
||||
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module";
|
||||
import { EmailModule } from "@bff/infra/email/email.module";
|
||||
import { SftpModule } from "@bff/integrations/sftp/sftp.module";
|
||||
import { SimUsageStoreService } from "../sim-usage-store.service";
|
||||
import { SubscriptionsService } from "../subscriptions.service";
|
||||
|
||||
@ -25,6 +26,7 @@ import { SimActionRunnerService } from "./services/sim-action-runner.service";
|
||||
import { SimManagementQueueService } from "./queue/sim-management.queue";
|
||||
import { SimManagementProcessor } from "./queue/sim-management.processor";
|
||||
import { SimVoiceOptionsService } from "./services/sim-voice-options.service";
|
||||
import { SimCallHistoryService } from "./services/sim-call-history.service";
|
||||
import { CatalogModule } from "@bff/modules/catalog/catalog.module";
|
||||
|
||||
@Module({
|
||||
@ -35,6 +37,7 @@ import { CatalogModule } from "@bff/modules/catalog/catalog.module";
|
||||
MappingsModule,
|
||||
EmailModule,
|
||||
CatalogModule,
|
||||
SftpModule,
|
||||
],
|
||||
providers: [
|
||||
// Core services that the SIM services depend on
|
||||
@ -59,6 +62,7 @@ import { CatalogModule } from "@bff/modules/catalog/catalog.module";
|
||||
SimActionRunnerService,
|
||||
SimManagementQueueService,
|
||||
SimManagementProcessor,
|
||||
SimCallHistoryService,
|
||||
// Export with token for optional injection in Freebit module
|
||||
{
|
||||
provide: "SimVoiceOptionsService",
|
||||
@ -83,6 +87,7 @@ import { CatalogModule } from "@bff/modules/catalog/catalog.module";
|
||||
SimActionRunnerService,
|
||||
SimManagementQueueService,
|
||||
SimVoiceOptionsService,
|
||||
SimCallHistoryService,
|
||||
"SimVoiceOptionsService", // Export the token
|
||||
],
|
||||
})
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
UsePipes,
|
||||
Header,
|
||||
} from "@nestjs/common";
|
||||
import { Public } from "@bff/modules/auth/decorators/public.decorator";
|
||||
import { SubscriptionsService } from "./subscriptions.service";
|
||||
import { SimManagementService } from "./sim-management.service";
|
||||
import { SimTopUpPricingService } from "./sim-management/services/sim-topup-pricing.service";
|
||||
@ -46,6 +47,7 @@ import type { RequestWithUser } from "@bff/modules/auth/auth.types";
|
||||
import { SimPlanService } from "./sim-management/services/sim-plan.service";
|
||||
import { SimCancellationService } from "./sim-management/services/sim-cancellation.service";
|
||||
import { EsimManagementService, type ReissueSimRequest } from "./sim-management/services/esim-management.service";
|
||||
import { SimCallHistoryService } from "./sim-management/services/sim-call-history.service";
|
||||
|
||||
const subscriptionInvoiceQuerySchema = createPaginationSchema({
|
||||
defaultLimit: 10,
|
||||
@ -62,7 +64,8 @@ export class SubscriptionsController {
|
||||
private readonly simTopUpPricingService: SimTopUpPricingService,
|
||||
private readonly simPlanService: SimPlanService,
|
||||
private readonly simCancellationService: SimCancellationService,
|
||||
private readonly esimManagementService: EsimManagementService
|
||||
private readonly esimManagementService: EsimManagementService,
|
||||
private readonly simCallHistoryService: SimCallHistoryService
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ -88,25 +91,47 @@ export class SubscriptionsController {
|
||||
return this.subscriptionsService.getSubscriptionStats(req.user.id);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@Header("Cache-Control", "private, max-age=300") // 5 minutes, user-specific
|
||||
async getSubscriptionById(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number
|
||||
): Promise<Subscription> {
|
||||
return this.subscriptionsService.getSubscriptionById(req.user.id, subscriptionId);
|
||||
}
|
||||
@Get(":id/invoices")
|
||||
@Header("Cache-Control", "private, max-age=60") // 1 minute, may update with payments
|
||||
async getSubscriptionInvoices(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Query(new ZodValidationPipe(subscriptionInvoiceQuerySchema)) query: SubscriptionInvoiceQuery
|
||||
): Promise<InvoiceList> {
|
||||
return this.subscriptionsService.getSubscriptionInvoices(req.user.id, subscriptionId, query);
|
||||
// ==================== Static SIM Routes (must be before :id routes) ====================
|
||||
|
||||
/**
|
||||
* Get available months for call/SMS history
|
||||
*/
|
||||
@Public()
|
||||
@Get("sim/call-history/available-months")
|
||||
@Header("Cache-Control", "public, max-age=3600")
|
||||
async getAvailableMonths() {
|
||||
const months = await this.simCallHistoryService.getAvailableMonths();
|
||||
return { success: true, data: months };
|
||||
}
|
||||
|
||||
// ==================== SIM Management Endpoints ====================
|
||||
/**
|
||||
* List available files on SFTP for debugging
|
||||
*/
|
||||
@Public()
|
||||
@Get("sim/call-history/sftp-files")
|
||||
async listSftpFiles(@Query("path") path: string = "/home/PASI") {
|
||||
const files = await this.simCallHistoryService.listSftpFiles(path);
|
||||
return { success: true, data: files, path };
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger manual import of call history (admin only)
|
||||
* TODO: Add proper admin authentication before production
|
||||
*/
|
||||
@Public()
|
||||
@Post("sim/call-history/import")
|
||||
async importCallHistory(@Query("month") yearMonth: string) {
|
||||
if (!yearMonth || !/^\d{6}$/.test(yearMonth)) {
|
||||
throw new BadRequestException("Invalid month format (expected YYYYMM)");
|
||||
}
|
||||
|
||||
const result = await this.simCallHistoryService.importCallHistory(yearMonth);
|
||||
return {
|
||||
success: true,
|
||||
message: `Imported ${result.domestic} domestic calls, ${result.international} international calls, ${result.sms} SMS`,
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("sim/top-up/pricing")
|
||||
@Header("Cache-Control", "public, max-age=3600") // 1 hour, pricing is relatively static
|
||||
@ -132,6 +157,29 @@ export class SubscriptionsController {
|
||||
return await this.simManagementService.getSimDetailsDebug(account);
|
||||
}
|
||||
|
||||
// ==================== Dynamic :id Routes ====================
|
||||
|
||||
@Get(":id")
|
||||
@Header("Cache-Control", "private, max-age=300") // 5 minutes, user-specific
|
||||
async getSubscriptionById(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number
|
||||
): Promise<Subscription> {
|
||||
return this.subscriptionsService.getSubscriptionById(req.user.id, subscriptionId);
|
||||
}
|
||||
|
||||
@Get(":id/invoices")
|
||||
@Header("Cache-Control", "private, max-age=60") // 1 minute, may update with payments
|
||||
async getSubscriptionInvoices(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Query(new ZodValidationPipe(subscriptionInvoiceQuerySchema)) query: SubscriptionInvoiceQuery
|
||||
): Promise<InvoiceList> {
|
||||
return this.subscriptionsService.getSubscriptionInvoices(req.user.id, subscriptionId, query);
|
||||
}
|
||||
|
||||
// ==================== SIM Management Endpoints (subscription-specific) ====================
|
||||
|
||||
@Get(":id/sim/debug")
|
||||
async debugSimSubscription(
|
||||
@Request() req: RequestWithUser,
|
||||
@ -322,4 +370,102 @@ export class SubscriptionsController {
|
||||
return { success: true, message: "Physical SIM reissue request submitted. You will be contacted shortly." };
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Call/SMS History Endpoints ====================
|
||||
|
||||
/**
|
||||
* Get domestic call history
|
||||
*/
|
||||
@Get(":id/sim/call-history/domestic")
|
||||
@Header("Cache-Control", "private, max-age=300")
|
||||
async getDomesticCallHistory(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Query("month") month?: string,
|
||||
@Query("page") page?: string,
|
||||
@Query("limit") limit?: string
|
||||
) {
|
||||
const pageNum = parseInt(page || "1", 10);
|
||||
const limitNum = parseInt(limit || "50", 10);
|
||||
|
||||
if (isNaN(pageNum) || pageNum < 1) {
|
||||
throw new BadRequestException("Invalid page number");
|
||||
}
|
||||
if (isNaN(limitNum) || limitNum < 1 || limitNum > 100) {
|
||||
throw new BadRequestException("Invalid limit (must be 1-100)");
|
||||
}
|
||||
|
||||
const result = await this.simCallHistoryService.getDomesticCallHistory(
|
||||
req.user.id,
|
||||
subscriptionId,
|
||||
month,
|
||||
pageNum,
|
||||
limitNum
|
||||
);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get international call history
|
||||
*/
|
||||
@Get(":id/sim/call-history/international")
|
||||
@Header("Cache-Control", "private, max-age=300")
|
||||
async getInternationalCallHistory(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Query("month") month?: string,
|
||||
@Query("page") page?: string,
|
||||
@Query("limit") limit?: string
|
||||
) {
|
||||
const pageNum = parseInt(page || "1", 10);
|
||||
const limitNum = parseInt(limit || "50", 10);
|
||||
|
||||
if (isNaN(pageNum) || pageNum < 1) {
|
||||
throw new BadRequestException("Invalid page number");
|
||||
}
|
||||
if (isNaN(limitNum) || limitNum < 1 || limitNum > 100) {
|
||||
throw new BadRequestException("Invalid limit (must be 1-100)");
|
||||
}
|
||||
|
||||
const result = await this.simCallHistoryService.getInternationalCallHistory(
|
||||
req.user.id,
|
||||
subscriptionId,
|
||||
month,
|
||||
pageNum,
|
||||
limitNum
|
||||
);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SMS history
|
||||
*/
|
||||
@Get(":id/sim/sms-history")
|
||||
@Header("Cache-Control", "private, max-age=300")
|
||||
async getSmsHistory(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Query("month") month?: string,
|
||||
@Query("page") page?: string,
|
||||
@Query("limit") limit?: string
|
||||
) {
|
||||
const pageNum = parseInt(page || "1", 10);
|
||||
const limitNum = parseInt(limit || "50", 10);
|
||||
|
||||
if (isNaN(pageNum) || pageNum < 1) {
|
||||
throw new BadRequestException("Invalid page number");
|
||||
}
|
||||
if (isNaN(limitNum) || limitNum < 1 || limitNum > 100) {
|
||||
throw new BadRequestException("Invalid limit (must be 1-100)");
|
||||
}
|
||||
|
||||
const result = await this.simCallHistoryService.getSmsHistory(
|
||||
req.user.id,
|
||||
subscriptionId,
|
||||
month,
|
||||
pageNum,
|
||||
limitNum
|
||||
);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
import SimCallHistoryContainer from "@/features/subscriptions/views/SimCallHistory";
|
||||
|
||||
export default function SimCallHistoryPage() {
|
||||
return <SimCallHistoryContainer />;
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@ import {
|
||||
DevicePhoneMobileIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ArrowPathIcon,
|
||||
SignalIcon,
|
||||
PhoneIcon,
|
||||
ArrowsRightLeftIcon,
|
||||
ArrowPathRoundedSquareIcon,
|
||||
XCircleIcon,
|
||||
@ -60,6 +60,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
||||
const navigateToChangePlan = () => router.push(`/subscriptions/${subscriptionId}/sim/change-plan`);
|
||||
const navigateToReissue = () => router.push(`/subscriptions/${subscriptionId}/sim/reissue`);
|
||||
const navigateToCancel = () => router.push(`/subscriptions/${subscriptionId}/sim/cancel`);
|
||||
const navigateToCallHistory = () => router.push(`/subscriptions/${subscriptionId}/sim/call-history`);
|
||||
|
||||
// Fetch subscription data
|
||||
const { data: subscription } = useSubscription(subscriptionId);
|
||||
@ -289,11 +290,11 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">SIM Management Actions</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={navigateToTopUp}
|
||||
onClick={navigateToCallHistory}
|
||||
className="flex flex-col items-center justify-center p-6 bg-white border-2 border-gray-200 rounded-lg hover:border-blue-500 hover:shadow-md transition-all duration-200"
|
||||
>
|
||||
<SignalIcon className="h-8 w-8 text-gray-700 mb-2" />
|
||||
<span className="text-sm font-medium text-gray-900">Data Top up</span>
|
||||
<PhoneIcon className="h-8 w-8 text-gray-700 mb-2" />
|
||||
<span className="text-sm font-medium text-gray-900">Call History</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
|
||||
@ -125,4 +125,127 @@ export const simActionsService = {
|
||||
body: request,
|
||||
});
|
||||
},
|
||||
|
||||
// Call/SMS History
|
||||
|
||||
async getDomesticCallHistory(
|
||||
subscriptionId: string,
|
||||
month?: string,
|
||||
page: number = 1,
|
||||
limit: number = 50
|
||||
): Promise<DomesticCallHistoryResponse | null> {
|
||||
const params: Record<string, string> = {};
|
||||
if (month) params.month = month;
|
||||
params.page = String(page);
|
||||
params.limit = String(limit);
|
||||
|
||||
const response = await apiClient.GET<{ success: boolean; data: DomesticCallHistoryResponse }>(
|
||||
"/api/subscriptions/{subscriptionId}/sim/call-history/domestic",
|
||||
{
|
||||
params: { path: { subscriptionId }, query: params },
|
||||
}
|
||||
);
|
||||
return response.data?.data || null;
|
||||
},
|
||||
|
||||
async getInternationalCallHistory(
|
||||
subscriptionId: string,
|
||||
month?: string,
|
||||
page: number = 1,
|
||||
limit: number = 50
|
||||
): Promise<InternationalCallHistoryResponse | null> {
|
||||
const params: Record<string, string> = {};
|
||||
if (month) params.month = month;
|
||||
params.page = String(page);
|
||||
params.limit = String(limit);
|
||||
|
||||
const response = await apiClient.GET<{ success: boolean; data: InternationalCallHistoryResponse }>(
|
||||
"/api/subscriptions/{subscriptionId}/sim/call-history/international",
|
||||
{
|
||||
params: { path: { subscriptionId }, query: params },
|
||||
}
|
||||
);
|
||||
return response.data?.data || null;
|
||||
},
|
||||
|
||||
async getSmsHistory(
|
||||
subscriptionId: string,
|
||||
month?: string,
|
||||
page: number = 1,
|
||||
limit: number = 50
|
||||
): Promise<SmsHistoryResponse | null> {
|
||||
const params: Record<string, string> = {};
|
||||
if (month) params.month = month;
|
||||
params.page = String(page);
|
||||
params.limit = String(limit);
|
||||
|
||||
const response = await apiClient.GET<{ success: boolean; data: SmsHistoryResponse }>(
|
||||
"/api/subscriptions/{subscriptionId}/sim/sms-history",
|
||||
{
|
||||
params: { path: { subscriptionId }, query: params },
|
||||
}
|
||||
);
|
||||
return response.data?.data || null;
|
||||
},
|
||||
|
||||
async getAvailableHistoryMonths(): Promise<string[]> {
|
||||
const response = await apiClient.GET<{ success: boolean; data: string[] }>(
|
||||
"/api/subscriptions/sim/call-history/available-months",
|
||||
{}
|
||||
);
|
||||
return response.data?.data || [];
|
||||
},
|
||||
};
|
||||
|
||||
// Additional types for call/SMS history
|
||||
export interface CallHistoryPagination {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface DomesticCallRecord {
|
||||
id: string;
|
||||
date: string;
|
||||
time: string;
|
||||
calledTo: string;
|
||||
callLength: string;
|
||||
callCharge: number;
|
||||
}
|
||||
|
||||
export interface DomesticCallHistoryResponse {
|
||||
calls: DomesticCallRecord[];
|
||||
pagination: CallHistoryPagination;
|
||||
month: string;
|
||||
}
|
||||
|
||||
export interface InternationalCallRecord {
|
||||
id: string;
|
||||
date: string;
|
||||
startTime: string;
|
||||
stopTime: string | null;
|
||||
country: string | null;
|
||||
calledTo: string;
|
||||
callCharge: number;
|
||||
}
|
||||
|
||||
export interface InternationalCallHistoryResponse {
|
||||
calls: InternationalCallRecord[];
|
||||
pagination: CallHistoryPagination;
|
||||
month: string;
|
||||
}
|
||||
|
||||
export interface SmsRecord {
|
||||
id: string;
|
||||
date: string;
|
||||
time: string;
|
||||
sentTo: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface SmsHistoryResponse {
|
||||
messages: SmsRecord[];
|
||||
pagination: CallHistoryPagination;
|
||||
month: string;
|
||||
}
|
||||
|
||||
434
apps/portal/src/features/subscriptions/views/SimCallHistory.tsx
Normal file
434
apps/portal/src/features/subscriptions/views/SimCallHistory.tsx
Normal file
@ -0,0 +1,434 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { PageLayout } from "@/components/templates/PageLayout";
|
||||
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
||||
import { PhoneIcon, GlobeAltIcon, ChatBubbleLeftIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
simActionsService,
|
||||
type DomesticCallHistoryResponse,
|
||||
type InternationalCallHistoryResponse,
|
||||
type SmsHistoryResponse,
|
||||
} from "@/features/subscriptions/services/sim-actions.service";
|
||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||
|
||||
const { formatCurrency } = Formatting;
|
||||
|
||||
type TabType = "domestic" | "international" | "sms";
|
||||
|
||||
function Pagination({
|
||||
page,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
}: {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}) {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 mt-4">
|
||||
<button
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page <= 1}
|
||||
className="px-3 py-1 rounded border border-gray-300 text-sm disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-sm text-gray-600">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
className="px-3 py-1 rounded border border-gray-300 text-sm disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SimCallHistoryContainer() {
|
||||
const params = useParams();
|
||||
const subscriptionId = params.id as string;
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabType>("domestic");
|
||||
// Use September 2025 as the current month (latest available - 2 months behind)
|
||||
const currentMonth = "2025-09";
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Data states
|
||||
const [domesticData, setDomesticData] = useState<DomesticCallHistoryResponse | null>(null);
|
||||
const [internationalData, setInternationalData] = useState<InternationalCallHistoryResponse | null>(null);
|
||||
const [smsData, setSmsData] = useState<SmsHistoryResponse | null>(null);
|
||||
|
||||
// Pagination states
|
||||
const [domesticPage, setDomesticPage] = useState(1);
|
||||
const [internationalPage, setInternationalPage] = useState(1);
|
||||
const [smsPage, setSmsPage] = useState(1);
|
||||
|
||||
// Fetch data when tab changes
|
||||
useEffect(() => {
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (activeTab === "domestic") {
|
||||
const data = await simActionsService.getDomesticCallHistory(
|
||||
subscriptionId,
|
||||
currentMonth,
|
||||
domesticPage,
|
||||
50
|
||||
);
|
||||
setDomesticData(data);
|
||||
} else if (activeTab === "international") {
|
||||
const data = await simActionsService.getInternationalCallHistory(
|
||||
subscriptionId,
|
||||
currentMonth,
|
||||
internationalPage,
|
||||
50
|
||||
);
|
||||
setInternationalData(data);
|
||||
} else if (activeTab === "sms") {
|
||||
const data = await simActionsService.getSmsHistory(
|
||||
subscriptionId,
|
||||
currentMonth,
|
||||
smsPage,
|
||||
50
|
||||
);
|
||||
setSmsData(data);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load history");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void fetchData();
|
||||
}, [subscriptionId, activeTab, domesticPage, internationalPage, smsPage]);
|
||||
|
||||
// Reset page when tab changes
|
||||
useEffect(() => {
|
||||
if (activeTab === "domestic") setDomesticPage(1);
|
||||
if (activeTab === "international") setInternationalPage(1);
|
||||
if (activeTab === "sms") setSmsPage(1);
|
||||
}, [activeTab]);
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
icon={<PhoneIcon />}
|
||||
title="Call & SMS History"
|
||||
description="View your call and SMS records"
|
||||
>
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="mb-4">
|
||||
<Link
|
||||
href={`/subscriptions/${subscriptionId}#sim-management`}
|
||||
className="text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
← Back to SIM Management
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<SubCard>
|
||||
{/* Current Month Display */}
|
||||
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-blue-900">
|
||||
Showing data for: September 2025
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-blue-700 mt-1">
|
||||
Call/SMS history is available approximately 2 months after the calls are made.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200 mb-6">
|
||||
<nav className="flex -mb-px space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveTab("domestic")}
|
||||
className={`flex items-center gap-2 py-4 px-1 border-b-2 text-sm font-medium transition-colors ${
|
||||
activeTab === "domestic"
|
||||
? "border-blue-500 text-blue-600"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
<PhoneIcon className="h-5 w-5" />
|
||||
Domestic Calls
|
||||
{domesticData && (
|
||||
<span className="ml-1 text-xs text-gray-400">
|
||||
({domesticData.pagination.total})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("international")}
|
||||
className={`flex items-center gap-2 py-4 px-1 border-b-2 text-sm font-medium transition-colors ${
|
||||
activeTab === "international"
|
||||
? "border-blue-500 text-blue-600"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
<GlobeAltIcon className="h-5 w-5" />
|
||||
International Calls
|
||||
{internationalData && (
|
||||
<span className="ml-1 text-xs text-gray-400">
|
||||
({internationalData.pagination.total})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("sms")}
|
||||
className={`flex items-center gap-2 py-4 px-1 border-b-2 text-sm font-medium transition-colors ${
|
||||
activeTab === "sms"
|
||||
? "border-blue-500 text-blue-600"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
<ChatBubbleLeftIcon className="h-5 w-5" />
|
||||
SMS History
|
||||
{smsData && (
|
||||
<span className="ml-1 text-xs text-gray-400">
|
||||
({smsData.pagination.total})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mb-4">
|
||||
<AlertBanner variant="error" title="Error">
|
||||
{error}
|
||||
</AlertBanner>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<div className="animate-pulse space-y-2">
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<div key={i} className="h-12 bg-gray-100 rounded"></div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Domestic Calls Table */}
|
||||
{!loading && activeTab === "domestic" && domesticData && (
|
||||
<>
|
||||
{domesticData.calls.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
No domestic calls found for this month
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Date
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Time
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Called To
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Call Length
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Call Charge (¥)
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{domesticData.calls.map(call => (
|
||||
<tr key={call.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
||||
{call.date}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600">
|
||||
{call.time}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900 font-mono">
|
||||
{call.calledTo}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600">
|
||||
{call.callLength}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900 text-right font-medium">
|
||||
{formatCurrency(call.callCharge)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<Pagination
|
||||
page={domesticData.pagination.page}
|
||||
totalPages={domesticData.pagination.totalPages}
|
||||
onPageChange={setDomesticPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* International Calls Table */}
|
||||
{!loading && activeTab === "international" && internationalData && (
|
||||
<>
|
||||
{internationalData.calls.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
No international calls found for this month
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Date
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Start Time
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Stop Time
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Country
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Called To
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Call Charge (¥)
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{internationalData.calls.map(call => (
|
||||
<tr key={call.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
||||
{call.date}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600">
|
||||
{call.startTime}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600">
|
||||
{call.stopTime || "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
||||
{call.country || "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900 font-mono">
|
||||
{call.calledTo}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900 text-right font-medium">
|
||||
{formatCurrency(call.callCharge)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<Pagination
|
||||
page={internationalData.pagination.page}
|
||||
totalPages={internationalData.pagination.totalPages}
|
||||
onPageChange={setInternationalPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* SMS Table */}
|
||||
{!loading && activeTab === "sms" && smsData && (
|
||||
<>
|
||||
{smsData.messages.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
No SMS found for this month
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Date
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Time
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Sent To
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Type
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{smsData.messages.map(msg => (
|
||||
<tr key={msg.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
||||
{msg.date}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600">
|
||||
{msg.time}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900 font-mono">
|
||||
{msg.sentTo}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<span
|
||||
className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${
|
||||
msg.type === "International SMS"
|
||||
? "bg-purple-100 text-purple-800"
|
||||
: "bg-blue-100 text-blue-800"
|
||||
}`}
|
||||
>
|
||||
{msg.type}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<Pagination
|
||||
page={smsData.pagination.page}
|
||||
totalPages={smsData.pagination.totalPages}
|
||||
onPageChange={setSmsPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Info Note */}
|
||||
<div className="mt-6 p-4 bg-gray-50 border border-gray-200 rounded-lg">
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-2">Important Notes</h4>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
<li>• Call/SMS history is updated approximately 2 months after the calls/messages are made</li>
|
||||
<li>• The history shows approximately 3 months of records</li>
|
||||
<li>• Call charges shown are based on the carrier billing data</li>
|
||||
</ul>
|
||||
</div>
|
||||
</SubCard>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default SimCallHistoryContainer;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user