feat: turbostarter boilerplate
Production-ready Next.js boilerplate with: - Runtime env validation (fail-fast on missing vars) - Feature-gated config (S3, Stripe, email, OAuth) - Docker + Coolify deployment pipeline - PostgreSQL + pgvector, MinIO S3, Better Auth - TypeScript strict mode (no ignoreBuildErrors) - i18n (en/es), AI modules, billing, monitoring Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
290
apps/web/src/modules/admin/customers/data-table/columns.tsx
Normal file
290
apps/web/src/modules/admin/customers/data-table/columns.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { BillingStatus, config } from "@turbostarter/billing";
|
||||
import { isKey, useTranslation } from "@turbostarter/i18n";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@turbostarter/ui-web/avatar";
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { DataTableColumnHeader } from "@turbostarter/ui-web/data-table/data-table-column-header";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
} from "@turbostarter/ui-web/dropdown-menu";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
import { admin } from "~/modules/admin/lib/api";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
|
||||
import { CreditsDialog } from "../components/credits-dialog";
|
||||
import { invalidateCustomers } from "../server/invalidate";
|
||||
import { UpdateCustomerPlanModal } from "../update-customer-plan";
|
||||
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import type { User } from "@turbostarter/auth";
|
||||
import type { Customer } from "@turbostarter/billing";
|
||||
|
||||
export const CustomerActions = ({
|
||||
customer,
|
||||
}: {
|
||||
customer: Customer & { user: Pick<User, "name">; credits: number };
|
||||
}) => {
|
||||
const { t } = useTranslation(["common", "admin", "billing"]);
|
||||
const queryClient = useQueryClient();
|
||||
const [creditsDialogOpen, setCreditsDialogOpen] = useState(false);
|
||||
|
||||
const deleteCustomer = useMutation({
|
||||
...admin.mutations.customers.delete,
|
||||
onSuccess: async () => {
|
||||
await invalidateCustomers();
|
||||
await queryClient.invalidateQueries(
|
||||
admin.queries.users.getPlans({
|
||||
id: customer.userId,
|
||||
}),
|
||||
);
|
||||
toast.success(t("customers.customer.delete.success"));
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<span className="sr-only">{t("actions")}</span>
|
||||
<Icons.Ellipsis className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<UpdateCustomerPlanModal
|
||||
customer={customer}
|
||||
key={`update-plan-${customer.id}`}
|
||||
>
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||
{t("updatePlan")}
|
||||
</DropdownMenuItem>
|
||||
</UpdateCustomerPlanModal>
|
||||
<DropdownMenuItem onSelect={() => setCreditsDialogOpen(true)}>
|
||||
<Icons.HandCoins className="mr-2 size-4" />
|
||||
{t("manageCredits", { defaultValue: "Manage Credits" })}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{(() => {
|
||||
const isPending =
|
||||
deleteCustomer.isPending &&
|
||||
deleteCustomer.variables.id === customer.id;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() =>
|
||||
deleteCustomer.mutate({
|
||||
id: customer.id,
|
||||
})
|
||||
}
|
||||
disabled={isPending}
|
||||
key={`remove-${customer.id}`}
|
||||
>
|
||||
{t("delete")}
|
||||
{isPending && (
|
||||
<Icons.Loader2 className="ml-auto animate-spin text-current" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})()}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<CreditsDialog
|
||||
open={creditsDialogOpen}
|
||||
onOpenChange={setCreditsDialogOpen}
|
||||
customer={customer}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const useColumns = (): ColumnDef<
|
||||
Customer & { user: Pick<User, "name" | "image">; credits: number }
|
||||
>[] => {
|
||||
const { t, i18n } = useTranslation(["common", "billing"]);
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
return [
|
||||
{
|
||||
id: "q",
|
||||
accessorKey: "q",
|
||||
meta: {
|
||||
placeholder: `${t("searchPlaceholder")}`,
|
||||
variant: "text",
|
||||
},
|
||||
enableHiding: false,
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "user.name",
|
||||
accessorKey: "user.name",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t("name")} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<TurboLink
|
||||
href={pathsConfig.admin.users.user(row.original.userId)}
|
||||
className="group flex items-center gap-3"
|
||||
>
|
||||
<Avatar>
|
||||
<AvatarImage
|
||||
src={row.original.user.image ?? undefined}
|
||||
alt={row.original.user.name}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
<Icons.UserRound className="w-5" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="group-hover:text-primary truncate font-medium underline underline-offset-4">
|
||||
{row.original.user.name}
|
||||
</span>
|
||||
{row.original.userId === session?.user.id && (
|
||||
<Badge variant="outline">{t("you")}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TurboLink>
|
||||
);
|
||||
},
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
id: "customerId",
|
||||
accessorKey: "customerId",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t("id")} />
|
||||
),
|
||||
meta: {
|
||||
label: t("id"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "plan",
|
||||
accessorKey: "plan",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t("common:plan")} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const plan = config.plans.find((plan) => plan.id === row.original.plan);
|
||||
|
||||
if (!plan) {
|
||||
return <span>-</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant="outline">
|
||||
{isKey(plan.name, i18n, "billing") ? t(plan.name) : plan.name}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
label: t("common:plan"),
|
||||
variant: "multiSelect",
|
||||
options: Object.values(config.plans).map((plan) => ({
|
||||
label: isKey(plan.name, i18n, "billing") ? t(plan.name) : plan.name,
|
||||
value: plan.id,
|
||||
})),
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t("common:status")} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const statusKey = `status.${row.original.status?.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase())}`;
|
||||
|
||||
if (!row.original.status) {
|
||||
return <span>-</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant="secondary">
|
||||
{isKey(statusKey, i18n, "billing") ? t(statusKey) : statusKey}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
label: t("common:status"),
|
||||
variant: "multiSelect",
|
||||
options: Object.values(BillingStatus).map((status) => {
|
||||
const statusKey = `status.${status.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase())}`;
|
||||
return {
|
||||
label: isKey(statusKey, i18n, "billing") ? t(statusKey) : statusKey,
|
||||
value: status,
|
||||
};
|
||||
}),
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "credits",
|
||||
accessorKey: "credits",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Credits" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Icons.HandCoins className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium tabular-nums">
|
||||
{row.original.credits.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
label: "Credits",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "createdAt",
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => (
|
||||
<div className="ml-auto w-fit">
|
||||
<DataTableColumnHeader column={column} title={t("createdAt")} />
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="ml-auto text-right">
|
||||
{row.original.createdAt.toLocaleDateString(i18n.language)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
label: t("createdAt"),
|
||||
variant: "dateRange",
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<div className="ml-auto w-fit">
|
||||
<CustomerActions customer={row.original} />
|
||||
</div>
|
||||
),
|
||||
enableHiding: false,
|
||||
},
|
||||
];
|
||||
};
|
||||
Reference in New Issue
Block a user