Skip to content

Commit 2c728d3

Browse files
authored
Merge pull request #331 from GTBitsOfGood/319-item-splitting-and-granular-allocation
319 item splitting and granular allocation
2 parents 69ef0e7 + e448eb5 commit 2c728d3

3 files changed

Lines changed: 231 additions & 0 deletions

File tree

‎src/app/api/generalItems/[generalItemId]/lineItems/[lineItemId]/route.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from "@/util/errors";
1111
import { NextRequest, NextResponse } from "next/server";
1212
import { formDataToObject } from "@/util/formData";
13+
import { z } from "zod";
1314

1415
const updateSchema = singleLineItemSchema.partial();
1516

@@ -66,3 +67,35 @@ export async function DELETE(
6667
return errorResponse(error);
6768
}
6869
}
70+
71+
// splits line items
72+
export async function POST(
73+
request: NextRequest,
74+
{ params }: { params: Promise <{generalItemId: string; lineItemId: string}> }
75+
) {
76+
try {
77+
const session = await auth();
78+
if(!session?.user) {
79+
throw new AuthenticationError("Session required");
80+
}
81+
82+
UserService.checkPermission(session.user, "offerWrite");
83+
84+
const { generalItemId, lineItemId} = await params;
85+
86+
const body = await request.json()
87+
const splitSchema = z.object({
88+
quantities: z.array(z.number().int().positive()).min(2).max(7),
89+
});
90+
const parsed = splitSchema.safeParse(body);
91+
if (!parsed.success) {
92+
throw new Error(parsed.error.message);
93+
}
94+
const result = await LineItemService.splitLineItem(Number(lineItemId), Number(generalItemId), parsed.data.quantities);
95+
96+
return NextResponse.json(result, {status: 200 });
97+
98+
} catch (error) {
99+
return errorResponse(error);
100+
}
101+
}

‎src/components/chips/LineItemChipGroup.tsx‎

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { toast } from "react-hot-toast";
33
import { AdvancedBaseTableHandle } from "../baseTable/AdvancedBaseTable";
44
import { AllocationTableItem } from "../allocationTable/types";
55
import Chip from "./Chip";
6+
import { useState } from "react";
67

