Skip to content

Commit 85a7975

Browse files
authored
Merge pull request #2111 from dubinc/sales-improvements
Improve sales tab
2 parents a325c92 + 485bf3a commit 85a7975

File tree

11 files changed

+286
-56
lines changed

11 files changed

+286
-56
lines changed

apps/web/app/api/programs/[programId]/sales/count/route.ts

+30-5
Original file line numberDiff line numberDiff line change
@@ -35,25 +35,50 @@ export const GET = withWorkspace(
3535
},
3636
},
3737
_count: true,
38+
_sum: {
39+
amount: true,
40+
earnings: true,
41+
},
3842
});
3943

4044
const counts = salesCount.reduce(
4145
(acc, p) => {
42-
acc[p.status] = p._count;
46+
acc[p.status] = {
47+
count: p._count,
48+
amount: p._sum.amount ?? 0,
49+
earnings: p._sum.earnings ?? 0,
50+
};
4351
return acc;
4452
},
45-
{} as Record<CommissionStatus | "all", number>,
53+
{} as Record<
54+
CommissionStatus | "all",
55+
{
56+
count: number;
57+
amount: number;
58+
earnings: number;
59+
}
60+
>,
4661
);
4762

4863
// fill in missing statuses with 0
4964
Object.values(CommissionStatus).forEach((status) => {
5065
if (!(status in counts)) {
51-
counts[status] = 0;
66+
counts[status] = {
67+
count: 0,
68+
amount: 0,
69+
earnings: 0,
70+
};
5271
}
5372
});
5473

55-
counts.all = salesCount.reduce((acc, p) => acc + p._count, 0);
56-
74+
counts.all = salesCount.reduce(
75+
(acc, p) => ({
76+
count: acc.count + p._count,
77+
amount: acc.amount + (p._sum.amount ?? 0),
78+
earnings: acc.earnings + (p._sum.earnings ?? 0),
79+
}),
80+
{ count: 0, amount: 0, earnings: 0 },
81+
);
5782
return NextResponse.json(counts);
5883
},
5984
);

apps/web/app/app.dub.co/(dashboard)/[slug]/programs/[programId]/sales/page.tsx

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1+
"use client";
2+
13
import { PageContent } from "@/ui/layout/page-content";
24
import { MaxWidthWrapper } from "@dub/ui";
35
import { SaleStats } from "./sale-stats";
46
import { SaleTableBusiness } from "./sale-table";
7+
import { SaleToggle } from "./sale-toggle";
58

69
export default function ProgramSales() {
710
return (
8-
<PageContent title="Sales">
11+
<PageContent title="Sales" titleControls={<SaleToggle />}>
912
<MaxWidthWrapper>
1013
<SaleStats />
1114
<div className="mt-6">

apps/web/app/app.dub.co/(dashboard)/[slug]/programs/[programId]/sales/sale-stats.tsx

+40-6
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,32 @@ import { ProgramStatsFilter } from "@/ui/partners/program-stats-filter";
55
import { SaleStatusBadges } from "@/ui/partners/sale-status-badges";
66
import { useRouterStuff } from "@dub/ui";
77
import { Users } from "@dub/ui/icons";
8-
import { useParams } from "next/navigation";
8+
import { useParams, useSearchParams } from "next/navigation";
99

1010
export function SaleStats() {
1111
const { slug, programId } = useParams();
1212
const { queryParams } = useRouterStuff();
13-
13+
const searchParams = useSearchParams();
1414
const { salesCount, error } = useSalesCount();
1515

16+
const view = searchParams.get("view") || "sales";
17+
1618
return (
1719
<div className="xs:grid-cols-4 xs:divide-x xs:divide-y-0 grid divide-y divide-neutral-200 overflow-hidden rounded-lg border border-neutral-200">
1820
<ProgramStatsFilter
1921
label="All"
2022
href={`/${slug}/programs/${programId}/sales`}
21-
count={salesCount?.all}
23+
count={salesCount?.all.count}
24+
amount={
25+
view === "sales"
26+
? salesCount?.all.amount
27+
: view === "commissions"
28+
? salesCount?.all.earnings
29+
: undefined
30+
}
2231
icon={Users}
2332
iconClassName="text-neutral-600 bg-neutral-100"
33+
variant="loose"
2434
error={!!error}
2535
/>
2636
<ProgramStatsFilter
@@ -31,9 +41,17 @@ export function SaleStats() {
3141
getNewPath: true,
3242
}) as string
3343
}
34-
count={salesCount?.pending}
44+
count={salesCount?.pending.count}
45+
amount={
46+
view === "sales"
47+
? salesCount?.pending.amount
48+
: view === "commissions"
49+
? salesCount?.pending.earnings
50+
: undefined
51+
}
3552
icon={SaleStatusBadges.pending.icon}
3653
iconClassName={SaleStatusBadges.pending.className}
54+
variant="loose"
3755
error={!!error}
3856
/>
3957
<ProgramStatsFilter
@@ -44,9 +62,17 @@ export function SaleStats() {
4462
getNewPath: true,
4563
}) as string
4664
}
47-
count={salesCount?.processed}
65+
count={salesCount?.processed.count}
66+
amount={
67+
view === "sales"
68+
? salesCount?.processed.amount
69+
: view === "commissions"
70+
? salesCount?.processed.earnings
71+
: undefined
72+
}
4873
icon={SaleStatusBadges.processed.icon}
4974
iconClassName={SaleStatusBadges.processed.className}
75+
variant="loose"
5076
error={!!error}
5177
/>
5278
<ProgramStatsFilter
@@ -57,9 +83,17 @@ export function SaleStats() {
5783
getNewPath: true,
5884
}) as string
5985
}
60-
count={salesCount?.paid}
86+
count={salesCount?.paid.count}
87+
amount={
88+
view === "sales"
89+
? salesCount?.paid.amount
90+
: view === "commissions"
91+
? salesCount?.paid.earnings
92+
: undefined
93+
}
6194
icon={SaleStatusBadges.paid.icon}
6295
iconClassName={SaleStatusBadges.paid.className}
96+
variant="loose"
6397
error={!!error}
6498
/>
6599
</div>

