Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from "@/util/errors";
import { NextRequest, NextResponse } from "next/server";
import { formDataToObject } from "@/util/formData";
import { z } from "zod";

const updateSchema = singleLineItemSchema.partial();

Expand Down Expand Up @@ -66,3 +67,35 @@ export async function DELETE(
return errorResponse(error);
}
}

// splits line items
export async function POST(
request: NextRequest,
{ params }: { params: Promise <{generalItemId: string; lineItemId: string}> }
) {
try {
const session = await auth();
if(!session?.user) {
throw new AuthenticationError("Session required");
}

UserService.checkPermission(session.user, "offerWrite");

const { generalItemId, lineItemId} = await params;

const body = await request.json()
const splitSchema = z.object({
quantities: z.array(z.number().int().positive()).min(2).max(7),
});
const parsed = splitSchema.safeParse(body);
if (!parsed.success) {
throw new Error(parsed.error.message);
}
const result = await LineItemService.splitLineItem(Number(lineItemId), Number(generalItemId), parsed.data.quantities);

return NextResponse.json(result, {status: 200 });

} catch (error) {
return errorResponse(error);
}
}
133 changes: 133 additions & 0 deletions src/components/chips/LineItemChipGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { toast } from "react-hot-toast";
import { AdvancedBaseTableHandle } from "../baseTable/AdvancedBaseTable";
import { AllocationTableItem } from "../allocationTable/types";
import Chip from "./Chip";
import { useState } from "react";

