feat: Add Sonner toast notifications to all CRUD operations

- Install sonner package and create Toaster component
- Add toast notifications to all admin dashboard operations:
  * AdminPlans: create, update, delete, reorder, toggle visibility
  * AdminPaymentMethods: create, update, delete, reorder, toggle active
  * AdminUsers: suspend, unsuspend, grant pro access
  * AdminPayments: verify, reject
  * AdminSettings: save settings
- Add toast notifications to all member dashboard operations:
  * Wallets: create, update, delete
  * Transactions: create, update, delete
  * Profile: update name, avatar, phone, password, delete account
  * OTP: enable/disable email, WhatsApp, authenticator
- Replace all alert() calls with toast.success/error/warning
- Add proper success/error messages in Bahasa Indonesia
- Implement smart plan deletion (permanent if no subscriptions, soft delete if has subscriptions)
- Fix admin redirect after login (admin goes to /admin, users to /)
- Exclude admin accounts from subscription distribution chart
- Show inactive plans with visual indicators
- Add real revenue data to admin dashboard charts
- Use formatLargeNumber for consistent number formatting
This commit is contained in:
dwindown
2025-10-12 08:43:50 +07:00
parent 258d326909
commit c0df4a7c2a
37 changed files with 2744 additions and 628 deletions

View File