78
export interface PartnerDistributionSummary {
89
id: number;
@@ -92,6 +93,10 @@ function LineItemChip({
9293
isInteractionMode?: boolean;
9394
readOnly?: boolean;
9495
}) {
96+
const [isSplitting, setIsSplitting] = useState(false);
97+
const [isSplitLoading, setIsSplitLoading] = useState(false);
98+
const [numSplits, setNumSplits] = useState("");
99+
const [splitQuantities, setSplitQuantities] = useState<string[]>([]);
95100
const { apiClient } = useApiClient();
96101

97102
function updateLineItemInTable(updatedItem: Partial<typeof item>) {
@@ -103,6 +108,44 @@ function LineItemChip({
103108
}));
104109
}
105110

111+
async function splitItem() {
112+
if (isSplitLoading) return;
113+
setIsSplitLoading(true);
114+
try {
115+
const quantities = splitQuantities.map(Number);
116+
const response = await apiClient.post<{
117+
original: {id: number; quantity: number};
118+
newItem: (typeof item)[];
119+
}>(
120+
`/api/generalItems/${generalItemId}/lineItems/${item.id}`,
121+
{ body: JSON.stringify({ quantities }) }
122+
);
123+
124+
updateItem(generalItemId, (prev) => ({
125+
...prev,
126+
items: prev.items
127+
.map((it) =>
128+
it.id === item.id
129+
? {...it, quantity: response.original.quantity }
130+
: it
131+
)
132+
.concat(response.newItem.map((newItem) => ({
133+
...newItem,
134+
allocation: null,
135+
})))
136+
}));
137+
138+
toast.success("Line item split successfully!");
139+
setIsSplitting(false);
140+
setNumSplits("");
141+
setSplitQuantities([]);
142+
} catch (error) {
143+
toast.error((error as Error).message || "Failed to split line item.");
144+
} finally {
145+
setIsSplitLoading(false);
146+
}
147+
}
148+
106149
async function unallocateItem() {
107150
if (isInteractionMode) {
108151
updateLineItemInTable({
@@ -275,6 +318,96 @@ function LineItemChip({
275318
</button>
276319
))}
277320
</div>
321+
{!item.allocation && !isInteractionMode && item.quantity > 1 && (
322+
<div className="border-t border-gray-primary/20 mt-2 pt-2">
323+
{!isSplitting ? (
324+
<button
325+
onClick={() => setIsSplitting(true)}
326+
className="w-full text-center px-2 py-1 hover:bg-blue-primary/20 border border-blue-primary rounded transition-all duration-200"
327+
>
328+
Split by Quantity
329+
</button>
330+
) : (
331+
<div className="flex flex-col gap-2">
332+
<p className="text-gray-primary font-bold mb-1">Split Item (Total: {item.quantity})</p>
333+
<p className="text-sm text-gray-primary font-normal">
334+
Number of Splits
335+
</p>
336+
<input
337+
type="number"
338+
step={1}
339+
min={2}
340+
max={7}
341+
value={numSplits}
342+
onChange={(e) => {
343+
const val = e.target.value;
344+
setNumSplits(val);
345+
const n = Number(val);
346+
if (Number.isInteger(n) && n >= 2 && n <= 7) {
347+
setSplitQuantities(Array(n).fill(""));
348+
} else {
349+
setSplitQuantities([]);
350+
}
351+
}}
352+
placeholder="2 - 7"
353+
className="border border-gray-primary/20 rounded px-2 py-1 font-normal"
354+
/>
355+
{splitQuantities.length > 0 && (
356+
<div className="flex flex-col gap-1 mt-1">
357+
{splitQuantities.map((qty, i) => (
358+
<div key={i} className="flex items-center gap-2">
359+
<label className="text-sm text-gray-primary font-normal whitespace-nowrap">
360+
Line Item {i + 1}:
361+
</label>
362+
<input
363+
type="number"
364+
step={1}
365+
min={1}
366+
value={qty}
367+
onChange={(e) => {
368+
const updated = [...splitQuantities];
369+
updated[i] = e.target.value;
370+
setSplitQuantities(updated);
371+
}}
372+
placeholder="Qty"
373+
className="border border-gray-primary/20 rounded px-2 py-1 font-normal w-full"
374+
/>
375+
</div>
376+
))}
377+
<p className="flex justify-end text-s font-semibold text-gray-primary my-2">
378+
Total: {splitQuantities.reduce((sum, q) => sum + (Number(q) || 0), 0)} / {item.quantity}
379+
</p>
380+
</div>
381+
)}
382+
<div className="w-full flex justify-end gap-2">
383+
<button
384+
onClick={() => {
385+
setIsSplitting(false);
386+
setNumSplits("");
387+
setSplitQuantities([]);
388+
}}
389+
className="rounded border border-red-primary text-red-primary px-3 py-1 hover:bg-red-primary/10 transition-all duration-200"
390+
>
391+
Cancel
392+
</button>
393+
<button
394+
onClick={splitItem}
395+
disabled={
396+
isSplitLoading ||
397+
splitQuantities.length < 2 ||
398+
splitQuantities.some((q) => !q || !Number.isInteger(Number(q)) || Number(q) <= 0) ||
399+
splitQuantities.reduce((sum, q) => sum + (Number(q) || 0), 0) !== item.quantity
400+
}
401+
className="rounded bg-blue-primary text-white px-3 py-1 disabled:opacity-50"
402+
>
403+
Split
404+
</button>
405+
</div>
406+
</div>
407+
)}
408+
409+
</div>
410+
)}
278411
</div>
279412
)}
280413
/>

‎src/services/lineItemService.ts‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,71 @@ export class LineItemService {
201201
}
202202
}
203203

204+
static async splitLineItem(lineItemId: number, generalItemId: number, quantities: number[]) {
205+
return await db.$transaction(async (tx) => {
206+
await tx.$queryRaw`SELECT id FROM "LineItem" WHERE id = ${lineItemId} FOR UPDATE`;
207+
const lineItem = await tx.lineItem.findUnique({
208+
where: {id: lineItemId },
209+
include: {
210+
generalItem: {
211+
include: {
212+
donorOffer: {
213+
select: { state: true }
214+
}
215+
}
216+
},
217+
allocation: true
218+
}
219+
});
220+
221+
if (!lineItem) {
222+
throw new Error(`Line item with ID ${lineItemId} does not exist.`);
223+
}
224+
if (lineItem.generalItemId !== generalItemId) {
225+
throw new Error("Line item does not belong to this general item.");
226+
}
227+
if (lineItem.generalItem?.donorOffer?.state === "ARCHIVED") {
228+
throw new Error("Cannot split line items for archived donor offers.");
229+
}
230+
if (lineItem.allocation) {
231+
throw new Error("Cannot split an already allocated line item.");
232+
}
233+
if (quantities.length < 2 || quantities.length > 7) {
234+
throw new Error("Number of splits must be between 2 and 7");
235+
}
236+
if (quantities.some((q) => !Number.isInteger(q) || q <= 0)) {
237+
throw new Error("All quantities must be positive whole numbers.");
238+
}
239+
if (quantities.reduce((sum, q) => sum + q, 0) !== lineItem.quantity) {
240+
throw new Error(`Quantities must add up to ${lineItem.quantity}`);
241+
}
242+
243+
const original = await tx.lineItem.update({
244+
where: { id: lineItemId },
245+
data: { quantity: quantities[0] }
246+
});
247+
248+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
249+
const { id, allocation, generalItem, generalItemId: _gId , ...itemData } = lineItem;
250+
251+
const newItem = await Promise.all(
252+
quantities.slice(1).map((qty) =>
253+
tx.lineItem.create({
254+
data: {
255+
...itemData,
256+
quantity: qty,
257+
generalItem: {
258+
connect: { id: generalItemId },
259+
},
260+
},
261+
})
262+
)
263+
);
264+
265+
return { original, newItem };
266+
})
267+
}
268+
204269
static async getAllItems(
205270
filters?: Filters,
206271
page?: number,

0 commit comments

Comments
 (0)