apps/web/app/app.dub.co/(dashboard)/[slug]/programs/[programId]/sales/sale-table.tsx

+9-4
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,14 @@ const SaleTableBusinessInner = memo(
6060

6161
const { salesCount } = useSalesCount();
6262
const { data: sales, error } = useSWR<SaleResponse[]>(
63-
`/api/programs/${programId}/sales${getQueryString({
64-
workspaceId,
65-
})}`,
63+
`/api/programs/${programId}/sales${getQueryString(
64+
{
65+
workspaceId,
66+
},
67+
{
68+
exclude: ["view"],
69+
},
70+
)}`,
6671
fetcher,
6772
);
6873

@@ -190,7 +195,7 @@ const SaleTableBusinessInner = memo(
190195
thClassName: "border-l-0",
191196
tdClassName: "border-l-0",
192197
resourceName: (p) => `sale${p ? "s" : ""}`,
193-
rowCount: salesCount?.[status || "all"] ?? 0,
198+
rowCount: salesCount?.[status || "all"].count ?? 0,
194199
loading,
195200
error: error ? "Failed to load sales" : undefined,
196201
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"use client";
2+
3+
import { ToggleGroup, useRouterStuff } from "@dub/ui";
4+
import { useSearchParams } from "next/navigation";
5+
6+
const options = [
7+
{
8+
value: "sales",
9+
label: "Sales",
10+
},
11+
{
12+
value: "commissions",
13+
label: "Commissions",
14+
},
15+
];
16+
17+
export function SaleToggle() {
18+
const { queryParams } = useRouterStuff();
19+
const searchParams = useSearchParams();
20+
const view = searchParams.get("view") || "sales";
21+
22+
return (
23+
<ToggleGroup
24+
options={options}
25+
selected={view}
26+
selectAction={(option) => {
27+
queryParams({
28+
set: { view: option },
29+
});
30+
}}
31+
className="flex w-fit shrink-0 items-center gap-0.5 rounded-lg border-neutral-100 bg-neutral-100 p-0.5"
32+
optionClassName="h-9 flex items-center justify-center rounded-lg"
33+
indicatorClassName="border border-neutral-200 bg-white"
34+
/>
35+
);
36+
}

apps/web/app/app.dub.co/(dashboard)/[slug]/programs/[programId]/sales/use-sale-filters.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ export function useSaleFilters() {
8888
)}
8989
/>
9090
),
91-
right: nFormatter(salesCount?.[value] || 0, { full: true }),
91+
right: nFormatter(salesCount?.[value]?.count || 0, { full: true }),
9292
};
9393
}),
9494
},
@@ -175,7 +175,7 @@ function usePartnerFilterOptions(search: string) {
175175
...(selectedPartners
176176
?.filter((st) => !partners?.some((t) => t.id === st.id))
177177
?.map((st) => ({ ...st, hideDuringSearch: true })) ?? []),
178-
] as (EnrolledPartnerProps & { hideDuringSearch?: boolean })[]) ?? null;
178+
] as (EnrolledPartnerProps & { hideDuringSearch?: boolean })[]);
179179
}, [partnersLoading, partners, selectedPartners, searchParamsObj.partnerId]);
180180

