add contract storage fix various bugs and translations

This commit is contained in:
2026-02-16 01:23:31 +01:00
parent 627ddfc464
commit be8e32ebed
28 changed files with 1225 additions and 401 deletions

2
.gitignore vendored
View File

@@ -1,6 +1,6 @@
# Created by https://www.toptal.com/developers/gitignore/api/python # Created by https://www.toptal.com/developers/gitignore/api/python
# Edit at https://www.toptal.com/developers/gitignore?templates=python # Edit at https://www.toptal.com/developers/gitignore?templates=python
.vscode
### Python ### ### Python ###
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/

39
README.md Normal file
View File

@@ -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)

View File

@@ -1,65 +1,16 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from src.database import get_session from src.database import get_session
from sqlmodel import 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 from src.contracts.generate_contract import generate_html_contract
import src.models as models import src.models as models
from src.messages import PDFerrorOccured from src.messages import PDFerrorOccured
import src.contracts.service as service
import io import io
router = APIRouter(prefix='/contracts') 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): def compute_recurrent_prices(products_quantities: list[dict], nb_shipment: int):
result = 0 result = 0
for product_quantity in products_quantities: 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) result += compute_product_price(product, quantity, nb_shipment)
return result return result
def compute_planned_prices(planned: list[dict]): def compute_planned_prices(planneds: list[dict]):
result = 0 result = 0
for plan in planned: for planned in planneds:
result += plan['price'] 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 return result
@router.post('/') @router.post('/')
async def create_contract( async def create_contract(
contract: models.ContractBase, contract: models.ContractCreate,
session: Session = Depends(get_session) session: Session = Depends(get_session)
): ):
form = form_service.get_one(session, contract.form_id) new_contract = service.create_one(session, contract)
planned, recurrent = extract_products(session, contract.contract) planned_contract_products = list(filter(lambda contract_product: contract_product.product.type == models.ProductType.PLANNED, new_contract.products))
recurrent_price = compute_recurrent_prices(recurrent, len(form.shipments)) planneds = create_planned_dict(planned_contract_products)
total_price = '{:10.2f}'.format(recurrent_price + compute_planned_prices(planned)) 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)))
# TODO: Store contract 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: send contract to referer
# TODO: Store contract informations ?
try: try:
pdf_bytes = generate_html_contract( pdf_bytes = generate_html_contract(
form, new_contract,
contract.contract, cheques,
planned, planneds,
recurrent, recurrents,
'{:10.2f}'.format(recurrent_price), recurrent_price,
total_price total_price
) )
pdf_file = io.BytesIO(pdf_bytes) 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: except:
raise HTTPException(status_code=400, detail=PDFerrorOccured) raise HTTPException(status_code=400, detail=PDFerrorOccured)
return StreamingResponse( return StreamingResponse(
pdf_file, pdf_file,
media_type="application/pdf", media_type='application/pdf',
headers={ headers={
"Content-Disposition": f"attachement; filename=contract_{contract_id}.pdf" 'Content-Disposition': f'attachement; filename=contract_{contract_id}.pdf'
} }
) )
@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

View File

