Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
c078086
feat(schema): add new endpoints for human resources and survey jotform
iacopolea Jan 23, 2026
1e564ab
feat(api): add optional reviewerType and ux_notify fields to campaign…
iacopolea Jan 23, 2026
4ca74a9
feat(assets): add human and robot AI SVG icons
iacopolea Jan 26, 2026
57623e5
WIP(OtherCosts): add OtherCosts section with form handling and valida…
iDome89 Jan 28, 2026
b67cc9c
WIP(OtherCosts): implement AttachmentsDropzone component and integrat…
iDome89 Jan 29, 2026
f9b041b
feat(OtherCosts): implement finance management features including att…
iDome89 Feb 2, 2026
e744572
feat(OtherCosts): add PATCH functionality for updating campaign finan…
iDome89 Feb 3, 2026
414debd
feat(AttachmentsDropzone): add delete functionality for uploaded atta…
iDome89 Feb 3, 2026
9f92340
feat(OtherCosts): simplify data handling and validation in CostsFormP…
iDome89 Feb 3, 2026
c2622bf
feat(HumanResources): enhance form layout and improve subtotal display
iDome89 Feb 3, 2026
01b9658
feat(OtherCosts): add presigned URL support for attachments and enhan…
iDome89 Feb 4, 2026
a790418
Merge pull request #213 from AppQuality/UN-2134-auto-approve-column
marcbon Feb 4, 2026
82be1fd
feat(OtherCosts): improve cost deletion handling and update cost inpu…
iDome89 Feb 4, 2026
c831f06
feat(OtherCosts): add placeholder to cost input for better user guidance
iDome89 Feb 4, 2026
71f9dd3
feat(OtherCosts): remove loading state from cost types dropdown in Fo…
iDome89 Feb 5, 2026
7e515a6
feat(OtherCosts): replace custom StyledCostInput with standard Input …
iDome89 Feb 5, 2026
5a40635
Merge branch 'develop' into UN-2282
iDome89 Feb 5, 2026
7f23b43
Merge pull request #214 from AppQuality/UN-2282
d-beezee Feb 5, 2026
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
3 changes: 3 additions & 0 deletions src/assets/human.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions src/assets/robot-ai.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
48 changes: 48 additions & 0 deletions src/pages/BugsList/Table/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
import { useFiltersCardContext } from "../FilterContext";
import { Button as AppQualityButton } from "@appquality/appquality-design-system";
import { ReactComponent as InfoIcon } from "src/assets/info-icon.svg";
import { ReactComponent as HumanIcon } from "src/assets/human.svg";
import { ReactComponent as RobotAiIcon } from "src/assets/robot-ai.svg";
import Button from "./TableButton";
import Severity from "./Severity";
import Type from "./Type";
Expand All @@ -24,11 +26,50 @@ const LIMIT = 100;

const StarFill = icons.StarFill;

interface ValidationByItemProps {
icon: React.ReactNode;
label: string;
}

const ValidationByItem = ({ icon, label }: ValidationByItemProps) => (
<div style={{ display: "flex", alignItems: "center", gap: "1em" }}>
{icon}
<span>{label}</span>
</div>
);

const BugsTable = ({ id }: { id: string }) => {
const { filters, page, setPage, order, setOrder } = useFiltersCardContext();
const [scoreModal, setScoreModal] = useState(false);
const [statusModal, setStatusModal] = useState(false);

const getValidationByObject = (
statusName: string,
reviewerType?: string | null
) => {
if (statusName?.toLowerCase() === "pending") {
return {
content: "--",
};
}

if (reviewerType === "ai") {
return {
content: <ValidationByItem icon={<RobotAiIcon />} label="AI" />,
};
}

if (reviewerType === "human") {
return {
content: <ValidationByItem icon={<HumanIcon />} label="Human" />,
};
}

return {
content: "--",
};
};

let orderBy: GetCampaignsByCampaignBugsApiArg["orderBy"] = "id";
if (order.field === "internalId") orderBy = "id";
if (order.field === "tester") orderBy = "testerId";
Expand Down Expand Up @@ -94,6 +135,7 @@ const BugsTable = ({ id }: { id: string }) => {
score: {
content: <AiScore campaignId={id} bugId={r.id} />,
},
validation_by: getValidationByObject(r.status.name, r.reviewerType),
status: {
title: r.status.name,
content: r.status.name,
Expand Down Expand Up @@ -183,6 +225,12 @@ const BugsTable = ({ id }: { id: string }) => {
key: "score",
maxWidth: "15ch",
},
{
title: "Validation By",
dataIndex: "validation_by",
key: "validation_by",
maxWidth: "15ch",
},
{
title: "Status",
dataIndex: "status",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -93,6 +94,9 @@ export const CostAndResourceDetailsSection = ({
<Card className="aq-mb-4" title="Human Resources cost">
<HumanResources campaignId={campaignId || "0"} />
</Card>
<Card className="aq-mb-4" title="Other Costs">
<OtherCosts campaignId={campaignId || "0"} />
</Card>
</Section>
);
};
55 changes: 32 additions & 23 deletions src/pages/campaigns/quote/sections/HumanResources/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ const FormContent = ({ campaignId }: { campaignId: string }) => {
return (
<div key={`hr-row-wrapper-${index}`}>
<StyledRow>
<div>
<div style={{ flex: 1 }}>
<Select
menuTargetQuery={"body"}
name={"assignee-select"}
Expand All @@ -225,7 +225,7 @@ const FormContent = ({ campaignId }: { campaignId: string }) => {
/>
</div>

<div>
<div style={{ flex: 1 }}>
<FormLabel
htmlFor={`days-input-${index}`}
label={
Expand All @@ -249,7 +249,7 @@ const FormContent = ({ campaignId }: { campaignId: string }) => {
/>
</div>

<div>
<div style={{ flex: 1 }}>
<Select
placeholder={"-"}
menuTargetQuery={"body"}
Expand All @@ -270,33 +270,42 @@ const FormContent = ({ campaignId }: { campaignId: string }) => {
noOptionsMessage={() => "No options"}
/>
</div>

<div>
<Button
size="sm"
kind="danger"
onClick={() => {
values.items[index].notSaved
? arrayHelpers.remove(index)
: setRowPendingRemoval(index);
}}
>
<DeleteIcon />
</Button>
</div>
</StyledRow>

<div
style={{
display: "flex",
justifyContent: "flex-end",
marginBottom: "8px",
justifyContent: "space-between",
alignItems: "center",
marginTop: "8px",
}}
>
<Text>
Subtotal:{" "}
<span style={{ fontWeight: "bold" }}>{subtotal}€</span>
</Text>
<div
style={{
display: "flex",
justifyContent: "flex-end",
flex: 1,
}}
>
<Text>
Subtotal:{" "}
<span style={{ fontWeight: "bold" }}>
{subtotal}€
</span>
</Text>
</div>
<Button
size="sm"
kind="danger"
onClick={() => {
values.items[index].notSaved
? arrayHelpers.remove(index)
: setRowPendingRemoval(index);
}}
style={{ marginLeft: "16px" }}
>
<DeleteIcon />
</Button>
</div>
</div>
);
Expand Down
160 changes: 160 additions & 0 deletions src/pages/campaigns/quote/sections/OtherCosts/AttachmentsDropzone.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { Dropzone, Spinner } from "@appquality/appquality-design-system";
import { useFormikContext, getIn } from "formik";
import { useState } from "react";
import { usePostCampaignsByCampaignFinanceAttachmentsMutation } 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 [createAttachment] =
usePostCampaignsByCampaignFinanceAttachmentsMutation();
const { values, setFieldValue, errors, touched } =
useFormikContext<FormProps>();
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 createAttachment({
campaign: campaignId,
// @ts-ignore
body: formData,
}).unwrap();

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);
}
}

setFieldValue(name, updatedList);
setIsUploading(false);
};

const handleDelete = (index: number) => {
const updatedList = currentFiles.filter((_: any, i: number) => i !== index);
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 (
<div style={{ marginTop: "8px" }}>
<Dropzone
description="Click or drag files here to upload"
onAccepted={uploadMedia}
onRejected={() => {}}
disabled={isUploading}
danger={!!error && isTouched}
/>

{isUploading && (
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
marginTop: "8px",
}}
>
<Spinner size="sm" />
</div>
)}

<div
style={{
marginTop: "8px",
display: "flex",
flexWrap: "wrap",
gap: "8px",
}}
>
{currentFiles.map((file: any, idx: number) => (
<div
key={`${file.url}-${idx}`}
style={{
fontSize: "16px",
padding: "2px 8px",
border: "1px solid #ddd",
borderRadius: "4px",
background: "#fff",
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
{file.presignedUrl ? (
<span
onClick={() => downloadFile(file)}
style={{
cursor: "pointer",
color: "#0066cc",
display: "flex",
alignItems: "center",
gap: "4px",
flex: 1,
}}
title="Click to download"
>
📎 {file.url.split("/").pop()} ⬇
</span>
) : (
<span>📎 {file.url.split("/").pop()}</span>
)}
<button
type="button"
onClick={() => handleDelete(idx)}
style={{
border: "none",
background: "transparent",
cursor: "pointer",
padding: "0",
fontSize: "14px",
color: "#dc3545",
}}
title="Remove attachment"
>
</button>
</div>
))}
</div>

{error && isTouched && (
<div style={{ color: "red", fontSize: "12px", marginTop: "4px" }}>
{error}
</div>
)}
</div>
);
};
Loading
Loading