diff --git a/.gitignore b/.gitignore index ad4a1f1..963a1c0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # Created by https://www.toptal.com/developers/gitignore/api/python # Edit at https://www.toptal.com/developers/gitignore?templates=python - +.vscode ### Python ### # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..e334eb7 --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# TODO + +## backend\src\contracts\contracts.py + +- Send contract to referer +- Extract recap +- Extract all contracts +- store total price +- store pdf file + +## Wording + +- planned -> occasionnal (planifié -> occasionnel) +- all translations + +## Tutorial for referers + +### Glossary + +Occasional +Recurrent + +## Footer + +### Legal + +### About + +### Contact + +## Migrations + +- use alembic for migration management + +## Update contract after register + +## Filter contracts in home view + +## Only show current season (if multiple form, only show the one with latest start date) diff --git a/backend/src/contracts/contracts.py b/backend/src/contracts/contracts.py index 6e195fa..312e1c0 100644 --- a/backend/src/contracts/contracts.py +++ b/backend/src/contracts/contracts.py @@ -1,65 +1,16 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, Query 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 src.contracts.service as service + 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: @@ -68,41 +19,106 @@ def compute_recurrent_prices(products_quantities: list[dict], nb_shipment: int): result += compute_product_price(product, quantity, nb_shipment) return result -def compute_planned_prices(planned: list[dict]): +def compute_planned_prices(planneds: list[dict]): result = 0 - for plan in planned: - result += plan['price'] + for planned in planneds: + result += planned['price'] + return result + +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 find_dict_in_list(lst, key, value): + for i, dic in enumerate(lst): + if dic[key].id == value: + return i + return -1 + +def create_planned_dict(contract_products: list[models.ContractProduct]): + result = [] + for contract_product in contract_products: + existing_id = find_dict_in_list( + result, + 'shipment', + contract_product.shipment.id + ) + if existing_id < 0: + result.append({ + 'shipment': contract_product.shipment, + 'price': compute_product_price( + contract_product.product, + contract_product.quantity + ), + 'products': [{ + 'product': contract_product.product, + 'quantity': contract_product.quantity + }] + }) + else: + result[existing_id]['products'].append({ + 'product': contract_product.product, + 'quantity': contract_product.quantity + }) + result[existing_id]['price'] += compute_product_price( + contract_product.product, + contract_product.quantity + ) return result @router.post('/') async def create_contract( - contract: models.ContractBase, + contract: models.ContractCreate, 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 + new_contract = service.create_one(session, contract) + planned_contract_products = list(filter(lambda contract_product: contract_product.product.type == models.ProductType.PLANNED, new_contract.products)) + planneds = create_planned_dict(planned_contract_products) + recurrents = list(map(lambda x: {"product": x.product, "quantity": x.quantity}, filter(lambda contract_product: contract_product.product.type == models.ProductType.RECCURENT, new_contract.products))) + recurrent_price = compute_recurrent_prices(recurrents, len(new_contract.form.shipments)) + total_price = '{:10.2f}'.format(recurrent_price + compute_planned_prices(planneds)) + cheques = list(map(lambda x: {"name": x.name, "value": x.value}, new_contract.cheques)) # 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), + new_contract, + cheques, + planneds, + recurrents, + recurrent_price, total_price ) pdf_file = io.BytesIO(pdf_bytes) - contract_id = f'{contract.contract['firstname']}_{contract.contract['lastname']}_{form.productor.type}_{form.season}' + contract_id = f'{new_contract.firstname}_{new_contract.lastname}_{new_contract.form.productor.type}_{new_contract.form.season}' except: raise HTTPException(status_code=400, detail=PDFerrorOccured) return StreamingResponse( pdf_file, - media_type="application/pdf", + media_type='application/pdf', headers={ - "Content-Disposition": f"attachement; filename=contract_{contract_id}.pdf" + 'Content-Disposition': f'attachement; filename=contract_{contract_id}.pdf' } - ) \ No newline at end of file + ) + +@router.get('/', response_model=list[models.ContractPublic]) +def get_contracts( + forms: list[str] = Query([]), + session: Session = Depends(get_session) +): + return service.get_all(session, forms) + +@router.get('/{id}', response_model=models.ContractPublic) +def get_contract(id: int, session: Session = Depends(get_session)): + result = service.get_one(session, id) + if result is None: + raise HTTPException(status_code=404, detail=messages.notfound) + return result + +@router.delete('/{id}', response_model=models.ContractPublic) +def delete_contract(id: int, session: Session = Depends(get_session)): + result = service.delete_one(session, id) + if result is None: + raise HTTPException(status_code=404, detail=messages.notfound) + return result diff --git a/backend/src/contracts/generate_contract.py b/backend/src/contracts/generate_contract.py index 1fecdbc..f6858a6 100644 --- a/backend/src/contracts/generate_contract.py +++ b/backend/src/contracts/generate_contract.py @@ -5,37 +5,39 @@ import html from weasyprint import HTML def generate_html_contract( - form: models.Form, - contract_informations: dict, - planned: list[dict], - recurrent: list[dict], + contract: models.Contract, + cheques: list[dict], + planneds: list[dict], + reccurents: list[dict], recurrent_price: float, - total_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, + contract_name=contract.form.name, + contract_type=contract.form.productor.type, + contract_season=contract.form.season, + referer_name=contract.form.referer.name, + referer_email=contract.form.referer.email, + productor_name=contract.form.productor.name, + productor_address=contract.form.productor.address, + payment_methods_map={"cheque": "Ordre du chèque", "transfer": "virements"}, + productor_payment_methods=contract.form.productor.payment_methods, + member_name=f'{html.escape(contract.firstname)} {html.escape(contract.lastname)}', + member_email=html.escape(contract.email), + member_phone=html.escape(contract.phone), + contract_start_date=contract.form.start, + contract_end_date=contract.form.end, + planneds=planneds, + recurrents=reccurents, recurrent_price=recurrent_price, total_price=total_price, + contract_payment_method={"cheque": "chèque", "transfer": "virements"}[contract.payment_method], + cheques=cheques ) options = { 'page-size': 'Letter', diff --git a/backend/src/contracts/service.py b/backend/src/contracts/service.py new file mode 100644 index 0000000..51a07dc --- /dev/null +++ b/backend/src/contracts/service.py @@ -0,0 +1,65 @@ +from sqlmodel import Session, select +import src.models as models + +def get_all( + session: Session, + forms: list[str] +) -> list[models.ContractPublic]: + statement = select(models.Contract) + if len(forms) > 0: + statement = statement.join(models.Form).where(models.Form.name.in_(forms)) + return session.exec(statement.order_by(models.Contract.id)).all() + +def get_one(session: Session, contract_id: int) -> models.ContractPublic: + return session.get(models.Contract, contract_id) + +def create_one(session: Session, contract: models.ContractCreate) -> models.ContractPublic: + contract_create = contract.model_dump(exclude_unset=True, exclude=["products", "cheques"]) + new_contract = models.Contract(**contract_create) + + new_contract.cheques = [ + models.Cheque( + name=cheque.name, + value=cheque.value, + contract_id=new_contract.id + ) for cheque in contract.cheques + ] + + new_contract.products = [ + models.ContractProduct( + contract_id=new_contract.id, + product_id=contract_product.product_id, + shipment_id=contract_product.shipment_id, + quantity=contract_product.quantity + ) for contract_product in contract.products + ] + + session.add(new_contract) + session.commit() + session.refresh(new_contract) + return new_contract + +def update_one(session: Session, id: int, contract: models.ContractUpdate) -> models.ContractPublic: + statement = select(models.Contract).where(models.Contract.id == id) + result = session.exec(statement) + new_contract = result.first() + if not new_contract: + return None + contract_updates = contract.model_dump(exclude_unset=True) + for key, value in contract_updates.items(): + setattr(new_contract, key, value) + session.add(new_contract) + session.commit() + session.refresh(new_contract) + return new_contract + +def delete_one(session: Session, id: int) -> models.ContractPublic: + statement = select(models.Contract).where(models.Contract.id == id) + result = session.exec(statement) + contract = result.first() + if not contract: + return None + result = models.ContractPublic.model_validate(contract) + session.delete(contract) + session.commit() + return result diff --git a/backend/src/contracts/templates/layout.html b/backend/src/contracts/templates/layout.html index fa1e30f..c9e5858 100644 --- a/backend/src/contracts/templates/layout.html +++ b/backend/src/contracts/templates/layout.html @@ -1,106 +1,111 @@ - + - - {{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.

+

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 %} - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% for method in productor_payment_methods %} {% if method.details + != "" %} + + + + + {% endif %} {% 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}}
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. + 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}}. + 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 + 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. + 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é -
+
+
+
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é. -
  • +
  • + 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. + 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 %} -
+
+ {% if recurrents|length > 0 %} +

Produits récurents (pour chaques livraisons)

- - - - - - - - - - - {% for rec in recurrent %} - - - - - - - + + + + + + + + + + + {% for rec in recurrents %} + + + + + + + {% 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" }}
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}}€Total{{recurrent_price}}€
-
- {% endif %} - {% if planned|length > 0 %} -
+
+ {% endif %} {% if planneds|length > 0 %} +

Produits planifiés (par livraison)

- {% for plan in planned %} + {% for plan in planneds %}
{{plan.shipment.name}} {{plan.shipment.date}}
- - - - - - - - - - - {% for product in plan.products %} - - - - - - - - {% endfor%} - - - - - + + + + + + + + + + + {% 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}}€
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 %} -
+
+ {% endif %} +
Prix Total :
{{total_price}}€
-
-
+
+

Paiement par {{contract_payment_method}}

+ {% if contract_payment_method == "chèque" %} +
- - - - - - - - - - - - + + + {% for cheque in cheques %} + + {% endfor %} + + + + + {% for cheque in cheques %} + + {% endfor %} + +
Signature producteur-triceSignature adhérent-e
Cheque n°{{cheque.name}}
{{cheque.value}}€
+
+ {% endif %} +
+ + + + + + + + + + + + + +
Signature producteur-triceSignature adhérent-e
+
-
- - \ No newline at end of file + + diff --git a/backend/src/models.py b/backend/src/models.py index 9d2c0ca..616ac45 100644 --- a/backend/src/models.py +++ b/backend/src/models.py @@ -140,6 +140,10 @@ class Form(FormBase, table=True): "order_by": "Shipment.name" }, ) + contracts: list["Contract"] = Relationship( + back_populates="form", + cascade_delete=True + ) class FormUpdate(SQLModel): name: str | None @@ -168,20 +172,92 @@ class TemplateUpdate(SQLModel): class TemplateCreate(TemplateBase): pass +class ChequeBase(SQLModel): + name: str + value: str + +class Cheque(ChequeBase, table=True): + id: int | None = Field(default=None, primary_key=True) + contract_id: int = Field(foreign_key="contract.id", ondelete="CASCADE") + contract: Optional["Contract"] = Relationship( + back_populates="cheques", + ) + class ContractBase(SQLModel): + firstname: str + lastname: str + email: str + phone: str + payment_method: str + cheque_quantity: int + +class Contract(ContractBase, table=True): + id: int | None = Field(default=None, primary_key=True) + + form_id: int = Field( + foreign_key="form.id", + nullable=False, + ondelete="CASCADE" + ) + products: list["ContractProduct"] = Relationship( + back_populates="contract", + cascade_delete=True + ) + form: Optional[Form] = Relationship(back_populates="contracts") + cheques: list[Cheque] = Relationship( + back_populates="contract", + cascade_delete=True + ) + +class ContractCreate(ContractBase): + products: list["ContractProductCreate"] = [] + cheques: list["Cheque"] = [] 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 ContractUpdate(SQLModel): pass -class ContractCreate(ContractBase): +class ContractPublic(ContractBase): + id: int + products: list["ContractProduct"] = [] + form: Form + +class ContractProductBase(SQLModel): + product_id: int = Field( + foreign_key="product.id", + nullable=False, + ondelete="CASCADE" + ) + shipment_id: int | None = Field( + default=None, + foreign_key="shipment.id", + nullable=True, + ondelete="CASCADE" + ) + quantity: float + +class ContractProduct(ContractProductBase, table=True): + id: int | None = Field(default=None, primary_key=True) + contract_id: int = Field( + foreign_key="contract.id", + nullable=False, + ondelete="CASCADE" + ) + contract: Optional["Contract"] = Relationship(back_populates="products") + product: Optional["Product"] = Relationship() + shipment: Optional["Shipment"] = Relationship() + +class ContractProductPublic(ContractProductBase): + id: int + quantity: float + contract: Contract + product: Product + shipment: Optional["Shipment"] + +class ContractProductCreate(ContractProductBase): + pass + +class ContractProductUpdate(ContractProductBase): pass class ShipmentBase(SQLModel): diff --git a/frontend/.prettierignore b/frontend/.prettierignore index b37948f..6af7a58 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -5,4 +5,5 @@ coverage .next out public -*.lock \ No newline at end of file +*.lock +*.html \ No newline at end of file diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 0448efe..9fb56f9 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -1,19 +1,19 @@ { - "product name": "product name", - "product price": "product price", - "product quantity": "product quantity", - "product quantity unit": "product quantity unit", - "product type": "product type", + "product name": "name of the product", + "product price": "price of the product", + "product quantity": "quantity of the product", + "product quantity unit": "unit of the product quantity", + "product type": "type of the product", "planned": "planned", "planned products": "planned products", - "select products per shipment": "select products per shipment.", + "select products per shipment": "select products for each shipment", "recurrent products": "recurrent products", - "your selection in this category will apply for all shipments": "your selection in this category will apply for all shipments.", + "your selection in this category will apply for all shipments": "selection in this category applies to all shipments", "recurrent": "recurrent", - "product price kg": "product price kg", + "product price kg": "price per kilogram", "product unit": "product unit", "grams": "grams", - "kilo": "kilo", + "kilo": "kilogram", "piece": "piece", "filter by season": "filter by season", "filter by form": "filter by form", @@ -25,13 +25,13 @@ "productor": "productor", "referer": "referer", "edit form": "edit form", - "form name": "form name", + "form name": "name of the form", "contract season": "contract season", - "contract season recommandation": "recommandation : - (example: Winter-2025)", + "contract season recommandation": "recommendation: - (example: Winter-2025)", "start date": "start date", "end date": "end date", "nothing found": "nothing found", - "number of shipment": "number of shipment", + "number of shipment": "number of shipments", "cancel": "cancel", "create form": "create form", "edit productor": "edit productor", @@ -46,32 +46,32 @@ "transfer": "transfer", "type": "type", "create productor": "create productor", - "productor name": "productor name", - "productor type": "productor type", - "productor address": "productor address", - "productor payment": "productor payment", - "priceKg": "priceKg", + "productor name": "name of the productor", + "productor type": "type of the productor", + "productor address": "address of the productor", + "productor payment": "payment method of the productor", + "priceKg": "price per kilogram", "quantity": "quantity", - "quantity unit": "quantity unit", + "quantity unit": "unit of quantity", "unit": "sell unit", "price": "price", "create product": "create product", - "informations": "informations", + "informations": "information", "remove product": "remove product", "edit product": "edit product", "shipment name": "shipment name", "shipments": "shipments", "shipment": "shipment", "there is": "there is", - "for this contract": "for this contact.", + "for this contract": "for this contract", "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", + "shipment products is necessary only for planned products (if all products are recurrent leave empty)": "shipment products required only for planned products (leave empty if all products are recurrent)", + "recurrent product is for all shipments, planned product is for a specific shipment (see shipment form)": "recurrent products apply to all shipments, planned products apply to a specific shipment (see shipment form)", + "some contracts require a minimum value per shipment, ignore this field if it's not the case": "ignore this field if minimum shipment value is not required", + "minimum price for this shipment should be at least": "minimum price for this shipment", "remove shipment": "remove shipment", "productors": "productors", "products": "products", @@ -83,6 +83,8 @@ "actions": "actions", "all productors": "all productors", "all products": "all products", + "all shipments": "all shipments", + "all referers": "all referers", "a name": "a name", "a season": "a season", "a start date": "a start date", @@ -90,21 +92,22 @@ "a productor": "a productor", "a referer": "a referer", "a phone": "a phone", - "a fistname": "a fistname", + "a fistname": "a firstname", "a lastname": "a lastname", - "a email": "a email", + "a email": "an email", + "a payment method": "a payment method", "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", + "successfully edited user": "user edited successfully", + "successfully edited form": "form edited successfully", + "successfully edited product": "product edited successfully", + "successfully edited productor": "productor edited successfully", + "successfully edited shipment": "shipment edited successfully", + "successfully created user": "user created successfully", + "successfully created form": "form created successfully", + "successfully created product": "product created successfully", + "successfully created productor": "productor created successfully", + "successfully created shipment": "shipment created successfully", "error": "error", "error editing user": "error editing user", "error editing form": "error editing form", @@ -121,7 +124,17 @@ "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": "all theses informations are for contract generation." + "there is no contract for now": "no contracts available currently", + "for transfer method contact your referer or productor": "for transfer method, contact your referer or productor", + "cheque quantity": "number of cheques", + "enter cheque quantity": "enter number of cheques", + "cheque id": "cheque identifier", + "cheque value": "cheque value", + "enter cheque value": "enter cheque value", + "payment method": "payment method", + "enter payment method": "enter payment method", + "choose payment method": "choose a payment method (no actual payments, information only)", + "number of cheques between 1 and 3 cheques also enter your cheques identifiers, value is calculated automatically": "enter 1 to 3 cheques and their identifiers; values are calculated automatically", + "the product unit will be assigned to the quantity requested in the form": "product unit matches quantity requested in the form", + "all theses informations are for contract generation": "all this information is used for contract generation" } diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 0a04f6b..326dc99 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -6,15 +6,15 @@ "product type": "type de produit", "planned": "planifié", "planned products": "Produits planifiés par livraison", - "select products per shipment": "Selectionnez les produits pour chaque livraison.", + "select products per shipment": "Sélectionnez les produits pour chaque livraison.", "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: Pour 6 livraisons, le produits sera comptés 6 fois : une fois par livraison).", + "recurrent products": "Produits récurrents", + "your selection in this category will apply for all shipments": "votre sélection sera appliquée pour chaque livraisons (Exemple: Pour 6 livraisons, le produits sera comptés 6 fois : une fois par livraison).", "product price kg": "prix du produit au Kilo", "product unit": "unité de vente du produit", "piece": "pièce", "in": "en", - "enter quantity": "entrez la quantitée", + "enter quantity": "entrez la quantité", "filter by season": "filtrer par saisons", "filter by form": "filtrer par formulaire", "filter by productor": "filtrer par producteur·trice", @@ -33,7 +33,7 @@ "nothing found": "rien à afficher", "number of shipment": "nombre de livraisons", "cancel": "annuler", - "create form": "créer un formulare de contrat", + "create form": "créer un formulaire de contrat", "create productor": "créer le/la producteur·trice", "edit productor": "modifier le/la producteur·trice", "remove productor": "supprimer le/la producteur·trice", @@ -68,8 +68,8 @@ "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).", + "shipment products is necessary only for planned products (if all products are recurrent leave empty)": "il est nécessaire 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écurrents 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", @@ -85,6 +85,8 @@ "actions": "actions", "all productors": "tous les producteur·trices", "all products": "tous les produits", + "all shipments": "toutes les livraisons", + "all referers": "tous les référent·es", "is required": "est requis·e", "a name": "un nom", "a season": "une saison", @@ -96,6 +98,7 @@ "a fistname": "un prénom", "a lastname": "un nom", "a email": "une adresse email", + "a payment method": "une méthode de paiement", "submit contract": "Envoyer le contrat", "mililiter": "mililitres (ml)", "grams": "grammes (g)", @@ -106,17 +109,17 @@ "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 edited shipment": "livraison 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 created shipment": "livraison 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", + "successfully deleted shipment": "livraison 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", @@ -134,6 +137,16 @@ "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": "ces informations sont nécéssaires pour la génération de contrat." + "for transfer method contact your referer or productor": "pour mettre en place le virement automatique, contactez votre référent ou le producteur.", + "cheque quantity": "quantité de chèques (pour le paiement en plusieurs fois)", + "enter cheque quantity": "Entrez la quantité de chèques", + "cheque id": "identifiant du chèque", + "cheque value": "valeur du chèque", + "enter cheque value": "entrez la valeur du chèque", + "enter payment method": "sélectionnez votre méthode de paiement", + "number of cheques between 1 and 3 cheques also enter your cheques identifiers, value is calculated automatically": "nombre de chèques entre 1 et 3, entrez également les identifiants des chèques utilisés.", + "payment method": "méthode de paiement", + "choose payment method": "choisissez votre méthode de paiement (vous n'avez pas à payer tout de suite, uniquement renseigner comment vous souhaitez régler votre commande).", + "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 à la quantité demandée dans le formulaire des amapiens.", + "all theses informations are for contract generation": "ces informations sont nécessaires pour la génération de contrat." } diff --git a/frontend/src/components/Contracts/Filter/index.tsx b/frontend/src/components/Contracts/Filter/index.tsx new file mode 100644 index 0000000..7b0394e --- /dev/null +++ b/frontend/src/components/Contracts/Filter/index.tsx @@ -0,0 +1,30 @@ +import { Group, MultiSelect } from "@mantine/core"; +import { useMemo } from "react"; +import { t } from "@/config/i18n"; + +export type ContractFiltersProps = { + forms: string[]; + filters: URLSearchParams; + onFilterChange: (values: string[], filter: string) => void; +}; + +export default function ContractFilters({ forms, filters, onFilterChange }: ContractFiltersProps) { + const defaultNames = useMemo(() => { + return filters.getAll("forms"); + }, [filters]); + + return ( + + { + onFilterChange(values, "forms"); + }} + clearable + /> + + ); +} diff --git a/frontend/src/components/Contracts/Modal/index.tsx b/frontend/src/components/Contracts/Modal/index.tsx new file mode 100644 index 0000000..7705fa5 --- /dev/null +++ b/frontend/src/components/Contracts/Modal/index.tsx @@ -0,0 +1,84 @@ +import { Button, Group, Modal, TextInput, Title, type ModalBaseProps } from "@mantine/core"; +import { t } from "@/config/i18n"; +import { useForm } from "@mantine/form"; +import { IconCancel, IconEdit, IconPlus } from "@tabler/icons-react"; +import { type Contract, type ContractInputs } from "@/services/resources/contracts"; + +export type ContractModalProps = ModalBaseProps & { + currentContract?: Contract; + handleSubmit: (contract: ContractInputs, id?: number) => void; +}; + +export function ContractModal({ + opened, + onClose, + currentContract, + handleSubmit, +}: ContractModalProps) { + const form = useForm({ + // initialValues: { + // firstname: currentContract?.firstname ?? "", + // lastname: currentContract?.lastname ?? "", + // email: currentContract?.email ?? "", + // }, + // validate: { + // firstname: (value) => + // !value ? `${t("name", { capfirst: true })} ${t("is required")}` : null, + // email: (value) => + // !value ? `${t("email", { capfirst: true })} ${t("is required")}` : null, + // }, + }); + + return ( + + {t("informations", { capfirst: true })} + + + + + + + + ); +} diff --git a/frontend/src/components/Contracts/Row/index.tsx b/frontend/src/components/Contracts/Row/index.tsx new file mode 100644 index 0000000..2021823 --- /dev/null +++ b/frontend/src/components/Contracts/Row/index.tsx @@ -0,0 +1,55 @@ +import { ActionIcon, Table, Tooltip } from "@mantine/core"; +import { type Contract } from "@/services/resources/contracts"; +import { IconX } from "@tabler/icons-react"; +import { t } from "@/config/i18n"; +import { useDeleteContract } from "@/services/api"; + +export type ContractRowProps = { + contract: Contract; +}; + +export default function ContractRow({ contract }: ContractRowProps) { + // const [searchParams] = useSearchParams(); + const deleteMutation = useDeleteContract(); + // const navigate = useNavigate(); + + return ( + + + {contract.firstname} {contract.lastname} + + {contract.email} + + {contract.cheque_quantity > 0 && contract.cheque_quantity} {contract.payment_method} + + + {/* + { + e.stopPropagation(); + navigate( + `/dashboard/contracts/${contract.id}/edit${searchParams ? `?${searchParams.toString()}` : ""}`, + ); + }} + > + + + */} + + { + deleteMutation.mutate(contract.id); + }} + > + + + + + + ); +} diff --git a/frontend/src/components/PaymentMethods/Cheque/index.tsx b/frontend/src/components/PaymentMethods/Cheque/index.tsx new file mode 100644 index 0000000..9495889 --- /dev/null +++ b/frontend/src/components/PaymentMethods/Cheque/index.tsx @@ -0,0 +1,78 @@ +import { t } from "@/config/i18n"; +import type { ContractInputs } from "@/services/resources/contracts"; +import { Group, NumberInput, Stack, TextInput, Title } from "@mantine/core"; +import type { UseFormReturnType } from "@mantine/form"; +import { useEffect } from "react"; + +export type ContractChequeProps = { + inputForm: UseFormReturnType; + price: number; + chequeOrder: string; +}; + +export type Cheque = { + name: string; + value: string; +}; + +export function ContractCheque({ inputForm, price, chequeOrder }: ContractChequeProps) { + useEffect(() => { + if (!inputForm.values.payment_method.includes("cheque")) { + return; + } + const quantity = Number(inputForm.values.cheque_quantity); + if (!quantity || quantity <= 0) return; + const cheques = inputForm.values.cheques || []; + if (cheques.length !== quantity) { + const newCheques = Array.from({ length: quantity }, (_, i) => ({ + name: cheques[i]?.name ?? "", + value: cheques[i]?.value ?? 0, + })); + inputForm.setFieldValue("cheques", newCheques); + } + + const totalCents = Math.round(price * 100); + const base = Math.floor(totalCents / quantity); + const rest = totalCents - base * quantity; + for (let i = 0; i < quantity; i++) { + const val = (i === quantity - 1 ? base + rest : base) / 100; + inputForm.setFieldValue(`cheques.${i}.value`, val.toFixed(2)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [inputForm.values.cheque_quantity, price, inputForm.values.cheques]); + + return ( + + {`${t("order name")} : ${chequeOrder}`} + + + {inputForm.values.cheques.map((_cheque, index) => ( + + + + + ))} + + + ); +} diff --git a/frontend/src/components/Productors/Modal/index.tsx b/frontend/src/components/Productors/Modal/index.tsx index 2b22680..a68afa5 100644 --- a/frontend/src/components/Productors/Modal/index.tsx +++ b/frontend/src/components/Productors/Modal/index.tsx @@ -9,7 +9,7 @@ import { } from "@mantine/core"; import { t } from "@/config/i18n"; import { useForm } from "@mantine/form"; -import { IconCancel } from "@tabler/icons-react"; +import { IconCancel, IconEdit, IconPlus } from "@tabler/icons-react"; import { PaymentMethods, type Productor, @@ -94,26 +94,16 @@ export function ProductorModal({ ); }} /> - {form.values.payment_methods.map((method, index) => ( - - ))} + {form.values.payment_methods.map((method, index) => + method.name === "cheque" ? ( + + ) : null, + )}