@@ -5,12 +5,12 @@ import html
from weasyprint import HTML from weasyprint import HTML
def generate_html_contract( def generate_html_contract(
form: models.Form, contract: models.Contract,
contract_informations: dict, cheques: list[dict],
planned: list[dict], planneds: list[dict],
recurrent: list[dict], reccurents: list[dict],
recurrent_price: float, recurrent_price: float,
total_price: float, total_price: float
): ):
template_dir = "./src/contracts/templates" template_dir = "./src/contracts/templates"
template_loader = jinja2.FileSystemLoader(searchpath=template_dir) template_loader = jinja2.FileSystemLoader(searchpath=template_dir)
@@ -18,24 +18,26 @@ def generate_html_contract(
template_file = "layout.html" template_file = "layout.html"
template = template_env.get_template(template_file) template = template_env.get_template(template_file)
output_text = template.render( output_text = template.render(
contract_name=form.name, contract_name=contract.form.name,
contract_type=form.productor.type, contract_type=contract.form.productor.type,
contract_season=form.season, contract_season=contract.form.season,
referer_name=form.referer.name, referer_name=contract.form.referer.name,
referer_email=form.referer.email, referer_email=contract.form.referer.email,
productor_name=form.productor.name, productor_name=contract.form.productor.name,
productor_address=form.productor.address, productor_address=contract.form.productor.address,
payment_methods_map={"cheque": "Ordre du chèque", "transfer": "IBAN (paiement par virements)"}, payment_methods_map={"cheque": "Ordre du chèque", "transfer": "virements"},
productor_payment_methods=form.productor.payment_methods, productor_payment_methods=contract.form.productor.payment_methods,
member_name=f'{html.escape(contract_informations["firstname"])} {html.escape(contract_informations["lastname"])}', member_name=f'{html.escape(contract.firstname)} {html.escape(contract.lastname)}',
member_email=html.escape(contract_informations["email"]), member_email=html.escape(contract.email),
member_phone=html.escape(contract_informations["phone"]), member_phone=html.escape(contract.phone),
contract_start_date=form.start, contract_start_date=contract.form.start,
contract_end_date=form.end, contract_end_date=contract.form.end,
planned=planned, planneds=planneds,
recurrent=recurrent, recurrents=reccurents,
recurrent_price=recurrent_price, recurrent_price=recurrent_price,
total_price=total_price, total_price=total_price,
contract_payment_method={"cheque": "chèque", "transfer": "virements"}[contract.payment_method],
cheques=cheques
) )
options = { options = {
'page-size': 'Letter', 'page-size': 'Letter',

View File

@@ -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

View File

@@ -1,106 +1,111 @@
<!DOCTYPE html> <!doctype html>
<html> <html>
<head> <head>
<title>{{contract_name}}</title> <title>{{contract_name}}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style type="text/css"> <style type="text/css">
@page { @page {
size: A4; size: A4;
margin: 1cm; margin: 1cm;
@bottom-center { @bottom-center {
content: "Page " counter(page); content: "Page " counter(page);
} }
@top-center { @top-center {
content: "AMAP Croix Luizet - Contrat"; content: "AMAP Croix Luizet - Contrat";
font-size: 10px; font-size: 10px;
color: #666; color: #666;
} }
} }
table { table {
border-collapse: collapse; border-collapse: collapse;
border: 2px solid rgb(140 140 140); border: 2px solid rgb(140 140 140);
font-family: sans-serif; font-family: sans-serif;
font-size: 0.8rem; font-size: 0.8rem;
letter-spacing: 1px; letter-spacing: 1px;
} }
h1, h2, h3, h4, h5 { h1,
h2,
h3,
h4,
h5 {
page-break-after: avoid; page-break-after: avoid;
} }
h1 { h1 {
font-size: 22px; font-size: 22px;
margin-bottom: 5px; margin-bottom: 5px;
} }
h2 { h2 {
font-size: 16px; font-size: 16px;
margin-bottom: 15px; margin-bottom: 15px;
} }
h3 { h3 {
font-size: 15px; font-size: 15px;
margin-top: 30px; margin-top: 30px;
margin-bottom: 10px; margin-bottom: 10px;
} }
h4 { h4 {
font-size: 14px; font-size: 14px;
margin-top: 25px; margin-top: 25px;
margin-bottom: 10px; margin-bottom: 10px;
} }
h5 { h5 {
font-size: 13px; font-size: 13px;
margin-top: 20px; margin-top: 20px;
margin-bottom: 5px; margin-bottom: 5px;
} }
p { p {
margin: 8px 0; margin: 8px 0;
text-align: justify; text-align: justify;
} }
table { table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
margin-top: 10px; margin-top: 10px;
margin-bottom: 20px; margin-bottom: 20px;
font-size: 11px; font-size: 11px;
} }
th, th,
td { td {
border: 1px solid #999; border: 1px solid #999;
padding: 6px 8px; padding: 6px 8px;
vertical-align: top; vertical-align: top;
} }
th { th {
background-color: #f0f0f0; background-color: #f0f0f0;
text-align: left; text-align: left;
} }
tbody tr:nth-child(even) { tbody tr:nth-child(even) {
background-color: #f7f7f7; background-color: #f7f7f7;
} }
table { table {
page-break-inside: avoid; page-break-inside: avoid;
} }
tr { tr {
page-break-inside: avoid; page-break-inside: avoid;
} }
.container { .container {
width: 100%; width: 100%;
} }
html, body { html,
body {
font-family: sans-serif; font-family: sans-serif;
font-size: 12px; font-size: 12px;
line-height: 1.5; line-height: 1.5;
color: #222; color: #222;
} }
body { body {
margin: 0; margin: 0;
} }
.signature { .signature {
height: 80px; height: 80px;
} }
.total-box { .total-box {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
@@ -108,202 +113,269 @@
padding: 10px; padding: 10px;
width: 200px; width: 200px;
margin-left: auto; margin-left: auto;
} }
.total-label { .total-label {
font-size: 18px; font-size: 18px;
font-weight: bold; font-weight: bold;
} }
.total-price { .total-price {
font-size: 18px; font-size: 18px;
font-weight: bold; font-weight: bold;
text-align: right; text-align: right;
} }
</style> </style>
</head> </head>
<body> <body>
<div class="container">
<h1>AMAP Croix Luizet</h1>
<h2>67 rue Octavie Villeurbanne - <a href="https://amapcroixluizet.eu">https://amapcroixluizet.eu</a></h2>
<h3>Contrat d'engagement solidaire</h3>
<h4>Informations contractuelles</h4>
<div class="container"> <div class="container">
<p>Ce contrat est organisé par lAMAP CROIX-LUIZET et est régi par les statuts et le règlement intérieur de lAssociation.</p> <h1>AMAP Croix Luizet</h1>
<h2>
67 rue Octavie Villeurbanne -
<a href="https://amapcroixluizet.eu">https://amapcroixluizet.eu</a>
</h2>
<h3>Contrat d'engagement solidaire</h3>
<h4>Informations contractuelles</h4>
<div class="container">
<p>
Ce contrat est organisé par lAMAP CROIX-LUIZET et est régi par les
statuts et le règlement intérieur de lAssociation.
</p>
<table> <table>
<tbody> <tbody>
<tr> <tr>
<th>Type de contrat</th> <th>Type de contrat</th>
<td>{{contract_type}}</td> <td>{{contract_type}}</td>
</tr> </tr>
<tr> <tr>
<th>Saison du contrat</th> <th>Saison du contrat</th>
<td>{{contract_season}}</td> <td>{{contract_season}}</td>
</tr> </tr>
<tr> <tr>
<th>Type de contrat</th> <th>Type de contrat</th>
<td>{{contract_type}}</td> <td>{{contract_type}}</td>
</tr> </tr>
<tr> <tr>
<th>Référent·e</th> <th>Référent·e</th>
<td>{{referer_name}}</td> <td>{{referer_name}}</td>
</tr> </tr>
<tr> <tr>
<th>Email référent·e</th> <th>Email référent·e</th>
<td>{{referer_email}}</td> <td>{{referer_email}}</td>
</tr> </tr>
<tr> <tr>
<th>Le/La producteur·trice</th> <th>Le/La producteur·trice</th>
<td>{{productor_name}}</td> <td>{{productor_name}}</td>
</tr> </tr>
<tr> <tr>
<th>Adresse du producteur·trice</th> <th>Adresse du producteur·trice</th>
<td>{{productor_address}}</td> <td>{{productor_address}}</td>
</tr> </tr>
{% for method in productor_payment_methods %} {% for method in productor_payment_methods %} {% if method.details
<tr> != "" %}
<th>{{payment_methods_map[method.name]}}</th> <tr>
<td>{{method.details}}</td> <th>{{payment_methods_map[method.name]}}</th>
</tr> <td>{{method.details}}</td>
{% endfor %} </tr>
<tr> {% endif %} {% endfor %}
<th>Ladhérent·e</th> <tr>
<td>{{member_name}}</td> <th>Ladhérent·e</th>
</tr> <td>{{member_name}}</td>
<tr> </tr>
<th>Email de ladhérent·e</th> <tr>
<td>{{member_email}}</td> <th>Email de ladhérent·e</th>
</tr> <td>{{member_email}}</td>
<tr> </tr>
<th>Téléphone de l'adhérent·e</th> <tr>
<td>{{member_phone}}</td> <th>Téléphone de l'adhérent·e</th>
</tr> <td>{{member_phone}}</td>
</tbody> </tr>
</tbody>
</table> </table>
<p> <p>
L'adhérent-e et le-la producteur-trice sengagent à respecter le présent contrat, les statuts et le Règlement Intérieur de «lAMAP CROIX LUIZET» et la charte des AMAP. L'adhérent-e et le-la producteur-trice sengagent à respecter le
présent contrat, les statuts et le Règlement Intérieur de «lAMAP
CROIX LUIZET» et la charte des AMAP.
</p> </p>
</div> </div>
<div> <div>
<h5>Engagement réciproque</h5> <h5>Engagement réciproque</h5>
<p> <p>
Le/La producteur·trice sengage à fournir un panier <b>{{contract_type}}</b>, issu de son exploitation et de qualité en termes gustatifs. Il/Elle sengage à mener son exploitation dans un esprit de respect de la nature et de lenvironnement. Le/La producteur·trice sengage à fournir un panier
Le/La membre adhérent·e sengage à acheter 1 panier en acceptant les conséquences daléas climatiques ou autres évènements ayant un impact sur la qualité ou la quantité de produits dans le panier. <b>{{contract_type}}</b>, issu de son exploitation et de qualité en
Le contrat commence le <b>{{contract_start_date}}</b> et termine le <b>{{contract_end_date}}</b>. termes gustatifs. Il/Elle sengage à mener son exploitation dans un
esprit de respect de la nature et de lenvironnement. Le/La membre
adhérent·e sengage à acheter 1 panier en acceptant les conséquences
daléas climatiques ou autres évènements ayant un impact sur la
qualité ou la quantité de produits dans le panier. Le contrat commence
le <b>{{contract_start_date}}</b> et termine le
<b>{{contract_end_date}}</b>.
</p> </p>
</div> </div>
<div> <div>
<h5> <h5>
<b>Modalités de livraison</b> <b>Modalités de livraison</b>
</h5> </h5>
<p> <p>
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 dAdministration peut modifier exceptionnellement le lieu, le jour ou lhoraire 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 dAdministration peut modifier
exceptionnellement le lieu, le jour ou lhoraire de livraison.
</p> </p>
</div> </div>
<div> <div>
<h5> <h5>En cas dimpossibilité</h5>
En cas dimpossibilité
</h5>
<ul> <ul>
<li> <li>
Pour le/la producteur·trice dassurer une livraison, le Conseil dAdministration et le/la référent-e producteur·trice rechercheront, dans le respect des parties et de léthique de lAMAP une solution compensatrice. Pour le/la producteur·trice dassurer une livraison, le Conseil
</li> dAdministration et le/la référent-e producteur·trice rechercheront,
<li> dans le respect des parties et de léthique de lAMAP une solution
Pour ladhé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é. compensatrice.
</li> </li>
<li>
Pour ladhé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é.
</li>
</ul> </ul>
</div> </div>
<div> <div>
<h5>Rupture du contrat</h5> <h5>Rupture du contrat</h5>
<p> <p>
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, cest au conseil dadministration 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, cest au conseil dadministration de
statuer.
</p> </p>
</div> </div>
{% if recurrent|length > 0 %} {% if recurrents|length > 0 %}
<div class="container"> <div class="container">
<h4>Produits récurents (pour chaques livraisons)</h4> <h4>Produits récurents (pour chaques livraisons)</h4>
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Nom du produit</th> <th>Nom du produit</th>
<th>Prix (€)</th> <th>Prix (€)</th>
<th>Prix (€/kg)</th> <th>Prix (€/kg)</th>
<th>Poids</th> <th>Poids</th>
<th>Quantité</th> <th>Quantité</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for rec in recurrent %} {% for rec in recurrents %}
<tr> <tr>
<td>{{rec.product.name}}</td> <td>{{rec.product.name}}</td>
<td>{{rec.product.price if rec.product.price else ""}}</td> <td>{{rec.product.price if rec.product.price else ""}}</td>
<td>{{rec.product.price_kg if rec.product.price_kg else ""}}</td> <td>{{rec.product.price_kg if rec.product.price_kg else ""}}</td>
<td>{{rec.product.quantity if rec.product.quantity != None else ""}} {{rec.product.quantity_unit if rec.product.quantity_unit != None else ""}}</td> <td>
<td>{{rec.quantity}}{{"g" if rec.product.unit == "1" else "kg" if rec.product.unit == "2" else "p" }}</td> {{rec.product.quantity if rec.product.quantity != None else ""}}
</tr> {{rec.product.quantity_unit if rec.product.quantity_unit != None
else ""}}
</td>
<td>
{{rec.quantity}}{{"g" if rec.product.unit == "1" else "kg" if
rec.product.unit == "2" else "p" }}
</td>
</tr>
{% endfor %} {% endfor %}
<tr> <tr>
<th scope="row" colspan="4">Total</th> <th scope="row" colspan="4">Total</th>
<td>{{recurrent_price}}€</td> <td>{{recurrent_price}}€</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
{% endif %} {% endif %} {% if planneds|length > 0 %}
{% if planned|length > 0 %} <div class="container">
<div class="container">
<h4>Produits planifiés (par livraison)</h4> <h4>Produits planifiés (par livraison)</h4>
{% for plan in planned %} {% for plan in planneds %}
<h5>{{plan.shipment.name}} {{plan.shipment.date}}</h5> <h5>{{plan.shipment.name}} {{plan.shipment.date}}</h5>
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Nom du produit</th> <th>Nom du produit</th>
<th>Prix (€)</th> <th>Prix (€)</th>
<th>Prix (€/kg)</th> <th>Prix (€/kg)</th>
<th>Poids</th> <th>Poids</th>
<th>Quantité</th> <th>Quantité</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for product in plan.products %} {% for product in plan.products %}
<tr> <tr>
<td>{{product.product.name}}</td> <td>{{product.product.name}}</td>
<td>{{product.product.price if product.product.price else ""}}</td> <td>
<td>{{product.product.price_kg if product.product.price_kg else ""}}</td> {{product.product.price if product.product.price else ""}}
<td>{{product.product.quantity if product.product.quantity != None else ""}} {{product.product.quantity_unit if product.product.quantity_unit != None else ""}}</td> </td>
<td>{{product.quantity}}{{"g" if product.product.unit == "1" else "kg" if product.product.unit == "2" else "p" }}</td> <td>
</tr> {{product.product.price_kg if product.product.price_kg else ""}}
{% endfor%} </td>
<tr> <td>
<th scope="row" colspan="4">Total</th> {{product.product.quantity if product.product.quantity != None
<td>{{plan.price}}€</td> else ""}} {{product.product.quantity_unit if
</tr> product.product.quantity_unit != None else ""}}
</tbody> </td>
<td>
{{product.quantity}}{{"g" if product.product.unit == "1" else
"kg" if product.product.unit == "2" else "p" }}
</td>
</tr>
{% endfor%}
<tr>
<th scope="row" colspan="4">Total</th>
<td>{{plan.price}}€</td>
</tr>
</tbody>
</table> </table>
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
<div class="total-box"> <div class="total-box">
<div class="total-label">Prix Total :</div> <div class="total-label">Prix Total :</div>
<div class="total-price">{{total_price}}€</div> <div class="total-price">{{total_price}}€</div>
</div> </div>
<div class="container"> <h4>Paiement par {{contract_payment_method}}</h4>
{% if contract_payment_method == "chèque" %}
<div class="container">
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Signature producteur-trice</th> {% for cheque in cheques %}
<th>Signature adhérent-e</th> <th>Cheque n°{{cheque.name}}</th>
</tr> {% endfor %}
</thead> </tr>
<tbody> </thead>
<tr> <tbody>
<td class="signature"></td> <tr>
<td class="signature"></td> {% for cheque in cheques %}
</tr> <td>{{cheque.value}}€</td>
</tbody> {% endfor %}
</tr>
</tbody>
</table> </table>
</div>
{% endif %}
<div class="container">
<table>
<thead>
<tr>
<th>Signature producteur-trice</th>
<th>Signature adhérent-e</th>
</tr>
</thead>
<tbody>
<tr>
<td class="signature"></td>
<td class="signature"></td>
</tr>
</tbody>
</table>
</div>
</div> </div>
</div> </body>
</body>
</html> </html>

