feat: admin invoice/credit/pricing UI overhaul with auth token injection
Some checks failed
build-and-release / build (push) Failing after 0s
Some checks failed
build-and-release / build (push) Failing after 0s
- Inject Bearer token from sessionStorage into all API requests - Add typed API helpers for invoices, credits, discounts, ledger, orders, pricing, tax - Overhaul invoice, credit, pricing, tax admin routes with full CRUD UI - Remove committed log files Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2f042bbc15
commit
69079caa1d
20 changed files with 1986 additions and 3211 deletions
|
|
@ -1,3 +0,0 @@
|
|||
[ 32561ms] [VERBOSE] [DOM] Password field is not contained in a form: (More info: https://goo.gl/9p2vKq) %o @ http://127.0.0.1:3000/login:0
|
||||
[ 93139ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://127.0.0.1:3000/api/gateway/users/auth/internal/login:0
|
||||
[ 93459ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://127.0.0.1:3000/api/gateway/auth/internal/login:0
|
||||
|
|
@ -1 +0,0 @@
|
|||
[ 1124ms] [VERBOSE] [DOM] Password field is not contained in a form: (More info: https://goo.gl/9p2vKq) %o @ http://127.0.0.1:3000/login?from=%2Fadmin%2Fexternal-dashboard-management:0
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
[ 627ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://127.0.0.1:3000/api/gateway/api/auth/session:0
|
||||
[ 742ms] [VERBOSE] [DOM] Password field is not contained in a form: (More info: https://goo.gl/9p2vKq) %o @ http://127.0.0.1:3000/login?from=%2Fadmin%2Fexternal-dashboard-management:0
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,11 +0,0 @@
|
|||
|
||||
> dev
|
||||
> vinxi dev
|
||||
|
||||
vinxi v0.5.11
|
||||
vinxi found vinxi app config in vite.config.ts
|
||||
vinxi starting dev server
|
||||
|
||||
➜ Local: http://localhost:3000/
|
||||
➜ Network: use --host to expose
|
||||
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
|
||||
> dev
|
||||
> vinxi dev
|
||||
|
||||
vinxi v0.5.11
|
||||
|
|
@ -1 +0,0 @@
|
|||
Listening on http://[::]:3000
|
||||
82
admin.log
82
admin.log
|
|
@ -1,82 +0,0 @@
|
|||
|
||||
> dev
|
||||
> vinxi dev
|
||||
|
||||
vinxi v0.5.11
|
||||
vinxi found vinxi app config in vite.config.ts
|
||||
vinxi starting dev server
|
||||
|
||||
➜ Local: http://localhost:3000/
|
||||
➜ Network: use --host to expose
|
||||
|
||||
1:30:54 PM [vite] (ssr) page reload vinxi/routes
|
||||
1:30:54 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
|
||||
1:30:54 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/employees/index.tsx?pick=default&pick=$css
|
||||
1:31:04 PM [vite] (ssr) page reload vinxi/routes
|
||||
1:31:04 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
|
||||
1:31:04 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/employees/index.tsx?pick=default&pick=$css
|
||||
1:31:15 PM [vite] (ssr) page reload vinxi/routes
|
||||
1:31:15 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
|
||||
1:31:16 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/employees/index.tsx?pick=default&pick=$css
|
||||
1:31:47 PM [vite] (ssr) page reload vinxi/routes
|
||||
1:31:47 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
|
||||
1:31:47 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/employees/index.tsx?pick=default&pick=$css
|
||||
1:32:06 PM [vite] (ssr) page reload vinxi/routes
|
||||
1:32:06 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
|
||||
1:32:06 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/employees/index.tsx?pick=default&pick=$css
|
||||
1:32:13 PM [vite] (ssr) page reload vinxi/routes
|
||||
1:32:13 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
|
||||
1:32:13 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/employees/index.tsx?pick=default&pick=$css
|
||||
1:39:55 PM [vite] (ssr) page reload vinxi/routes
|
||||
1:39:55 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
|
||||
1:39:55 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/external-roles.tsx?pick=default&pick=$css
|
||||
1:40:16 PM [vite] (ssr) page reload vinxi/routes
|
||||
1:40:16 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
|
||||
1:40:16 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/external-roles.tsx?pick=default&pick=$css
|
||||
1:42:29 PM [vite] (ssr) page reload vinxi/routes
|
||||
1:42:29 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
|
||||
1:42:29 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/external-roles.tsx?pick=default&pick=$css
|
||||
1:43:02 PM [vite] (ssr) page reload vinxi/routes
|
||||
1:43:02 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
|
||||
1:43:02 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/external-roles.tsx?pick=default&pick=$css
|
||||
1:43:06 PM [vite] (ssr) page reload vinxi/routes
|
||||
1:43:06 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
|
||||
1:43:06 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/external-roles.tsx?pick=default&pick=$css
|
||||
1:46:00 PM [vite] (ssr) page reload vinxi/routes
|
||||
1:46:00 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
|
||||
1:46:00 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/external-roles.tsx?pick=default&pick=$css
|
||||
1:47:24 PM [vite] (ssr) page reload vinxi/routes
|
||||
1:47:24 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
|
||||
1:47:24 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/external-roles.tsx?pick=default&pick=$css
|
||||
3:43:28 PM [vite] (ssr) page reload vinxi/routes
|
||||
3:43:28 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
|
||||
3:43:28 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/external-roles.tsx?pick=default&pick=$css
|
||||
3:43:29 PM [vite] (client) hmr invalidate /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/external-roles.tsx?pick=default&pick=$css
|
||||
3:43:29 PM [vite] (client) page reload src/routes/admin/external-roles.tsx
|
||||
3:49:14 PM [vite] (ssr) page reload vinxi/routes
|
||||
3:49:14 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
|
||||
3:49:14 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/external-roles.tsx?pick=default&pick=$css
|
||||
3:50:11 PM [vite] (ssr) page reload vinxi/routes
|
||||
3:50:11 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
|
||||
3:50:11 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/external-roles.tsx?pick=default&pick=$css
|
||||
3:50:36 PM [vite] (ssr) page reload vinxi/routes
|
||||
3:50:36 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
|
||||
3:50:36 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/external-roles.tsx?pick=default&pick=$css
|
||||
5:15:08 PM [vite] (ssr) page reload vinxi/routes
|
||||
5:15:08 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
|
||||
5:15:09 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/external-roles.tsx?pick=default&pick=$css
|
||||
5:15:14 PM [vite] (ssr) page reload vinxi/routes
|
||||
5:15:14 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
|
||||
5:15:15 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/external-roles.tsx?pick=default&pick=$css
|
||||
5:15:21 PM [vite] (ssr) page reload vinxi/routes
|
||||
5:15:21 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
|
||||
5:15:21 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/external-roles.tsx?pick=default&pick=$css
|
||||
5:15:38 PM [vite] (ssr) page reload vinxi/routes
|
||||
5:15:38 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
|
||||
5:15:38 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/external-roles.tsx?pick=default&pick=$css
|
||||
5:31:17 PM [vite] (ssr) page reload vinxi/routes
|
||||
5:31:17 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
|
||||
5:31:17 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/external-roles.tsx?pick=default&pick=$css
|
||||
5:43:15 PM [vite] (ssr) page reload vinxi/routes
|
||||
5:43:15 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
|
||||
5:43:15 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/external-roles.tsx?pick=default&pick=$css
|
||||
|
|
@ -519,19 +519,6 @@ export default function AdminShell(props: { children: JSX.Element }) {
|
|||
const interval = setInterval(fetchUnreadCount, 30000);
|
||||
onCleanup(() => clearInterval(interval));
|
||||
|
||||
const isPreview =
|
||||
searchParams._preview === "1" ||
|
||||
(typeof sessionStorage !== "undefined" &&
|
||||
sessionStorage.getItem("nxtgauge_admin_preview") === "1");
|
||||
|
||||
if (isPreview) {
|
||||
if (typeof sessionStorage !== "undefined")
|
||||
sessionStorage.setItem("nxtgauge_admin_preview", "1");
|
||||
setAdminSession();
|
||||
setCheckedSession(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const verify = async () => {
|
||||
if (!hasAdminSession()) {
|
||||
navigate(`/login?from=${encodeURIComponent(location.pathname + location.search)}`, {
|
||||
|
|
|
|||
308
src/lib/api.ts
308
src/lib/api.ts
|
|
@ -2,6 +2,7 @@ type ApiRequestOptions = {
|
|||
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
body?: unknown;
|
||||
headers?: HeadersInit;
|
||||
raw?: boolean;
|
||||
};
|
||||
|
||||
type ApiResponse<T = any> = {
|
||||
|
|
@ -12,9 +13,21 @@ type ApiResponse<T = any> = {
|
|||
|
||||
const API_BASE = "";
|
||||
|
||||
function getAdminToken(): string {
|
||||
if (typeof sessionStorage === "undefined") return "";
|
||||
return sessionStorage.getItem("nxtgauge_admin_access_token") || "";
|
||||
}
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = getAdminToken();
|
||||
if (!token) return {};
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
async function request<T = any>(path: string, options: ApiRequestOptions = {}): Promise<ApiResponse<T>> {
|
||||
const headers: HeadersInit = {
|
||||
Accept: "application/json",
|
||||
...authHeaders(),
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
|
|
@ -30,11 +43,23 @@ async function request<T = any>(path: string, options: ApiRequestOptions = {}):
|
|||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}${path}`, init);
|
||||
|
||||
if (options.raw) {
|
||||
return { data: res as any, status: res.status, headers: res.headers };
|
||||
}
|
||||
|
||||
const raw = await res.text();
|
||||
const data = raw ? JSON.parse(raw) : null;
|
||||
let data: any = null;
|
||||
try {
|
||||
data = raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
data = raw;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const message = (data && (data.message || data.error)) || `Request failed (${res.status})`;
|
||||
const message =
|
||||
(data && typeof data === "object" && (data.message || data.error)) ||
|
||||
`Request failed (${res.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
|
|
@ -48,3 +73,282 @@ export const api = {
|
|||
patch: <T = any>(path: string, body?: unknown, headers?: HeadersInit) => request<T>(path, { method: "PATCH", body, headers }),
|
||||
delete: <T = any>(path: string, headers?: HeadersInit) => request<T>(path, { method: "DELETE", headers }),
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Invoices
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type InvoiceStatus = "DRAFT" | "ISSUED" | "PAID" | "VOID" | "REFUNDED";
|
||||
export type InvoiceType = "TAX_INVOICE" | "BILL_OF_SUPPLY" | "CREDIT_NOTE" | "DEBIT_NOTE";
|
||||
|
||||
export interface AdminInvoiceSummary {
|
||||
id: string;
|
||||
invoice_number: string;
|
||||
payment_id: string;
|
||||
user_id: string;
|
||||
user_email: string | null;
|
||||
status: InvoiceStatus;
|
||||
currency: string;
|
||||
invoice_type: InvoiceType;
|
||||
subtotal: number;
|
||||
discount_amount: number;
|
||||
cgst_amount: number;
|
||||
sgst_amount: number;
|
||||
igst_amount: number;
|
||||
total: number;
|
||||
customer_name: string | null;
|
||||
customer_gstin: string | null;
|
||||
pdf_object_key: string | null;
|
||||
issued_at: string;
|
||||
paid_at: string | null;
|
||||
}
|
||||
|
||||
export interface AdminInvoiceLine {
|
||||
line_number: number;
|
||||
description: string;
|
||||
hsn_sac_code: string | null;
|
||||
quantity: number;
|
||||
unit_price_paise: number;
|
||||
tax_rate_percent: number;
|
||||
}
|
||||
|
||||
export interface AdminInvoiceDetail {
|
||||
invoice: AdminInvoiceSummary & {
|
||||
cgst_rate: number;
|
||||
sgst_rate: number;
|
||||
igst_rate: number;
|
||||
seller_name: string;
|
||||
seller_address: string;
|
||||
seller_gstin: string | null;
|
||||
seller_pan: string | null;
|
||||
seller_state_code: string | null;
|
||||
place_of_supply_state: string | null;
|
||||
customer_email: string | null;
|
||||
customer_phone: string | null;
|
||||
customer_billing_address: string | null;
|
||||
customer_state_code: string | null;
|
||||
discount_label: string | null;
|
||||
notes: string | null;
|
||||
voided_at: string | null;
|
||||
voided_by_user_id: string | null;
|
||||
void_reason: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
lines: AdminInvoiceLine[];
|
||||
totals: {
|
||||
subtotal: number;
|
||||
discount: number;
|
||||
taxable_value: number;
|
||||
cgst: number;
|
||||
sgst: number;
|
||||
igst: number;
|
||||
total_tax: number;
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
export async function listAdminInvoices(params?: { status?: InvoiceStatus; page?: number; limit?: number }) {
|
||||
const qp = new URLSearchParams();
|
||||
if (params?.status) qp.set("status", params.status);
|
||||
if (params?.page) qp.set("page", String(params.page));
|
||||
if (params?.limit) qp.set("limit", String(params.limit));
|
||||
const q = qp.toString();
|
||||
return api.get<{ invoices: AdminInvoiceSummary[]; page: number; limit: number }>(
|
||||
`/api/admin/invoices${q ? `?${q}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAdminInvoice(id: string) {
|
||||
return api.get<AdminInvoiceDetail>(`/api/admin/invoices/${id}`);
|
||||
}
|
||||
|
||||
export async function voidAdminInvoice(id: string, reason: string) {
|
||||
return api.post<{ message: string }>(`/api/admin/invoices/${id}/void`, { reason });
|
||||
}
|
||||
|
||||
export async function markAdminInvoicePaid(id: string) {
|
||||
return api.post<{ message: string }>(`/api/admin/invoices/${id}/mark-paid`);
|
||||
}
|
||||
|
||||
export function adminInvoiceHtmlUrl(id: string): string {
|
||||
return `/api/admin/invoices/${id}/html`;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Pricing packages (admin)
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AdminPackage {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
package_type: string;
|
||||
role_key: string | null;
|
||||
applicable_roles: string[];
|
||||
tracecoins_amount: number;
|
||||
price: number;
|
||||
price_inr: number;
|
||||
duration_days: number | null;
|
||||
valid_from: string | null;
|
||||
valid_until: string | null;
|
||||
is_promotional: boolean;
|
||||
is_active: boolean;
|
||||
features: any;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreatePackageInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
package_type: string;
|
||||
applicable_roles?: string[];
|
||||
role_key?: string;
|
||||
tracecoins_amount: number;
|
||||
price: number;
|
||||
price_inr?: number;
|
||||
duration_days?: number;
|
||||
valid_from?: string;
|
||||
valid_until?: string;
|
||||
is_promotional?: boolean;
|
||||
is_active?: boolean;
|
||||
features?: any;
|
||||
}
|
||||
|
||||
export interface UpdatePackageInput {
|
||||
name?: string;
|
||||
description?: string;
|
||||
applicable_roles?: string[];
|
||||
tracecoins_amount?: number;
|
||||
price?: number;
|
||||
price_inr?: number;
|
||||
duration_days?: number;
|
||||
valid_from?: string;
|
||||
valid_until?: string;
|
||||
is_promotional?: boolean;
|
||||
is_active?: boolean;
|
||||
features?: any;
|
||||
}
|
||||
|
||||
export async function listAdminPackages() {
|
||||
return api.get<{ packages: AdminPackage[] } | AdminPackage[]>(`/api/admin/tracecoin-packages`);
|
||||
}
|
||||
|
||||
export async function createAdminPackage(input: CreatePackageInput) {
|
||||
return api.post<AdminPackage>(`/api/admin/tracecoin-packages`, input);
|
||||
}
|
||||
|
||||
export async function updateAdminPackage(id: string, input: UpdatePackageInput) {
|
||||
return api.patch<AdminPackage>(`/api/admin/tracecoin-packages/${id}`, input);
|
||||
}
|
||||
|
||||
export async function deleteAdminPackage(id: string) {
|
||||
return api.delete<{ message?: string }>(`/api/admin/tracecoin-packages/${id}`);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Credits (admin)
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type LedgerTypeFilter = "CREDIT" | "DEBIT" | "RESERVE" | "RELEASE" | "ADJUSTMENT";
|
||||
|
||||
export interface AdminLedgerEntry {
|
||||
id: string;
|
||||
wallet_id: string;
|
||||
user_id: string;
|
||||
user_email: string | null;
|
||||
entry_type: string;
|
||||
amount: number;
|
||||
balance_after: number | null;
|
||||
reason: string | null;
|
||||
reference_id: string | null;
|
||||
actor_user_id: string | null;
|
||||
metadata: any;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AdminWallet {
|
||||
user_id: string;
|
||||
balance: number;
|
||||
reserved: number;
|
||||
available: number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AdminReconcileRow {
|
||||
user_id: string;
|
||||
user_email: string | null;
|
||||
expected_balance: number;
|
||||
actual_balance: number;
|
||||
discrepancy: number;
|
||||
}
|
||||
|
||||
export async function getAdminCreditBalance(userId: string) {
|
||||
return api.get<AdminWallet>(
|
||||
`/api/admin/credits/balance?userId=${encodeURIComponent(userId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAdminCreditLedger(
|
||||
userId: string,
|
||||
params?: { page?: number; limit?: number; type?: LedgerTypeFilter },
|
||||
) {
|
||||
const qp = new URLSearchParams();
|
||||
qp.set("userId", userId);
|
||||
if (params?.page) qp.set("page", String(params.page));
|
||||
if (params?.limit) qp.set("limit", String(params.limit));
|
||||
if (params?.type) qp.set("type", params.type);
|
||||
return api.get<{ entries: AdminLedgerEntry[]; page: number; limit: number }>(
|
||||
`/api/admin/credits/ledger?${qp.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
export interface AdminAdjustInput {
|
||||
user_id: string;
|
||||
amount: number;
|
||||
type: "ADD" | "DEDUCT";
|
||||
reason: string;
|
||||
reference_id?: string;
|
||||
}
|
||||
|
||||
export async function adjustAdminCredits(input: AdminAdjustInput) {
|
||||
return api.post<{
|
||||
message: string;
|
||||
user_id: string;
|
||||
amount: number;
|
||||
balance: number;
|
||||
reserved: number;
|
||||
available: number;
|
||||
ledger_entry_id: string;
|
||||
audit_logged: boolean;
|
||||
}>(`/api/admin/credits/adjust`, input);
|
||||
}
|
||||
|
||||
export async function reconcileAdminCredits(params?: { from?: string; to?: string }) {
|
||||
const qp = new URLSearchParams();
|
||||
if (params?.from) qp.set("from", params.from);
|
||||
if (params?.to) qp.set("to", params.to);
|
||||
const q = qp.toString();
|
||||
return api.get<{
|
||||
results: AdminReconcileRow[];
|
||||
from: string;
|
||||
to: string;
|
||||
}>(`/api/admin/credits/reconcile${q ? `?${q}` : ""}`);
|
||||
}
|
||||
|
||||
export async function listAdminLedger(params?: {
|
||||
user_id?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}) {
|
||||
const qp = new URLSearchParams();
|
||||
if (params?.user_id) qp.set("user_id", params.user_id);
|
||||
if (params?.page) qp.set("page", String(params.page));
|
||||
if (params?.limit) qp.set("limit", String(params.limit));
|
||||
const q = qp.toString();
|
||||
return api.get<{ entries: AdminLedgerEntry[]; page: number; limit: number }>(
|
||||
`/api/admin/ledger${q ? `?${q}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,25 @@ import type { RuntimeDashboardLayout } from './types';
|
|||
// All API calls go through the gateway proxy
|
||||
const API_GATEWAY = '';
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
if (typeof sessionStorage === 'undefined') return {};
|
||||
const token = sessionStorage.getItem('nxtgauge_admin_access_token');
|
||||
if (!token) return {};
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
function authedFetch(url: string, init: RequestInit = {}): Promise<Response> {
|
||||
return fetch(url, {
|
||||
...init,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authHeaders(),
|
||||
...(init.headers as Record<string, string> | undefined),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type RuntimeRecordType = 'role' | 'dashboard' | 'onboarding' | 'knowledge_base';
|
||||
export type RuntimeRecordStatus = 'draft' | 'published';
|
||||
|
||||
|
|
@ -40,19 +59,18 @@ export async function saveRuntimeConfig(type: RuntimeRecordType, key: string, pa
|
|||
try {
|
||||
// 1. Ensure the Role exists first. We lookup by key.
|
||||
// If it doesn't exist, we create it generically.
|
||||
const roleRes = await fetch(`${API_GATEWAY}/api/admin/roles/${normalizedKey}`);
|
||||
const roleRes = await authedFetch(`${API_GATEWAY}/api/admin/roles/${normalizedKey}`);
|
||||
let role: RoleRecord;
|
||||
|
||||
if (!roleRes.ok && roleRes.status === 404) {
|
||||
// Create it
|
||||
const createRes = await fetch(`${API_GATEWAY}/api/admin/roles`, {
|
||||
const createRes = await authedFetch(`${API_GATEWAY}/api/admin/roles`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
key: normalizedKey,
|
||||
name: normalizedKey, // Using key as name for now
|
||||
name: normalizedKey,
|
||||
audience: 'EXTERNAL',
|
||||
})
|
||||
}),
|
||||
});
|
||||
if (!createRes.ok) throw new Error('Failed to create role');
|
||||
role = await createRes.json();
|
||||
|
|
@ -80,10 +98,9 @@ export async function saveRuntimeConfig(type: RuntimeRecordType, key: string, pa
|
|||
bodyPayload.config_json = payload;
|
||||
}
|
||||
|
||||
const saveRes = await fetch(`${API_GATEWAY}${endpointMap[type]}`, {
|
||||
const saveRes = await authedFetch(`${API_GATEWAY}${endpointMap[type]}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(bodyPayload)
|
||||
body: JSON.stringify(bodyPayload),
|
||||
});
|
||||
|
||||
if (!saveRes.ok) {
|
||||
|
|
@ -105,7 +122,7 @@ export async function listRuntimeConfigs<T>(type: RuntimeRecordType): Promise<Ar
|
|||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_GATEWAY}${endpointMap[type]}`);
|
||||
const res = await authedFetch(`${API_GATEWAY}${endpointMap[type]}`);
|
||||
if (!res.ok) throw new Error(`Failed to list ${type} configs`);
|
||||
const data = await res.json();
|
||||
|
||||
|
|
@ -133,7 +150,7 @@ export async function getRuntimeConfig<T>(type: RuntimeRecordType, roleId: strin
|
|||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_GATEWAY}${endpointMap[type]}`);
|
||||
const res = await authedFetch(`${API_GATEWAY}${endpointMap[type]}`);
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
|
||||
|
|
@ -149,7 +166,7 @@ export async function getRuntimeConfig<T>(type: RuntimeRecordType, roleId: strin
|
|||
|
||||
async function resolveInternalRoleId(preferredKeys: string[] = ['ADMIN', 'SUPER_ADMIN']): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(`${API_GATEWAY}/api/admin/roles?audience=INTERNAL`);
|
||||
const res = await authedFetch(`${API_GATEWAY}/api/admin/roles?audience=INTERNAL`);
|
||||
if (!res.ok) return null;
|
||||
|
||||
const data = await res.json();
|
||||
|
|
@ -209,7 +226,7 @@ function normalizeDashboardLayout(input: any): RuntimeDashboardLayout | null {
|
|||
|
||||
async function readDashboardConfig(roleId: string, audience: 'INTERNAL' | 'EXTERNAL'): Promise<DashboardConfigResponse | null> {
|
||||
try {
|
||||
const res = await fetch(`${API_GATEWAY}/api/admin/dashboard-config/${roleId}?audience=${audience}`);
|
||||
const res = await authedFetch(`${API_GATEWAY}/api/admin/dashboard-config/${roleId}?audience=${audience}`);
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
} catch {
|
||||
|
|
@ -245,9 +262,8 @@ export async function saveAdminDashboardLayout(layout: RuntimeDashboardLayout):
|
|||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_GATEWAY}/api/admin/dashboard-config`, {
|
||||
const res = await authedFetch(`${API_GATEWAY}/api/admin/dashboard-config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
role_id: roleId,
|
||||
audience: 'INTERNAL',
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -2,6 +2,21 @@ import { createMemo, createResource, createSignal, Show, For } from 'solid-js';
|
|||
|
||||
const API = '';
|
||||
|
||||
function getToken(): string {
|
||||
return typeof sessionStorage !== "undefined"
|
||||
? sessionStorage.getItem("nxtgauge_admin_access_token") || ""
|
||||
: "";
|
||||
}
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = getToken();
|
||||
return {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
'company',
|
||||
'customer',
|
||||
|
|
@ -36,7 +51,10 @@ type Package = {
|
|||
|
||||
async function loadDiscounts(): Promise<Discount[]> {
|
||||
try {
|
||||
const res = await fetch(`${API}/api/admin/discounts`);
|
||||
const res = await fetch(`${API}/api/admin/discounts`, {
|
||||
headers: authHeaders(),
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to load');
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? data : (data.discounts || []);
|
||||
|
|
@ -47,7 +65,10 @@ async function loadDiscounts(): Promise<Discount[]> {
|
|||
|
||||
async function loadPackages(): Promise<Package[]> {
|
||||
try {
|
||||
const res = await fetch(`${API}/api/admin/tracecoin-packages`);
|
||||
const res = await fetch(`${API}/api/admin/tracecoin-packages`, {
|
||||
headers: authHeaders(),
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to load');
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? data : (data.packages || []);
|
||||
|
|
@ -161,7 +182,8 @@ export default function DiscountPage() {
|
|||
const method = f.id ? 'PATCH' : 'POST';
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: authHeaders(),
|
||||
credentials: "include",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to save discount');
|
||||
|
|
@ -180,7 +202,8 @@ export default function DiscountPage() {
|
|||
setToggling(discount.id);
|
||||
const res = await fetch(`${API}/api/admin/discounts/${discount.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: authHeaders(),
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ is_active: !discount.is_active }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to toggle');
|
||||
|
|
|
|||
|
|
@ -1,94 +1,93 @@
|
|||
import { createSignal, createMemo, onMount, Show, For } from 'solid-js';
|
||||
import { createMemo, createResource, createSignal, onMount, Show, For } from 'solid-js';
|
||||
import {
|
||||
api,
|
||||
AdminInvoiceSummary,
|
||||
AdminInvoiceDetail,
|
||||
InvoiceStatus,
|
||||
listAdminInvoices,
|
||||
getAdminInvoice,
|
||||
voidAdminInvoice,
|
||||
markAdminInvoicePaid,
|
||||
adminInvoiceHtmlUrl,
|
||||
} from '~/lib/api';
|
||||
|
||||
const API = '';
|
||||
|
||||
function getToken(): string {
|
||||
return typeof sessionStorage !== 'undefined'
|
||||
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
|
||||
: '';
|
||||
}
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = getToken();
|
||||
return {
|
||||
Accept: 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
}
|
||||
type FilterStatus = 'all' | InvoiceStatus;
|
||||
|
||||
export default function InvoicePage() {
|
||||
const [invoices, setInvoices] = createSignal<any[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [loadError, setLoadError] = createSignal('');
|
||||
const [invoices, { refetch }] = createResource<AdminInvoiceSummary[]>(
|
||||
() => listAdminInvoices().then((r) => r.data?.invoices ?? []),
|
||||
);
|
||||
const [search, setSearch] = createSignal('');
|
||||
const [statusFilter, setStatusFilter] = createSignal<'all' | 'paid' | 'pending' | 'failed'>('all');
|
||||
const [sortBy, setSortBy] = createSignal<'newest' | 'oldest' | 'total_desc' | 'total_asc'>('newest');
|
||||
const [sortMenuOpen, setSortMenuOpen] = createSignal(false);
|
||||
const [filterMenuOpen, setFilterMenuOpen] = createSignal(false);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true); setLoadError('');
|
||||
try {
|
||||
const res = await fetch(`${API}/api/admin/invoices`, { headers: authHeaders(), credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`Request failed (${res.status})`);
|
||||
const data = await res.json();
|
||||
setInvoices(Array.isArray(data) ? data : (data.invoices ?? []));
|
||||
} catch (err: any) {
|
||||
setLoadError(err.message || 'Could not load invoices.');
|
||||
setInvoices([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => void load());
|
||||
const [statusFilter, setStatusFilter] = createSignal<FilterStatus>('all');
|
||||
const [sortBy, setSortBy] = createSignal<'newest' | 'oldest' | 'total_desc' | 'total_asc'>(
|
||||
'newest',
|
||||
);
|
||||
const [selectedInvoice, setSelectedInvoice] = createSignal<AdminInvoiceDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = createSignal(false);
|
||||
const [voidModalInvoice, setVoidModalInvoice] = createSignal<AdminInvoiceSummary | null>(null);
|
||||
const [voidReason, setVoidReason] = createSignal('');
|
||||
const [actionError, setActionError] = createSignal('');
|
||||
const [actionBusy, setActionBusy] = createSignal('');
|
||||
|
||||
const filteredInvoices = createMemo(() => {
|
||||
const q = search().toLowerCase();
|
||||
let data = invoices();
|
||||
const data = invoices() ?? [];
|
||||
const q = search().toLowerCase().trim();
|
||||
let filtered = data;
|
||||
if (q) {
|
||||
data = data.filter((inv) =>
|
||||
(inv.invoice_number || inv.id || '').toLowerCase().includes(q) ||
|
||||
(inv.user_id || '').toLowerCase().includes(q) ||
|
||||
(inv.package_name || '').toLowerCase().includes(q) ||
|
||||
(inv.status || '').toLowerCase().includes(q)
|
||||
filtered = filtered.filter(
|
||||
(inv) =>
|
||||
(inv.invoice_number ?? '').toLowerCase().includes(q) ||
|
||||
(inv.user_email ?? '').toLowerCase().includes(q) ||
|
||||
(inv.user_id ?? '').toLowerCase().includes(q) ||
|
||||
(inv.customer_name ?? '').toLowerCase().includes(q) ||
|
||||
(inv.status ?? '').toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
if (statusFilter() !== 'all') {
|
||||
data = data.filter((inv) => {
|
||||
const st = String(inv.status || '').toLowerCase();
|
||||
if (statusFilter() === 'paid') return st === 'paid' || st === 'issued';
|
||||
if (statusFilter() === 'pending') return st === 'pending';
|
||||
if (statusFilter() === 'failed') return st === 'failed';
|
||||
return true;
|
||||
});
|
||||
filtered = filtered.filter((inv) => inv.status === statusFilter());
|
||||
}
|
||||
const sorted = [...data];
|
||||
const sorted = [...filtered];
|
||||
sorted.sort((a, b) => {
|
||||
const aCreated = new Date(a.created_at || 0).getTime();
|
||||
const bCreated = new Date(b.created_at || 0).getTime();
|
||||
const aDate = new Date(a.issued_at ?? 0).getTime();
|
||||
const bDate = new Date(b.issued_at ?? 0).getTime();
|
||||
const aTotal = Number(a.total ?? 0);
|
||||
const bTotal = Number(b.total ?? 0);
|
||||
if (sortBy() === 'oldest') return aCreated - bCreated;
|
||||
if (sortBy() === 'oldest') return aDate - bDate;
|
||||
if (sortBy() === 'total_desc') return bTotal - aTotal;
|
||||
if (sortBy() === 'total_asc') return aTotal - bTotal;
|
||||
return bCreated - aCreated;
|
||||
return bDate - aDate;
|
||||
});
|
||||
return sorted;
|
||||
});
|
||||
|
||||
const exportCsv = () => {
|
||||
const headers = ['Invoice', 'User', 'Package', 'Total', 'Tax', 'Status', 'Date'];
|
||||
const headers = [
|
||||
'Invoice #',
|
||||
'User Email',
|
||||
'Customer',
|
||||
'Subtotal',
|
||||
'CGST',
|
||||
'SGST',
|
||||
'IGST',
|
||||
'Total',
|
||||
'Status',
|
||||
'Issued At',
|
||||
];
|
||||
const rows = filteredInvoices().map((item) => [
|
||||
item.invoice_number || item.id,
|
||||
item.user_id || '—',
|
||||
item.package_name || '—',
|
||||
item.total != null ? `₹${(item.total / 100).toFixed(2)}` : '—',
|
||||
item.tax != null ? `₹${(item.tax / 100).toFixed(2)}` : '—',
|
||||
item.status || '—',
|
||||
item.created_at ? new Date(item.created_at).toLocaleString() : '—',
|
||||
item.invoice_number ?? '',
|
||||
item.user_email ?? '',
|
||||
item.customer_name ?? '',
|
||||
paiseToRupees(item.subtotal),
|
||||
paiseToRupees(item.cgst_amount),
|
||||
paiseToRupees(item.sgst_amount),
|
||||
paiseToRupees(item.igst_amount),
|
||||
paiseToRupees(item.total),
|
||||
item.status ?? '',
|
||||
item.issued_at ?? '',
|
||||
]);
|
||||
const csv = [headers, ...rows].map((line) => line.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(',')).join('\n');
|
||||
const csv = [headers, ...rows]
|
||||
.map((line) => line.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
|
||||
.join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
|
|
@ -98,135 +97,501 @@ export default function InvoicePage() {
|
|||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const openDetail = async (inv: AdminInvoiceSummary) => {
|
||||
setDetailLoading(true);
|
||||
setActionError('');
|
||||
try {
|
||||
const res = await getAdminInvoice(inv.id);
|
||||
setSelectedInvoice(res.data);
|
||||
} catch (e: any) {
|
||||
setActionError(e?.message || 'Failed to load invoice.');
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeDetail = () => {
|
||||
setSelectedInvoice(null);
|
||||
setVoidModalInvoice(null);
|
||||
setVoidReason('');
|
||||
setActionError('');
|
||||
};
|
||||
|
||||
const handleVoid = async () => {
|
||||
const inv = voidModalInvoice();
|
||||
if (!inv) return;
|
||||
const reason = voidReason().trim();
|
||||
if (!reason) {
|
||||
setActionError('Void reason is required.');
|
||||
return;
|
||||
}
|
||||
setActionBusy('void');
|
||||
setActionError('');
|
||||
try {
|
||||
await voidAdminInvoice(inv.id, reason);
|
||||
setVoidModalInvoice(null);
|
||||
setVoidReason('');
|
||||
await refetch();
|
||||
if (selectedInvoice()?.invoice.id === inv.id) {
|
||||
const res = await getAdminInvoice(inv.id);
|
||||
setSelectedInvoice(res.data);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setActionError(e?.message || 'Failed to void invoice.');
|
||||
} finally {
|
||||
setActionBusy('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkPaid = async (inv: AdminInvoiceSummary) => {
|
||||
setActionBusy(`paid:${inv.id}`);
|
||||
setActionError('');
|
||||
try {
|
||||
await markAdminInvoicePaid(inv.id);
|
||||
await refetch();
|
||||
if (selectedInvoice()?.invoice.id === inv.id) {
|
||||
const res = await getAdminInvoice(inv.id);
|
||||
setSelectedInvoice(res.data);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setActionError(e?.message || 'Failed to mark invoice as paid.');
|
||||
} finally {
|
||||
setActionBusy('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="w-full space-y-6 pb-8">
|
||||
<div style="margin-bottom:1.5rem">
|
||||
<h1 class="text-[28px] font-bold leading-tight text-[#111827]">Invoice Management</h1>
|
||||
<p class="mt-1 text-[14px] text-[#6B7280]">View and download all platform invoices.</p>
|
||||
<div class="w-full space-y-6 pb-8">
|
||||
<div style="margin-bottom:1.5rem">
|
||||
<h1 class="text-[28px] font-bold leading-tight text-[#111827]">Invoice Management</h1>
|
||||
<p class="mt-1 text-[14px] text-[#6B7280]">
|
||||
View, void, and mark invoices as paid. Invoices are auto-generated when a Tracecoin package is purchased.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Show when={actionError()}>
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{actionError()}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<section class="rounded-xl border border-gray-200 bg-white shadow-sm">
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:14px 20px;border-bottom:1px solid #F3F4F6;flex-wrap:wrap">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by invoice #, email, customer, or status..."
|
||||
value={search()}
|
||||
onInput={(e) => setSearch(e.currentTarget.value)}
|
||||
style="height:34px;flex:1;min-width:240px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:13px;color:#111827;outline:none"
|
||||
/>
|
||||
<select
|
||||
value={statusFilter()}
|
||||
onChange={(e) => setStatusFilter(e.currentTarget.value as FilterStatus)}
|
||||
style="height:34px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;color:#374151"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="ISSUED">Issued</option>
|
||||
<option value="PAID">Paid</option>
|
||||
<option value="VOID">Void</option>
|
||||
<option value="REFUNDED">Refunded</option>
|
||||
<option value="DRAFT">Draft</option>
|
||||
</select>
|
||||
<select
|
||||
value={sortBy()}
|
||||
onChange={(e) => setSortBy(e.currentTarget.value as any)}
|
||||
style="height:34px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;color:#374151"
|
||||
>
|
||||
<option value="newest">Newest First</option>
|
||||
<option value="oldest">Oldest First</option>
|
||||
<option value="total_desc">Total High-Low</option>
|
||||
<option value="total_asc">Total Low-High</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
style="height:34px;padding:0 14px;font-size:12px"
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;background:#0D0D2A;padding:0 12px;font-size:12px;font-weight:600;color:white;border:none;cursor:pointer"
|
||||
onClick={exportCsv}
|
||||
>
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style="position:relative;margin-top:1.5rem;margin-left:-24px;margin-right:-24px;border-radius:0;border-left:none;border-right:none;overflow:visible;border-top:1px solid #E5E7EB;border-bottom:1px solid #E5E7EB;background:white;box-shadow:0 1px 3px rgba(0,0,0,0.06)">
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:14px 20px;border-bottom:1px solid #F3F4F6;position:relative;z-index:20;margin-bottom:0">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search invoices by number, user, package, or status..."
|
||||
value={search()}
|
||||
onInput={(e) => setSearch(e.currentTarget.value)}
|
||||
style="height:34px;flex:1;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:13px;color:#111827;outline:none"
|
||||
/>
|
||||
<div style="position:relative;">
|
||||
<button type="button" onClick={() => { setSortMenuOpen((v) => !v); setFilterMenuOpen(false); }} style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 4v13"/><path d="m3 13 4 4 4-4"/><path d="M17 20V7"/><path d="m21 11-4-4-4 4"/></svg>
|
||||
Sort
|
||||
</button>
|
||||
<Show when={sortMenuOpen()}>
|
||||
<div style="position:absolute;right:0;top:38px;z-index:40;min-width:180px;border:1px solid #E5E7EB;border-radius:12px;background:#fff;box-shadow:0 12px 24px rgba(2,6,23,0.12);padding:8px">
|
||||
<For each={[
|
||||
{ key: 'newest', label: 'Newest First' },
|
||||
{ key: 'oldest', label: 'Oldest First' },
|
||||
{ key: 'total_desc', label: 'Total High-Low' },
|
||||
{ key: 'total_asc', label: 'Total Low-High' },
|
||||
] as { key: 'newest' | 'oldest' | 'total_desc' | 'total_asc'; label: string }[]}>
|
||||
{(item) => (
|
||||
<button type="button" onClick={() => { setSortBy(item.key); setSortMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${sortBy() === item.key ? '#FF5E13' : '#374151'};background:${sortBy() === item.key ? '#FFF1EB' : 'transparent'}`}>{item.label}</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div style="position:relative;">
|
||||
<button type="button" onClick={() => { setFilterMenuOpen((v) => !v); setSortMenuOpen(false); }} style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 5h18M6 12h12M10 19h4"/></svg>
|
||||
Filters
|
||||
</button>
|
||||
<Show when={filterMenuOpen()}>
|
||||
<div style="position:absolute;right:0;top:38px;z-index:40;min-width:180px;border:1px solid #E5E7EB;border-radius:12px;background:#fff;box-shadow:0 12px 24px rgba(2,6,23,0.12);padding:8px">
|
||||
<For each={[
|
||||
{ key: 'all', label: 'All Status' },
|
||||
{ key: 'paid', label: 'Paid/Issued' },
|
||||
{ key: 'pending', label: 'Pending' },
|
||||
{ key: 'failed', label: 'Failed' },
|
||||
] as { key: 'all' | 'paid' | 'pending' | 'failed'; label: string }[]}>
|
||||
{(item) => (
|
||||
<button type="button" onClick={() => { setStatusFilter(item.key); setFilterMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${statusFilter() === item.key ? '#FF5E13' : '#374151'};background:${statusFilter() === item.key ? '#FFF1EB' : 'transparent'}`}>{item.label}</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<button type="button" onClick={exportCsv} style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;background:#0D0D2A;padding:0 12px;font-size:12px;font-weight:600;color:white;border:none;cursor:pointer">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="data-table w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Invoice #</th>
|
||||
<th>User</th>
|
||||
<th>Package</th>
|
||||
<th>Total (₹)</th>
|
||||
<th>Tax (₹)</th>
|
||||
<th>Status</th>
|
||||
<th>Date</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Show when={loading()}>
|
||||
<tr><td colspan="8" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
|
||||
</Show>
|
||||
<Show when={!loading() && loadError()}>
|
||||
<tr><td colspan="8" style="text-align:center;padding:32px;color:#b91c1c">{loadError()}</td></tr>
|
||||
</Show>
|
||||
<Show when={!loading() && !loadError() && filteredInvoices().length === 0}>
|
||||
<tr><td colspan="8" style="text-align:center;padding:32px;color:#94a3b8">No records found.</td></tr>
|
||||
</Show>
|
||||
<Show when={!loading() && !loadError() && filteredInvoices().length > 0}>
|
||||
{filteredInvoices().map((item) => (
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="font-semibold text-slate-900" style="font-family:monospace">{item.invoice_number || item.id}</td>
|
||||
<td class="text-slate-500">{item.user_id || '—'}</td>
|
||||
<td class="text-slate-500">{item.package_name || '—'}</td>
|
||||
<td class="text-slate-500">₹{item.total != null ? (item.total / 100).toFixed(2) : '—'}</td>
|
||||
<td class="text-slate-500">₹{item.tax != null ? (item.tax / 100).toFixed(2) : '—'}</td>
|
||||
<td>
|
||||
<span class={`inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600 ${item.status === 'PAID' || item.status === 'ISSUED' ? 'active' : ''}`}>
|
||||
{item.status || '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-slate-500">{item.created_at ? new Date(item.created_at).toLocaleString() : '—'}</td>
|
||||
<td>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<Show when={item.download_url || item.pdf_url}>
|
||||
<Show
|
||||
when={!invoices.loading}
|
||||
fallback={
|
||||
<p style="padding:32px;text-align:center;color:#64748b">Loading invoices...</p>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={!invoices.error}
|
||||
fallback={
|
||||
<div class="m-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
Failed to load invoices: {String(invoices.error)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={filteredInvoices().length > 0}
|
||||
fallback={
|
||||
<p style="padding:32px;text-align:center;color:#94a3b8">
|
||||
No invoices found.
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="data-table w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Invoice #</th>
|
||||
<th>Customer</th>
|
||||
<th>Subtotal (₹)</th>
|
||||
<th>Total (₹)</th>
|
||||
<th>Status</th>
|
||||
<th>Issued</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={filteredInvoices()}>
|
||||
{(item) => (
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="font-semibold text-slate-900" style="font-family:monospace">
|
||||
{item.invoice_number ?? '—'}
|
||||
</td>
|
||||
<td>
|
||||
<div class="text-slate-900 font-medium">
|
||||
{item.customer_name ?? '—'}
|
||||
</div>
|
||||
<Show when={item.user_email}>
|
||||
<div class="text-xs text-slate-500">{item.user_email}</div>
|
||||
</Show>
|
||||
</td>
|
||||
<td class="text-slate-700">₹{paiseToRupees(item.subtotal)}</td>
|
||||
<td class="text-slate-900 font-semibold">₹{paiseToRupees(item.total)}</td>
|
||||
<td>
|
||||
<span
|
||||
class={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${statusClass(item.status)}`}
|
||||
>
|
||||
{item.status ?? '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-slate-500 text-xs">
|
||||
{item.issued_at
|
||||
? new Date(item.issued_at).toLocaleDateString('en-IN', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})
|
||||
: '—'}
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
style="padding:6px 10px;font-size:12px"
|
||||
onClick={() => openDetail(item)}
|
||||
>
|
||||
View
|
||||
</button>
|
||||
<a
|
||||
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
href={item.download_url || item.pdf_url}
|
||||
class="btn-secondary"
|
||||
style="padding:6px 10px;font-size:12px"
|
||||
href={adminInvoiceHtmlUrl(item.id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
download
|
||||
>
|
||||
Download
|
||||
HTML
|
||||
</a>
|
||||
</Show>
|
||||
<Show when={!item.download_url && !item.pdf_url}>
|
||||
<span style="color:#94a3b8;font-size:12px">—</span>
|
||||
</Show>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</Show>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={item.status === 'ISSUED'}>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
style="padding:6px 10px;font-size:12px;color:#15803d;border-color:#86efac"
|
||||
disabled={actionBusy() === `paid:${item.id}`}
|
||||
onClick={() => handleMarkPaid(item)}
|
||||
>
|
||||
{actionBusy() === `paid:${item.id}` ? '...' : 'Mark Paid'}
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={item.status === 'ISSUED' || item.status === 'PAID'}>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
style="padding:6px 10px;font-size:12px;color:#b91c1c;border-color:#fca5a5"
|
||||
onClick={() => setVoidModalInvoice(item)}
|
||||
>
|
||||
Void
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</section>
|
||||
|
||||
{/* Detail modal */}
|
||||
<Show when={detailLoading() || selectedInvoice()}>
|
||||
<div
|
||||
style="position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:50;padding:24px;overflow-y:auto"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) closeDetail();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="bg-white rounded-2xl shadow-2xl"
|
||||
style="width:100%;max-width:920px;max-height:90vh;overflow-y:auto;padding:32px"
|
||||
>
|
||||
<Show when={detailLoading()}>
|
||||
<div class="text-center py-12">
|
||||
<p class="text-slate-500">Loading invoice...</p>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={selectedInvoice()}>
|
||||
<InvoiceDetailPanel detail={selectedInvoice()!} onClose={closeDetail} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Void modal */}
|
||||
<Show when={voidModalInvoice()}>
|
||||
<div
|
||||
style="position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:60;padding:24px"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) setVoidModalInvoice(null);
|
||||
}}
|
||||
>
|
||||
<div class="bg-white rounded-2xl shadow-2xl" style="width:100%;max-width:480px;padding:24px">
|
||||
<h2 class="text-xl font-bold mb-2">Void Invoice</h2>
|
||||
<p class="text-sm text-slate-600 mb-4">
|
||||
Void <strong>{voidModalInvoice()?.invoice_number}</strong>? This cannot be undone.
|
||||
Tracecoins may need to be refunded separately.
|
||||
</p>
|
||||
<label class="block text-sm font-medium mb-1">Reason *</label>
|
||||
<textarea
|
||||
value={voidReason()}
|
||||
onInput={(e) => setVoidReason(e.currentTarget.value)}
|
||||
placeholder="e.g. Customer requested refund"
|
||||
rows={3}
|
||||
style="width:100%;padding:8px 12px;border:1px solid #e2e8f0;border-radius:8px;font-size:14px;box-sizing:border-box;resize:vertical"
|
||||
/>
|
||||
<Show when={actionError()}>
|
||||
<div class="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{actionError()}
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex justify-end gap-2 mt-5">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
onClick={() => {
|
||||
setVoidModalInvoice(null);
|
||||
setVoidReason('');
|
||||
setActionError('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
style="background:#b91c1c"
|
||||
disabled={actionBusy() === 'void'}
|
||||
onClick={handleVoid}
|
||||
>
|
||||
{actionBusy() === 'void' ? 'Voiding...' : 'Void Invoice'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InvoiceDetailPanel(props: { detail: AdminInvoiceDetail; onClose: () => void }) {
|
||||
const inv = () => props.detail.invoice;
|
||||
return (
|
||||
<div>
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold">TAX INVOICE</h2>
|
||||
<p class="text-slate-500 font-mono text-sm mt-1">{inv().invoice_number}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
onClick={props.onClose}
|
||||
style="padding:6px 12px"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<span class={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold ${statusClass(inv().status)}`}>
|
||||
{inv().status}
|
||||
</span>
|
||||
<Show when={inv().paid_at}>
|
||||
<span class="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold bg-green-100 text-green-700">
|
||||
Paid on {new Date(inv().paid_at!).toLocaleDateString('en-IN')}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={inv().voided_at}>
|
||||
<span class="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold bg-red-100 text-red-700">
|
||||
Voided on {new Date(inv().voided_at!).toLocaleDateString('en-IN')}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<div class="border rounded-lg p-4">
|
||||
<p class="text-xs uppercase text-slate-500 mb-2">From</p>
|
||||
<p class="font-semibold">{inv().seller_name}</p>
|
||||
<p class="text-sm whitespace-pre-line mt-1">{inv().seller_address}</p>
|
||||
<p class="text-xs mt-2">
|
||||
{inv().seller_gstin && <>GSTIN: {inv().seller_gstin}<br /></>}
|
||||
{inv().seller_pan && <>PAN: {inv().seller_pan}</>}
|
||||
</p>
|
||||
</div>
|
||||
<div class="border rounded-lg p-4">
|
||||
<p class="text-xs uppercase text-slate-500 mb-2">Bill To</p>
|
||||
<p class="font-semibold">{inv().customer_name ?? '—'}</p>
|
||||
<p class="text-sm whitespace-pre-line mt-1">{inv().customer_billing_address ?? '—'}</p>
|
||||
<p class="text-xs mt-2">
|
||||
{inv().customer_gstin && <>GSTIN: {inv().customer_gstin}<br /></>}
|
||||
{inv().place_of_supply_state && <>Place of Supply: {inv().place_of_supply_state}</>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="w-full text-sm mb-4">
|
||||
<thead>
|
||||
<tr class="text-left text-xs uppercase text-slate-500 border-b">
|
||||
<th class="py-2">#</th>
|
||||
<th class="py-2">Description</th>
|
||||
<th class="py-2">HSN/SAC</th>
|
||||
<th class="py-2 text-right">Qty</th>
|
||||
<th class="py-2 text-right">Rate</th>
|
||||
<th class="py-2 text-right">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={props.detail.lines}>
|
||||
{(line) => (
|
||||
<tr class="border-b">
|
||||
<td class="py-2">{line.line_number}</td>
|
||||
<td class="py-2">{line.description}</td>
|
||||
<td class="py-2">{line.hsn_sac_code ?? '—'}</td>
|
||||
<td class="py-2 text-right">{line.quantity}</td>
|
||||
<td class="py-2 text-right">{paiseToRupees(line.unit_price_paise)}</td>
|
||||
<td class="py-2 text-right">
|
||||
{paiseToRupees(Math.round(line.unit_price_paise * line.quantity))}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="border-t pt-4 space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-slate-600">Subtotal</span>
|
||||
<span>₹{paiseToRupees(props.detail.totals.subtotal)}</span>
|
||||
</div>
|
||||
<Show when={props.detail.totals.discount > 0}>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-slate-600">Discount</span>
|
||||
<span>-₹{paiseToRupees(props.detail.totals.discount)}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-slate-600">Taxable value</span>
|
||||
<span>₹{paiseToRupees(props.detail.totals.taxable_value)}</span>
|
||||
</div>
|
||||
<Show when={props.detail.totals.cgst > 0}>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-slate-600">CGST ({(inv().cgst_rate).toFixed(2)}%)</span>
|
||||
<span>₹{paiseToRupees(props.detail.totals.cgst)}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-slate-600">SGST ({(inv().sgst_rate).toFixed(2)}%)</span>
|
||||
<span>₹{paiseToRupees(props.detail.totals.sgst)}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.detail.totals.igst > 0}>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-slate-600">IGST ({(inv().igst_rate).toFixed(2)}%)</span>
|
||||
<span>₹{paiseToRupees(props.detail.totals.igst)}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex justify-between text-lg font-bold pt-2 border-t">
|
||||
<span>Total ({inv().currency})</span>
|
||||
<span class="text-orange-600">₹{paiseToRupees(props.detail.totals.total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={inv().void_reason}>
|
||||
<div class="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm">
|
||||
<p class="text-red-800 font-semibold">Void reason</p>
|
||||
<p class="text-red-700 mt-1">{inv().void_reason}</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="mt-6 flex gap-2">
|
||||
<a
|
||||
class="btn-primary"
|
||||
href={adminInvoiceHtmlUrl(inv().id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Open Printable View
|
||||
</a>
|
||||
<button type="button" class="btn-secondary" onClick={() => window.print()}>
|
||||
Print
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function statusClass(status: string | null | undefined): string {
|
||||
switch ((status ?? '').toUpperCase()) {
|
||||
case 'PAID':
|
||||
return 'bg-green-100 text-green-700';
|
||||
case 'ISSUED':
|
||||
return 'bg-blue-100 text-blue-700';
|
||||
case 'VOID':
|
||||
return 'bg-red-100 text-red-700';
|
||||
case 'REFUNDED':
|
||||
return 'bg-gray-100 text-gray-700';
|
||||
case 'DRAFT':
|
||||
return 'bg-amber-100 text-amber-700';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-600';
|
||||
}
|
||||
}
|
||||
|
||||
function paiseToRupees(paise: number | null | undefined): string {
|
||||
if (paise == null) return '—';
|
||||
const rupees = (paise / 100).toLocaleString('en-IN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
return rupees;
|
||||
}
|
||||
|
|
@ -2,21 +2,41 @@ import { createMemo, createResource, createSignal, Show, For } from 'solid-js';
|
|||
|
||||
const API = '';
|
||||
|
||||
function getToken(): string {
|
||||
return typeof sessionStorage !== "undefined"
|
||||
? sessionStorage.getItem("nxtgauge_admin_access_token") || ""
|
||||
: "";
|
||||
}
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = getToken();
|
||||
return {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
type LedgerEntry = {
|
||||
id: string;
|
||||
entry_type?: string;
|
||||
type?: string;
|
||||
order_id?: string;
|
||||
invoice_id?: string;
|
||||
user_id?: string;
|
||||
amount?: number;
|
||||
note?: string;
|
||||
created_at?: string;
|
||||
entry_type: string;
|
||||
amount: number;
|
||||
balance_after: number | null;
|
||||
reason: string | null;
|
||||
reference_id: string | null;
|
||||
actor_user_id: string | null;
|
||||
metadata: any;
|
||||
created_at: string;
|
||||
user_id: string;
|
||||
user_email: string | null;
|
||||
};
|
||||
|
||||
async function loadLedger(): Promise<LedgerEntry[]> {
|
||||
try {
|
||||
const res = await fetch(`${API}/api/admin/ledger`);
|
||||
const res = await fetch(`${API}/api/admin/ledger?page=1&limit=100`, {
|
||||
headers: authHeaders(),
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to load');
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? data : (data.entries || data.ledger || []);
|
||||
|
|
@ -27,33 +47,31 @@ async function loadLedger(): Promise<LedgerEntry[]> {
|
|||
|
||||
function typeBadgeStyle(entryType: string): string {
|
||||
switch ((entryType || '').toUpperCase()) {
|
||||
case 'TRACECOIN_PURCHASE':
|
||||
return 'background:#dbeafe;color:#1d4ed8;border-color:#bfdbfe';
|
||||
case 'PAYMENT':
|
||||
case 'CREDIT':
|
||||
return 'background:#dcfce7;color:#166534;border-color:#bbf7d0';
|
||||
case 'DISCOUNT':
|
||||
case 'DEBIT':
|
||||
return 'background:#fee2e2;color:#991b1b;border-color:#fecaca';
|
||||
case 'RESERVE':
|
||||
return 'background:#fff7ed;color:#c2410c;border-color:#fed7aa';
|
||||
case 'COUPON':
|
||||
case 'RELEASE':
|
||||
return 'background:#dbeafe;color:#1d4ed8;border-color:#bfdbfe';
|
||||
case 'ADJUSTMENT':
|
||||
return 'background:#f3e8ff;color:#7e22ce;border-color:#e9d5ff';
|
||||
case 'INVOICE':
|
||||
default:
|
||||
return 'background:#f1f5f9;color:#475569;border-color:#e2e8f0';
|
||||
}
|
||||
}
|
||||
|
||||
function formatAmount(entry: LedgerEntry): string {
|
||||
const t = (entry.entry_type || entry.type || '').toUpperCase();
|
||||
if (t === 'TRACECOIN_PURCHASE') {
|
||||
return entry.amount != null ? `${entry.amount} TC` : '—';
|
||||
}
|
||||
if (entry.amount == null) return '—';
|
||||
return `₹${(entry.amount / 100).toFixed(2)}`;
|
||||
const sign = entry.amount >= 0 ? '+' : '';
|
||||
return `${sign}${entry.amount} TC`;
|
||||
}
|
||||
|
||||
export default function LedgerPage() {
|
||||
const [entries] = createResource(loadLedger);
|
||||
const [search, setSearch] = createSignal('');
|
||||
const [typeFilter, setTypeFilter] = createSignal<'all' | 'payment' | 'discount' | 'coupon' | 'invoice' | 'tracecoin_purchase'>('all');
|
||||
const [typeFilter, setTypeFilter] = createSignal<'all' | 'CREDIT' | 'DEBIT' | 'RESERVE' | 'RELEASE' | 'ADJUSTMENT'>('all');
|
||||
const [sortBy, setSortBy] = createSignal<'newest' | 'oldest' | 'amount_desc' | 'amount_asc'>('newest');
|
||||
const [sortMenuOpen, setSortMenuOpen] = createSignal(false);
|
||||
const [filterMenuOpen, setFilterMenuOpen] = createSignal(false);
|
||||
|
|
@ -63,16 +81,16 @@ export default function LedgerPage() {
|
|||
const q = search().toLowerCase().trim();
|
||||
if (q) {
|
||||
data = data.filter((item) => {
|
||||
const t = String(item.entry_type || item.type || '').toLowerCase();
|
||||
const t = String(item.entry_type || '').toLowerCase();
|
||||
return t.includes(q)
|
||||
|| String(item.order_id || '').toLowerCase().includes(q)
|
||||
|| String(item.invoice_id || '').toLowerCase().includes(q)
|
||||
|| String(item.user_email || '').toLowerCase().includes(q)
|
||||
|| String(item.user_id || '').toLowerCase().includes(q)
|
||||
|| String(item.note || '').toLowerCase().includes(q);
|
||||
|| String(item.reason || '').toLowerCase().includes(q)
|
||||
|| String(item.reference_id || '').toLowerCase().includes(q);
|
||||
});
|
||||
}
|
||||
if (typeFilter() !== 'all') {
|
||||
data = data.filter((item) => String(item.entry_type || item.type || '').toLowerCase() === typeFilter());
|
||||
data = data.filter((item) => String(item.entry_type || '').toUpperCase() === typeFilter());
|
||||
}
|
||||
const sorted = [...data];
|
||||
sorted.sort((a, b) => {
|
||||
|
|
@ -89,14 +107,14 @@ export default function LedgerPage() {
|
|||
});
|
||||
|
||||
const exportCsv = () => {
|
||||
const headers = ['Type', 'Order ID', 'Invoice ID', 'User ID', 'Amount', 'Note', 'Date'];
|
||||
const headers = ['Type', 'User', 'Amount', 'Balance After', 'Reason', 'Reference', 'Date'];
|
||||
const rows = filteredEntries().map((item) => [
|
||||
item.entry_type || item.type || '—',
|
||||
item.order_id || '—',
|
||||
item.invoice_id || '—',
|
||||
item.user_id || '—',
|
||||
item.entry_type || '—',
|
||||
item.user_email || '—',
|
||||
formatAmount(item),
|
||||
item.note || '—',
|
||||
item.balance_after != null ? String(item.balance_after) : '—',
|
||||
item.reason || '—',
|
||||
item.reference_id || '—',
|
||||
item.created_at ? new Date(item.created_at).toLocaleString() : '—',
|
||||
]);
|
||||
const csv = [headers, ...rows].map((line) => line.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(',')).join('\n');
|
||||
|
|
@ -152,15 +170,15 @@ export default function LedgerPage() {
|
|||
Filters
|
||||
</button>
|
||||
<Show when={filterMenuOpen()}>
|
||||
<div style="position:absolute;right:0;top:38px;z-index:40;min-width:180px;border:1px solid #E5E7EB;border-radius:12px;background:#fff;box-shadow:0 12px 24px rgba(2,6,23,0.12);padding:8px">
|
||||
<div style="position:absolute;right:0;top:38px;z-index:40;min-width:170px;border:1px solid #E5E7EB;border-radius:12px;background:#fff;box-shadow:0 12px 24px rgba(2,6,23,0.12);padding:8px">
|
||||
<For each={[
|
||||
{ key: 'all', label: 'All Types' },
|
||||
{ key: 'payment', label: 'Payment' },
|
||||
{ key: 'discount', label: 'Discount' },
|
||||
{ key: 'coupon', label: 'Coupon' },
|
||||
{ key: 'invoice', label: 'Invoice' },
|
||||
{ key: 'tracecoin_purchase', label: 'Tracecoin Purchase' },
|
||||
] as { key: 'all' | 'payment' | 'discount' | 'coupon' | 'invoice' | 'tracecoin_purchase'; label: string }[]}>
|
||||
{ key: 'CREDIT', label: 'Credit' },
|
||||
{ key: 'DEBIT', label: 'Debit' },
|
||||
{ key: 'RESERVE', label: 'Reserve' },
|
||||
{ key: 'RELEASE', label: 'Release' },
|
||||
{ key: 'ADJUSTMENT', label: 'Adjustment' },
|
||||
] as { key: 'all' | 'CREDIT' | 'DEBIT' | 'RESERVE' | 'RELEASE' | 'ADJUSTMENT'; label: string }[]}>
|
||||
{(item) => (
|
||||
<button type="button" onClick={() => { setTypeFilter(item.key); setFilterMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${typeFilter() === item.key ? '#FF5E13' : '#374151'};background:${typeFilter() === item.key ? '#FFF1EB' : 'transparent'}`}>{item.label}</button>
|
||||
)}
|
||||
|
|
@ -179,11 +197,11 @@ export default function LedgerPage() {
|
|||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Order ID</th>
|
||||
<th>Invoice ID</th>
|
||||
<th>User ID</th>
|
||||
<th>User</th>
|
||||
<th>Amount</th>
|
||||
<th>Note</th>
|
||||
<th>Balance After</th>
|
||||
<th>Reason</th>
|
||||
<th>Reference</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -200,7 +218,7 @@ export default function LedgerPage() {
|
|||
<Show when={!entries.loading && !entries.error && filteredEntries().length > 0}>
|
||||
<For each={filteredEntries()}>
|
||||
{(item) => {
|
||||
const entryType = item.entry_type || item.type || '—';
|
||||
const entryType = item.entry_type || '—';
|
||||
return (
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td>
|
||||
|
|
@ -208,11 +226,18 @@ export default function LedgerPage() {
|
|||
{entryType}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-slate-500" style="font-size:12px;font-family:monospace">{item.order_id || '—'}</td>
|
||||
<td class="text-slate-500" style="font-size:12px;font-family:monospace">{item.invoice_id || '—'}</td>
|
||||
<td class="text-slate-500" style="font-size:12px;font-family:monospace">{item.user_id || '—'}</td>
|
||||
<td class="font-semibold text-slate-900">{formatAmount(item)}</td>
|
||||
<td class="text-slate-500">{item.note || '—'}</td>
|
||||
<td class="text-slate-500">
|
||||
<div class="text-xs">{item.user_email || '—'}</div>
|
||||
<div class="text-xs font-mono text-slate-400">{item.user_id?.substring(0, 8) ?? '—'}…</div>
|
||||
</td>
|
||||
<td class="font-semibold" style={item.amount >= 0 ? 'color:#16a34a' : 'color:#dc2626'}>
|
||||
{formatAmount(item)}
|
||||
</td>
|
||||
<td class="text-slate-700">{item.balance_after != null ? item.balance_after : '—'}</td>
|
||||
<td class="text-slate-500 text-xs">{item.reason || '—'}</td>
|
||||
<td class="text-slate-500 text-xs font-mono">
|
||||
{item.reference_id ? item.reference_id.substring(0, 8) + '…' : '—'}
|
||||
</td>
|
||||
<td class="text-slate-500">{item.created_at ? new Date(item.created_at).toLocaleString() : '—'}</td>
|
||||
</tr>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,23 +2,41 @@ import { createResource, createSignal, createMemo, Show, For } from 'solid-js';
|
|||
|
||||
const API = '';
|
||||
|
||||
function getToken(): string {
|
||||
return typeof sessionStorage !== "undefined"
|
||||
? sessionStorage.getItem("nxtgauge_admin_access_token") || ""
|
||||
: "";
|
||||
}
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = getToken();
|
||||
return {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
type Order = {
|
||||
id: string;
|
||||
order_number?: string;
|
||||
user_name?: string;
|
||||
user_email?: string;
|
||||
package_name?: string;
|
||||
tracecoin_amount?: number;
|
||||
coupon_code?: string;
|
||||
total?: number;
|
||||
amount?: number;
|
||||
payu_txnid?: string;
|
||||
amount_inr: number;
|
||||
tracecoins_credited: number;
|
||||
status: string;
|
||||
issued_at?: string;
|
||||
paid_at?: string | null;
|
||||
created_at?: string;
|
||||
user_id: string;
|
||||
user_email?: string | null;
|
||||
package_name?: string | null;
|
||||
};
|
||||
|
||||
async function loadOrders(): Promise<Order[]> {
|
||||
try {
|
||||
const res = await fetch(`${API}/api/admin/orders`);
|
||||
const res = await fetch(`${API}/api/admin/orders?page=1&limit=200`, {
|
||||
headers: authHeaders(),
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to load');
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? data : (data.orders || []);
|
||||
|
|
@ -29,18 +47,19 @@ async function loadOrders(): Promise<Order[]> {
|
|||
|
||||
function statusClass(status: string): string {
|
||||
const s = (status || '').toUpperCase();
|
||||
if (s === 'PAID' || s === 'COMPLETED') return 'active';
|
||||
if (s === 'PAID' || s === 'COMPLETED' || s === 'SUCCESS') return 'active';
|
||||
if (s === 'PENDING') return 'pending';
|
||||
if (s === 'FAILED') return 'failed';
|
||||
if (s === 'REFUNDED' || s === 'VOID') return 'inactive';
|
||||
return '';
|
||||
}
|
||||
|
||||
function statusStyle(status: string): string {
|
||||
const s = (status || '').toUpperCase();
|
||||
if (s === 'PAID' || s === 'COMPLETED') return 'background:#dcfce7;color:#166534;border-color:#bbf7d0';
|
||||
if (s === 'PAID' || s === 'COMPLETED' || s === 'SUCCESS') return 'background:#dcfce7;color:#166534;border-color:#bbf7d0';
|
||||
if (s === 'PENDING') return 'background:#fff7ed;color:#c2410c;border-color:#fed7aa';
|
||||
if (s === 'FAILED') return 'background:#fee2e2;color:#991b1b;border-color:#fecaca';
|
||||
if (s === 'REFUNDED') return 'background:#f1f5f9;color:#475569;border-color:#e2e8f0';
|
||||
if (s === 'REFUNDED' || s === 'VOID') return 'background:#f1f5f9;color:#475569;border-color:#e2e8f0';
|
||||
return '';
|
||||
}
|
||||
|
||||
|
|
@ -57,28 +76,28 @@ export default function OrderPage() {
|
|||
let data = orders() ?? [];
|
||||
if (q) {
|
||||
data = data.filter((o) => {
|
||||
const orderNum = (o.order_number || o.id || '').toLowerCase();
|
||||
const email = (o.user_email || '').toLowerCase();
|
||||
const name = (o.user_name || '').toLowerCase();
|
||||
const status = (o.status || '').toLowerCase();
|
||||
return orderNum.includes(q) || email.includes(q) || name.includes(q) || status.includes(q);
|
||||
const txn = (o.payu_txnid || o.id || '').toLowerCase();
|
||||
const email = (o.user_email || '').toLowerCase();
|
||||
const pkg = (o.package_name || '').toLowerCase();
|
||||
const status = (o.status || '').toLowerCase();
|
||||
return txn.includes(q) || email.includes(q) || pkg.includes(q) || status.includes(q);
|
||||
});
|
||||
}
|
||||
if (statusFilter() !== 'all') {
|
||||
data = data.filter((o) => {
|
||||
const st = (o.status || '').toLowerCase();
|
||||
if (statusFilter() === 'paid') return st === 'paid' || st === 'completed';
|
||||
if (statusFilter() === 'pending') return st === 'pending';
|
||||
if (statusFilter() === 'failed') return st === 'failed';
|
||||
const st = (o.status || '').toUpperCase();
|
||||
if (statusFilter() === 'paid') return st === 'PAID' || st === 'COMPLETED' || st === 'SUCCESS';
|
||||
if (statusFilter() === 'pending') return st === 'PENDING';
|
||||
if (statusFilter() === 'failed') return st === 'FAILED';
|
||||
return true;
|
||||
});
|
||||
}
|
||||
const sorted = [...data];
|
||||
sorted.sort((a, b) => {
|
||||
const aCreated = new Date(a.created_at || 0).getTime();
|
||||
const bCreated = new Date(b.created_at || 0).getTime();
|
||||
const aAmt = Number(a.total ?? a.amount ?? 0);
|
||||
const bAmt = Number(b.total ?? b.amount ?? 0);
|
||||
const aCreated = new Date(a.created_at || a.issued_at || 0).getTime();
|
||||
const bCreated = new Date(b.created_at || b.issued_at || 0).getTime();
|
||||
const aAmt = Number(a.amount_inr ?? 0);
|
||||
const bAmt = Number(b.amount_inr ?? 0);
|
||||
if (sortBy() === 'oldest') return aCreated - bCreated;
|
||||
if (sortBy() === 'amount_desc') return bAmt - aAmt;
|
||||
if (sortBy() === 'amount_asc') return aAmt - bAmt;
|
||||
|
|
@ -88,13 +107,12 @@ export default function OrderPage() {
|
|||
});
|
||||
|
||||
const exportCsv = () => {
|
||||
const headers = ['Order', 'User', 'Package', 'TraceCoins', 'Coupon', 'Total', 'Status', 'Created'];
|
||||
const headers = ['Order', 'User Email', 'Package', 'TraceCoins', 'Total', 'Status', 'Created'];
|
||||
const rows = filtered().map((item) => [
|
||||
item.order_number || item.id,
|
||||
item.user_name || item.user_email || '—',
|
||||
item.payu_txnid || item.id,
|
||||
item.user_email || '—',
|
||||
item.package_name || '—',
|
||||
item.tracecoin_amount ?? '—',
|
||||
item.coupon_code || '—',
|
||||
item.tracecoins_credited ?? '—',
|
||||
formatAmount(item),
|
||||
item.status || '—',
|
||||
item.created_at ? new Date(item.created_at).toLocaleString() : '—',
|
||||
|
|
@ -110,9 +128,8 @@ export default function OrderPage() {
|
|||
};
|
||||
|
||||
const formatAmount = (order: Order) => {
|
||||
const raw = order.total ?? order.amount;
|
||||
if (raw == null) return '—';
|
||||
return `₹${(raw / 100).toFixed(2)}`;
|
||||
if (order.amount_inr == null) return '—';
|
||||
return `₹${(order.amount_inr / 100).toFixed(2)}`;
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -187,7 +204,6 @@ export default function OrderPage() {
|
|||
<th>User</th>
|
||||
<th>Package</th>
|
||||
<th>TraceCoins</th>
|
||||
<th>Coupon</th>
|
||||
<th>Total</th>
|
||||
<th>Status</th>
|
||||
<th>Created At</th>
|
||||
|
|
@ -195,23 +211,24 @@ export default function OrderPage() {
|
|||
</thead>
|
||||
<tbody>
|
||||
<Show when={orders.loading}>
|
||||
<tr><td colspan="8" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
|
||||
<tr><td colspan="7" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
|
||||
</Show>
|
||||
<Show when={!orders.loading && orders.error}>
|
||||
<tr><td colspan="8" style="text-align:center;padding:32px;color:#b91c1c">Failed to load. Is the backend running?</td></tr>
|
||||
<tr><td colspan="7" style="text-align:center;padding:32px;color:#b91c1c">Failed to load. Is the backend running?</td></tr>
|
||||
</Show>
|
||||
<Show when={!orders.loading && !orders.error && filtered().length === 0}>
|
||||
<tr><td colspan="8" style="text-align:center;padding:32px;color:#94a3b8">No orders found.</td></tr>
|
||||
<tr><td colspan="7" style="text-align:center;padding:32px;color:#94a3b8">No orders found.</td></tr>
|
||||
</Show>
|
||||
<Show when={!orders.loading && !orders.error && filtered().length > 0}>
|
||||
<For each={filtered()}>
|
||||
{(item) => (
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="font-semibold text-slate-900" style="font-family:monospace">{item.order_number || item.id}</td>
|
||||
<td class="text-slate-500">{item.user_name || item.user_email || '—'}</td>
|
||||
<td class="font-semibold text-slate-900" style="font-family:monospace">
|
||||
{item.payu_txnid || item.id}
|
||||
</td>
|
||||
<td class="text-slate-500">{item.user_email || '—'}</td>
|
||||
<td class="text-slate-500">{item.package_name || '—'}</td>
|
||||
<td class="text-slate-500">{item.tracecoin_amount ?? '—'}</td>
|
||||
<td class="text-slate-500">{item.coupon_code || '—'}</td>
|
||||
<td class="text-slate-500">{item.tracecoins_credited ?? '—'}</td>
|
||||
<td class="text-slate-500">{formatAmount(item)}</td>
|
||||
<td>
|
||||
<span
|
||||
|
|
@ -221,7 +238,13 @@ export default function OrderPage() {
|
|||
{item.status || '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-slate-500">{item.created_at ? new Date(item.created_at).toLocaleString() : '—'}</td>
|
||||
<td class="text-slate-500">
|
||||
{item.created_at
|
||||
? new Date(item.created_at).toLocaleString()
|
||||
: item.issued_at
|
||||
? new Date(item.issued_at).toLocaleString()
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Show, createSignal, onMount } from 'solid-js';
|
||||
import { Show, createSignal, onMount } from "solid-js";
|
||||
|
||||
type PaymentGatewayConfig = {
|
||||
provider: string;
|
||||
|
|
@ -10,18 +10,22 @@ type PaymentGatewayConfig = {
|
|||
merchantId: string;
|
||||
apiKey: string;
|
||||
secretKey: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
};
|
||||
|
||||
const DEFAULT_CONFIG: PaymentGatewayConfig = {
|
||||
provider: 'Razorpay',
|
||||
provider: 'PayU',
|
||||
mode: 'sandbox',
|
||||
enabled: true,
|
||||
baseUrl: 'https://api.razorpay.com',
|
||||
baseUrl: 'https://test.payu.in',
|
||||
callbackUrl: '',
|
||||
webhookUrl: '',
|
||||
merchantId: '',
|
||||
apiKey: '',
|
||||
secretKey: '',
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
};
|
||||
|
||||
const READ_ENDPOINTS = [
|
||||
|
|
@ -55,6 +59,8 @@ function normalizePayload(payload: any): PaymentGatewayConfig {
|
|||
merchantId: String(src.merchantId || src.merchant_id || ''),
|
||||
apiKey: String(src.apiKey || src.api_key || ''),
|
||||
secretKey: String(src.secretKey || src.secret_key || ''),
|
||||
clientId: String(src.clientId || src.client_id || ''),
|
||||
clientSecret: String(src.clientSecret || src.client_secret || ''),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -64,6 +70,7 @@ export default function PaymentGatewayManagementPage() {
|
|||
const [error, setError] = createSignal('');
|
||||
const [success, setSuccess] = createSignal('');
|
||||
const [showSecret, setShowSecret] = createSignal(false);
|
||||
const [showClientSecret, setShowClientSecret] = createSignal(false);
|
||||
const [cfg, setCfg] = createSignal<PaymentGatewayConfig>(DEFAULT_CONFIG);
|
||||
|
||||
const load = async () => {
|
||||
|
|
@ -109,6 +116,8 @@ export default function PaymentGatewayManagementPage() {
|
|||
merchant_id: cfg().merchantId.trim(),
|
||||
api_key: cfg().apiKey.trim(),
|
||||
secret_key: cfg().secretKey.trim(),
|
||||
client_id: cfg().clientId.trim(),
|
||||
client_secret: cfg().clientSecret.trim(),
|
||||
};
|
||||
|
||||
let saved = false;
|
||||
|
|
@ -145,7 +154,7 @@ export default function PaymentGatewayManagementPage() {
|
|||
<div class="w-full space-y-6 pb-8">
|
||||
<div style="margin-bottom:1.5rem">
|
||||
<h1 class="text-[28px] font-bold leading-tight text-[#111827]">Payment Gateway Management</h1>
|
||||
<p class="mt-1 text-[14px] text-[#6B7280]">Manage Razorpay credentials, mode, and callback URLs for platform payments.</p>
|
||||
<p class="mt-1 text-[14px] text-[#6B7280]">Manage PayU credentials, mode, and callback URLs for platform payments.</p>
|
||||
</div>
|
||||
|
||||
<Show when={error()}>
|
||||
|
|
@ -163,35 +172,32 @@ export default function PaymentGatewayManagementPage() {
|
|||
<form onSubmit={save} class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class={labelCls}>Provider</label>
|
||||
<input class={inputCls} value={cfg().provider} onInput={(e) => setField('provider', e.currentTarget.value)} placeholder="Razorpay" />
|
||||
<input class={inputCls} value={cfg().provider} onInput={(e) => setField('provider', e.currentTarget.value)} placeholder="PayU" />
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelCls}>Mode</label>
|
||||
<select class={inputCls} value={cfg().mode} onChange={(e) => setField('mode', e.currentTarget.value === 'live' ? 'live' : 'sandbox')}>
|
||||
<option value="sandbox">Sandbox</option>
|
||||
<option value="live">Live</option>
|
||||
<option value="sandbox">Sandbox (Test)</option>
|
||||
<option value="live">Live (Production)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelCls}>Base API URL</label>
|
||||
<input class={inputCls} value={cfg().baseUrl} onInput={(e) => setField('baseUrl', e.currentTarget.value)} placeholder="https://api.provider.com" />
|
||||
<input class={inputCls} value={cfg().baseUrl} onInput={(e) => setField('baseUrl', e.currentTarget.value)} placeholder="https://test.payu.in" />
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelCls}>Merchant / Account ID</label>
|
||||
<input class={inputCls} value={cfg().merchantId} onInput={(e) => setField('merchantId', e.currentTarget.value)} placeholder="Optional internal reference" />
|
||||
<label class={labelCls}>Merchant Key</label>
|
||||
<input class={inputCls} value={cfg().apiKey} onInput={(e) => setField('apiKey', e.currentTarget.value)} placeholder="PayU merchant key" />
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelCls}>Razorpay Key ID</label>
|
||||
<input class={inputCls} value={cfg().apiKey} onInput={(e) => setField('apiKey', e.currentTarget.value)} placeholder="rzp_test_xxxxx" />
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelCls}>Razorpay Key Secret</label>
|
||||
<label class={labelCls}>Merchant Salt</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type={showSecret() ? 'text' : 'password'}
|
||||
class={inputCls}
|
||||
value={cfg().secretKey}
|
||||
onInput={(e) => setField('secretKey', e.currentTarget.value)}
|
||||
placeholder="PayU merchant salt"
|
||||
/>
|
||||
<button type="button" class="rounded-lg border border-gray-200 px-3 py-2 text-xs font-semibold text-gray-700 hover:bg-gray-50" onClick={() => setShowSecret((v) => !v)}>
|
||||
{showSecret() ? 'Hide' : 'Show'}
|
||||
|
|
@ -199,12 +205,31 @@ export default function PaymentGatewayManagementPage() {
|
|||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelCls}>Callback URL</label>
|
||||
<input class={inputCls} value={cfg().callbackUrl} onInput={(e) => setField('callbackUrl', e.currentTarget.value)} placeholder="https://yourapp.com/payment/callback" />
|
||||
<label class={labelCls}>Client ID (optional)</label>
|
||||
<input class={inputCls} value={cfg().clientId} onInput={(e) => setField('clientId', e.currentTarget.value)} placeholder="PayU OAuth client id" />
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelCls}>Client Secret (optional)</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type={showClientSecret() ? 'text' : 'password'}
|
||||
class={inputCls}
|
||||
value={cfg().clientSecret}
|
||||
onInput={(e) => setField('clientSecret', e.currentTarget.value)}
|
||||
placeholder="PayU OAuth client secret"
|
||||
/>
|
||||
<button type="button" class="rounded-lg border border-gray-200 px-3 py-2 text-xs font-semibold text-gray-700 hover:bg-gray-50" onClick={() => setShowClientSecret((v) => !v)}>
|
||||
{showClientSecret() ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelCls}>Callback URL (surl/furl)</label>
|
||||
<input class={inputCls} value={cfg().callbackUrl} onInput={(e) => setField('callbackUrl', e.currentTarget.value)} placeholder="https://yourapp.com/api/payments/payu-return" />
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelCls}>Webhook URL</label>
|
||||
<input class={inputCls} value={cfg().webhookUrl} onInput={(e) => setField('webhookUrl', e.currentTarget.value)} placeholder="https://yourapp.com/payment/webhook" />
|
||||
<input class={inputCls} value={cfg().webhookUrl} onInput={(e) => setField('webhookUrl', e.currentTarget.value)} placeholder="https://yourapp.com/api/payments/payu/webhook" />
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700">
|
||||
|
|
|
|||
|
|
@ -1,227 +1,187 @@
|
|||
import { createSignal, createMemo, onMount, Show, For } from "solid-js";
|
||||
|
||||
const API = "";
|
||||
|
||||
function getToken(): string {
|
||||
return typeof sessionStorage !== "undefined"
|
||||
? sessionStorage.getItem("nxtgauge_admin_access_token") || ""
|
||||
: "";
|
||||
}
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = getToken();
|
||||
return {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
type Package = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
package_type: string;
|
||||
applicable_roles: string[];
|
||||
tracecoins_amount: number;
|
||||
price: number;
|
||||
duration_days?: number;
|
||||
valid_from?: string;
|
||||
valid_until?: string;
|
||||
is_promotional: boolean;
|
||||
is_active: boolean;
|
||||
features?: any;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
is_available?: boolean;
|
||||
is_expired?: boolean;
|
||||
};
|
||||
import { createMemo, createResource, createSignal, onMount, Show, For } from 'solid-js';
|
||||
import {
|
||||
api,
|
||||
AdminPackage,
|
||||
CreatePackageInput,
|
||||
UpdatePackageInput,
|
||||
listAdminPackages,
|
||||
createAdminPackage,
|
||||
updateAdminPackage,
|
||||
deleteAdminPackage,
|
||||
} from '~/lib/api';
|
||||
|
||||
const PACKAGE_TYPES = [
|
||||
{ value: "TRACECOIN_BUNDLE", label: "Tracecoin Bundle" },
|
||||
{ value: "CONTACT_VIEWS", label: "Contact Views (Company)" },
|
||||
{ value: "JOB_POSTING", label: "Job Posting (Company)" },
|
||||
{ value: "LEAD_REQUEST", label: "Lead Request (Professional)" },
|
||||
{ value: "REQUIREMENT_SLOTS", label: "Requirement Slots (Customer)" },
|
||||
{ value: 'TRACECOIN_BUNDLE', label: 'Tracecoin Bundle' },
|
||||
{ value: 'CONTACT_VIEWS', label: 'Contact Views (Company)' },
|
||||
{ value: 'JOB_POSTING', label: 'Job Posting (Company)' },
|
||||
{ value: 'LEAD_REQUEST', label: 'Lead Request (Professional)' },
|
||||
{ value: 'REQUIREMENT_SLOTS', label: 'Requirement Slots (Customer)' },
|
||||
];
|
||||
|
||||
const ALL_ROLES = [
|
||||
"COMPANY",
|
||||
"CUSTOMER",
|
||||
"JOB_SEEKER",
|
||||
"PHOTOGRAPHER",
|
||||
"VIDEO_EDITOR",
|
||||
"GRAPHIC_DESIGNER",
|
||||
"SOCIAL_MEDIA_MANAGER",
|
||||
"FITNESS_TRAINER",
|
||||
"CATERING_SERVICE",
|
||||
"MAKEUP_ARTIST",
|
||||
"TUTOR",
|
||||
"DEVELOPER",
|
||||
"UGC_CONTENT_CREATOR",
|
||||
'COMPANY',
|
||||
'CUSTOMER',
|
||||
'JOB_SEEKER',
|
||||
'PHOTOGRAPHER',
|
||||
'VIDEO_EDITOR',
|
||||
'GRAPHIC_DESIGNER',
|
||||
'SOCIAL_MEDIA_MANAGER',
|
||||
'FITNESS_TRAINER',
|
||||
'CATERING_SERVICE',
|
||||
'MAKEUP_ARTIST',
|
||||
'TUTOR',
|
||||
'DEVELOPER',
|
||||
'UGC_CONTENT_CREATOR',
|
||||
];
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
COMPANY: "Company",
|
||||
CUSTOMER: "Customer",
|
||||
JOB_SEEKER: "Job Seeker",
|
||||
PHOTOGRAPHER: "Photographer",
|
||||
VIDEO_EDITOR: "Video Editor",
|
||||
GRAPHIC_DESIGNER: "Graphic Designer",
|
||||
SOCIAL_MEDIA_MANAGER: "Social Media Manager",
|
||||
FITNESS_TRAINER: "Fitness Trainer",
|
||||
CATERING_SERVICE: "Catering Service",
|
||||
MAKEUP_ARTIST: "Makeup Artist",
|
||||
TUTOR: "Tutor",
|
||||
DEVELOPER: "Developer",
|
||||
UGC_CONTENT_CREATOR: "UGC Creator",
|
||||
COMPANY: 'Company',
|
||||
CUSTOMER: 'Customer',
|
||||
JOB_SEEKER: 'Job Seeker',
|
||||
PHOTOGRAPHER: 'Photographer',
|
||||
VIDEO_EDITOR: 'Video Editor',
|
||||
GRAPHIC_DESIGNER: 'Graphic Designer',
|
||||
SOCIAL_MEDIA_MANAGER: 'Social Media Manager',
|
||||
FITNESS_TRAINER: 'Fitness Trainer',
|
||||
CATERING_SERVICE: 'Catering Service',
|
||||
MAKEUP_ARTIST: 'Makeup Artist',
|
||||
TUTOR: 'Tutor',
|
||||
DEVELOPER: 'Developer',
|
||||
UGC_CONTENT_CREATOR: 'UGC Creator',
|
||||
};
|
||||
|
||||
type SortMode = "name_asc" | "name_desc" | "price_asc" | "price_desc" | "coins_asc" | "coins_desc";
|
||||
type SortMode = 'name_asc' | 'name_desc' | 'price_asc' | 'price_desc' | 'coins_asc' | 'coins_desc';
|
||||
|
||||
const SORT_LABELS: Record<SortMode, string> = {
|
||||
name_asc: "Name A→Z",
|
||||
name_desc: "Name Z→A",
|
||||
price_asc: "Price ↑",
|
||||
price_desc: "Price ↓",
|
||||
coins_asc: "TraceCoins ↑",
|
||||
coins_desc: "TraceCoins ↓",
|
||||
name_asc: 'Name A→Z',
|
||||
name_desc: 'Name Z→A',
|
||||
price_asc: 'Price ↑',
|
||||
price_desc: 'Price ↓',
|
||||
coins_asc: 'TraceCoins ↑',
|
||||
coins_desc: 'TraceCoins ↓',
|
||||
};
|
||||
|
||||
export default function PricingPage() {
|
||||
const [rows, setRows] = createSignal<Package[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [loadError, setLoadError] = createSignal("");
|
||||
const [view, setView] = createSignal<"packages" | "create">("packages");
|
||||
const [packages, { refetch }] = createResource<AdminPackage[]>(async () => {
|
||||
const res = await listAdminPackages();
|
||||
const raw = res.data;
|
||||
if (Array.isArray(raw)) return raw;
|
||||
return (raw as any)?.packages ?? [];
|
||||
});
|
||||
const [view, setView] = createSignal<'packages' | 'create'>('packages');
|
||||
|
||||
// Filters
|
||||
const [search, setSearch] = createSignal("");
|
||||
const [typeFilter, setTypeFilter] = createSignal("all");
|
||||
const [statusFilter, setStatusFilter] = createSignal("all");
|
||||
const [sortBy, setSortBy] = createSignal<SortMode>("name_asc");
|
||||
const [search, setSearch] = createSignal('');
|
||||
const [typeFilter, setTypeFilter] = createSignal('all');
|
||||
const [statusFilter, setStatusFilter] = createSignal('all');
|
||||
const [sortBy, setSortBy] = createSignal<SortMode>('name_asc');
|
||||
const [sortOpen, setSortOpen] = createSignal(false);
|
||||
|
||||
// Inline edit
|
||||
const [editingId, setEditingId] = createSignal("");
|
||||
const [editName, setEditName] = createSignal("");
|
||||
const [editTracecoins, setEditTracecoins] = createSignal("");
|
||||
const [editPrice, setEditPrice] = createSignal("");
|
||||
const [editingId, setEditingId] = createSignal('');
|
||||
const [editName, setEditName] = createSignal('');
|
||||
const [editTracecoins, setEditTracecoins] = createSignal('');
|
||||
const [editPrice, setEditPrice] = createSignal('');
|
||||
const [editActive, setEditActive] = createSignal(true);
|
||||
const [editSaving, setEditSaving] = createSignal(false);
|
||||
const [editError, setEditError] = createSignal("");
|
||||
const [togglingId, setTogglingId] = createSignal("");
|
||||
const [editError, setEditError] = createSignal('');
|
||||
const [togglingId, setTogglingId] = createSignal('');
|
||||
const [deletingId, setDeletingId] = createSignal('');
|
||||
|
||||
// Create form
|
||||
const [cName, setCName] = createSignal("");
|
||||
const [cDescription, setCDescription] = createSignal("");
|
||||
const [cType, setCType] = createSignal("TRACECOIN_BUNDLE");
|
||||
const [cName, setCName] = createSignal('');
|
||||
const [cDescription, setCDescription] = createSignal('');
|
||||
const [cType, setCType] = createSignal('TRACECOIN_BUNDLE');
|
||||
const [cRoles, setCRoles] = createSignal<string[]>([]);
|
||||
const [cTracecoins, setCTracecoins] = createSignal("");
|
||||
const [cPrice, setCPrice] = createSignal("");
|
||||
const [cDuration, setCDuration] = createSignal("");
|
||||
const [cValidFrom, setCValidFrom] = createSignal("");
|
||||
const [cValidUntil, setCValidUntil] = createSignal("");
|
||||
const [cTracecoins, setCTracecoins] = createSignal('');
|
||||
const [cPrice, setCPrice] = createSignal('');
|
||||
const [cDuration, setCDuration] = createSignal('');
|
||||
const [cValidFrom, setCValidFrom] = createSignal('');
|
||||
const [cValidUntil, setCValidUntil] = createSignal('');
|
||||
const [cPromotional, setCPromotional] = createSignal(false);
|
||||
const [cSaving, setCsaving] = createSignal(false);
|
||||
const [cError, setCError] = createSignal("");
|
||||
const [cError, setCError] = createSignal('');
|
||||
const [roleDropdownOpen, setRoleDropdownOpen] = createSignal(false);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setLoadError("");
|
||||
try {
|
||||
const res = await fetch(`${API}/api/packages`, {
|
||||
headers: authHeaders(),
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error(`Request failed (${res.status})`);
|
||||
const data = await res.json();
|
||||
setRows(Array.isArray(data) ? data : (data.data ?? []));
|
||||
} catch (err: any) {
|
||||
setLoadError(err.message || "Could not load packages.");
|
||||
setRows([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => void load());
|
||||
|
||||
const filteredRows = createMemo(() => {
|
||||
let r = rows();
|
||||
let r = packages() ?? [];
|
||||
const q = search().toLowerCase();
|
||||
if (q)
|
||||
r = r.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
p.package_type.toLowerCase().includes(q) ||
|
||||
p.applicable_roles.some((r) => r.toLowerCase().includes(q))
|
||||
(p.applicable_roles ?? []).some((rl) => rl.toLowerCase().includes(q)),
|
||||
);
|
||||
if (typeFilter() !== "all") r = r.filter((p) => p.package_type === typeFilter());
|
||||
if (statusFilter() === "active") r = r.filter((p) => p.is_active);
|
||||
if (statusFilter() === "inactive") r = r.filter((p) => !p.is_active);
|
||||
if (typeFilter() !== 'all') r = r.filter((p) => p.package_type === typeFilter());
|
||||
if (statusFilter() === 'active') r = r.filter((p) => p.is_active);
|
||||
if (statusFilter() === 'inactive') r = r.filter((p) => !p.is_active);
|
||||
const sorted = [...r];
|
||||
const mode = sortBy();
|
||||
sorted.sort((a, b) => {
|
||||
if (mode === "name_desc") return b.name.localeCompare(a.name);
|
||||
if (mode === "price_asc") return a.price - b.price;
|
||||
if (mode === "price_desc") return b.price - a.price;
|
||||
if (mode === "coins_asc") return a.tracecoins_amount - b.tracecoins_amount;
|
||||
if (mode === "coins_desc") return b.tracecoins_amount - a.tracecoins_amount;
|
||||
if (mode === 'name_desc') return b.name.localeCompare(a.name);
|
||||
if (mode === 'price_asc') return a.price - b.price;
|
||||
if (mode === 'price_desc') return b.price - a.price;
|
||||
if (mode === 'coins_asc') return a.tracecoins_amount - b.tracecoins_amount;
|
||||
if (mode === 'coins_desc') return b.tracecoins_amount - a.tracecoins_amount;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
return sorted;
|
||||
});
|
||||
|
||||
const startEdit = (pkg: Package) => {
|
||||
const startEdit = (pkg: AdminPackage) => {
|
||||
setEditingId(pkg.id);
|
||||
setEditName(pkg.name);
|
||||
setEditTracecoins(String(pkg.tracecoins_amount));
|
||||
setEditPrice(String(pkg.price));
|
||||
setEditError("");
|
||||
setEditPrice(String(pkg.price_inr ?? pkg.price));
|
||||
setEditActive(!!pkg.is_active);
|
||||
setEditError('');
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingId("");
|
||||
setEditError("");
|
||||
setEditingId('');
|
||||
setEditError('');
|
||||
};
|
||||
|
||||
const saveEdit = async (id: string) => {
|
||||
setEditSaving(true);
|
||||
setEditError('');
|
||||
try {
|
||||
setEditSaving(true);
|
||||
setEditError("");
|
||||
const res = await fetch(`${API}/api/packages/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: authHeaders(),
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
name: editName(),
|
||||
tracecoins_amount: Number(editTracecoins()),
|
||||
price: Number(editPrice()),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to save");
|
||||
setEditingId("");
|
||||
await load();
|
||||
const update: UpdatePackageInput = {
|
||||
name: editName(),
|
||||
tracecoins_amount: Number(editTracecoins()),
|
||||
price_inr: Number(editPrice()),
|
||||
is_active: editActive(),
|
||||
};
|
||||
await updateAdminPackage(id, update);
|
||||
setEditingId('');
|
||||
await refetch();
|
||||
} catch (err: any) {
|
||||
setEditError(err.message || "Failed to save");
|
||||
setEditError(err?.message || 'Failed to save');
|
||||
} finally {
|
||||
setEditSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleActive = async (pkg: Package) => {
|
||||
const toggleActive = async (pkg: AdminPackage) => {
|
||||
setTogglingId(pkg.id);
|
||||
try {
|
||||
setTogglingId(pkg.id);
|
||||
await fetch(`${API}/api/packages/${pkg.id}`, {
|
||||
method: "PATCH",
|
||||
headers: authHeaders(),
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ is_active: !pkg.is_active }),
|
||||
});
|
||||
await load();
|
||||
} catch {
|
||||
/* ignore */
|
||||
await updateAdminPackage(pkg.id, { is_active: !pkg.is_active });
|
||||
await refetch();
|
||||
} catch (err: any) {
|
||||
console.error('toggleActive failed', err);
|
||||
} finally {
|
||||
setTogglingId("");
|
||||
setTogglingId('');
|
||||
}
|
||||
};
|
||||
|
||||
const deletePackage = async (pkg: AdminPackage) => {
|
||||
if (!confirm(`Delete package "${pkg.name}"? This cannot be undone.`)) return;
|
||||
setDeletingId(pkg.id);
|
||||
try {
|
||||
await deleteAdminPackage(pkg.id);
|
||||
await refetch();
|
||||
} catch (err: any) {
|
||||
alert(err?.message || 'Failed to delete package.');
|
||||
} finally {
|
||||
setDeletingId('');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -236,54 +196,50 @@ export default function PricingPage() {
|
|||
|
||||
const handleCreate = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setCsaving(true);
|
||||
setCError('');
|
||||
try {
|
||||
setCsaving(true);
|
||||
setCError("");
|
||||
const body: Record<string, any> = {
|
||||
const body: CreatePackageInput = {
|
||||
name: cName(),
|
||||
description: cDescription() || undefined,
|
||||
package_type: cType(),
|
||||
applicable_roles: cRoles(),
|
||||
tracecoins_amount: Number(cTracecoins()),
|
||||
price: Number(cPrice()),
|
||||
price_inr: Number(cPrice()),
|
||||
price: Number(cPrice()), // legacy field for old clients
|
||||
is_promotional: cPromotional(),
|
||||
is_active: true,
|
||||
};
|
||||
if (cDuration()) body.duration_days = Number(cDuration());
|
||||
if (cValidFrom()) body.valid_from = new Date(cValidFrom()).toISOString();
|
||||
if (cValidUntil()) body.valid_until = new Date(cValidUntil()).toISOString();
|
||||
|
||||
const res = await fetch(`${API}/api/packages`, {
|
||||
method: "POST",
|
||||
headers: authHeaders(),
|
||||
credentials: "include",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to create package");
|
||||
setCName("");
|
||||
setCDescription("");
|
||||
setCType("TRACECOIN_BUNDLE");
|
||||
await createAdminPackage(body);
|
||||
setCName('');
|
||||
setCDescription('');
|
||||
setCType('TRACECOIN_BUNDLE');
|
||||
setCRoles([]);
|
||||
setCTracecoins("");
|
||||
setCPrice("");
|
||||
setCDuration("");
|
||||
setCValidFrom("");
|
||||
setCValidUntil("");
|
||||
setCTracecoins('');
|
||||
setCPrice('');
|
||||
setCDuration('');
|
||||
setCValidFrom('');
|
||||
setCValidUntil('');
|
||||
setCPromotional(false);
|
||||
setView("packages");
|
||||
await load();
|
||||
setView('packages');
|
||||
await refetch();
|
||||
} catch (err: any) {
|
||||
setCError(err.message || "Failed to create");
|
||||
setCError(err?.message || 'Failed to create');
|
||||
} finally {
|
||||
setCsaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return "—";
|
||||
return new Date(dateStr).toLocaleDateString("en-IN", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
const formatDate = (dateStr?: string | null) => {
|
||||
if (!dateStr) return '—';
|
||||
return new Date(dateStr).toLocaleDateString('en-IN', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -300,27 +256,43 @@ export default function PricingPage() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Show when={packages.error}>
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
Failed to load packages: {String(packages.error)}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="bg-white border-b border-gray-200 px-6 flex items-center gap-8 sticky top-0 z-10">
|
||||
{(["packages", "create"] as const).map((t) => (
|
||||
<button
|
||||
type="button"
|
||||
class={
|
||||
view() === t
|
||||
? "py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium"
|
||||
: "py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors"
|
||||
}
|
||||
onClick={() => setView(t)}
|
||||
>
|
||||
{t === "packages" ? "Packages" : "Create Package"}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
class={
|
||||
view() === 'packages'
|
||||
? 'py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium'
|
||||
: 'py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors'
|
||||
}
|
||||
onClick={() => setView('packages')}
|
||||
>
|
||||
Packages
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={
|
||||
view() === 'create'
|
||||
? 'py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium'
|
||||
: 'py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors'
|
||||
}
|
||||
onClick={() => setView('create')}
|
||||
>
|
||||
Create Package
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{/* ── Packages list ── */}
|
||||
<Show when={view() === "packages"}>
|
||||
<div style="position:relative;margin-top:1.5rem;margin-left:-24px;margin-right:-24px;border-radius:0;border-left:none;border-right:none;overflow:visible;border-top:1px solid #E5E7EB;border-bottom:1px solid #E5E7EB;background:white;box-shadow:0 1px 3px rgba(0,0,0,0.06)">
|
||||
<Show when={view() === 'packages'}>
|
||||
<section
|
||||
class="rounded-xl border border-gray-200 bg-white shadow-sm"
|
||||
style="margin-top:1.5rem"
|
||||
>
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:14px 20px;border-bottom:1px solid #F3F4F6;flex-wrap:wrap">
|
||||
<input
|
||||
type="text"
|
||||
|
|
@ -353,15 +325,6 @@ export default function PricingPage() {
|
|||
onClick={() => setSortOpen(!sortOpen())}
|
||||
>
|
||||
Sort: {SORT_LABELS[sortBy()]}
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||
<path
|
||||
d="M3 4.5L6 7.5L9 4.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<Show when={sortOpen()}>
|
||||
<div style="position:absolute;top:38px;right:0;background:white;border:1px solid #e5e7eb;border-radius:12px;box-shadow:0 4px 16px rgba(0,0,0,.1);z-index:50;min-width:190px;padding:6px">
|
||||
|
|
@ -373,7 +336,7 @@ export default function PricingPage() {
|
|||
setSortBy(key);
|
||||
setSortOpen(false);
|
||||
}}
|
||||
style={`display:block;width:100%;text-align:left;padding:8px 12px;font-size:13px;border-radius:8px;border:none;cursor:pointer;background:${sortBy() === key ? "#FFF1EB" : "transparent"};color:${sortBy() === key ? "#FF5E13" : "#374151"};font-weight:${sortBy() === key ? "600" : "400"}`}
|
||||
style={`display:block;width:100%;text-align:left;padding:8px 12px;font-size:13px;border-radius:8px;border:none;cursor:pointer;background:${sortBy() === key ? '#FFF1EB' : 'transparent'};color:${sortBy() === key ? '#FF5E13' : '#374151'};font-weight:${sortBy() === key ? '600' : '400'}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
|
|
@ -384,50 +347,43 @@ export default function PricingPage() {
|
|||
</div>
|
||||
<button
|
||||
type="button"
|
||||
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #D1D5DB;background:#fff;padding:0 12px;font-size:12px;font-weight:600;color:#0f172a;cursor:pointer"
|
||||
onClick={load}
|
||||
class="btn-primary"
|
||||
style="height:34px;padding:0 12px;font-size:12px"
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={loadError()}>
|
||||
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{loadError()}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="table-card">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="data-table w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Applicable Roles</th>
|
||||
<th>TraceCoins</th>
|
||||
<th>Price (₹)</th>
|
||||
<th>Valid Period</th>
|
||||
<th>Status</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Show when={loading()}>
|
||||
<Show
|
||||
when={!packages.loading}
|
||||
fallback={
|
||||
<p style="padding:32px;text-align:center;color:#64748b">Loading packages...</p>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={filteredRows().length > 0}
|
||||
fallback={
|
||||
<p style="padding:32px;text-align:center;color:#94a3b8">
|
||||
No packages found.
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="data-table w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<td colspan="8" style="text-align:center;padding:32px;color:#64748b">
|
||||
Loading...
|
||||
</td>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Applicable Roles</th>
|
||||
<th>TraceCoins</th>
|
||||
<th>Price (₹)</th>
|
||||
<th>Valid Period</th>
|
||||
<th>Status</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</Show>
|
||||
<Show when={!loading() && filteredRows().length === 0}>
|
||||
<tr>
|
||||
<td colspan="8" style="text-align:center;padding:32px;color:#94a3b8">
|
||||
No packages found.
|
||||
</td>
|
||||
</tr>
|
||||
</Show>
|
||||
<Show when={!loading() && filteredRows().length > 0}>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={filteredRows()}>
|
||||
{(pkg) => (
|
||||
<>
|
||||
|
|
@ -435,26 +391,32 @@ export default function PricingPage() {
|
|||
<td class="font-semibold text-slate-900">
|
||||
{pkg.name}
|
||||
{pkg.is_promotional && (
|
||||
<span style="margin-left:6px;font-size:10px;background:#FEF3C7;color:#D97706;padding:1px 6px;border-radius:4px">
|
||||
<span
|
||||
style="margin-left:6px;font-size:10px;background:#FEF3C7;color:#D97706;padding:1px 6px;border-radius:4px"
|
||||
>
|
||||
PROMO
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span style="display:inline-block;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:600;background:#e0f2fe;color:#0369a1">
|
||||
<span
|
||||
style="display:inline-block;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:600;background:#e0f2fe;color:#0369a1"
|
||||
>
|
||||
{getTypeLabel(pkg.package_type)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:4px">
|
||||
<For each={pkg.applicable_roles.slice(0, 3)}>
|
||||
<For each={(pkg.applicable_roles ?? []).slice(0, 3)}>
|
||||
{(role) => (
|
||||
<span style="display:inline-block;padding:1px 6px;border-radius:4px;font-size:10px;background:#f1f5f9;color:#475569">
|
||||
<span
|
||||
style="display:inline-block;padding:1px 6px;border-radius:4px;font-size:10px;background:#f1f5f9;color:#475569"
|
||||
>
|
||||
{ROLE_LABELS[role] || role}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
{pkg.applicable_roles.length > 3 && (
|
||||
{(pkg.applicable_roles ?? []).length > 3 && (
|
||||
<span style="font-size:10px;color:#64748b">
|
||||
+{pkg.applicable_roles.length - 3}
|
||||
</span>
|
||||
|
|
@ -462,48 +424,61 @@ export default function PricingPage() {
|
|||
</div>
|
||||
</td>
|
||||
<td class="text-slate-700 font-medium">{pkg.tracecoins_amount}</td>
|
||||
<td class="text-slate-700">₹{pkg.price.toLocaleString("en-IN")}</td>
|
||||
<td class="text-slate-700">₹{(pkg.price_inr ?? pkg.price).toLocaleString('en-IN')}</td>
|
||||
<td style="font-size:12px;color:#64748b">
|
||||
{pkg.valid_from || pkg.valid_until ? (
|
||||
<>
|
||||
{formatDate(pkg.valid_from)} - {formatDate(pkg.valid_until)}
|
||||
</>
|
||||
) : (
|
||||
"Always"
|
||||
'Always'
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
style={`display:inline-flex;align-items:center;border-radius:9999px;border:1px solid ${pkg.is_active ? "#FFD8C2" : "#D1D5DB"};background:${pkg.is_active ? "#FFF1EB" : "#F3F4F6"};color:${pkg.is_active ? "#FF5E13" : "#4B5563"};padding:2px 10px;font-size:12px;font-weight:500`}
|
||||
style={`display:inline-flex;align-items:center;border-radius:9999px;border:1px solid ${pkg.is_active ? '#FFD8C2' : '#D1D5DB'};background:${pkg.is_active ? '#FFF1EB' : '#F3F4F6'};color:${pkg.is_active ? '#FF5E13' : '#4B5563'};padding:2px 10px;font-size:12px;font-weight:500`}
|
||||
>
|
||||
<span
|
||||
style={`display:inline-block;width:6px;height:6px;border-radius:50%;background:${pkg.is_active ? "#FF5E13" : "#9CA3AF"};margin-right:5px`}
|
||||
style={`display:inline-block;width:6px;height:6px;border-radius:50%;background:${pkg.is_active ? '#FF5E13' : '#9CA3AF'};margin-right:5px`}
|
||||
/>
|
||||
{pkg.is_active ? "Active" : "Inactive"}
|
||||
{pkg.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button
|
||||
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
style="padding:6px 10px;font-size:12px"
|
||||
onClick={() => startEdit(pkg)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
class={
|
||||
type="button"
|
||||
class={pkg.is_active ? 'btn-secondary' : 'btn-primary'}
|
||||
style={
|
||||
pkg.is_active
|
||||
? "inline-flex items-center rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-100 transition-colors"
|
||||
: "btn-primary"
|
||||
? 'padding:6px 10px;font-size:12px;color:#b91c1c;border-color:#fca5a5'
|
||||
: 'padding:6px 10px;font-size:12px'
|
||||
}
|
||||
disabled={togglingId() === pkg.id}
|
||||
onClick={() => toggleActive(pkg)}
|
||||
>
|
||||
{togglingId() === pkg.id
|
||||
? "..."
|
||||
? '...'
|
||||
: pkg.is_active
|
||||
? "Disable"
|
||||
: "Enable"}
|
||||
? 'Disable'
|
||||
: 'Enable'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
style="padding:6px 10px;font-size:12px;color:#b91c1c;border-color:#fca5a5"
|
||||
disabled={deletingId() === pkg.id}
|
||||
onClick={() => deletePackage(pkg)}
|
||||
>
|
||||
{deletingId() === pkg.id ? '...' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
|
|
@ -512,24 +487,32 @@ export default function PricingPage() {
|
|||
<tr>
|
||||
<td colspan="8" style="background:#f8fafc;padding:16px">
|
||||
<Show when={editError()}>
|
||||
<div class="mb-3 rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700">
|
||||
<div
|
||||
class="mb-3 rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700"
|
||||
>
|
||||
{editError()}
|
||||
</div>
|
||||
</Show>
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap;align-items:flex-end">
|
||||
<div
|
||||
style="display:flex;gap:12px;flex-wrap:wrap;align-items:flex-end"
|
||||
>
|
||||
<div>
|
||||
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px">
|
||||
<label
|
||||
style="display:block;font-size:12px;font-weight:600;margin-bottom:4px"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editName()}
|
||||
onInput={(e) => setEditName(e.currentTarget.value)}
|
||||
style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;width:180px"
|
||||
style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;width:220px"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px">
|
||||
<label
|
||||
style="display:block;font-size:12px;font-weight:600;margin-bottom:4px"
|
||||
>
|
||||
TraceCoins
|
||||
</label>
|
||||
<input
|
||||
|
|
@ -540,7 +523,9 @@ export default function PricingPage() {
|
|||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px">
|
||||
<label
|
||||
style="display:block;font-size:12px;font-weight:600;margin-bottom:4px"
|
||||
>
|
||||
Price (₹)
|
||||
</label>
|
||||
<input
|
||||
|
|
@ -550,16 +535,32 @@ export default function PricingPage() {
|
|||
style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;width:110px"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
style="display:block;font-size:12px;font-weight:600;margin-bottom:4px"
|
||||
>
|
||||
Active
|
||||
</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editActive()}
|
||||
onChange={(e) => setEditActive(e.currentTarget.checked)}
|
||||
/>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
style="padding:8px 14px;font-size:13px"
|
||||
disabled={editSaving()}
|
||||
onClick={() => saveEdit(pkg.id)}
|
||||
>
|
||||
{editSaving() ? "Saving..." : "Save"}
|
||||
{editSaving() ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
style="padding:8px 14px;font-size:13px"
|
||||
onClick={cancelEdit}
|
||||
>
|
||||
Cancel
|
||||
|
|
@ -572,24 +573,21 @@ export default function PricingPage() {
|
|||
</>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Show when={!loading()}>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="padding:10px 16px;font-size:12px;color:#64748b;border-top:1px solid #f1f5f9">
|
||||
{filteredRows().length} of {rows().length} packages
|
||||
{filteredRows().length} of {(packages() ?? []).length} packages
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
</Show>
|
||||
|
||||
{/* ── Create Package ── */}
|
||||
<Show when={view() === "create"}>
|
||||
<Show when={view() === 'create'}>
|
||||
<section
|
||||
class="rounded-xl border border-gray-200 bg-white shadow-sm p-6"
|
||||
style="max-width:600px"
|
||||
style="max-width:600px;margin-top:1.5rem"
|
||||
>
|
||||
<h2 style="margin:0 0 20px;font-size:16px;font-weight:700">New Package</h2>
|
||||
<Show when={cError()}>
|
||||
|
|
@ -647,7 +645,7 @@ export default function PricingPage() {
|
|||
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box;text-align:left;background:white;cursor:pointer"
|
||||
>
|
||||
{cRoles().length === 0
|
||||
? "Select roles..."
|
||||
? 'Select roles...'
|
||||
: `${cRoles().length} role(s) selected`}
|
||||
<span style="float:right">▼</span>
|
||||
</button>
|
||||
|
|
@ -768,7 +766,7 @@ export default function PricingPage() {
|
|||
</div>
|
||||
<div>
|
||||
<button class="btn-primary" type="submit" disabled={cSaving()}>
|
||||
{cSaving() ? "Creating..." : "Create Package"}
|
||||
{cSaving() ? 'Creating...' : 'Create Package'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -777,4 +775,4 @@ export default function PricingPage() {
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,9 +2,27 @@ import { createMemo, createResource, createSignal, Show, For } from 'solid-js';
|
|||
|
||||
const API = '';
|
||||
|
||||
function getToken(): string {
|
||||
return typeof sessionStorage !== "undefined"
|
||||
? sessionStorage.getItem("nxtgauge_admin_access_token") || ""
|
||||
: "";
|
||||
}
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = getToken();
|
||||
return {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadTaxes(): Promise<any[]> {
|
||||
try {
|
||||
const res = await fetch(`${API}/api/admin/tax`);
|
||||
const res = await fetch(`${API}/api/admin/tax`, {
|
||||
headers: authHeaders(),
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to load');
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? data : (data.taxes || data.tax || []);
|
||||
|
|
@ -28,6 +46,8 @@ export default function TaxPage() {
|
|||
const [name, setName] = createSignal('');
|
||||
const [rate, setRate] = createSignal('');
|
||||
const [description, setDescription] = createSignal('');
|
||||
const [taxType, setTaxType] = createSignal('GST');
|
||||
const [appliesTo, setAppliesTo] = createSignal('ALL');
|
||||
|
||||
const handleSave = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -36,13 +56,23 @@ export default function TaxPage() {
|
|||
setFormError('');
|
||||
const res = await fetch(`${API}/api/admin/tax`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name(), rate: Number(rate()), description: description() }),
|
||||
headers: authHeaders(),
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
name: name(),
|
||||
tax_type: taxType(),
|
||||
tax_rate: Number(rate()),
|
||||
applies_to: appliesTo(),
|
||||
description: description(),
|
||||
is_active: true,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to create tax');
|
||||
setName('');
|
||||
setRate('');
|
||||
setDescription('');
|
||||
setTaxType('GST');
|
||||
setAppliesTo('ALL');
|
||||
setShowForm(false);
|
||||
refetch();
|
||||
} catch (err: any) {
|
||||
|
|
@ -56,7 +86,11 @@ export default function TaxPage() {
|
|||
if (!confirm(`Delete tax "${taxName}"?`)) return;
|
||||
try {
|
||||
setDeleting(id);
|
||||
const res = await fetch(`${API}/api/admin/tax/${id}`, { method: 'DELETE' });
|
||||
const res = await fetch(`${API}/api/admin/tax/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders(),
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to delete');
|
||||
refetch();
|
||||
} catch {
|
||||
|
|
@ -135,6 +169,36 @@ export default function TaxPage() {
|
|||
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
|
||||
/>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Tax Type</label>
|
||||
<select
|
||||
value={taxType()}
|
||||
onChange={(e) => setTaxType(e.currentTarget.value)}
|
||||
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
|
||||
>
|
||||
<option value="GST">GST</option>
|
||||
<option value="CGST">CGST</option>
|
||||
<option value="SGST">SGST</option>
|
||||
<option value="IGST">IGST</option>
|
||||
<option value="VAT">VAT</option>
|
||||
<option value="CESS">CESS</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Applies To</label>
|
||||
<select
|
||||
value={appliesTo()}
|
||||
onChange={(e) => setAppliesTo(e.currentTarget.value)}
|
||||
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
|
||||
>
|
||||
<option value="ALL">All</option>
|
||||
<option value="PACKAGES">Packages</option>
|
||||
<option value="AI_CREDITS">AI Credits</option>
|
||||
<option value="LEAD_REQUESTS">Lead Requests</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Rate (%)</label>
|
||||
<input
|
||||
|
|
@ -255,13 +319,12 @@ export default function TaxPage() {
|
|||
</td>
|
||||
<td>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<a class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href={`/admin/tax/${item.id}/edit`}>Edit</a>
|
||||
<button
|
||||
class="inline-flex items-center rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-100 transition-colors"
|
||||
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
disabled={deleting() === item.id}
|
||||
onClick={() => handleDelete(item.id, item.name)}
|
||||
>
|
||||
{deleting() === item.id ? '...' : 'Delete'}
|
||||
{deleting() === item.id ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -65,7 +65,16 @@ export default function LoginPage() {
|
|||
const completeAdminLogin = () => {
|
||||
setAdminSession();
|
||||
const from = new URLSearchParams(window.location.search).get('from');
|
||||
navigate(from && from.startsWith('/admin') ? from : '/admin', { replace: true });
|
||||
let destination = '/admin';
|
||||
if (from) {
|
||||
try {
|
||||
const parsed = new URL(from, window.location.origin);
|
||||
if (parsed.origin === window.location.origin && parsed.pathname.startsWith('/admin')) {
|
||||
destination = parsed.pathname + parsed.search + parsed.hash;
|
||||
}
|
||||
} catch { /* ignore invalid URLs */ }
|
||||
}
|
||||
navigate(destination, { replace: true });
|
||||
};
|
||||
|
||||
const directSignIn = async () => {
|
||||
|
|
@ -119,8 +128,7 @@ export default function LoginPage() {
|
|||
setChallengeId(cId);
|
||||
setMaskedEmail(pickMaskedEmail(payload, trimmedEmail));
|
||||
setResetStep('verify');
|
||||
const dev = String(payload?.debugCode || payload?.data?.debugCode || '').trim();
|
||||
setInfo(dev ? `Code sent. [DEV: ${dev}]` : 'Reset code sent to your email.');
|
||||
setInfo('Reset code sent to your email.');
|
||||
} catch (e: any) { setError(String(e?.message || 'Failed to send reset code.')); }
|
||||
finally { setIsSubmitting(false); }
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue