From f440cef59e06ce891571875c2cb1eb572936f4b6 Mon Sep 17 00:00:00 2001 From: JulienAldon Date: Sat, 14 Feb 2026 23:59:44 +0100 Subject: [PATCH] add contract pdf generation --- backend/README.md | 4 +- backend/pyproject.toml | 3 +- backend/src/auth/auth.py | 6 +- backend/src/contracts/contracts.py | 107 +++++- backend/src/contracts/generate_contract.py | 58 ++++ backend/src/contracts/templates/layout.html | 309 ++++++++++++++++++ backend/src/messages.py | 5 +- backend/src/models.py | 34 +- backend/src/productors/service.py | 23 +- backend/test.pdf | Bin 0 -> 27071 bytes frontend/locales/en.json | 40 ++- frontend/locales/fr.json | 69 +++- frontend/package-lock.json | 92 +++++- frontend/package.json | 1 + frontend/src/components/Forms/Card/index.tsx | 1 + frontend/src/components/Forms/Modal/index.tsx | 17 +- frontend/src/components/Label/index.tsx | 28 ++ frontend/src/components/Navbar/index.css | 13 +- frontend/src/components/Navbar/index.tsx | 14 +- .../src/components/Productors/Modal/index.tsx | 71 ++-- .../src/components/Productors/Row/index.tsx | 12 +- .../src/components/Products/Modal/index.tsx | 29 +- .../src/components/Products/Row/index.tsx | 26 +- .../src/components/Shipments/Form/index.tsx | 35 +- .../src/components/Shipments/Modal/index.tsx | 2 +- frontend/src/components/Users/Modal/index.tsx | 1 - frontend/src/main.tsx | 3 + frontend/src/pages/Contract/index.tsx | 87 ++++- frontend/src/pages/Dashboard/index.tsx | 2 +- frontend/src/pages/Forms/index.tsx | 19 +- frontend/src/pages/Home/index.tsx | 13 +- frontend/src/pages/NotFound/index.tsx | 24 ++ frontend/src/pages/Productors/index.tsx | 11 +- frontend/src/pages/Products/index.tsx | 10 +- frontend/src/pages/Shipments/index.tsx | 9 + frontend/src/pages/Users/index.tsx | 14 +- frontend/src/router.tsx | 6 +- frontend/src/services/api.ts | 183 +++++++++++ frontend/src/services/resources/contracts.ts | 4 + frontend/src/services/resources/forms.ts | 6 +- frontend/src/services/resources/productors.ts | 19 +- frontend/src/services/resources/products.ts | 12 +- 42 files changed, 1299 insertions(+), 123 deletions(-) create mode 100644 backend/src/contracts/generate_contract.py create mode 100644 backend/src/contracts/templates/layout.html create mode 100644 backend/test.pdf create mode 100644 frontend/src/components/Label/index.tsx create mode 100644 frontend/src/pages/NotFound/index.tsx create mode 100644 frontend/src/services/resources/contracts.ts diff --git a/backend/README.md b/backend/README.md index 0ee3cd5..216cc47 100644 --- a/backend/README.md +++ b/backend/README.md @@ -13,7 +13,9 @@ ## Installation ```console -pip install backend +apt install weasyprint +hatch shell +fastapi dev src/main.py ``` ## License diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 30cb798..23ca4f3 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -27,7 +27,8 @@ dependencies = [ "psycopg2-binary", "PyJWT", "cryptography", - "requests" + "requests", + "weasyprint", ] [project.urls] diff --git a/backend/src/auth/auth.py b/backend/src/auth/auth.py index 801d496..ff02820 100644 --- a/backend/src/auth/auth.py +++ b/backend/src/auth/auth.py @@ -13,6 +13,8 @@ import jwt from jwt import PyJWKClient import requests +from src.messages import tokenExpired, invalidToken + router = APIRouter(prefix="/auth") jwk_client = PyJWKClient(JWKS_URL) @@ -78,9 +80,9 @@ def verify_token(token: str): ) return payload except jwt.ExpiredSignatureError: - raise HTTPException(status_code=401, detail="Token expired") + raise HTTPException(status_code=401, detail=tokenExpired) except jwt.InvalidTokenError: - raise HTTPException(status_code=401, detail="Invalid token") + raise HTTPException(status_code=401, detail=invalidToken) def get_current_user( diff --git a/backend/src/contracts/contracts.py b/backend/src/contracts/contracts.py index 7e7902a..6e195fa 100644 --- a/backend/src/contracts/contracts.py +++ b/backend/src/contracts/contracts.py @@ -1,3 +1,108 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Depends +from fastapi.responses import StreamingResponse +from src.database import get_session +from sqlmodel import Session +import src.forms.service as form_service +import src.shipments.service as shipment_service +import src.products.service as product_service +from src.contracts.generate_contract import generate_html_contract +import src.models as models +from src.messages import PDFerrorOccured +import io router = APIRouter(prefix='/contracts') + +def find_dict_in_list(lst, key, value): + for i, dic in enumerate(lst): + if dic[key].id == value: + return i + return -1 + +def extract_products(session: Session, contract: dict): + planned = [] + recurrent = [] + for key in contract.keys(): + key_list = key.split("-") + if "planned" in key: + shipment_id = int(key_list[1]) + product_id = int(key_list[2]) + shipment = shipment_service.get_one(session, shipment_id) + product = product_service.get_one(session, product_id) + + existing_id = find_dict_in_list(planned, "shipment", shipment_id) + if existing_id >= 0: + planned[existing_id]["products"].append({ + "product": product, + "quantity": contract[key], + }) + planned[existing_id]['price'] += compute_product_price(product, contract[key]) + else: + planned.append({ + "shipment": shipment, + "price": compute_product_price(product, contract[key]), + "products": [{ + "product": product, + "quantity": contract[key], + }] + }) + if "recurrent" in key: + product_id = int(key_list[1]) + product = product_service.get_one(session, product_id) + recurrent.append({ + "product": product, + "quantity": contract[key] + }) + return planned, recurrent + +def compute_product_price(product: models.Product, quantity: int, nb_shipment: int = 1): + product_quantity_unit = 1 if product.unit == models.Unit.KILO else 1000 + final_quantity = quantity if product.price else quantity / product_quantity_unit + final_price = product.price if product.price else product.price_kg + return final_price * final_quantity * nb_shipment + +def compute_recurrent_prices(products_quantities: list[dict], nb_shipment: int): + result = 0 + for product_quantity in products_quantities: + product = product_quantity['product'] + quantity = product_quantity['quantity'] + result += compute_product_price(product, quantity, nb_shipment) + return result + +def compute_planned_prices(planned: list[dict]): + result = 0 + for plan in planned: + result += plan['price'] + return result + +@router.post('/') +async def create_contract( + contract: models.ContractBase, + session: Session = Depends(get_session) +): + form = form_service.get_one(session, contract.form_id) + planned, recurrent = extract_products(session, contract.contract) + recurrent_price = compute_recurrent_prices(recurrent, len(form.shipments)) + total_price = '{:10.2f}'.format(recurrent_price + compute_planned_prices(planned)) + # TODO: Store contract + # TODO: send contract to referer + # TODO: Store contract informations ? + try: + pdf_bytes = generate_html_contract( + form, + contract.contract, + planned, + recurrent, + '{:10.2f}'.format(recurrent_price), + total_price + ) + pdf_file = io.BytesIO(pdf_bytes) + contract_id = f'{contract.contract['firstname']}_{contract.contract['lastname']}_{form.productor.type}_{form.season}' + except: + raise HTTPException(status_code=400, detail=PDFerrorOccured) + return StreamingResponse( + pdf_file, + media_type="application/pdf", + headers={ + "Content-Disposition": f"attachement; filename=contract_{contract_id}.pdf" + } + ) \ No newline at end of file diff --git a/backend/src/contracts/generate_contract.py b/backend/src/contracts/generate_contract.py new file mode 100644 index 0000000..1fecdbc --- /dev/null +++ b/backend/src/contracts/generate_contract.py @@ -0,0 +1,58 @@ + +import jinja2 +import src.models as models +import html +from weasyprint import HTML + +def generate_html_contract( + form: models.Form, + contract_informations: dict, + planned: list[dict], + recurrent: list[dict], + recurrent_price: float, + total_price: float, +): + template_dir = "./src/contracts/templates" + template_loader = jinja2.FileSystemLoader(searchpath=template_dir) + template_env = jinja2.Environment(loader=template_loader, autoescape=jinja2.select_autoescape(["html", "xml"])) + template_file = "layout.html" + template = template_env.get_template(template_file) + output_text = template.render( + contract_name=form.name, + contract_type=form.productor.type, + contract_season=form.season, + referer_name=form.referer.name, + referer_email=form.referer.email, + productor_name=form.productor.name, + productor_address=form.productor.address, + payment_methods_map={"cheque": "Ordre du chèque", "transfer": "IBAN (paiement par virements)"}, + productor_payment_methods=form.productor.payment_methods, + member_name=f'{html.escape(contract_informations["firstname"])} {html.escape(contract_informations["lastname"])}', + member_email=html.escape(contract_informations["email"]), + member_phone=html.escape(contract_informations["phone"]), + contract_start_date=form.start, + contract_end_date=form.end, + planned=planned, + recurrent=recurrent, + recurrent_price=recurrent_price, + total_price=total_price, + ) + options = { + 'page-size': 'Letter', + 'margin-top': '0.5in', + 'margin-right': '0.5in', + 'margin-bottom': '0.5in', + 'margin-left': '0.5in', + 'encoding': "UTF-8", + 'print-media-type': True, + "disable-javascript": True, + "disable-external-links": True, + 'enable-local-file-access': False, + "disable-local-file-access": True, + "no-images": True, + } + + return HTML( + string=output_text, + base_url=template_dir + ).write_pdf() \ No newline at end of file diff --git a/backend/src/contracts/templates/layout.html b/backend/src/contracts/templates/layout.html new file mode 100644 index 0000000..fa1e30f --- /dev/null +++ b/backend/src/contracts/templates/layout.html @@ -0,0 +1,309 @@ + + + + {{contract_name}} + + + + +
+

