add logout logic and wip recap

This commit is contained in:
Julien Aldon
2026-02-18 18:08:30 +01:00
parent aca24ca560
commit acbaadff67
29 changed files with 363 additions and 100 deletions

View File

@@ -28,6 +28,14 @@ export default function ContractRow({ contract }: ContractRowProps) {
<Table.Td>
{contract.cheque_quantity > 0 && contract.cheque_quantity} {contract.payment_method}
</Table.Td>
<Table.Td>
{
`${Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
}).format(contract.total_price)}`
}
</Table.Td>
<Table.Td>
<Tooltip label={t("download contract", { capfirst: true })}>
<ActionIcon

View File

@@ -41,7 +41,6 @@ export function Navbar() {
href={`${Config.backend_uri}/auth/logout`}
className={"navLink"}
aria-label={t("logout", { capfirst: true })}
>
{t("logout", { capfirst: true })}
</a>

View File

@@ -1,6 +1,6 @@
import { t } from "@/config/i18n";
import type { ContractInputs } from "@/services/resources/contracts";
import { Group, NumberInput, Stack, TextInput, Title } from "@mantine/core";
import { Group, NumberInput, Stack, Text, TextInput, Title } from "@mantine/core";
import type { UseFormReturnType } from "@mantine/form";
import { useEffect } from "react";
@@ -56,13 +56,18 @@ export function ContractCheque({ inputForm, price, chequeOrder }: ContractCheque
{...inputForm.getInputProps(`cheque_quantity`)}
/>
<Group grow>
{inputForm.values.cheques.map((_cheque, index) => (
{inputForm.values.cheques.map((cheque, index) => (
<Stack key={`${index}`}>
<TextInput
label={t("cheque id", { capfirst: true })}
placeholder={t("cheque id", { capfirst: true })}
{...inputForm.getInputProps(`cheques.${index}.name`)}
/>
error={
cheque.name == "" ?
<Text size="sm" c="red">{inputForm?.errors.cheques}</Text> :
null
}
/>
<NumberInput
readOnly
label={t("cheque value", { capfirst: true })}

View File

@@ -21,7 +21,6 @@ export default function ProductorsFilter({
const defaultTypes = useMemo(() => {
return filters.getAll("types");
}, [filters]);
return (
<Group>
<MultiSelect

View File

@@ -46,6 +46,12 @@ export function ProductorModal({
!value ? `${t("address", { capfirst: true })} ${t("is required")}` : null,
type: (value) =>
!value ? `${t("type", { capfirst: true })} ${t("is required")}` : null,
payment_methods: (value) =>
value.length === 0 || value.some(
(payment) =>
payment.name === "cheque" &&
payment.details === "") ?
`${t("a payment method", { capfirst: true })} ${t("is required")}` : null,
},
});
@@ -89,6 +95,7 @@ export function ProductorModal({
clearable
searchable
value={form.values.payment_methods.map((p) => p.name)}
error={form.errors.payment_methods}
onChange={(names) => {
form.setFieldValue(
"payment_methods",

View File

@@ -35,7 +35,7 @@ export function Contract() {
email: "",
phone: "",
payment_method: "",
cheque_quantity: 1,
cheque_quantity: 0,
cheques: [],
products: {},
},
@@ -50,6 +50,8 @@ export function Contract() {
!value ? `${t("a phone", { capfirst: true })} ${t("is required")}` : null,
payment_method: (value) =>
!value ? `${t("a payment method", { capfirst: true })} ${t("is required")}` : null,
cheques: (value, values) =>
values.payment_method === "cheque" && value.some((val) => val.name == "") ? `${t("cheque id", {capfirst: true})} ${t("is required")}` : null,
},
});

View File

@@ -1,7 +1,7 @@
import { ActionIcon, Group, Loader, ScrollArea, Stack, Table, Title, Tooltip } from "@mantine/core";
import { t } from "@/config/i18n";
import { useGetAllContractFile, useGetContracts } from "@/services/api";
import { IconDownload } from "@tabler/icons-react";
import { useGetAllContractFile, useGetContracts, useGetRecap } from "@/services/api";
import { IconDownload, IconTableExport } from "@tabler/icons-react";
import ContractRow from "@/components/Contracts/Row";
import { useLocation, useNavigate, useSearchParams } from "react-router";
import { ContractModal } from "@/components/Contracts/Modal";
@@ -14,7 +14,9 @@ export default function Contracts() {
const location = useLocation();
const navigate = useNavigate();
const getAllContractFilesMutation = useGetAllContractFile();
const getRecapMutation = useGetRecap();
const isdownload = location.pathname.includes("/download");
const isrecap = location.pathname.includes("/export");
const closeModal = useCallback(() => {
navigate(`/dashboard/contracts${searchParams ? `?${searchParams.toString()}` : ""}`);
@@ -52,6 +54,13 @@ export default function Contracts() {
[getAllContractFilesMutation],
);
const handleDownloadRecap = useCallback(
async (id: number) => {
await getRecapMutation.mutateAsync(id);
},
[getAllContractFilesMutation],
)
if (!contracts || isPending)
return (
<Group align="center" justify="center" h="80vh" w="100%">
@@ -63,23 +72,45 @@ export default function Contracts() {
<Stack>
<Group justify="space-between">
<Title order={2}>{t("all contracts", { capfirst: true })}</Title>
<Tooltip label={t("download contracts", { capfirst: true })}>
<ActionIcon
onClick={(e) => {
e.stopPropagation();
navigate(
`/dashboard/contracts/download${searchParams ? `?${searchParams.toString()}` : ""}`,
);
}}
<Group>
<Tooltip label={t("download contracts", { capfirst: true })}>
<ActionIcon
onClick={(e) => {
e.stopPropagation();
navigate(
`/dashboard/contracts/download${searchParams ? `?${searchParams.toString()}` : ""}`,
);
}}
>
<IconDownload />
</ActionIcon>
</Tooltip>
<Tooltip
label={t("download recap", { capfirst: true })}
>
<IconDownload />
</ActionIcon>
</Tooltip>
<ActionIcon
onClick={(e) => {
e.stopPropagation();
navigate(
`/dashboard/contracts/export${searchParams ? `?${searchParams.toString()}` : ""}`,
);
}}
>
<IconTableExport />
</ActionIcon>
</Tooltip>
</Group>
<ContractModal
opened={isdownload}
onClose={closeModal}
handleSubmit={handleDownloadContracts}
/>
<ContractModal
opened={isrecap}
onClose={closeModal}
handleSubmit={handleDownloadRecap}
/>
</Group>
<ContractsFilters
forms={forms || []}
@@ -94,6 +125,7 @@ export default function Contracts() {
<Table.Th>{t("name", { capfirst: true })}</Table.Th>
<Table.Th>{t("email", { capfirst: true })}</Table.Th>
<Table.Th>{t("payment method", { capfirst: true })}</Table.Th>
<Table.Th>{t("total price", { capfirst: true })}</Table.Th>
<Table.Th>{t("actions", { capfirst: true })}</Table.Th>
</Table.Tr>
</Table.Thead>

View File

@@ -1,11 +1,12 @@
import { Tabs } from "@mantine/core";
import { t } from "@/config/i18n";
import { Link, Outlet, useLocation, useNavigate } from "react-router";
import { useAuth } from "@/services/auth/AuthProvider";
export default function Dashboard() {
const navigate = useNavigate();
const location = useLocation();
const {loggedUser} = useAuth();
return (
<Tabs
w={{ base: "100%", md: "80%", lg: "60%" }}
@@ -21,7 +22,11 @@ export default function Dashboard() {
<Tabs.Tab renderRoot={(props) => (<Link to="/dashboard/forms" {...props}></Link>)} value="forms">{t("forms", { capfirst: true })}</Tabs.Tab>
<Tabs.Tab renderRoot={(props) => (<Link to="/dashboard/shipments" {...props}></Link>)} value="shipments">{t("shipments", { capfirst: true })}</Tabs.Tab>
<Tabs.Tab renderRoot={(props) => (<Link to="/dashboard/contracts" {...props}></Link>)} value="contracts">{t("contracts", { capfirst: true })}</Tabs.Tab>
<Tabs.Tab renderRoot={(props) => (<Link to="/dashboard/users" {...props}></Link>)} value="users">{t("users", { capfirst: true })}</Tabs.Tab>
{
loggedUser?.user?.roles && loggedUser?.user?.roles?.length > 5 ?
<Tabs.Tab renderRoot={(props) => (<Link to="/dashboard/users" {...props}></Link>)} value="users">{t("users", { capfirst: true })}</Tabs.Tab> :
null
}
</Tabs.List>
<Outlet />
</Tabs>

View File

@@ -3,21 +3,20 @@ import {
Accordion,
ActionIcon,
Blockquote,
Group,
NumberInput,
Paper,
Select,
Stack,
TableOfContents,
Text,
TextInput,
Title,
} from "@mantine/core";
import {
IconDownload,
IconEdit,
IconInfoCircle,
IconLink,
IconPlus,
IconTableExport,
IconTestPipe,
IconX,
} from "@tabler/icons-react";
@@ -77,7 +76,7 @@ export function Help() {
<ActionIcon size="sm">
<IconPlus />
</ActionIcon>{" "}
{t("button in top left of the page", { section: t("productors") })}
{t("button in top right of the page", { section: t("productors") })}
</Text>
<Text>
{t("to edit a use the", { capfirst: true, section: t("a productor") })}{" "}
@@ -127,7 +126,7 @@ export function Help() {
<ActionIcon size="sm">
<IconPlus />
</ActionIcon>{" "}
{t("button in top left of the page", { section: t("products") })}
{t("button in top right of the page", { section: t("products") })}
</Text>
<Text>
{t("to edit a use the", { capfirst: true, section: t("a productor") })}{" "}
@@ -178,7 +177,7 @@ export function Help() {
<ActionIcon size="sm">
<IconPlus />
</ActionIcon>{" "}
{t("button in top left of the page", { section: t("forms") })}
{t("button in top right of the page", { section: t("forms") })}
</Text>
<Text>
{t("to edit a use the", { capfirst: true, section: t("a productor") })}{" "}
@@ -225,7 +224,7 @@ export function Help() {
<ActionIcon size="sm">
<IconPlus />
</ActionIcon>{" "}
{t("button in top left of the page", { section: t("shipments") })}
{t("button in top right of the page", { section: t("shipments") })}
</Text>
<Text>
{t("to edit a use the", { capfirst: true, section: t("a productor") })}{" "}
@@ -243,6 +242,41 @@ export function Help() {
</Text>
</Blockquote>
</Stack>
<Title order={3}>{t("export contracts", {capfirst: true})}</Title>
<Stack>
<Text>
{t("to export contracts submissions before sending to the productor go to the contracts section", {capfirst: true})}
<ActionIcon
ml="4"
size="xs"
component={Link}
to="/dashboard/contracts"
aria-label={t("link to the section", {
capfirst: true,
section: t("shipments"),
})}
style={{ cursor: "pointer", alignSelf: "center" }}
>
<IconLink />
</ActionIcon>
</Text>
<Text>{t("in this page you can view all contracts submissions, you can remove duplicates submission or download a specific contract", {capfirst: true})}</Text>
<Text>
{t("you can download all contracts for your form using the export all", {capfirst: true})}{" "}
<ActionIcon size="sm">
<IconDownload/>
</ActionIcon>{" "}
{t("button in top right of the page", { section: t("contracts") })}{" "}
{t("in the same corner you can download a recap by clicking on the button", {capfirst: true})}{" "}
<ActionIcon size="sm">
<IconTableExport/>
</ActionIcon>{" "}
</Text>
<Text>
{t("once all contracts downloaded, you can delete the form (to avoid new submissions) and hide it from the home page", {capfirst: true})}
</Text>
</Stack>
<Title order={3}>{t("glossary", { capfirst: true })}</Title>
<Stack>
<Title order={4} fw={700}>

View File

@@ -1,21 +1,46 @@
import { Flex, Text } from "@mantine/core";
import { Flex, Stack, Text } from "@mantine/core";
import { useGetForms } from "@/services/api";
import { FormCard } from "@/components/Forms/Card";
import type { Form } from "@/services/resources/forms";
import { t } from "@/config/i18n";
import { useSearchParams } from "react-router";
import { useEffect } from "react";
import { showNotification } from "@mantine/notifications";
export function Home() {
const { data: allForms } = useGetForms();
const { data: allForms } = useGetForms(new URLSearchParams("?current_season=true"));
const [searchParams] = useSearchParams();
useEffect(() => {
if (searchParams.get("sessionExpired")) {
showNotification({
title: t("session expired", {capfirst: true}),
message: t("your session has expired please log in again", {capfirst: true}),
color: "red",
autoClose: 5000,
});
}
if (searchParams.get("userNotAllowed")) {
showNotification({
title: t("user not allowed", {capfirst: true}),
message: t("your keycloak user has no roles, please contact your administrator", {capfirst: true}),
color: "red",
autoClose: 5000,
});
}
}, [searchParams])
return (
<Flex gap="md" wrap="wrap" justify="center">
{allForms && allForms?.length > 0 ? (
allForms.map((form: Form) => <FormCard form={form} key={form.id} />)
) : (
<Text mt="lg" size="lg">
{t("there is no contract for now", { capfirst: true })}
</Text>
)}
</Flex>
<Stack mt="lg">
<Flex gap="md" wrap="wrap" justify="center">
{allForms && allForms?.length > 0 ? (
allForms.map((form: Form) => <FormCard form={form} key={form.id} />)
) : (
<Text mt="lg" size="lg">
{t("there is no contract for now", { capfirst: true })}
</Text>
)}
</Flex>
</Stack>
);
}

View File

@@ -18,7 +18,9 @@ export default function Productors() {
const [searchParams, setSearchParams] = useSearchParams();
const location = useLocation();
const navigate = useNavigate();
const { data: productors, isPending } = useGetProductors(searchParams);
const { data: allProductors } = useGetProductors();
const isCreate = location.pathname === "/dashboard/productors/create";
const isEdit = location.pathname.includes("/edit");
@@ -29,15 +31,13 @@ export default function Productors() {
return null;
}, [location, isEdit]);
const closeModal = useCallback(() => {
navigate(`/dashboard/productors${searchParams ? `?${searchParams.toString()}` : ""}`);
}, [navigate, searchParams]);
const { data: productors, isPending } = useGetProductors(searchParams);
const { data: currentProductor } = useGetProductor(Number(editId), {
enabled: !!editId,
});
const { data: allProductors } = useGetProductors();
const closeModal = useCallback(() => {
navigate(`/dashboard/productors${searchParams ? `?${searchParams.toString()}` : ""}`);
}, [navigate, searchParams]);
const names = useMemo(() => {
return allProductors

View File

@@ -17,7 +17,6 @@ export default function Products() {
const [searchParams, setSearchParams] = useSearchParams();
const location = useLocation();
const navigate = useNavigate();
const isCreate = location.pathname === "/dashboard/products/create";
const isEdit = location.pathname.includes("/edit");

View File

@@ -39,6 +39,7 @@ export const router = createBrowserRouter([
{ path: "products/:id/edit", Component: Products },
{ path: "contracts", Component: Contracts },
{ path: "contracts/download", Component: Contracts },
{ path: "contracts/export", Component: Contracts },
{ path: "users", Component: Users },
{ path: "users/create", Component: Users },
{ path: "users/:id/edit", Component: Users },

View File

@@ -38,7 +38,8 @@ export async function fetchWithAuth(input: RequestInfo, options?: RequestInit) {
if (res.status === 401) {
const refresh = await refreshToken();
if (refresh.status == 400 || refresh.status == 401) {
window.location.href = `${Config.backend_uri}/auth/logout`;
window.location.href = `/?sessionExpired=True`;
const error = new Error("Unauthorized");
error.cause = 401
throw error;
@@ -49,6 +50,9 @@ export async function fetchWithAuth(input: RequestInfo, options?: RequestInit) {
});
return newRes;
}
if (res.status == 403) {
throw new Error(res.statusText);
}
return res;
}
@@ -693,7 +697,33 @@ export function useGetContractFile() {
disposition && disposition?.includes("filename=")
? disposition.split("filename=")[1].replace(/"/g, "")
: `contract_${id}.pdf`;
console.log(disposition);
return { blob, filename };
},
onSuccess: ({ blob, filename }) => {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
},
});
}
export function useGetRecap() {
return useMutation({
mutationFn: async (form_id: number) => {
const res = await fetchWithAuth(`${Config.backend_uri}/contracts/${form_id}/recap`, {
credentials: "include",
}).then((res) => res);
if (!res.ok) throw new Error();
const blob = await res.blob();
const disposition = res.headers.get("Content-Disposition");
const filename =
disposition && disposition?.includes("filename=")
? disposition.split("filename=")[1].replace(/"/g, "")
: `contract_recap_${form_id}.odt`;
return { blob, filename };
},
onSuccess: ({ blob, filename }) => {

View File

@@ -14,6 +14,7 @@ export type Contract = {
phone: string;
payment_method: string;
cheque_quantity: number;
total_price: number;
};
export type ContractCreate = {

View File

@@ -15,7 +15,7 @@ export type User = {
name: string;
email: string;
products: Product[];
roles: Role[];
roles: string[];
};
export type UserInputs = {