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:
@@ -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 {};
|
||||
|
||||
@@ -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')),
|
||||
|
||||
@@ -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"}
|
||||
65
apps/api/dist/admin/admin-payments.service.d.ts
vendored
65
apps/api/dist/admin/admin-payments.service.d.ts
vendored
@@ -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;
|
||||
}[]>;
|
||||
}
|
||||
|
||||
37
apps/api/dist/admin/admin-payments.service.js
vendored
37
apps/api/dist/admin/admin-payments.service.js
vendored
@@ -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([
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"admin-payments.service.js","sourceRoot":"","sources":["../../src/admin/admin-payments.service.ts"],"names":[],"mappings":"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"}
|
||||
{"version":3,"file":"admin-payments.service.js","sourceRoot":"","sources":["../../src/admin/admin-payments.service.ts"],"names":[],"mappings":"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"}
|
||||
82
apps/api/dist/admin/admin-plans.controller.d.ts
vendored
82
apps/api/dist/admin/admin-plans.controller.d.ts
vendored
@@ -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[];
|
||||
|
||||
82
apps/api/dist/admin/admin-plans.service.d.ts
vendored
82
apps/api/dist/admin/admin-plans.service.d.ts
vendored
@@ -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;
|
||||
|
||||
30
apps/api/dist/admin/admin-plans.service.js
vendored
30
apps/api/dist/admin/admin-plans.service.js
vendored
@@ -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({
|
||||
|
||||
@@ -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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"}
|
||||
2
apps/api/dist/tsconfig.build.tsbuildinfo
vendored
2
apps/api/dist/tsconfig.build.tsbuildinfo
vendored
File diff suppressed because one or more lines are too long
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
128
apps/web/package-lock.json
generated
128
apps/web/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>} />
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 Advanced analytics 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 } } }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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!")
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
28
apps/web/src/components/ui/checkbox.tsx
Normal file
28
apps/web/src/components/ui/checkbox.tsx
Normal 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 }
|
||||
25
apps/web/src/components/ui/sonner.tsx
Normal file
25
apps/web/src/components/ui/sonner.tsx
Normal 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 }
|
||||
27
apps/web/src/components/ui/switch.tsx
Normal file
27
apps/web/src/components/ui/switch.tsx
Normal 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 }
|
||||
23
apps/web/src/components/ui/textarea.tsx
Normal file
23
apps/web/src/components/ui/textarea.tsx
Normal 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 }
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user