AMAP Croix Luizet

+

67 rue Octavie Villeurbanne - https://amapcroixluizet.eu

+

Contrat d'engagement solidaire

+

Informations contractuelles

+
+

Ce contrat est organisé par l’AMAP CROIX-LUIZET et est régi par les statuts et le règlement intérieur de l’Association.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% for method in productor_payment_methods %} + + + + + {% endfor %} + + + + + + + + + + + + + +
Type de contrat{{contract_type}}
Saison du contrat{{contract_season}}
Type de contrat{{contract_type}}
Référent·e{{referer_name}}
Email référent·e{{referer_email}}
Le/La producteur·trice{{productor_name}}
Adresse du producteur·trice{{productor_address}}
{{payment_methods_map[method.name]}}{{method.details}}
L’adhérent·e{{member_name}}
Email de l’adhérent·e{{member_email}}
Téléphone de l'adhérent·e{{member_phone}}
+

+ L'adhérent-e et le-la producteur-trice s’engagent à respecter le présent contrat, les statuts et le Règlement Intérieur de «l’AMAP CROIX LUIZET» et la charte des AMAP. +

+
+
+
Engagement réciproque
+

+ Le/La producteur·trice s’engage à fournir un panier {{contract_type}}, issu de son exploitation et de qualité en termes gustatifs. Il/Elle s’engage à mener son exploitation dans un esprit de respect de la nature et de l’environnement. + Le/La membre adhérent·e s’engage à acheter 1 panier en acceptant les conséquences d’aléas climatiques ou autres évènements ayant un impact sur la qualité ou la quantité de produits dans le panier. + Le contrat commence le {{contract_start_date}} et termine le {{contract_end_date}}. +

+
+
+
+ Modalités de livraison +
+

+ Les livraisons sont effectuées exclusivement à la Maison du Citoyen, 67 rue Octavie – 69100 VILLEURBANNE, les jeudis soir de 19h00 à 20h00. Toutefois en accord avec le producteur, et suivant les mesures sanitaires en vigueur, le Conseil d’Administration peut modifier exceptionnellement le lieu, le jour ou l’horaire de livraison. +

+
+
+
+ En cas d’impossibilité +
+
    +
  • + Pour le/la producteur·trice d’assurer une livraison, le Conseil d’Administration et le/la référent-e producteur·trice rechercheront, dans le respect des parties et de l’éthique de l’AMAP une solution compensatrice. +
  • +
  • + Pour l’adhérent·e de respecter le calendrier et de venir récupérer sa commande, les membres chargés de la distribution disposeront des paniers restants qui seront donnés à une association caritative ou distribués aux Amapien·ennes présent·es. Aucun panier ne sera remboursé. +
  • +
+
+
+
Rupture du contrat
+

+ Ce contrat peut être interrompu unilatéralement par le/la membre adhérent, si et seulement si, un/une remplaçant·e est trouvé immédiatement, de sorte que le/la producteur·trice ne soit pas pénalisé financièrement. Ce contrat peut être rompu bilatéralement à tout moment. En cas de désaccord, c’est au conseil d’administration de statuer. +

+
+ {% if recurrent|length > 0 %} +
+

Produits récurents (pour chaques livraisons)

+ + + + + + + + + + + + {% for rec in recurrent %} + + + + + + + + {% endfor %} + + + + + +
Nom du produitPrix (€)Prix (€/kg)PoidsQuantité
{{rec.product.name}}{{rec.product.price if rec.product.price else ""}}{{rec.product.price_kg if rec.product.price_kg else ""}}{{rec.product.quantity if rec.product.quantity != None else ""}} {{rec.product.quantity_unit if rec.product.quantity_unit != None else ""}}{{rec.quantity}}{{"g" if rec.product.unit == "1" else "kg" if rec.product.unit == "2" else "p" }}
Total{{recurrent_price}}€
+
+ {% endif %} + {% if planned|length > 0 %} +
+

Produits planifiés (par livraison)