181181
return { partners: result, partnersAsync };
@@ -216,7 +216,7 @@ function useCustomerFilterOptions(search: string) {
216216
...(selectedCustomers
217217
?.filter((st) => !customers?.some((t) => t.id === st.id))
218218
?.map((st) => ({ ...st, hideDuringSearch: true })) ?? []),
219-
] as (CustomerProps & { hideDuringSearch?: boolean })[]) ?? null;
219+
] as (CustomerProps & { hideDuringSearch?: boolean })[]);
220220
}, [
221221
customersLoading,
222222
customers,

apps/web/lib/swr/use-sales-count.ts

+9-4
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,15 @@ export default function useSalesCount(
1515
const { getQueryString } = useRouterStuff();
1616

1717
const { data: salesCount, error } = useSWR<SalesCount>(
18-
`/api/programs/${programId}/sales/count${getQueryString({
19-
workspaceId,
20-
...opts,
21-
})}`,
18+
`/api/programs/${programId}/sales/count${getQueryString(
19+
{
20+
workspaceId,
21+
...opts,
22+
},
23+
{
24+
exclude: ["view"],
25+
},
26+
)}`,
2227
fetcher,
2328
);
2429

apps/web/lib/types.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,14 @@ export type PartnersCount = Record<ProgramEnrollmentStatus | "all", number>;
359359

360360
export type SaleProps = z.infer<typeof ProgramSaleSchema>;
361361

362-
export type SalesCount = Record<CommissionStatus | "all", number>;
362+
export type SalesCount = Record<
363+
CommissionStatus | "all",
364+
{
365+
count: number;
366+
amount: number;
367+
earnings: number;
368+
}
369+
>;
363370

364371
export type SaleResponse = z.infer<typeof ProgramSaleResponseSchema>;
365372

+73
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { calculateSaleEarnings } from "@/lib/api/sales/calculate-sale-earnings";
2+
import { determinePartnerReward } from "@/lib/partners/determine-partner-reward";
3+
import { prisma } from "@dub/prisma";
4+
import { Prisma } from "@dub/prisma/client";
5+
import "dotenv-flow/config";
6+
7+
// update commissions for a program
8+
async function main() {
9+
const where: Prisma.CommissionWhereInput = {
10+
programId: "prog_MuZ3Tpycbzsp9c1FuYR03c4u",
11+
status: "pending",
12+
earnings: 0,
13+
};
14+
15+
const commissions = await prisma.commission.findMany({
16+
where,
17+
take: 50,
18+
});
19+
20+
const updatedCommissions = await Promise.all(
21+
commissions.map(async (commission) => {
22+
const reward = await determinePartnerReward({
23+
event: commission.type,
24+
partnerId: commission.partnerId,
25+
programId: commission.programId,
26+
});
27+
if (!reward) {
28+
return null;
29+
}
30+
// Recalculate the earnings based on the new amount
31+
const earnings = calculateSaleEarnings({
32+
reward,
33+
sale: {
34+
amount: commission.amount,
35+
quantity: commission.quantity,
36+
},
37+
});
38+
39+
return prisma.commission.update({
40+
where: { id: commission.id },
41+
data: {
42+
earnings,
43+
},
44+
});
45+
}),
46+
);
47+
console.table(updatedCommissions, [
48+
"id",
49+
"partnerId",
50+
"amount",
51+
"earnings",
52+
"createdAt",
53+
]);
54+
55+
const remainingCommissions = await prisma.commission.count({
56+
where,
57+
});
58+
console.log(`${remainingCommissions} commissions left to update`);
59+
const pendingCommissions = await prisma.commission.findMany({
60+
where: {
61+
...where,
62+
earnings: undefined,
63+
},
64+
});
65+
console.log(
66+
`${pendingCommissions.reduce((acc, curr) => acc + curr.amount, 0)} amount`,
67+
);
68+
console.log(
69+
`${pendingCommissions.reduce((acc, curr) => acc + curr.earnings, 0)} earnings`,
70+
);
71+
}
72+
73+
main();

apps/web/ui/partners/amount-row-item.tsx

+8-8
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,21 @@ export function AmountRowItem({
1717
maximumFractionDigits: 2,
1818
});
1919
if (status === PayoutStatus.pending) {
20-
if (!payoutsEnabled) {
20+
if (amount < MIN_PAYOUT_AMOUNT) {
2121
return (
22-
<Tooltip content="This partner does not have payouts enabled, which means they will not be able to receive any payouts from this program.">
22+
<Tooltip
23+
content={`Minimum payout amount is ${currencyFormatter(
24+
MIN_PAYOUT_AMOUNT / 100,
25+
)}. This payout will be accrued and processed during the next payout period.`}
26+
>
2327
<span className="cursor-default truncate text-neutral-400 underline decoration-dotted underline-offset-2">
2428
{display}
2529
</span>
2630
</Tooltip>
2731
);
28-
} else if (amount < MIN_PAYOUT_AMOUNT) {
32+
} else if (!payoutsEnabled) {
2933
return (
30-
<Tooltip
31-
content={`Minimum payout amount is ${currencyFormatter(
32-
MIN_PAYOUT_AMOUNT / 100,
33-
)}. This payout will be accrued and processed during the next payout period.`}
34-
>
34+
<Tooltip content="This partner does not have payouts enabled, which means they will not be able to receive any payouts from this program.">
3535
<span className="cursor-default truncate text-neutral-400 underline decoration-dotted underline-offset-2">
3636
{display}
3737
</span>

0 commit comments

Comments
 (0)