Skip to content

Commit 08a77c8

Browse files
authored
Merge pull request #387 from Code-4-Community/383-dev---ensure-edit-revenue-works
edit revenue
2 parents 1da382c + c2b410b commit 08a77c8

3 files changed

Lines changed: 359 additions & 11 deletions

File tree

Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
import { useState } from "react";
2+
import { faPlus } from "@fortawesome/free-solid-svg-icons";
3+
import { CashflowRevenue } from "../../../../../middle-layer/types/CashflowRevenue";
4+
import { Installment } from "../../../../../middle-layer/types/Installment";
5+
import { RevenueType } from "../../../../../middle-layer/types/RevenueType";
6+
import Button from "../../../components/Button";
7+
import InputField from "../../../components/InputField";
8+
import {
9+
saveRevenueEdits,
10+
} from "../processCashflowDataEditSave";
11+
import CashCategoryDropdown from "./CashCategoryDropdown";
12+
import CashRevenueInstallment, {
13+
EditableInstallment,
14+
} from "./CashRevenueInstallment";
15+
import { formatMoney } from "../CashFlowPage";
16+
17+
type CashEditRevenueProps = {
18+
revenueItem: CashflowRevenue;
19+
onClose: () => void;
20+
};
21+
22+
type FieldErrors = {
23+
type?: string;
24+
name?: string;
25+
singleAmount?: string;
26+
singleDate?: string;
27+
installments?: string;
28+
submit?: string;
29+
};
30+
31+
const EMPTY_INSTALLMENT: EditableInstallment = {
32+
amount: null,
33+
date: null,
34+
};
35+
36+
const toDateValue = (dateValue: Date | string | null | undefined) => {
37+
if (!dateValue) {
38+
return null;
39+
}
40+
41+
const parsedDate = new Date(dateValue);
42+
if (Number.isNaN(parsedDate.getTime())) {
43+
return null;
44+
}
45+
46+
return parsedDate;
47+
};
48+
49+
const toEditableInstallment = (
50+
installment: Installment,
51+
): EditableInstallment => ({
52+
amount: Number.isFinite(installment.amount) ? installment.amount : null,
53+
date: toDateValue(installment.date),
54+
});
55+
56+
export default function CashEditRevenue({
57+
revenueItem,
58+
onClose,
59+
}: CashEditRevenueProps) {
60+
const initialInstallments = revenueItem.installments.map(toEditableInstallment);
61+
const [name, setName] = useState<string>(revenueItem.name);
62+
const [type, setType] = useState<RevenueType | null>(revenueItem.type);
63+
const [isMultipleInstallments, setIsMultipleInstallments] = useState(
64+
initialInstallments.length > 1,
65+
);
66+
const [singleInstallment, setSingleInstallment] = useState<EditableInstallment>(
67+
initialInstallments[0] ?? EMPTY_INSTALLMENT,
68+
);
69+
const [installments, setInstallments] = useState<EditableInstallment[]>(
70+
initialInstallments.length > 1 ? initialInstallments : [],
71+
);
72+
const [errors, setErrors] = useState<FieldErrors>({});
73+
const [isSubmitting, setIsSubmitting] = useState(false);
74+
75+
const isValidInstallment = (installment: EditableInstallment) => {
76+
if (installment.amount === null || installment.date === null) {
77+
return false;
78+
}
79+
80+
return (
81+
Number.isFinite(installment.amount) &&
82+
installment.amount > 0 &&
83+
!Number.isNaN(installment.date.getTime())
84+
);
85+
};
86+
87+
const toInstallment = (installment: EditableInstallment): Installment => ({
88+
amount: installment.amount as number,
89+
date: installment.date as Date,
90+
});
91+
92+
const buildPayload = (): CashflowRevenue | null => {
93+
const nextErrors: FieldErrors = {};
94+
95+
if (!type) {
96+
nextErrors.type = "Please select a category.";
97+
}
98+
99+
if (!name.trim()) {
100+
nextErrors.name = "Please enter a name.";
101+
}
102+
103+
let cleanedInstallments: Installment[] = [];
104+
if (isMultipleInstallments) {
105+
const allValid =
106+
installments.length > 0 && installments.every(isValidInstallment);
107+
108+
if (!allValid) {
109+
nextErrors.installments =
110+
"Please fill all installment amounts and dates with valid values.";
111+
} else {
112+
cleanedInstallments = installments.map(toInstallment);
113+
}
114+
} else {
115+
if (singleInstallment.amount === null || singleInstallment.amount <= 0) {
116+
nextErrors.singleAmount = "Amount must be greater than 0.";
117+
}
118+
119+
if (
120+
singleInstallment.date === null ||
121+
Number.isNaN(singleInstallment.date.getTime())
122+
) {
123+
nextErrors.singleDate = "Please enter a valid date.";
124+
}
125+
126+
if (!nextErrors.singleAmount && !nextErrors.singleDate) {
127+
cleanedInstallments = [toInstallment(singleInstallment)];
128+
}
129+
}
130+
131+
setErrors(nextErrors);
132+
if (Object.keys(nextErrors).length > 0 || !type) {
133+
return null;
134+
}
135+
136+
const totalAmount = cleanedInstallments.reduce(
137+
(sum, installment) => sum + installment.amount,
138+
0,
139+
);
140+
141+
return {
142+
amount: totalAmount,
143+
type,
144+
name: name.trim(),
145+
installments: cleanedInstallments,
146+
};
147+
};
148+
149+
const addInstallment = () => {
150+
if (!isMultipleInstallments) {
151+
setInstallments([singleInstallment, EMPTY_INSTALLMENT]);
152+
setIsMultipleInstallments(true);
153+
return;
154+
}
155+
156+
setInstallments((previousInstallments) => [
157+
...previousInstallments,
158+
EMPTY_INSTALLMENT,
159+
]);
160+
};
161+
162+
const updateInstallment = (
163+
installmentIndex: number,
164+
key: "amount" | "date",
165+
value: number | Date | null,
166+
) => {
167+
setInstallments((previousInstallments) =>
168+
previousInstallments.map((installment, index) =>
169+
index === installmentIndex
170+
? { ...installment, [key]: value }
171+
: installment,
172+
),
173+
);
174+
};
175+
176+
const removeInstallment = (installmentIndex: number) => {
177+
setInstallments((previousInstallments) => {
178+
const updatedInstallments = previousInstallments.filter(
179+
(_, index) => index !== installmentIndex,
180+
);
181+
182+
if (updatedInstallments.length <= 1) {
183+
setIsMultipleInstallments(false);
184+
setSingleInstallment(updatedInstallments[0] ?? EMPTY_INSTALLMENT);
185+
return [];
186+
}
187+
188+
return updatedInstallments;
189+
});
190+
};
191+
192+
const totalAmount = isMultipleInstallments
193+
? installments.reduce(
194+
(sum, installment) => sum + (installment.amount ?? 0),
195+
0,
196+
)
197+
: (singleInstallment.amount ?? 0);
198+
199+
const handleSave = async () => {
200+
const payload = buildPayload();
201+
if (!payload) {
202+
return;
203+
}
204+
205+
setIsSubmitting(true);
206+
setErrors((previous) => ({ ...previous, submit: undefined }));
207+
208+
const result = await saveRevenueEdits(revenueItem.name, payload);
209+
if (!result.success) {
210+
setErrors((previous) => ({
211+
...previous,
212+
submit: result.error || "Unable to update revenue source.",
213+
}));
214+
setIsSubmitting(false);
215+
return;
216+
}
217+
218+
setIsSubmitting(false);
219+
onClose();
220+
};
221+
222+
return (
223+
<div className="flex flex-col w-full gap-4">
224+
<div className="grid grid-cols-1 xl:grid-cols-2 w-full gap-4">
225+
<div className="flex flex-col gap-1">
226+
<InputField
227+
type="text"
228+
id="revenue_source_name"
229+
label="Revenue Source Name"
230+
value={name}
231+
error={Boolean(errors.name)}
232+
onChange={(event) => setName(event.target.value)}
233+
/>
234+
{errors.name ? <p className="text-red text-sm">{errors.name}</p> : null}
235+
</div>
236+
<div className="flex flex-col gap-1">
237+
<CashCategoryDropdown
238+
type={RevenueType}
239+
onChange={(event) => {
240+
const nextType = event.target.value;
241+
setType(nextType ? (nextType as RevenueType) : null);
242+
}}
243+
value={type ?? ""}
244+
error={Boolean(errors.type)}
245+
/>
246+
{errors.type ? <p className="text-red text-sm">{errors.type}</p> : null}
247+
</div>
248+
</div>
249+
250+
{isMultipleInstallments ? (
251+
<>
252+
{installments.map((installment, index) => (
253+
<CashRevenueInstallment
254+
key={index}
255+
id={`edit_${index}`}
256+
installment={installment}
257+
onAmountChange={(value) => updateInstallment(index, "amount", value)}
258+
onDateChange={(value) => updateInstallment(index, "date", value)}
259+
onDelete={() => removeInstallment(index)}
260+
/>
261+
))}
262+
{errors.installments ? (
263+
<p className="text-red text-sm">{errors.installments}</p>
264+
) : null}
265+
</>
266+
) : (
267+
<div className="grid grid-cols-1 xl:grid-cols-2 w-full gap-4">
268+
<div className="flex flex-col gap-1">
269+
<InputField
270+
type="number"
271+
id="edit_amount"
272+
label="Amount ($)"
273+
value={singleInstallment.amount ?? ""}
274+
placeholder="e.g. 1000"
275+
error={Boolean(errors.singleAmount)}
276+
onChange={(event) =>
277+
setSingleInstallment((previous) => ({
278+
...previous,
279+
amount: event.target.value === "" ? null : Number(event.target.value),
280+
}))
281+
}
282+
/>
283+
{errors.singleAmount ? (
284+
<p className="text-red text-sm">{errors.singleAmount}</p>
285+
) : null}
286+
</div>
287+
<div className="flex flex-col gap-1">
288+
<InputField
289+
type="date"
290+
id="edit_date"
291+
label="Date"
292+
value={
293+
singleInstallment.date
294+
? `${singleInstallment.date.getFullYear()}-${`${singleInstallment.date.getMonth() + 1}`.padStart(2, "0")}-${`${singleInstallment.date.getDate()}`.padStart(2, "0")}`
295+
: ""
296+
}
297+
placeholder="MM/DD/YYYY"
298+
error={Boolean(errors.singleDate)}
299+
onChange={(event) =>
300+
setSingleInstallment((previous) => ({
301+
...previous,
302+
date: event.target.value
303+
? new Date(`${event.target.value}T00:00:00`)
304+
: null,
305+
}))
306+
}
307+
/>
308+
{errors.singleDate ? (
309+
<p className="text-red text-sm">{errors.singleDate}</p>
310+
) : null}
311+
</div>
312+
</div>
313+
)}
314+
315+
{errors.submit ? <p className="text-red text-sm">{errors.submit}</p> : null}
316+
317+
<div className="flex flex-wrap items-center gap-2 mt-2">
318+
<Button
319+
text="Add Installment"
320+
onClick={addInstallment}
321+
logo={faPlus}
322+
logoPosition="left"
323+
className="bg-primary-900 text-white text-sm"
324+
/>
325+
<div className="font-semibold ml-auto text-sm lg:text-base">
326+
{"Total: "}
327+
{formatMoney(totalAmount)}
328+
</div>
329+
<Button
330+
text="Cancel"
331+
onClick={onClose}
332+
className="bg-white text-black border border-grey-500 text-sm lg:text-base"
333+
/>
334+
<Button
335+
text={isSubmitting ? "Saving..." : "Save"}
336+
onClick={handleSave}
337+
disabled={isSubmitting}
338+
className="bg-primary-900 text-white text-sm lg:text-base"
339+
/>
340+
</div>
341+
</div>
342+
);
343+
}