+ {% for plan in planned %} +
{{plan.shipment.name}} {{plan.shipment.date}}
+ + + + + + + + + + + + {% for product in plan.products %} + + + + + + + + {% endfor%} + + + + + +
Nom du produitPrix (€)Prix (€/kg)PoidsQuantité
{{product.product.name}}{{product.product.price if product.product.price else ""}}{{product.product.price_kg if product.product.price_kg else ""}}{{product.product.quantity if product.product.quantity != None else ""}} {{product.product.quantity_unit if product.product.quantity_unit != None else ""}}{{product.quantity}}{{"g" if product.product.unit == "1" else "kg" if product.product.unit == "2" else "p" }}
Total{{plan.price}}€
+ {% endfor %} +
+ {% endif %} +
+
Prix Total :
+
{{total_price}}€
+
+
+ + + + + + + + + + + + + +
Signature producteur-triceSignature adhérent-e
+
+
+ + \ No newline at end of file diff --git a/backend/src/messages.py b/backend/src/messages.py index a4f484d..4c0f417 100644 --- a/backend/src/messages.py +++ b/backend/src/messages.py @@ -1 +1,4 @@ -notfound = "Resource was not found." \ No newline at end of file +notfound = "Resource was not found." +PDFerrorOccured = "An error occured during PDF generation please contact administrator" +tokenExpired = "Token expired" +invalidToken = "Invalid token" \ No newline at end of file diff --git a/backend/src/models.py b/backend/src/models.py index 66e8650..9d2c0ca 100644 --- a/backend/src/models.py +++ b/backend/src/models.py @@ -20,15 +20,30 @@ class UserUpdate(SQLModel): class UserCreate(UserBase): pass +class PaymentMethodBase(SQLModel): + name: str + details: str + +class PaymentMethod(PaymentMethodBase, table=True): + id: int | None = Field(default=None, primary_key=True) + productor_id: int = Field(foreign_key="productor.id", ondelete="CASCADE") + productor: Optional["Productor"] = Relationship( + back_populates="payment_methods", + ) + +class PaymentMethodPublic(PaymentMethodBase): + id: int + productor: Optional["Productor"] + class ProductorBase(SQLModel): name: str address: str - payment: str type: str class ProductorPublic(ProductorBase): id: int products: list["Product"] = [] + payment_methods: list["PaymentMethod"] = [] class Productor(ProductorBase, table=True): id: int | None = Field(default=None, primary_key=True) @@ -39,15 +54,19 @@ class Productor(ProductorBase, table=True): "order_by": "Product.name" }, ) + payment_methods: list["PaymentMethod"] = Relationship( + back_populates="productor", + cascade_delete=True + ) class ProductorUpdate(SQLModel): name: str | None address: str | None - payment: str | None + payment_methods: list["PaymentMethod"] = [] type: str | None class ProductorCreate(ProductorBase): - pass + payment_methods: list["PaymentMethod"] = [] class Unit(StrEnum): GRAMS = "1" @@ -102,6 +121,7 @@ class FormBase(SQLModel): season: str start: datetime.date end: datetime.date + minimum_shipment_value: float | None class FormPublic(FormBase): id: int @@ -128,6 +148,7 @@ class FormUpdate(SQLModel): season: str | None start: datetime.date | None end: datetime.date | None + minimum_shipment_value: float | None class FormCreate(FormBase): pass @@ -148,13 +169,14 @@ class TemplateCreate(TemplateBase): pass class ContractBase(SQLModel): - pass + form_id: int + contract: dict class ContractPublic(ContractBase): id: int -class Contract(ContractBase, table=True): - id: int | None = Field(default=None, primary_key=True) +# class Contract(ContractBase, table=True): +# id: int | None = Field(default=None, primary_key=True) class ContractUpdate(SQLModel): pass diff --git a/backend/src/productors/service.py b/backend/src/productors/service.py index a6d9e80..68fc1ab 100644 --- a/backend/src/productors/service.py +++ b/backend/src/productors/service.py @@ -13,8 +13,16 @@ def get_one(session: Session, productor_id: int) -> models.ProductorPublic: return session.get(models.Productor, productor_id) def create_one(session: Session, productor: models.ProductorCreate) -> models.ProductorPublic: - productor_create = productor.model_dump(exclude_unset=True) + productor_create = productor.model_dump(exclude_unset=True, exclude="payment_methods") new_productor = models.Productor(**productor_create) + + new_productor.payment_methods = [ + models.PaymentMethod( + name=pm.name, + details=pm.details + ) for pm in productor.payment_methods + ] + session.add(new_productor) session.commit() session.refresh(new_productor) @@ -26,7 +34,20 @@ def update_one(session: Session, id: int, productor: models.ProductorUpdate) -> new_productor = result.first() if not new_productor: return None + productor_updates = productor.model_dump(exclude_unset=True) + if "payment_methods" in productor_updates: + new_productor.payment_methods.clear() + for pm in productor_updates["payment_methods"]: + new_productor.payment_methods.append( + models.PaymentMethod( + name=pm["name"], + details=pm["details"], + productor_id=id + ) + ) + del productor_updates["payment_methods"] + for key, value in productor_updates.items(): setattr(new_productor, key, value) session.add(new_productor) diff --git a/backend/test.pdf b/backend/test.pdf new file mode 100644 index 0000000000000000000000000000000000000000..554d27aaa2dadf963c0733b219bb696f3fd83d8a GIT binary patch literal 27071 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.10.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3277,6 +3332,17 @@ "node": ">= 0.8.0" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3335,6 +3401,12 @@ } } }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/react-number-format": { "version": "5.4.4", "resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.4.tgz", @@ -3463,6 +3535,22 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 86ce633..82e812e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "@mantine/dates": "^8.3.14", "@mantine/form": "^8.3.14", "@mantine/hooks": "^8.3.14", + "@mantine/notifications": "^8.3.14", "@tabler/icons": "^3.36.1", "@tabler/icons-react": "^3.36.1", "@tanstack/react-query": "^5.90.20", diff --git a/frontend/src/components/Forms/Card/index.tsx b/frontend/src/components/Forms/Card/index.tsx index 2644658..2b4d1a9 100644 --- a/frontend/src/components/Forms/Card/index.tsx +++ b/frontend/src/components/Forms/Card/index.tsx @@ -16,6 +16,7 @@ export function FormCard({form}: FormCardProps) { ({ initialValues: { name: "", @@ -28,6 +29,7 @@ export default function FormModal({ end: null, productor_id: "", referer_id: "", + minimum_shipment_value: null, }, validate: { name: (value) => @@ -67,10 +69,9 @@ export default function FormModal({ return ( <Modal - w={{base: "100%", md: "80%", lg: "50%"}} opened={opened} onClose={onClose} - title={currentForm ? t("edit form") : t('create form')} + title={currentForm ? t("edit form", {capfirst: true}) : t('create form', {capfirst: true})} > <TextInput label={t("form name", {capfirst: true})} @@ -123,6 +124,13 @@ export default function FormModal({ data={productorsSelect || []} {...form.getInputProps('productor_id')} /> + <NumberInput + label={t("minimum shipment value", {capfirst: true})} + placeholder={t("minimum shipment value", {capfirst: true})} + description={t("some contracts require a minimum value per shipment, ignore this field if it's not the case", {capfirst: true})} + radius="sm" + {...form.getInputProps('minimum_shipment_value')} + /> <Group mt="sm" justify="space-between"> <Button variant="filled" @@ -137,6 +145,7 @@ export default function FormModal({ <Button variant="filled" aria-label={currentForm ? t("edit form", {capfirst: true}) : t('create form', {capfirst: true})} + leftSection={currentForm ? <IconEdit/> : <IconPlus/>} onClick={() => { form.validate(); if (form.isValid()) { diff --git a/frontend/src/components/Label/index.tsx b/frontend/src/components/Label/index.tsx new file mode 100644 index 0000000..f203037 --- /dev/null +++ b/frontend/src/components/Label/index.tsx @@ -0,0 +1,28 @@ +import { ActionIcon, Tooltip } from "@mantine/core"; +import { IconInfoCircle } from "@tabler/icons-react"; + +export type InputLabelProps = { + label: string; + info: string; + isRequired?: boolean; +} + +export function InputLabel({label, info, isRequired}: InputLabelProps) { + return ( + <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}> + <Tooltip label={info}> + <ActionIcon variant="transparent" size="xs" color="gray"> + <IconInfoCircle size={16}/> + </ActionIcon> + </Tooltip> + <span> + {label} + { + isRequired ? + <span style={{ color: 'red' }}> *</span> : null + } + </span> + + </div> + ); +} \ No newline at end of file diff --git a/frontend/src/components/Navbar/index.css b/frontend/src/components/Navbar/index.css index 2756e61..b9fba6a 100644 --- a/frontend/src/components/Navbar/index.css +++ b/frontend/src/components/Navbar/index.css @@ -1,12 +1,13 @@ nav { display: flex; - justify-content: space-between; justify-self: left; - width: 50%; + padding: 1rem; + background-color: var(--mantine-color-blue-4); } -a { - gap: 1em; +.navLink { + color: #fff; + font-weight: bold; + margin-right: 1rem; text-decoration: none; -} - +} \ No newline at end of file diff --git a/frontend/src/components/Navbar/index.tsx b/frontend/src/components/Navbar/index.tsx index fe405b1..caced57 100644 --- a/frontend/src/components/Navbar/index.tsx +++ b/frontend/src/components/Navbar/index.tsx @@ -5,8 +5,18 @@ import "./index.css"; export function Navbar() { return ( <nav> - <NavLink to="/">{t("home", {capfirst: true})}</NavLink> - <NavLink to="/dashboard/productors">{t("dashboard", {capfirst: true})}</NavLink> + <NavLink + className={"navLink"} + to="/" + > + {t("home", {capfirst: true})} + </NavLink> + <NavLink + className={"navLink"} + to="/dashboard/productors" + > + {t("dashboard", {capfirst: true})} + </NavLink> </nav> ); } \ No newline at end of file diff --git a/frontend/src/components/Productors/Modal/index.tsx b/frontend/src/components/Productors/Modal/index.tsx index 837c99b..8f1c7ce 100644 --- a/frontend/src/components/Productors/Modal/index.tsx +++ b/frontend/src/components/Productors/Modal/index.tsx @@ -1,8 +1,8 @@ -import { Button, Group, Modal, TextInput, Title, type ModalBaseProps } from "@mantine/core"; +import { Button, Group, Modal, MultiSelect, TextInput, Title, type ModalBaseProps } from "@mantine/core"; import { t } from "@/config/i18n"; import { useForm } from "@mantine/form"; import { IconCancel } from "@tabler/icons-react"; -import type { Productor, ProductorInputs } from "@/services/resources/productors"; +import { PaymentMethods, type Productor, type ProductorInputs } from "@/services/resources/productors"; import { useEffect } from "react"; export type ProductorModalProps = ModalBaseProps & { @@ -20,18 +20,16 @@ export function ProductorModal({ initialValues: { name: "", address: "", - payment: "", + payment_methods: [], type: "", }, validate: { name: (value) => - !value ? `${t("name", {capfirst: true})} ${t('is required')}` : null, + !value ? `${t("name", {capfirst: true})} ${t("is required")}` : null, address: (value) => - !value ? `${t("address", {capfirst: true})} ${t('is required')}` : null, - payment: (value) => - !value ? `${t("payment", {capfirst: true})} ${t('is required')}` : null, + !value ? `${t("address", {capfirst: true})} ${t("is required")}` : null, type: (value) => - !value ? `${t("type", {capfirst: true})} ${t('is required')}` : null + !value ? `${t("type", {capfirst: true})} ${t("is required")}` : null } }); @@ -45,7 +43,6 @@ export function ProductorModal({ return ( <Modal - w={{base: "100%", md: "80%", lg: "50%"}} opened={opened} onClose={onClose} title={t("create productor", {capfirst: true})} @@ -56,30 +53,65 @@ export function ProductorModal({ placeholder={t("productor name", {capfirst: true})} radius="sm" withAsterisk - {...form.getInputProps('name')} + {...form.getInputProps("name")} /> <TextInput label={t("productor type", {capfirst: true})} placeholder={t("productor type", {capfirst: true})} radius="sm" withAsterisk - {...form.getInputProps('type')} + {...form.getInputProps("type")} /> <TextInput label={t("productor address", {capfirst: true})} placeholder={t("productor address", {capfirst: true})} radius="sm" withAsterisk - {...form.getInputProps('address')} + {...form.getInputProps("address")} /> - <TextInput - label={t("productor payment", {capfirst: true})} - placeholder={t("productor payment", {capfirst: true})} + <MultiSelect + label={t("payment methods", {capfirst: true})} + placeholder={t("payment methods", {capfirst: true})} radius="sm" withAsterisk - {...form.getInputProps('payment')} + data={PaymentMethods} + clearable + searchable + value={form.values.payment_methods.map(p => p.name)} + onChange={(names) => { + form.setFieldValue("payment_methods", names.map(name => { + const existing = form.values.payment_methods.find(p => p.name === name); + return existing ?? { + name, + details: "" + }; + })); + }} /> - + { + form.values.payment_methods.map((method, index) => ( + <TextInput + key={index} + label={ + method.name === "cheque" ? + t("order name", {capfirst: true}) : + method.name === "transfer" ? + t("IBAN") : + t("details", {capfirst: true}) + } + placeholder={ + method.name === "cheque" ? + t("order name", {capfirst: true}) : + method.name === "transfer" ? + t("IBAN") : + t("details", {capfirst: true}) + } + {...form.getInputProps( + `payment_methods.${index}.details` + )} + /> + )) + } <Group mt="sm" justify="space-between"> <Button variant="filled" @@ -93,14 +125,15 @@ export function ProductorModal({ >{t("cancel", {capfirst: true})}</Button> <Button variant="filled" - aria-label={currentProductor ? t("edit productor", {capfirst: true}) : t('create productor', {capfirst: true})} + aria-label={currentProductor ? t("edit productor", {capfirst: true}) : t("create productor", {capfirst: true})} onClick={() => { form.validate(); + console.log(form.getValues()) if (form.isValid()) { handleSubmit(form.getValues(), currentProductor?.id) } }} - >{currentProductor ? t("edit productor", {capfirst: true}) : t('create productor', {capfirst: true})}</Button> + >{currentProductor ? t("edit productor", {capfirst: true}) : t("create productor", {capfirst: true})}</Button> </Group> </Modal> ); diff --git a/frontend/src/components/Productors/Row/index.tsx b/frontend/src/components/Productors/Row/index.tsx index 695c580..6833b45 100644 --- a/frontend/src/components/Productors/Row/index.tsx +++ b/frontend/src/components/Productors/Row/index.tsx @@ -1,4 +1,4 @@ -import { ActionIcon, Table, Tooltip } from "@mantine/core"; +import { ActionIcon, Badge, Table, Tooltip } from "@mantine/core"; import { t } from "@/config/i18n"; import { IconEdit, IconX } from "@tabler/icons-react"; import type { Productor } from "@/services/resources/productors"; @@ -21,7 +21,15 @@ export default function ProductorRow({ <Table.Td>{productor.name}</Table.Td> <Table.Td>{productor.type}</Table.Td> <Table.Td>{productor.address}</Table.Td> - <Table.Td>{productor.payment}</Table.Td> + <Table.Td> + { + productor.payment_methods.map((value) =>( + <Badge ml="xs"> + {t(value.name, {capfirst: true})} + </Badge> + )) + } + </Table.Td> <Table.Td> <Tooltip label={t("edit productor", {capfirst: true})}> <ActionIcon diff --git a/frontend/src/components/Products/Modal/index.tsx b/frontend/src/components/Products/Modal/index.tsx index ac93c98..c080e57 100644 --- a/frontend/src/components/Products/Modal/index.tsx +++ b/frontend/src/components/Products/Modal/index.tsx @@ -1,10 +1,11 @@ -import { Button, Group, Modal, NumberInput, Pill, Select, TextInput, Title, Tooltip, type ModalBaseProps } from "@mantine/core"; +import { Button, Group, Modal, NumberInput, Select, TextInput, Title, type ModalBaseProps } from "@mantine/core"; import { t } from "@/config/i18n"; import { useForm } from "@mantine/form"; -import { IconCancel, IconInfoCircle } from "@tabler/icons-react"; +import { IconCancel } from "@tabler/icons-react"; import { ProductQuantityUnit, productToProductInputs, ProductUnit, type Product, type ProductInputs } from "@/services/resources/products"; import { useEffect, useMemo } from "react"; import { getProductors } from "@/services/api"; +import { InputLabel } from "@/components/Label"; export type ProductModalProps = ModalBaseProps & { currentProduct?: Product; @@ -42,7 +43,7 @@ export function ProductModal({ !value ? `${t("type", {capfirst: true})} ${t('is required')}` : null, productor_id: (value) => !value ? `${t("productor", {capfirst: true})} ${t('is required')}` : null - } + }, }); useEffect(() => { @@ -57,7 +58,6 @@ export function ProductModal({ return ( <Modal - w={{base: "100%", md: "80%", lg: "50%"}} opened={opened} onClose={onClose} title={t("create product", {capfirst: true})} @@ -78,19 +78,23 @@ export function ProductModal({ label={t("product name", {capfirst: true})} placeholder={t("product name", {capfirst: true})} radius="sm" - withAsterisk {...form.getInputProps('name')} /> <Select - label={t("product type", {capfirst: true})} + label={ + <InputLabel + label={t("product type", {capfirst: true})} + info={t("recurrent product is for all shipments, planned product is for a specific shipment (see shipment form)", {capfirst: true})} + isRequired + /> + } placeholder={t("product type", {capfirst: true})} radius="sm" - withAsterisk searchable clearable data={[ - {value: "1", label: t("planned")}, - {value: "2", label: t("recurrent")} + {value: "1", label: t("planned", {capfirst: true})}, + {value: "2", label: t("recurrent", {capfirst: true})} ]} {...form.getInputProps('type')} /> @@ -103,7 +107,7 @@ export function ProductModal({ withAsterisk searchable clearable - data={Object.entries(ProductUnit).map(([key, value]) => ({value: key, label: t(value)}))} + data={Object.entries(ProductUnit).map(([key, value]) => ({value: key, label: t(value, {capfirst: true})}))} {...form.getInputProps('unit')} /> <Group grow> @@ -131,7 +135,9 @@ export function ProductModal({ label={t("product quantity unit", {capfirst: true})} placeholder={t("product quantity unit", {capfirst: true})} radius="sm" - data={Object.entries(ProductQuantityUnit).map(([key, value]) => ({value: key, label: t(value)}))} + clearable + searchable + data={Object.entries(ProductQuantityUnit).map(([key, value]) => ({value: key, label: t(value, {capfirst: true})}))} {...form.getInputProps('quantity_unit', {capfirst: true})} /> @@ -152,7 +158,6 @@ export function ProductModal({ aria-label={currentProduct ? t("edit product", {capfirst: true}) : t('create product', {capfirst: true})} onClick={() => { form.validate(); - console.log(form.isValid(), form.getValues()) if (form.isValid()) { handleSubmit(form.getValues(), currentProduct?.id) } diff --git a/frontend/src/components/Products/Row/index.tsx b/frontend/src/components/Products/Row/index.tsx index 53f9d64..7b4498e 100644 --- a/frontend/src/components/Products/Row/index.tsx +++ b/frontend/src/components/Products/Row/index.tsx @@ -20,11 +20,27 @@ export default function ProductRow({ <Table.Tr key={product.id}> <Table.Td>{product.name}</Table.Td> <Table.Td>{t(ProductType[product.type])}</Table.Td> - <Table.Td>{product.price}</Table.Td> - <Table.Td>{product.price_kg}</Table.Td> - <Table.Td>{product.quantity}</Table.Td> - <Table.Td>{product.quantity_unit}</Table.Td> - <Table.Td>{t(ProductUnit[product.unit])}</Table.Td> + <Table.Td> + { + product.price ? + Intl.NumberFormat( + "fr-FR", + {style: "currency", currency: "EUR"} + ).format(product.price) : null + } + </Table.Td> + <Table.Td> + { + product.price_kg ? + `${Intl.NumberFormat( + "fr-FR", + {style: "currency", currency: "EUR"} + ).format(product.price_kg)}/kg` : null + + } + </Table.Td> + <Table.Td>{product.quantity}{product.quantity_unit}</Table.Td> + <Table.Td>{t(ProductUnit[product.unit], {capfirst: true})}</Table.Td> <Table.Td> <Tooltip label={t("edit product", {capfirst: true})}> <ActionIcon diff --git a/frontend/src/components/Shipments/Form/index.tsx b/frontend/src/components/Shipments/Form/index.tsx index c6bbf7b..8995cad 100644 --- a/frontend/src/components/Shipments/Form/index.tsx +++ b/frontend/src/components/Shipments/Form/index.tsx @@ -1,13 +1,15 @@ -import { Accordion, Group, Text } from "@mantine/core"; +import { Accordion, Group, Stack, Text } from "@mantine/core"; import type { Shipment } from "@/services/resources/shipments"; import { ProductForm } from "@/components/Products/Form"; import type { UseFormReturnType } from "@mantine/form"; import { useMemo } from "react"; import { computePrices } from "@/pages/Contract"; +import { t } from "@/config/i18n"; export type ShipmentFormProps = { inputForm: UseFormReturnType<Record<string, string | number>>; shipment: Shipment; + minimumPrice?: number | null; index: number; } @@ -15,6 +17,7 @@ export default function ShipmentForm({ shipment, index, inputForm, + minimumPrice, }: ShipmentFormProps) { const shipmentPrice = useMemo(() => { const values = Object @@ -26,18 +29,42 @@ export default function ShipmentForm({ return computePrices(values, shipment.products); }, [inputForm, shipment.products]); + const priceRequirement = useMemo(() => { + if (!minimumPrice) + return false; + return minimumPrice ? shipmentPrice < minimumPrice : true + }, [shipmentPrice, minimumPrice]) + return ( <Accordion.Item value={String(index)}> <Accordion.Control> <Group justify="space-between"> - <Text>{shipment.name}</Text> - <Text>{ + <Text>{shipment.name}</Text> + <Stack gap={0}> + <Text c={priceRequirement ? "red" : "green"}>{ Intl.NumberFormat( "fr-FR", {style: "currency", currency: "EUR"} ).format(shipmentPrice) }</Text> - <Text mr="lg">{shipment.date}</Text> + { + priceRequirement ? + <Text c="red"size="sm"> + {`${t("minimum price for this shipment should be at least", {capfirst: true})} ${minimumPrice}€`} + </Text> : + null + } + </Stack> + <Text mr="lg"> + {`${ + new Date(shipment.date).toLocaleDateString("fr-FR", { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + }) + }`} + </Text> </Group> </Accordion.Control> <Accordion.Panel> diff --git a/frontend/src/components/Shipments/Modal/index.tsx b/frontend/src/components/Shipments/Modal/index.tsx index b63aa22..f3a0942 100644 --- a/frontend/src/components/Shipments/Modal/index.tsx +++ b/frontend/src/components/Shipments/Modal/index.tsx @@ -63,7 +63,6 @@ export default function ShipmentModal({ return ( <Modal - w={{base: "100%", md: "80%", lg: "50%"}} opened={opened} onClose={onClose} title={currentShipment ? t("edit shipment") : t('create shipment')} @@ -93,6 +92,7 @@ export default function ShipmentModal({ <MultiSelect label={t("shipment products", {capfirst: true})} placeholder={t("shipment products", {capfirst: true})} + description={t("shipment products is necessary only for planned products (if all products are recurrent leave empty)", {capfirst: true})} data={productsSelect || []} clearable searchable diff --git a/frontend/src/components/Users/Modal/index.tsx b/frontend/src/components/Users/Modal/index.tsx index 32a9f66..31a6281 100644 --- a/frontend/src/components/Users/Modal/index.tsx +++ b/frontend/src/components/Users/Modal/index.tsx @@ -37,7 +37,6 @@ export function UserModal({ return ( <Modal - w={{base: "100%", md: "80%", lg: "50%"}} opened={opened} onClose={onClose} title={t("create user", {capfirst: true})} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index a535df6..d1c9a66 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -6,6 +6,8 @@ import { MantineProvider } from "@mantine/core"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import '@mantine/core/styles.css'; import '@mantine/dates/styles.css'; +import '@mantine/notifications/styles.css'; +import { Notifications } from "@mantine/notifications"; const queryClient = new QueryClient() @@ -13,6 +15,7 @@ createRoot(document.getElementById("root")!).render( <StrictMode> <QueryClientProvider client={queryClient}> <MantineProvider> + <Notifications /> <RouterProvider router={router} /> </MantineProvider> </QueryClientProvider> diff --git a/frontend/src/pages/Contract/index.tsx b/frontend/src/pages/Contract/index.tsx index 1f8179f..fa81531 100644 --- a/frontend/src/pages/Contract/index.tsx +++ b/frontend/src/pages/Contract/index.tsx @@ -1,12 +1,12 @@ import { ProductForm } from "@/components/Products/Form"; import ShipmentForm from "@/components/Shipments/Form"; import { t } from "@/config/i18n"; -import { getForm } from "@/services/api"; +import { createContract, getForm } from "@/services/api"; import { type Product } from "@/services/resources/products"; import { Accordion, Button, Group, List, Loader, Overlay, Stack, Text, TextInput, Title } from "@mantine/core"; import { useForm } from "@mantine/form"; import { IconMail, IconPhone, IconUser } from "@tabler/icons-react"; -import { useMemo } from "react"; +import { useCallback, useMemo, useRef } from "react"; import { useParams } from "react-router"; export function computePrices(values: [string, any][], products: Product[], nbShipment?: number) { @@ -15,7 +15,7 @@ export function computePrices(values: [string, any][], products: Product[], nbSh const productId = Number(keyArray[keyArray.length - 1]); const product = products.find((product) => product.id === productId); if (!product) { - return 0; + return prev + 0; } const isRecurent = key.includes("recurrent") && nbShipment; const productPrice = Number(product.price || product.price_kg); @@ -29,6 +29,12 @@ export function Contract() { const { id } = useParams(); const { data: form } = getForm(Number(id), {enabled: !!id}); const inputForm = useForm<Record<string, number | string>>({ + initialValues: { + firstname: "", + lastname: "", + email: "", + phone: "", + }, validate: { firstname: (value) => !value ? `${t("a firstname", {capfirst: true})} ${t("is required")}` : null, lastname: (value) => !value ? `${t("a lastname", {capfirst: true})} ${t("is required")}` : null, @@ -37,6 +43,8 @@ export function Contract() { } }); + const createContractMutation = createContract(); + const productsRecurent = useMemo(() => { return form?.productor?.products.filter((el) => el.type === "2") }, [form]); @@ -50,10 +58,73 @@ export function Contract() { }, [form]) const price = useMemo(() => { + if (!allProducts) { + return 0; + } const values = Object.entries(inputForm.getValues()); return computePrices(values, allProducts, form?.shipments.length); }, [inputForm, allProducts, form?.shipments]); + const inputRefs: Record<string, React.RefObject<HTMLInputElement | null>> = { + firstname: useRef<HTMLInputElement>(null), + lastname: useRef<HTMLInputElement>(null), + email: useRef<HTMLInputElement>(null), + phone: useRef<HTMLInputElement>(null) + } + + const isShipmentsMinimumValue = useCallback(() => { + const shipmentErrors = form.shipments + .map((shipment) => { + const total = computePrices( + Object.entries(inputForm.getValues()), + shipment.products + ); + if (total < (form?.minimum_shipment_value || 0)) { + return shipment.id; // mark shipment as invalid + } + return null; + }) + .filter(Boolean); + return shipmentErrors.length === 0; + }, [form]); + + const withDefaultValues = useCallback((values: Record<string, number | string>) => { + const result = {...values}; + + productsRecurent.forEach((product: Product) => { + const key = `recurrent-${product.id}`; + if (result[key] === undefined || result[key] === "") { + result[key] = 0; + } + }); + + form.shipments.forEach((shipment) => { + shipment.products.forEach((product) => { + const key = `planned-${shipment.id}-${product.id}`; + if (result[key] === undefined || result[key] === "") { + result[key] = 0; + } + }) + }); + + return result; + }, [productsRecurent, form]); + + const handleSubmit = useCallback(async () => { + const errors = inputForm.validate(); + if (inputForm.isValid() && isShipmentsMinimumValue()) { + const contract = { + form_id: form.id, + contract: withDefaultValues(inputForm.getValues()), + } + await createContractMutation.mutateAsync(contract); + } else { + const firstErrorField = Object.keys(errors.errors)[0]; + const ref = inputRefs[firstErrorField]; + ref?.current?.scrollIntoView({behavior: "smooth", block: "center"}); + } + }, [inputForm, inputRefs, isShipmentsMinimumValue, form]); + if (!form) return <Loader/>; @@ -74,6 +145,7 @@ export function Contract() { required leftSection={<IconUser/>} {...inputForm.getInputProps('firstname')} + ref={inputRefs.firstname} /> <TextInput label={t("lastname", {capfirst: true})} @@ -83,6 +155,7 @@ export function Contract() { required leftSection={<IconUser/>} {...inputForm.getInputProps('lastname')} + ref={inputRefs.lastname} /> </Group> <Group grow> @@ -94,6 +167,7 @@ export function Contract() { required leftSection={<IconMail/>} {...inputForm.getInputProps('email')} + ref={inputRefs.email} /> <TextInput label={t("phone", {capfirst: true})} @@ -103,6 +177,7 @@ export function Contract() { required leftSection={<IconPhone/>} {...inputForm.getInputProps('phone')} + ref={inputRefs.phone} /> </Group> <Title order={3}>{t('shipments', {capfirst: true})} @@ -149,6 +224,7 @@ export function Contract() { { shipments.map((shipment, index) => ( diff --git a/frontend/src/pages/Dashboard/index.tsx b/frontend/src/pages/Dashboard/index.tsx index 8c36c38..a0c882b 100644 --- a/frontend/src/pages/Dashboard/index.tsx +++ b/frontend/src/pages/Dashboard/index.tsx @@ -19,7 +19,7 @@ export default function Dashboard() { {t("products", {capfirst: true})} {t("forms", {capfirst: true})} {t("shipments", {capfirst: true})} - {t("templates", {capfirst: true})} + {/* {t("templates", {capfirst: true})} */} {t("users", {capfirst: true})} diff --git a/frontend/src/pages/Forms/index.tsx b/frontend/src/pages/Forms/index.tsx index 05c554b..3f7f1a4 100644 --- a/frontend/src/pages/Forms/index.tsx +++ b/frontend/src/pages/Forms/index.tsx @@ -8,6 +8,7 @@ import FormModal from "@/components/Forms/Modal"; import FormRow from "@/components/Forms/Row"; import type { Form, FormInputs } from "@/services/resources/forms"; import FilterForms from "@/components/Forms/Filter"; +import { notifications } from "@mantine/notifications"; export function Forms() { const [ searchParams, setSearchParams ] = useSearchParams(); @@ -52,11 +53,16 @@ export function Forms() { await createFormMutation.mutateAsync({ ...form, start: form?.start, - end: form?.start, + end: form?.end, productor_id: Number(form.productor_id), - referer_id: Number(form.referer_id) + referer_id: Number(form.referer_id), + minimum_shipment_value: Number(form.minimum_shipment_value), }); closeModal(); + notifications.show({ + title: t("success", {capfirst: true}), + message: t("successfully created form", {capfirst: true}), + }); }, [createFormMutation]); const handleEditForm = useCallback(async (form: FormInputs, id?: number) => { @@ -67,12 +73,17 @@ export function Forms() { form: { ...form, start: form.start, - end: form.start, + end: form.end, productor_id: Number(form.productor_id), - referer_id: Number(form.referer_id) + referer_id: Number(form.referer_id), + minimum_shipment_value: Number(form.minimum_shipment_value), } }); closeModal(); + notifications.show({ + title: t("success", {capfirst: true}), + message: t("successfully edited form", {capfirst: true}), + }); }, [editFormMutation]); const onFilterChange = useCallback(( diff --git a/frontend/src/pages/Home/index.tsx b/frontend/src/pages/Home/index.tsx index 7818a9c..2982969 100644 --- a/frontend/src/pages/Home/index.tsx +++ b/frontend/src/pages/Home/index.tsx @@ -1,9 +1,8 @@ -import { Flex } from "@mantine/core"; -import { t } from "@/config/i18n"; -import { useParams } from "react-router"; +import { Flex, Text } from "@mantine/core"; import { getForms } from "@/services/api"; import { FormCard } from "@/components/Forms/Card"; import type { Form } from "@/services/resources/forms"; +import { t } from "@/config/i18n"; export function Home() { const { data: allForms } = getForms(); @@ -11,9 +10,11 @@ export function Home() { return ( { - allForms?.map((form: Form) => ( - - )) + allForms && allForms?.length > 0 ? + allForms.map((form: Form) => ( + + )) : + {t("there is no contract for now",{capfirst: true})} } ); diff --git a/frontend/src/pages/NotFound/index.tsx b/frontend/src/pages/NotFound/index.tsx new file mode 100644 index 0000000..25262b8 --- /dev/null +++ b/frontend/src/pages/NotFound/index.tsx @@ -0,0 +1,24 @@ +import { t } from "@/config/i18n"; +import { ActionIcon, Stack, Text, Title, Tooltip } from "@mantine/core"; +import { IconHome } from "@tabler/icons-react"; +import { useNavigate } from "react-router"; + +export function NotFound() { + const navigate = useNavigate() + return ( + + {t("oops", {capfirst: true})} + {t('this page does not exists', {capfirst: true})} + + { + navigate('/') + }} + > + + + + + ); +} \ No newline at end of file diff --git a/frontend/src/pages/Productors/index.tsx b/frontend/src/pages/Productors/index.tsx index 3449480..1d6a260 100644 --- a/frontend/src/pages/Productors/index.tsx +++ b/frontend/src/pages/Productors/index.tsx @@ -8,6 +8,7 @@ import { ProductorModal } from "@/components/Productors/Modal"; import { useCallback, useMemo } from "react"; import type { Productor, ProductorInputs } from "@/services/resources/productors"; import ProductorsFilters from "@/components/Productors/Filter"; +import { notifications } from "@mantine/notifications"; export default function Productors() { const [ searchParams, setSearchParams ] = useSearchParams(); @@ -50,6 +51,10 @@ export default function Productors() { ...productor }); closeModal(); + notifications.show({ + title: t("success", {capfirst: true}), + message: t("successfully created productor", {capfirst: true}), + }); }, [createProductorMutation]); const handleEditProductor = useCallback(async (productor: ProductorInputs, id?: number) => { @@ -60,6 +65,10 @@ export default function Productors() { productor: productor }); closeModal(); + notifications.show({ + title: t("success", {capfirst: true}), + message: t("successfully edited productor", {capfirst: true}), + }); }, []); const onFilterChange = useCallback((values: string[], filter: string) => { @@ -116,7 +125,7 @@ export default function Productors() { {t("name", {capfirst: true})} {t("type", {capfirst: true})} {t("address", {capfirst: true})} - {t("payment", {capfirst: true})} + {t("payment methods", {capfirst: true})} {t("actions", {capfirst: true})} diff --git a/frontend/src/pages/Products/index.tsx b/frontend/src/pages/Products/index.tsx index 42f4bbd..1b2d28d 100644 --- a/frontend/src/pages/Products/index.tsx +++ b/frontend/src/pages/Products/index.tsx @@ -8,6 +8,7 @@ import { ProductModal } from "@/components/Products/Modal"; import { useCallback, useMemo } from "react"; import { productCreateFromProductInputs, type Product, type ProductInputs } from "@/services/resources/products"; import ProductsFilters from "@/components/Products/Filter"; +import { notifications } from "@mantine/notifications"; export default function Products() { const [ searchParams, setSearchParams ] = useSearchParams(); @@ -48,6 +49,10 @@ export default function Products() { const handleCreateProduct = useCallback(async (product: ProductInputs) => { await createProductMutation.mutateAsync(productCreateFromProductInputs(product)); closeModal(); + notifications.show({ + title: t("success", {capfirst: true}), + message: t("successfully created product", {capfirst: true}), + }); }, [createProductMutation]); const handleEditProduct = useCallback(async (product: ProductInputs, id?: number) => { @@ -58,6 +63,10 @@ export default function Products() { product: productCreateFromProductInputs(product) }); closeModal(); + notifications.show({ + title: t("success", {capfirst: true}), + message: t("successfully edited product", {capfirst: true}), + }); }, []); const onFilterChange = useCallback((values: string[], filter: string) => { @@ -116,7 +125,6 @@ export default function Products() { {t("price", {capfirst: true})} {t("priceKg", {capfirst: true})} {t("quantity", {capfirst: true})} - {t("quantity unit", {capfirst: true})} {t("unit", {capfirst: true})} {t("actions", {capfirst: true})} diff --git a/frontend/src/pages/Shipments/index.tsx b/frontend/src/pages/Shipments/index.tsx index 5dbb1f4..f366a9e 100644 --- a/frontend/src/pages/Shipments/index.tsx +++ b/frontend/src/pages/Shipments/index.tsx @@ -8,6 +8,7 @@ import { useCallback, useMemo } from "react"; import { shipmentCreateFromShipmentInputs, type Shipment, type ShipmentInputs } from "@/services/resources/shipments"; import ShipmentModal from "@/components/Shipments/Modal"; import ShipmentsFilters from "@/components/Shipments/Filter"; +import { notifications } from "@mantine/notifications"; export default function Shipments() { const [ searchParams, setSearchParams ] = useSearchParams(); @@ -43,6 +44,10 @@ export default function Shipments() { const handleCreateShipment = useCallback(async (shipment: ShipmentInputs) => { await createShipmentMutation.mutateAsync(shipmentCreateFromShipmentInputs(shipment)); closeModal(); + notifications.show({ + title: t("success", {capfirst: true}), + message: t("successfully created shipment", {capfirst: true}), + }); }, [createShipmentMutation]); const handleEditShipment = useCallback(async (shipment: ShipmentInputs, id?: number) => { @@ -53,6 +58,10 @@ export default function Shipments() { shipment: shipmentCreateFromShipmentInputs(shipment) }); closeModal(); + notifications.show({ + title: t("success", {capfirst: true}), + message: t("successfully edited shipment", {capfirst: true}), + }); }, []); const onFilterChange = useCallback((values: string[], filter: string) => { diff --git a/frontend/src/pages/Users/index.tsx b/frontend/src/pages/Users/index.tsx index 2d3cf92..23858a6 100644 --- a/frontend/src/pages/Users/index.tsx +++ b/frontend/src/pages/Users/index.tsx @@ -42,18 +42,18 @@ export default function Users() { const editUserMutation = editUser(); const handleCreateUser = useCallback(async (user: UserInputs) => { - await createUserMutation.mutateAsync(user); - closeModal(); + await createUserMutation.mutateAsync(user); + closeModal(); }, [createUserMutation]); const handleEditUser = useCallback(async (user: UserInputs, id?: number) => { if (!id) return; - await editUserMutation.mutateAsync({ - id: id, - user: user - }); - closeModal(); + await editUserMutation.mutateAsync({ + id: id, + user: user + }); + closeModal(); }, []); const onFilterChange = useCallback((values: string[], filter: string) => { diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 4777836..08d075a 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -8,17 +8,17 @@ import { Forms } from "@/pages/Forms"; import Dashboard from "@/pages/Dashboard"; import Productors from "@/pages/Productors"; import Products from "@/pages/Products"; -import Templates from "@/pages/Templates"; import Users from "@/pages/Users"; import Shipments from "./pages/Shipments"; import { Contract } from "./pages/Contract"; +import { NotFound } from "./pages/NotFound"; // import { CreateForms } from "@/pages/Forms/CreateForm"; export const router = createBrowserRouter([ { path: "/", Component: Root, - // errorElement: , + errorElement: , children: [ { index: true, Component: Home }, { path: "/forms", Component: Forms }, @@ -31,7 +31,7 @@ export const router = createBrowserRouter([ { path: "products", Component: Products }, { path: "products/create", Component: Products }, { path: "products/:id/edit", Component: Products }, - { path: "templates", Component: Templates }, + // { path: "templates", Component: Templates }, { path: "users", Component: Users }, { path: "users/create", Component: Users }, { path: "users/:id/edit", Component: Users }, diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 6a1fb34..11b0305 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -5,6 +5,9 @@ import type { Shipment, ShipmentCreate, ShipmentEditPayload } from "@/services/r import type { Productor, ProductorCreate, ProductorEditPayload } from "@/services/resources/productors"; import type { User, UserCreate, UserEditPayload } from "@/services/resources/users"; import type { Product, ProductCreate, ProductEditPayload } from "./resources/products"; +import type { ContractCreate } from "./resources/contracts"; +import { notifications } from "@mantine/notifications"; +import { t } from "@/config/i18n"; export function getShipments(filters?: URLSearchParams): UseQueryResult { const queryString = filters?.toString() @@ -43,7 +46,18 @@ export function createShipment() { }).then((res) => res.json()); }, onSuccess: async () => { + notifications.show({ + title: t("success", {capfirst: true}), + message: t("successfully created shipment", {capfirst: true}), + }); await queryClient.invalidateQueries({ queryKey: ['shipments'] }) + }, + onError: (error: any) => { + notifications.show({ + title: t("error", {capfirst: true}), + message: error?.message || t(`error editing shipment`, {capfirst: true}), + color: "red" + }); } }) } @@ -62,7 +76,18 @@ export function editShipment() { }).then((res) => res.json()); }, onSuccess: async () => { + notifications.show({ + title: t("success", {capfirst: true}), + message: t("successfully edited shipment", {capfirst: true}), + }); await queryClient.invalidateQueries({ queryKey: ['shipments'] }) + }, + onError: (error: any) => { + notifications.show({ + title: t("error", {capfirst: true}), + message: error?.message || t(`error editing shipment`, {capfirst: true}), + color: "red" + }); } }) } @@ -79,7 +104,18 @@ export function deleteShipment() { }).then((res) => res.json()); }, onSuccess: async () => { + notifications.show({ + title: t("success", {capfirst: true}), + message: t("successfully deleted shipment", {capfirst: true}), + }); await queryClient.invalidateQueries({ queryKey: ['shipments'] }) + }, + onError: (error: any) => { + notifications.show({ + title: t("error", {capfirst: true}), + message: error?.message || t(`error deleting shipment`, {capfirst: true}), + color: "red" + }); } }); } @@ -121,7 +157,18 @@ export function createProductor() { }).then((res) => res.json()); }, onSuccess: async () => { + notifications.show({ + title: t("success", {capfirst: true}), + message: t("successfully created productor", {capfirst: true}), + }); await queryClient.invalidateQueries({ queryKey: ['productors'] }) + }, + onError: (error: any) => { + notifications.show({ + title: t("error", {capfirst: true}), + message: error?.message || t(`error editing productor`, {capfirst: true}), + color: "red" + }); } }) } @@ -140,7 +187,18 @@ export function editProductor() { }).then((res) => res.json()); }, onSuccess: async () => { + notifications.show({ + title: t("success", {capfirst: true}), + message: t("successfully edited productor", {capfirst: true}), + }); await queryClient.invalidateQueries({ queryKey: ['productors'] }) + }, + onError: (error: any) => { + notifications.show({ + title: t("error", {capfirst: true}), + message: error?.message || t(`error editing productor`, {capfirst: true}), + color: "red" + }); } }) } @@ -157,7 +215,18 @@ export function deleteProductor() { }).then((res) => res.json()); }, onSuccess: async () => { + notifications.show({ + title: t("success", {capfirst: true}), + message: t("successfully deleted productor", {capfirst: true}), + }); await queryClient.invalidateQueries({ queryKey: ['productors'] }) + }, + onError: (error: any) => { + notifications.show({ + title: t("error", {capfirst: true}), + message: error?.message || t(`error deleting productor`, {capfirst: true}), + color: "red" + }); } }); } @@ -216,7 +285,18 @@ export function deleteForm() { }).then((res) => res.json()); }, onSuccess: async () => { + notifications.show({ + title: t("success", {capfirst: true}), + message: t("successfully deleted form", {capfirst: true}), + }); await queryClient.invalidateQueries({ queryKey: ['forms'] }) + }, + onError: (error: any) => { + notifications.show({ + title: t("error", {capfirst: true}), + message: error?.message || t(`error deleting form`, {capfirst: true}), + color: "red" + }); } }); } @@ -235,7 +315,18 @@ export function editForm() { }).then((res) => res.json()); }, onSuccess: async () => { + notifications.show({ + title: t("success", {capfirst: true}), + message: t("successfully edited form", {capfirst: true}), + }); await queryClient.invalidateQueries({ queryKey: ['forms'] }) + }, + onError: (error: any) => { + notifications.show({ + title: t("error", {capfirst: true}), + message: error?.message || t(`error editing form`, {capfirst: true}), + color: "red" + }); } }); } @@ -277,7 +368,18 @@ export function createProduct() { }).then((res) => res.json()); }, onSuccess: async () => { + notifications.show({ + title: t("success", {capfirst: true}), + message: t("successfully created product", {capfirst: true}), + }); await queryClient.invalidateQueries({ queryKey: ['products'] }) + }, + onError: (error: any) => { + notifications.show({ + title: t("error", {capfirst: true}), + message: error?.message || t(`error editing product`, {capfirst: true}), + color: "red" + }); } }); } @@ -294,7 +396,18 @@ export function deleteProduct() { }).then((res) => res.json()); }, onSuccess: async () => { + notifications.show({ + title: t("success", {capfirst: true}), + message: t("successfully deleted product", {capfirst: true}), + }); await queryClient.invalidateQueries({ queryKey: ['products'] }) + }, + onError: (error: any) => { + notifications.show({ + title: t("error", {capfirst: true}), + message: error?.message || t(`error deleting product`, {capfirst: true}), + color: "red" + }); } }); } @@ -313,7 +426,18 @@ export function editProduct() { }).then((res) => res.json()); }, onSuccess: async () => { + notifications.show({ + title: t("success", {capfirst: true}), + message: t("successfully edited product", {capfirst: true}), + }); await queryClient.invalidateQueries({ queryKey: ['products'] }) + }, + onError: (error: any) => { + notifications.show({ + title: t("error", {capfirst: true}), + message: error?.message || t(`error editing product`, {capfirst: true}), + color: "red" + }); } }); } @@ -355,7 +479,18 @@ export function createUser() { }).then((res) => res.json()); }, onSuccess: async () => { + notifications.show({ + title: t("success", {capfirst: true}), + message: t("successfully created user", {capfirst: true}), + }); await queryClient.invalidateQueries({ queryKey: ['users'] }) + }, + onError: (error: any) => { + notifications.show({ + title: t("error", {capfirst: true}), + message: error?.message || t(`error editing user`, {capfirst: true}), + color: "red" + }); } }); } @@ -372,7 +507,18 @@ export function deleteUser() { }).then((res) => res.json()); }, onSuccess: async () => { + notifications.show({ + title: t("success", {capfirst: true}), + message: t("successfully deleted user", {capfirst: true}), + }); await queryClient.invalidateQueries({ queryKey: ['users'] }) + }, + onError: (error: any) => { + notifications.show({ + title: t("error", {capfirst: true}), + message: error?.message || t(`error deleting user`, {capfirst: true}), + color: "red" + }); } }); } @@ -391,7 +537,44 @@ export function editUser() { }).then((res) => res.json()); }, onSuccess: async () => { + notifications.show({ + title: t("success", {capfirst: true}), + message: t("successfully edited user", {capfirst: true}), + }); await queryClient.invalidateQueries({ queryKey: ['users'] }) + }, + onError: (error: any) => { + notifications.show({ + title: t("error", {capfirst: true}), + message: error?.message || t(`error editing user`, {capfirst: true}), + color: "red" + }); } }); } + + +export function createContract() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (newContract: ContractCreate) => { + return fetch(`${Config.backend_uri}/contracts`, { + method: 'POST', + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(newContract), + }).then(async (res) => await res.blob()); + }, + onSuccess: async (pdfBlob) => { + const url = URL.createObjectURL(pdfBlob); + const link = document.createElement("a"); + link.href = url; + link.download = `contract.pdf`; + link.click(); + URL.revokeObjectURL(url); + await queryClient.invalidateQueries({ queryKey: ["contracts"] }); + } + }); +} \ No newline at end of file diff --git a/frontend/src/services/resources/contracts.ts b/frontend/src/services/resources/contracts.ts new file mode 100644 index 0000000..fc8e08f --- /dev/null +++ b/frontend/src/services/resources/contracts.ts @@ -0,0 +1,4 @@ +export type ContractCreate = { + form_id: number; + contract: Record; +} \ No newline at end of file diff --git a/frontend/src/services/resources/forms.ts b/frontend/src/services/resources/forms.ts index 08bf4dc..1e133a8 100644 --- a/frontend/src/services/resources/forms.ts +++ b/frontend/src/services/resources/forms.ts @@ -1,5 +1,5 @@ import type { Productor } from "@/services/resources/productors"; -import type { Shipment, ShipmentInputs } from "@/services/resources/shipments"; +import type { Shipment } from "@/services/resources/shipments"; import type { User } from "@/services/resources/users"; export type Form = { @@ -11,6 +11,7 @@ export type Form = { productor: Productor; referer: User; shipments: Shipment[]; + minimum_shipment_value: number | null; } export type FormCreate = { @@ -20,6 +21,7 @@ export type FormCreate = { end: string; productor_id: number; referer_id: number; + minimum_shipment_value: number | null; } export type FormEdit = { @@ -29,6 +31,7 @@ export type FormEdit = { end?: string | null; productor_id?: number | null; referer_id?: number | null; + minimum_shipment_value: number | null; } export type FormEditPayload = { @@ -43,4 +46,5 @@ export type FormInputs = { end: string | null; productor_id: string; referer_id: string; + minimum_shipment_value: number | string | null; } \ No newline at end of file diff --git a/frontend/src/services/resources/productors.ts b/frontend/src/services/resources/productors.ts index 9108760..b04252f 100644 --- a/frontend/src/services/resources/productors.ts +++ b/frontend/src/services/resources/productors.ts @@ -1,10 +1,21 @@ +import { t } from "@/config/i18n"; import type { Product } from "./products"; +export const PaymentMethods = [ + {value: "cheque", label: t("cheque", {capfirst: true})}, + {value: "transfer", label: t("transfer", {capfirst: true})}, +] + +export type PaymentMethod = { + name: string; + details: string; +} + export type Productor = { id: number; name: string; address: string; - payment: string; + payment_methods: PaymentMethod[]; type: string; products: Product[] } @@ -12,22 +23,22 @@ export type Productor = { export type ProductorCreate = { name: string; address: string; - payment: string; + payment_methods: PaymentMethod[]; type: string; } export type ProductorEdit = { name: string | null; address: string | null; - payment: string | null; + payment_methods: PaymentMethod[]; type: string | null; } export type ProductorInputs = { name: string; address: string; - payment: string; type: string; + payment_methods: PaymentMethod[]; } export type ProductorEditPayload = { diff --git a/frontend/src/services/resources/products.ts b/frontend/src/services/resources/products.ts index 7fed503..aee8c65 100644 --- a/frontend/src/services/resources/products.ts +++ b/frontend/src/services/resources/products.ts @@ -61,9 +61,9 @@ export type ProductInputs = { productor_id: string | null; name: string; unit: string | null; - price: number | null; - price_kg: number | null; - quantity: number | null; + price: number | string | null; + price_kg: number | string | null; + quantity: number | string | null; quantity_unit: string | null; type: string | null; } @@ -91,9 +91,9 @@ export function productCreateFromProductInputs(productInput: ProductInputs): Pro productor_id: Number(productInput.productor_id)!, name: productInput.name, unit: productInput.unit!, - price: productInput.price!, - price_kg: productInput.price_kg, - quantity: productInput.quantity, + price: productInput.price === "" || !productInput.price ? null : Number(productInput.price), + price_kg: productInput.price_kg === "" || !productInput.price_kg ? null : Number(productInput.price_kg), + quantity: productInput.quantity === "" || !productInput.quantity ? null : Number(productInput.quantity), quantity_unit: productInput.quantity_unit, type: productInput.type!, }