Files
cfw-autumn/vite/src/views/customers2/components/table/customer-balance/CustomerBalanceTableColumns.tsx
2026-05-14 11:14:30 +01:00

578 lines
14 KiB
TypeScript

import type {
Entity,
FullCusEntWithFullCusProduct,
FullCustomer,
} from "@autumn/shared";
import {
cusEntsToBalance,
cusEntsToGrantedBalance,
cusEntsToPrepaidQuantity,
cusEntToPrepaidQuantity,
getRolloverFields,
isFreeCustomerEntitlement,
isPrepaidCustomerEntitlement,
nullish,
} from "@autumn/shared";
import {
BoxArrowDownIcon,
BracketsSquareIcon,
CaretRightIcon,
MoneyWavyIcon,
PulseIcon,
WalletIcon,
} from "@phosphor-icons/react";
import type { Row } from "@tanstack/react-table";
import { Trash } from "lucide-react";
import { AdminHover } from "@/components/general/AdminHover";
import { ToolbarButton } from "@/components/general/table-components/ToolbarButton";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/v2/dropdowns/DropdownMenu";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/v2/tooltips/Tooltip";
import { cn } from "@/lib/utils";
import { formatUnixToDateTimeString } from "@/utils/formatUtils/formatDateUtils";
import { getCusEntHoverTexts } from "@/views/admin/adminUtils";
import { useFeatureUsageBalance } from "@/views/customers2/hooks/useFeatureUsageBalance";
import { CustomerFeatureUsageBar } from "../customer-feature-usage/CustomerFeatureUsageBar";
import { FeatureBalanceDisplay } from "../customer-feature-usage/FeatureBalanceDisplay";
import type { CustomerBalanceRowData } from "./CustomerBalanceTable";
import {
canDeleteCustomerBalance,
getCustomerBalanceSourceParts,
} from "./customerBalanceUtils";
function getBalanceBillingIcon({
balance,
}: {
balance: FullCusEntWithFullCusProduct;
}) {
const size = 14;
const weight = "duotone" as const;
if (isFreeCustomerEntitlement(balance))
return {
icon: <BoxArrowDownIcon size={size} weight={weight} />,
color: "text-green-500",
label: "Included",
};
if (isPrepaidCustomerEntitlement(balance))
return {
icon: <WalletIcon size={size} weight={weight} />,
color: "text-orange-500",
label: "Prepaid price",
};
return {
icon: <MoneyWavyIcon size={size} weight={weight} />,
color: "text-yellow-500",
label: "Usage-based price",
};
}
function BalanceBillingIcon({
balance,
}: {
balance: FullCusEntWithFullCusProduct;
}) {
const { icon, color, label } = getBalanceBillingIcon({ balance });
return (
<Tooltip>
<TooltipTrigger asChild>
<div className={cn("shrink-0", color)}>{icon}</div>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
/** Computes balance values from a single entitlement (for sub-rows) */
function getIndividualEntValues({
ent,
entityId,
}: {
ent: FullCusEntWithFullCusProduct;
entityId: string | null;
}) {
const balance = cusEntsToBalance({
cusEnts: [ent],
entityId: entityId ?? undefined,
withRollovers: false,
});
const grantedBalance = cusEntsToGrantedBalance({
cusEnts: [ent],
entityId: entityId ?? undefined,
withRollovers: false,
});
const prepaidAllowance = cusEntsToPrepaidQuantity({
cusEnts: [ent],
sumAcrossEntities: nullish(entityId),
});
void grantedBalance;
void prepaidAllowance;
const rolloverBalance =
getRolloverFields({ cusEnt: ent, entityId: entityId ?? undefined })
?.balance ?? 0;
const quantity = ent.customer_product?.quantity || 1;
const allowance =
(ent.entitlement.allowance ?? 0) * quantity +
(entityId && ent.entities?.[entityId]
? (ent.entities[entityId].adjustment ?? ent.adjustment ?? 0)
: (ent.adjustment ?? 0)) +
cusEntToPrepaidQuantity({ cusEnt: ent });
return { balance, allowance, quantity, rolloverBalance };
}
// --- Usage cells ---
function ParentUsageCell({
ent,
fullCustomer,
entityId,
customerEntitlements,
}: {
ent: FullCusEntWithFullCusProduct;
fullCustomer: FullCustomer | null | undefined;
entityId: string | null;
customerEntitlements?: FullCusEntWithFullCusProduct[];
}) {
const {
allowance,
balance,
rolloverBalance,
initialAllowance,
usageType,
shouldShowOutOfBalance,
shouldShowUsed,
} = useFeatureUsageBalance({
fullCustomer,
featureId: ent.entitlement.feature.id,
entityId,
customerEntitlements,
});
if (ent.unlimited) {
return <span className="text-t4">Unlimited</span>;
}
return (
<FeatureBalanceDisplay
allowance={allowance}
initialAllowance={initialAllowance}
balance={balance}
rolloverBalance={rolloverBalance}
shouldShowOutOfBalance={shouldShowOutOfBalance}
shouldShowUsed={shouldShowUsed}
usageType={usageType}
/>
);
}
function SubRowUsageCell({
ent,
entityId,
}: {
ent: FullCusEntWithFullCusProduct;
entityId: string | null;
}) {
if (ent.unlimited) {
return <span className="text-t4">Unlimited</span>;
}
const { balance, allowance, rolloverBalance } = getIndividualEntValues({
ent,
entityId,
});
const shouldShowOutOfBalance = allowance > 0 || balance > 0;
const shouldShowUsed = balance < 0 || (balance === 0 && allowance <= 0);
return (
<FeatureBalanceDisplay
allowance={allowance}
initialAllowance={allowance}
balance={balance}
rolloverBalance={rolloverBalance}
shouldShowOutOfBalance={shouldShowOutOfBalance}
shouldShowUsed={shouldShowUsed}
usageType={ent.entitlement.feature.config?.usage_type}
/>
);
}
function UsageCell({
row,
fullCustomer,
entityId,
customerEntitlements,
}: {
row: Row<CustomerBalanceRowData>;
fullCustomer: FullCustomer | null | undefined;
entityId: string | null;
customerEntitlements?: FullCusEntWithFullCusProduct[];
}) {
if (row.depth > 0) {
return <SubRowUsageCell ent={row.original} entityId={entityId} />;
}
return (
<ParentUsageCell
customerEntitlements={customerEntitlements}
ent={row.original}
fullCustomer={fullCustomer}
entityId={entityId}
/>
);
}
// --- Bar cells ---
function BarCellContent({
ent,
allowance,
balance,
quantity,
}: {
ent: FullCusEntWithFullCusProduct;
allowance: number;
balance: number;
quantity: number;
}) {
const hasReset = ent.next_reset_at != null;
const hasExpiry = ent.expires_at != null;
return (
<div className="flex gap-3 items-center">
{hasExpiry ? (
<span className="text-t3 text-tiny flex justify-center !px-1 bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded-md min-w-30">
Expires {formatUnixToDateTimeString(ent.expires_at)}
</span>
) : (
<span
className={cn(
"text-t3 text-tiny flex justify-center !px-1 bg-muted rounded-md min-w-30",
hasReset ? "opacity-100" : "opacity-0",
)}
>
Resets {formatUnixToDateTimeString(ent.next_reset_at)}
</span>
)}
<div
className={cn(
"w-full max-w-50 flex justify-center pr-2 h-full items-center min-w-16",
(allowance ?? 0) > 0 ? "opacity-100" : "opacity-0",
)}
>
<CustomerFeatureUsageBar
allowance={allowance}
balance={balance}
quantity={quantity}
horizontal={true}
/>
</div>
</div>
);
}
function ParentBarCell({
ent,
fullCustomer,
entityId,
customerEntitlements,
}: {
ent: FullCusEntWithFullCusProduct;
fullCustomer: FullCustomer | null | undefined;
entityId: string | null;
customerEntitlements?: FullCusEntWithFullCusProduct[];
}) {
const { allowance, balance, quantity } = useFeatureUsageBalance({
fullCustomer,
featureId: ent.entitlement.feature.id,
entityId,
customerEntitlements,
});
return (
<BarCellContent
ent={ent}
allowance={allowance}
balance={balance}
quantity={quantity}
/>
);
}
function SubRowBarCell({
ent,
entityId,
}: {
ent: FullCusEntWithFullCusProduct;
entityId: string | null;
}) {
const { allowance, balance, quantity } = getIndividualEntValues({
ent,
entityId,
});
return (
<BarCellContent
ent={ent}
allowance={allowance}
balance={balance}
quantity={quantity}
/>
);
}
function BarCell({
row,
fullCustomer,
entityId,
customerEntitlements,
}: {
row: Row<CustomerBalanceRowData>;
fullCustomer: FullCustomer | null | undefined;
entityId: string | null;
customerEntitlements?: FullCusEntWithFullCusProduct[];
}) {
if (row.depth > 0) {
return <SubRowBarCell ent={row.original} entityId={entityId} />;
}
return (
<ParentBarCell
customerEntitlements={customerEntitlements}
ent={row.original}
fullCustomer={fullCustomer}
entityId={entityId}
/>
);
}
function BalanceActionsCell({
row,
onDeleteClick,
onRecordUsageClick,
onCheckBalanceClick,
}: {
row: Row<CustomerBalanceRowData>;
onDeleteClick?: (balance: FullCusEntWithFullCusProduct) => void;
onRecordUsageClick?: (balance: FullCusEntWithFullCusProduct) => void;
onCheckBalanceClick?: (balance: FullCusEntWithFullCusProduct) => void;
}) {
const isParentRow = row.depth === 0;
const canDelete =
!row.getCanExpand() && canDeleteCustomerBalance({ balance: row.original });
const canRecordUsage = isParentRow && !!onRecordUsageClick;
const canCheckBalance = isParentRow && !!onCheckBalanceClick;
if (!canDelete && !canRecordUsage && !canCheckBalance) return null;
return (
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ToolbarButton onClick={(event) => event.stopPropagation()} />
</DropdownMenuTrigger>
<DropdownMenuContent
className="text-t2"
align="end"
onClick={(event) => event.stopPropagation()}
>
{canRecordUsage && (
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation();
onRecordUsageClick(row.original);
}}
>
<div className="flex w-full items-center justify-between gap-2 text-sm">
Record usage
<PulseIcon size={12} className="text-t3" />
</div>
</DropdownMenuItem>
)}
{canCheckBalance && (
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation();
onCheckBalanceClick(row.original);
}}
>
<div className="flex w-full items-center justify-between gap-2 text-sm">
Check balance
<BracketsSquareIcon size={12} className="text-t3" />
</div>
</DropdownMenuItem>
)}
{canDelete && onDeleteClick && (
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation();
onDeleteClick(row.original);
}}
>
<div className="flex w-full items-center justify-between gap-2 text-sm">
Delete
<Trash size={12} className="text-t3" />
</div>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
// --- Column definitions ---
export const CustomerBalanceTableColumns = ({
fullCustomer,
entityId,
entities = [],
onDeleteClick,
onRecordUsageClick,
onCheckBalanceClick,
}: {
fullCustomer: FullCustomer | null | undefined;
entityId: string | null;
entities?: Entity[];
onDeleteClick?: (balance: FullCusEntWithFullCusProduct) => void;
onRecordUsageClick?: (balance: FullCusEntWithFullCusProduct) => void;
onCheckBalanceClick?: (balance: FullCusEntWithFullCusProduct) => void;
}) => [
{
header: "Feature",
accessorKey: "feature",
enableResizing: true,
minSize: 100,
cell: ({ row }: { row: Row<CustomerBalanceRowData> }) => {
const ent = row.original;
const isSubRow = row.depth > 0;
if (isSubRow) {
const { productName, intervalLabel, entityName } =
getCustomerBalanceSourceParts({ balance: ent, entities });
const hasPlan = !!ent.customer_product;
const metaParts = [intervalLabel, entityName]
.filter(Boolean)
.join(" · ");
if (!hasPlan) {
return (
<div className="flex items-center gap-2 min-w-0">
<BalanceBillingIcon balance={ent} />
<AdminHover
texts={getCusEntHoverTexts({
cusEnt: ent,
entities,
})}
>
<span className="text-t3 truncate text-xs">{metaParts}</span>
</AdminHover>
</div>
);
}
return (
<div className="flex flex-col gap-0.5 min-w-0">
<div className="flex items-center gap-2">
<BalanceBillingIcon balance={ent} />
<AdminHover
texts={getCusEntHoverTexts({
cusEnt: ent,
entities,
})}
>
<span className="text-t1 text-xs font-medium truncate">
{productName}
</span>
</AdminHover>
</div>
<span className="text-t3 text-xs truncate pl-5.5">{metaParts}</span>
</div>
);
}
const canExpand = row.getCanExpand();
const isExpanded = row.getIsExpanded();
return (
<div className="flex items-center gap-2">
{canExpand && (
<span
className={cn(
"inline-flex text-t3 transition-transform duration-200",
isExpanded && "rotate-90",
)}
>
<CaretRightIcon size={14} weight="bold" />
</span>
)}
<AdminHover
texts={getCusEntHoverTexts({
cusEnt: ent,
entities,
})}
>
<span className="font-medium text-t1 truncate">
{ent.entitlement.feature.name}
</span>
</AdminHover>
</div>
);
},
},
{
header: "Usage",
accessorKey: "usage",
cell: ({ row }: { row: Row<CustomerBalanceRowData> }) => (
<UsageCell
row={row}
fullCustomer={fullCustomer}
entityId={entityId}
customerEntitlements={
row.original.subRows?.length ? row.original.subRows : [row.original]
}
/>
),
},
{
header: "Bar",
size: 220,
accessorKey: "bar",
cell: ({ row }: { row: Row<CustomerBalanceRowData> }) => (
<BarCell
row={row}
fullCustomer={fullCustomer}
entityId={entityId}
customerEntitlements={
row.original.subRows?.length ? row.original.subRows : [row.original]
}
/>
),
},
{
id: "actions",
header: "",
size: 44,
cell: ({ row }: { row: Row<CustomerBalanceRowData> }) => (
<BalanceActionsCell
row={row}
onDeleteClick={onDeleteClick}
onRecordUsageClick={onRecordUsageClick}
onCheckBalanceClick={onCheckBalanceClick}
/>
),
},
];