@@ -16,10 +16,10 @@ export declare class AdminPaymentsController {
subscription: ({
plan: {
id: string;
currency: string;
createdAt: Date;
updatedAt: Date;
name: string;
currency: string;
slug: string;
description: string | null;
price: import("@prisma/client/runtime/library").Decimal;
@@ -42,10 +42,10 @@ export declare class AdminPaymentsController {
};
} & {
id: string;
userId: string;
status: string;
createdAt: Date;
updatedAt: Date;
status: string;
userId: string;
planId: string;
startDate: Date;
endDate: Date;
@@ -56,21 +56,19 @@ export declare class AdminPaymentsController {
}) | null;
} & {
id: string;
createdAt: Date;
updatedAt: Date;
status: string;
method: string;
userId: string;
currency: string;
amount: import("@prisma/client/runtime/library").Decimal;
subscriptionId: string | null;
invoiceNumber: string;
amount: import("@prisma/client/runtime/library").Decimal;
currency: string;
method: string;
tripayReference: string | null;
tripayFee: import("@prisma/client/runtime/library").Decimal | null;
totalAmount: import("@prisma/client/runtime/library").Decimal;
paymentChannel: string | null;
paymentUrl: string | null;
qrUrl: string | null;
status: string;
proofImageUrl: string | null;
transferDate: Date | null;
verifiedBy: string | null;
@@ -81,8 +79,15 @@ export declare class AdminPaymentsController {
notes: string | null;
expiresAt: Date | null;
paidAt: Date | null;
createdAt: Date;
updatedAt: Date;
})[]>;
getPendingCount(): Promise<number>;
getMonthlyRevenue(): Promise<{
month: string;
revenue: number;
users: number;
}[]>;
findOne(id: string): Promise<({
user: {
id: string;
@@ -92,10 +97,10 @@ export declare class AdminPaymentsController {
subscription: ({
plan: {
id: string;
currency: string;
createdAt: Date;
updatedAt: Date;
name: string;
currency: string;
slug: string;
description: string | null;
price: import("@prisma/client/runtime/library").Decimal;
@@ -118,10 +123,10 @@ export declare class AdminPaymentsController {
};
} & {
id: string;
userId: string;
status: string;
createdAt: Date;
updatedAt: Date;
status: string;
userId: string;
planId: string;
startDate: Date;
endDate: Date;
@@ -132,21 +137,19 @@ export declare class AdminPaymentsController {
}) | null;
} & {
id: string;
createdAt: Date;
updatedAt: Date;
status: string;
method: string;
userId: string;
currency: string;
amount: import("@prisma/client/runtime/library").Decimal;
subscriptionId: string | null;
invoiceNumber: string;
amount: import("@prisma/client/runtime/library").Decimal;
currency: string;
method: string;
tripayReference: string | null;
tripayFee: import("@prisma/client/runtime/library").Decimal | null;
totalAmount: import("@prisma/client/runtime/library").Decimal;
paymentChannel: string | null;
paymentUrl: string | null;
qrUrl: string | null;
status: string;
proofImageUrl: string | null;
transferDate: Date | null;
verifiedBy: string | null;
@@ -157,24 +160,24 @@ export declare class AdminPaymentsController {
notes: string | null;
expiresAt: Date | null;
paidAt: Date | null;
createdAt: Date;
updatedAt: Date;
}) | null>;
verify(id: string, req: RequestWithUser): Promise<{
id: string;
createdAt: Date;
updatedAt: Date;
status: string;
method: string;
userId: string;
currency: string;
amount: import("@prisma/client/runtime/library").Decimal;
subscriptionId: string | null;
invoiceNumber: string;
amount: import("@prisma/client/runtime/library").Decimal;
currency: string;
method: string;
tripayReference: string | null;
tripayFee: import("@prisma/client/runtime/library").Decimal | null;
totalAmount: import("@prisma/client/runtime/library").Decimal;
paymentChannel: string | null;
paymentUrl: string | null;
qrUrl: string | null;
status: string;
proofImageUrl: string | null;
transferDate: Date | null;
verifiedBy: string | null;
@@ -185,26 +188,26 @@ export declare class AdminPaymentsController {
notes: string | null;
expiresAt: Date | null;
paidAt: Date | null;
createdAt: Date;
updatedAt: Date;
}>;
reject(id: string, req: RequestWithUser, body: {
reason: string;
}): Promise<{
id: string;
createdAt: Date;
updatedAt: Date;
status: string;
method: string;
userId: string;
currency: string;
amount: import("@prisma/client/runtime/library").Decimal;
subscriptionId: string | null;
invoiceNumber: string;
amount: import("@prisma/client/runtime/library").Decimal;
currency: string;
method: string;
tripayReference: string | null;
tripayFee: import("@prisma/client/runtime/library").Decimal | null;
totalAmount: import("@prisma/client/runtime/library").Decimal;
paymentChannel: string | null;
paymentUrl: string | null;
qrUrl: string | null;
status: string;
proofImageUrl: string | null;
transferDate: Date | null;
verifiedBy: string | null;
@@ -215,6 +218,8 @@ export declare class AdminPaymentsController {
notes: string | null;
expiresAt: Date | null;
paidAt: Date | null;
createdAt: Date;
updatedAt: Date;
}>;
}
export {};

View File

@@ -28,6 +28,9 @@ let AdminPaymentsController = class AdminPaymentsController {
getPendingCount() {
return this.service.getPendingCount();
}
getMonthlyRevenue() {
return this.service.getMonthlyRevenue();
}
findOne(id) {
return this.service.findOne(id);
}
@@ -52,6 +55,12 @@ __decorate([
__metadata("design:paramtypes", []),
__metadata("design:returntype", void 0)
], AdminPaymentsController.prototype, "getPendingCount", null);
__decorate([
(0, common_1.Get)('revenue/monthly'),
__metadata("design:type", Function),
__metadata("design:paramtypes", []),
__metadata("design:returntype", void 0)
], AdminPaymentsController.prototype, "getMonthlyRevenue", null);
__decorate([
(0, common_1.Get)(':id'),
__param(0, (0, common_1.Param)('id')),

View File

@@ -1 +1 @@
{"version":3,"file":"admin-payments.controller.js","sourceRoot":"","sources":["../../src/admin/admin-payments.controller.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;AAAA,2CASwB;AACxB,mDAA+C;AAC/C,sDAAkD;AAClD,qEAAgE;AAUzD,IAAM,uBAAuB,GAA7B,MAAM,uBAAuB;IACL;IAA7B,YAA6B,OAA6B;QAA7B,YAAO,GAAP,OAAO,CAAsB;IAAG,CAAC;IAG9D,OAAO,CAAkB,MAAe;QACtC,OAAO,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;IACtC,CAAC;IAGD,eAAe;QACb,OAAO,IAAI,CAAC,OAAO,CAAC,eAAe,EAAE,CAAC;IACxC,CAAC;IAGD,OAAO,CAAc,EAAU;QAC7B,OAAO,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;IAClC,CAAC;IAGD,MAAM,CAAc,EAAU,EAAS,GAAoB;QACzD,OAAO,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,EAAE,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IAClD,CAAC;IAGD,MAAM,CACS,EAAU,EAChB,GAAoB,EACnB,IAAwB;QAEhC,OAAO,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,EAAE,GAAG,CAAC,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,MAAM,CAAC,CAAC;IAC/D,CAAC;CACF,CAAA;AA/BY,0DAAuB;AAIlC;IADC,IAAA,YAAG,GAAE;IACG,WAAA,IAAA,cAAK,EAAC,QAAQ,CAAC,CAAA;;;;sDAEvB;AAGD;IADC,IAAA,YAAG,EAAC,eAAe,CAAC;;;;8DAGpB;AAGD;IADC,IAAA,YAAG,EAAC,KAAK,CAAC;IACF,WAAA,IAAA,cAAK,EAAC,IAAI,CAAC,CAAA;;;;sDAEnB;AAGD;IADC,IAAA,aAAI,EAAC,YAAY,CAAC;IACX,WAAA,IAAA,cAAK,EAAC,IAAI,CAAC,CAAA;IAAc,WAAA,IAAA,YAAG,GAAE,CAAA;;;;qDAErC;AAGD;IADC,IAAA,aAAI,EAAC,YAAY,CAAC;IAEhB,WAAA,IAAA,cAAK,EAAC,IAAI,CAAC,CAAA;IACX,WAAA,IAAA,YAAG,GAAE,CAAA;IACL,WAAA,IAAA,aAAI,GAAE,CAAA;;;;qDAGR;kCA9BU,uBAAuB;IAFnC,IAAA,mBAAU,EAAC,gBAAgB,CAAC;IAC5B,IAAA,kBAAS,EAAC,sBAAS,EAAE,wBAAU,CAAC;qCAEO,6CAAoB;GAD/C,uBAAuB,CA+BnC"}
{"version":3,"file":"admin-payments.controller.js","sourceRoot":"","sources":["../../src/admin/admin-payments.controller.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;AAAA,2CASwB;AACxB,mDAA+C;AAC/C,sDAAkD;AAClD,qEAAgE;AAUzD,IAAM,uBAAuB,GAA7B,MAAM,uBAAuB;IACL;IAA7B,YAA6B,OAA6B;QAA7B,YAAO,GAAP,OAAO,CAAsB;IAAG,CAAC;IAG9D,OAAO,CAAkB,MAAe;QACtC,OAAO,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;IACtC,CAAC;IAGD,eAAe;QACb,OAAO,IAAI,CAAC,OAAO,CAAC,eAAe,EAAE,CAAC;IACxC,CAAC;IAGD,iBAAiB;QACf,OAAO,IAAI,CAAC,OAAO,CAAC,iBAAiB,EAAE,CAAC;IAC1C,CAAC;IAGD,OAAO,CAAc,EAAU;QAC7B,OAAO,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;IAClC,CAAC;IAGD,MAAM,CAAc,EAAU,EAAS,GAAoB;QACzD,OAAO,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,EAAE,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IAClD,CAAC;IAGD,MAAM,CACS,EAAU,EAChB,GAAoB,EACnB,IAAwB;QAEhC,OAAO,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,EAAE,GAAG,CAAC,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,MAAM,CAAC,CAAC;IAC/D,CAAC;CACF,CAAA;AApCY,0DAAuB;AAIlC;IADC,IAAA,YAAG,GAAE;IACG,WAAA,IAAA,cAAK,EAAC,QAAQ,CAAC,CAAA;;;;sDAEvB;AAGD;IADC,IAAA,YAAG,EAAC,eAAe,CAAC;;;;8DAGpB;AAGD;IADC,IAAA,YAAG,EAAC,iBAAiB,CAAC;;;;gEAGtB;AAGD;IADC,IAAA,YAAG,EAAC,KAAK,CAAC;IACF,WAAA,IAAA,cAAK,EAAC,IAAI,CAAC,CAAA;;;;sDAEnB;AAGD;IADC,IAAA,aAAI,EAAC,YAAY,CAAC;IACX,WAAA,IAAA,cAAK,EAAC,IAAI,CAAC,CAAA;IAAc,WAAA,IAAA,YAAG,GAAE,CAAA;;;;qDAErC;AAGD;IADC,IAAA,aAAI,EAAC,YAAY,CAAC;IAEhB,WAAA,IAAA,cAAK,EAAC,IAAI,CAAC,CAAA;IACX,WAAA,IAAA,YAAG,GAAE,CAAA;IACL,WAAA,IAAA,aAAI,GAAE,CAAA;;;;qDAGR;kCAnCU,uBAAuB;IAFnC,IAAA,mBAAU,EAAC,gBAAgB,CAAC;IAC5B,IAAA,kBAAS,EAAC,sBAAS,EAAE,wBAAU,CAAC;qCAEO,6CAAoB;GAD/C,uBAAuB,CAoCnC"}

View File

@@ -11,10 +11,10 @@ export declare class AdminPaymentsService {
subscription: ({
plan: {
id: string;
currency: string;
createdAt: Date;
updatedAt: Date;
name: string;
currency: string;
slug: string;
description: string | null;
price: import("@prisma/client/runtime/library").Decimal;
@@ -37,10 +37,10 @@ export declare class AdminPaymentsService {
};
} & {
id: string;
userId: string;
status: string;
createdAt: Date;
updatedAt: Date;
status: string;
userId: string;
planId: string;
startDate: Date;
endDate: Date;
@@ -51,21 +51,19 @@ export declare class AdminPaymentsService {
}) | null;
} & {
id: string;
createdAt: Date;
updatedAt: Date;
status: string;
method: string;
userId: string;
currency: string;
amount: import("@prisma/client/runtime/library").Decimal;
subscriptionId: string | null;
invoiceNumber: string;
amount: import("@prisma/client/runtime/library").Decimal;
currency: string;
method: string;
tripayReference: string | null;
tripayFee: import("@prisma/client/runtime/library").Decimal | null;
totalAmount: import("@prisma/client/runtime/library").Decimal;
paymentChannel: string | null;
paymentUrl: string | null;
qrUrl: string | null;
status: string;
proofImageUrl: string | null;
transferDate: Date | null;
verifiedBy: string | null;
@@ -76,6 +74,8 @@ export declare class AdminPaymentsService {
notes: string | null;
expiresAt: Date | null;
paidAt: Date | null;
createdAt: Date;
updatedAt: Date;
})[]>;
findOne(id: string): Promise<({
user: {
@@ -86,10 +86,10 @@ export declare class AdminPaymentsService {
subscription: ({
plan: {
id: string;
currency: string;
createdAt: Date;
updatedAt: Date;
name: string;
currency: string;
slug: string;
description: string | null;
price: import("@prisma/client/runtime/library").Decimal;
@@ -112,10 +112,10 @@ export declare class AdminPaymentsService {
};
} & {
id: string;
userId: string;
status: string;
createdAt: Date;
updatedAt: Date;
status: string;
userId: string;
planId: string;
startDate: Date;
endDate: Date;
@@ -126,21 +126,19 @@ export declare class AdminPaymentsService {
}) | null;
} & {
id: string;
createdAt: Date;
updatedAt: Date;
status: string;
method: string;
userId: string;
currency: string;
amount: import("@prisma/client/runtime/library").Decimal;
subscriptionId: string | null;
invoiceNumber: string;
amount: import("@prisma/client/runtime/library").Decimal;
currency: string;
method: string;
tripayReference: string | null;
tripayFee: import("@prisma/client/runtime/library").Decimal | null;
totalAmount: import("@prisma/client/runtime/library").Decimal;
paymentChannel: string | null;
paymentUrl: string | null;
qrUrl: string | null;
status: string;
proofImageUrl: string | null;
transferDate: Date | null;
verifiedBy: string | null;
@@ -151,24 +149,24 @@ export declare class AdminPaymentsService {
notes: string | null;
expiresAt: Date | null;
paidAt: Date | null;
createdAt: Date;
updatedAt: Date;
}) | null>;
verify(id: string, adminUserId: string): Promise<{
id: string;
createdAt: Date;
updatedAt: Date;
status: string;
method: string;
userId: string;
currency: string;
amount: import("@prisma/client/runtime/library").Decimal;
subscriptionId: string | null;
invoiceNumber: string;
amount: import("@prisma/client/runtime/library").Decimal;
currency: string;
method: string;
tripayReference: string | null;
tripayFee: import("@prisma/client/runtime/library").Decimal | null;
totalAmount: import("@prisma/client/runtime/library").Decimal;
paymentChannel: string | null;
paymentUrl: string | null;
qrUrl: string | null;
status: string;
proofImageUrl: string | null;
transferDate: Date | null;
verifiedBy: string | null;
@@ -179,24 +177,24 @@ export declare class AdminPaymentsService {
notes: string | null;
expiresAt: Date | null;
paidAt: Date | null;
createdAt: Date;
updatedAt: Date;
}>;
reject(id: string, adminUserId: string, reason: string): Promise<{
id: string;
createdAt: Date;
updatedAt: Date;
status: string;
method: string;
userId: string;
currency: string;
amount: import("@prisma/client/runtime/library").Decimal;
subscriptionId: string | null;
invoiceNumber: string;
amount: import("@prisma/client/runtime/library").Decimal;
currency: string;
method: string;
tripayReference: string | null;
tripayFee: import("@prisma/client/runtime/library").Decimal | null;
totalAmount: import("@prisma/client/runtime/library").Decimal;
paymentChannel: string | null;
paymentUrl: string | null;
qrUrl: string | null;
status: string;
proofImageUrl: string | null;
transferDate: Date | null;
verifiedBy: string | null;
@@ -207,6 +205,13 @@ export declare class AdminPaymentsService {
notes: string | null;
expiresAt: Date | null;
paidAt: Date | null;
createdAt: Date;
updatedAt: Date;
}>;
getPendingCount(): Promise<number>;
getMonthlyRevenue(): Promise<{
month: string;
revenue: number;
users: number;
}[]>;
}

View File

@@ -107,6 +107,43 @@ let AdminPaymentsService = class AdminPaymentsService {
where: { status: 'pending' },
});
}
async getMonthlyRevenue() {
const sixMonthsAgo = new Date();
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);
const payments = await this.prisma.payment.findMany({
where: {
status: 'paid',
paidAt: {
gte: sixMonthsAgo,
},
},
select: {
amount: true,
paidAt: true,
},
});
const monthlyData = {};
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
payments.forEach((payment) => {
if (payment.paidAt) {
const date = new Date(payment.paidAt);
const monthKey = `${months[date.getMonth()]} ${date.getFullYear()}`;
if (!monthlyData[monthKey]) {
monthlyData[monthKey] = { revenue: 0, count: 0 };
}
monthlyData[monthKey].revenue += Number(payment.amount);
monthlyData[monthKey].count += 1;
}
});
const result = Object.entries(monthlyData)
.map(([month, data]) => ({
month: month.split(' ')[0],
revenue: data.revenue,
users: data.count,
}))
.slice(-6);
return result;
}
};
exports.AdminPaymentsService = AdminPaymentsService;
exports.AdminPaymentsService = AdminPaymentsService = __decorate([

View File

@@ -1 +1 @@
{"version":3,"file":"admin-payments.service.js","sourceRoot":"","sources":["../../src/admin/admin-payments.service.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAA4C;AAC5C,6DAAyD;AAGlD,IAAM,oBAAoB,GAA1B,MAAM,oBAAoB;IACF;IAA7B,YAA6B,MAAqB;QAArB,WAAM,GAAN,MAAM,CAAe;IAAG,CAAC;IAEtD,KAAK,CAAC,OAAO,CAAC,MAAe;QAC3B,OAAO,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC;YAClC,KAAK,EAAE,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC,SAAS;YACtC,OAAO,EAAE;gBACP,IAAI,EAAE;oBACJ,MAAM,EAAE;wBACN,EAAE,EAAE,IAAI;wBACR,KAAK,EAAE,IAAI;wBACX,IAAI,EAAE,IAAI;qBACX;iBACF;gBACD,YAAY,EAAE;oBACZ,OAAO,EAAE;wBACP,IAAI,EAAE,IAAI;qBACX;iBACF;aACF;YACD,OAAO,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE;SAC/B,CAAC,CAAC;IACL,CAAC;IAED,KAAK,CAAC,OAAO,CAAC,EAAU;QACtB,OAAO,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,UAAU,CAAC;YACpC,KAAK,EAAE,EAAE,EAAE,EAAE;YACb,OAAO,EAAE;gBACP,IAAI,EAAE;oBACJ,MAAM,EAAE;wBACN,EAAE,EAAE,IAAI;wBACR,KAAK,EAAE,IAAI;wBACX,IAAI,EAAE,IAAI;qBACX;iBACF;gBACD,YAAY,EAAE;oBACZ,OAAO,EAAE;wBACP,IAAI,EAAE,IAAI;qBACX;iBACF;aACF;SACF,CAAC,CAAC;IACL,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,EAAU,EAAE,WAAmB;QAC1C,MAAM,OAAO,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,UAAU,CAAC;YACnD,KAAK,EAAE,EAAE,EAAE,EAAE;YACb,OAAO,EAAE,EAAE,YAAY,EAAE,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,EAAE,EAAE;SACvD,CAAC,CAAC;QAEH,IAAI,CAAC,OAAO,EAAE,CAAC;YACb,MAAM,IAAI,KAAK,CAAC,mBAAmB,CAAC,CAAC;QACvC,CAAC;QAGD,MAAM,cAAc,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC;YACtD,KAAK,EAAE,EAAE,EAAE,EAAE;YACb,IAAI,EAAE;gBACJ,MAAM,EAAE,MAAM;gBACd,UAAU,EAAE,WAAW;gBACvB,UAAU,EAAE,IAAI,IAAI,EAAE;gBACtB,MAAM,EAAE,IAAI,IAAI,EAAE;aACnB;SACF,CAAC,CAAC;QAGH,IAAI,OAAO,CAAC,cAAc,IAAI,OAAO,CAAC,YAAY,EAAE,CAAC;YACnD,MAAM,IAAI,GAAG,OAAO,CAAC,YAAY,CAAC,IAAI,CAAC;YACvC,MAAM,GAAG,GAAG,IAAI,IAAI,EAAE,CAAC;YACvB,MAAM,OAAO,GAAG,IAAI,IAAI,CAAC,GAAG,CAAC,CAAC;YAE9B,IAAI,IAAI,CAAC,YAAY,EAAE,CAAC;gBACtB,OAAO,CAAC,OAAO,CAAC,OAAO,CAAC,OAAO,EAAE,GAAG,IAAI,CAAC,YAAY,CAAC,CAAC;YACzD,CAAC;YAED,MAAM,IAAI,CAAC,MAAM,CAAC,YAAY,CAAC,MAAM,CAAC;gBACpC,KAAK,EAAE,EAAE,EAAE,EAAE,OAAO,CAAC,cAAc,EAAE;gBACrC,IAAI,EAAE;oBACJ,MAAM,EAAE,QAAQ;oBAChB,SAAS,EAAE,GAAG;oBACd,OAAO,EAAE,IAAI,CAAC,YAAY,KAAK,UAAU,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,OAAO;iBAC7E;aACF,CAAC,CAAC;QACL,CAAC;QAED,OAAO,cAAc,CAAC;IACxB,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,EAAU,EAAE,WAAmB,EAAE,MAAc;QAC1D,OAAO,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC;YAChC,KAAK,EAAE,EAAE,EAAE,EAAE;YACb,IAAI,EAAE;gBACJ,MAAM,EAAE,UAAU;gBAClB,UAAU,EAAE,WAAW;gBACvB,UAAU,EAAE,IAAI,IAAI,EAAE;gBACtB,eAAe,EAAE,MAAM;aACxB;SACF,CAAC,CAAC;IACL,CAAC;IAED,KAAK,CAAC,eAAe;QACnB,OAAO,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC;YAC/B,KAAK,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE;SAC7B,CAAC,CAAC;IACL,CAAC;CACF,CAAA;AAzGY,oDAAoB;+BAApB,oBAAoB;IADhC,IAAA,mBAAU,GAAE;qCAE0B,8BAAa;GADvC,oBAAoB,CAyGhC"}
{"version":3,"file":"admin-payments.service.js","sourceRoot":"","sources":["../../src/admin/admin-payments.service.ts"],"names":[],"mappings":"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"}

View File

@@ -8,13 +8,11 @@ export declare class AdminPlansController {
};
} & {
id: string;
createdAt: Date;
updatedAt: Date;
name: string;
currency: string;
slug: string;
description: string | null;
price: import("@prisma/client/runtime/library").Decimal;
currency: string;
durationType: string;
durationDays: number | null;
trialDays: number;
@@ -31,6 +29,8 @@ export declare class AdminPlansController {
maxTeamMembers: number | null;
apiEnabled: boolean;
apiRateLimit: number | null;
createdAt: Date;
updatedAt: Date;
})[]>;
findOne(id: string): Promise<({
_count: {
@@ -38,13 +38,11 @@ export declare class AdminPlansController {
};
} & {
id: string;
createdAt: Date;
updatedAt: Date;
name: string;
currency: string;
slug: string;
description: string | null;
price: import("@prisma/client/runtime/library").Decimal;
currency: string;
durationType: string;
durationDays: number | null;
trialDays: number;
@@ -61,16 +59,16 @@ export declare class AdminPlansController {
maxTeamMembers: number | null;
apiEnabled: boolean;
apiRateLimit: number | null;
createdAt: Date;
updatedAt: Date;
}) | null>;
create(data: any): Promise<{
id: string;
createdAt: Date;
updatedAt: Date;
name: string;
currency: string;
slug: string;
description: string | null;
price: import("@prisma/client/runtime/library").Decimal;
currency: string;
durationType: string;
durationDays: number | null;
trialDays: number;
@@ -87,16 +85,16 @@ export declare class AdminPlansController {
maxTeamMembers: number | null;
apiEnabled: boolean;
apiRateLimit: number | null;
createdAt: Date;
updatedAt: Date;
}>;
update(id: string, data: any): Promise<{
id: string;
createdAt: Date;
updatedAt: Date;
name: string;
currency: string;
slug: string;
description: string | null;
price: import("@prisma/client/runtime/library").Decimal;
currency: string;
durationType: string;
durationDays: number | null;
trialDays: number;
@@ -113,32 +111,44 @@ export declare class AdminPlansController {
maxTeamMembers: number | null;
apiEnabled: boolean;
apiRateLimit: number | null;
createdAt: Date;
updatedAt: Date;
}>;
delete(id: string): Promise<{
id: string;
createdAt: Date;
updatedAt: Date;
name: string;
currency: string;
slug: string;
description: string | null;
price: import("@prisma/client/runtime/library").Decimal;
durationType: string;
durationDays: number | null;
trialDays: number;
features: import("@prisma/client/runtime/library").JsonValue;
badge: string | null;
badgeColor: string | null;
highlightColor: string | null;
sortOrder: number;
isActive: boolean;
isVisible: boolean;
isFeatured: boolean;
maxWallets: number | null;
maxGoals: number | null;
maxTeamMembers: number | null;
apiEnabled: boolean;
apiRateLimit: number | null;
success: boolean;
message: string;
action: string;
plan: {
id: string;
name: string;
slug: string;
description: string | null;
price: import("@prisma/client/runtime/library").Decimal;
currency: string;
durationType: string;
durationDays: number | null;
trialDays: number;
features: import("@prisma/client/runtime/library").JsonValue;
badge: string | null;
badgeColor: string | null;
highlightColor: string | null;
sortOrder: number;
isActive: boolean;
isVisible: boolean;
isFeatured: boolean;
maxWallets: number | null;
maxGoals: number | null;
maxTeamMembers: number | null;
apiEnabled: boolean;
apiRateLimit: number | null;
createdAt: Date;
updatedAt: Date;
};
} | {
success: boolean;
message: string;
action: string;
plan?: undefined;
}>;
reorder(body: {
planIds: string[];

View File

@@ -8,13 +8,11 @@ export declare class AdminPlansService {
};
} & {
id: string;
createdAt: Date;
updatedAt: Date;
name: string;
currency: string;
slug: string;
description: string | null;
price: import("@prisma/client/runtime/library").Decimal;
currency: string;
durationType: string;
durationDays: number | null;
trialDays: number;
@@ -31,6 +29,8 @@ export declare class AdminPlansService {
maxTeamMembers: number | null;
apiEnabled: boolean;
apiRateLimit: number | null;
createdAt: Date;
updatedAt: Date;
})[]>;
findOne(id: string): Promise<({
_count: {
@@ -38,13 +38,11 @@ export declare class AdminPlansService {
};
} & {
id: string;
createdAt: Date;
updatedAt: Date;
name: string;
currency: string;
slug: string;
description: string | null;
price: import("@prisma/client/runtime/library").Decimal;
currency: string;
durationType: string;
durationDays: number | null;
trialDays: number;
@@ -61,16 +59,16 @@ export declare class AdminPlansService {
maxTeamMembers: number | null;
apiEnabled: boolean;
apiRateLimit: number | null;
createdAt: Date;
updatedAt: Date;
}) | null>;
create(data: any): Promise<{
id: string;
createdAt: Date;
updatedAt: Date;
name: string;
currency: string;
slug: string;
description: string | null;
price: import("@prisma/client/runtime/library").Decimal;
currency: string;
durationType: string;
durationDays: number | null;
trialDays: number;
@@ -87,16 +85,16 @@ export declare class AdminPlansService {
maxTeamMembers: number | null;
apiEnabled: boolean;
apiRateLimit: number | null;
createdAt: Date;
updatedAt: Date;
}>;
update(id: string, data: any): Promise<{
id: string;
createdAt: Date;
updatedAt: Date;
name: string;
currency: string;
slug: string;
description: string | null;
price: import("@prisma/client/runtime/library").Decimal;
currency: string;
durationType: string;
durationDays: number | null;
trialDays: number;
@@ -113,32 +111,44 @@ export declare class AdminPlansService {
maxTeamMembers: number | null;
apiEnabled: boolean;
apiRateLimit: number | null;
createdAt: Date;
updatedAt: Date;
}>;
delete(id: string): Promise<{
id: string;
createdAt: Date;
updatedAt: Date;
name: string;
currency: string;
slug: string;
description: string | null;
price: import("@prisma/client/runtime/library").Decimal;
durationType: string;
durationDays: number | null;
trialDays: number;
features: import("@prisma/client/runtime/library").JsonValue;
badge: string | null;
badgeColor: string | null;
highlightColor: string | null;
sortOrder: number;
isActive: boolean;
isVisible: boolean;
isFeatured: boolean;
maxWallets: number | null;
maxGoals: number | null;
maxTeamMembers: number | null;
apiEnabled: boolean;
apiRateLimit: number | null;
success: boolean;
message: string;
action: string;
plan: {
id: string;
name: string;
slug: string;
description: string | null;
price: import("@prisma/client/runtime/library").Decimal;
currency: string;
durationType: string;
durationDays: number | null;
trialDays: number;
features: import("@prisma/client/runtime/library").JsonValue;
badge: string | null;
badgeColor: string | null;
highlightColor: string | null;
sortOrder: number;
isActive: boolean;
isVisible: boolean;
isFeatured: boolean;
maxWallets: number | null;
maxGoals: number | null;
maxTeamMembers: number | null;
apiEnabled: boolean;
apiRateLimit: number | null;
createdAt: Date;
updatedAt: Date;
};
} | {
success: boolean;
message: string;
action: string;
plan?: undefined;
}>;
reorder(planIds: string[]): Promise<{
success: boolean;

View File

@@ -49,10 +49,36 @@ let AdminPlansService = class AdminPlansService {
});
}
async delete(id) {
return this.prisma.plan.update({
const plan = await this.prisma.plan.findUnique({
where: { id },
data: { isActive: false, isVisible: false },
include: {
_count: {
select: { subscriptions: true },
},
},
});
if (!plan) {
throw new Error('Plan not found');
}
if (plan._count.subscriptions > 0) {
return {
success: false,
message: `Cannot delete plan. There are ${plan._count.subscriptions} active subscription(s) associated with this plan. The plan has been deactivated instead.`,
action: 'deactivated',
plan: await this.prisma.plan.update({
where: { id },
data: { isActive: false, isVisible: false },
}),
};
}
await this.prisma.plan.delete({
where: { id },
});
return {
success: true,
message: 'Plan permanently deleted',
action: 'deleted',
};
}
async reorder(planIds) {
const updates = planIds.map((id, index) => this.prisma.plan.update({

View File

@@ -1 +1 @@
{"version":3,"file":"admin-plans.service.js","sourceRoot":"","sources":["../../src/admin/admin-plans.service.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAA4C;AAC5C,6DAAyD;AAGlD,IAAM,iBAAiB,GAAvB,MAAM,iBAAiB;IACC;IAA7B,YAA6B,MAAqB;QAArB,WAAM,GAAN,MAAM,CAAe;IAAG,CAAC;IAEtD,KAAK,CAAC,OAAO;QACX,OAAO,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC;YAC/B,OAAO,EAAE,EAAE,SAAS,EAAE,KAAK,EAAE;YAC7B,OAAO,EAAE;gBACP,MAAM,EAAE;oBACN,MAAM,EAAE,EAAE,aAAa,EAAE,IAAI,EAAE;iBAChC;aACF;SACF,CAAC,CAAC;IACL,CAAC;IAED,KAAK,CAAC,OAAO,CAAC,EAAU;QACtB,OAAO,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC;YACjC,KAAK,EAAE,EAAE,EAAE,EAAE;YACb,OAAO,EAAE;gBACP,MAAM,EAAE;oBACN,MAAM,EAAE,EAAE,aAAa,EAAE,IAAI,EAAE;iBAChC;aACF;SACF,CAAC,CAAC;IACL,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,IAAS;QACpB,OAAO,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC;YAC7B,IAAI;SACL,CAAC,CAAC;IACL,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,EAAU,EAAE,IAAS;QAChC,OAAO,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC;YAC7B,KAAK,EAAE,EAAE,EAAE,EAAE;YACb,IAAI;SACL,CAAC,CAAC;IACL,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,EAAU;QAErB,OAAO,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC;YAC7B,KAAK,EAAE,EAAE,EAAE,EAAE;YACb,IAAI,EAAE,EAAE,QAAQ,EAAE,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE;SAC5C,CAAC,CAAC;IACL,CAAC;IAED,KAAK,CAAC,OAAO,CAAC,OAAiB;QAE7B,MAAM,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,KAAK,EAAE,EAAE,CACxC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC;YACtB,KAAK,EAAE,EAAE,EAAE,EAAE;YACb,IAAI,EAAE,EAAE,SAAS,EAAE,KAAK,GAAG,CAAC,EAAE;SAC/B,CAAC,CACH,CAAC;QAEF,MAAM,IAAI,CAAC,MAAM,CAAC,YAAY,CAAC,OAAO,CAAC,CAAC;QACxC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;IAC3B,CAAC;CACF,CAAA;AA1DY,8CAAiB;4BAAjB,iBAAiB;IAD7B,IAAA,mBAAU,GAAE;qCAE0B,8BAAa;GADvC,iBAAiB,CA0D7B"}
{"version":3,"file":"admin-plans.service.js","sourceRoot":"","sources":["../../src/admin/admin-plans.service.ts"],"names":[],"mappings":"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"}

File diff suppressed because one or more lines are too long

View File

@@ -33,6 +33,11 @@ export class AdminPaymentsController {
return this.service.getPendingCount();
}
@Get('revenue/monthly')
getMonthlyRevenue() {
return this.service.getMonthlyRevenue();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.service.findOne(id);

View File

@@ -107,4 +107,52 @@ export class AdminPaymentsService {
where: { status: 'pending' },
});
}
async getMonthlyRevenue() {
// Get payments from last 6 months
const sixMonthsAgo = new Date();
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);
const payments = await this.prisma.payment.findMany({
where: {
status: 'paid',
paidAt: {
gte: sixMonthsAgo,
},
},
select: {
amount: true,
paidAt: true,
},
});
// Group by month
const monthlyData: { [key: string]: { revenue: number; count: number } } = {};
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
payments.forEach((payment) => {
if (payment.paidAt) {
const date = new Date(payment.paidAt);
const monthKey = `${months[date.getMonth()]} ${date.getFullYear()}`;
if (!monthlyData[monthKey]) {
monthlyData[monthKey] = { revenue: 0, count: 0 };
}
monthlyData[monthKey].revenue += Number(payment.amount);
monthlyData[monthKey].count += 1;
}
});
// Convert to array and sort by date
const result = Object.entries(monthlyData)
.map(([month, data]) => ({
month: month.split(' ')[0], // Just the month name
revenue: data.revenue,
users: data.count,
}))
.slice(-6); // Last 6 months
return result;
}
}

View File

@@ -41,11 +41,43 @@ export class AdminPlansService {
}
async delete(id: string) {
// Soft delete - just deactivate
return this.prisma.plan.update({
// Check if plan has any subscriptions
const plan = await this.prisma.plan.findUnique({
where: { id },
data: { isActive: false, isVisible: false },
include: {
_count: {
select: { subscriptions: true },
},
},
});
if (!plan) {
throw new Error('Plan not found');
}
// If plan has subscriptions, soft delete (deactivate)
if (plan._count.subscriptions > 0) {
return {
success: false,
message: `Cannot delete plan. There are ${plan._count.subscriptions} active subscription(s) associated with this plan. The plan has been deactivated instead.`,
action: 'deactivated',
plan: await this.prisma.plan.update({
where: { id },
data: { isActive: false, isVisible: false },
}),
};
}
// If no subscriptions, permanently delete
await this.prisma.plan.delete({
where: { id },
});
return {
success: true,
message: 'Plan permanently deleted',
action: 'deleted',
};
}
async reorder(planIds: string[]) {

View File

@@ -8,8 +8,12 @@
"name": "web",
"version": "0.0.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
@@ -17,6 +21,7 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"axios": "^1.11.0",
@@ -31,6 +36,7 @@
"react-hook-form": "^7.64.0",
"react-router-dom": "^7.9.4",
"recharts": "^2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"zod": "^4.1.12"
@@ -378,6 +384,59 @@
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
"license": "MIT"
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz",
@@ -1243,6 +1302,36 @@
}
}
},
"node_modules/@radix-ui/react-checkbox": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz",
"integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
@@ -1782,6 +1871,35 @@
}
}
},
"node_modules/@radix-ui/react-switch": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
"integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tabs": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
@@ -5536,6 +5654,16 @@
"node": ">=8"
}
},
"node_modules/sonner": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",

View File

@@ -10,8 +10,12 @@
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
@@ -19,6 +23,7 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"axios": "^1.11.0",
@@ -33,6 +38,7 @@
"react-hook-form": "^7.64.0",
"react-router-dom": "^7.9.4",
"recharts": "^2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"zod": "^4.1.12"

View File

@@ -1,6 +1,7 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider, useAuth } from './contexts/AuthContext'
import { ThemeProvider } from './components/ThemeProvider'
import { Toaster } from './components/ui/sonner'
import { Dashboard } from './components/Dashboard'
import { Login } from './components/pages/Login'
import { Register } from './components/pages/Register'
@@ -59,6 +60,7 @@ export default function App() {
<BrowserRouter>
<ThemeProvider defaultTheme="light" storageKey="tabungin-ui-theme">
<AuthProvider>
<Toaster />
<Routes>
{/* Public Routes */}
<Route path="/auth/login" element={<PublicRoute><Login /></PublicRoute>} />

View File

@@ -10,6 +10,7 @@ import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar'
import { useAuth } from '@/contexts/AuthContext'
import { getAvatarUrl } from '@/lib/utils'
@@ -54,6 +55,7 @@ interface AdminSidebarProps {
export function AdminSidebar({ currentPage, onNavigate }: AdminSidebarProps) {
const { user, logout } = useAuth()
const { isMobile, setOpenMobile } = useSidebar()
return (
<Sidebar>
@@ -73,7 +75,10 @@ export function AdminSidebar({ currentPage, onNavigate }: AdminSidebarProps) {
<SidebarMenuButton
asChild
isActive={isActive}
onClick={() => onNavigate(item.url)}
onClick={() => {
onNavigate(item.url)
if (isMobile) setOpenMobile(false)
}}
className={`${
isActive
? 'bg-primary/10 text-primary hover:bg-primary/10 hover:text-primary'

View File

@@ -1,6 +1,11 @@
import { useEffect, useState } from 'react'
import axios from 'axios'
import { Users, CreditCard, DollarSign, TrendingUp } from 'lucide-react'
import { Users, CreditCard, DollarSign, TrendingUp, Activity, Wallet, ArrowUpRight } from 'lucide-react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Bar, BarChart, Line, LineChart, ResponsiveContainer, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts'
import { formatLargeNumber } from '@/utils/numberFormat'
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'
@@ -8,15 +13,24 @@ interface Stats {
totalUsers: number
activeSubscriptions: number
suspendedUsers: number
totalRevenue: number
monthlyRevenue: number
revenueGrowth: number
newUsersThisMonth: number
userGrowth: number
}
export function AdminDashboard() {
const [stats, setStats] = useState<Stats | null>(null)
const [pendingPayments, setPendingPayments] = useState(0)
const [loading, setLoading] = useState(true)
const [subscriptionData, setSubscriptionData] = useState<{ plan: string; count: number }[]>([])
const [revenueData, setRevenueData] = useState<{ month: string; revenue: number; users: number }[]>([])
useEffect(() => {
fetchStats()
fetchSubscriptionData()
fetchRevenueData()
}, [])
const fetchStats = async () => {
@@ -39,6 +53,65 @@ export function AdminDashboard() {
}
}
const fetchSubscriptionData = async () => {
try {
const token = localStorage.getItem('token')
const [plansRes, usersRes] = await Promise.all([
axios.get(`${API_URL}/api/admin/plans`, {
headers: { Authorization: `Bearer ${token}` },
}),
axios.get(`${API_URL}/api/admin/users`, {
headers: { Authorization: `Bearer ${token}` },
}),
])
// Count subscriptions per plan
const plans = plansRes.data
const users = usersRes.data
const planCounts = plans.map((plan: { name: string; _count?: { subscriptions: number } }) => ({
plan: plan.name,
count: plan._count?.subscriptions || 0,
}))
// Count users without subscriptions as "Free" (exclude admins)
const nonAdminUsers = users.filter((user: { role: string }) => user.role !== 'admin')
const totalUsers = nonAdminUsers.length
const subscribedUsers = planCounts.reduce((sum: number, plan: { count: number }) => sum + plan.count, 0)
const freeUsers = totalUsers - subscribedUsers
// Add Free tier at the beginning
if (freeUsers > 0) {
planCounts.unshift({ plan: 'Free', count: freeUsers })
}
setSubscriptionData(planCounts)
} catch (error) {
console.error('Failed to fetch subscription data:', error)
}
}
const fetchRevenueData = async () => {
try {
const token = localStorage.getItem('token')
const response = await axios.get(`${API_URL}/api/admin/payments/revenue/monthly`, {
headers: { Authorization: `Bearer ${token}` },
})
setRevenueData(response.data)
} catch (error) {
console.error('Failed to fetch revenue data:', error)
// Fallback to mock data if API fails
setRevenueData([
{ month: 'Jan', revenue: 0, users: 0 },
{ month: 'Feb', revenue: 0, users: 0 },
{ month: 'Mar', revenue: 0, users: 0 },
{ month: 'Apr', revenue: 0, users: 0 },
{ month: 'May', revenue: 0, users: 0 },
{ month: 'Jun', revenue: 0, users: 0 },
])
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
@@ -47,106 +120,211 @@ export function AdminDashboard() {
)
}
const statCards = [
{
name: 'Total Users',
value: stats?.totalUsers || 0,
icon: Users,
color: 'text-blue-600',
bgColor: 'bg-blue-500/20',
},
{
name: 'Active Subscriptions',
value: stats?.activeSubscriptions || 0,
icon: TrendingUp,
color: 'text-green-600',
bgColor: 'bg-green-500/20',
},
{
name: 'Pending Payments',
value: pendingPayments,
icon: DollarSign,
color: 'text-yellow-600',
bgColor: 'bg-yellow-500/20',
},
{
name: 'Suspended Users',
value: stats?.suspendedUsers || 0,
icon: CreditCard,
color: 'text-red-600',
bgColor: 'bg-red-500/20',
},
]
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground">
Dashboard
</h1>
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-3xl font-bold text-foreground">Dashboard</h1>
<p className="mt-2 text-muted-foreground">
Selamat datang di panel admin
Selamat datang di panel admin - Overview performa aplikasi
</p>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4 mb-8">
{statCards.map((stat) => (
<div
key={stat.name}
className="bg-card rounded-lg shadow p-6 border border-border"
>
<div className="flex items-center">
<div className={`p-3 rounded-lg ${stat.bgColor}`}>
<stat.icon className={`h-6 w-6 ${stat.color}`} />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-muted-foreground">
{stat.name}
</p>
<p className="text-2xl font-bold text-foreground">
{stat.value}
</p>
</div>
</div>
</div>
))}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats?.totalUsers || 0}</div>
<p className="text-xs text-muted-foreground flex items-center mt-1">
<ArrowUpRight className="h-3 w-3 text-green-600 mr-1" />
<span className="text-green-600">+{stats?.userGrowth || 0}%</span>
<span className="ml-1">dari bulan lalu</span>
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active Subscriptions</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats?.activeSubscriptions || 0}</div>
<p className="text-xs text-muted-foreground mt-1">
Langganan aktif saat ini
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Monthly Revenue</CardTitle>
<DollarSign className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatLargeNumber(stats?.monthlyRevenue || 0, 'IDR')}</div>
<p className="text-xs text-muted-foreground flex items-center mt-1">
<ArrowUpRight className="h-3 w-3 text-green-600 mr-1" />
<span className="text-green-600">+{stats?.revenueGrowth || 0}%</span>
<span className="ml-1">dari bulan lalu</span>
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Pending Payments</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{pendingPayments}</div>
<p className="text-xs text-muted-foreground mt-1">
Menunggu verifikasi
</p>
</CardContent>
</Card>
</div>
{/* Quick Actions */}
<div className="bg-card rounded-lg shadow border border-border p-6">
<h2 className="text-lg font-semibold text-foreground mb-4">
Quick Actions
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<a
href="/admin/plans"
className="flex items-center p-4 border border-border rounded-lg hover:bg-accent transition-colors"
>
<CreditCard className="h-5 w-5 text-primary mr-3" />
<span className="text-sm font-medium text-foreground">
Kelola Plans
</span>
</a>
<a
href="/admin/payments"
className="flex items-center p-4 border border-border rounded-lg hover:bg-accent transition-colors"
>
<DollarSign className="h-5 w-5 text-primary mr-3" />
<span className="text-sm font-medium text-foreground">
Verifikasi Pembayaran
</span>
</a>
<a
href="/admin/users"
className="flex items-center p-4 border border-border rounded-lg hover:bg-accent transition-colors"
>
<Users className="h-5 w-5 text-primary mr-3" />
<span className="text-sm font-medium text-foreground">
Kelola Users
</span>
</a>
</div>
{/* Charts Row */}
<div className="grid gap-4 md:grid-cols-2">
{/* Revenue Chart */}
<Card>
<CardHeader>
<CardTitle>Revenue Overview</CardTitle>
<CardDescription>Pendapatan 6 bulan terakhir</CardDescription>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={revenueData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="month" className="text-xs" />
<YAxis className="text-xs" />
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '8px'
}}
formatter={(value: number) => formatLargeNumber(value, 'IDR')}
/>
<Line
type="monotone"
dataKey="revenue"
stroke="hsl(var(--primary))"
strokeWidth={2}
dot={{ fill: 'hsl(var(--primary))' }}
/>
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* Subscription Distribution */}
<Card>
<CardHeader>
<CardTitle>Subscription Distribution</CardTitle>
<CardDescription>Distribusi pengguna per plan</CardDescription>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={subscriptionData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="plan" className="text-xs" />
<YAxis className="text-xs" />
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '8px'
}}
/>
<Bar dataKey="count" fill="hsl(var(--primary))" radius={[8, 8, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
{/* Recent Activity & Quick Actions */}
<div className="grid gap-4 md:grid-cols-2">
{/* Quick Actions */}
<Card>
<CardHeader>
<CardTitle>Quick Actions</CardTitle>
<CardDescription>Akses cepat ke fitur utama</CardDescription>
</CardHeader>
<CardContent className="grid gap-2">
<Button variant="outline" className="justify-start" asChild>
<a href="/admin/plans">
<CreditCard className="h-4 w-4 mr-2" />
Kelola Plans
</a>
</Button>
<Button variant="outline" className="justify-start" asChild>
<a href="/admin/payments">
<DollarSign className="h-4 w-4 mr-2" />
Verifikasi Pembayaran
{pendingPayments > 0 && (
<Badge variant="destructive" className="ml-auto">{pendingPayments}</Badge>
)}
</a>
</Button>
<Button variant="outline" className="justify-start" asChild>
<a href="/admin/users">
<Users className="h-4 w-4 mr-2" />
Kelola Users
</a>
</Button>
<Button variant="outline" className="justify-start" asChild>
<a href="/admin/payment-methods">
<Wallet className="h-4 w-4 mr-2" />
Metode Pembayaran
</a>
</Button>
</CardContent>
</Card>
{/* System Status */}
<Card>
<CardHeader>
<CardTitle>System Status</CardTitle>
<CardDescription>Status sistem dan statistik</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-green-500" />
<span className="text-sm">All Systems Operational</span>
</div>
<Badge variant="outline" className="bg-green-500/10 text-green-600 border-green-500/20">
Healthy
</Badge>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Total Users</span>
<span className="font-medium">{stats?.totalUsers || 0}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Active Subscriptions</span>
<span className="font-medium">{stats?.activeSubscriptions || 0}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Suspended Users</span>
<span className="font-medium text-red-600">{stats?.suspendedUsers || 0}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Total Revenue</span>
<span className="font-medium">{formatLargeNumber(stats?.totalRevenue || 0, 'IDR')}</span>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
)

View File

@@ -1,26 +1,268 @@
import { useState, useEffect } from 'react'
import { Plus, Edit, Trash2, Eye, EyeOff, GripVertical, CreditCard, Wallet, Building2 } from 'lucide-react'
import { api } from '@/lib/api'
import axios from 'axios'
import { Plus, Edit, Trash2, GripVertical, CreditCard, Wallet, Building2 } from 'lucide-react'
import { toast } from 'sonner'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core'
import type { DragEndEvent } from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'
interface PaymentMethod {
id: string
name: string
type: 'bank_transfer' | 'ewallet' | 'qris' | 'other'
accountNumber?: string
accountName?: string
displayName: string
type: string
provider: string
accountNumber: string
accountName: string
logoUrl?: string
instructions?: string
isActive: boolean
isVisible: boolean
displayOrder: number
sortOrder: number
createdAt: string
updatedAt: string
}
interface PaymentMethodFormData {
displayName: string
type: string
provider: string
accountNumber: string
accountName: string
logoUrl: string
instructions: string
isActive: boolean
}
function SortableMethodCard({ method, onEdit, onDelete, onToggleActive, getTypeIcon, getTypeLabel, getTypeColor }: {
method: PaymentMethod
onEdit: (method: PaymentMethod) => void
onDelete: (id: string) => void
onToggleActive: (method: PaymentMethod) => void
getTypeIcon: (type: string) => React.ComponentType<{ className?: string }>
getTypeLabel: (type: string) => string
getTypeColor: (type: string) => string
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: method.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}
const TypeIcon = getTypeIcon(method.type)
return (
<div
ref={setNodeRef}
style={style}
className="group relative bg-gradient-to-br from-card to-card/50 rounded-2xl border-2 border-border/50 hover:border-primary/30 overflow-hidden transition-all duration-300 hover:shadow-2xl hover:-translate-y-1"
>
{/* Content */}
<div className="p-6">
{/* Header: Drag Icon + Title + Type Badge */}
<div className="flex items-start gap-3 mb-6">
{/* Drag Handle */}
<div
{...attributes}
{...listeners}
className="opacity-0 group-hover:opacity-100 transition-opacity pt-1 cursor-move"
>
<GripVertical className="h-5 w-5 mt-1 text-muted-foreground/50" />
</div>
{/* Logo */}
{method.logoUrl ? (
<img
src={method.logoUrl}
alt={method.displayName}
className="h-12 w-12 rounded-lg object-contain bg-muted p-2"
onError={(e) => {
e.currentTarget.style.display = 'none'
e.currentTarget.nextElementSibling?.classList.remove('hidden')
}}
/>
) : null}
<div className={`h-12 w-12 rounded-lg bg-muted flex items-center justify-center ${method.logoUrl ? 'hidden' : ''}`}>
<TypeIcon className="h-6 w-6 text-muted-foreground" />
</div>
{/* Title & Type */}
<div className="flex-1 min-w-0">
{/* Method Name + Type Badge */}
<div className="flex items-center gap-2 mb-2 flex-wrap">
<h3 className="text-2xl font-bold text-foreground">
{method.displayName}
</h3>
<span
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-semibold ${getTypeColor(
method.type
)}`}
>
<TypeIcon className="h-3 w-3" />
{getTypeLabel(method.type)}
</span>
</div>
{/* Account Info */}
{method.accountNumber && (
<div className="text-sm text-muted-foreground space-y-1">
<div className="font-mono">{method.accountNumber}</div>
{method.accountName && (
<div className="font-medium">{method.accountName}</div>
)}
</div>
)}
</div>
</div>
{/* Instructions */}
{method.instructions && (
<div className="mb-6 p-4 rounded-lg bg-muted/50 border border-border/50">
<p className="text-xs text-muted-foreground mb-1">Instruksi:</p>
<p className="text-sm text-foreground line-clamp-3">
{method.instructions}
</p>
</div>
)}
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-4 mb-6">
<div className="p-3 rounded-lg bg-muted/50 border border-border/50">
<div className="text-xs text-muted-foreground mb-1">Status</div>
<div className="flex items-center gap-2">
{method.isActive ? (
<>
<div className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
<span className="text-sm font-semibold text-green-600">Active</span>
</>
) : (
<>
<div className="h-2 w-2 rounded-full bg-gray-400" />
<span className="text-sm font-semibold text-muted-foreground">
Inactive
</span>
</>
)}
</div>
</div>
<div className="p-3 rounded-lg bg-muted/50 border border-border/50">
<div className="text-xs text-muted-foreground mb-1">Provider</div>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-foreground">
{method.provider}
</span>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-2">
<button
onClick={() => onToggleActive(method)}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium text-sm transition-all ${
method.isActive
? 'bg-green-500/10 text-green-600 hover:bg-green-500/20'
: 'bg-muted text-muted-foreground hover:bg-muted/80'
}`}
>
<span>{method.isActive ? 'Active' : 'Inactive'}</span>
</button>
<button
onClick={() => onEdit(method)}
className="p-2.5 rounded-lg bg-primary/10 text-primary hover:bg-primary hover:text-primary-foreground transition-all"
title="Edit"
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={() => onDelete(method.id)}
className="p-2.5 rounded-lg bg-destructive/10 text-destructive hover:bg-destructive hover:text-destructive-foreground transition-all"
title="Hapus"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
</div>
)
}
export function AdminPaymentMethods() {
const [methods, setMethods] = useState<PaymentMethod[]>([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editingMethod, setEditingMethod] = useState<PaymentMethod | null>(null)
const [deleteDialog, setDeleteDialog] = useState<{ open: boolean; methodId: string }>({ open: false, methodId: '' })
const [formData, setFormData] = useState<PaymentMethodFormData>({
displayName: '',
type: 'bank_transfer',
provider: '',
accountNumber: '',
accountName: '',
logoUrl: '',
instructions: '',
isActive: true,
})
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
useEffect(() => {
fetchMethods()
@@ -29,7 +271,10 @@ export function AdminPaymentMethods() {
const fetchMethods = async () => {
try {
setLoading(true)
const response = await api.get('/admin/payment-methods')
const token = localStorage.getItem('token')
const response = await axios.get(`${API_URL}/api/admin/payment-methods`, {
headers: { Authorization: `Bearer ${token}` },
})
setMethods(response.data)
} catch (error) {
console.error('Failed to fetch payment methods:', error)
@@ -38,39 +283,121 @@ export function AdminPaymentMethods() {
}
}
const toggleVisibility = async (method: PaymentMethod) => {
try {
await api.patch(`/admin/payment-methods/${method.id}/visibility`, {
isVisible: !method.isVisible,
})
fetchMethods()
} catch (error) {
console.error('Failed to toggle visibility:', error)
alert('Gagal mengubah visibilitas')
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event
if (over && active.id !== over.id) {
const oldIndex = methods.findIndex((m) => m.id === active.id)
const newIndex = methods.findIndex((m) => m.id === over.id)
const newMethods = arrayMove(methods, oldIndex, newIndex)
setMethods(newMethods)
// Save new order to backend
try {
const token = localStorage.getItem('token')
await axios.post(
`${API_URL}/api/admin/payment-methods/reorder`,
{ methodIds: newMethods.map((m) => m.id) },
{ headers: { Authorization: `Bearer ${token}` } }
)
toast.success('Urutan metode pembayaran berhasil diubah')
} catch (error) {
console.error('Failed to reorder methods:', error)
toast.error('Gagal mengubah urutan metode pembayaran')
fetchMethods() // Revert on error
}
}
}
const toggleActive = async (method: PaymentMethod) => {
try {
await api.patch(`/admin/payment-methods/${method.id}/status`, {
isActive: !method.isActive,
})
const token = localStorage.getItem('token')
await axios.put(
`${API_URL}/api/admin/payment-methods/${method.id}`,
{ ...method, isActive: !method.isActive },
{ headers: { Authorization: `Bearer ${token}` } }
)
toast.success(method.isActive ? 'Metode pembayaran dinonaktifkan' : 'Metode pembayaran diaktifkan')
fetchMethods()
} catch (error) {
console.error('Failed to toggle status:', error)
alert('Gagal mengubah status')
toast.error('Gagal mengubah status')
}
}
const handleDelete = async (id: string) => {
if (!confirm('Yakin ingin menghapus metode pembayaran ini?')) return
const openDeleteDialog = (id: string) => {
setDeleteDialog({ open: true, methodId: id })
}
const handleDelete = async () => {
try {
await api.delete(`/admin/payment-methods/${id}`)
const token = localStorage.getItem('token')
await axios.delete(`${API_URL}/api/admin/payment-methods/${deleteDialog.methodId}`, {
headers: { Authorization: `Bearer ${token}` },
})
toast.success('Metode pembayaran berhasil dihapus')
fetchMethods()
setDeleteDialog({ open: false, methodId: '' })
} catch (error) {
console.error('Failed to delete payment method:', error)
alert('Gagal menghapus metode pembayaran')
toast.error('Gagal menghapus metode pembayaran')
}
}
const handleOpenModal = (method?: PaymentMethod) => {
if (method) {
setEditingMethod(method)
setFormData({
displayName: method.displayName,
type: method.type,
provider: method.provider,
accountNumber: method.accountNumber,
accountName: method.accountName,
logoUrl: method.logoUrl || '',
instructions: method.instructions || '',
isActive: method.isActive,
})
} else {
setEditingMethod(null)
setFormData({
displayName: '',
type: 'bank_transfer',
provider: '',
accountNumber: '',
accountName: '',
logoUrl: '',
instructions: '',
isActive: true,
})
}
setShowModal(true)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
const token = localStorage.getItem('token')
if (editingMethod) {
await axios.put(
`${API_URL}/api/admin/payment-methods/${editingMethod.id}`,
formData,
{ headers: { Authorization: `Bearer ${token}` } }
)
} else {
await axios.post(`${API_URL}/api/admin/payment-methods`, formData, {
headers: { Authorization: `Bearer ${token}` },
})
}
toast.success(editingMethod ? 'Metode pembayaran berhasil diupdate' : 'Metode pembayaran berhasil ditambahkan')
fetchMethods()
setShowModal(false)
setEditingMethod(null)
} catch (error) {
console.error('Failed to save payment method:', error)
toast.error('Gagal menyimpan metode pembayaran')
}
}
@@ -132,162 +459,172 @@ export function AdminPaymentMethods() {
Kelola metode pembayaran yang tersedia
</p>
</div>
<button
onClick={() => {
setEditingMethod(null)
setShowModal(true)
}}
className="flex items-center px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
>
<Button onClick={() => handleOpenModal()}>
<Plus className="h-5 w-5 mr-2" />
Tambah Metode
</button>
</Button>
</div>
{/* Payment Methods Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{methods.map((method) => {
const TypeIcon = getTypeIcon(method.type)
return (
<div
key={method.id}
className="group relative bg-gradient-to-br from-card to-card/50 rounded-2xl border-2 border-border/50 hover:border-primary/30 overflow-hidden transition-all duration-300 hover:shadow-2xl hover:-translate-y-1"
>
{/* Content */}
<div className="p-6">
{/* Header: Drag Icon + Title + Type Badge */}
<div className="flex items-start gap-3 mb-6">
{/* Drag Handle */}
<div className="opacity-0 group-hover:opacity-100 transition-opacity pt-1">
<GripVertical className="h-5 w-5 mt-1 text-muted-foreground/50 cursor-move" />
</div>
{/* Title & Type */}
<div className="flex-1 min-w-0">
{/* Method Name + Type Badge */}
<div className="flex items-center gap-2 mb-2 flex-wrap">
<h3 className="text-2xl font-bold text-foreground">
{method.name}
</h3>
<span
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-semibold ${getTypeColor(
method.type
)}`}
>
<TypeIcon className="h-3 w-3" />
{getTypeLabel(method.type)}
</span>
</div>
{/* Account Info */}
{method.accountNumber && (
<div className="text-sm text-muted-foreground space-y-1">
<div className="font-mono">{method.accountNumber}</div>
{method.accountName && (
<div className="font-medium">{method.accountName}</div>
)}
</div>
)}
</div>
</div>
{/* Instructions */}
{method.instructions && (
<div className="mb-6 p-4 rounded-lg bg-muted/50 border border-border/50">
<p className="text-xs text-muted-foreground mb-1">Instruksi:</p>
<p className="text-sm text-foreground line-clamp-3">
{method.instructions}
</p>
</div>
)}
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-4 mb-6">
<div className="p-3 rounded-lg bg-muted/50 border border-border/50">
<div className="text-xs text-muted-foreground mb-1">Status</div>
<div className="flex items-center gap-2">
{method.isActive ? (
<>
<div className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
<span className="text-sm font-semibold text-green-600">Active</span>
</>
) : (
<>
<div className="h-2 w-2 rounded-full bg-gray-400" />
<span className="text-sm font-semibold text-muted-foreground">
Inactive
</span>
</>
)}
</div>
</div>
<div className="p-3 rounded-lg bg-muted/50 border border-border/50">
<div className="text-xs text-muted-foreground mb-1">Visibilitas</div>
<div className="flex items-center gap-2">
{method.isVisible ? (
<Eye className="h-4 w-4 text-green-600" />
) : (
<EyeOff className="h-4 w-4 text-muted-foreground" />
)}
<span className="text-sm font-semibold text-foreground">
{method.isVisible ? 'Visible' : 'Hidden'}
</span>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-2">
<button
onClick={() => toggleActive(method)}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium text-sm transition-all ${
method.isActive
? 'bg-green-500/10 text-green-600 hover:bg-green-500/20'
: 'bg-muted text-muted-foreground hover:bg-muted/80'
}`}
>
<span>{method.isActive ? 'Active' : 'Inactive'}</span>
</button>
<button
onClick={() => toggleVisibility(method)}
className="p-2.5 rounded-lg bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground transition-all"
title={method.isVisible ? 'Sembunyikan' : 'Tampilkan'}
>
{method.isVisible ? (
<Eye className="h-4 w-4" />
) : (
<EyeOff className="h-4 w-4" />
)}
</button>
<button
onClick={() => {
setEditingMethod(method)
setShowModal(true)
}}
className="p-2.5 rounded-lg bg-primary/10 text-primary hover:bg-primary hover:text-primary-foreground transition-all"
title="Edit"
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={() => handleDelete(method.id)}
className="p-2.5 rounded-lg bg-destructive/10 text-destructive hover:bg-destructive hover:text-destructive-foreground transition-all"
title="Hapus"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
</div>
)
})}
</div>
{/* Payment Methods Grid with Drag & Drop */}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={methods.map((m) => m.id)} strategy={verticalListSortingStrategy}>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{methods.map((method) => (
<SortableMethodCard
key={method.id}
method={method}
onEdit={handleOpenModal}
onDelete={openDeleteDialog}
onToggleActive={toggleActive}
getTypeIcon={getTypeIcon as (type: string) => React.ComponentType<{ className?: string }>}
getTypeLabel={getTypeLabel}
getTypeColor={getTypeColor}
/>
))}
</div>
</SortableContext>
</DndContext>
{methods.length === 0 && (
<div className="text-center py-12">
<p className="text-muted-foreground">Belum ada metode pembayaran</p>
</div>
)}
{/* Modal */}
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingMethod ? 'Edit Metode Pembayaran' : 'Tambah Metode Pembayaran'}</DialogTitle>
<DialogDescription>
{editingMethod ? 'Ubah informasi metode pembayaran' : 'Tambah metode pembayaran baru'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="displayName">Nama Tampilan</Label>
<Input
id="displayName"
required
value={formData.displayName}
onChange={(e) => setFormData({ ...formData, displayName: e.target.value })}
placeholder="BCA, GoPay, QRIS, dll"
/>
</div>
<div className="space-y-2">
<Label htmlFor="provider">Provider</Label>
<Input
id="provider"
required
value={formData.provider}
onChange={(e) => setFormData({ ...formData, provider: e.target.value })}
placeholder="BCA, Gopay, OVO, dll"
/>
</div>
<div className="space-y-2">
<Label htmlFor="type">Tipe</Label>
<Select value={formData.type} onValueChange={(value) => setFormData({ ...formData, type: value })}>
<SelectTrigger id="type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="bank_transfer">Transfer Bank</SelectItem>
<SelectItem value="ewallet">E-Wallet</SelectItem>
<SelectItem value="qris">QRIS</SelectItem>
<SelectItem value="other">Lainnya</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="accountNumber">Nomor Rekening / Akun</Label>
<Input
id="accountNumber"
value={formData.accountNumber}
onChange={(e) => setFormData({ ...formData, accountNumber: e.target.value })}
placeholder="1234567890"
/>
</div>
<div className="space-y-2">
<Label htmlFor="accountName">Nama Pemilik</Label>
<Input
id="accountName"
value={formData.accountName}
onChange={(e) => setFormData({ ...formData, accountName: e.target.value })}
placeholder="John Doe"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="instructions">Instruksi Pembayaran</Label>
<Textarea
id="instructions"
value={formData.instructions}
onChange={(e) => setFormData({ ...formData, instructions: e.target.value })}
rows={4}
placeholder="Petunjuk cara melakukan pembayaran..."
/>
</div>
<div className="space-y-2">
<Label htmlFor="logoUrl">Logo URL</Label>
<Input
id="logoUrl"
type="url"
value={formData.logoUrl}
onChange={(e) => setFormData({ ...formData, logoUrl: e.target.value })}
placeholder="https://example.com/logo.png"
/>
</div>
<div className="p-4 rounded-lg border border-border bg-muted/30">
<div className="flex items-center gap-3">
<Checkbox
id="isActive"
checked={formData.isActive}
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked as boolean })}
/>
<Label htmlFor="isActive" className="cursor-pointer flex justify-between w-full">
<div className="font-semibold text-foreground">Active</div>
<div className="text-xs text-muted-foreground">Metode pembayaran dapat digunakan</div>
</Label>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setShowModal(false)}>
Batal
</Button>
<Button type="submit">
{editingMethod ? 'Update' : 'Tambah'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={deleteDialog.open} onOpenChange={(open) => setDeleteDialog({ ...deleteDialog, open })}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Hapus Metode Pembayaran?</AlertDialogTitle>
<AlertDialogDescription>
Apakah Anda yakin ingin menghapus metode pembayaran ini? Tindakan ini tidak dapat dibatalkan.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Batal</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Hapus
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -1,12 +1,383 @@
import { useEffect, useState } from 'react'
import axios from 'axios'
import { CheckCircle, XCircle, Clock, Eye, Search, Filter, Check, X } from 'lucide-react'
import { toast } from 'sonner'
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'
interface Payment {
id: string
userId: string
planId: string
amount: number
currency: string
paymentMethodId: string
proofUrl?: string
status: 'pending' | 'verified' | 'rejected'
notes?: string
verifiedAt?: string
verifiedBy?: string
rejectedAt?: string
rejectedBy?: string
createdAt: string
user: {
name: string
email: string
}
plan: {
name: string
}
paymentMethod: {
name: string
type: string
}
}
type FilterStatus = 'all' | 'pending' | 'verified' | 'rejected'
export function AdminPayments() {
const [payments, setPayments] = useState<Payment[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [filter, setFilter] = useState<FilterStatus>('all')
const [selectedPayment, setSelectedPayment] = useState<Payment | null>(null)
useEffect(() => {
fetchPayments()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [search, filter])
const fetchPayments = async () => {
try {
setLoading(true)
const token = localStorage.getItem('token')
const params = new URLSearchParams()
if (search) params.append('search', search)
if (filter !== 'all') params.append('status', filter)
const response = await axios.get(
`${API_URL}/api/admin/payments?${params.toString()}`,
{ headers: { Authorization: `Bearer ${token}` } }
)
setPayments(response.data)
} catch (error) {
console.error('Failed to fetch payments:', error)
} finally {
setLoading(false)
}
}
const handleVerify = async (paymentId: string) => {
const notes = prompt('Catatan verifikasi (opsional):')
if (notes === null) return
try {
const token = localStorage.getItem('token')
await axios.post(
`${API_URL}/api/admin/payments/${paymentId}/verify`,
{ notes },
{ headers: { Authorization: `Bearer ${token}` } }
)
toast.success('Pembayaran berhasil diverifikasi')
fetchPayments()
} catch (error) {
console.error('Failed to verify payment:', error)
toast.error('Gagal memverifikasi pembayaran')
}
}
const handleReject = async (paymentId: string) => {
const reason = prompt('Alasan penolakan:')
if (!reason) return
try {
const token = localStorage.getItem('token')
await axios.post(
`${API_URL}/api/admin/payments/${paymentId}/reject`,
{ reason },
{ headers: { Authorization: `Bearer ${token}` } }
)
toast.success('Pembayaran berhasil ditolak')
fetchPayments()
} catch (error) {
console.error('Failed to reject payment:', error)
toast.error('Gagal menolak pembayaran')
}
}
const formatCurrency = (amount: number, currency: string) => {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: currency,
minimumFractionDigits: 0,
}).format(amount)
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
const getStatusBadge = (status: string) => {
switch (status) {
case 'pending':
return (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-semibold bg-yellow-500/10 text-yellow-600">
<Clock className="h-3 w-3" />
Pending
</span>
)
case 'verified':
return (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-semibold bg-green-500/10 text-green-600">
<CheckCircle className="h-3 w-3" />
Verified
</span>
)
case 'rejected':
return (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-semibold bg-red-500/10 text-red-600">
<XCircle className="h-3 w-3" />
Rejected
</span>
)
default:
return null
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-muted-foreground">Memuat...</div>
</div>
)
}
return (
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-4">
Payment Verification
</h1>
<p className="text-gray-600 dark:text-gray-400">
Verify pending payments (Coming soon)
</p>
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground">Verifikasi Pembayaran</h1>
<p className="mt-2 text-muted-foreground">
Kelola dan verifikasi bukti pembayaran dari pengguna
</p>
</div>
{/* Filters & Search */}
<div className="mb-6 flex flex-col sm:flex-row gap-4">
{/* Search */}
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-muted-foreground" />
<input
type="text"
placeholder="Cari berdasarkan email atau nama..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-input rounded-lg bg-background text-foreground focus:ring-2 focus:ring-ring focus:border-transparent"
/>
</div>
{/* Status Filter */}
<div className="flex items-center gap-2">
<Filter className="h-5 w-5 text-muted-foreground" />
<select
value={filter}
onChange={(e) => setFilter(e.target.value as FilterStatus)}
className="px-4 py-2 border border-input rounded-lg bg-background text-foreground focus:ring-2 focus:ring-ring focus:border-transparent"
>
<option value="all">Semua Status</option>
<option value="pending">Pending</option>
<option value="verified">Verified</option>
<option value="rejected">Rejected</option>
</select>
</div>
</div>
{/* Payments Table */}
<div className="bg-card rounded-xl border border-border overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-border">
<thead className="bg-muted">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Plan
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Jumlah
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Metode
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Tanggal
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-card divide-y divide-border">
{payments.map((payment) => (
<tr key={payment.id} className="hover:bg-muted/50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10 rounded-full bg-primary flex items-center justify-center text-primary-foreground font-semibold">
{payment.user.name?.[0] || payment.user.email[0].toUpperCase()}
</div>
<div className="ml-4">
<div className="text-sm font-medium text-foreground">
{payment.user.name || 'No name'}
</div>
<div className="text-sm text-muted-foreground">
{payment.user.email}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-foreground">
{payment.plan.name}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-bold text-foreground">
{formatCurrency(payment.amount, payment.currency)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-muted-foreground">
{payment.paymentMethod.name}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getStatusBadge(payment.status)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
{formatDate(payment.createdAt)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex items-center gap-2">
{payment.proofUrl && (
<button
onClick={() => setSelectedPayment(payment)}
className="p-2 rounded-lg text-primary hover:bg-primary/10 transition-colors"
title="Lihat Bukti"
>
<Eye className="h-4 w-4" />
</button>
)}
{payment.status === 'pending' && (
<>
<button
onClick={() => handleVerify(payment.id)}
className="p-2 rounded-lg text-green-600 hover:bg-green-500/10 transition-colors"
title="Verifikasi"
>
<Check className="h-4 w-4" />
</button>
<button
onClick={() => handleReject(payment.id)}
className="p-2 rounded-lg text-destructive hover:bg-destructive/10 transition-colors"
title="Tolak"
>
<X className="h-4 w-4" />
</button>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{payments.length === 0 && (
<div className="text-center py-12">
<p className="text-muted-foreground">Tidak ada pembayaran</p>
</div>
)}
{/* Proof Modal */}
{selectedPayment && (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
onClick={() => setSelectedPayment(null)}
>
<div
className="bg-card rounded-xl max-w-2xl w-full max-h-[90vh] overflow-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="p-6 border-b border-border">
<h2 className="text-xl font-bold text-foreground">Bukti Pembayaran</h2>
<p className="text-sm text-muted-foreground mt-1">
{selectedPayment.user.name} - {selectedPayment.plan.name}
</p>
</div>
<div className="p-6">
{selectedPayment.proofUrl ? (
<img
src={selectedPayment.proofUrl}
alt="Bukti Pembayaran"
className="w-full rounded-lg"
/>
) : (
<p className="text-muted-foreground">Tidak ada bukti pembayaran</p>
)}
{selectedPayment.notes && (
<div className="mt-4 p-4 bg-muted rounded-lg">
<p className="text-sm font-medium text-foreground">Catatan:</p>
<p className="text-sm text-muted-foreground mt-1">
{selectedPayment.notes}
</p>
</div>
)}
</div>
<div className="p-6 border-t border-border flex justify-end gap-2">
<button
onClick={() => setSelectedPayment(null)}
className="px-4 py-2 rounded-lg bg-muted text-foreground hover:bg-muted/80 transition-colors"
>
Tutup
</button>
{selectedPayment.status === 'pending' && (
<>
<button
onClick={() => {
handleVerify(selectedPayment.id)
setSelectedPayment(null)
}}
className="px-4 py-2 rounded-lg bg-green-500 text-white hover:bg-green-600 transition-colors"
>
Verifikasi
</button>
<button
onClick={() => {
handleReject(selectedPayment.id)
setSelectedPayment(null)
}}
className="px-4 py-2 rounded-lg bg-destructive text-destructive-foreground hover:bg-destructive/90 transition-colors"
>
Tolak
</button>
</>
)}
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,6 +1,54 @@
import { useEffect, useState } from 'react'
import axios from 'axios'
import { Plus, Edit, Trash2, Eye, EyeOff, GripVertical } from 'lucide-react'
import { toast } from 'sonner'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core'
import type { DragEndEvent } from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'
@@ -14,7 +62,7 @@ interface Plan {
durationType: string
durationDays: number | null
trialDays: number
features: any
features: string[] | Record<string, unknown>
badge: string | null
badgeColor: string | null
sortOrder: number
@@ -26,11 +74,212 @@ interface Plan {
}
}
interface PlanFormData {
name: string
slug: string
description: string
price: string
currency: string
durationType: string
durationDays: number | null
trialDays: number
features: string[]
badge: string
badgeColor: string
isActive: boolean
isVisible: boolean
}
function SortablePlanCard({ plan, onEdit, onDelete, onToggleVisibility, formatPrice }: {
plan: Plan
onEdit: (plan: Plan) => void
onDelete: (id: string) => void
onToggleVisibility: (plan: Plan) => void
formatPrice: (price: string, currency: string) => string
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: plan.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}
return (
<div
ref={setNodeRef}
style={style}
className={`group relative bg-gradient-to-br from-card to-card/50 rounded-2xl border-2 overflow-hidden transition-all duration-300 hover:shadow-2xl hover:-translate-y-1 ${
!plan.isActive
? 'border-red-500/50 opacity-60'
: 'border-border/50 hover:border-primary/30'
}`}
>
{/* Content */}
<div className="p-6">
{/* Header: Drag Icon + Title + Badge */}
<div className="flex items-start gap-3 mb-6">
{/* Drag Handle */}
<div
{...attributes}
{...listeners}
className="opacity-0 group-hover:opacity-100 transition-opacity pt-1 cursor-move"
>
<GripVertical className="h-5 w-5 mt-1 text-muted-foreground/50" />
</div>
{/* Title & Description */}
<div className="flex-1 min-w-0">
{/* Plan Name + Badge */}
<div className="flex items-center gap-2 mb-2 flex-wrap">
<h3 className="text-2xl font-bold text-foreground">{plan.name}</h3>
{plan.badge && (
<span
className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold shadow-sm ${
plan.badgeColor === 'blue'
? 'bg-gradient-to-r from-blue-500 to-blue-600 text-white'
: plan.badgeColor === 'green'
? 'bg-gradient-to-r from-green-500 to-green-600 text-white'
: plan.badgeColor === 'red'
? 'bg-gradient-to-r from-red-500 to-red-600 text-white'
: plan.badgeColor === 'orange'
? 'bg-gradient-to-r from-orange-500 to-orange-600 text-white'
: 'bg-muted text-muted-foreground'
}`}
>
{plan.badge}
</span>
)}
</div>
{/* Description */}
<p className="text-sm text-muted-foreground line-clamp-2">
{plan.description}
</p>
</div>
</div>
{/* Price - Hero Section */}
<div className="mb-6 p-6 rounded-xl bg-gradient-to-br from-primary/5 to-primary/10 border border-primary/20">
<div className="flex items-baseline gap-2 mb-1">
<span className="text-4xl font-black text-foreground tracking-tight">
{formatPrice(plan.price, plan.currency)}
</span>
</div>
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-medium text-muted-foreground">
{plan.durationType === 'lifetime'
? 'Akses Selamanya'
: plan.durationType === 'monthly'
? 'per bulan'
: plan.durationType === 'yearly'
? 'per tahun'
: plan.durationType}
</p>
{plan.trialDays > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-primary/20 text-primary text-xs font-semibold">
🎁 {plan.trialDays} hari gratis
</span>
)}
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-4 mb-6">
<div className="p-3 rounded-lg bg-muted/50 border border-border/50">
<div className="text-xs text-muted-foreground mb-1">Subscribers</div>
<div className="text-2xl font-bold text-foreground">
{plan._count.subscriptions}
</div>
</div>
<div className="p-3 rounded-lg bg-muted/50 border border-border/50">
<div className="text-xs text-muted-foreground mb-1">Status</div>
<div className="flex items-center gap-2">
{plan.isActive ? (
<>
<div className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
<span className="text-sm font-semibold text-green-600">Active</span>
</>
) : (
<>
<div className="h-2 w-2 rounded-full bg-gray-400" />
<span className="text-sm font-semibold text-muted-foreground">
Inactive
</span>
</>
)}
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-2">
<button
onClick={() => onToggleVisibility(plan)}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium text-sm transition-all ${
plan.isVisible
? 'bg-primary/10 text-primary hover:bg-primary/20'
: 'bg-muted text-muted-foreground hover:bg-muted/80'
}`}
>
{plan.isVisible ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
<span>{plan.isVisible ? 'Visible' : 'Hidden'}</span>
</button>
<button
onClick={() => onEdit(plan)}
className="p-2.5 rounded-lg bg-primary/10 text-primary hover:bg-primary hover:text-primary-foreground transition-all"
title="Edit"
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={() => onDelete(plan.id)}
className="p-2.5 rounded-lg bg-destructive/10 text-destructive hover:bg-destructive hover:text-destructive-foreground transition-all"
title="Hapus"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
</div>
)
}
export function AdminPlans() {
const [plans, setPlans] = useState<Plan[]>([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editingPlan, setEditingPlan] = useState<Plan | null>(null)
const [deleteDialog, setDeleteDialog] = useState<{ open: boolean; planId: string }>({ open: false, planId: '' })
const [formData, setFormData] = useState<PlanFormData>({
name: '',
slug: '',
description: '',
price: '0',
currency: 'IDR',
durationType: 'monthly',
durationDays: null,
trialDays: 0,
features: [],
badge: '',
badgeColor: '',
isActive: true,
isVisible: true,
})
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
useEffect(() => {
fetchPlans()
@@ -50,18 +299,56 @@ export function AdminPlans() {
}
}
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this plan?')) return
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event
if (over && active.id !== over.id) {
const oldIndex = plans.findIndex((p) => p.id === active.id)
const newIndex = plans.findIndex((p) => p.id === over.id)
const newPlans = arrayMove(plans, oldIndex, newIndex)
setPlans(newPlans)
// Save new order to backend
try {
const token = localStorage.getItem('token')
await axios.post(
`${API_URL}/api/admin/plans/reorder`,
{ planIds: newPlans.map((p) => p.id) },
{ headers: { Authorization: `Bearer ${token}` } }
)
toast.success('Urutan plan berhasil diubah')
} catch (error) {
console.error('Failed to reorder plans:', error)
toast.error('Gagal mengubah urutan plan')
fetchPlans() // Revert on error
}
}
}
const openDeleteDialog = (id: string) => {
setDeleteDialog({ open: true, planId: id })
}
const handleDelete = async () => {
try {
const token = localStorage.getItem('token')
await axios.delete(`${API_URL}/api/admin/plans/${id}`, {
const response = await axios.delete(`${API_URL}/api/admin/plans/${deleteDialog.planId}`, {
headers: { Authorization: `Bearer ${token}` },
})
// Show appropriate message based on response
if (response.data.action === 'deactivated') {
toast.warning(response.data.message)
} else {
toast.success('Plan berhasil dihapus')
}
fetchPlans()
setDeleteDialog({ open: false, planId: '' })
} catch (error) {
console.error('Failed to delete plan:', error)
alert('Failed to delete plan')
toast.error('Gagal menghapus plan')
}
}
@@ -73,9 +360,76 @@ export function AdminPlans() {
{ isVisible: !plan.isVisible },
{ headers: { Authorization: `Bearer ${token}` } }
)
toast.success(plan.isVisible ? 'Plan berhasil disembunyikan' : 'Plan berhasil ditampilkan')
fetchPlans()
} catch (error) {
console.error('Failed to update plan:', error)
toast.error('Gagal mengubah visibilitas plan')
}
}
const handleOpenModal = (plan?: Plan) => {
if (plan) {
setEditingPlan(plan)
setFormData({
name: plan.name,
slug: plan.slug,
description: plan.description,
price: plan.price,
currency: plan.currency,
durationType: plan.durationType,
durationDays: plan.durationDays,
trialDays: plan.trialDays,
features: Array.isArray(plan.features) ? plan.features : [],
badge: plan.badge || '',
badgeColor: plan.badgeColor || '',
isActive: plan.isActive,
isVisible: plan.isVisible,
})
} else {
setEditingPlan(null)
setFormData({
name: '',
slug: '',
description: '',
price: '0',
currency: 'IDR',
durationType: 'monthly',
durationDays: null,
trialDays: 0,
features: [],
badge: '',
badgeColor: '',
isActive: true,
isVisible: true,
})
}
setShowModal(true)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
const token = localStorage.getItem('token')
if (editingPlan) {
await axios.put(
`${API_URL}/api/admin/plans/${editingPlan.id}`,
formData,
{ headers: { Authorization: `Bearer ${token}` } }
)
} else {
await axios.post(`${API_URL}/api/admin/plans`, formData, {
headers: { Authorization: `Bearer ${token}` },
})
}
toast.success(editingPlan ? 'Plan berhasil diupdate' : 'Plan berhasil ditambahkan')
fetchPlans()
setShowModal(false)
setEditingPlan(null)
} catch (error) {
console.error('Failed to save plan:', error)
toast.error('Gagal menyimpan plan')
}
}
@@ -101,171 +455,222 @@ export function AdminPlans() {
<div>
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold text-foreground">
Kelola Plans
</h1>
<p className="mt-2 text-muted-foreground">
Kelola paket berlangganan
</p>
<h1 className="text-3xl font-bold text-foreground">Kelola Plans</h1>
<p className="mt-2 text-muted-foreground">Kelola paket berlangganan</p>
</div>
<button
onClick={() => {
setEditingPlan(null)
setShowModal(true)
}}
className="flex items-center px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
>
<Button onClick={() => handleOpenModal()}>
<Plus className="h-5 w-5 mr-2" />
Tambah Plan
</button>
</Button>
</div>
{/* Plans Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{plans.map((plan) => (
<div
key={plan.id}
className="group relative bg-gradient-to-br from-card to-card/50 rounded-2xl border-2 border-border/50 hover:border-primary/30 overflow-hidden transition-all duration-300 hover:shadow-2xl hover:-translate-y-1"
>
{/* Content */}
<div className="p-6">
{/* Header: Drag Icon + Title/Description + Badge */}
<div className="flex items-start gap-3 mb-6">
{/* Drag Handle */}
<div className="opacity-0 group-hover:opacity-100 transition-opacity pt-1">
<GripVertical className="h-5 w-5 mt-1 text-muted-foreground/50 cursor-move" />
</div>
{/* Title & Description */}
<div className="flex-1 min-w-0">
{/* Plan Name + Badge */}
<div className="flex items-center gap-2 mb-2 flex-wrap">
<h3 className="text-2xl font-bold text-foreground">
{plan.name}
</h3>
{plan.badge && (
<span
className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold shadow-sm ${
plan.badgeColor === 'blue'
? 'bg-gradient-to-r from-blue-500 to-blue-600 text-white'
: plan.badgeColor === 'green'
? 'bg-gradient-to-r from-green-500 to-green-600 text-white'
: 'bg-muted text-muted-foreground'
}`}
>
{plan.badge}
</span>
)}
</div>
{/* Description */}
<p className="text-sm text-muted-foreground line-clamp-2">
{plan.description}
</p>
</div>
</div>
{/* Plans Grid with Drag & Drop */}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={plans.map((p) => p.id)} strategy={verticalListSortingStrategy}>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{plans.map((plan) => (
<SortablePlanCard
key={plan.id}
plan={plan}
onEdit={handleOpenModal}
onDelete={openDeleteDialog}
onToggleVisibility={toggleVisibility}
formatPrice={formatPrice}
/>
))}
</div>
</SortableContext>
</DndContext>
{/* Price - Hero Section */}
<div className="mb-6 p-6 rounded-xl bg-gradient-to-br from-primary/5 to-primary/10 border border-primary/20">
<div className="flex items-baseline gap-2 mb-1">
<span className="text-4xl font-black text-foreground tracking-tight">
{formatPrice(plan.price, plan.currency)}
</span>
</div>
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-medium text-muted-foreground">
{plan.durationType === 'lifetime'
? 'Akses Selamanya'
: plan.durationType === 'monthly'
? 'per bulan'
: plan.durationType === 'yearly'
? 'per tahun'
: plan.durationType}
</p>
{plan.trialDays > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-primary/20 text-primary text-xs font-semibold">
🎁 {plan.trialDays} hari gratis
</span>
)}
</div>
</div>
{/* Modal */}
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingPlan ? 'Edit Plan' : 'Tambah Plan Baru'}</DialogTitle>
<DialogDescription>
{editingPlan ? 'Ubah informasi plan berlangganan' : 'Buat plan berlangganan baru'}
</DialogDescription>
</DialogHeader>
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-4 mb-6">
<div className="p-3 rounded-lg bg-muted/50 border border-border/50">
<div className="text-xs text-muted-foreground mb-1">Subscribers</div>
<div className="text-2xl font-bold text-foreground">
{plan._count.subscriptions}
</div>
</div>
<div className="p-3 rounded-lg bg-muted/50 border border-border/50">
<div className="text-xs text-muted-foreground mb-1">Status</div>
<div className="flex items-center gap-2">
{plan.isActive ? (
<>
<div className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
<span className="text-sm font-semibold text-green-600">Active</span>
</>
) : (
<>
<div className="h-2 w-2 rounded-full bg-gray-400" />
<span className="text-sm font-semibold text-muted-foreground">Inactive</span>
</>
)}
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name">Nama Plan</Label>
<Input
id="name"
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-2">
<button
onClick={() => toggleVisibility(plan)}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium text-sm transition-all ${
plan.isVisible
? 'bg-primary/10 text-primary hover:bg-primary/20'
: 'bg-muted text-muted-foreground hover:bg-muted/80'
}`}
>
{plan.isVisible ? (
<>
<Eye className="h-4 w-4" />
<span>Visible</span>
</>
) : (
<>
<EyeOff className="h-4 w-4" />
<span>Hidden</span>
</>
)}
</button>
<button
onClick={() => {
setEditingPlan(plan)
setShowModal(true)
}}
className="p-2.5 rounded-lg bg-primary/10 text-primary hover:bg-primary hover:text-primary-foreground transition-all"
title="Edit Plan"
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={() => handleDelete(plan.id)}
className="p-2.5 rounded-lg bg-destructive/10 text-destructive hover:bg-destructive hover:text-destructive-foreground transition-all"
title="Hapus Plan"
>
<Trash2 className="h-4 w-4" />
</button>
<div className="space-y-2">
<Label htmlFor="slug">Slug</Label>
<Input
id="slug"
required
value={formData.slug}
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
/>
</div>
</div>
</div>
))}
</div>
{plans.length === 0 && (
<div className="text-center py-12">
<p className="text-muted-foreground">Tidak ada plan</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="description">Deskripsi</Label>
<Textarea
id="description"
required
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="price">Harga</Label>
<Input
id="price"
type="number"
required
value={formData.price}
onChange={(e) => setFormData({ ...formData, price: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="currency">Currency</Label>
<Select value={formData.currency} onValueChange={(value) => setFormData({ ...formData, currency: value })}>
<SelectTrigger id="currency">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="IDR">IDR</SelectItem>
<SelectItem value="USD">USD</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="durationType">Tipe Durasi</Label>
<Select value={formData.durationType} onValueChange={(value) => setFormData({ ...formData, durationType: value })}>
<SelectTrigger id="durationType">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Monthly</SelectItem>
<SelectItem value="yearly">Yearly</SelectItem>
<SelectItem value="lifetime">Lifetime</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="trialDays">Trial Days</Label>
<Input
id="trialDays"
type="number"
value={formData.trialDays}
onChange={(e) => setFormData({ ...formData, trialDays: parseInt(e.target.value) || 0 })}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="features">Features (satu per baris)</Label>
<Textarea
id="features"
value={formData.features.join('\n')}
onChange={(e) => setFormData({ ...formData, features: e.target.value.split('\n') })}
rows={5}
placeholder="Unlimited wallets&#10;Advanced analytics&#10;Priority support"
className="font-mono text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="badge">Badge</Label>
<Input
id="badge"
value={formData.badge}
onChange={(e) => setFormData({ ...formData, badge: e.target.value })}
placeholder="Popular, Best Value, etc"
/>
</div>
<div className="space-y-2">
<Label htmlFor="badgeColor">Badge Color</Label>
<Select value={formData.badgeColor || undefined} onValueChange={(value) => setFormData({ ...formData, badgeColor: value })}>
<SelectTrigger id="badgeColor">
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent>
<SelectItem value="blue">Blue</SelectItem>
<SelectItem value="green">Green</SelectItem>
<SelectItem value="red">Red</SelectItem>
<SelectItem value="orange">Orange</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="p-4 rounded-lg border border-border bg-muted/30">
<div className="flex items-center gap-3">
<Checkbox
id="isActive"
checked={formData.isActive}
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked as boolean })}
/>
<Label htmlFor="isActive" className="cursor-pointer flex justify-between w-full">
<div className="font-semibold text-foreground">Active</div>
<div className="text-xs text-muted-foreground">Plan dapat digunakan</div>
</Label>
</div>
</div>
<div className="p-4 rounded-lg border border-border bg-muted/30">
<div className="flex items-center gap-3">
<Checkbox
id="isVisible"
checked={formData.isVisible}
onCheckedChange={(checked) => setFormData({ ...formData, isVisible: checked as boolean })}
/>
<Label htmlFor="isVisible" className="cursor-pointer flex justify-between w-full">
<div className="font-semibold text-foreground">Visible</div>
<div className="text-xs text-muted-foreground">Tampil di halaman pricing</div>
</Label>
</div>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setShowModal(false)}>
Batal
</Button>
<Button type="submit">
{editingPlan ? 'Update' : 'Tambah'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={deleteDialog.open} onOpenChange={(open) => setDeleteDialog({ ...deleteDialog, open })}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Hapus Plan?</AlertDialogTitle>
<AlertDialogDescription>
Apakah Anda yakin ingin menghapus plan ini? Tindakan ini tidak dapat dibatalkan.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Batal</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Hapus
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -1,12 +1,295 @@
import { useState, useEffect } from 'react'
import axios from 'axios'
import { Shield, Database, Save, Globe } from 'lucide-react'
import { toast } from 'sonner'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import { Button } from '@/components/ui/button'
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'
interface Settings {
appName: string
appUrl: string
supportEmail: string
enableRegistration: boolean
enableEmailVerification: boolean
enablePaymentVerification: boolean
maintenanceMode: boolean
maintenanceMessage: string
}
export function AdminSettings() {
const [settings, setSettings] = useState<Settings>({
appName: 'Tabungin',
appUrl: 'https://tabungin.app',
supportEmail: 'support@tabungin.app',
enableRegistration: true,
enableEmailVerification: true,
enablePaymentVerification: true,
maintenanceMode: false,
maintenanceMessage: 'Sistem sedang dalam pemeliharaan. Mohon coba lagi nanti.',
})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
useEffect(() => {
fetchSettings()
}, [])
const fetchSettings = async () => {
try {
setLoading(true)
const token = localStorage.getItem('token')
const response = await axios.get(`${API_URL}/api/admin/config/by-category`, {
headers: { Authorization: `Bearer ${token}` },
})
// Convert config array to settings object
interface ConfigItem {
key: string
value: string
}
interface ConfigData {
general?: ConfigItem[]
features?: ConfigItem[]
system?: ConfigItem[]
}
const configData = response.data as ConfigData
const settingsObj: Settings = {
appName: configData.general?.find((c) => c.key === 'app_name')?.value || 'Tabungin',
appUrl: configData.general?.find((c) => c.key === 'app_url')?.value || 'https://tabungin.app',
supportEmail: configData.general?.find((c) => c.key === 'support_email')?.value || 'support@tabungin.app',
enableRegistration: configData.features?.find((c) => c.key === 'enable_registration')?.value === 'true',
enableEmailVerification: configData.features?.find((c) => c.key === 'enable_email_verification')?.value === 'true',
enablePaymentVerification: configData.features?.find((c) => c.key === 'enable_payment_verification')?.value === 'true',
maintenanceMode: configData.system?.find((c) => c.key === 'maintenance_mode')?.value === 'true',
maintenanceMessage: configData.system?.find((c) => c.key === 'maintenance_message')?.value || 'Sistem sedang dalam pemeliharaan. Mohon coba lagi nanti.',
}
setSettings(settingsObj)
} catch (error) {
console.error('Failed to fetch settings:', error)
} finally {
setLoading(false)
}
}
const handleSave = async () => {
try {
setSaving(true)
const token = localStorage.getItem('token')
// Save each setting individually
const configUpdates = [
{ key: 'app_name', value: settings.appName, category: 'general', label: 'Nama Aplikasi', type: 'text' },
{ key: 'app_url', value: settings.appUrl, category: 'general', label: 'URL Aplikasi', type: 'text' },
{ key: 'support_email', value: settings.supportEmail, category: 'general', label: 'Email Support', type: 'email' },
{ key: 'enable_registration', value: String(settings.enableRegistration), category: 'features', label: 'Registrasi Pengguna Baru', type: 'boolean' },
{ key: 'enable_email_verification', value: String(settings.enableEmailVerification), category: 'features', label: 'Verifikasi Email', type: 'boolean' },
{ key: 'enable_payment_verification', value: String(settings.enablePaymentVerification), category: 'features', label: 'Verifikasi Pembayaran', type: 'boolean' },
{ key: 'maintenance_mode', value: String(settings.maintenanceMode), category: 'system', label: 'Mode Pemeliharaan', type: 'boolean' },
{ key: 'maintenance_message', value: settings.maintenanceMessage, category: 'system', label: 'Pesan Pemeliharaan', type: 'text' },
]
await Promise.all(
configUpdates.map((config) =>
axios.post(`${API_URL}/api/admin/config/${config.key}`, config, {
headers: { Authorization: `Bearer ${token}` },
})
)
)
toast.success('Pengaturan berhasil disimpan')
fetchSettings() // Refresh
} catch (error) {
console.error('Failed to save settings:', error)
toast.error('Gagal menyimpan pengaturan')
} finally {
setSaving(false)
}
}
const handleChange = (field: keyof Settings, value: string | boolean) => {
setSettings((prev) => ({ ...prev, [field]: value }))
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-muted-foreground">Memuat...</div>
</div>
)
}
return (
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-4">
App Settings
</h1>
<p className="text-gray-600 dark:text-gray-400">
Manage app configuration (Coming soon)
</p>
<div className="max-w-4xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground">Pengaturan Aplikasi</h1>
<p className="mt-2 text-muted-foreground">
Kelola konfigurasi dan pengaturan sistem
</p>
</div>
<div className="space-y-6">
{/* General Settings */}
<div className="bg-card rounded-xl border border-border p-6">
<div className="flex items-center gap-3 mb-6">
<div className="p-2 rounded-lg bg-primary/10">
<Globe className="h-5 w-5 text-primary" />
</div>
<div>
<h2 className="text-lg font-semibold text-foreground">Pengaturan Umum</h2>
<p className="text-sm text-muted-foreground">Informasi dasar aplikasi</p>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-2">
Nama Aplikasi
</label>
<Input
type="text"
value={settings.appName}
onChange={(e) => handleChange('appName', e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-2">
URL Aplikasi
</label>
<Input
type="url"
value={settings.appUrl}
onChange={(e) => handleChange('appUrl', e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-2">
Email Support
</label>
<Input
type="email"
value={settings.supportEmail}
onChange={(e) => handleChange('supportEmail', e.target.value)}
/>
</div>
</div>
</div>
{/* Feature Toggles */}
<div className="bg-card rounded-xl border border-border p-6">
<div className="flex items-center gap-3 mb-6">
<div className="p-2 rounded-lg bg-primary/10">
<Shield className="h-5 w-5 text-primary" />
</div>
<div>
<h2 className="text-lg font-semibold text-foreground">Fitur & Keamanan</h2>
<p className="text-sm text-muted-foreground">Aktifkan atau nonaktifkan fitur</p>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50">
<div>
<p className="font-medium text-foreground">Registrasi Pengguna Baru</p>
<p className="text-sm text-muted-foreground">
Izinkan pengguna baru mendaftar
</p>
</div>
<Switch
checked={settings.enableRegistration}
onCheckedChange={(checked) => handleChange('enableRegistration', checked)}
/>
</div>
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50">
<div>
<p className="font-medium text-foreground">Verifikasi Email</p>
<p className="text-sm text-muted-foreground">
Wajibkan verifikasi email untuk pengguna baru
</p>
</div>
<Switch
checked={settings.enableEmailVerification}
onCheckedChange={(checked) => handleChange('enableEmailVerification', checked)}
/>
</div>
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50">
<div>
<p className="font-medium text-foreground">Verifikasi Pembayaran Manual</p>
<p className="text-sm text-muted-foreground">
Aktifkan verifikasi manual untuk pembayaran
</p>
</div>
<Switch
checked={settings.enablePaymentVerification}
onCheckedChange={(checked) => handleChange('enablePaymentVerification', checked)}
/>
</div>
</div>
</div>
{/* Maintenance Mode */}
<div className="bg-card rounded-xl border border-border p-6">
<div className="flex items-center gap-3 mb-6">
<div className="p-2 rounded-lg bg-primary/10">
<Database className="h-5 w-5 text-primary" />
</div>
<div>
<h2 className="text-lg font-semibold text-foreground">Mode Pemeliharaan</h2>
<p className="text-sm text-muted-foreground">
Nonaktifkan akses sementara untuk maintenance
</p>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between p-4 rounded-lg bg-destructive/10 border border-destructive/20">
<div>
<p className="font-medium text-foreground">Mode Pemeliharaan</p>
<p className="text-sm text-muted-foreground">
Aktifkan untuk menutup akses sementara
</p>
</div>
<Switch
checked={settings.maintenanceMode}
onCheckedChange={(checked) => handleChange('maintenanceMode', checked)}
className="data-[state=checked]:bg-destructive"
/>
</div>
{settings.maintenanceMode && (
<div>
<label className="block text-sm font-medium text-foreground mb-2">
Pesan Pemeliharaan
</label>
<Textarea
value={settings.maintenanceMessage}
onChange={(e) => handleChange('maintenanceMessage', e.target.value)}
rows={3}
/>
</div>
)}
</div>
</div>
{/* Save Button */}
<div className="flex justify-end">
<Button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2"
>
<Save className="h-5 w-5" />
{saving ? 'Menyimpan...' : 'Simpan Pengaturan'}
</Button>
</div>
</div>
</div>
)
}

View File

@@ -1,6 +1,18 @@
import { useEffect, useState } from 'react'
import axios from 'axios'
import { Search, UserX, UserCheck, Crown } from 'lucide-react'
import { toast } from 'sonner'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'
@@ -23,9 +35,14 @@ export function AdminUsers() {
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [suspendDialog, setSuspendDialog] = useState<{ open: boolean; userId: string; suspend: boolean }>({ open: false, userId: '', suspend: false })
const [suspendReason, setSuspendReason] = useState('')
const [grantProDialog, setGrantProDialog] = useState<{ open: boolean; userId: string }>({ open: false, userId: '' })
const [proDays, setProDays] = useState('')
useEffect(() => {
fetchUsers()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [search])
const fetchUsers = async () => {
@@ -43,47 +60,55 @@ export function AdminUsers() {
}
}
const handleSuspend = async (userId: string, suspend: boolean) => {
const reason = suspend
? prompt('Enter suspension reason:')
: null
const openSuspendDialog = (userId: string, suspend: boolean) => {
setSuspendDialog({ open: true, userId, suspend })
setSuspendReason('')
}
if (suspend && !reason) return
const handleSuspend = async () => {
if (suspendDialog.suspend && !suspendReason) return
try {
const token = localStorage.getItem('token')
const endpoint = suspend ? 'suspend' : 'unsuspend'
const endpoint = suspendDialog.suspend ? 'suspend' : 'unsuspend'
await axios.post(
`${API_URL}/api/admin/users/${userId}/${endpoint}`,
suspend ? { reason } : {},
`${API_URL}/api/admin/users/${suspendDialog.userId}/${endpoint}`,
suspendDialog.suspend ? { reason: suspendReason } : {},
{ headers: { Authorization: `Bearer ${token}` } }
)
toast.success(suspendDialog.suspend ? 'User berhasil disuspend' : 'User berhasil diaktifkan kembali')
fetchUsers()
setSuspendDialog({ open: false, userId: '', suspend: false })
} catch (error) {
console.error('Failed to update user:', error)
alert('Failed to update user')
toast.error('Gagal mengupdate user')
}
}
const handleGrantPro = async (userId: string) => {
const days = prompt('Enter duration in days (e.g., 30 for monthly):')
if (!days || isNaN(parseInt(days))) return
const openGrantProDialog = (userId: string) => {
setGrantProDialog({ open: true, userId })
setProDays('')
}
const handleGrantPro = async () => {
if (!proDays || isNaN(parseInt(proDays))) return
try {
const token = localStorage.getItem('token')
await axios.post(
`${API_URL}/api/admin/users/${userId}/grant-pro`,
`${API_URL}/api/admin/users/${grantProDialog.userId}/grant-pro`,
{
planSlug: 'pro-monthly',
durationDays: parseInt(days),
durationDays: parseInt(proDays),
},
{ headers: { Authorization: `Bearer ${token}` } }
)
alert('Pro access granted successfully!')
toast.success('Akses Pro berhasil diberikan!')
fetchUsers()
setGrantProDialog({ open: false, userId: '' })
} catch (error) {
console.error('Failed to grant pro access:', error)
alert('Failed to grant pro access')
toast.error('Gagal memberikan akses Pro')
}
}
@@ -110,12 +135,12 @@ export function AdminUsers() {
<div className="mb-6">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-muted-foreground" />
<input
<Input
type="text"
placeholder="Search by email or name..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-input rounded-lg bg-background text-foreground focus:ring-2 focus:ring-ring focus:border-transparent"
className="pl-10"
/>
</div>
</div>
@@ -194,7 +219,7 @@ export function AdminUsers() {
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
{user.suspendedAt ? (
<button
onClick={() => handleSuspend(user.id, false)}
onClick={() => openSuspendDialog(user.id, false)}
className="text-primary hover:text-primary/80"
title="Unsuspend"
>
@@ -202,7 +227,7 @@ export function AdminUsers() {
</button>
) : (
<button
onClick={() => handleSuspend(user.id, true)}
onClick={() => openSuspendDialog(user.id, true)}
className="text-destructive hover:text-destructive/80"
title="Suspend"
>
@@ -210,7 +235,7 @@ export function AdminUsers() {
</button>
)}
<button
onClick={() => handleGrantPro(user.id)}
onClick={() => openGrantProDialog(user.id)}
className="text-primary hover:text-primary/80"
title="Grant Pro Access"
>
@@ -229,6 +254,69 @@ export function AdminUsers() {
<p className="text-muted-foreground">Tidak ada user</p>
</div>
)}
{/* Suspend/Unsuspend Dialog */}
<Dialog open={suspendDialog.open} onOpenChange={(open) => setSuspendDialog({ ...suspendDialog, open })}>
<DialogContent>
<DialogHeader>
<DialogTitle>{suspendDialog.suspend ? 'Suspend User' : 'Unsuspend User'}</DialogTitle>
<DialogDescription>
{suspendDialog.suspend
? 'Enter a reason for suspending this user.'
: 'Are you sure you want to unsuspend this user?'}
</DialogDescription>
</DialogHeader>
{suspendDialog.suspend && (
<div className="space-y-2">
<Label htmlFor="reason">Suspension Reason</Label>
<Input
id="reason"
value={suspendReason}
onChange={(e) => setSuspendReason(e.target.value)}
placeholder="Enter reason..."
/>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setSuspendDialog({ open: false, userId: '', suspend: false })}>
Cancel
</Button>
<Button onClick={handleSuspend} variant={suspendDialog.suspend ? 'destructive' : 'default'}>
{suspendDialog.suspend ? 'Suspend' : 'Unsuspend'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Grant Pro Dialog */}
<Dialog open={grantProDialog.open} onOpenChange={(open) => setGrantProDialog({ ...grantProDialog, open })}>
<DialogContent>
<DialogHeader>
<DialogTitle>Grant Pro Access</DialogTitle>
<DialogDescription>
Enter the duration in days for the pro subscription.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="days">Duration (days)</Label>
<Input
id="days"
type="number"
value={proDays}
onChange={(e) => setProDays(e.target.value)}
placeholder="30"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setGrantProDialog({ open: false, userId: '' })}>
Cancel
</Button>
<Button onClick={handleGrantPro}>
Grant Access
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from "react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import {
Dialog,
@@ -167,18 +168,17 @@ export function TransactionDialog({ open, onOpenChange, transaction, onSuccess }
if (isEditing) {
await axios.put(`${API}/wallets/${walletId}/transactions/${transaction.id}`, data)
toast.success('Transaksi berhasil diupdate')
} else {
await axios.post(`${API}/wallets/${walletId}/transactions`, data)
toast.success('Transaksi berhasil ditambahkan')
}
onSuccess()
onOpenChange(false)
// Reset form
resetForm()
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to save transaction'
setError(message)
} catch (error) {
console.error("Failed to save transaction:", error)
toast.error('Gagal menyimpan transaksi')
} finally {
setLoading(false)
}

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from "react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import {
Dialog,
@@ -90,23 +91,17 @@ export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDi
if (isEditing) {
await axios.put(`${API}/wallets/${wallet.id}`, data)
toast.success('Wallet berhasil diupdate')
} else {
await axios.post(`${API}/wallets`, data)
toast.success('Wallet berhasil ditambahkan')
}
onSuccess()
onOpenChange(false)
// Reset form
setName("")
setKind("money")
setCurrency("IDR")
setUnit("")
setInitialAmount("")
setPricePerUnit("")
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to save wallet'
setError(message)
} catch (error) {
console.error("Failed to save wallet:", error)
toast.error('Gagal menyimpan wallet')
} finally {
setLoading(false)
}

View File

@@ -10,6 +10,7 @@ import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar"
import { useAuth } from "@/contexts/AuthContext"
import { getAvatarUrl } from "@/lib/utils"
@@ -44,6 +45,7 @@ interface AppSidebarProps {
export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
const { user, logout } = useAuth()
const { isMobile, setOpenMobile } = useSidebar()
return (
<Sidebar>
@@ -63,7 +65,10 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
<SidebarMenuButton
asChild
isActive={isActive}
onClick={() => onNavigate(item.url)}
onClick={() => {
onNavigate(item.url)
if (isMobile) setOpenMobile(false)
}}
className={`${
isActive
? 'bg-primary/10 text-primary hover:bg-primary/10 hover:text-primary'

View File

@@ -38,8 +38,12 @@ export function Login() {
}
})
} else {
// Login successful, redirect to dashboard
navigate('/')
// Login successful, redirect based on role
if (result.user?.role === 'admin') {
navigate('/admin')
} else {
navigate('/')
}
}
} catch (err) {
const error = err as { response?: { data?: { message?: string } } }

View File

@@ -2,7 +2,7 @@ import { useState, useEffect, useMemo } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Plus, Wallet, Receipt, TrendingUp, TrendingDown, Calendar } from "lucide-react"
import { Plus, Wallet, TrendingUp, TrendingDown, Calendar } from "lucide-react"
import { ChartContainer, ChartTooltip } from "@/components/ui/chart"
import { Bar, XAxis, YAxis, ResponsiveContainer, PieChart as RechartsPieChart, Pie, Cell, Line, ComposedChart } from "recharts"
import {

View File

@@ -1,4 +1,6 @@
import { useState, useEffect } from "react"
import axios from "axios"
import { toast } from "sonner"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -24,7 +26,6 @@ import {
} from "lucide-react"
import { useAuth } from "@/contexts/AuthContext"
import { getAvatarUrl } from "@/lib/utils"
import axios from "axios"
const API = "/api"
@@ -170,6 +171,7 @@ export function Profile() {
}
await axios.put(`${API}/users/profile`, { name: editedName })
toast.success('Nama berhasil diupdate')
setNameSuccess("Name updated successfully!")
setIsEditingName(false)
@@ -212,7 +214,8 @@ export function Profile() {
}
})
// Reload user data
toast.success('Avatar berhasil diupdate')
// Reload user data to get new avatar URL
window.location.reload()
} catch (error) {
const err = error as { response?: { data?: { message?: string } } }
@@ -236,7 +239,8 @@ export function Profile() {
data: { password: deletePassword }
})
// Logout and redirect
toast.success('Akun berhasil dihapus')
// Logout and redirect to login
localStorage.removeItem('token')
window.location.href = '/auth/login'
} catch (error) {
@@ -267,6 +271,7 @@ export function Profile() {
// Update phone
await axios.put(`${API}/users/profile`, { phone })
toast.success('Nomor telepon berhasil diupdate')
setPhoneSuccess("Phone number updated successfully!")
// Reload OTP status
@@ -283,6 +288,7 @@ export function Profile() {
try {
setEmailOtpLoading(true)
await axios.post(`${API}/otp/email/send`)
toast.success('Kode OTP telah dikirim ke email')
setEmailOtpSent(true)
} catch (error) {
console.error('Failed to send email OTP:', error)
@@ -295,6 +301,7 @@ export function Profile() {
try {
setEmailOtpLoading(true)
await axios.post(`${API}/otp/email/verify`, { code: emailOtpCode })
toast.success('Email OTP berhasil diaktifkan')
await loadOtpStatus()
setEmailOtpCode("")
setEmailOtpSent(false)
@@ -309,6 +316,7 @@ export function Profile() {
try {
setEmailOtpLoading(true)
await axios.post(`${API}/otp/email/disable`)
toast.success('Email OTP berhasil dinonaktifkan')
await loadOtpStatus()
} catch (error) {
console.error('Failed to disable email OTP:', error)
@@ -321,6 +329,7 @@ export function Profile() {
try {
setWhatsappOtpLoading(true)
await axios.post(`${API}/otp/whatsapp/send`, { mode: 'test' })
toast.success('Kode OTP telah dikirim ke WhatsApp')
setWhatsappOtpSent(true)
} catch (error) {
console.error('Failed to send WhatsApp OTP:', error)
@@ -333,6 +342,7 @@ export function Profile() {
try {
setWhatsappOtpLoading(true)
await axios.post(`${API}/otp/whatsapp/verify`, { code: whatsappOtpCode })
toast.success('WhatsApp OTP berhasil diaktifkan')
await loadOtpStatus()
setWhatsappOtpCode("")
setWhatsappOtpSent(false)
@@ -347,6 +357,7 @@ export function Profile() {
try {
setWhatsappOtpLoading(true)
await axios.post(`${API}/otp/whatsapp/disable`)
toast.success('WhatsApp OTP berhasil dinonaktifkan')
await loadOtpStatus()
} catch (error) {
console.error('Failed to disable WhatsApp OTP:', error)
@@ -376,6 +387,7 @@ export function Profile() {
try {
setTotpLoading(true)
await axios.post(`${API}/otp/totp/verify`, { code: totpCode })
toast.success('Authenticator App berhasil diaktifkan')
await loadOtpStatus()
setTotpCode("")
setShowTotpSetup(false)
@@ -390,6 +402,7 @@ export function Profile() {
try {
setTotpLoading(true)
await axios.post(`${API}/otp/totp/disable`)
toast.success('Authenticator App berhasil dinonaktifkan')
await loadOtpStatus()
setShowTotpSetup(false)
// Clear QR code and secret when disabling
@@ -453,7 +466,8 @@ export function Profile() {
isSettingPassword: true // Flag to tell backend this is initial password
})
setPasswordSuccess("Password set successfully! You can now login with email/password.")
// Refresh to update hasPassword status
toast.success('Password berhasil diatur')
setPasswordSuccess("Password set successfully! Redirecting...")
setTimeout(() => window.location.reload(), 2000)
} else {
// Change password for user with existing password
@@ -461,6 +475,7 @@ export function Profile() {
currentPassword,
newPassword
})
toast.success('Password berhasil diubah')
setPasswordSuccess("Password changed successfully!")
}

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useMemo } from "react"
import { useSearchParams } from "react-router-dom"
import { toast } from "sonner"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -19,7 +20,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Plus, Search, Edit, Trash2, Receipt, TrendingUp, TrendingDown, Filter, X } from "lucide-react"
import { Plus, Search, Edit, Trash2, TrendingUp, TrendingDown, Filter, X } from "lucide-react"
import { Label } from "@/components/ui/label"
import axios from "axios"
import { formatCurrency } from "@/constants/currencies"
@@ -145,10 +146,11 @@ export function Transactions() {
try {
await axios.delete(`${API}/wallets/${walletId}/transactions/${transactionId}`)
toast.success('Transaksi berhasil dihapus')
await loadData()
} catch (error) {
console.error('Failed to delete transaction:', error)
alert('Failed to delete transaction')
toast.error('Gagal menghapus transaksi')
}
}

View File

@@ -21,6 +21,7 @@ import {
import { Plus, Search, Edit, Trash2, Wallet, Filter, X } from "lucide-react"
import { Label } from "@/components/ui/label"
import axios from "axios"
import { toast } from "sonner"
import { WalletDialog } from "@/components/dialogs/WalletDialog"
import {
AlertDialog,
@@ -76,10 +77,11 @@ export function Wallets() {
const deleteWallet = async (id: string) => {
try {
await axios.delete(`${API}/wallets/${id}`)
toast.success('Wallet berhasil dihapus')
await loadWallets()
} catch (error) {
console.error('Failed to delete wallet:', error)
alert('Failed to delete wallet')
toast.error('Gagal menghapus wallet')
}
}

View File

@@ -0,0 +1,28 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-5 w-5 shrink-0 rounded border-2 border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -0,0 +1,25 @@
import { Toaster as Sonner } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
)
}
export { Toaster }

View File

@@ -0,0 +1,27 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@@ -0,0 +1,23 @@
import * as React from "react"
import { cn } from "@/lib/utils"
type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

View File

@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/authform.tsx","./src/components/breadcrumb.tsx","./src/components/dashboard.tsx","./src/components/logo.tsx","./src/components/themetoggle.tsx","./src/components/dialogs/transactiondialog.tsx","./src/components/dialogs/walletdialog.tsx","./src/components/layout/appsidebar.tsx","./src/components/layout/dashboardlayout.tsx","./src/components/pages/overview.tsx","./src/components/pages/transactions.tsx","./src/components/pages/wallets.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/card.tsx","./src/components/ui/chart.tsx","./src/components/ui/command.tsx","./src/components/ui/date-picker.tsx","./src/components/ui/dialog.tsx","./src/components/ui/form.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/multi-select.tsx","./src/components/ui/multiselector.tsx","./src/components/ui/popover.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/table.tsx","./src/components/ui/tooltip.tsx","./src/constants/currencies.ts","./src/hooks/use-mobile.ts","./src/hooks/useauth.ts","./src/lib/firebase.ts","./src/lib/utils.ts","./src/utils/exchangerate.ts","./src/utils/numberformat.ts"],"version":"5.9.2"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/breadcrumb.tsx","./src/components/dashboard.tsx","./src/components/logo.tsx","./src/components/themeprovider.tsx","./src/components/themetoggle.tsx","./src/components/admin/adminbreadcrumb.tsx","./src/components/admin/adminlayout.tsx","./src/components/admin/adminsidebar.tsx","./src/components/admin/pages/admindashboard.tsx","./src/components/admin/pages/adminpaymentmethods.tsx","./src/components/admin/pages/adminpayments.tsx","./src/components/admin/pages/adminplans.tsx","./src/components/admin/pages/adminsettings.tsx","./src/components/admin/pages/adminusers.tsx","./src/components/dialogs/transactiondialog.tsx","./src/components/dialogs/walletdialog.tsx","./src/components/layout/appsidebar.tsx","./src/components/layout/authlayout.tsx","./src/components/layout/dashboardlayout.tsx","./src/components/pages/authcallback.tsx","./src/components/pages/login.tsx","./src/components/pages/otpverification.tsx","./src/components/pages/overview.tsx","./src/components/pages/profile.tsx","./src/components/pages/register.tsx","./src/components/pages/transactions.tsx","./src/components/pages/wallets.tsx","./src/components/test/calendartest.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/alert.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/card.tsx","./src/components/ui/chart.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/command.tsx","./src/components/ui/date-picker.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/form.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/multi-select.tsx","./src/components/ui/multiselector.tsx","./src/components/ui/popover.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/sonner.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/constants/currencies.ts","./src/contexts/authcontext.tsx","./src/hooks/use-mobile.ts","./src/hooks/usetheme.ts","./src/lib/utils.ts","./src/utils/exchangerate.ts","./src/utils/numberformat.ts"],"version":"5.9.2"}