frontend/src/main-page/cash-flow/components/CashRevenueInstallment.tsx

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import InputField from "../../../components/InputField";
2-
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
3-
import { faXmark } from "@fortawesome/free-solid-svg-icons";
2+
import { faTrash } from "@fortawesome/free-solid-svg-icons";
3+
import Button from "../../../components/Button";
44

55
export type EditableInstallment = {
66
amount: number | null;
@@ -72,14 +72,13 @@ export default function CashRevenueInstallment({
7272
/>
7373
</div>
7474
{showDelete ? (
75-
<button
76-
type="button"
77-
aria-label="Delete installment"
78-
onClick={onDelete}
79-
className="rounded-full bg-red hover:bg-red-light text-white w-10 h-10 flex items-center justify-center mb-1.5 shrink-0"
80-
>
81-
<FontAwesomeIcon icon={faXmark} className="text-lg" />
82-
</button>
75+
<Button
76+
text=""
77+
onClick={() => onDelete?.()}
78+
logo={faTrash}
79+
logoPosition="center"
80+
className="bg-red-light text-red w-10 h-10 p-0 rounded-full mb-1.5 shrink-0 border-0"
81+
/>
8382
) : (
8483
<div className="hidden xl:block w-10 h-10 shrink-0" />
8584
)}

0 commit comments

Comments
 (0)