export interface PartnerDistributionSummary {
id: number;
Expand Down Expand Up @@ -92,6 +93,10 @@ function LineItemChip({
isInteractionMode?: boolean;
readOnly?: boolean;
}) {
const [isSplitting, setIsSplitting] = useState(false);
const [isSplitLoading, setIsSplitLoading] = useState(false);
const [numSplits, setNumSplits] = useState("");
const [splitQuantities, setSplitQuantities] = useState<string[]>([]);
const { apiClient } = useApiClient();

function updateLineItemInTable(updatedItem: Partial<typeof item>) {
Expand All @@ -103,6 +108,44 @@ function LineItemChip({
}));
}

async function splitItem() {
if (isSplitLoading) return;
setIsSplitLoading(true);
try {
const quantities = splitQuantities.map(Number);
const response = await apiClient.post<{
original: {id: number; quantity: number};
newItem: (typeof item)[];
}>(
`/api/generalItems/${generalItemId}/lineItems/${item.id}`,
{ body: JSON.stringify({ quantities }) }
);

updateItem(generalItemId, (prev) => ({
...prev,
items: prev.items
.map((it) =>
it.id === item.id
? {...it, quantity: response.original.quantity }
: it
)
.concat(response.newItem.map((newItem) => ({
...newItem,
allocation: null,
})))
}));

toast.success("Line item split successfully!");
setIsSplitting(false);
setNumSplits("");
setSplitQuantities([]);
} catch (error) {
toast.error((error as Error).message || "Failed to split line item.");
} finally {
setIsSplitLoading(false);
}
}

async function unallocateItem() {
if (isInteractionMode) {
updateLineItemInTable({
Expand Down Expand Up @@ -275,6 +318,96 @@ function LineItemChip({
</button>
))}
</div>
{!item.allocation && !isInteractionMode && item.quantity > 1 && (
<div className="border-t border-gray-primary/20 mt-2 pt-2">
{!isSplitting ? (
<button
onClick={() => setIsSplitting(true)}
className="w-full text-center px-2 py-1 hover:bg-blue-primary/20 border border-blue-primary rounded transition-all duration-200"
>
Split by Quantity
</button>
) : (
<div className="flex flex-col gap-2">
<p className="text-gray-primary font-bold mb-1">Split Item (Total: {item.quantity})</p>
<p className="text-sm text-gray-primary font-normal">
Number of Splits
</p>
<input
type="number"
step={1}
min={2}
max={7}
value={numSplits}
onChange={(e) => {
const val = e.target.value;
setNumSplits(val);
const n = Number(val);
if (Number.isInteger(n) && n >= 2 && n <= 7) {
setSplitQuantities(Array(n).fill(""));
} else {
setSplitQuantities([]);
Comment thread
ND68 marked this conversation as resolved.
}
}}
placeholder="2 - 7"
className="border border-gray-primary/20 rounded px-2 py-1 font-normal"
/>
{splitQuantities.length > 0 && (
<div className="flex flex-col gap-1 mt-1">
{splitQuantities.map((qty, i) => (
<div key={i} className="flex items-center gap-2">
<label className="text-sm text-gray-primary font-normal whitespace-nowrap">
Line Item {i + 1}:
</label>
<input
type="number"
step={1}
min={1}
value={qty}
onChange={(e) => {
const updated = [...splitQuantities];
updated[i] = e.target.value;
setSplitQuantities(updated);
}}
placeholder="Qty"
className="border border-gray-primary/20 rounded px-2 py-1 font-normal w-full"
/>
</div>
))}
<p className="flex justify-end text-s font-semibold text-gray-primary my-2">
Total: {splitQuantities.reduce((sum, q) => sum + (Number(q) || 0), 0)} / {item.quantity}
</p>
</div>
)}
<div className="w-full flex justify-end gap-2">
<button
onClick={() => {
setIsSplitting(false);
setNumSplits("");
setSplitQuantities([]);
}}
className="rounded border border-red-primary text-red-primary px-3 py-1 hover:bg-red-primary/10 transition-all duration-200"
>
Cancel
</button>
<button
onClick={splitItem}
disabled={
isSplitLoading ||
splitQuantities.length < 2 ||
splitQuantities.some((q) => !q || !Number.isInteger(Number(q)) || Number(q) <= 0) ||
splitQuantities.reduce((sum, q) => sum + (Number(q) || 0), 0) !== item.quantity
}
className="rounded bg-blue-primary text-white px-3 py-1 disabled:opacity-50"
Comment thread
ND68 marked this conversation as resolved.
>
Split
</button>
</div>
</div>
)}

</div>
)}
</div>
)}
/>
Expand Down
65 changes: 65 additions & 0 deletions src/services/lineItemService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,71 @@ export class LineItemService {
}
}

static async splitLineItem(lineItemId: number, generalItemId: number, quantities: number[]) {
return await db.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM "LineItem" WHERE id = ${lineItemId} FOR UPDATE`;
const lineItem = await tx.lineItem.findUnique({
where: {id: lineItemId },
include: {
generalItem: {
include: {
donorOffer: {
select: { state: true }
}
}
},
allocation: true
}
});

if (!lineItem) {
throw new Error(`Line item with ID ${lineItemId} does not exist.`);
}
if (lineItem.generalItemId !== generalItemId) {
throw new Error("Line item does not belong to this general item.");
}
if (lineItem.generalItem?.donorOffer?.state === "ARCHIVED") {
throw new Error("Cannot split line items for archived donor offers.");
}
if (lineItem.allocation) {
throw new Error("Cannot split an already allocated line item.");
}
if (quantities.length < 2 || quantities.length > 7) {
throw new Error("Number of splits must be between 2 and 7");
}
if (quantities.some((q) => !Number.isInteger(q) || q <= 0)) {
throw new Error("All quantities must be positive whole numbers.");
}
if (quantities.reduce((sum, q) => sum + q, 0) !== lineItem.quantity) {
throw new Error(`Quantities must add up to ${lineItem.quantity}`);
}

const original = await tx.lineItem.update({
where: { id: lineItemId },
data: { quantity: quantities[0] }
});

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { id, allocation, generalItem, generalItemId: _gId , ...itemData } = lineItem;

const newItem = await Promise.all(
quantities.slice(1).map((qty) =>
tx.lineItem.create({
data: {
...itemData,
quantity: qty,
generalItem: {
connect: { id: generalItemId },
},
},
})
)
);

return { original, newItem };
})
}

static async getAllItems(
filters?: Filters,
page?: number,
Expand Down
Loading