From 57623e5abcb513742f659f576e746c1f02d946b7 Mon Sep 17 00:00:00 2001 From: ZecD Date: Wed, 28 Jan 2026 17:53:20 +0100 Subject: [PATCH 01/12] WIP(OtherCosts): add OtherCosts section with form handling and validation --- .../CostAndResourceDetailsSection.tsx | 4 + .../sections/OtherCosts/CostsFormProvider.tsx | 71 +++++ .../quote/sections/OtherCosts/index.tsx | 296 ++++++++++++++++++ 3 files changed, 371 insertions(+) create mode 100644 src/pages/campaigns/quote/sections/OtherCosts/CostsFormProvider.tsx create mode 100644 src/pages/campaigns/quote/sections/OtherCosts/index.tsx diff --git a/src/pages/campaigns/quote/sections/CostAndResourceDetailsSection.tsx b/src/pages/campaigns/quote/sections/CostAndResourceDetailsSection.tsx index 3f6d7251..21fb2d39 100644 --- a/src/pages/campaigns/quote/sections/CostAndResourceDetailsSection.tsx +++ b/src/pages/campaigns/quote/sections/CostAndResourceDetailsSection.tsx @@ -9,6 +9,7 @@ import { useGetDossiersByCampaignCostsQuery } from "src/services/tryberApi"; import { HorizontalDivider } from "../components/Dividers"; import HumanResources from "./HumanResources"; import { Section } from "./Section"; +import OtherCosts from "./OtherCosts"; type CostAndResourceDetailsSectionProps = { campaignId?: string; @@ -93,6 +94,9 @@ export const CostAndResourceDetailsSection = ({ + + + ); }; diff --git a/src/pages/campaigns/quote/sections/OtherCosts/CostsFormProvider.tsx b/src/pages/campaigns/quote/sections/OtherCosts/CostsFormProvider.tsx new file mode 100644 index 00000000..dbf6171c --- /dev/null +++ b/src/pages/campaigns/quote/sections/OtherCosts/CostsFormProvider.tsx @@ -0,0 +1,71 @@ +import { Formik } from "@appquality/appquality-design-system"; +import * as yup from "yup"; + +import siteWideMessageStore from "src/redux/siteWideMessages"; + +export type FormProps = { + items: Array<{ + notSaved?: boolean; + description: string; + type: number; + supplier: number; + cost: number; + files: { url: string; mimeType: string }[]; + }>; +}; + +const CostsFormProvider = ({ + children, + campaignId, +}: { + children: React.ReactNode; + campaignId: string; +}) => { + const { add } = siteWideMessageStore(); + const data: FormProps = { items: [] }; + + const validationSchema = yup.object({ + items: yup.array().of( + yup.object({ + description: yup.string().required("Required"), + type: yup.number().required("Required").min(0), + supplier: yup.number().required("Required").min(0), + cost: yup.number().required("Required").min(0), + files: yup + .array() + .of( + yup.object({ + url: yup.string().required(), + mimeType: yup.string().required(), + }) + ) + .required(), + }) + ), + }); + + /* if (!data || isLoading) { + return null; + } */ + + return ( + + enableReinitialize + initialValues={{ + items: (data.items || []).map((item) => ({ + description: item.description || "", + type: item.type || 0, + supplier: item.supplier || 0, + cost: item.cost || 0, + files: item.files || [], + })), + }} + validationSchema={validationSchema} + onSubmit={() => {}} + > +
{children}
+ + ); +}; + +export default CostsFormProvider; diff --git a/src/pages/campaigns/quote/sections/OtherCosts/index.tsx b/src/pages/campaigns/quote/sections/OtherCosts/index.tsx new file mode 100644 index 00000000..0ca7e897 --- /dev/null +++ b/src/pages/campaigns/quote/sections/OtherCosts/index.tsx @@ -0,0 +1,296 @@ +import { + aqBootstrapTheme, + Button, + FormLabel, + Input, + Modal, + ModalBody, + Select, + Text, +} from "@appquality/appquality-design-system"; +import { FieldArray, useFormikContext } from "formik"; +import { useState } from "react"; +import { ReactComponent as DeleteIcon } from "src/assets/trash.svg"; +import { styled } from "styled-components"; +import CostsFormProvider, { FormProps } from "./CostsFormProvider"; + +const StyledRow = styled.div` + margin-top: ${({ theme }) => theme.grid.spacing.default}; + display: flex; + gap: ${({ theme }) => theme.grid.sizes[4]}; + align-items: flex-end; + flex-direction: row; + margin-bottom: ${({ theme }) => theme.grid.sizes[3]}; + + > div:not(:last-child) { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + min-width: 0; + } + + > div:last-child { + flex: 0; + } +`; + +// These should ideally come from an API hook like useGetSuppliers() or useGetCostTypes() +const COST_TYPES = [ + { value: "1", label: "Travel" }, + { value: "2", label: "Equipment" }, +]; +const SUPPLIERS = [ + { value: "1", label: "Supplier A" }, + { value: "2", label: "Supplier B" }, +]; + +const OtherCosts = ({ campaignId }: { campaignId: string }) => { + return ( + + + + ); +}; + +const FormContent = () => { + const { values, isValid, submitForm, dirty, isSubmitting } = + useFormikContext(); + const [rowPendingRemoval, setRowPendingRemoval] = useState( + null + ); + + const totalOtherCosts = values.items + ? values.items + .reduce((sum, item) => sum + (Number(item.cost) || 0), 0) + .toFixed(2) + : "0.00"; + + return ( + <> + + 💡 + Add Other Costs and{" "} + fill all required fields (*) + to enable saving + + + ( + <> + {values.items && + values.items.length > 0 && + values.items.map((item, index) => { + const selectedType = COST_TYPES.find( + (t) => t.value === String(item.type) + ); + const selectedSupplier = SUPPLIERS.find( + (s) => s.value === String(item.supplier) + ); + + return ( +
+ + {/* DESCRIPTION */} +
+ + Description + * + + } + /> + { + arrayHelpers.replace(index, { + ...item, + description: value, + }); + }} + /> +
+
+ + {/* TYPE */} +
+ + Supplier * + + } + value={selectedSupplier ?? { value: "", label: "" }} + onChange={(opt) => { + arrayHelpers.replace(index, { + ...item, + supplier: Number(opt.value), + }); + }} + /> +
+ + {/* COST */} +
+ + Cost (€) * + + } + /> + { + arrayHelpers.replace(index, { + ...item, + cost: Number(value), + }); + }} + /> +
+ +
+ +
+
+
+ ); + })} + + setRowPendingRemoval(null)} + footer={ +
+ + +
+ } + > + + + This will permanently remove this cost item. +
+ Are you sure? +
+
+
+ +
+ +
+ TOTAL OTHER COSTS: + + {totalOtherCosts}€ + +
+
+ + )} + /> + +
+ +
+ + ); +}; + +export default OtherCosts; From b67cc9cbd4d054ff192cc412483c6f826027eee1 Mon Sep 17 00:00:00 2001 From: ZecD Date: Thu, 29 Jan 2026 15:34:14 +0100 Subject: [PATCH 02/12] WIP(OtherCosts): implement AttachmentsDropzone component and integrate file upload functionality --- .../OtherCosts/AttachmentsDropzone.tsx | 85 +++++++++++++++++++ .../sections/OtherCosts/CostsFormProvider.tsx | 7 -- .../quote/sections/OtherCosts/index.tsx | 48 ++++++----- .../quote/sections/OtherCosts/utils.ts | 3 + 4 files changed, 117 insertions(+), 26 deletions(-) create mode 100644 src/pages/campaigns/quote/sections/OtherCosts/AttachmentsDropzone.tsx create mode 100644 src/pages/campaigns/quote/sections/OtherCosts/utils.ts diff --git a/src/pages/campaigns/quote/sections/OtherCosts/AttachmentsDropzone.tsx b/src/pages/campaigns/quote/sections/OtherCosts/AttachmentsDropzone.tsx new file mode 100644 index 00000000..c4e3fb13 --- /dev/null +++ b/src/pages/campaigns/quote/sections/OtherCosts/AttachmentsDropzone.tsx @@ -0,0 +1,85 @@ +import { Dropzone, Spinner } from "@appquality/appquality-design-system"; +import { useFormikContext, getIn } from "formik"; +import { useState } from "react"; +import { usePostUsersMeCampaignsByCampaignIdMediaMutation } from "src/services/tryberApi"; +import { normalizeFileName } from "./utils"; +import { FormProps } from "./CostsFormProvider"; + +interface Props { + campaignId: string; + name: string; +} + +export const AttachmentsDropzone = ({ campaignId, name }: Props) => { + const [createMedia] = usePostUsersMeCampaignsByCampaignIdMediaMutation(); + const { values, setFieldValue, errors, touched } = + useFormikContext(); + const [isUploading, setIsUploading] = useState(false); + + const currentFiles = getIn(values, name) || []; + const error = getIn(errors, name); + const isTouched = getIn(touched, name); + + const uploadMedia = async (files: File[]) => { + setIsUploading(true); + const updatedList = [...currentFiles]; + + for (const f of files) { + const formData = new FormData(); + formData.append("media", f, normalizeFileName(f.name)); + + try { + const res = await createMedia({ + campaignId, + // @ts-ignore + body: formData, + }).unwrap(); + + /* updatedList.push({ url: res.url, mimeType: f.type }); */ + } catch (e) { + console.error(e); + } + } + + setFieldValue(name, updatedList); + setIsUploading(false); + }; + + return ( +
+ {}} + disabled={isUploading} + danger={!!error && isTouched} + /> + + {isUploading && } + +
+ {currentFiles.map((file: any, idx: number) => ( +
+ 📎 {file.url.split("/").pop()} +
+ ))} +
+
+ ); +}; diff --git a/src/pages/campaigns/quote/sections/OtherCosts/CostsFormProvider.tsx b/src/pages/campaigns/quote/sections/OtherCosts/CostsFormProvider.tsx index dbf6171c..f7cc67b4 100644 --- a/src/pages/campaigns/quote/sections/OtherCosts/CostsFormProvider.tsx +++ b/src/pages/campaigns/quote/sections/OtherCosts/CostsFormProvider.tsx @@ -1,8 +1,6 @@ import { Formik } from "@appquality/appquality-design-system"; import * as yup from "yup"; -import siteWideMessageStore from "src/redux/siteWideMessages"; - export type FormProps = { items: Array<{ notSaved?: boolean; @@ -21,7 +19,6 @@ const CostsFormProvider = ({ children: React.ReactNode; campaignId: string; }) => { - const { add } = siteWideMessageStore(); const data: FormProps = { items: [] }; const validationSchema = yup.object({ @@ -44,10 +41,6 @@ const CostsFormProvider = ({ ), }); - /* if (!data || isLoading) { - return null; - } */ - return ( enableReinitialize diff --git a/src/pages/campaigns/quote/sections/OtherCosts/index.tsx b/src/pages/campaigns/quote/sections/OtherCosts/index.tsx index 0ca7e897..b218aec6 100644 --- a/src/pages/campaigns/quote/sections/OtherCosts/index.tsx +++ b/src/pages/campaigns/quote/sections/OtherCosts/index.tsx @@ -13,6 +13,7 @@ import { useState } from "react"; import { ReactComponent as DeleteIcon } from "src/assets/trash.svg"; import { styled } from "styled-components"; import CostsFormProvider, { FormProps } from "./CostsFormProvider"; +import { AttachmentsDropzone } from "./AttachmentsDropzone"; const StyledRow = styled.div` margin-top: ${({ theme }) => theme.grid.spacing.default}; @@ -35,7 +36,6 @@ const StyledRow = styled.div` } `; -// These should ideally come from an API hook like useGetSuppliers() or useGetCostTypes() const COST_TYPES = [ { value: "1", label: "Travel" }, { value: "2", label: "Equipment" }, @@ -48,12 +48,12 @@ const SUPPLIERS = [ const OtherCosts = ({ campaignId }: { campaignId: string }) => { return ( - + ); }; -const FormContent = () => { +const FormContent = ({ campaignId }: { campaignId: string }) => { const { values, isValid, submitForm, dirty, isSubmitting } = useFormikContext(); const [rowPendingRemoval, setRowPendingRemoval] = useState( @@ -72,7 +72,6 @@ const FormContent = () => { 💡 Add Other Costs and{" "} fill all required fields (*) - to enable saving { render={(arrayHelpers) => ( <> {values.items && - values.items.length > 0 && values.items.map((item, index) => { const selectedType = COST_TYPES.find( (t) => t.value === String(item.type) @@ -90,15 +88,21 @@ const FormContent = () => { ); return ( -
+
- {/* DESCRIPTION */}
- Description + Description{" "} * } @@ -117,10 +121,9 @@ const FormContent = () => {
- {/* TYPE */}
{ }} />
- - {/* COST */}
{ }} />
-
+ +
+ + Attachments * + + } + /> + +
); })} @@ -231,8 +243,6 @@ const FormContent = () => { This will permanently remove this cost item. -
- Are you sure?
diff --git a/src/pages/campaigns/quote/sections/OtherCosts/utils.ts b/src/pages/campaigns/quote/sections/OtherCosts/utils.ts new file mode 100644 index 00000000..6a3e2d4f --- /dev/null +++ b/src/pages/campaigns/quote/sections/OtherCosts/utils.ts @@ -0,0 +1,3 @@ +export const normalizeFileName = (fileName: string) => { + return fileName.normalize("NFD").replace(/\p{Diacritic}/gu, ""); +}; From f9b041b6814e3187615c49632c53c69376418da4 Mon Sep 17 00:00:00 2001 From: ZecD Date: Mon, 2 Feb 2026 16:34:59 +0100 Subject: [PATCH 03/12] feat(OtherCosts): implement finance management features including attachments and cost handling --- .../OtherCosts/AttachmentsDropzone.tsx | 39 ++- .../sections/OtherCosts/CostsFormProvider.tsx | 64 +++- .../quote/sections/OtherCosts/index.tsx | 169 +++++++-- .../quote/sections/SummaryFinanceCard.tsx | 43 ++- src/services/tryberApi/api.ts | 1 + src/services/tryberApi/apiTags.ts | 9 + src/services/tryberApi/index.ts | 172 ++++++++++ src/utils/schema.ts | 322 ++++++++++++++++++ 8 files changed, 772 insertions(+), 47 deletions(-) diff --git a/src/pages/campaigns/quote/sections/OtherCosts/AttachmentsDropzone.tsx b/src/pages/campaigns/quote/sections/OtherCosts/AttachmentsDropzone.tsx index c4e3fb13..60d1a867 100644 --- a/src/pages/campaigns/quote/sections/OtherCosts/AttachmentsDropzone.tsx +++ b/src/pages/campaigns/quote/sections/OtherCosts/AttachmentsDropzone.tsx @@ -1,7 +1,7 @@ import { Dropzone, Spinner } from "@appquality/appquality-design-system"; import { useFormikContext, getIn } from "formik"; import { useState } from "react"; -import { usePostUsersMeCampaignsByCampaignIdMediaMutation } from "src/services/tryberApi"; +import { usePostCampaignsByCampaignFinanceAttachmentsMutation } from "src/services/tryberApi"; import { normalizeFileName } from "./utils"; import { FormProps } from "./CostsFormProvider"; @@ -11,11 +11,11 @@ interface Props { } export const AttachmentsDropzone = ({ campaignId, name }: Props) => { - const [createMedia] = usePostUsersMeCampaignsByCampaignIdMediaMutation(); + const [createAttachment] = + usePostCampaignsByCampaignFinanceAttachmentsMutation(); const { values, setFieldValue, errors, touched } = useFormikContext(); const [isUploading, setIsUploading] = useState(false); - const currentFiles = getIn(values, name) || []; const error = getIn(errors, name); const isTouched = getIn(touched, name); @@ -29,13 +29,19 @@ export const AttachmentsDropzone = ({ campaignId, name }: Props) => { formData.append("media", f, normalizeFileName(f.name)); try { - const res = await createMedia({ - campaignId, + const res = await createAttachment({ + campaign: campaignId, // @ts-ignore body: formData, }).unwrap(); - /* updatedList.push({ url: res.url, mimeType: f.type }); */ + if (res.attachments && res.attachments.length > 0) { + const newFile = res.attachments[0]; + updatedList.push({ + url: newFile.url, + mimeType: newFile.mime_type, + }); + } } catch (e) { console.error(e); } @@ -55,7 +61,18 @@ export const AttachmentsDropzone = ({ campaignId, name }: Props) => { danger={!!error && isTouched} /> - {isUploading && } + {isUploading && ( +
+ +
+ )}
{ > {currentFiles.map((file: any, idx: number) => (
{
))}
+ + {error && isTouched && ( +
+ {error} +
+ )}
); }; diff --git a/src/pages/campaigns/quote/sections/OtherCosts/CostsFormProvider.tsx b/src/pages/campaigns/quote/sections/OtherCosts/CostsFormProvider.tsx index f7cc67b4..70a4bf72 100644 --- a/src/pages/campaigns/quote/sections/OtherCosts/CostsFormProvider.tsx +++ b/src/pages/campaigns/quote/sections/OtherCosts/CostsFormProvider.tsx @@ -1,8 +1,14 @@ import { Formik } from "@appquality/appquality-design-system"; +import siteWideMessageStore from "src/redux/siteWideMessages"; +import { + useGetCampaignsByCampaignFinanceOtherCostsQuery, + usePostCampaignsByCampaignFinanceOtherCostsMutation, +} from "src/services/tryberApi"; import * as yup from "yup"; export type FormProps = { items: Array<{ + cost_id?: number; notSaved?: boolean; description: string; type: number; @@ -19,14 +25,50 @@ const CostsFormProvider = ({ children: React.ReactNode; campaignId: string; }) => { - const data: FormProps = { items: [] }; + const { data, isLoading } = useGetCampaignsByCampaignFinanceOtherCostsQuery({ + campaign: campaignId, + }); + const [createOtherCosts] = + usePostCampaignsByCampaignFinanceOtherCostsMutation(); + const { add } = siteWideMessageStore(); + + const handleSubmit = async (values: FormProps) => { + try { + const newItems = values.items.filter((item) => item.notSaved); + for (const item of newItems) { + await createOtherCosts({ + campaign: campaignId, + body: { + description: item.description, + type_id: item.type, + supplier_id: item.supplier, + cost: item.cost, + attachments: item.files.map((file) => ({ + url: file.url, + mime_type: file.mimeType, + })), + }, + }).unwrap(); + } + add({ + message: "Other costs saved successfully", + type: "success", + }); + } catch (error) { + console.error("Failed to save other costs:", error); + add({ + message: "Failed to save other costs", + type: "danger", + }); + } + }; const validationSchema = yup.object({ items: yup.array().of( yup.object({ description: yup.string().required("Required"), type: yup.number().required("Required").min(0), - supplier: yup.number().required("Required").min(0), + supplier: yup.number().required("Required").min(1), cost: yup.number().required("Required").min(0), files: yup .array() @@ -41,20 +83,28 @@ const CostsFormProvider = ({ ), }); + if (!data || isLoading) { + return null; + } + return ( enableReinitialize initialValues={{ - items: (data.items || []).map((item) => ({ + items: (data?.items || []).map((item) => ({ + cost_id: item.cost_id, description: item.description || "", - type: item.type || 0, - supplier: item.supplier || 0, + type: item.type?.id || 0, + supplier: item.supplier?.id || 0, cost: item.cost || 0, - files: item.files || [], + files: (item.attachments || []).map((attachment) => ({ + url: attachment.url || "", + mimeType: attachment.mimetype || "", + })), })), }} validationSchema={validationSchema} - onSubmit={() => {}} + onSubmit={handleSubmit} >
{children}
diff --git a/src/pages/campaigns/quote/sections/OtherCosts/index.tsx b/src/pages/campaigns/quote/sections/OtherCosts/index.tsx index b218aec6..e74cee67 100644 --- a/src/pages/campaigns/quote/sections/OtherCosts/index.tsx +++ b/src/pages/campaigns/quote/sections/OtherCosts/index.tsx @@ -1,6 +1,7 @@ import { aqBootstrapTheme, Button, + Dropdown, FormLabel, Input, Modal, @@ -9,11 +10,18 @@ import { Text, } from "@appquality/appquality-design-system"; import { FieldArray, useFormikContext } from "formik"; -import { useState } from "react"; +import { useState, useMemo } from "react"; import { ReactComponent as DeleteIcon } from "src/assets/trash.svg"; import { styled } from "styled-components"; +import siteWideMessageStore from "src/redux/siteWideMessages"; import CostsFormProvider, { FormProps } from "./CostsFormProvider"; import { AttachmentsDropzone } from "./AttachmentsDropzone"; +import { + useGetCampaignsByCampaignFinanceSupplierQuery, + useGetCampaignsByCampaignFinanceTypeQuery, + usePostCampaignsByCampaignFinanceSupplierMutation, + useDeleteCampaignsByCampaignFinanceOtherCostsMutation, +} from "src/services/tryberApi"; const StyledRow = styled.div` margin-top: ${({ theme }) => theme.grid.spacing.default}; @@ -36,14 +44,38 @@ const StyledRow = styled.div` } `; -const COST_TYPES = [ - { value: "1", label: "Travel" }, - { value: "2", label: "Equipment" }, -]; -const SUPPLIERS = [ - { value: "1", label: "Supplier A" }, - { value: "2", label: "Supplier B" }, -]; +const useCostTypes = ({ campaignId }: { campaignId: string }) => { + const { data, isLoading } = useGetCampaignsByCampaignFinanceTypeQuery({ + campaign: campaignId, + }); + + const options = useMemo(() => { + if (!data?.items) return []; + return data.items.map((item, index) => ({ + value: String(index + 1), + label: item.name || `Type ${index + 1}`, + })); + }, [data]); + + return { data: options, isLoading }; +}; + +const useSuppliers = ({ campaignId }: { campaignId: string }) => { + const { data, isLoading, refetch } = + useGetCampaignsByCampaignFinanceSupplierQuery({ + campaign: campaignId, + }); + + const options = useMemo(() => { + if (!data?.items) return []; + return data.items.map((item, index) => ({ + value: String(index + 1), + label: item.name, + })); + }, [data]); + + return { data: options, isLoading, refetch }; +}; const OtherCosts = ({ campaignId }: { campaignId: string }) => { return ( @@ -60,6 +92,51 @@ const FormContent = ({ campaignId }: { campaignId: string }) => { null ); + const { data: costTypes, isLoading: costTypesLoading } = useCostTypes({ + campaignId, + }); + const { data: suppliers, refetch: refetchSuppliers } = useSuppliers({ + campaignId, + }); + const [createSupplier] = usePostCampaignsByCampaignFinanceSupplierMutation(); + const [deleteOtherCost] = + useDeleteCampaignsByCampaignFinanceOtherCostsMutation(); + const { add } = siteWideMessageStore(); + + const handleDelete = async (index: number, arrayHelpers: any) => { + const item = values.items[index]; + + if (item.notSaved) { + // Item not saved yet, just remove from array + arrayHelpers.remove(index); + setRowPendingRemoval(null); + } else if (item.cost_id) { + // Item is saved, call delete API + try { + await deleteOtherCost({ + campaign: campaignId, + body: { cost_id: item.cost_id }, + }).unwrap(); + + arrayHelpers.remove(index); + setRowPendingRemoval(null); + await submitForm(); + + add({ + message: "Cost deleted successfully", + type: "success", + }); + } catch (error) { + console.error("Failed to delete cost:", error); + add({ + message: "Failed to delete cost", + type: "danger", + }); + setRowPendingRemoval(null); + } + } + }; + const totalOtherCosts = values.items ? values.items .reduce((sum, item) => sum + (Number(item.cost) || 0), 0) @@ -80,12 +157,9 @@ const FormContent = ({ campaignId }: { campaignId: string }) => { <> {values.items && values.items.map((item, index) => { - const selectedType = COST_TYPES.find( + const selectedType = costTypes.find( (t) => t.value === String(item.type) ); - const selectedSupplier = SUPPLIERS.find( - (s) => s.value === String(item.supplier) - ); return (
{ />
+
Supplier * } - value={selectedSupplier ?? { value: "", label: "" }} - onChange={(opt) => { - arrayHelpers.replace(index, { - ...item, - supplier: Number(opt.value), - }); + /> + s.value === String(item.supplier) + )} + onChange={(opt: any) => { + if (opt) { + arrayHelpers.replace(index, { + ...item, + supplier: Number(opt.value), + }); + } else { + arrayHelpers.replace(index, { + ...item, + supplier: 0, + }); + } + }} + onCreateOption={async (inputValue: string) => { + try { + const response = await createSupplier({ + campaign: campaignId, + body: { name: inputValue }, + }); + if ("data" in response) { + await refetchSuppliers(); + // The new supplier will be at the end of the list, so use length + const newSupplierId = + (suppliers?.length || 0) + 1; + arrayHelpers.replace(index, { + ...item, + supplier: newSupplierId, + }); + } + } catch (e) { + console.error("Failed to create supplier:", e); + } }} + placeholder="Start typing to select or add" />
@@ -226,11 +339,9 @@ const FormContent = ({ campaignId }: { campaignId: string }) => {
+
+ Other costs: + + {otherCostsTotal.toFixed(2)}€{" "} + +
+
{ color: aqBootstrapTheme.palette.primary, }} > - {((communityCostsData?.totalCost || 0) + hrCostsTotal).toFixed(2)}€ + {( + (communityCostsData?.totalCost || 0) + + hrCostsTotal + + otherCostsTotal + ).toFixed(2)} + €
@@ -168,7 +203,9 @@ export const SummaryFinanceCard = ({ campaignId }: { campaignId: string }) => { {agreementData?.tokens && agreementData?.agreement?.value ? `${( ((agreementData.tokens * agreementData.agreement.value - - ((communityCostsData?.totalCost || 0) + hrCostsTotal)) / + ((communityCostsData?.totalCost || 0) + + hrCostsTotal + + otherCostsTotal)) / (agreementData.tokens * agreementData.agreement.value)) * 100 diff --git a/src/services/tryberApi/api.ts b/src/services/tryberApi/api.ts index e92edd23..17e92ed9 100644 --- a/src/services/tryberApi/api.ts +++ b/src/services/tryberApi/api.ts @@ -39,6 +39,7 @@ export const api = createApi({ "HumanResources", "Agreements", "Quote", + "OtherCosts", ], endpoints: () => ({}), // auto generated npm run generate-api }); diff --git a/src/services/tryberApi/apiTags.ts b/src/services/tryberApi/apiTags.ts index 671b5dc5..925b7d39 100644 --- a/src/services/tryberApi/apiTags.ts +++ b/src/services/tryberApi/apiTags.ts @@ -260,6 +260,15 @@ tryberApi.enhanceEndpoints({ putDossiersByCampaignAgreements: { invalidatesTags: ["Agreements"], }, + getCampaignsByCampaignFinanceOtherCosts: { + providesTags: ["OtherCosts"], + }, + postCampaignsByCampaignFinanceOtherCosts: { + invalidatesTags: ["OtherCosts"], + }, + deleteCampaignsByCampaignFinanceOtherCosts: { + invalidatesTags: ["OtherCosts"], + }, }, }); diff --git a/src/services/tryberApi/index.ts b/src/services/tryberApi/index.ts index 3cc84394..9defb9d6 100644 --- a/src/services/tryberApi/index.ts +++ b/src/services/tryberApi/index.ts @@ -242,6 +242,16 @@ const injectedRtkApi = api.injectEndpoints({ url: `/campaigns/${queryArg.campaign}/clusters`, }), }), + postCampaignsByCampaignFinanceAttachments: build.mutation< + PostCampaignsByCampaignFinanceAttachmentsApiResponse, + PostCampaignsByCampaignFinanceAttachmentsApiArg + >({ + query: (queryArg) => ({ + url: `/campaigns/${queryArg.campaign}/finance/attachments`, + method: "POST", + body: queryArg.body, + }), + }), getCampaignsByCampaignForms: build.query< GetCampaignsByCampaignFormsApiResponse, GetCampaignsByCampaignFormsApiArg @@ -1188,6 +1198,60 @@ const injectedRtkApi = api.injectEndpoints({ body: queryArg.body, }), }), + getCampaignsByCampaignFinanceSupplier: build.query< + GetCampaignsByCampaignFinanceSupplierApiResponse, + GetCampaignsByCampaignFinanceSupplierApiArg + >({ + query: (queryArg) => ({ + url: `/campaigns/${queryArg.campaign}/finance/supplier`, + }), + }), + postCampaignsByCampaignFinanceSupplier: build.mutation< + PostCampaignsByCampaignFinanceSupplierApiResponse, + PostCampaignsByCampaignFinanceSupplierApiArg + >({ + query: (queryArg) => ({ + url: `/campaigns/${queryArg.campaign}/finance/supplier`, + method: "POST", + body: queryArg.body, + }), + }), + getCampaignsByCampaignFinanceType: build.query< + GetCampaignsByCampaignFinanceTypeApiResponse, + GetCampaignsByCampaignFinanceTypeApiArg + >({ + query: (queryArg) => ({ + url: `/campaigns/${queryArg.campaign}/finance/type`, + }), + }), + getCampaignsByCampaignFinanceOtherCosts: build.query< + GetCampaignsByCampaignFinanceOtherCostsApiResponse, + GetCampaignsByCampaignFinanceOtherCostsApiArg + >({ + query: (queryArg) => ({ + url: `/campaigns/${queryArg.campaign}/finance/otherCosts`, + }), + }), + postCampaignsByCampaignFinanceOtherCosts: build.mutation< + PostCampaignsByCampaignFinanceOtherCostsApiResponse, + PostCampaignsByCampaignFinanceOtherCostsApiArg + >({ + query: (queryArg) => ({ + url: `/campaigns/${queryArg.campaign}/finance/otherCosts`, + method: "POST", + body: queryArg.body, + }), + }), + deleteCampaignsByCampaignFinanceOtherCosts: build.mutation< + DeleteCampaignsByCampaignFinanceOtherCostsApiResponse, + DeleteCampaignsByCampaignFinanceOtherCostsApiArg + >({ + query: (queryArg) => ({ + url: `/campaigns/${queryArg.campaign}/finance/otherCosts`, + method: "DELETE", + body: queryArg.body, + }), + }), }), overrideExisting: false, }); @@ -1669,6 +1733,24 @@ export type GetCampaignsByCampaignClustersApiArg = { /** A campaign id */ campaign: string; }; +export type PostCampaignsByCampaignFinanceAttachmentsApiResponse = + /** status 200 OK */ { + attachments?: { + url: string; + name: string; + mime_type: string; + }[]; + failed?: { + name: string; + path: string; + }[]; + }; +export type PostCampaignsByCampaignFinanceAttachmentsApiArg = { + campaign: string; + body: { + attachment?: Blob | Blob[]; + }; +}; export type GetCampaignsByCampaignFormsApiResponse = /** status 200 OK */ { id: number; question: string; @@ -2128,6 +2210,7 @@ export type PostDossiersApiArg = { skipPagesAndTasks?: number; } & { notify_everyone?: 0 | 1; + ux_notify?: number; }; }; export type GetDossiersByCampaignApiResponse = /** status 200 OK */ { @@ -3410,6 +3493,88 @@ export type PostCampaignsByCampaignTasksAndUsecaseSurveyJotformApiArg = { testerQuestionId: string; }; }; +export type GetCampaignsByCampaignFinanceSupplierApiResponse = + /** status 200 OK */ { + items: { + name: string; + created_at?: string; + created_by?: number; + id: number; + }[]; + }; +export type GetCampaignsByCampaignFinanceSupplierApiArg = { + campaign: string; +}; +export type PostCampaignsByCampaignFinanceSupplierApiResponse = + /** status 201 Created */ { + supplier_id: number; + }; +export type PostCampaignsByCampaignFinanceSupplierApiArg = { + campaign: string; + body: { + name: string; + }; +}; +export type GetCampaignsByCampaignFinanceTypeApiResponse = + /** status 200 OK */ { + items: { + name: string; + id: number; + }[]; + }; +export type GetCampaignsByCampaignFinanceTypeApiArg = { + campaign: string; +}; +export type GetCampaignsByCampaignFinanceOtherCostsApiResponse = + /** status 200 OK */ { + items: { + cost_id: number; + type: { + name: string; + id: number; + }; + supplier: { + name: string; + id: number; + }; + description: string; + attachments: { + id: number; + url: string; + mimetype: string; + }[]; + cost: number; + }[]; + }; +export type GetCampaignsByCampaignFinanceOtherCostsApiArg = { + /** A campaign id */ + campaign: string; +}; +export type PostCampaignsByCampaignFinanceOtherCostsApiResponse = + /** status 201 Created */ undefined; +export type PostCampaignsByCampaignFinanceOtherCostsApiArg = { + /** A campaign id */ + campaign: string; + body: { + description: string; + type_id: number; + supplier_id: number; + cost: number; + attachments: { + url: string; + mime_type: string; + }[]; + }; +}; +export type DeleteCampaignsByCampaignFinanceOtherCostsApiResponse = + /** status 200 OK */ undefined; +export type DeleteCampaignsByCampaignFinanceOtherCostsApiArg = { + /** A campaign id */ + campaign: string; + body: { + cost_id: number; + }; +}; export type Agreement = { expirationDate: string; isTokenBased?: boolean; @@ -3759,6 +3924,7 @@ export const { useGetCampaignsByCampaignCandidatesQuery, usePostCampaignsByCampaignCandidatesMutation, useGetCampaignsByCampaignClustersQuery, + usePostCampaignsByCampaignFinanceAttachmentsMutation, useGetCampaignsByCampaignFormsQuery, useGetCampaignsByCampaignGroupsQuery, useGetCampaignsByCampaignObservationsQuery, @@ -3870,4 +4036,10 @@ export const { useGetUsersMeRankQuery, useGetUsersMeRankListQuery, usePostCampaignsByCampaignTasksAndUsecaseSurveyJotformMutation, + useGetCampaignsByCampaignFinanceSupplierQuery, + usePostCampaignsByCampaignFinanceSupplierMutation, + useGetCampaignsByCampaignFinanceTypeQuery, + useGetCampaignsByCampaignFinanceOtherCostsQuery, + usePostCampaignsByCampaignFinanceOtherCostsMutation, + useDeleteCampaignsByCampaignFinanceOtherCostsMutation, } = injectedRtkApi; diff --git a/src/utils/schema.ts b/src/utils/schema.ts index 834efabc..3d6b88bd 100644 --- a/src/utils/schema.ts +++ b/src/utils/schema.ts @@ -146,6 +146,14 @@ export interface paths { }; }; }; + "/campaigns/{campaign}/finance/attachments": { + post: operations["post-campaigns-campaign-finance-attachments"]; + parameters: { + path: { + campaign: string; + }; + }; + }; "/campaigns/{campaign}/forms": { get: operations["get-campaigns-campaign-forms"]; parameters: { @@ -367,6 +375,7 @@ export interface paths { }; "/dossiers/{campaign}/humanResources": { get: operations["get-dossiers-campaign-humanResources"]; + put: operations["put-dossiers-campaign-humanResources"]; parameters: { path: { /** A campaign id */ @@ -786,6 +795,45 @@ export interface paths { "/users/me/rank/list": { get: operations["get-users-me-rank-list"]; }; + "/campaigns/{campaign}/tasks/{usecase}/survey/jotform": { + post: operations["post-campaigns-campaign-tasks-usecase-survey-jotform"]; + parameters: { + path: { + campaign: string; + usecase: string; + }; + }; + }; + "/campaigns/{campaign}/finance/supplier": { + /** Get all finance suppliers */ + get: operations["get-campaigns-campaign-finance-supplier"]; + post: operations["post-campaigns-campaign-finance-supplier"]; + parameters: { + path: { + campaign: string; + }; + }; + }; + "/campaigns/{campaign}/finance/type": { + get: operations["get-campaigns-campaign-finance-type"]; + parameters: { + path: { + campaign: string; + }; + }; + }; + "/campaigns/{campaign}/finance/otherCosts": { + get: operations["get-campaigns-campaign-finance-otherCosts"]; + /** Create a new campaign cost */ + post: operations["post-campaigns-campaign-finance-otherCosts"]; + delete: operations["delete-campaigns-campaign-finance-otherCosts"]; + parameters: { + path: { + /** A campaign id */ + campaign: components["parameters"]["campaign"]; + }; + }; + }; } export interface components { @@ -2107,6 +2155,41 @@ export interface operations { 404: components["responses"]["NotFound"]; }; }; + "post-campaigns-campaign-finance-attachments": { + parameters: { + path: { + campaign: string; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": { + attachments?: { + url: string; + name: string; + mime_type: string; + }[]; + failed?: { + name: string; + path: string; + }[]; + }; + }; + }; + 403: components["responses"]["NotAuthorized"]; + /** Internal Server Error */ + 500: unknown; + }; + requestBody: { + content: { + "multipart/form-data": { + attachment?: string | string[]; + }; + }; + }; + }; "get-campaigns-campaign-forms": { parameters: { path: { @@ -2993,6 +3076,8 @@ export interface operations { * @enum {undefined} */ notify_everyone?: 0 | 1; + /** @example 1 */ + ux_notify?: number; }; }; }; @@ -3248,6 +3333,32 @@ export interface operations { }; }; }; + "put-dossiers-campaign-humanResources": { + parameters: { + path: { + /** A campaign id */ + campaign: components["parameters"]["campaign"]; + }; + }; + responses: { + /** OK */ + 200: unknown; + 403: components["responses"]["NotAuthorized"]; + 404: components["responses"]["NotFound"]; + /** Internal Server Error */ + 500: unknown; + }; + /** Overwrites the data for the given campaign in the campaign_human_resources table */ + requestBody: { + content: { + "application/json": { + assignee: number; + days: number; + rate: number; + }[]; + }; + }; + }; "post-dossiers-campaign-manual": { parameters: { path: { @@ -5403,6 +5514,217 @@ export interface operations { 404: components["responses"]["NotFound"]; }; }; + "post-campaigns-campaign-tasks-usecase-survey-jotform": { + parameters: { + path: { + campaign: string; + usecase: string; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": { [key: string]: unknown }; + }; + }; + "": { + content: { + "application/json": { [key: string]: unknown }; + }; + }; + }; + requestBody: { + content: { + "application/json": { + jotformId: string; + testerQuestionId: string; + }; + }; + }; + }; + /** Get all finance suppliers */ + "get-campaigns-campaign-finance-supplier": { + parameters: { + path: { + campaign: string; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": { + items: { + name: string; + created_at?: string; + created_by?: number; + id: number; + }[]; + }; + }; + }; + /** Bad Request */ + 400: unknown; + /** Forbidden */ + 403: unknown; + /** Internal Server Error */ + 500: unknown; + }; + }; + "post-campaigns-campaign-finance-supplier": { + parameters: { + path: { + campaign: string; + }; + }; + responses: { + /** Created */ + 201: { + content: { + "application/json": { + supplier_id: number; + }; + }; + }; + /** Bad Request */ + 400: unknown; + /** Forbidden */ + 403: unknown; + /** Internal Server Error */ + 500: unknown; + }; + requestBody: { + content: { + "application/json": { + name: string; + }; + }; + }; + }; + "get-campaigns-campaign-finance-type": { + parameters: { + path: { + campaign: string; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": { + items: { + name: string; + id: number; + }[]; + }; + }; + }; + 403: components["responses"]["NotAuthorized"]; + 404: components["responses"]["NotFound"]; + /** Internal Server Error */ + 500: unknown; + }; + }; + "get-campaigns-campaign-finance-otherCosts": { + parameters: { + path: { + /** A campaign id */ + campaign: components["parameters"]["campaign"]; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": { + items: { + cost_id: number; + type: { + name: string; + id: number; + }; + supplier: { + name: string; + id: number; + }; + description: string; + attachments: { + id: number; + url: string; + mimetype: string; + }[]; + cost: number; + }[]; + }; + }; + }; + /** Forbidden */ + 403: unknown; + /** Not Found */ + 404: unknown; + /** Internal Server Error */ + 500: unknown; + }; + }; + /** Create a new campaign cost */ + "post-campaigns-campaign-finance-otherCosts": { + parameters: { + path: { + /** A campaign id */ + campaign: components["parameters"]["campaign"]; + }; + }; + responses: { + /** Created */ + 201: unknown; + /** Bad Request */ + 400: unknown; + 403: components["responses"]["NotAuthorized"]; + 404: components["responses"]["NotFound"]; + /** Internal Server Error */ + 500: unknown; + }; + requestBody: { + content: { + "application/json": { + description: string; + type_id: number; + supplier_id: number; + cost: number; + attachments: { + url: string; + mime_type: string; + }[]; + }; + }; + }; + }; + "delete-campaigns-campaign-finance-otherCosts": { + parameters: { + path: { + /** A campaign id */ + campaign: components["parameters"]["campaign"]; + }; + }; + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: unknown; + 403: components["responses"]["NotAuthorized"]; + 404: components["responses"]["NotFound"]; + /** Internal Server Error */ + 500: unknown; + }; + requestBody: { + content: { + "application/json": { + cost_id: number; + }; + }; + }; + }; } export interface external {} From e744572ac2b51a74d01f9959ee10877f9d7c621f Mon Sep 17 00:00:00 2001 From: ZecD Date: Tue, 3 Feb 2026 11:38:32 +0100 Subject: [PATCH 04/12] feat(OtherCosts): add PATCH functionality for updating campaign finance other costs --- .../sections/OtherCosts/CostsFormProvider.tsx | 86 ++++++++++++++++--- src/services/tryberApi/apiTags.ts | 3 + src/services/tryberApi/index.ts | 38 ++++++++ src/utils/schema.ts | 57 +++++++++++- 4 files changed, 167 insertions(+), 17 deletions(-) diff --git a/src/pages/campaigns/quote/sections/OtherCosts/CostsFormProvider.tsx b/src/pages/campaigns/quote/sections/OtherCosts/CostsFormProvider.tsx index 70a4bf72..52e40836 100644 --- a/src/pages/campaigns/quote/sections/OtherCosts/CostsFormProvider.tsx +++ b/src/pages/campaigns/quote/sections/OtherCosts/CostsFormProvider.tsx @@ -1,8 +1,10 @@ import { Formik } from "@appquality/appquality-design-system"; +import { useRef, useEffect } from "react"; import siteWideMessageStore from "src/redux/siteWideMessages"; import { useGetCampaignsByCampaignFinanceOtherCostsQuery, usePostCampaignsByCampaignFinanceOtherCostsMutation, + usePatchCampaignsByCampaignFinanceOtherCostsMutation, } from "src/services/tryberApi"; import * as yup from "yup"; @@ -30,11 +32,51 @@ const CostsFormProvider = ({ }); const [createOtherCosts] = usePostCampaignsByCampaignFinanceOtherCostsMutation(); + const [updateOtherCosts] = + usePatchCampaignsByCampaignFinanceOtherCostsMutation(); const { add } = siteWideMessageStore(); + const initialValuesRef = useRef(null); + + useEffect(() => { + if (data && !initialValuesRef.current) { + initialValuesRef.current = { + items: (data?.items || []).map((item) => ({ + cost_id: item.cost_id, + description: item.description || "", + type: item.type?.id || 0, + supplier: item.supplier?.id || 0, + cost: item.cost || 0, + files: (item.attachments || []).map((attachment) => ({ + url: attachment.url || "", + mimeType: attachment.mimetype || "", + })), + })), + }; + } + }, [data]); + const handleSubmit = async (values: FormProps) => { try { const newItems = values.items.filter((item) => item.notSaved); + + const modifiedItems = values.items.filter((item) => { + if (item.notSaved || !item.cost_id) return false; + + const initialItem = initialValuesRef.current?.items.find( + (i) => i.cost_id === item.cost_id + ); + if (!initialItem) return false; + + return ( + item.description !== initialItem.description || + item.type !== initialItem.type || + item.supplier !== initialItem.supplier || + item.cost !== initialItem.cost || + JSON.stringify(item.files) !== JSON.stringify(initialItem.files) + ); + }); + for (const item of newItems) { await createOtherCosts({ campaign: campaignId, @@ -50,6 +92,22 @@ const CostsFormProvider = ({ }, }).unwrap(); } + for (const item of modifiedItems) { + await updateOtherCosts({ + campaign: campaignId, + body: { + cost_id: item.cost_id!, + description: item.description, + type_id: item.type, + supplier_id: item.supplier, + cost: item.cost, + attachments: item.files.map((file) => ({ + url: file.url, + mime_type: file.mimeType, + })), + }, + }).unwrap(); + } add({ message: "Other costs saved successfully", @@ -87,22 +145,24 @@ const CostsFormProvider = ({ return null; } + const initialValues: FormProps = { + items: (data?.items || []).map((item) => ({ + cost_id: item.cost_id, + description: item.description || "", + type: item.type?.id || 0, + supplier: item.supplier?.id || 0, + cost: item.cost || 0, + files: (item.attachments || []).map((attachment) => ({ + url: attachment.url || "", + mimeType: attachment.mimetype || "", + })), + })), + }; + return ( enableReinitialize - initialValues={{ - items: (data?.items || []).map((item) => ({ - cost_id: item.cost_id, - description: item.description || "", - type: item.type?.id || 0, - supplier: item.supplier?.id || 0, - cost: item.cost || 0, - files: (item.attachments || []).map((attachment) => ({ - url: attachment.url || "", - mimeType: attachment.mimetype || "", - })), - })), - }} + initialValues={initialValues} validationSchema={validationSchema} onSubmit={handleSubmit} > diff --git a/src/services/tryberApi/apiTags.ts b/src/services/tryberApi/apiTags.ts index 925b7d39..e66b1d6b 100644 --- a/src/services/tryberApi/apiTags.ts +++ b/src/services/tryberApi/apiTags.ts @@ -269,6 +269,9 @@ tryberApi.enhanceEndpoints({ deleteCampaignsByCampaignFinanceOtherCosts: { invalidatesTags: ["OtherCosts"], }, + patchCampaignsByCampaignFinanceOtherCosts: { + invalidatesTags: ["OtherCosts"], + }, }, }); diff --git a/src/services/tryberApi/index.ts b/src/services/tryberApi/index.ts index 9defb9d6..afb85cbd 100644 --- a/src/services/tryberApi/index.ts +++ b/src/services/tryberApi/index.ts @@ -1252,6 +1252,16 @@ const injectedRtkApi = api.injectEndpoints({ body: queryArg.body, }), }), + patchCampaignsByCampaignFinanceOtherCosts: build.mutation< + PatchCampaignsByCampaignFinanceOtherCostsApiResponse, + PatchCampaignsByCampaignFinanceOtherCostsApiArg + >({ + query: (queryArg) => ({ + url: `/campaigns/${queryArg.campaign}/finance/otherCosts`, + method: "PATCH", + body: queryArg.body, + }), + }), }), overrideExisting: false, }); @@ -3575,6 +3585,33 @@ export type DeleteCampaignsByCampaignFinanceOtherCostsApiArg = { cost_id: number; }; }; +export type PatchCampaignsByCampaignFinanceOtherCostsApiResponse = + /** status 200 OK */ { + description: string; + type: string; + cost_id: number; + supplier: string; + cost: number; + attachments: { + url: string; + mime_type: string; + }[]; + }; +export type PatchCampaignsByCampaignFinanceOtherCostsApiArg = { + /** A campaign id */ + campaign: string; + body: { + description: string; + type_id: number; + supplier_id: number; + cost: number; + attachments: { + url: string; + mime_type: string; + }[]; + cost_id: number; + }; +}; export type Agreement = { expirationDate: string; isTokenBased?: boolean; @@ -4042,4 +4079,5 @@ export const { useGetCampaignsByCampaignFinanceOtherCostsQuery, usePostCampaignsByCampaignFinanceOtherCostsMutation, useDeleteCampaignsByCampaignFinanceOtherCostsMutation, + usePatchCampaignsByCampaignFinanceOtherCostsMutation, } = injectedRtkApi; diff --git a/src/utils/schema.ts b/src/utils/schema.ts index 3d6b88bd..c5a54a6b 100644 --- a/src/utils/schema.ts +++ b/src/utils/schema.ts @@ -827,10 +827,11 @@ export interface paths { /** Create a new campaign cost */ post: operations["post-campaigns-campaign-finance-otherCosts"]; delete: operations["delete-campaigns-campaign-finance-otherCosts"]; + patch: operations["patch-campaigns-campaign-finance-otherCosts"]; parameters: { path: { /** A campaign id */ - campaign: components["parameters"]["campaign"]; + campaign: string; }; }; }; @@ -5630,7 +5631,7 @@ export interface operations { parameters: { path: { /** A campaign id */ - campaign: components["parameters"]["campaign"]; + campaign: string; }; }; responses: { @@ -5672,7 +5673,7 @@ export interface operations { parameters: { path: { /** A campaign id */ - campaign: components["parameters"]["campaign"]; + campaign: string; }; }; responses: { @@ -5704,7 +5705,7 @@ export interface operations { parameters: { path: { /** A campaign id */ - campaign: components["parameters"]["campaign"]; + campaign: string; }; }; responses: { @@ -5725,6 +5726,54 @@ export interface operations { }; }; }; + "patch-campaigns-campaign-finance-otherCosts": { + parameters: { + path: { + /** A campaign id */ + campaign: string; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": { + description: string; + type: string; + cost_id: number; + supplier: string; + cost: number; + attachments: { + url: string; + mime_type: string; + }[]; + }; + }; + }; + /** Bad Request */ + 400: unknown; + /** Forbidden */ + 403: unknown; + 404: components["responses"]["NotFound"]; + /** Internal Server Error */ + 500: unknown; + }; + requestBody: { + content: { + "application/json": { + description: string; + type_id: number; + supplier_id: number; + cost: number; + attachments: { + url: string; + mime_type: string; + }[]; + cost_id: number; + }; + }; + }; + }; } export interface external {} From 414debd088d74df0b7b462f383d62caad4bc462d Mon Sep 17 00:00:00 2001 From: ZecD Date: Tue, 3 Feb 2026 12:01:28 +0100 Subject: [PATCH 05/12] feat(AttachmentsDropzone): add delete functionality for uploaded attachments --- .../OtherCosts/AttachmentsDropzone.tsx | 25 ++++++++++++++++++- .../quote/sections/OtherCosts/index.tsx | 2 -- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/pages/campaigns/quote/sections/OtherCosts/AttachmentsDropzone.tsx b/src/pages/campaigns/quote/sections/OtherCosts/AttachmentsDropzone.tsx index 60d1a867..e4bf92c7 100644 --- a/src/pages/campaigns/quote/sections/OtherCosts/AttachmentsDropzone.tsx +++ b/src/pages/campaigns/quote/sections/OtherCosts/AttachmentsDropzone.tsx @@ -51,6 +51,11 @@ export const AttachmentsDropzone = ({ campaignId, name }: Props) => { setIsUploading(false); }; + const handleDelete = (index: number) => { + const updatedList = currentFiles.filter((_: any, i: number) => i !== index); + setFieldValue(name, updatedList); + }; + return (
{ border: "1px solid #ddd", borderRadius: "4px", background: "#fff", + display: "flex", + alignItems: "center", + gap: "8px", }} > - 📎 {file.url.split("/").pop()} + 📎 {file.url.split("/").pop()} +
))} diff --git a/src/pages/campaigns/quote/sections/OtherCosts/index.tsx b/src/pages/campaigns/quote/sections/OtherCosts/index.tsx index e74cee67..143f96d3 100644 --- a/src/pages/campaigns/quote/sections/OtherCosts/index.tsx +++ b/src/pages/campaigns/quote/sections/OtherCosts/index.tsx @@ -107,11 +107,9 @@ const FormContent = ({ campaignId }: { campaignId: string }) => { const item = values.items[index]; if (item.notSaved) { - // Item not saved yet, just remove from array arrayHelpers.remove(index); setRowPendingRemoval(null); } else if (item.cost_id) { - // Item is saved, call delete API try { await deleteOtherCost({ campaign: campaignId, From 9f923403114d7148135b3b793f34c480a715b697 Mon Sep 17 00:00:00 2001 From: ZecD Date: Tue, 3 Feb 2026 15:40:59 +0100 Subject: [PATCH 06/12] feat(OtherCosts): simplify data handling and validation in CostsFormProvider --- .../sections/OtherCosts/CostsFormProvider.tsx | 20 +++++++++++-------- src/services/tryberApi/index.ts | 6 +++--- src/utils/schema.ts | 6 +++--- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/pages/campaigns/quote/sections/OtherCosts/CostsFormProvider.tsx b/src/pages/campaigns/quote/sections/OtherCosts/CostsFormProvider.tsx index 52e40836..7e778717 100644 --- a/src/pages/campaigns/quote/sections/OtherCosts/CostsFormProvider.tsx +++ b/src/pages/campaigns/quote/sections/OtherCosts/CostsFormProvider.tsx @@ -39,7 +39,7 @@ const CostsFormProvider = ({ const initialValuesRef = useRef(null); useEffect(() => { - if (data && !initialValuesRef.current) { + if (data) { initialValuesRef.current = { items: (data?.items || []).map((item) => ({ cost_id: item.cost_id, @@ -77,10 +77,10 @@ const CostsFormProvider = ({ ); }); - for (const item of newItems) { + if (newItems.length > 0) { await createOtherCosts({ campaign: campaignId, - body: { + body: newItems.map((item) => ({ description: item.description, type_id: item.type, supplier_id: item.supplier, @@ -89,13 +89,14 @@ const CostsFormProvider = ({ url: file.url, mime_type: file.mimeType, })), - }, + })), }).unwrap(); } - for (const item of modifiedItems) { + + if (modifiedItems.length > 0) { await updateOtherCosts({ campaign: campaignId, - body: { + body: modifiedItems.map((item) => ({ cost_id: item.cost_id!, description: item.description, type_id: item.type, @@ -105,7 +106,7 @@ const CostsFormProvider = ({ url: file.url, mime_type: file.mimeType, })), - }, + })), }).unwrap(); } @@ -136,7 +137,8 @@ const CostsFormProvider = ({ mimeType: yup.string().required(), }) ) - .required(), + .required() + .min(1, "At least one attachment is required"), }) ), }); @@ -162,6 +164,8 @@ const CostsFormProvider = ({ return ( enableReinitialize + validateOnMount + validateOnChange initialValues={initialValues} validationSchema={validationSchema} onSubmit={handleSubmit} diff --git a/src/services/tryberApi/index.ts b/src/services/tryberApi/index.ts index afb85cbd..8960f366 100644 --- a/src/services/tryberApi/index.ts +++ b/src/services/tryberApi/index.ts @@ -3574,7 +3574,7 @@ export type PostCampaignsByCampaignFinanceOtherCostsApiArg = { url: string; mime_type: string; }[]; - }; + }[]; }; export type DeleteCampaignsByCampaignFinanceOtherCostsApiResponse = /** status 200 OK */ undefined; @@ -3596,7 +3596,7 @@ export type PatchCampaignsByCampaignFinanceOtherCostsApiResponse = url: string; mime_type: string; }[]; - }; + }[]; export type PatchCampaignsByCampaignFinanceOtherCostsApiArg = { /** A campaign id */ campaign: string; @@ -3610,7 +3610,7 @@ export type PatchCampaignsByCampaignFinanceOtherCostsApiArg = { mime_type: string; }[]; cost_id: number; - }; + }[]; }; export type Agreement = { expirationDate: string; diff --git a/src/utils/schema.ts b/src/utils/schema.ts index c5a54a6b..eb824545 100644 --- a/src/utils/schema.ts +++ b/src/utils/schema.ts @@ -5697,7 +5697,7 @@ export interface operations { url: string; mime_type: string; }[]; - }; + }[]; }; }; }; @@ -5747,7 +5747,7 @@ export interface operations { url: string; mime_type: string; }[]; - }; + }[]; }; }; /** Bad Request */ @@ -5770,7 +5770,7 @@ export interface operations { mime_type: string; }[]; cost_id: number; - }; + }[]; }; }; }; From c2622bf9659b976120f1c1f8ef75a93893e2264f Mon Sep 17 00:00:00 2001 From: ZecD Date: Tue, 3 Feb 2026 16:41:43 +0100 Subject: [PATCH 07/12] feat(HumanResources): enhance form layout and improve subtotal display --- .../quote/sections/HumanResources/index.tsx | 55 +++++++++------ .../quote/sections/OtherCosts/index.tsx | 70 +++++++++++++------ 2 files changed, 82 insertions(+), 43 deletions(-) diff --git a/src/pages/campaigns/quote/sections/HumanResources/index.tsx b/src/pages/campaigns/quote/sections/HumanResources/index.tsx index 3ce96fd6..f78c4cc7 100644 --- a/src/pages/campaigns/quote/sections/HumanResources/index.tsx +++ b/src/pages/campaigns/quote/sections/HumanResources/index.tsx @@ -199,7 +199,7 @@ const FormContent = ({ campaignId }: { campaignId: string }) => { return (
-
+
{ noOptionsMessage={() => "No options"} />
- -
- -
- - Subtotal:{" "} - {subtotal}€ - +
+ + Subtotal:{" "} + + {subtotal}€ + + +
+
); diff --git a/src/pages/campaigns/quote/sections/OtherCosts/index.tsx b/src/pages/campaigns/quote/sections/OtherCosts/index.tsx index 143f96d3..d5a0bf80 100644 --- a/src/pages/campaigns/quote/sections/OtherCosts/index.tsx +++ b/src/pages/campaigns/quote/sections/OtherCosts/index.tsx @@ -44,6 +44,12 @@ const StyledRow = styled.div` } `; +const StyledCostInput = styled(Input)` + input { + text-align: right; + } +`; + const useCostTypes = ({ campaignId }: { campaignId: string }) => { const { data, isLoading } = useGetCampaignsByCampaignFinanceTypeQuery({ campaign: campaignId, @@ -159,11 +165,13 @@ const FormContent = ({ campaignId }: { campaignId: string }) => { (t) => t.value === String(item.type) ); + const isLastItem = index === values.items.length - 1; + return (
{ -
+
{ + onChange={(value: string) => { arrayHelpers.replace(index, { ...item, cost: Number(value), @@ -291,19 +298,6 @@ const FormContent = ({ campaignId }: { campaignId: string }) => { }} />
-
- -
@@ -319,6 +313,42 @@ const FormContent = ({ campaignId }: { campaignId: string }) => { name={`items.${index}.files`} />
+ +
+
+ + Subtotal:{" "} + + {(Number(item.cost) || 0).toFixed(2)}€ + + +
+ +
); })} From 01b9658a3feac5827e0534f4d0e17e35bb95eb94 Mon Sep 17 00:00:00 2001 From: ZecD Date: Wed, 4 Feb 2026 11:27:28 +0100 Subject: [PATCH 08/12] feat(OtherCosts): add presigned URL support for attachments and enhance file download functionality --- .../OtherCosts/AttachmentsDropzone.tsx | 33 +++++++++++++++++-- .../sections/OtherCosts/CostsFormProvider.tsx | 17 ++++++++-- .../quote/sections/OtherCosts/index.tsx | 2 +- src/services/tryberApi/index.ts | 1 + src/utils/schema.ts | 1 + 5 files changed, 49 insertions(+), 5 deletions(-) diff --git a/src/pages/campaigns/quote/sections/OtherCosts/AttachmentsDropzone.tsx b/src/pages/campaigns/quote/sections/OtherCosts/AttachmentsDropzone.tsx index e4bf92c7..e149a3f6 100644 --- a/src/pages/campaigns/quote/sections/OtherCosts/AttachmentsDropzone.tsx +++ b/src/pages/campaigns/quote/sections/OtherCosts/AttachmentsDropzone.tsx @@ -56,6 +56,18 @@ export const AttachmentsDropzone = ({ campaignId, name }: Props) => { setFieldValue(name, updatedList); }; + const downloadFile = (file: any) => { + const fileName = file.url.split("/").pop() || "attachment"; + const link = document.createElement("a"); + link.href = file.presignedUrl; + link.download = fileName; + link.target = "_blank"; + link.rel = "noopener noreferrer"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + return (
{
{ gap: "8px", }} > - 📎 {file.url.split("/").pop()} + {file.presignedUrl ? ( + downloadFile(file)} + style={{ + cursor: "pointer", + color: "#0066cc", + display: "flex", + alignItems: "center", + gap: "4px", + flex: 1, + }} + title="Click to download" + > + 📎 {file.url.split("/").pop()} ⬇ + + ) : ( + 📎 {file.url.split("/").pop()} + )}