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: ({
|
subscription: ({
|
||||||
plan: {
|
plan: {
|
||||||
id: string;
|
id: string;
|
||||||
|
currency: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
name: string;
|
name: string;
|
||||||
currency: string;
|
|
||||||
slug: string;
|
slug: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
price: import("@prisma/client/runtime/library").Decimal;
|
price: import("@prisma/client/runtime/library").Decimal;
|
||||||
@@ -42,10 +42,10 @@ export declare class AdminPaymentsController {
|
|||||||
};
|
};
|
||||||
} & {
|
} & {
|
||||||
id: string;
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
status: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
status: string;
|
|
||||||
userId: string;
|
|
||||||
planId: string;
|
planId: string;
|
||||||
startDate: Date;
|
startDate: Date;
|
||||||
endDate: Date;
|
endDate: Date;
|
||||||
@@ -56,21 +56,19 @@ export declare class AdminPaymentsController {
|
|||||||
}) | null;
|
}) | null;
|
||||||
} & {
|
} & {
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
status: string;
|
|
||||||
method: string;
|
|
||||||
userId: string;
|
userId: string;
|
||||||
currency: string;
|
|
||||||
amount: import("@prisma/client/runtime/library").Decimal;
|
|
||||||
subscriptionId: string | null;
|
subscriptionId: string | null;
|
||||||
invoiceNumber: string;
|
invoiceNumber: string;
|
||||||
|
amount: import("@prisma/client/runtime/library").Decimal;
|
||||||
|
currency: string;
|
||||||
|
method: string;
|
||||||
tripayReference: string | null;
|
tripayReference: string | null;
|
||||||
tripayFee: import("@prisma/client/runtime/library").Decimal | null;
|
tripayFee: import("@prisma/client/runtime/library").Decimal | null;
|
||||||
totalAmount: import("@prisma/client/runtime/library").Decimal;
|
totalAmount: import("@prisma/client/runtime/library").Decimal;
|
||||||
paymentChannel: string | null;
|
paymentChannel: string | null;
|
||||||
paymentUrl: string | null;
|
paymentUrl: string | null;
|
||||||
qrUrl: string | null;
|
qrUrl: string | null;
|
||||||
|
status: string;
|
||||||
proofImageUrl: string | null;
|
proofImageUrl: string | null;
|
||||||
transferDate: Date | null;
|
transferDate: Date | null;
|
||||||
verifiedBy: string | null;
|
verifiedBy: string | null;
|
||||||
@@ -81,8 +79,15 @@ export declare class AdminPaymentsController {
|
|||||||
notes: string | null;
|
notes: string | null;
|
||||||
expiresAt: Date | null;
|
expiresAt: Date | null;
|
||||||
paidAt: Date | null;
|
paidAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
})[]>;
|
})[]>;
|
||||||
getPendingCount(): Promise<number>;
|
getPendingCount(): Promise<number>;
|
||||||
|
getMonthlyRevenue(): Promise<{
|
||||||
|
month: string;
|
||||||
|
revenue: number;
|
||||||
|
users: number;
|
||||||
|
}[]>;
|
||||||
findOne(id: string): Promise<({
|
findOne(id: string): Promise<({
|
||||||
user: {
|
user: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -92,10 +97,10 @@ export declare class AdminPaymentsController {
|
|||||||
subscription: ({
|
subscription: ({
|
||||||
plan: {
|
plan: {
|
||||||
id: string;
|
id: string;
|
||||||
|
currency: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
name: string;
|
name: string;
|
||||||
currency: string;
|
|
||||||
slug: string;
|
slug: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
price: import("@prisma/client/runtime/library").Decimal;
|
price: import("@prisma/client/runtime/library").Decimal;
|
||||||
@@ -118,10 +123,10 @@ export declare class AdminPaymentsController {
|
|||||||
};
|
};
|
||||||
} & {
|
} & {
|
||||||
id: string;
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
status: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
status: string;
|
|
||||||
userId: string;
|
|
||||||
planId: string;
|
planId: string;
|
||||||
startDate: Date;
|
startDate: Date;
|
||||||
endDate: Date;
|
endDate: Date;
|
||||||
@@ -132,21 +137,19 @@ export declare class AdminPaymentsController {
|
|||||||
}) | null;
|
}) | null;
|
||||||
} & {
|
} & {
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
status: string;
|
|
||||||
method: string;
|
|
||||||
userId: string;
|
userId: string;
|
||||||
currency: string;
|
|
||||||
amount: import("@prisma/client/runtime/library").Decimal;
|
|
||||||
subscriptionId: string | null;
|
subscriptionId: string | null;
|
||||||
invoiceNumber: string;
|
invoiceNumber: string;
|
||||||
|
amount: import("@prisma/client/runtime/library").Decimal;
|
||||||
|
currency: string;
|
||||||
|
method: string;
|
||||||
tripayReference: string | null;
|
tripayReference: string | null;
|
||||||
tripayFee: import("@prisma/client/runtime/library").Decimal | null;
|
tripayFee: import("@prisma/client/runtime/library").Decimal | null;
|
||||||
totalAmount: import("@prisma/client/runtime/library").Decimal;
|
totalAmount: import("@prisma/client/runtime/library").Decimal;
|
||||||
paymentChannel: string | null;
|
paymentChannel: string | null;
|
||||||
paymentUrl: string | null;
|
paymentUrl: string | null;
|
||||||
qrUrl: string | null;
|
qrUrl: string | null;
|
||||||
|
status: string;
|
||||||
proofImageUrl: string | null;
|
proofImageUrl: string | null;
|
||||||
transferDate: Date | null;
|
transferDate: Date | null;
|
||||||
verifiedBy: string | null;
|
verifiedBy: string | null;
|
||||||
@@ -157,24 +160,24 @@ export declare class AdminPaymentsController {
|
|||||||
notes: string | null;
|
notes: string | null;
|
||||||
expiresAt: Date | null;
|
expiresAt: Date | null;
|
||||||
paidAt: Date | null;
|
paidAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
}) | null>;
|
}) | null>;
|
||||||
verify(id: string, req: RequestWithUser): Promise<{
|
verify(id: string, req: RequestWithUser): Promise<{
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
status: string;
|
|
||||||
method: string;
|
|
||||||
userId: string;
|
userId: string;
|
||||||
currency: string;
|
|
||||||
amount: import("@prisma/client/runtime/library").Decimal;
|
|
||||||
subscriptionId: string | null;
|
subscriptionId: string | null;
|
||||||
invoiceNumber: string;
|
invoiceNumber: string;
|
||||||
|
amount: import("@prisma/client/runtime/library").Decimal;
|
||||||
|
currency: string;
|
||||||
|
method: string;
|
||||||
tripayReference: string | null;
|
tripayReference: string | null;
|
||||||
tripayFee: import("@prisma/client/runtime/library").Decimal | null;
|
tripayFee: import("@prisma/client/runtime/library").Decimal | null;
|
||||||
totalAmount: import("@prisma/client/runtime/library").Decimal;
|
totalAmount: import("@prisma/client/runtime/library").Decimal;
|
||||||
paymentChannel: string | null;
|
paymentChannel: string | null;
|
||||||
paymentUrl: string | null;
|
paymentUrl: string | null;
|
||||||
qrUrl: string | null;
|
qrUrl: string | null;
|
||||||
|
status: string;
|
||||||
proofImageUrl: string | null;
|
proofImageUrl: string | null;
|
||||||
transferDate: Date | null;
|
transferDate: Date | null;
|
||||||
verifiedBy: string | null;
|
verifiedBy: string | null;
|
||||||
@@ -185,26 +188,26 @@ export declare class AdminPaymentsController {
|
|||||||
notes: string | null;
|
notes: string | null;
|
||||||
expiresAt: Date | null;
|
expiresAt: Date | null;
|
||||||
paidAt: Date | null;
|
paidAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
}>;
|
}>;
|
||||||
reject(id: string, req: RequestWithUser, body: {
|
reject(id: string, req: RequestWithUser, body: {
|
||||||
reason: string;
|
reason: string;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
status: string;
|
|
||||||
method: string;
|
|
||||||
userId: string;
|
userId: string;
|
||||||
currency: string;
|
|
||||||
amount: import("@prisma/client/runtime/library").Decimal;
|
|
||||||
subscriptionId: string | null;
|
subscriptionId: string | null;
|
||||||
invoiceNumber: string;
|
invoiceNumber: string;
|
||||||
|
amount: import("@prisma/client/runtime/library").Decimal;
|
||||||
|
currency: string;
|
||||||
|
method: string;
|
||||||
tripayReference: string | null;
|
tripayReference: string | null;
|
||||||
tripayFee: import("@prisma/client/runtime/library").Decimal | null;
|
tripayFee: import("@prisma/client/runtime/library").Decimal | null;
|
||||||
totalAmount: import("@prisma/client/runtime/library").Decimal;
|
totalAmount: import("@prisma/client/runtime/library").Decimal;
|
||||||
paymentChannel: string | null;
|
paymentChannel: string | null;
|
||||||
paymentUrl: string | null;
|
paymentUrl: string | null;
|
||||||
qrUrl: string | null;
|
qrUrl: string | null;
|
||||||
|
status: string;
|
||||||
proofImageUrl: string | null;
|
proofImageUrl: string | null;
|
||||||
transferDate: Date | null;
|
transferDate: Date | null;
|
||||||
verifiedBy: string | null;
|
verifiedBy: string | null;
|
||||||
@@ -215,6 +218,8 @@ export declare class AdminPaymentsController {
|
|||||||
notes: string | null;
|
notes: string | null;
|
||||||
expiresAt: Date | null;
|
expiresAt: Date | null;
|
||||||
paidAt: Date | null;
|
paidAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ let AdminPaymentsController = class AdminPaymentsController {
|
|||||||
getPendingCount() {
|
getPendingCount() {
|
||||||
return this.service.getPendingCount();
|
return this.service.getPendingCount();
|
||||||
}
|
}
|
||||||
|
getMonthlyRevenue() {
|
||||||
|
return this.service.getMonthlyRevenue();
|
||||||
|
}
|
||||||
findOne(id) {
|
findOne(id) {
|
||||||
return this.service.findOne(id);
|
return this.service.findOne(id);
|
||||||
}
|
}
|
||||||
@@ -52,6 +55,12 @@ __decorate([
|
|||||||
__metadata("design:paramtypes", []),
|
__metadata("design:paramtypes", []),
|
||||||
__metadata("design:returntype", void 0)
|
__metadata("design:returntype", void 0)
|
||||||
], AdminPaymentsController.prototype, "getPendingCount", null);
|
], 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([
|
__decorate([
|
||||||
(0, common_1.Get)(':id'),
|
(0, common_1.Get)(':id'),
|
||||||
__param(0, (0, common_1.Param)('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: ({
|
subscription: ({
|
||||||
plan: {
|
plan: {
|
||||||
id: string;
|
id: string;
|
||||||
|
currency: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
name: string;
|
name: string;
|
||||||
currency: string;
|
|
||||||
slug: string;
|
slug: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
price: import("@prisma/client/runtime/library").Decimal;
|
price: import("@prisma/client/runtime/library").Decimal;
|
||||||
@@ -37,10 +37,10 @@ export declare class AdminPaymentsService {
|
|||||||
};
|
};
|
||||||
} & {
|
} & {
|
||||||
id: string;
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
status: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
status: string;
|
|
||||||
userId: string;
|
|
||||||
planId: string;
|
planId: string;
|
||||||
startDate: Date;
|
startDate: Date;
|
||||||
endDate: Date;
|
endDate: Date;
|
||||||
@@ -51,21 +51,19 @@ export declare class AdminPaymentsService {
|
|||||||
}) | null;
|
}) | null;
|
||||||
} & {
|
} & {
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
status: string;
|
|
||||||
method: string;
|
|
||||||
userId: string;
|
userId: string;
|
||||||
currency: string;
|
|
||||||
amount: import("@prisma/client/runtime/library").Decimal;
|
|
||||||
subscriptionId: string | null;
|
subscriptionId: string | null;
|
||||||
invoiceNumber: string;
|
invoiceNumber: string;
|
||||||
|
amount: import("@prisma/client/runtime/library").Decimal;
|
||||||
|
currency: string;
|
||||||
|
method: string;
|
||||||
tripayReference: string | null;
|
tripayReference: string | null;
|
||||||
tripayFee: import("@prisma/client/runtime/library").Decimal | null;
|
tripayFee: import("@prisma/client/runtime/library").Decimal | null;
|
||||||
totalAmount: import("@prisma/client/runtime/library").Decimal;
|
totalAmount: import("@prisma/client/runtime/library").Decimal;
|
||||||
paymentChannel: string | null;
|
paymentChannel: string | null;
|
||||||
paymentUrl: string | null;
|
paymentUrl: string | null;
|
||||||
qrUrl: string | null;
|
qrUrl: string | null;
|
||||||
|
status: string;
|
||||||
proofImageUrl: string | null;
|
proofImageUrl: string | null;
|
||||||
transferDate: Date | null;
|
transferDate: Date | null;
|
||||||
verifiedBy: string | null;
|
verifiedBy: string | null;
|
||||||
@@ -76,6 +74,8 @@ export declare class AdminPaymentsService {
|
|||||||
notes: string | null;
|
notes: string | null;
|
||||||
expiresAt: Date | null;
|
expiresAt: Date | null;
|
||||||
paidAt: Date | null;
|
paidAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
})[]>;
|
})[]>;
|
||||||
findOne(id: string): Promise<({
|
findOne(id: string): Promise<({
|
||||||
user: {
|
user: {
|
||||||
@@ -86,10 +86,10 @@ export declare class AdminPaymentsService {
|
|||||||
subscription: ({
|
subscription: ({
|
||||||
plan: {
|
plan: {
|
||||||
id: string;
|
id: string;
|
||||||
|
currency: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
name: string;
|
name: string;
|
||||||
currency: string;
|
|
||||||
slug: string;
|
slug: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
price: import("@prisma/client/runtime/library").Decimal;
|
price: import("@prisma/client/runtime/library").Decimal;
|
||||||
@@ -112,10 +112,10 @@ export declare class AdminPaymentsService {
|
|||||||
};
|
};
|
||||||
} & {
|
} & {
|
||||||
id: string;
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
status: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
status: string;
|
|
||||||
userId: string;
|
|
||||||
planId: string;
|
planId: string;
|
||||||
startDate: Date;
|
startDate: Date;
|
||||||
endDate: Date;
|
endDate: Date;
|
||||||
@@ -126,21 +126,19 @@ export declare class AdminPaymentsService {
|
|||||||
}) | null;
|
}) | null;
|
||||||
} & {
|
} & {
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
status: string;
|
|
||||||
method: string;
|
|
||||||
userId: string;
|
userId: string;
|
||||||
currency: string;
|
|
||||||
amount: import("@prisma/client/runtime/library").Decimal;
|
|
||||||
subscriptionId: string | null;
|
subscriptionId: string | null;
|
||||||
invoiceNumber: string;
|
invoiceNumber: string;
|
||||||
|
amount: import("@prisma/client/runtime/library").Decimal;
|
||||||
|
currency: string;
|
||||||
|
method: string;
|
||||||
tripayReference: string | null;
|
tripayReference: string | null;
|
||||||
tripayFee: import("@prisma/client/runtime/library").Decimal | null;
|
tripayFee: import("@prisma/client/runtime/library").Decimal | null;
|
||||||
totalAmount: import("@prisma/client/runtime/library").Decimal;
|
totalAmount: import("@prisma/client/runtime/library").Decimal;
|
||||||
paymentChannel: string | null;
|
paymentChannel: string | null;
|
||||||
paymentUrl: string | null;
|
paymentUrl: string | null;
|
||||||
qrUrl: string | null;
|
qrUrl: string | null;
|
||||||
|
status: string;
|
||||||
proofImageUrl: string | null;
|
proofImageUrl: string | null;
|
||||||
transferDate: Date | null;
|
transferDate: Date | null;
|
||||||
verifiedBy: string | null;
|
verifiedBy: string | null;
|
||||||
@@ -151,24 +149,24 @@ export declare class AdminPaymentsService {
|
|||||||
notes: string | null;
|
notes: string | null;
|
||||||
expiresAt: Date | null;
|
expiresAt: Date | null;
|
||||||
paidAt: Date | null;
|
paidAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
}) | null>;
|
}) | null>;
|
||||||
verify(id: string, adminUserId: string): Promise<{
|
verify(id: string, adminUserId: string): Promise<{
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
status: string;
|
|
||||||
method: string;
|
|
||||||
userId: string;
|
userId: string;
|
||||||
currency: string;
|
|
||||||
amount: import("@prisma/client/runtime/library").Decimal;
|
|
||||||
subscriptionId: string | null;
|
subscriptionId: string | null;
|
||||||
invoiceNumber: string;
|
invoiceNumber: string;
|
||||||
|
amount: import("@prisma/client/runtime/library").Decimal;
|
||||||
|
currency: string;
|
||||||
|
method: string;
|
||||||
tripayReference: string | null;
|
tripayReference: string | null;
|
||||||
tripayFee: import("@prisma/client/runtime/library").Decimal | null;
|
tripayFee: import("@prisma/client/runtime/library").Decimal | null;
|
||||||
totalAmount: import("@prisma/client/runtime/library").Decimal;
|
totalAmount: import("@prisma/client/runtime/library").Decimal;
|
||||||
paymentChannel: string | null;
|
paymentChannel: string | null;
|
||||||
paymentUrl: string | null;
|
paymentUrl: string | null;
|
||||||
qrUrl: string | null;
|
qrUrl: string | null;
|
||||||
|
status: string;
|
||||||
proofImageUrl: string | null;
|
proofImageUrl: string | null;
|
||||||
transferDate: Date | null;
|
transferDate: Date | null;
|
||||||
verifiedBy: string | null;
|
verifiedBy: string | null;
|
||||||
@@ -179,24 +177,24 @@ export declare class AdminPaymentsService {
|
|||||||
notes: string | null;
|
notes: string | null;
|
||||||
expiresAt: Date | null;
|
expiresAt: Date | null;
|
||||||
paidAt: Date | null;
|
paidAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
}>;
|
}>;
|
||||||
reject(id: string, adminUserId: string, reason: string): Promise<{
|
reject(id: string, adminUserId: string, reason: string): Promise<{
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
status: string;
|
|
||||||
method: string;
|
|
||||||
userId: string;
|
userId: string;
|
||||||
currency: string;
|
|
||||||
amount: import("@prisma/client/runtime/library").Decimal;
|
|
||||||
subscriptionId: string | null;
|
subscriptionId: string | null;
|
||||||
invoiceNumber: string;
|
invoiceNumber: string;
|
||||||
|
amount: import("@prisma/client/runtime/library").Decimal;
|
||||||
|
currency: string;
|
||||||
|
method: string;
|
||||||
tripayReference: string | null;
|
tripayReference: string | null;
|
||||||
tripayFee: import("@prisma/client/runtime/library").Decimal | null;
|
tripayFee: import("@prisma/client/runtime/library").Decimal | null;
|
||||||
totalAmount: import("@prisma/client/runtime/library").Decimal;
|
totalAmount: import("@prisma/client/runtime/library").Decimal;
|
||||||
paymentChannel: string | null;
|
paymentChannel: string | null;
|
||||||
paymentUrl: string | null;
|
paymentUrl: string | null;
|
||||||
qrUrl: string | null;
|
qrUrl: string | null;
|
||||||
|
status: string;
|
||||||
proofImageUrl: string | null;
|
proofImageUrl: string | null;
|
||||||
transferDate: Date | null;
|
transferDate: Date | null;
|
||||||
verifiedBy: string | null;
|
verifiedBy: string | null;
|
||||||
@@ -207,6 +205,13 @@ export declare class AdminPaymentsService {
|
|||||||
notes: string | null;
|
notes: string | null;
|
||||||
expiresAt: Date | null;
|
expiresAt: Date | null;
|
||||||
paidAt: Date | null;
|
paidAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
}>;
|
}>;
|
||||||
getPendingCount(): Promise<number>;
|
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' },
|
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;
|
||||||
exports.AdminPaymentsService = AdminPaymentsService = __decorate([
|
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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"}
|
||||||
40
apps/api/dist/admin/admin-plans.controller.d.ts
vendored
40
apps/api/dist/admin/admin-plans.controller.d.ts
vendored
@@ -8,13 +8,11 @@ export declare class AdminPlansController {
|
|||||||
};
|
};
|
||||||
} & {
|
} & {
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
name: string;
|
name: string;
|
||||||
currency: string;
|
|
||||||
slug: string;
|
slug: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
price: import("@prisma/client/runtime/library").Decimal;
|
price: import("@prisma/client/runtime/library").Decimal;
|
||||||
|
currency: string;
|
||||||
durationType: string;
|
durationType: string;
|
||||||
durationDays: number | null;
|
durationDays: number | null;
|
||||||
trialDays: number;
|
trialDays: number;
|
||||||
@@ -31,6 +29,8 @@ export declare class AdminPlansController {
|
|||||||
maxTeamMembers: number | null;
|
maxTeamMembers: number | null;
|
||||||
apiEnabled: boolean;
|
apiEnabled: boolean;
|
||||||
apiRateLimit: number | null;
|
apiRateLimit: number | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
})[]>;
|
})[]>;
|
||||||
findOne(id: string): Promise<({
|
findOne(id: string): Promise<({
|
||||||
_count: {
|
_count: {
|
||||||
@@ -38,13 +38,11 @@ export declare class AdminPlansController {
|
|||||||
};
|
};
|
||||||
} & {
|
} & {
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
name: string;
|
name: string;
|
||||||
currency: string;
|
|
||||||
slug: string;
|
slug: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
price: import("@prisma/client/runtime/library").Decimal;
|
price: import("@prisma/client/runtime/library").Decimal;
|
||||||
|
currency: string;
|
||||||
durationType: string;
|
durationType: string;
|
||||||
durationDays: number | null;
|
durationDays: number | null;
|
||||||
trialDays: number;
|
trialDays: number;
|
||||||
@@ -61,16 +59,16 @@ export declare class AdminPlansController {
|
|||||||
maxTeamMembers: number | null;
|
maxTeamMembers: number | null;
|
||||||
apiEnabled: boolean;
|
apiEnabled: boolean;
|
||||||
apiRateLimit: number | null;
|
apiRateLimit: number | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
}) | null>;
|
}) | null>;
|
||||||
create(data: any): Promise<{
|
create(data: any): Promise<{
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
name: string;
|
name: string;
|
||||||
currency: string;
|
|
||||||
slug: string;
|
slug: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
price: import("@prisma/client/runtime/library").Decimal;
|
price: import("@prisma/client/runtime/library").Decimal;
|
||||||
|
currency: string;
|
||||||
durationType: string;
|
durationType: string;
|
||||||
durationDays: number | null;
|
durationDays: number | null;
|
||||||
trialDays: number;
|
trialDays: number;
|
||||||
@@ -87,16 +85,16 @@ export declare class AdminPlansController {
|
|||||||
maxTeamMembers: number | null;
|
maxTeamMembers: number | null;
|
||||||
apiEnabled: boolean;
|
apiEnabled: boolean;
|
||||||
apiRateLimit: number | null;
|
apiRateLimit: number | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
}>;
|
}>;
|
||||||
update(id: string, data: any): Promise<{
|
update(id: string, data: any): Promise<{
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
name: string;
|
name: string;
|
||||||
currency: string;
|
|
||||||
slug: string;
|
slug: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
price: import("@prisma/client/runtime/library").Decimal;
|
price: import("@prisma/client/runtime/library").Decimal;
|
||||||
|
currency: string;
|
||||||
durationType: string;
|
durationType: string;
|
||||||
durationDays: number | null;
|
durationDays: number | null;
|
||||||
trialDays: number;
|
trialDays: number;
|
||||||
@@ -113,16 +111,20 @@ export declare class AdminPlansController {
|
|||||||
maxTeamMembers: number | null;
|
maxTeamMembers: number | null;
|
||||||
apiEnabled: boolean;
|
apiEnabled: boolean;
|
||||||
apiRateLimit: number | null;
|
apiRateLimit: number | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
}>;
|
}>;
|
||||||
delete(id: string): Promise<{
|
delete(id: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
action: string;
|
||||||
|
plan: {
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
name: string;
|
name: string;
|
||||||
currency: string;
|
|
||||||
slug: string;
|
slug: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
price: import("@prisma/client/runtime/library").Decimal;
|
price: import("@prisma/client/runtime/library").Decimal;
|
||||||
|
currency: string;
|
||||||
durationType: string;
|
durationType: string;
|
||||||
durationDays: number | null;
|
durationDays: number | null;
|
||||||
trialDays: number;
|
trialDays: number;
|
||||||
@@ -139,6 +141,14 @@ export declare class AdminPlansController {
|
|||||||
maxTeamMembers: number | null;
|
maxTeamMembers: number | null;
|
||||||
apiEnabled: boolean;
|
apiEnabled: boolean;
|
||||||
apiRateLimit: number | null;
|
apiRateLimit: number | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
} | {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
action: string;
|
||||||
|
plan?: undefined;
|
||||||
}>;
|
}>;
|
||||||
reorder(body: {
|
reorder(body: {
|
||||||
planIds: string[];
|
planIds: string[];
|
||||||
|
|||||||
40
apps/api/dist/admin/admin-plans.service.d.ts
vendored
40
apps/api/dist/admin/admin-plans.service.d.ts
vendored
@@ -8,13 +8,11 @@ export declare class AdminPlansService {
|
|||||||
};
|
};
|
||||||
} & {
|
} & {
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
name: string;
|
name: string;
|
||||||
currency: string;
|
|
||||||
slug: string;
|
slug: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
price: import("@prisma/client/runtime/library").Decimal;
|
price: import("@prisma/client/runtime/library").Decimal;
|
||||||
|
currency: string;
|
||||||
durationType: string;
|
durationType: string;
|
||||||
durationDays: number | null;
|
durationDays: number | null;
|
||||||
trialDays: number;
|
trialDays: number;
|
||||||
@@ -31,6 +29,8 @@ export declare class AdminPlansService {
|
|||||||
maxTeamMembers: number | null;
|
maxTeamMembers: number | null;
|
||||||
apiEnabled: boolean;
|
apiEnabled: boolean;
|
||||||
apiRateLimit: number | null;
|
apiRateLimit: number | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
})[]>;
|
})[]>;
|
||||||
findOne(id: string): Promise<({
|
findOne(id: string): Promise<({
|
||||||
_count: {
|
_count: {
|
||||||
@@ -38,13 +38,11 @@ export declare class AdminPlansService {
|
|||||||
};
|
};
|
||||||
} & {
|
} & {
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
name: string;
|
name: string;
|
||||||
currency: string;
|
|
||||||
slug: string;
|
slug: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
price: import("@prisma/client/runtime/library").Decimal;
|
price: import("@prisma/client/runtime/library").Decimal;
|
||||||
|
currency: string;
|
||||||
durationType: string;
|
durationType: string;
|
||||||
durationDays: number | null;
|
durationDays: number | null;
|
||||||
trialDays: number;
|
trialDays: number;
|
||||||
@@ -61,16 +59,16 @@ export declare class AdminPlansService {
|
|||||||
maxTeamMembers: number | null;
|
maxTeamMembers: number | null;
|
||||||
apiEnabled: boolean;
|
apiEnabled: boolean;
|
||||||
apiRateLimit: number | null;
|
apiRateLimit: number | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
}) | null>;
|
}) | null>;
|
||||||
create(data: any): Promise<{
|
create(data: any): Promise<{
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
name: string;
|
name: string;
|
||||||
currency: string;
|
|
||||||
slug: string;
|
slug: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
price: import("@prisma/client/runtime/library").Decimal;
|
price: import("@prisma/client/runtime/library").Decimal;
|
||||||
|
currency: string;
|
||||||
durationType: string;
|
durationType: string;
|
||||||
durationDays: number | null;
|
durationDays: number | null;
|
||||||
trialDays: number;
|
trialDays: number;
|
||||||
@@ -87,16 +85,16 @@ export declare class AdminPlansService {
|
|||||||
maxTeamMembers: number | null;
|
maxTeamMembers: number | null;
|
||||||
apiEnabled: boolean;
|
apiEnabled: boolean;
|
||||||
apiRateLimit: number | null;
|
apiRateLimit: number | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
}>;
|
}>;
|
||||||
update(id: string, data: any): Promise<{
|
update(id: string, data: any): Promise<{
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
name: string;
|
name: string;
|
||||||
currency: string;
|
|
||||||
slug: string;
|
slug: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
price: import("@prisma/client/runtime/library").Decimal;
|
price: import("@prisma/client/runtime/library").Decimal;
|
||||||
|
currency: string;
|
||||||
durationType: string;
|
durationType: string;
|
||||||
durationDays: number | null;
|
durationDays: number | null;
|
||||||
trialDays: number;
|
trialDays: number;
|
||||||
@@ -113,16 +111,20 @@ export declare class AdminPlansService {
|
|||||||
maxTeamMembers: number | null;
|
maxTeamMembers: number | null;
|
||||||
apiEnabled: boolean;
|
apiEnabled: boolean;
|
||||||
apiRateLimit: number | null;
|
apiRateLimit: number | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
}>;
|
}>;
|
||||||
delete(id: string): Promise<{
|
delete(id: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
action: string;
|
||||||
|
plan: {
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
name: string;
|
name: string;
|
||||||
currency: string;
|
|
||||||
slug: string;
|
slug: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
price: import("@prisma/client/runtime/library").Decimal;
|
price: import("@prisma/client/runtime/library").Decimal;
|
||||||
|
currency: string;
|
||||||
durationType: string;
|
durationType: string;
|
||||||
durationDays: number | null;
|
durationDays: number | null;
|
||||||
trialDays: number;
|
trialDays: number;
|
||||||
@@ -139,6 +141,14 @@ export declare class AdminPlansService {
|
|||||||
maxTeamMembers: number | null;
|
maxTeamMembers: number | null;
|
||||||
apiEnabled: boolean;
|
apiEnabled: boolean;
|
||||||
apiRateLimit: number | null;
|
apiRateLimit: number | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
} | {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
action: string;
|
||||||
|
plan?: undefined;
|
||||||
}>;
|
}>;
|
||||||
reorder(planIds: string[]): Promise<{
|
reorder(planIds: string[]): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|||||||
28
apps/api/dist/admin/admin-plans.service.js
vendored
28
apps/api/dist/admin/admin-plans.service.js
vendored
@@ -49,10 +49,36 @@ let AdminPlansService = class AdminPlansService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
async delete(id) {
|
async delete(id) {
|
||||||
return this.prisma.plan.update({
|
const plan = await this.prisma.plan.findUnique({
|
||||||
|
where: { id },
|
||||||
|
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 },
|
where: { id },
|
||||||
data: { isActive: false, isVisible: false },
|
data: { isActive: false, isVisible: false },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await this.prisma.plan.delete({
|
||||||
|
where: { id },
|
||||||
});
|
});
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Plan permanently deleted',
|
||||||
|
action: 'deleted',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
async reorder(planIds) {
|
async reorder(planIds) {
|
||||||
const updates = planIds.map((id, index) => this.prisma.plan.update({
|
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();
|
return this.service.getPendingCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('revenue/monthly')
|
||||||
|
getMonthlyRevenue() {
|
||||||
|
return this.service.getMonthlyRevenue();
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
return this.service.findOne(id);
|
return this.service.findOne(id);
|
||||||
|
|||||||
@@ -107,4 +107,52 @@ export class AdminPaymentsService {
|
|||||||
where: { status: 'pending' },
|
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) {
|
async delete(id: string) {
|
||||||
// Soft delete - just deactivate
|
// Check if plan has any subscriptions
|
||||||
return this.prisma.plan.update({
|
const plan = await this.prisma.plan.findUnique({
|
||||||
|
where: { id },
|
||||||
|
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 },
|
where: { id },
|
||||||
data: { isActive: false, isVisible: false },
|
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[]) {
|
async reorder(planIds: string[]) {
|
||||||
|
|||||||
128
apps/web/package-lock.json
generated
128
apps/web/package-lock.json
generated
@@ -8,8 +8,12 @@
|
|||||||
"name": "web",
|
"name": "web",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@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-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
@@ -17,6 +21,7 @@
|
|||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
@@ -31,6 +36,7 @@
|
|||||||
"react-hook-form": "^7.64.0",
|
"react-hook-form": "^7.64.0",
|
||||||
"react-router-dom": "^7.9.4",
|
"react-router-dom": "^7.9.4",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
@@ -378,6 +384,59 @@
|
|||||||
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
|
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.9",
|
"version": "0.25.9",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz",
|
"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": {
|
"node_modules/@radix-ui/react-collection": {
|
||||||
"version": "1.1.7",
|
"version": "1.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
"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": {
|
"node_modules/@radix-ui/react-tabs": {
|
||||||
"version": "1.1.13",
|
"version": "1.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
|
||||||
@@ -5536,6 +5654,16 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
|||||||
@@ -10,8 +10,12 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@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-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
@@ -19,6 +23,7 @@
|
|||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
@@ -33,6 +38,7 @@
|
|||||||
"react-hook-form": "^7.64.0",
|
"react-hook-form": "^7.64.0",
|
||||||
"react-router-dom": "^7.9.4",
|
"react-router-dom": "^7.9.4",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||||
import { ThemeProvider } from './components/ThemeProvider'
|
import { ThemeProvider } from './components/ThemeProvider'
|
||||||
|
import { Toaster } from './components/ui/sonner'
|
||||||
import { Dashboard } from './components/Dashboard'
|
import { Dashboard } from './components/Dashboard'
|
||||||
import { Login } from './components/pages/Login'
|
import { Login } from './components/pages/Login'
|
||||||
import { Register } from './components/pages/Register'
|
import { Register } from './components/pages/Register'
|
||||||
@@ -59,6 +60,7 @@ export default function App() {
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<ThemeProvider defaultTheme="light" storageKey="tabungin-ui-theme">
|
<ThemeProvider defaultTheme="light" storageKey="tabungin-ui-theme">
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
<Toaster />
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Public Routes */}
|
{/* Public Routes */}
|
||||||
<Route path="/auth/login" element={<PublicRoute><Login /></PublicRoute>} />
|
<Route path="/auth/login" element={<PublicRoute><Login /></PublicRoute>} />
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
|
useSidebar,
|
||||||
} from '@/components/ui/sidebar'
|
} from '@/components/ui/sidebar'
|
||||||
import { useAuth } from '@/contexts/AuthContext'
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
import { getAvatarUrl } from '@/lib/utils'
|
import { getAvatarUrl } from '@/lib/utils'
|
||||||
@@ -54,6 +55,7 @@ interface AdminSidebarProps {
|
|||||||
|
|
||||||
export function AdminSidebar({ currentPage, onNavigate }: AdminSidebarProps) {
|
export function AdminSidebar({ currentPage, onNavigate }: AdminSidebarProps) {
|
||||||
const { user, logout } = useAuth()
|
const { user, logout } = useAuth()
|
||||||
|
const { isMobile, setOpenMobile } = useSidebar()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar>
|
<Sidebar>
|
||||||
@@ -73,7 +75,10 @@ export function AdminSidebar({ currentPage, onNavigate }: AdminSidebarProps) {
|
|||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
asChild
|
asChild
|
||||||
isActive={isActive}
|
isActive={isActive}
|
||||||
onClick={() => onNavigate(item.url)}
|
onClick={() => {
|
||||||
|
onNavigate(item.url)
|
||||||
|
if (isMobile) setOpenMobile(false)
|
||||||
|
}}
|
||||||
className={`${
|
className={`${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-primary/10 text-primary hover:bg-primary/10 hover:text-primary'
|
? 'bg-primary/10 text-primary hover:bg-primary/10 hover:text-primary'
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import axios from 'axios'
|
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'
|
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'
|
||||||
|
|
||||||
@@ -8,15 +13,24 @@ interface Stats {
|
|||||||
totalUsers: number
|
totalUsers: number
|
||||||
activeSubscriptions: number
|
activeSubscriptions: number
|
||||||
suspendedUsers: number
|
suspendedUsers: number
|
||||||
|
totalRevenue: number
|
||||||
|
monthlyRevenue: number
|
||||||
|
revenueGrowth: number
|
||||||
|
newUsersThisMonth: number
|
||||||
|
userGrowth: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdminDashboard() {
|
export function AdminDashboard() {
|
||||||
const [stats, setStats] = useState<Stats | null>(null)
|
const [stats, setStats] = useState<Stats | null>(null)
|
||||||
const [pendingPayments, setPendingPayments] = useState(0)
|
const [pendingPayments, setPendingPayments] = useState(0)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [subscriptionData, setSubscriptionData] = useState<{ plan: string; count: number }[]>([])
|
||||||
|
const [revenueData, setRevenueData] = useState<{ month: string; revenue: number; users: number }[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchStats()
|
fetchStats()
|
||||||
|
fetchSubscriptionData()
|
||||||
|
fetchRevenueData()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const fetchStats = async () => {
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<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 (
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-8">
|
<h1 className="text-3xl font-bold text-foreground">Dashboard</h1>
|
||||||
<h1 className="text-3xl font-bold text-foreground">
|
|
||||||
Dashboard
|
|
||||||
</h1>
|
|
||||||
<p className="mt-2 text-muted-foreground">
|
<p className="mt-2 text-muted-foreground">
|
||||||
Selamat datang di panel admin
|
Selamat datang di panel admin - Overview performa aplikasi
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Grid */}
|
{/* Stats Grid */}
|
||||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4 mb-8">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
{statCards.map((stat) => (
|
<Card>
|
||||||
<div
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
key={stat.name}
|
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
|
||||||
className="bg-card rounded-lg shadow p-6 border border-border"
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
>
|
</CardHeader>
|
||||||
<div className="flex items-center">
|
<CardContent>
|
||||||
<div className={`p-3 rounded-lg ${stat.bgColor}`}>
|
<div className="text-2xl font-bold">{stats?.totalUsers || 0}</div>
|
||||||
<stat.icon className={`h-6 w-6 ${stat.color}`} />
|
<p className="text-xs text-muted-foreground flex items-center mt-1">
|
||||||
</div>
|
<ArrowUpRight className="h-3 w-3 text-green-600 mr-1" />
|
||||||
<div className="ml-4">
|
<span className="text-green-600">+{stats?.userGrowth || 0}%</span>
|
||||||
<p className="text-sm font-medium text-muted-foreground">
|
<span className="ml-1">dari bulan lalu</span>
|
||||||
{stat.name}
|
|
||||||
</p>
|
</p>
|
||||||
<p className="text-2xl font-bold text-foreground">
|
</CardContent>
|
||||||
{stat.value}
|
</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>
|
</p>
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
|
||||||
))}
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Quick Actions */}
|
{/* Charts Row */}
|
||||||
<div className="bg-card rounded-lg shadow border border-border p-6">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<h2 className="text-lg font-semibold text-foreground mb-4">
|
{/* Revenue Chart */}
|
||||||
Quick Actions
|
<Card>
|
||||||
</h2>
|
<CardHeader>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<CardTitle>Revenue Overview</CardTitle>
|
||||||
<a
|
<CardDescription>Pendapatan 6 bulan terakhir</CardDescription>
|
||||||
href="/admin/plans"
|
</CardHeader>
|
||||||
className="flex items-center p-4 border border-border rounded-lg hover:bg-accent transition-colors"
|
<CardContent>
|
||||||
>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<CreditCard className="h-5 w-5 text-primary mr-3" />
|
<LineChart data={revenueData}>
|
||||||
<span className="text-sm font-medium text-foreground">
|
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||||
Kelola Plans
|
<XAxis dataKey="month" className="text-xs" />
|
||||||
</span>
|
<YAxis className="text-xs" />
|
||||||
</a>
|
<Tooltip
|
||||||
<a
|
contentStyle={{
|
||||||
href="/admin/payments"
|
backgroundColor: 'hsl(var(--card))',
|
||||||
className="flex items-center p-4 border border-border rounded-lg hover:bg-accent transition-colors"
|
border: '1px solid hsl(var(--border))',
|
||||||
>
|
borderRadius: '8px'
|
||||||
<DollarSign className="h-5 w-5 text-primary mr-3" />
|
}}
|
||||||
<span className="text-sm font-medium text-foreground">
|
formatter={(value: number) => formatLargeNumber(value, 'IDR')}
|
||||||
Verifikasi Pembayaran
|
/>
|
||||||
</span>
|
<Line
|
||||||
</a>
|
type="monotone"
|
||||||
<a
|
dataKey="revenue"
|
||||||
href="/admin/users"
|
stroke="hsl(var(--primary))"
|
||||||
className="flex items-center p-4 border border-border rounded-lg hover:bg-accent transition-colors"
|
strokeWidth={2}
|
||||||
>
|
dot={{ fill: 'hsl(var(--primary))' }}
|
||||||
<Users className="h-5 w-5 text-primary mr-3" />
|
/>
|
||||||
<span className="text-sm font-medium text-foreground">
|
</LineChart>
|
||||||
Kelola Users
|
</ResponsiveContainer>
|
||||||
</span>
|
</CardContent>
|
||||||
</a>
|
</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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,26 +1,268 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Plus, Edit, Trash2, Eye, EyeOff, GripVertical, CreditCard, Wallet, Building2 } from 'lucide-react'
|
import axios from 'axios'
|
||||||
import { api } from '@/lib/api'
|
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 {
|
interface PaymentMethod {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
displayName: string
|
||||||
type: 'bank_transfer' | 'ewallet' | 'qris' | 'other'
|
type: string
|
||||||
accountNumber?: string
|
provider: string
|
||||||
accountName?: string
|
accountNumber: string
|
||||||
|
accountName: string
|
||||||
|
logoUrl?: string
|
||||||
instructions?: string
|
instructions?: string
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
isVisible: boolean
|
sortOrder: number
|
||||||
displayOrder: number
|
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: 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() {
|
export function AdminPaymentMethods() {
|
||||||
const [methods, setMethods] = useState<PaymentMethod[]>([])
|
const [methods, setMethods] = useState<PaymentMethod[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showModal, setShowModal] = useState(false)
|
const [showModal, setShowModal] = useState(false)
|
||||||
const [editingMethod, setEditingMethod] = useState<PaymentMethod | null>(null)
|
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(() => {
|
useEffect(() => {
|
||||||
fetchMethods()
|
fetchMethods()
|
||||||
@@ -29,7 +271,10 @@ export function AdminPaymentMethods() {
|
|||||||
const fetchMethods = async () => {
|
const fetchMethods = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
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)
|
setMethods(response.data)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch payment methods:', error)
|
console.error('Failed to fetch payment methods:', error)
|
||||||
@@ -38,39 +283,121 @@ export function AdminPaymentMethods() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleVisibility = async (method: PaymentMethod) => {
|
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 {
|
try {
|
||||||
await api.patch(`/admin/payment-methods/${method.id}/visibility`, {
|
const token = localStorage.getItem('token')
|
||||||
isVisible: !method.isVisible,
|
await axios.post(
|
||||||
})
|
`${API_URL}/api/admin/payment-methods/reorder`,
|
||||||
fetchMethods()
|
{ methodIds: newMethods.map((m) => m.id) },
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } }
|
||||||
|
)
|
||||||
|
toast.success('Urutan metode pembayaran berhasil diubah')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to toggle visibility:', error)
|
console.error('Failed to reorder methods:', error)
|
||||||
alert('Gagal mengubah visibilitas')
|
toast.error('Gagal mengubah urutan metode pembayaran')
|
||||||
|
fetchMethods() // Revert on error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const toggleActive = async (method: PaymentMethod) => {
|
const toggleActive = async (method: PaymentMethod) => {
|
||||||
try {
|
try {
|
||||||
await api.patch(`/admin/payment-methods/${method.id}/status`, {
|
const token = localStorage.getItem('token')
|
||||||
isActive: !method.isActive,
|
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()
|
fetchMethods()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to toggle status:', error)
|
console.error('Failed to toggle status:', error)
|
||||||
alert('Gagal mengubah status')
|
toast.error('Gagal mengubah status')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const openDeleteDialog = (id: string) => {
|
||||||
if (!confirm('Yakin ingin menghapus metode pembayaran ini?')) return
|
setDeleteDialog({ open: true, methodId: id })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
try {
|
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()
|
fetchMethods()
|
||||||
|
setDeleteDialog({ open: false, methodId: '' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete payment method:', 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
|
Kelola metode pembayaran yang tersedia
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button onClick={() => handleOpenModal()}>
|
||||||
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"
|
|
||||||
>
|
|
||||||
<Plus className="h-5 w-5 mr-2" />
|
<Plus className="h-5 w-5 mr-2" />
|
||||||
Tambah Metode
|
Tambah Metode
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Payment Methods Grid */}
|
{/* 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">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{methods.map((method) => {
|
{methods.map((method) => (
|
||||||
const TypeIcon = getTypeIcon(method.type)
|
<SortableMethodCard
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={method.id}
|
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"
|
method={method}
|
||||||
>
|
onEdit={handleOpenModal}
|
||||||
{/* Content */}
|
onDelete={openDeleteDialog}
|
||||||
<div className="p-6">
|
onToggleActive={toggleActive}
|
||||||
{/* Header: Drag Icon + Title + Type Badge */}
|
getTypeIcon={getTypeIcon as (type: string) => React.ComponentType<{ className?: string }>}
|
||||||
<div className="flex items-start gap-3 mb-6">
|
getTypeLabel={getTypeLabel}
|
||||||
{/* Drag Handle */}
|
getTypeColor={getTypeColor}
|
||||||
<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>
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
{methods.length === 0 && (
|
{methods.length === 0 && (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-muted-foreground">Belum ada metode pembayaran</p>
|
<p className="text-muted-foreground">Belum ada metode pembayaran</p>
|
||||||
</div>
|
</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>
|
</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() {
|
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 (
|
return (
|
||||||
<div>
|
<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">
|
||||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-4">
|
<Clock className="h-3 w-3" />
|
||||||
Payment Verification
|
Pending
|
||||||
</h1>
|
</span>
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
)
|
||||||
Verify pending payments (Coming soon)
|
case 'verified':
|
||||||
</p>
|
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>
|
||||||
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,54 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { Plus, Edit, Trash2, Eye, EyeOff, GripVertical } from 'lucide-react'
|
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'
|
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'
|
||||||
|
|
||||||
@@ -14,7 +62,7 @@ interface Plan {
|
|||||||
durationType: string
|
durationType: string
|
||||||
durationDays: number | null
|
durationDays: number | null
|
||||||
trialDays: number
|
trialDays: number
|
||||||
features: any
|
features: string[] | Record<string, unknown>
|
||||||
badge: string | null
|
badge: string | null
|
||||||
badgeColor: string | null
|
badgeColor: string | null
|
||||||
sortOrder: number
|
sortOrder: number
|
||||||
@@ -26,123 +74,72 @@ interface Plan {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdminPlans() {
|
interface PlanFormData {
|
||||||
const [plans, setPlans] = useState<Plan[]>([])
|
name: string
|
||||||
const [loading, setLoading] = useState(true)
|
slug: string
|
||||||
const [showModal, setShowModal] = useState(false)
|
description: string
|
||||||
const [editingPlan, setEditingPlan] = useState<Plan | null>(null)
|
price: string
|
||||||
|
currency: string
|
||||||
useEffect(() => {
|
durationType: string
|
||||||
fetchPlans()
|
durationDays: number | null
|
||||||
}, [])
|
trialDays: number
|
||||||
|
features: string[]
|
||||||
const fetchPlans = async () => {
|
badge: string
|
||||||
try {
|
badgeColor: string
|
||||||
const token = localStorage.getItem('token')
|
isActive: boolean
|
||||||
const response = await axios.get(`${API_URL}/api/admin/plans`, {
|
isVisible: boolean
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
setPlans(response.data)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch plans:', error)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
function SortablePlanCard({ plan, onEdit, onDelete, onToggleVisibility, formatPrice }: {
|
||||||
if (!confirm('Are you sure you want to delete this plan?')) return
|
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 })
|
||||||
|
|
||||||
try {
|
const style = {
|
||||||
const token = localStorage.getItem('token')
|
transform: CSS.Transform.toString(transform),
|
||||||
await axios.delete(`${API_URL}/api/admin/plans/${id}`, {
|
transition,
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
opacity: isDragging ? 0.5 : 1,
|
||||||
})
|
|
||||||
fetchPlans()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to delete plan:', error)
|
|
||||||
alert('Failed to delete plan')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleVisibility = async (plan: Plan) => {
|
|
||||||
try {
|
|
||||||
const token = localStorage.getItem('token')
|
|
||||||
await axios.put(
|
|
||||||
`${API_URL}/api/admin/plans/${plan.id}`,
|
|
||||||
{ isVisible: !plan.isVisible },
|
|
||||||
{ headers: { Authorization: `Bearer ${token}` } }
|
|
||||||
)
|
|
||||||
fetchPlans()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to update plan:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatPrice = (price: string, currency: string) => {
|
|
||||||
const amount = parseInt(price)
|
|
||||||
if (amount === 0) return 'Free'
|
|
||||||
return new Intl.NumberFormat('id-ID', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: currency,
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
}).format(amount)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="text-muted-foreground">Memuat...</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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>
|
|
||||||
</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"
|
|
||||||
>
|
|
||||||
<Plus className="h-5 w-5 mr-2" />
|
|
||||||
Tambah Plan
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Plans Grid */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{plans.map((plan) => (
|
|
||||||
<div
|
<div
|
||||||
key={plan.id}
|
ref={setNodeRef}
|
||||||
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"
|
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 */}
|
{/* Content */}
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
{/* Header: Drag Icon + Title/Description + Badge */}
|
{/* Header: Drag Icon + Title + Badge */}
|
||||||
<div className="flex items-start gap-3 mb-6">
|
<div className="flex items-start gap-3 mb-6">
|
||||||
{/* Drag Handle */}
|
{/* Drag Handle */}
|
||||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity pt-1">
|
<div
|
||||||
<GripVertical className="h-5 w-5 mt-1 text-muted-foreground/50 cursor-move" />
|
{...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>
|
</div>
|
||||||
|
|
||||||
{/* Title & Description */}
|
{/* Title & Description */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{/* Plan Name + Badge */}
|
{/* Plan Name + Badge */}
|
||||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
<h3 className="text-2xl font-bold text-foreground">
|
<h3 className="text-2xl font-bold text-foreground">{plan.name}</h3>
|
||||||
{plan.name}
|
|
||||||
</h3>
|
|
||||||
{plan.badge && (
|
{plan.badge && (
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold shadow-sm ${
|
className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold shadow-sm ${
|
||||||
@@ -150,6 +147,10 @@ export function AdminPlans() {
|
|||||||
? 'bg-gradient-to-r from-blue-500 to-blue-600 text-white'
|
? 'bg-gradient-to-r from-blue-500 to-blue-600 text-white'
|
||||||
: plan.badgeColor === 'green'
|
: plan.badgeColor === 'green'
|
||||||
? 'bg-gradient-to-r from-green-500 to-green-600 text-white'
|
? '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'
|
: 'bg-muted text-muted-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -209,7 +210,9 @@ export function AdminPlans() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="h-2 w-2 rounded-full bg-gray-400" />
|
<div className="h-2 w-2 rounded-full bg-gray-400" />
|
||||||
<span className="text-sm font-semibold text-muted-foreground">Inactive</span>
|
<span className="text-sm font-semibold text-muted-foreground">
|
||||||
|
Inactive
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -219,53 +222,455 @@ export function AdminPlans() {
|
|||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleVisibility(plan)}
|
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 ${
|
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
|
plan.isVisible
|
||||||
? 'bg-primary/10 text-primary hover:bg-primary/20'
|
? 'bg-primary/10 text-primary hover:bg-primary/20'
|
||||||
: 'bg-muted text-muted-foreground hover:bg-muted/80'
|
: 'bg-muted text-muted-foreground hover:bg-muted/80'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{plan.isVisible ? (
|
{plan.isVisible ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
|
||||||
<>
|
<span>{plan.isVisible ? 'Visible' : 'Hidden'}</span>
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
<span>Visible</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<EyeOff className="h-4 w-4" />
|
|
||||||
<span>Hidden</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => onEdit(plan)}
|
||||||
setEditingPlan(plan)
|
|
||||||
setShowModal(true)
|
|
||||||
}}
|
|
||||||
className="p-2.5 rounded-lg bg-primary/10 text-primary hover:bg-primary hover:text-primary-foreground transition-all"
|
className="p-2.5 rounded-lg bg-primary/10 text-primary hover:bg-primary hover:text-primary-foreground transition-all"
|
||||||
title="Edit Plan"
|
title="Edit"
|
||||||
>
|
>
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(plan.id)}
|
onClick={() => onDelete(plan.id)}
|
||||||
className="p-2.5 rounded-lg bg-destructive/10 text-destructive hover:bg-destructive hover:text-destructive-foreground transition-all"
|
className="p-2.5 rounded-lg bg-destructive/10 text-destructive hover:bg-destructive hover:text-destructive-foreground transition-all"
|
||||||
title="Hapus Plan"
|
title="Hapus"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
</div>
|
}
|
||||||
|
|
||||||
{plans.length === 0 && (
|
export function AdminPlans() {
|
||||||
<div className="text-center py-12">
|
const [plans, setPlans] = useState<Plan[]>([])
|
||||||
<p className="text-muted-foreground">Tidak ada plan</p>
|
const [loading, setLoading] = useState(true)
|
||||||
</div>
|
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()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchPlans = async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
const response = await axios.get(`${API_URL}/api/admin/plans`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
setPlans(response.data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch plans:', error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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')
|
||||||
|
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)
|
||||||
|
toast.error('Gagal menghapus plan')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleVisibility = async (plan: Plan) => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
await axios.put(
|
||||||
|
`${API_URL}/api/admin/plans/${plan.id}`,
|
||||||
|
{ 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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatPrice = (price: string, currency: string) => {
|
||||||
|
const amount = parseInt(price)
|
||||||
|
if (amount === 0) return 'Free'
|
||||||
|
return new Intl.NumberFormat('id-ID', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: currency,
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
}).format(amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-muted-foreground">Memuat...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => handleOpenModal()}>
|
||||||
|
<Plus className="h-5 w-5 mr-2" />
|
||||||
|
Tambah Plan
|
||||||
|
</Button>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<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 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>
|
</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() {
|
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 (
|
return (
|
||||||
<div>
|
<div className="flex items-center justify-center h-64">
|
||||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-4">
|
<div className="text-muted-foreground">Memuat...</div>
|
||||||
App Settings
|
</div>
|
||||||
</h1>
|
)
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
}
|
||||||
Manage app configuration (Coming soon)
|
|
||||||
</p>
|
return (
|
||||||
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { Search, UserX, UserCheck, Crown } from 'lucide-react'
|
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'
|
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 [users, setUsers] = useState<User[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [search, setSearch] = useState('')
|
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(() => {
|
useEffect(() => {
|
||||||
fetchUsers()
|
fetchUsers()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [search])
|
}, [search])
|
||||||
|
|
||||||
const fetchUsers = async () => {
|
const fetchUsers = async () => {
|
||||||
@@ -43,47 +60,55 @@ export function AdminUsers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSuspend = async (userId: string, suspend: boolean) => {
|
const openSuspendDialog = (userId: string, suspend: boolean) => {
|
||||||
const reason = suspend
|
setSuspendDialog({ open: true, userId, suspend })
|
||||||
? prompt('Enter suspension reason:')
|
setSuspendReason('')
|
||||||
: null
|
}
|
||||||
|
|
||||||
if (suspend && !reason) return
|
const handleSuspend = async () => {
|
||||||
|
if (suspendDialog.suspend && !suspendReason) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
const endpoint = suspend ? 'suspend' : 'unsuspend'
|
const endpoint = suspendDialog.suspend ? 'suspend' : 'unsuspend'
|
||||||
await axios.post(
|
await axios.post(
|
||||||
`${API_URL}/api/admin/users/${userId}/${endpoint}`,
|
`${API_URL}/api/admin/users/${suspendDialog.userId}/${endpoint}`,
|
||||||
suspend ? { reason } : {},
|
suspendDialog.suspend ? { reason: suspendReason } : {},
|
||||||
{ headers: { Authorization: `Bearer ${token}` } }
|
{ headers: { Authorization: `Bearer ${token}` } }
|
||||||
)
|
)
|
||||||
|
toast.success(suspendDialog.suspend ? 'User berhasil disuspend' : 'User berhasil diaktifkan kembali')
|
||||||
fetchUsers()
|
fetchUsers()
|
||||||
|
setSuspendDialog({ open: false, userId: '', suspend: false })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update user:', error)
|
console.error('Failed to update user:', error)
|
||||||
alert('Failed to update user')
|
toast.error('Gagal mengupdate user')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleGrantPro = async (userId: string) => {
|
const openGrantProDialog = (userId: string) => {
|
||||||
const days = prompt('Enter duration in days (e.g., 30 for monthly):')
|
setGrantProDialog({ open: true, userId })
|
||||||
if (!days || isNaN(parseInt(days))) return
|
setProDays('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGrantPro = async () => {
|
||||||
|
if (!proDays || isNaN(parseInt(proDays))) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
await axios.post(
|
await axios.post(
|
||||||
`${API_URL}/api/admin/users/${userId}/grant-pro`,
|
`${API_URL}/api/admin/users/${grantProDialog.userId}/grant-pro`,
|
||||||
{
|
{
|
||||||
planSlug: 'pro-monthly',
|
planSlug: 'pro-monthly',
|
||||||
durationDays: parseInt(days),
|
durationDays: parseInt(proDays),
|
||||||
},
|
},
|
||||||
{ headers: { Authorization: `Bearer ${token}` } }
|
{ headers: { Authorization: `Bearer ${token}` } }
|
||||||
)
|
)
|
||||||
alert('Pro access granted successfully!')
|
toast.success('Akses Pro berhasil diberikan!')
|
||||||
fetchUsers()
|
fetchUsers()
|
||||||
|
setGrantProDialog({ open: false, userId: '' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to grant pro access:', 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="mb-6">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-muted-foreground" />
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by email or name..."
|
placeholder="Search by email or name..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -194,7 +219,7 @@ export function AdminUsers() {
|
|||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
|
||||||
{user.suspendedAt ? (
|
{user.suspendedAt ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSuspend(user.id, false)}
|
onClick={() => openSuspendDialog(user.id, false)}
|
||||||
className="text-primary hover:text-primary/80"
|
className="text-primary hover:text-primary/80"
|
||||||
title="Unsuspend"
|
title="Unsuspend"
|
||||||
>
|
>
|
||||||
@@ -202,7 +227,7 @@ export function AdminUsers() {
|
|||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSuspend(user.id, true)}
|
onClick={() => openSuspendDialog(user.id, true)}
|
||||||
className="text-destructive hover:text-destructive/80"
|
className="text-destructive hover:text-destructive/80"
|
||||||
title="Suspend"
|
title="Suspend"
|
||||||
>
|
>
|
||||||
@@ -210,7 +235,7 @@ export function AdminUsers() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleGrantPro(user.id)}
|
onClick={() => openGrantProDialog(user.id)}
|
||||||
className="text-primary hover:text-primary/80"
|
className="text-primary hover:text-primary/80"
|
||||||
title="Grant Pro Access"
|
title="Grant Pro Access"
|
||||||
>
|
>
|
||||||
@@ -229,6 +254,69 @@ export function AdminUsers() {
|
|||||||
<p className="text-muted-foreground">Tidak ada user</p>
|
<p className="text-muted-foreground">Tidak ada user</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -167,18 +168,17 @@ export function TransactionDialog({ open, onOpenChange, transaction, onSuccess }
|
|||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
await axios.put(`${API}/wallets/${walletId}/transactions/${transaction.id}`, data)
|
await axios.put(`${API}/wallets/${walletId}/transactions/${transaction.id}`, data)
|
||||||
|
toast.success('Transaksi berhasil diupdate')
|
||||||
} else {
|
} else {
|
||||||
await axios.post(`${API}/wallets/${walletId}/transactions`, data)
|
await axios.post(`${API}/wallets/${walletId}/transactions`, data)
|
||||||
|
toast.success('Transaksi berhasil ditambahkan')
|
||||||
}
|
}
|
||||||
|
|
||||||
onSuccess()
|
onSuccess()
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
|
} catch (error) {
|
||||||
// Reset form
|
console.error("Failed to save transaction:", error)
|
||||||
resetForm()
|
toast.error('Gagal menyimpan transaksi')
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Failed to save transaction'
|
|
||||||
setError(message)
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -90,23 +91,17 @@ export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDi
|
|||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
await axios.put(`${API}/wallets/${wallet.id}`, data)
|
await axios.put(`${API}/wallets/${wallet.id}`, data)
|
||||||
|
toast.success('Wallet berhasil diupdate')
|
||||||
} else {
|
} else {
|
||||||
await axios.post(`${API}/wallets`, data)
|
await axios.post(`${API}/wallets`, data)
|
||||||
|
toast.success('Wallet berhasil ditambahkan')
|
||||||
}
|
}
|
||||||
|
|
||||||
onSuccess()
|
onSuccess()
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
|
} catch (error) {
|
||||||
// Reset form
|
console.error("Failed to save wallet:", error)
|
||||||
setName("")
|
toast.error('Gagal menyimpan wallet')
|
||||||
setKind("money")
|
|
||||||
setCurrency("IDR")
|
|
||||||
setUnit("")
|
|
||||||
setInitialAmount("")
|
|
||||||
setPricePerUnit("")
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Failed to save wallet'
|
|
||||||
setError(message)
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
import { useAuth } from "@/contexts/AuthContext"
|
import { useAuth } from "@/contexts/AuthContext"
|
||||||
import { getAvatarUrl } from "@/lib/utils"
|
import { getAvatarUrl } from "@/lib/utils"
|
||||||
@@ -44,6 +45,7 @@ interface AppSidebarProps {
|
|||||||
|
|
||||||
export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
|
export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
|
||||||
const { user, logout } = useAuth()
|
const { user, logout } = useAuth()
|
||||||
|
const { isMobile, setOpenMobile } = useSidebar()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar>
|
<Sidebar>
|
||||||
@@ -63,7 +65,10 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
|
|||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
asChild
|
asChild
|
||||||
isActive={isActive}
|
isActive={isActive}
|
||||||
onClick={() => onNavigate(item.url)}
|
onClick={() => {
|
||||||
|
onNavigate(item.url)
|
||||||
|
if (isMobile) setOpenMobile(false)
|
||||||
|
}}
|
||||||
className={`${
|
className={`${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-primary/10 text-primary hover:bg-primary/10 hover:text-primary'
|
? 'bg-primary/10 text-primary hover:bg-primary/10 hover:text-primary'
|
||||||
|
|||||||
@@ -38,9 +38,13 @@ export function Login() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// Login successful, redirect to dashboard
|
// Login successful, redirect based on role
|
||||||
|
if (result.user?.role === 'admin') {
|
||||||
|
navigate('/admin')
|
||||||
|
} else {
|
||||||
navigate('/')
|
navigate('/')
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = err as { response?: { data?: { message?: string } } }
|
const error = err as { response?: { data?: { message?: string } } }
|
||||||
setError(error.response?.data?.message || 'Login failed. Please check your credentials.')
|
setError(error.response?.data?.message || 'Login failed. Please check your credentials.')
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useMemo } from "react"
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Badge } from "@/components/ui/badge"
|
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 { ChartContainer, ChartTooltip } from "@/components/ui/chart"
|
||||||
import { Bar, XAxis, YAxis, ResponsiveContainer, PieChart as RechartsPieChart, Pie, Cell, Line, ComposedChart } from "recharts"
|
import { Bar, XAxis, YAxis, ResponsiveContainer, PieChart as RechartsPieChart, Pie, Cell, Line, ComposedChart } from "recharts"
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
|
import axios from "axios"
|
||||||
|
import { toast } from "sonner"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -24,7 +26,6 @@ import {
|
|||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useAuth } from "@/contexts/AuthContext"
|
import { useAuth } from "@/contexts/AuthContext"
|
||||||
import { getAvatarUrl } from "@/lib/utils"
|
import { getAvatarUrl } from "@/lib/utils"
|
||||||
import axios from "axios"
|
|
||||||
|
|
||||||
const API = "/api"
|
const API = "/api"
|
||||||
|
|
||||||
@@ -170,6 +171,7 @@ export function Profile() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await axios.put(`${API}/users/profile`, { name: editedName })
|
await axios.put(`${API}/users/profile`, { name: editedName })
|
||||||
|
toast.success('Nama berhasil diupdate')
|
||||||
setNameSuccess("Name updated successfully!")
|
setNameSuccess("Name updated successfully!")
|
||||||
setIsEditingName(false)
|
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()
|
window.location.reload()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as { response?: { data?: { message?: string } } }
|
const err = error as { response?: { data?: { message?: string } } }
|
||||||
@@ -236,7 +239,8 @@ export function Profile() {
|
|||||||
data: { password: deletePassword }
|
data: { password: deletePassword }
|
||||||
})
|
})
|
||||||
|
|
||||||
// Logout and redirect
|
toast.success('Akun berhasil dihapus')
|
||||||
|
// Logout and redirect to login
|
||||||
localStorage.removeItem('token')
|
localStorage.removeItem('token')
|
||||||
window.location.href = '/auth/login'
|
window.location.href = '/auth/login'
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -267,6 +271,7 @@ export function Profile() {
|
|||||||
|
|
||||||
// Update phone
|
// Update phone
|
||||||
await axios.put(`${API}/users/profile`, { phone })
|
await axios.put(`${API}/users/profile`, { phone })
|
||||||
|
toast.success('Nomor telepon berhasil diupdate')
|
||||||
setPhoneSuccess("Phone number updated successfully!")
|
setPhoneSuccess("Phone number updated successfully!")
|
||||||
|
|
||||||
// Reload OTP status
|
// Reload OTP status
|
||||||
@@ -283,6 +288,7 @@ export function Profile() {
|
|||||||
try {
|
try {
|
||||||
setEmailOtpLoading(true)
|
setEmailOtpLoading(true)
|
||||||
await axios.post(`${API}/otp/email/send`)
|
await axios.post(`${API}/otp/email/send`)
|
||||||
|
toast.success('Kode OTP telah dikirim ke email')
|
||||||
setEmailOtpSent(true)
|
setEmailOtpSent(true)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to send email OTP:', error)
|
console.error('Failed to send email OTP:', error)
|
||||||
@@ -295,6 +301,7 @@ export function Profile() {
|
|||||||
try {
|
try {
|
||||||
setEmailOtpLoading(true)
|
setEmailOtpLoading(true)
|
||||||
await axios.post(`${API}/otp/email/verify`, { code: emailOtpCode })
|
await axios.post(`${API}/otp/email/verify`, { code: emailOtpCode })
|
||||||
|
toast.success('Email OTP berhasil diaktifkan')
|
||||||
await loadOtpStatus()
|
await loadOtpStatus()
|
||||||
setEmailOtpCode("")
|
setEmailOtpCode("")
|
||||||
setEmailOtpSent(false)
|
setEmailOtpSent(false)
|
||||||
@@ -309,6 +316,7 @@ export function Profile() {
|
|||||||
try {
|
try {
|
||||||
setEmailOtpLoading(true)
|
setEmailOtpLoading(true)
|
||||||
await axios.post(`${API}/otp/email/disable`)
|
await axios.post(`${API}/otp/email/disable`)
|
||||||
|
toast.success('Email OTP berhasil dinonaktifkan')
|
||||||
await loadOtpStatus()
|
await loadOtpStatus()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to disable email OTP:', error)
|
console.error('Failed to disable email OTP:', error)
|
||||||
@@ -321,6 +329,7 @@ export function Profile() {
|
|||||||
try {
|
try {
|
||||||
setWhatsappOtpLoading(true)
|
setWhatsappOtpLoading(true)
|
||||||
await axios.post(`${API}/otp/whatsapp/send`, { mode: 'test' })
|
await axios.post(`${API}/otp/whatsapp/send`, { mode: 'test' })
|
||||||
|
toast.success('Kode OTP telah dikirim ke WhatsApp')
|
||||||
setWhatsappOtpSent(true)
|
setWhatsappOtpSent(true)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to send WhatsApp OTP:', error)
|
console.error('Failed to send WhatsApp OTP:', error)
|
||||||
@@ -333,6 +342,7 @@ export function Profile() {
|
|||||||
try {
|
try {
|
||||||
setWhatsappOtpLoading(true)
|
setWhatsappOtpLoading(true)
|
||||||
await axios.post(`${API}/otp/whatsapp/verify`, { code: whatsappOtpCode })
|
await axios.post(`${API}/otp/whatsapp/verify`, { code: whatsappOtpCode })
|
||||||
|
toast.success('WhatsApp OTP berhasil diaktifkan')
|
||||||
await loadOtpStatus()
|
await loadOtpStatus()
|
||||||
setWhatsappOtpCode("")
|
setWhatsappOtpCode("")
|
||||||
setWhatsappOtpSent(false)
|
setWhatsappOtpSent(false)
|
||||||
@@ -347,6 +357,7 @@ export function Profile() {
|
|||||||
try {
|
try {
|
||||||
setWhatsappOtpLoading(true)
|
setWhatsappOtpLoading(true)
|
||||||
await axios.post(`${API}/otp/whatsapp/disable`)
|
await axios.post(`${API}/otp/whatsapp/disable`)
|
||||||
|
toast.success('WhatsApp OTP berhasil dinonaktifkan')
|
||||||
await loadOtpStatus()
|
await loadOtpStatus()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to disable WhatsApp OTP:', error)
|
console.error('Failed to disable WhatsApp OTP:', error)
|
||||||
@@ -376,6 +387,7 @@ export function Profile() {
|
|||||||
try {
|
try {
|
||||||
setTotpLoading(true)
|
setTotpLoading(true)
|
||||||
await axios.post(`${API}/otp/totp/verify`, { code: totpCode })
|
await axios.post(`${API}/otp/totp/verify`, { code: totpCode })
|
||||||
|
toast.success('Authenticator App berhasil diaktifkan')
|
||||||
await loadOtpStatus()
|
await loadOtpStatus()
|
||||||
setTotpCode("")
|
setTotpCode("")
|
||||||
setShowTotpSetup(false)
|
setShowTotpSetup(false)
|
||||||
@@ -390,6 +402,7 @@ export function Profile() {
|
|||||||
try {
|
try {
|
||||||
setTotpLoading(true)
|
setTotpLoading(true)
|
||||||
await axios.post(`${API}/otp/totp/disable`)
|
await axios.post(`${API}/otp/totp/disable`)
|
||||||
|
toast.success('Authenticator App berhasil dinonaktifkan')
|
||||||
await loadOtpStatus()
|
await loadOtpStatus()
|
||||||
setShowTotpSetup(false)
|
setShowTotpSetup(false)
|
||||||
// Clear QR code and secret when disabling
|
// Clear QR code and secret when disabling
|
||||||
@@ -453,7 +466,8 @@ export function Profile() {
|
|||||||
isSettingPassword: true // Flag to tell backend this is initial password
|
isSettingPassword: true // Flag to tell backend this is initial password
|
||||||
})
|
})
|
||||||
setPasswordSuccess("Password set successfully! You can now login with email/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)
|
setTimeout(() => window.location.reload(), 2000)
|
||||||
} else {
|
} else {
|
||||||
// Change password for user with existing password
|
// Change password for user with existing password
|
||||||
@@ -461,6 +475,7 @@ export function Profile() {
|
|||||||
currentPassword,
|
currentPassword,
|
||||||
newPassword
|
newPassword
|
||||||
})
|
})
|
||||||
|
toast.success('Password berhasil diubah')
|
||||||
setPasswordSuccess("Password changed successfully!")
|
setPasswordSuccess("Password changed successfully!")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useMemo } from "react"
|
import { useState, useEffect, useMemo } from "react"
|
||||||
import { useSearchParams } from "react-router-dom"
|
import { useSearchParams } from "react-router-dom"
|
||||||
|
import { toast } from "sonner"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -19,7 +20,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} 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 { Label } from "@/components/ui/label"
|
||||||
import axios from "axios"
|
import axios from "axios"
|
||||||
import { formatCurrency } from "@/constants/currencies"
|
import { formatCurrency } from "@/constants/currencies"
|
||||||
@@ -145,10 +146,11 @@ export function Transactions() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.delete(`${API}/wallets/${walletId}/transactions/${transactionId}`)
|
await axios.delete(`${API}/wallets/${walletId}/transactions/${transactionId}`)
|
||||||
|
toast.success('Transaksi berhasil dihapus')
|
||||||
await loadData()
|
await loadData()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete transaction:', 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 { Plus, Search, Edit, Trash2, Wallet, Filter, X } from "lucide-react"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import axios from "axios"
|
import axios from "axios"
|
||||||
|
import { toast } from "sonner"
|
||||||
import { WalletDialog } from "@/components/dialogs/WalletDialog"
|
import { WalletDialog } from "@/components/dialogs/WalletDialog"
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -76,10 +77,11 @@ export function Wallets() {
|
|||||||
const deleteWallet = async (id: string) => {
|
const deleteWallet = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await axios.delete(`${API}/wallets/${id}`)
|
await axios.delete(`${API}/wallets/${id}`)
|
||||||
|
toast.success('Wallet berhasil dihapus')
|
||||||
await loadWallets()
|
await loadWallets()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete wallet:', 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