View File

@@ -140,6 +140,10 @@ class Form(FormBase, table=True):
"order_by": "Shipment.name" "order_by": "Shipment.name"
}, },
) )
contracts: list["Contract"] = Relationship(
back_populates="form",
cascade_delete=True
)
class FormUpdate(SQLModel): class FormUpdate(SQLModel):
name: str | None name: str | None
@@ -168,20 +172,92 @@ class TemplateUpdate(SQLModel):
class TemplateCreate(TemplateBase): class TemplateCreate(TemplateBase):
pass 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): 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 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): class ContractUpdate(SQLModel):
pass 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 pass
class ShipmentBase(SQLModel): class ShipmentBase(SQLModel):

View File

@@ -6,3 +6,4 @@ coverage
out out
public public
*.lock *.lock
*.html

View File

@@ -1,19 +1,19 @@
{ {
"product name": "product name", "product name": "name of the product",
"product price": "product price", "product price": "price of the product",
"product quantity": "product quantity", "product quantity": "quantity of the product",
"product quantity unit": "product quantity unit", "product quantity unit": "unit of the product quantity",
"product type": "product type", "product type": "type of the product",
"planned": "planned", "planned": "planned",
"planned products": "planned products", "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", "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", "recurrent": "recurrent",
"product price kg": "product price kg", "product price kg": "price per kilogram",
"product unit": "product unit", "product unit": "product unit",
"grams": "grams", "grams": "grams",
"kilo": "kilo", "kilo": "kilogram",
"piece": "piece", "piece": "piece",
"filter by season": "filter by season", "filter by season": "filter by season",
"filter by form": "filter by form", "filter by form": "filter by form",
@@ -25,13 +25,13 @@
"productor": "productor", "productor": "productor",
"referer": "referer", "referer": "referer",
"edit form": "edit form", "edit form": "edit form",
"form name": "form name", "form name": "name of the form",
"contract season": "contract season", "contract season": "contract season",
"contract season recommandation": "recommandation : <Season>-<year> (example: Winter-2025)", "contract season recommandation": "recommendation: <Season>-<year> (example: Winter-2025)",
"start date": "start date", "start date": "start date",
"end date": "end date", "end date": "end date",
"nothing found": "nothing found", "nothing found": "nothing found",
"number of shipment": "number of shipment", "number of shipment": "number of shipments",
"cancel": "cancel", "cancel": "cancel",
"create form": "create form", "create form": "create form",
"edit productor": "edit productor", "edit productor": "edit productor",
@@ -46,32 +46,32 @@
"transfer": "transfer", "transfer": "transfer",
"type": "type", "type": "type",
"create productor": "create productor", "create productor": "create productor",
"productor name": "productor name", "productor name": "name of the productor",
"productor type": "productor type", "productor type": "type of the productor",
"productor address": "productor address", "productor address": "address of the productor",
"productor payment": "productor payment", "productor payment": "payment method of the productor",
"priceKg": "priceKg", "priceKg": "price per kilogram",
"quantity": "quantity", "quantity": "quantity",
"quantity unit": "quantity unit", "quantity unit": "unit of quantity",
"unit": "sell unit", "unit": "sell unit",
"price": "price", "price": "price",
"create product": "create product", "create product": "create product",
"informations": "informations", "informations": "information",
"remove product": "remove product", "remove product": "remove product",
"edit product": "edit product", "edit product": "edit product",
"shipment name": "shipment name", "shipment name": "shipment name",
"shipments": "shipments", "shipments": "shipments",
"shipment": "shipment", "shipment": "shipment",
"there is": "there is", "there is": "there is",
"for this contract": "for this contact.", "for this contract": "for this contract",
"shipment date": "shipment date", "shipment date": "shipment date",
"shipment products": "shipment products", "shipment products": "shipment products",
"minimum shipment value": "minimum shipment value", "minimum shipment value": "minimum shipment value",
"shipment form": "shipment form", "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)", "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 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)": "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": "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": "ignore this field if minimum shipment value is not required",
"minimum price for this shipment should be at least": "minimum price for this shipment should be at least", "minimum price for this shipment should be at least": "minimum price for this shipment",
"remove shipment": "remove shipment", "remove shipment": "remove shipment",
"productors": "productors", "productors": "productors",
"products": "products", "products": "products",
@@ -83,6 +83,8 @@
"actions": "actions", "actions": "actions",
"all productors": "all productors", "all productors": "all productors",
"all products": "all products", "all products": "all products",
"all shipments": "all shipments",
"all referers": "all referers",
"a name": "a name", "a name": "a name",
"a season": "a season", "a season": "a season",
"a start date": "a start date", "a start date": "a start date",
@@ -90,21 +92,22 @@
"a productor": "a productor", "a productor": "a productor",
"a referer": "a referer", "a referer": "a referer",
"a phone": "a phone", "a phone": "a phone",
"a fistname": "a fistname", "a fistname": "a firstname",
"a lastname": "a lastname", "a lastname": "a lastname",
"a email": "a email", "a email": "an email",
"a payment method": "a payment method",
"submit contract": "submit contract", "submit contract": "submit contract",
"success": "success", "success": "success",
"successfully edited user": "successfully edited user", "successfully edited user": "user edited successfully",
"successfully edited form": "successfully edited form", "successfully edited form": "form edited successfully",
"successfully edited product": "successfully edited product", "successfully edited product": "product edited successfully",
"successfully edited productor": "successfully edited productor", "successfully edited productor": "productor edited successfully",
"successfully edited shipment": "successfully edited shipment", "successfully edited shipment": "shipment edited successfully",
"successfully created user": "successfully created user", "successfully created user": "user created successfully",
"successfully created form": "successfully created form", "successfully created form": "form created successfully",
"successfully created product": "successfully created product", "successfully created product": "product created successfully",
"successfully created productor": "successfully created productor", "successfully created productor": "productor created successfully",
"successfully created shipment": "successfully created shipment", "successfully created shipment": "shipment created successfully",
"error": "error", "error": "error",
"error editing user": "error editing user", "error editing user": "error editing user",
"error editing form": "error editing form", "error editing form": "error editing form",
@@ -121,7 +124,17 @@
"error deleting product": "error deleting product", "error deleting product": "error deleting product",
"error deleting productor": "error deleting productor", "error deleting productor": "error deleting productor",
"error deleting shipment": "error deleting shipment", "error deleting shipment": "error deleting shipment",
"there is no contract for now": "there is no contract for now.", "there is no contract for now": "no contracts available currently",
"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", "for transfer method contact your referer or productor": "for transfer method, contact your referer or productor",
"all theses informations are for contract generation": "all theses informations are for contract generation." "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"
} }

