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é +
+ +
+
+
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 0000000..554d27a Binary files /dev/null and b/backend/test.pdf differ diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 72eefe5..2a05911 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -39,7 +39,9 @@ "filter by name": "filter by name", "filter by type": "filter by type", "address": "address", - "payment": "payment", + "payment methods": "payment methods", + "cheque": "cheque", + "transfer": "transfer", "type": "type", "create productor": "create productor", "productor name": "productor name", @@ -61,6 +63,13 @@ "there is": "there is", "for this contract": "for this contact.", "shipment date": "shipment date", + "shipment products": "shipment products", + "minimum shipment value": "minimum shipment value", + "shipment form": "shipment form", + "shipment products is necessary only for planned products (if all products are recurrent leave empty)": "shipment products is necessary only for planned products (if all products are recurrent leave empty)", + "recurrent product is for all shipments, planned product is for a specific shipment (see shipment form)": "recurrent product is for all shipments, planned product is for a specific shipment (see shipment form).", + "some contracts require a minimum value per shipment, ignore this field if it's not the case": "some contracts require a minimum value per shipment, ignore this field if it's not the case.", + "minimum price for this shipment should be at least": "minimum price for this shipment should be at least", "remove shipment": "remove shipment", "productors": "productors", "products": "products", @@ -83,5 +92,34 @@ "a lastname": "a lastname", "a email": "a email", "submit contract": "submit contract", + "success": "success", + "successfully edited user": "successfully edited user", + "successfully edited form": "successfully edited form", + "successfully edited product": "successfully edited product", + "successfully edited productor": "successfully edited productor", + "successfully edited shipment": "successfully edited shipment", + "successfully created user": "successfully created user", + "successfully created form": "successfully created form", + "successfully created product": "successfully created product", + "successfully created productor": "successfully created productor", + "successfully created shipment": "successfully created shipment", + "error": "error", + "error editing user": "error editing user", + "error editing form": "error editing form", + "error editing product": "error editing product", + "error editing productor": "error editing productor", + "error editing shipment": "error editing shipment", + "error creating user": "error creating user", + "error creating form": "error creating form", + "error creating product": "error creating product", + "error creating productor": "error creating productor", + "error creating shipment": "error creating shipment", + "error deleting user": "error deleting user", + "error deleting form": "error deleting form", + "error deleting product": "error deleting product", + "error deleting productor": "error deleting productor", + "error deleting shipment": "error deleting shipment", + "there is no contract for now": "there is no contract for now.", + "the product unit will be assigned to the quantity requested in the form": "the product unit will be assigned to the quantity requested in the form", "all theses informations are for contract generation, no informations is stored outside of contracts": "all theses informations are for contract generation, no informations is stored outside of contracts." } \ No newline at end of file diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 089d81c..f105a60 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -2,17 +2,16 @@ "product name": "nom du produit", "product price": "prix du produit", "product quantity": "quantité du produit", + "product quantity unit": "Unité de quantité du produit", "product type": "type de produit", "planned": "planifié", "planned products": "Produits planifiés par livraison", "select products per shipment": "Selectionnez les produits pour chaque livraison.", - "recurrent": "récurrent", + "recurrent": "récurent", "recurrent products": "Produits récurents", - "your selection in this category will apply for all shipments": "votre selection sera appliquée pour chaque livraisons (Exemple: 6 livraisons, le produits sera comptés 6 fois : une fois par livraison).", + "your selection in this category will apply for all shipments": "votre selection sera appliquée pour chaque livraisons (Exemple: Pour 6 livraisons, le produits sera comptés 6 fois : une fois par livraison).", "product price kg": "prix du produit au Kilo", "product unit": "unité de vente du produit", - "grams": "grammes", - "kilo": "kilo", "piece": "pièce", "in": "en", "enter quantity": "entrez la quantitée", @@ -33,20 +32,24 @@ "number of shipment": "nombre de livraisons", "cancel": "annuler", "create form": "créer un formulare de contrat", - "edit productor": "modifier le producteur·trice", - "remove productor": "supprimer le producteur·trice", + "create productor": "créer le/la producteur·trice", + "edit productor": "modifier le/la producteur·trice", + "remove productor": "supprimer le/la producteur·trice", "home": "accueil", "dashboard": "tableau de bord", "filter by name": "filtrer par nom", "filter by type": "filtrer par type", "address": "adresse", - "payment": "ordre du chèque", + "payment methods": "méthodes de paiement", "type": "type", - "create productor": "créer le producteur·trice", + "cheque": "chèque", + "transfer": "virement", + "order name": "Ordre du chèque", + "IBAN": "IBAN", "productor name": "nom du producteur·trice", "productor type": "type du producteur·trice", "productor address": "adresse du producteur·trice", - "productor payment": "ordre du chèque du producteur·trice", + "productor payment": "méthodes de paiement du producteur·trice", "priceKg": "prix au kilo", "quantity": "quantité", "quantity unit": "unité de quantité", @@ -60,6 +63,13 @@ "shipment date": "date de la livraison", "shipments": "livraisons", "shipment": "livraison", + "shipment products": "produits pour la livraison", + "shipment form": "formulaire lié a la livraison", + "minimum shipment value": "valeur minimum d'une livraison (€)", + "shipment products is necessary only for planned products (if all products are recurrent leave empty)": "il est nécéssaire de configurer les produits pour la livraison uniquement si il y a des produits planifiés (laisser vide si tous les produits sont récurents).", + "recurrent product is for all shipments, planned product is for a specific shipment (see shipment form)": "les produits récurents sont pour toutes les livraisons, les produits planifiés sont pour une livraison particulière (voir formulaire de création de livraison).", + "some contracts require a minimum value per shipment, ignore this field if it's not the case": "certains contrats nécessitent une valeur minimum par livraison. Ce champ peut être ignoré s’il ne s’applique pas à votre contrat.", + "minimum price for this shipment should be at least": "le prix minimum d'une livraison doit être au moins de", "there is": "il y a", "for this contract": "pour ce contrat.", "remove shipment": "supprimer la livraison", @@ -79,11 +89,50 @@ "a start date": "une date de début", "a end date": "une date de fin", "a productor": "un(e) producteur·trice", - "a referer": "un référent·e", + "a referer": "un(e) référent·e", "a phone": "un numéro de téléphone", "a fistname": "un prénom", "a lastname": "un nom", "a email": "une adresse email", "submit contract": "Envoyer le contrat", + "mililiter": "mililitres (ml)", + "grams": "grammes (g)", + "kilo": "kilogrammes (kg)", + "liter": "litres (L)", + "success": "succès", + "successfully edited user": "utilisateur·trice correctement édité", + "successfully edited form": "formulaire correctement édité", + "successfully edited product": "produit correctement édité", + "successfully edited productor": "producteur·trice correctement édité(e)", + "successfully edited shipment": "livaison correctement éditée", + "successfully created user": "utilisateur·trice correctement créé(e)", + "successfully created form": "formulaire correctement créé", + "successfully created product": "produit correctement créé", + "successfully created productor": "producteur·trice correctement créé(e)", + "successfully created shipment": "livaison correctement créée", + "successfully deleted user": "utilisateur·trice correctement supprimé", + "successfully deleted form": "formulaire correctement supprimé", + "successfully deleted product": "produit correctement supprimé", + "successfully deleted productor": "producteur·trice correctement supprimé(e)", + "successfully deleted shipment": "livaison correctement supprimée", + "error": "erreur", + "error editing user": "erreur pendant l'édition de l'utilisateur·trice", + "error editing form": "erreur pendant l'édition du formulaire", + "error editing product": "erreur pendant l'édition du produit", + "error editing productor": "erreur pendant l'édition du producteur·trice", + "error editing shipment": "erreur pendant l'édition de la livraison", + "error creating user": "erreur pendant la création de l'utilisateur·trice", + "error creating form": "erreur pendant la création du formulaire", + "error creating product": "erreur pendant la création du produit", + "error creating productor": "erreur pendant la création du producteur·trice", + "error creating shipment": "erreur pendant la création de la livraison", + "error deleting user": "erreur pendant la suppression de l'utilisateur·trice", + "error deleting form": "erreur pendant la suppression du formulaire", + "error deleting product": "erreur pendant la suppression du produit", + "error deleting productor": "erreur pendant la suppression du producteur·trice", + "error deleting shipment": "erreur pendant la suppression de la livraison", + "there is no contract for now": "Il n'y a pas de contrats pour le moment.", + + "the product unit will be assigned to the quantity requested in the form": "L'unité de vente du produit définit l'unité associée a la quantité demandée dans le formulaire des amapiens.", "all theses informations are for contract generation, no informations is stored outside of contracts": "ces informations sont nécéssaires pour la génération de contrat, aucune information personnelle n'est gardée ailleurs que dans les contrats générés." } \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 05d254c..1c9e40a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,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", @@ -1147,6 +1148,31 @@ "react": "^18.x || ^19.x" } }, + "node_modules/@mantine/notifications": { + "version": "8.3.14", + "resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-8.3.14.tgz", + "integrity": "sha512-+ia97wrcU9Zfv+jXYvgr2GdISqKTHbQE9nnEIZvGUBPAqKr9b2JAsaXQS/RsAdoXUI+kKDEtH2fyVYS7zrSi/Q==", + "license": "MIT", + "dependencies": { + "@mantine/store": "8.3.14", + "react-transition-group": "4.4.5" + }, + "peerDependencies": { + "@mantine/core": "8.3.14", + "@mantine/hooks": "8.3.14", + "react": "^18.x || ^19.x", + "react-dom": "^18.x || ^19.x" + } + }, + "node_modules/@mantine/store": { + "version": "8.3.14", + "resolved": "https://registry.npmjs.org/@mantine/store/-/store-8.3.14.tgz", + "integrity": "sha512-bgW+fYHDOp7Pk4+lcEm3ZF7dD/sIMKHyR985cOqSHAYJPRcVFb+zcEK/SWoFZqlyA4qh08CNrASOaod8N0XKfA==", + "license": "MIT", + "peerDependencies": { + "react": "^18.x || ^19.x" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -2226,7 +2252,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, "node_modules/dayjs": { @@ -2266,6 +2291,16 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.286", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", @@ -2818,7 +2853,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -2937,6 +2971,18 @@ "dev": true, "license": "MIT" }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3009,6 +3055,15 @@ "dev": true, "license": "MIT" }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.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!, }