Compare commits

..

3 Commits

Author SHA1 Message Date
04bcaa2601 fix merge
All checks were successful
Deploy Amap / deploy (push) Successful in 1m51s
2026-09-07 19:29:27 +02:00
e69ddf3749 fix pr 2026-09-07 19:16:07 +02:00
768fdb8d97 Add min cheque value and view contract at the end of form flow 2026-09-07 19:15:06 +02:00
17 changed files with 995 additions and 434 deletions

View File

@@ -0,0 +1,33 @@
"""add minimum for payment method cheque
Revision ID: 31071e10af15
Revises: e777ed5729ce
Create Date: 2026-09-07 18:48:08.498571
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel.sql.sqltypes
# revision identifiers, used by Alembic.
revision: str = '31071e10af15'
down_revision: Union[str, Sequence[str], None] = 'e777ed5729ce'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('paymentmethod', sa.Column('min', sa.Integer(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('paymentmethod', 'min')
# ### end Alembic commands ###

View File

@@ -42,7 +42,6 @@ def get_one(
"""Get one contract""" """Get one contract"""
return session.get(models.Contract, contract_id) return session.get(models.Contract, contract_id)
def create_one( def create_one(
session: Session, session: Session,
contract: models.ContractCreate contract: models.ContractCreate

View File

@@ -61,6 +61,7 @@ class PaymentMethodBase(SQLModel):
name: str name: str
details: str details: str
max: int | None max: int | None
min: int | None
class PaymentMethod(PaymentMethodBase, table=True): class PaymentMethod(PaymentMethodBase, table=True):

View File

@@ -63,12 +63,14 @@ def update_one(
if 'payment_methods' in productor_updates: if 'payment_methods' in productor_updates:
new_productor.payment_methods.clear() new_productor.payment_methods.clear()
for pm in productor_updates['payment_methods']: for pm in productor_updates['payment_methods']:
print(pm)
new_productor.payment_methods.append( new_productor.payment_methods.append(
models.PaymentMethod( models.PaymentMethod(
name=pm['name'], name=pm['name'],
details=pm['details'], details=pm['details'],
productor_id=id, productor_id=id,
max=pm['max'] max=pm['max'],
min=pm['min']
) )
) )
del productor_updates['payment_methods'] del productor_updates['payment_methods']

View File

@@ -80,6 +80,7 @@ class TestUsersService:
role_names=['test role'] role_names=['test role']
) )
user_id = users[0].id user_id = users[0].id
print(user_id)
result = users_service.update_one(session, user_id, user_update) result = users_service.update_one(session, user_id, user_update)
assert result.id == user_id assert result.id == user_id

View File

@@ -35,7 +35,6 @@
"all referers": "all referents", "all referers": "all referents",
"all shipments": "all shipments", "all shipments": "all shipments",
"all shipments should be recreated for each form creation": "shipments must be recreated for each new contract form.", "all shipments should be recreated for each form creation": "shipments must be recreated for each new contract form.",
"all theses informations are for contract generation": "all this information is required for contract generation.",
"and/or": "and/or", "and/or": "and/or",
"are you sure you want to delete": "are you sure you want to delete", "are you sure you want to delete": "are you sure you want to delete",
"button in front of the line you want to delete": "in front of the line you want to delete (in the actions column).", "button in front of the line you want to delete": "in front of the line you want to delete (in the actions column).",
@@ -223,5 +222,11 @@
"you can download all contracts for your form using the export all": "you can download all contracts for your form using the export all", "you can download all contracts for your form using the export all": "you can download all contracts for your form using the export all",
"your keycloak user has no roles, please contact your administrator": "your keycloak user has no roles, please contact your administrator", "your keycloak user has no roles, please contact your administrator": "your keycloak user has no roles, please contact your administrator",
"your selection in this category will apply for all shipments": "your selection will apply to all shipments (Example: For 6 shipments, the product will be counted 6 times: once per shipment).", "your selection in this category will apply for all shipments": "your selection will apply to all shipments (Example: For 6 shipments, the product will be counted 6 times: once per shipment).",
"your session has expired please log in again": "your session has expired please log in again" "your session has expired please log in again": "your session has expired please log in again",
"all theses informations are for contract generation": "all this information is required for contract generation.",
"i have read and accept the mutual agreement": "i have read and accept the mutual agreement.",
"here": "here",
"your contract has been submited": "your contract has been submited.",
"show contract": "show contract",
"you must accept terms and conditions": "you must accept terms and conditions."
} }

View File

@@ -35,7 +35,6 @@
"all referers": "tous les référent·es", "all referers": "tous les référent·es",
"all shipments": "toutes les livraisons", "all shipments": "toutes les livraisons",
"all shipments should be recreated for each form creation": "les livraisons étant liées à un formulaire elles doivent être recréés pour chaque nouveau formulaire.", "all shipments should be recreated for each form creation": "les livraisons étant liées à un formulaire elles doivent être recréés pour chaque nouveau formulaire.",
"all theses informations are for contract generation": "ces informations sont nécessaires pour la génération de contrat.",
"and/or": "et/ou", "and/or": "et/ou",
"are you sure you want to delete": "êtes vous sûr de vouloir supprimer", "are you sure you want to delete": "êtes vous sûr de vouloir supprimer",
"button in front of the line you want to delete": "en face de la ligne que vous souhaitez supprimer. (dans la colonne actions).", "button in front of the line you want to delete": "en face de la ligne que vous souhaitez supprimer. (dans la colonne actions).",
@@ -223,5 +222,11 @@
"you can download all contracts for your form using the export all": "vous pouvez télécharger tous les contrats de votre formulaire en utilisant le bouton", "you can download all contracts for your form using the export all": "vous pouvez télécharger tous les contrats de votre formulaire en utilisant le bouton",
"your keycloak user has no roles, please contact your administrator": "votre utilisateur keycloak n'a pas de roles configurés, contactez votre administrateur.", "your keycloak user has no roles, please contact your administrator": "votre utilisateur keycloak n'a pas de roles configurés, contactez votre administrateur.",
"your selection in this category will apply for all shipments": "votre sélection sera appliquée pour chaque livraisons (Exemple: Pour 6 livraisons, le produits sera compté 6 fois : une fois par livraison).", "your selection in this category will apply for all shipments": "votre sélection sera appliquée pour chaque livraisons (Exemple: Pour 6 livraisons, le produits sera compté 6 fois : une fois par livraison).",
"your session has expired please log in again": "votre session a expiré veuillez vous reconnecter." "your session has expired please log in again": "votre session a expiré veuillez vous reconnecter.",
"all theses informations are for contract generation": "ces informations sont nécessaires pour la génération de contrat.",
"i have read and accept the mutual agreement": "j'ai lu et j'accepte le contrat d'engagement réciproque.",
"here": "ici",
"your contract has been submited": "votre contrat a été envoyé au référent, si vous avez fait une erreur dans la saisie de votre contrat, contactez votre référent·e.",
"show contract": "voir le contrat",
"you must accept terms and conditions": "vous devez accepter les termes du contrat d'engagement réciproque."
} }

File diff suppressed because it is too large Load Diff

View File

@@ -46,24 +46,24 @@ export function ContractCheque({ inputForm, price, productor }: ContractChequePr
return productor?.payment_methods.find((el) => el.name === "cheque"); return productor?.payment_methods.find((el) => el.name === "cheque");
}, [productor]); }, [productor]);
const maxCheques = useMemo(() => {
return paymentMethod?.max && paymentMethod?.max !== "" ? Number(paymentMethod?.max) : 3
}, [paymentMethod])
return ( return (
<Stack> <Stack>
<Title order={4}>{`${t("order name", {capfirst: true})} : ${paymentMethod?.details}`}</Title> <Title
order={4}
>{`${t("order name", { capfirst: true })} : ${paymentMethod?.details}`}</Title>
<NumberInput <NumberInput
clampBehavior="strict"
label={t("cheque quantity", { capfirst: true })} label={t("cheque quantity", { capfirst: true })}
placeholder={t("enter cheque quantity", { capfirst: true })} placeholder={t("enter cheque quantity", { capfirst: true })}
description={t( description={t(
"number of cheques between 1 and 3 cheques also enter your cheques identifiers, value is calculated automatically", "number of cheques between 1 and 3 cheques also enter your cheques identifiers, value is calculated automatically",
{ capfirst: true, max: maxCheques }, { capfirst: true, min: paymentMethod?.min || 1, max: paymentMethod?.max || 3 },
)} )}
min={1} min={
max={maxCheques} paymentMethod?.min && paymentMethod?.min !== "" ? Number(paymentMethod?.min) : 1
}
max={
paymentMethod?.max && paymentMethod?.max !== "" ? Number(paymentMethod?.max) : 3
}
{...inputForm.getInputProps(`cheque_quantity`)} {...inputForm.getInputProps(`cheque_quantity`)}
/> />
<Group grow> <Group grow>

View File

@@ -0,0 +1,17 @@
export type PdfViewerProps = {
file: string;
};
export function PdfViewer({ file }: PdfViewerProps) {
return (
<div onContextMenu={(e) => e.preventDefault()}>
<iframe
src={`${file.toString()}#toolbar=0&navpanes=0`}
title="Contract PDF"
width="100%"
height="800px"
style={{ border: 0 }}
/>
</div>
);
}

View File

@@ -120,6 +120,7 @@ export function ProductorModal({
name, name,
details: "", details: "",
max: "", max: "",
min: "",
} }
); );
}), }),
@@ -134,6 +135,12 @@ export function ProductorModal({
placeholder={t("order name", { capfirst: true })} placeholder={t("order name", { capfirst: true })}
{...form.getInputProps(`payment_methods.${index}.details`)} {...form.getInputProps(`payment_methods.${index}.details`)}
/> />
<NumberInput
label={t("min cheque number", { capfirst: true })}
placeholder={t("min cheque number", { capfirst: true })}
description={t("can be empty default to 1", { capfirst: true })}
{...form.getInputProps(`payment_methods.${index}.min`)}
/>
<NumberInput <NumberInput
label={t("max cheque number", { capfirst: true })} label={t("max cheque number", { capfirst: true })}
placeholder={t("max cheque number", { capfirst: true })} placeholder={t("max cheque number", { capfirst: true })}

View File

@@ -19,16 +19,24 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { useForm } from "@mantine/form"; import { useForm } from "@mantine/form";
import { IconDownload, IconMail, IconPhone, IconUser } from "@tabler/icons-react"; import { IconDownload, IconMail, IconPhone, IconUser } from "@tabler/icons-react";
import { useCallback, useMemo, useRef } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router"; import { useParams } from "react-router";
import { computePrices } from "./price"; import { computePrices } from "./price";
import { ContractCheque } from "@/components/PaymentMethods/Cheque"; import { ContractCheque } from "@/components/PaymentMethods/Cheque";
import { tranformProducts, type ContractInputs } from "@/services/resources/contracts"; import { tranformProducts, type ContractInputs } from "@/services/resources/contracts";
import type { Shipment } from "@/services/resources/shipments"; import type { Shipment } from "@/services/resources/shipments";
import { useDisclosure } from "@mantine/hooks";
import { ContractRecap } from "./recap";
export function Contract() { export function Contract() {
const { id } = useParams(); const { id } = useParams();
const [pdfUrl, setPdfUrl] = useState<string | undefined>();
const { data: form } = useGetForm(Number(id), { enabled: !!id }); const { data: form } = useGetForm(Number(id), { enabled: !!id });
const [isRecapModalOpened, { open: openRecapModal, close: closeRecapModal }] =
useDisclosure(false);
const minCheques = useMemo(() => {
return form?.productor?.payment_methods.find((pm) => pm.name === "cheque")?.min;
}, [form]);
const inputForm = useForm<ContractInputs>({ const inputForm = useForm<ContractInputs>({
initialValues: { initialValues: {
firstname: "", firstname: "",
@@ -58,6 +66,12 @@ export function Contract() {
}, },
}); });
useEffect(() => {
if (!form) return;
console.log("aze");
inputForm.setFieldValue("cheque_quantity", Number(minCheques) || 1);
}, [form]);
const createContractMutation = useCreateContract(); const createContractMutation = useCreateContract();
const productsRecurent = useMemo(() => { const productsRecurent = useMemo(() => {
@@ -79,8 +93,8 @@ export function Contract() {
return [...acc, shipment]; return [...acc, shipment];
} }
return acc; return acc;
}, [] as Shipment[]) }, [] as Shipment[]);
}, [form]) }, [form]);
const price = useMemo(() => { const price = useMemo(() => {
if (!allProducts) { if (!allProducts) {
@@ -155,8 +169,10 @@ export function Contract() {
form_id: form.id, form_id: form.id,
products: tranformProducts(withDefaultValues(formValues.products)), products: tranformProducts(withDefaultValues(formValues.products)),
}; };
await createContractMutation.mutateAsync(contract); const { blob } = await createContractMutation.mutateAsync(contract);
window.location.href = "/"; const url = URL.createObjectURL(blob);
setPdfUrl(url);
openRecapModal();
} else { } else {
const firstErrorField = Object.keys(errors.errors)[0]; const firstErrorField = Object.keys(errors.errors)[0];
const ref = inputRefs.current[firstErrorField]; const ref = inputRefs.current[firstErrorField];
@@ -169,6 +185,7 @@ export function Contract() {
form, form,
createContractMutation, createContractMutation,
withDefaultValues, withDefaultValues,
setPdfUrl,
]); ]);
if (!form || !shipments || !productsRecurent) if (!form || !shipments || !productsRecurent)
@@ -324,6 +341,7 @@ export function Contract() {
{t("for transfer method contact your referer or productor", { capfirst: true })} {t("for transfer method contact your referer or productor", { capfirst: true })}
</Text> </Text>
) : null} ) : null}
<ContractRecap opened={isRecapModalOpened} close={closeRecapModal} pdfUrl={pdfUrl} />
<Space h="15vh"></Space> <Space h="15vh"></Space>
<Overlay <Overlay
bg={"lightGray"} bg={"lightGray"}
@@ -349,7 +367,7 @@ export function Contract() {
aria-label={t("submit contracts")} aria-label={t("submit contracts")}
onClick={handleSubmit} onClick={handleSubmit}
> >
{t("submit", { capfirst: true })} {t("submit contract", { capfirst: true })}
</Button> </Button>
</Overlay> </Overlay>
</Stack> </Stack>

View File

@@ -0,0 +1,70 @@
import { PdfViewer } from "@/components/PdfViewer";
import { t } from "@/config/i18n";
import { Button, Checkbox, Loader, Modal, Stack, Text } from "@mantine/core";
import { isNotEmpty, useForm } from "@mantine/form";
import { useState } from "react";
export type ContractRecapProps = {
opened: boolean;
close: () => void;
pdfUrl?: string;
};
export function ContractRecap({ opened, close, pdfUrl }: ContractRecapProps) {
const [viewPdf, setViewPdf] = useState<boolean>(false);
const form = useForm({
mode: "uncontrolled",
initialValues: { terms: false },
validate: {
terms: isNotEmpty(t("you must accept terms and conditions", { capfirst: true })),
},
});
if (!pdfUrl)
return (
<Modal
opened={opened}
onClose={close}
size="100%"
title={t("contract recap", { capfirst: true })}
>
<Loader></Loader>
</Modal>
);
return (
<Modal
opened={opened}
onClose={close}
size={"55rem"}
title={t("contract recap", { capfirst: true })}
>
<Stack>
<Text>{t("your contract has been submited", { capfirst: true })}</Text>
<Button onClick={() => setViewPdf(!viewPdf)}>
{t("show contract", { capfirst: true })}
</Button>
{viewPdf ? <PdfViewer file={pdfUrl} /> : null}
<form
onSubmit={form.onSubmit(() => {
const link = document.createElement("a");
link.href = pdfUrl;
link.target = "_blank";
link.click();
URL.revokeObjectURL(pdfUrl);
})}
>
<Stack>
<Checkbox
label={t("i have read and accept the mutual agreement", {
capfirst: true,
})}
key={form.key("terms")}
{...form.getInputProps("terms", { type: "checkbox" })}
/>
<Button type="submit">{t("download contract", { capfirst: true })}</Button>
</Stack>
</form>
</Stack>
</Modal>
);
}

View File

@@ -68,6 +68,7 @@ export default function Productors() {
name: payment.name, name: payment.name,
details: payment.details, details: payment.details,
max: payment.max === "" ? null : payment.max, max: payment.max === "" ? null : payment.max,
min: payment.min === "" ? null : payment.min,
})), })),
}); });
closeModal(); closeModal();
@@ -86,6 +87,7 @@ export default function Productors() {
name: payment.name, name: payment.name,
details: payment.details, details: payment.details,
max: payment.max === "" ? null : payment.max, max: payment.max === "" ? null : payment.max,
min: payment.min === "" ? null : payment.min,
})), })),
}, },
}); });

View File

@@ -27,10 +27,17 @@ import { t } from "@/config/i18n";
import type { DeleteDependencies, EntityName } from "./resources/common"; import type { DeleteDependencies, EntityName } from "./resources/common";
export async function refreshToken() { export async function refreshToken() {
return await fetch(`${Config.backend_uri}/auth/refresh`, {method: "POST", credentials: "include"}); return await fetch(`${Config.backend_uri}/auth/refresh`, {
method: "POST",
credentials: "include",
});
} }
export async function fetchWithAuth(input: RequestInfo, options?: RequestInit, redirect: boolean = true) { export async function fetchWithAuth(
input: RequestInfo,
options?: RequestInit,
redirect: boolean = true,
) {
const res = await fetch(input, { const res = await fetch(input, {
credentials: "include", credentials: "include",
...options, ...options,
@@ -38,12 +45,11 @@ export async function fetchWithAuth(input: RequestInfo, options?: RequestInit, r
if (res.status === 401) { if (res.status === 401) {
const refresh = await refreshToken(); const refresh = await refreshToken();
if (refresh.status !== 200) { if (refresh.status == 400 || refresh.status == 401) {
if (redirect) if (redirect) window.location.href = `/?sessionExpired=True`;
window.location.href = `/?sessionExpired=True`;
const error = new Error("Unauthorized"); const error = new Error("Unauthorized");
error.cause = 401 error.cause = 401;
throw error; throw error;
} }
const newRes = await fetch(input, { const newRes = await fetch(input, {
@@ -51,10 +57,9 @@ export async function fetchWithAuth(input: RequestInfo, options?: RequestInit, r
...options, ...options,
}); });
if (newRes.status === 401 || newRes.status === 403) { if (newRes.status === 401 || newRes.status === 403) {
if (redirect) if (redirect) window.location.href = `/?sessionExpired=True`;
window.location.href = `/?sessionExpired=True`;
const error = new Error("Unauthorized"); const error = new Error("Unauthorized");
error.cause = 401 error.cause = 401;
throw error; throw error;
} }
return newRes; return newRes;
@@ -196,9 +201,7 @@ export function useGetProductors(filters?: URLSearchParams): UseQueryResult<Prod
queryFn: () => queryFn: () =>
fetchWithAuth(`${Config.backend_uri}/productors${filters ? `?${queryString}` : ""}`, { fetchWithAuth(`${Config.backend_uri}/productors${filters ? `?${queryString}` : ""}`, {
credentials: "include", credentials: "include",
}) }).then((res) => res.json()),
.then((res) => res.json()
),
}); });
} }
@@ -329,30 +332,28 @@ export function useGetForm(
}); });
} }
export function useGetDeleteDependencies( export function useGetDeleteDependencies(entity: EntityName, id?: number) {
entity: EntityName,
id?: number,
) {
return useQuery<DeleteDependencies[]>({ return useQuery<DeleteDependencies[]>({
queryKey: [`${entity}_delete_preview_${id}`], queryKey: [`${entity}_delete_preview_${id}`],
queryFn: () => queryFn: () =>
fetchWithAuth(`${Config.backend_uri}/${entity}s/${id}/preview-delete`, { fetchWithAuth(`${Config.backend_uri}/${entity}s/${id}/preview-delete`, {
credentials: "include", credentials: "include",
}).then((res) => { }).then((res) => {
const result = res.json() const result = res.json();
return result return result;
}), }),
enabled: !!id, enabled: !!id,
}); });
} }
export function useGetForms(filters?: URLSearchParams): UseQueryResult<Form[], Error> { export function useGetForms(filters?: URLSearchParams): UseQueryResult<Form[], Error> {
const queryString = filters?.toString(); const queryString = filters?.toString();
return useQuery<Form[]>({ return useQuery<Form[]>({
queryKey: ["forms", queryString], queryKey: ["forms", queryString],
queryFn: () => queryFn: () =>
fetch(`${Config.backend_uri}/forms${filters ? `?${queryString}` : ""}`).then((res) => res.json()), fetch(`${Config.backend_uri}/forms${filters ? `?${queryString}` : ""}`).then((res) =>
res.json(),
),
}); });
} }
@@ -361,7 +362,9 @@ export function useGetReferentForms(filters?: URLSearchParams): UseQueryResult<F
return useQuery<Form[]>({ return useQuery<Form[]>({
queryKey: ["forms", queryString], queryKey: ["forms", queryString],
queryFn: () => queryFn: () =>
fetchWithAuth(`${Config.backend_uri}/forms/referents${filters ? `?${queryString}` : ""}`).then((res) => res.json()), fetchWithAuth(
`${Config.backend_uri}/forms/referents${filters ? `?${queryString}` : ""}`,
).then((res) => res.json()),
}); });
} }
@@ -749,8 +752,9 @@ export function useGetContractFile() {
export function useGetContractFileTemplate() { export function useGetContractFileTemplate() {
return useMutation({ return useMutation({
mutationFn: async (form_id: number) => { mutationFn: async (form_id: number) => {
const res = await fetchWithAuth(`${Config.backend_uri}/contracts/${form_id}/base`) const res = await fetchWithAuth(`${Config.backend_uri}/contracts/${form_id}/base`).then(
.then((res) => res); (res) => res,
);
if (!res.ok) throw new Error(); if (!res.ok) throw new Error();
const blob = await res.blob(); const blob = await res.blob();
@@ -772,7 +776,6 @@ export function useGetContractFileTemplate() {
}); });
} }
export function useGetRecap() { export function useGetRecap() {
return useMutation({ return useMutation({
mutationFn: async (form_id: number) => { mutationFn: async (form_id: number) => {
@@ -848,13 +851,7 @@ export function useCreateContract() {
: `contract_${newContract.form_id}.pdf`; : `contract_${newContract.form_id}.pdf`;
return { blob, filename }; return { blob, filename };
}, },
onSuccess: async ({ blob, filename }) => { onSuccess: async () => {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
await queryClient.invalidateQueries({ queryKey: ["contracts"] }); await queryClient.invalidateQueries({ queryKey: ["contracts"] });
}, },
}); });
@@ -906,9 +903,13 @@ export function useCurrentUser() {
return useQuery<UserLogged>({ return useQuery<UserLogged>({
queryKey: ["currentUser"], queryKey: ["currentUser"],
queryFn: () => { queryFn: () => {
return fetchWithAuth(`${Config.backend_uri}/auth/user/me`, { return fetchWithAuth(
`${Config.backend_uri}/auth/user/me`,
{
credentials: "include", credentials: "include",
}, false).then((res) => res.json()); },
false,
).then((res) => res.json());
}, },
retry: false, retry: false,
}); });

View File

@@ -10,6 +10,7 @@ export type PaymentMethod = {
name: string; name: string;
details: string; details: string;
max: number | string | null; max: number | string | null;
min: number | string | null;
}; };
export type Productor = { export type Productor = {