View File

@@ -6,15 +6,15 @@
"product type": "type de produit", "product type": "type de produit",
"planned": "planifié", "planned": "planifié",
"planned products": "Produits planifiés par livraison", "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": "récurent",
"recurrent products": "Produits récurents", "recurrent products": "Produits récurrents",
"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).", "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 price kg": "prix du produit au Kilo",
"product unit": "unité de vente du produit", "product unit": "unité de vente du produit",
"piece": "pièce", "piece": "pièce",
"in": "en", "in": "en",
"enter quantity": "entrez la quantitée", "enter quantity": "entrez la quantité",
"filter by season": "filtrer par saisons", "filter by season": "filtrer par saisons",
"filter by form": "filtrer par formulaire", "filter by form": "filtrer par formulaire",
"filter by productor": "filtrer par producteur·trice", "filter by productor": "filtrer par producteur·trice",
@@ -33,7 +33,7 @@
"nothing found": "rien à afficher", "nothing found": "rien à afficher",
"number of shipment": "nombre de livraisons", "number of shipment": "nombre de livraisons",
"cancel": "annuler", "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", "create productor": "créer le/la producteur·trice",
"edit productor": "modifier le/la producteur·trice", "edit productor": "modifier le/la producteur·trice",
"remove productor": "supprimer le/la producteur·trice", "remove productor": "supprimer le/la producteur·trice",
@@ -68,8 +68,8 @@
"shipment products": "produits pour la livraison", "shipment products": "produits pour la livraison",
"shipment form": "formulaire lié a la livraison", "shipment form": "formulaire lié a la livraison",
"minimum shipment value": "valeur minimum d'une 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).", "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écurents sont pour toutes les livraisons, les produits planifiés sont pour une livraison particulière (voir formulaire de création de livraison).", "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é sil ne sapplique pas à votre contrat.", "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é sil ne sapplique pas à votre contrat.",
"minimum price for this shipment should be at least": "le prix minimum d'une livraison doit être au moins de", "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", "there is": "il y a",
@@ -85,6 +85,8 @@
"actions": "actions", "actions": "actions",
"all productors": "tous les producteur·trices", "all productors": "tous les producteur·trices",
"all products": "tous les produits", "all products": "tous les produits",
"all shipments": "toutes les livraisons",
"all referers": "tous les référent·es",
"is required": "est requis·e", "is required": "est requis·e",
"a name": "un nom", "a name": "un nom",
"a season": "une saison", "a season": "une saison",
@@ -96,6 +98,7 @@
"a fistname": "un prénom", "a fistname": "un prénom",
"a lastname": "un nom", "a lastname": "un nom",
"a email": "une adresse email", "a email": "une adresse email",
"a payment method": "une méthode de paiement",
"submit contract": "Envoyer le contrat", "submit contract": "Envoyer le contrat",
"mililiter": "mililitres (ml)", "mililiter": "mililitres (ml)",
"grams": "grammes (g)", "grams": "grammes (g)",
@@ -106,17 +109,17 @@
"successfully edited form": "formulaire correctement édité", "successfully edited form": "formulaire correctement édité",
"successfully edited product": "produit correctement édité", "successfully edited product": "produit correctement édité",
"successfully edited productor": "producteur·trice correctement édité(e)", "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 user": "utilisateur·trice correctement créé(e)",
"successfully created form": "formulaire correctement créé", "successfully created form": "formulaire correctement créé",
"successfully created product": "produit correctement créé", "successfully created product": "produit correctement créé",
"successfully created productor": "producteur·trice correctement créé(e)", "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 user": "utilisateur·trice correctement supprimé",
"successfully deleted form": "formulaire correctement supprimé", "successfully deleted form": "formulaire correctement supprimé",
"successfully deleted product": "produit correctement supprimé", "successfully deleted product": "produit correctement supprimé",
"successfully deleted productor": "producteur·trice correctement supprimé(e)", "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": "erreur",
"error editing user": "erreur pendant l'édition de l'utilisateur·trice", "error editing user": "erreur pendant l'édition de l'utilisateur·trice",
"error editing form": "erreur pendant l'édition du formulaire", "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 productor": "erreur pendant la suppression du producteur·trice",
"error deleting shipment": "erreur pendant la suppression de la livraison", "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.", "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.", "for transfer method contact your referer or productor": "pour mettre en place le virement automatique, contactez votre référent ou le producteur.",
"all theses informations are for contract generation": "ces informations sont nécéssaires pour la génération de contrat." "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."
} }

View File

@@ -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 (
<Group>
<MultiSelect
aria-label={t("filter by form", { capfirst: true })}
placeholder={t("filter by form", { capfirst: true })}
data={forms}
defaultValue={defaultNames}
onChange={(values: string[]) => {
onFilterChange(values, "forms");
}}
clearable
/>
</Group>
);
}

View File

@@ -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<ContractInputs>({
// 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 (
<Modal opened={opened} onClose={onClose} title={t("create contract", { capfirst: true })}>
<Title order={4}>{t("informations", { capfirst: true })}</Title>
<TextInput
label={t("contract name", { capfirst: true })}
placeholder={t("contract name", { capfirst: true })}
radius="sm"
withAsterisk
{...form.getInputProps("name")}
/>
<TextInput
label={t("contract email", { capfirst: true })}
placeholder={t("contract email", { capfirst: true })}
radius="sm"
withAsterisk
{...form.getInputProps("email")}
/>
<Group mt="sm" justify="space-between">
<Button
variant="filled"
color="red"
aria-label={t("cancel", { capfirst: true })}
leftSection={<IconCancel />}
onClick={() => {
form.clearErrors();
onClose();
}}
>
{t("cancel", { capfirst: true })}
</Button>
<Button
variant="filled"
aria-label={
currentContract
? t("edit contract", { capfirst: true })
: t("create contract", { capfirst: true })
}
leftSection={currentContract ? <IconEdit /> : <IconPlus />}
onClick={() => {
form.validate();
if (form.isValid()) {
handleSubmit(form.getValues(), currentContract?.id);
}
}}
>
{currentContract
? t("edit contract", { capfirst: true })
: t("create contract", { capfirst: true })}
</Button>
</Group>
</Modal>
);
}

View File

@@ -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 (
<Table.Tr key={contract.id}>
<Table.Td>
{contract.firstname} {contract.lastname}
</Table.Td>
<Table.Td>{contract.email}</Table.Td>
<Table.Td>
{contract.cheque_quantity > 0 && contract.cheque_quantity} {contract.payment_method}
</Table.Td>
<Table.Td>
{/* <Tooltip label={t("edit contract", { capfirst: true })}>
<ActionIcon
size="sm"
mr="5"
onClick={(e) => {
e.stopPropagation();
navigate(
`/dashboard/contracts/${contract.id}/edit${searchParams ? `?${searchParams.toString()}` : ""}`,
);
}}
>
<IconEdit />
</ActionIcon>
</Tooltip> */}
<Tooltip label={t("remove contract", { capfirst: true })}>
<ActionIcon
color="red"
size="sm"
mr="5"
onClick={() => {
deleteMutation.mutate(contract.id);
}}
>
<IconX />
</ActionIcon>
</Tooltip>
</Table.Td>
</Table.Tr>
);
}

View File

@@ -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<ContractInputs>;
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 (
<Stack>
<Title order={4}>{`${t("order name")} : ${chequeOrder}`}</Title>
<NumberInput
label={t("cheque quantity", { capfirst: true })}
placeholder={t("enter cheque quantity", { capfirst: true })}
description={t(
"number of cheques between 1 and 3 cheques also enter your cheques identifiers, value is calculated automatically",
{ capfirst: true },
)}
min={1}
max={3}
{...inputForm.getInputProps(`cheque_quantity`)}
/>
<Group grow>
{inputForm.values.cheques.map((_cheque, index) => (
<Stack key={`${index}`}>
<TextInput
label={t("cheque id", { capfirst: true })}
placeholder={t("cheque id", { capfirst: true })}
{...inputForm.getInputProps(`cheques.${index}.name`)}
/>
<NumberInput
readOnly
label={t("cheque value", { capfirst: true })}
suffix={"€"}
placeholder={t("enter cheque value", { capfirst: true })}
{...inputForm.getInputProps(`cheques.${index}.value`)}
/>
</Stack>
))}
</Group>
</Stack>
);
}

View File

@@ -9,7 +9,7 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { t } from "@/config/i18n"; import { t } from "@/config/i18n";
import { useForm } from "@mantine/form"; import { useForm } from "@mantine/form";
import { IconCancel } from "@tabler/icons-react"; import { IconCancel, IconEdit, IconPlus } from "@tabler/icons-react";
import { import {
PaymentMethods, PaymentMethods,
type Productor, type Productor,
@@ -94,26 +94,16 @@ export function ProductorModal({
); );
}} }}
/> />
{form.values.payment_methods.map((method, index) => ( {form.values.payment_methods.map((method, index) =>
<TextInput method.name === "cheque" ? (
key={index} <TextInput
label={ key={index}
method.name === "cheque" label={t("order name", { capfirst: true })}
? t("order name", { capfirst: true }) placeholder={t("order name", { capfirst: true })}
: method.name === "transfer" {...form.getInputProps(`payment_methods.${index}.details`)}
? t("IBAN") />
: t("details", { capfirst: true }) ) : null,
} )}
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"> <Group mt="sm" justify="space-between">
<Button <Button
variant="filled" variant="filled"
@@ -134,6 +124,7 @@ export function ProductorModal({
? t("edit productor", { capfirst: true }) ? t("edit productor", { capfirst: true })
: t("create productor", { capfirst: true }) : t("create productor", { capfirst: true })
} }
leftSection={currentProductor ? <IconEdit /> : <IconPlus />}
onClick={() => { onClick={() => {
form.validate(); form.validate();
if (form.isValid()) { if (form.isValid()) {

View File

@@ -1,11 +1,12 @@
import { t } from "@/config/i18n"; import { t } from "@/config/i18n";
import type { ContractInputs } from "@/services/resources/contracts";
import { ProductUnit, type Product } from "@/services/resources/products"; import { ProductUnit, type Product } from "@/services/resources/products";
import type { Shipment } from "@/services/resources/shipments"; import type { Shipment } from "@/services/resources/shipments";
import { Group, NumberInput } from "@mantine/core"; import { Group, NumberInput } from "@mantine/core";
import type { UseFormReturnType } from "@mantine/form"; import type { UseFormReturnType } from "@mantine/form";
export type ProductFormProps = { export type ProductFormProps = {
inputForm: UseFormReturnType<Record<string, string | number>>; inputForm: UseFormReturnType<ContractInputs>;
product: Product; product: Product;
shipment?: Shipment; shipment?: Shipment;
}; };
@@ -33,7 +34,9 @@ export function ProductForm({ inputForm, product, shipment }: ProductFormProps)
aria-label={t("enter quantity")} aria-label={t("enter quantity")}
placeholder={`${t("enter quantity", { capfirst: true })} ${t("in")} ${t(ProductUnit[product.unit])}`} placeholder={`${t("enter quantity", { capfirst: true })} ${t("in")} ${t(ProductUnit[product.unit])}`}
{...inputForm.getInputProps( {...inputForm.getInputProps(
shipment ? `planned-${shipment.id}-${product.id}` : `recurrent-${product.id}`, shipment
? `products.planned-${shipment.id}-${product.id}`
: `products.recurrent-${product.id}`,
)} )}
/> />
</Group> </Group>

View File

@@ -10,7 +10,7 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { t } from "@/config/i18n"; import { t } from "@/config/i18n";
import { useForm } from "@mantine/form"; import { useForm } from "@mantine/form";
import { IconCancel } from "@tabler/icons-react"; import { IconCancel, IconEdit, IconPlus } from "@tabler/icons-react";
import { import {
ProductQuantityUnit, ProductQuantityUnit,
ProductUnit, ProductUnit,
@@ -179,6 +179,7 @@ export function ProductModal({ opened, onClose, currentProduct, handleSubmit }:
? t("edit product", { capfirst: true }) ? t("edit product", { capfirst: true })
: t("create product", { capfirst: true }) : t("create product", { capfirst: true })
} }
leftSection={currentProduct ? <IconEdit /> : <IconPlus />}
onClick={() => { onClick={() => {
form.validate(); form.validate();
if (form.isValid()) { if (form.isValid()) {

View File

@@ -5,9 +5,10 @@ import type { UseFormReturnType } from "@mantine/form";
import { useMemo } from "react"; import { useMemo } from "react";
import { t } from "@/config/i18n"; import { t } from "@/config/i18n";
import { computePrices } from "@/pages/Contract/price"; import { computePrices } from "@/pages/Contract/price";
import type { ContractInputs } from "@/services/resources/contracts";
export type ShipmentFormProps = { export type ShipmentFormProps = {
inputForm: UseFormReturnType<Record<string, string | number>>; inputForm: UseFormReturnType<ContractInputs>;
shipment: Shipment; shipment: Shipment;
minimumPrice?: number | null; minimumPrice?: number | null;
index: number; index: number;
@@ -20,7 +21,7 @@ export default function ShipmentForm({
minimumPrice, minimumPrice,
}: ShipmentFormProps) { }: ShipmentFormProps) {
const shipmentPrice = useMemo(() => { const shipmentPrice = useMemo(() => {
const values = Object.entries(inputForm.getValues()).filter( const values = Object.entries(inputForm.getValues().products).filter(
([key]) => key.includes("planned") && key.split("-")[1] === String(shipment.id), ([key]) => key.includes("planned") && key.split("-")[1] === String(shipment.id),
); );
return computePrices(values, shipment.products); return computePrices(values, shipment.products);

View File

@@ -9,7 +9,7 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { t } from "@/config/i18n"; import { t } from "@/config/i18n";
import { DatePickerInput } from "@mantine/dates"; import { DatePickerInput } from "@mantine/dates";
import { IconCancel } from "@tabler/icons-react"; import { IconCancel, IconEdit, IconPlus } from "@tabler/icons-react";
import { useForm } from "@mantine/form"; import { useForm } from "@mantine/form";
import { useMemo } from "react"; import { useMemo } from "react";
import { type Shipment, type ShipmentInputs } from "@/services/resources/shipments"; import { type Shipment, type ShipmentInputs } from "@/services/resources/shipments";
@@ -129,6 +129,7 @@ export default function ShipmentModal({
? t("edit shipment", { capfirst: true }) ? t("edit shipment", { capfirst: true })
: t("create shipment", { capfirst: true }) : t("create shipment", { capfirst: true })
} }
leftSection={currentShipment ? <IconEdit /> : <IconPlus />}
onClick={() => { onClick={() => {
form.validate(); form.validate();
if (form.isValid()) { if (form.isValid()) {

View File

@@ -1,7 +1,7 @@
import { Button, Group, Modal, TextInput, Title, type ModalBaseProps } from "@mantine/core"; import { Button, Group, Modal, TextInput, Title, type ModalBaseProps } from "@mantine/core";
import { t } from "@/config/i18n"; import { t } from "@/config/i18n";
import { useForm } from "@mantine/form"; import { useForm } from "@mantine/form";
import { IconCancel } from "@tabler/icons-react"; import { IconCancel, IconEdit, IconPlus } from "@tabler/icons-react";
import { type User, type UserInputs } from "@/services/resources/users"; import { type User, type UserInputs } from "@/services/resources/users";
export type UserModalProps = ModalBaseProps & { export type UserModalProps = ModalBaseProps & {
@@ -60,6 +60,7 @@ export function UserModal({ opened, onClose, currentUser, handleSubmit }: UserMo
? t("edit user", { capfirst: true }) ? t("edit user", { capfirst: true })
: t("create user", { capfirst: true }) : t("create user", { capfirst: true })
} }
leftSection={currentUser ? <IconEdit /> : <IconPlus />}
onClick={() => { onClick={() => {
form.validate(); form.validate();
if (form.isValid()) { if (form.isValid()) {

View File

@@ -4,10 +4,10 @@ import { RouterProvider } from "react-router";
import { router } from "@/router.tsx"; import { router } from "@/router.tsx";
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Notifications } from "@mantine/notifications";
import "@mantine/core/styles.css"; import "@mantine/core/styles.css";
import "@mantine/dates/styles.css"; import "@mantine/dates/styles.css";
import "@mantine/notifications/styles.css"; import "@mantine/notifications/styles.css";
import { Notifications } from "@mantine/notifications";
const queryClient = new QueryClient(); const queryClient = new QueryClient();

View File

@@ -10,6 +10,8 @@ import {
List, List,
Loader, Loader,
Overlay, Overlay,
Select,
Space,
Stack, Stack,
Text, Text,
TextInput, TextInput,
@@ -20,16 +22,22 @@ import { IconMail, IconPhone, IconUser } from "@tabler/icons-react";
import { useCallback, useMemo, useRef } from "react"; import { useCallback, useMemo, useRef } from "react";
import { useParams } from "react-router"; import { useParams } from "react-router";
import { computePrices } from "./price"; import { computePrices } from "./price";
import { ContractCheque } from "@/components/PaymentMethods/Cheque";
import { tranformProducts, type ContractInputs } from "@/services/resources/contracts";
export function Contract() { export function Contract() {
const { id } = useParams(); const { id } = useParams();
const { data: form } = useGetForm(Number(id), { enabled: !!id }); const { data: form } = useGetForm(Number(id), { enabled: !!id });
const inputForm = useForm<Record<string, number | string>>({ const inputForm = useForm<ContractInputs>({
initialValues: { initialValues: {
firstname: "", firstname: "",
lastname: "", lastname: "",
email: "", email: "",
phone: "", phone: "",
payment_method: "",
cheque_quantity: 1,
cheques: [],
products: {},
}, },
validate: { validate: {
firstname: (value) => firstname: (value) =>
@@ -40,6 +48,8 @@ export function Contract() {
!value ? `${t("a email", { capfirst: true })} ${t("is required")}` : null, !value ? `${t("a email", { capfirst: true })} ${t("is required")}` : null,
phone: (value) => phone: (value) =>
!value ? `${t("a phone", { capfirst: true })} ${t("is required")}` : null, !value ? `${t("a phone", { capfirst: true })} ${t("is required")}` : null,
payment_method: (value) =>
!value ? `${t("a payment method", { capfirst: true })} ${t("is required")}` : null,
}, },
}); });
@@ -61,8 +71,8 @@ export function Contract() {
if (!allProducts) { if (!allProducts) {
return 0; return 0;
} }
const values = Object.entries(inputForm.getValues()); const productValues = Object.entries(inputForm.getValues().products);
return computePrices(values, allProducts, form?.shipments.length); return computePrices(productValues, allProducts, form?.shipments.length);
}, [inputForm, allProducts, form?.shipments]); }, [inputForm, allProducts, form?.shipments]);
const inputRefs = useRef<Record<string, HTMLInputElement | null>>({ const inputRefs = useRef<Record<string, HTMLInputElement | null>>({
@@ -70,6 +80,7 @@ export function Contract() {
lastname: null, lastname: null,
email: null, email: null,
phone: null, phone: null,
payment_method: null,
}); });
const isShipmentsMinimumValue = useCallback(() => { const isShipmentsMinimumValue = useCallback(() => {
@@ -77,7 +88,7 @@ export function Contract() {
const shipmentErrors = form.shipments const shipmentErrors = form.shipments
.map((shipment) => { .map((shipment) => {
const total = computePrices( const total = computePrices(
Object.entries(inputForm.getValues()), Object.entries(inputForm.getValues().products),
shipment.products, shipment.products,
); );
if (total < (form?.minimum_shipment_value || 0)) { if (total < (form?.minimum_shipment_value || 0)) {
@@ -122,8 +133,9 @@ export function Contract() {
} }
if (inputForm.isValid() && isShipmentsMinimumValue()) { if (inputForm.isValid() && isShipmentsMinimumValue()) {
const contract = { const contract = {
...inputForm.getValues(),
form_id: form.id, form_id: form.id,
contract: withDefaultValues(inputForm.getValues()), products: tranformProducts(withDefaultValues(inputForm.getValues().products)),
}; };
await createContractMutation.mutateAsync(contract); await createContractMutation.mutateAsync(contract);
} else { } else {
@@ -253,6 +265,36 @@ export function Contract() {
</Accordion> </Accordion>
</> </>
) : null} ) : null}
<Title order={3}>{t("payment method", { capfirst: true })}</Title>
<Select
label={t("payment method", { capfirst: true })}
placeholder={t("enter payment method", { capfirst: true })}
description={t("choose payment method", { capfirst: true })}
data={form.productor.payment_methods.map((payment) => ({
value: payment.name,
label: t(payment.name, { capfirst: true }),
}))}
{...inputForm.getInputProps("payment_method")}
ref={(el) => {
inputRefs.current.payment_method = el;
}}
/>
{inputForm.values.payment_method === "cheque" ? (
<ContractCheque
chequeOrder={
form?.productor?.payment_methods.find((el) => el.name === "cheque")
?.details || ""
}
price={price}
inputForm={inputForm}
/>
) : null}
{inputForm.values.payment_method === "transfer" ? (
<Text>
{t("for transfer method contact your referer or productor", { capfirst: true })}
</Text>
) : null}
<Space h="15vh"></Space>
<Overlay <Overlay
bg={"lightGray"} bg={"lightGray"}
h="10vh" h="10vh"

View File

@@ -0,0 +1,120 @@
import { ActionIcon, Group, Loader, ScrollArea, Stack, Table, Title, Tooltip } from "@mantine/core";
import { t } from "@/config/i18n";
import { useCreateContract, useGetContract, useGetContracts } from "@/services/api";
import { IconPlus } from "@tabler/icons-react";
import ContractRow from "@/components/Contracts/Row";
import { useLocation, useNavigate, useSearchParams } from "react-router";
import { ContractModal } from "@/components/Contracts/Modal";
import { useCallback, useMemo } from "react";
import { type Contract, type ContractInputs } from "@/services/resources/contracts";
import ContractsFilters from "@/components/Contracts/Filter";
export default function Contracts() {
const [searchParams, setSearchParams] = useSearchParams();
// const location = useLocation();
// const navigate = useNavigate();
// const isCreate = location.pathname === "/dashboard/contracts/create";
// const isEdit = location.pathname.includes("/edit");
// const editId = useMemo(() => {
// if (isEdit) {
// return location.pathname.split("/")[3];
// }
// return null;
// }, [location, isEdit]);
// const closeModal = useCallback(() => {
// navigate(`/dashboard/contracts${searchParams ? `?${searchParams.toString()}` : ""}`);
// }, [navigate, searchParams]);
const { data: contracts, isPending } = useGetContracts(searchParams);
// const { data: currentContract } = useGetContract(Number(editId), {
// enabled: !!editId,
// });
const { data: allContracts } = useGetContracts();
const forms = useMemo(() => {
return allContracts
?.map((contract: Contract) => contract.form.name)
.filter((contract, index, array) => array.indexOf(contract) === index);
}, [allContracts]);
const onFilterChange = useCallback(
(values: string[], filter: string) => {
setSearchParams((prev) => {
const params = new URLSearchParams(prev);
params.delete(filter);
values.forEach((value) => {
params.append(filter, value);
});
return params;
});
},
[setSearchParams],
);
if (!contracts || isPending)
return (
<Group align="center" justify="center" h="80vh" w="100%">
<Loader color="pink" />
</Group>
);
return (
<Stack>
<Group justify="space-between">
<Title order={2}>{t("all referers", { capfirst: true })}</Title>
{/* <Tooltip label={t("create contract", { capfirst: true })}>
<ActionIcon
onClick={(e) => {
e.stopPropagation();
navigate(
`/dashboard/contracts/create${searchParams ? `?${searchParams.toString()}` : ""}`,
);
}}
>
<IconPlus />
</ActionIcon>
</Tooltip>
<ContractModal
key={`${currentContract?.id}_create`}
opened={isCreate}
onClose={closeModal}
handleSubmit={handleCreateContract}
/>
<ContractModal
key={`${currentContract?.id}_edit`}
opened={isEdit}
onClose={closeModal}
currentContract={currentContract}
handleSubmit={handleEditContract}
/> */}
</Group>
<ContractsFilters
forms={forms || []}
filters={searchParams}
onFilterChange={onFilterChange}
/>
<ScrollArea type="auto">
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>{t("name", { capfirst: true })}</Table.Th>
<Table.Th>{t("email", { capfirst: true })}</Table.Th>
<Table.Th>{t("payment method", { capfirst: true })}</Table.Th>
<Table.Th>{t("actions", { capfirst: true })}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{contracts.map((contract) => (
<ContractRow contract={contract} key={contract.id} />
))}
</Table.Tbody>
</Table>
</ScrollArea>
</Stack>
);
}

View File

@@ -19,7 +19,7 @@ export default function Dashboard() {
<Tabs.Tab value="products">{t("products", { capfirst: true })}</Tabs.Tab> <Tabs.Tab value="products">{t("products", { capfirst: true })}</Tabs.Tab>
<Tabs.Tab value="forms">{t("forms", { capfirst: true })}</Tabs.Tab> <Tabs.Tab value="forms">{t("forms", { capfirst: true })}</Tabs.Tab>
<Tabs.Tab value="shipments">{t("shipments", { capfirst: true })}</Tabs.Tab> <Tabs.Tab value="shipments">{t("shipments", { capfirst: true })}</Tabs.Tab>
{/* <Tabs.Tab value="templates">{t("templates", {capfirst: true})}</Tabs.Tab> */} <Tabs.Tab value="contracts">{t("contracts", { capfirst: true })}</Tabs.Tab>
<Tabs.Tab value="users">{t("users", { capfirst: true })}</Tabs.Tab> <Tabs.Tab value="users">{t("users", { capfirst: true })}</Tabs.Tab>
</Tabs.List> </Tabs.List>
<Outlet /> <Outlet />

View File

@@ -89,7 +89,7 @@ export default function Users() {
return ( return (
<Stack> <Stack>
<Group justify="space-between"> <Group justify="space-between">
<Title order={2}>{t("all users", { capfirst: true })}</Title> <Title order={2}>{t("all referers", { capfirst: true })}</Title>
<Tooltip label={t("create user", { capfirst: true })}> <Tooltip label={t("create user", { capfirst: true })}>
<ActionIcon <ActionIcon
onClick={(e) => { onClick={(e) => {

View File

@@ -10,6 +10,7 @@ import Users from "@/pages/Users";
import Shipments from "./pages/Shipments"; import Shipments from "./pages/Shipments";
import { Contract } from "./pages/Contract"; import { Contract } from "./pages/Contract";
import { NotFound } from "./pages/NotFound"; import { NotFound } from "./pages/NotFound";
import Contracts from "./pages/Contracts";
export const router = createBrowserRouter([ export const router = createBrowserRouter([
{ {
@@ -29,7 +30,7 @@ export const router = createBrowserRouter([
{ path: "products", Component: Products }, { path: "products", Component: Products },
{ path: "products/create", Component: Products }, { path: "products/create", Component: Products },
{ path: "products/:id/edit", Component: Products }, { path: "products/:id/edit", Component: Products },
// { path: "templates", Component: Templates }, { path: "contracts", Component: Contracts },
{ path: "users", Component: Users }, { path: "users", Component: Users },
{ path: "users/create", Component: Users }, { path: "users/create", Component: Users },
{ path: "users/:id/edit", Component: Users }, { path: "users/:id/edit", Component: Users },

View File

@@ -15,7 +15,7 @@ import type {
} from "@/services/resources/productors"; } from "@/services/resources/productors";
import type { User, UserCreate, UserEditPayload } from "@/services/resources/users"; import type { User, UserCreate, UserEditPayload } from "@/services/resources/users";
import type { Product, ProductCreate, ProductEditPayload } from "./resources/products"; import type { Product, ProductCreate, ProductEditPayload } from "./resources/products";
import type { ContractCreate } from "./resources/contracts"; import type { Contract, ContractCreate } from "./resources/contracts";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { t } from "@/config/i18n"; import { t } from "@/config/i18n";
@@ -563,6 +563,29 @@ export function useEditUser() {
}); });
} }
export function useGetContracts(filters?: URLSearchParams): UseQueryResult<Contract[], Error> {
const queryString = filters?.toString();
return useQuery<Contract[]>({
queryKey: ["contracts", queryString],
queryFn: () =>
fetch(`${Config.backend_uri}/contracts${filters ? `?${queryString}` : ""}`).then(
(res) => res.json(),
),
});
}
export function useGetContract(
id?: number,
options?: Partial<DefinedInitialDataOptions<Contract, Error, Contract, readonly unknown[]>>,
) {
return useQuery<Contract>({
queryKey: ["contract"],
queryFn: () => fetch(`${Config.backend_uri}/contracts/${id}`).then((res) => res.json()),
enabled: !!id,
...options,
});
}
export function useCreateContract() { export function useCreateContract() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -587,3 +610,31 @@ export function useCreateContract() {
}, },
}); });
} }
export function useDeleteContract() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => {
return fetch(`${Config.backend_uri}/contracts/${id}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
}).then((res) => res.json());
},
onSuccess: async () => {
notifications.show({
title: t("success", { capfirst: true }),
message: t("successfully deleted contract", { capfirst: true }),
});
await queryClient.invalidateQueries({ queryKey: ["contracts"] });
},
onError: (error) => {
notifications.show({
title: t("error", { capfirst: true }),
message: error?.message || t(`error deleting contract`, { capfirst: true }),
color: "red",
});
},
});
}

View File

@@ -1,4 +1,72 @@
import type { Cheque } from "@/components/PaymentMethods/Cheque";
import type { Form } from "./forms";
import type { Product } from "./products";
import type { Shipment } from "./shipments";
export type Contract = {
id: number;
form_id: number;
products: ContractProduct;
form: Form;
firstname: string;
lastname: string;
email: string;
phone: string;
payment_method: string;
cheque_quantity: number;
};
export type ContractCreate = { export type ContractCreate = {
form_id: number; form_id: number;
contract: Record<string, string | number | null>; firstname: string;
lastname: string;
email: string;
phone: string;
payment_method: string;
cheque_quantity: number;
products: ContractProductCreate[];
cheques: Cheque[];
}; };
export type ContractInputs = {
firstname: string;
lastname: string;
email: string;
phone: string;
products: Record<string, string | number>;
payment_method: string;
cheques: Cheque[];
cheque_quantity: number;
};
export type ContractProduct = {
id: number;
product_id: number;
shipment_id: number;
quantity: number;
contract: Contract;
product: Product;
shipment?: Shipment | null;
};
export type ContractProductCreate = {
product_id: number;
shipment_id: number | null;
quantity: number;
};
export function tranformProducts(
products: Record<string, string | number>,
): ContractProductCreate[] {
return Object.entries(products).map(([key, value]) => {
const quantity = value;
const parts = key.split("-");
const shipment_id = parts[0] === "planned" ? Number(parts[1]) : null;
const product_id = parts[0] === "planned" ? Number(parts[2]) : Number(parts[1]);
return {
quantity: Number(quantity),
shipment_id: shipment_id,
product_id: product_id,
};
});
}