add contract pdf generation

This commit is contained in:
2026-02-14 23:59:44 +01:00
parent 7e42fbe106
commit f440cef59e
42 changed files with 1299 additions and 123 deletions

View File

@@ -13,7 +13,9 @@
## Installation ## Installation
```console ```console
pip install backend apt install weasyprint
hatch shell
fastapi dev src/main.py
``` ```
## License ## License

View File

@@ -27,7 +27,8 @@ dependencies = [
"psycopg2-binary", "psycopg2-binary",
"PyJWT", "PyJWT",
"cryptography", "cryptography",
"requests" "requests",
"weasyprint",
] ]
[project.urls] [project.urls]

View File

@@ -13,6 +13,8 @@ import jwt
from jwt import PyJWKClient from jwt import PyJWKClient
import requests import requests
from src.messages import tokenExpired, invalidToken
router = APIRouter(prefix="/auth") router = APIRouter(prefix="/auth")
jwk_client = PyJWKClient(JWKS_URL) jwk_client = PyJWKClient(JWKS_URL)
@@ -78,9 +80,9 @@ def verify_token(token: str):
) )
return payload return payload
except jwt.ExpiredSignatureError: except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Token expired") raise HTTPException(status_code=401, detail=tokenExpired)
except jwt.InvalidTokenError: except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid token") raise HTTPException(status_code=401, detail=invalidToken)
def get_current_user( def get_current_user(

View File

@@ -1,3 +1,108 @@
from fastapi import APIRouter from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse
from src.database import get_session
from sqlmodel import Session
import src.forms.service as form_service
import src.shipments.service as shipment_service
import src.products.service as product_service
from src.contracts.generate_contract import generate_html_contract
import src.models as models
from src.messages import PDFerrorOccured
import io
router = APIRouter(prefix='/contracts') router = APIRouter(prefix='/contracts')
def find_dict_in_list(lst, key, value):
for i, dic in enumerate(lst):
if dic[key].id == value:
return i
return -1
def extract_products(session: Session, contract: dict):
planned = []
recurrent = []
for key in contract.keys():
key_list = key.split("-")
if "planned" in key:
shipment_id = int(key_list[1])
product_id = int(key_list[2])
shipment = shipment_service.get_one(session, shipment_id)
product = product_service.get_one(session, product_id)
existing_id = find_dict_in_list(planned, "shipment", shipment_id)
if existing_id >= 0:
planned[existing_id]["products"].append({
"product": product,
"quantity": contract[key],
})
planned[existing_id]['price'] += compute_product_price(product, contract[key])
else:
planned.append({
"shipment": shipment,
"price": compute_product_price(product, contract[key]),
"products": [{
"product": product,
"quantity": contract[key],
}]
})
if "recurrent" in key:
product_id = int(key_list[1])
product = product_service.get_one(session, product_id)
recurrent.append({
"product": product,
"quantity": contract[key]
})
return planned, recurrent
def compute_product_price(product: models.Product, quantity: int, nb_shipment: int = 1):
product_quantity_unit = 1 if product.unit == models.Unit.KILO else 1000
final_quantity = quantity if product.price else quantity / product_quantity_unit
final_price = product.price if product.price else product.price_kg
return final_price * final_quantity * nb_shipment
def compute_recurrent_prices(products_quantities: list[dict], nb_shipment: int):
result = 0
for product_quantity in products_quantities:
product = product_quantity['product']
quantity = product_quantity['quantity']
result += compute_product_price(product, quantity, nb_shipment)
return result
def compute_planned_prices(planned: list[dict]):
result = 0
for plan in planned:
result += plan['price']
return result
@router.post('/')
async def create_contract(
contract: models.ContractBase,
session: Session = Depends(get_session)
):
form = form_service.get_one(session, contract.form_id)
planned, recurrent = extract_products(session, contract.contract)
recurrent_price = compute_recurrent_prices(recurrent, len(form.shipments))
total_price = '{:10.2f}'.format(recurrent_price + compute_planned_prices(planned))
# TODO: Store contract
# TODO: send contract to referer
# TODO: Store contract informations ?
try:
pdf_bytes = generate_html_contract(
form,
contract.contract,
planned,
recurrent,
'{:10.2f}'.format(recurrent_price),
total_price
)
pdf_file = io.BytesIO(pdf_bytes)
contract_id = f'{contract.contract['firstname']}_{contract.contract['lastname']}_{form.productor.type}_{form.season}'
except:
raise HTTPException(status_code=400, detail=PDFerrorOccured)
return StreamingResponse(
pdf_file,
media_type="application/pdf",
headers={
"Content-Disposition": f"attachement; filename=contract_{contract_id}.pdf"
}
)

View File

@@ -0,0 +1,58 @@
import jinja2
import src.models as models
import html
from weasyprint import HTML
def generate_html_contract(
form: models.Form,
contract_informations: dict,
planned: list[dict],
recurrent: list[dict],
recurrent_price: float,
total_price: float,
):
template_dir = "./src/contracts/templates"
template_loader = jinja2.FileSystemLoader(searchpath=template_dir)
template_env = jinja2.Environment(loader=template_loader, autoescape=jinja2.select_autoescape(["html", "xml"]))
template_file = "layout.html"
template = template_env.get_template(template_file)
output_text = template.render(
contract_name=form.name,
contract_type=form.productor.type,
contract_season=form.season,
referer_name=form.referer.name,
referer_email=form.referer.email,
productor_name=form.productor.name,
productor_address=form.productor.address,
payment_methods_map={"cheque": "Ordre du chèque", "transfer": "IBAN (paiement par virements)"},
productor_payment_methods=form.productor.payment_methods,
member_name=f'{html.escape(contract_informations["firstname"])} {html.escape(contract_informations["lastname"])}',
member_email=html.escape(contract_informations["email"]),
member_phone=html.escape(contract_informations["phone"]),
contract_start_date=form.start,
contract_end_date=form.end,
planned=planned,
recurrent=recurrent,
recurrent_price=recurrent_price,
total_price=total_price,
)
options = {
'page-size': 'Letter',
'margin-top': '0.5in',
'margin-right': '0.5in',
'margin-bottom': '0.5in',
'margin-left': '0.5in',
'encoding': "UTF-8",
'print-media-type': True,
"disable-javascript": True,
"disable-external-links": True,
'enable-local-file-access': False,
"disable-local-file-access": True,
"no-images": True,
}
return HTML(
string=output_text,
base_url=template_dir
).write_pdf()

View File

@@ -0,0 +1,309 @@
<!DOCTYPE html>
<html>
<head>
<title>{{contract_name}}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style type="text/css">
@page {
size: A4;
margin: 1cm;
@bottom-center {
content: "Page " counter(page);
}
@top-center {
content: "AMAP Croix Luizet - Contrat";
font-size: 10px;
color: #666;
}
}
table {
border-collapse: collapse;
border: 2px solid rgb(140 140 140);
font-family: sans-serif;
font-size: 0.8rem;
letter-spacing: 1px;
}
h1, h2, h3, h4, h5 {
page-break-after: avoid;
}
h1 {
font-size: 22px;
margin-bottom: 5px;
}
h2 {
font-size: 16px;
margin-bottom: 15px;
}
h3 {
font-size: 15px;
margin-top: 30px;
margin-bottom: 10px;
}
h4 {
font-size: 14px;
margin-top: 25px;
margin-bottom: 10px;
}
h5 {
font-size: 13px;
margin-top: 20px;
margin-bottom: 5px;
}
p {
margin: 8px 0;
text-align: justify;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
margin-bottom: 20px;
font-size: 11px;
}
th,
td {
border: 1px solid #999;
padding: 6px 8px;
vertical-align: top;
}
th {
background-color: #f0f0f0;
text-align: left;
}
tbody tr:nth-child(even) {
background-color: #f7f7f7;
}
table {
page-break-inside: avoid;
}
tr {
page-break-inside: avoid;
}
.container {
width: 100%;
}
html, body {
font-family: sans-serif;
font-size: 12px;
line-height: 1.5;
color: #222;
}
body {
margin: 0;
}
.signature {
height: 80px;
}
.total-box {
display: flex;
flex-direction: column;
justify-content: space-between;
border: 1px solid #999;
padding: 10px;
width: 200px;
margin-left: auto;
}
.total-label {
font-size: 18px;
font-weight: bold;
}
.total-price {
font-size: 18px;
font-weight: bold;
text-align: right;
}
</style>
</head>
<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">
<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>
<tbody>
<tr>
<th>Type de contrat</th>
<td>{{contract_type}}</td>
</tr>
<tr>
<th>Saison du contrat</th>
<td>{{contract_season}}</td>
</tr>
<tr>
<th>Type de contrat</th>
<td>{{contract_type}}</td>
</tr>
<tr>
<th>Référent·e</th>
<td>{{referer_name}}</td>
</tr>
<tr>
<th>Email référent·e</th>
<td>{{referer_email}}</td>
</tr>
<tr>
<th>Le/La producteur·trice</th>
<td>{{productor_name}}</td>
</tr>
<tr>
<th>Adresse du producteur·trice</th>
<td>{{productor_address}}</td>
</tr>
{% for method in productor_payment_methods %}
<tr>
<th>{{payment_methods_map[method.name]}}</th>
<td>{{method.details}}</td>
</tr>
{% endfor %}
<tr>
<th>Ladhérent·e</th>
<td>{{member_name}}</td>
</tr>
<tr>
<th>Email de ladhérent·e</th>
<td>{{member_email}}</td>
</tr>
<tr>
<th>Téléphone de l'adhérent·e</th>
<td>{{member_phone}}</td>
</tr>
</tbody>
</table>
<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.
</p>
</div>
<div>
<h5>Engagement réciproque</h5>
<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 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>
</div>
<div>
<h5>
<b>Modalités de livraison</b>
</h5>
<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.
</p>
</div>
<div>
<h5>
En cas dimpossibilité
</h5>
<ul>
<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.
</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>
</div>
<div>
<h5>Rupture du contrat</h5>
<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.
</p>
</div>
{% if recurrent|length > 0 %}
<div class="container">
<h4>Produits récurents (pour chaques livraisons)</h4>
<table>
<thead>
<tr>
<th>Nom du produit</th>
<th>Prix (€)</th>
<th>Prix (€/kg)</th>
<th>Poids</th>
<th>Quantité</th>
</tr>
</thead>
<tbody>
{% for rec in recurrent %}
<tr>
<td>{{rec.product.name}}</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.quantity if rec.product.quantity != None else ""}} {{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 %}
<tr>
<th scope="row" colspan="4">Total</th>
<td>{{recurrent_price}}€</td>
</tr>
</tbody>
</table>
</div>
{% endif %}
{% if planned|length > 0 %}
<div class="container">
<h4>Produits planifiés (par livraison)</h4>
{% for plan in planned %}
<h5>{{plan.shipment.name}} {{plan.shipment.date}}</h5>
<table>
<thead>
<tr>
<th>Nom du produit</th>
<th>Prix (€)</th>
<th>Prix (€/kg)</th>
<th>Poids</th>
<th>Quantité</th>
</tr>
</thead>
<tbody>
{% for product in plan.products %}
<tr>
<td>{{product.product.name}}</td>
<td>{{product.product.price if product.product.price else ""}}</td>
<td>{{product.product.price_kg if product.product.price_kg else ""}}</td>
<td>{{product.product.quantity if product.product.quantity != None else ""}} {{product.product.quantity_unit if product.product.quantity_unit != None else ""}}</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>
{% endfor %}
</div>
{% endif %}
<div class="total-box">
<div class="total-label">Prix Total :</div>
<div class="total-price">{{total_price}}€</div>
</div>
<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>
</body>
</html>

View File

@@ -1 +1,4 @@
notfound = "Resource was not found." notfound = "Resource was not found."
PDFerrorOccured = "An error occured during PDF generation please contact administrator"
tokenExpired = "Token expired"
invalidToken = "Invalid token"

View File

@@ -20,15 +20,30 @@ class UserUpdate(SQLModel):
class UserCreate(UserBase): class UserCreate(UserBase):
pass pass
class PaymentMethodBase(SQLModel):
name: str
details: str
class PaymentMethod(PaymentMethodBase, table=True):
id: int | None = Field(default=None, primary_key=True)
productor_id: int = Field(foreign_key="productor.id", ondelete="CASCADE")
productor: Optional["Productor"] = Relationship(
back_populates="payment_methods",
)
class PaymentMethodPublic(PaymentMethodBase):
id: int
productor: Optional["Productor"]
class ProductorBase(SQLModel): class ProductorBase(SQLModel):
name: str name: str
address: str address: str
payment: str
type: str type: str
class ProductorPublic(ProductorBase): class ProductorPublic(ProductorBase):
id: int id: int
products: list["Product"] = [] products: list["Product"] = []
payment_methods: list["PaymentMethod"] = []
class Productor(ProductorBase, table=True): class Productor(ProductorBase, table=True):
id: int | None = Field(default=None, primary_key=True) id: int | None = Field(default=None, primary_key=True)
@@ -39,15 +54,19 @@ class Productor(ProductorBase, table=True):
"order_by": "Product.name" "order_by": "Product.name"
}, },
) )
payment_methods: list["PaymentMethod"] = Relationship(
back_populates="productor",
cascade_delete=True
)
class ProductorUpdate(SQLModel): class ProductorUpdate(SQLModel):
name: str | None name: str | None
address: str | None address: str | None
payment: str | None payment_methods: list["PaymentMethod"] = []
type: str | None type: str | None
class ProductorCreate(ProductorBase): class ProductorCreate(ProductorBase):
pass payment_methods: list["PaymentMethod"] = []
class Unit(StrEnum): class Unit(StrEnum):
GRAMS = "1" GRAMS = "1"
@@ -102,6 +121,7 @@ class FormBase(SQLModel):
season: str season: str
start: datetime.date start: datetime.date
end: datetime.date end: datetime.date
minimum_shipment_value: float | None
class FormPublic(FormBase): class FormPublic(FormBase):
id: int id: int
@@ -128,6 +148,7 @@ class FormUpdate(SQLModel):
season: str | None season: str | None
start: datetime.date | None start: datetime.date | None
end: datetime.date | None end: datetime.date | None
minimum_shipment_value: float | None
class FormCreate(FormBase): class FormCreate(FormBase):
pass pass
@@ -148,13 +169,14 @@ class TemplateCreate(TemplateBase):
pass pass
class ContractBase(SQLModel): class ContractBase(SQLModel):
pass form_id: int
contract: dict
class ContractPublic(ContractBase): class ContractPublic(ContractBase):
id: int id: int
class Contract(ContractBase, table=True): # class Contract(ContractBase, table=True):
id: int | None = Field(default=None, primary_key=True) # id: int | None = Field(default=None, primary_key=True)
class ContractUpdate(SQLModel): class ContractUpdate(SQLModel):
pass pass

View File

@@ -13,8 +13,16 @@ def get_one(session: Session, productor_id: int) -> models.ProductorPublic:
return session.get(models.Productor, productor_id) return session.get(models.Productor, productor_id)
def create_one(session: Session, productor: models.ProductorCreate) -> models.ProductorPublic: def create_one(session: Session, productor: models.ProductorCreate) -> models.ProductorPublic:
productor_create = productor.model_dump(exclude_unset=True) productor_create = productor.model_dump(exclude_unset=True, exclude="payment_methods")
new_productor = models.Productor(**productor_create) new_productor = models.Productor(**productor_create)
new_productor.payment_methods = [
models.PaymentMethod(
name=pm.name,
details=pm.details
) for pm in productor.payment_methods
]
session.add(new_productor) session.add(new_productor)
session.commit() session.commit()
session.refresh(new_productor) session.refresh(new_productor)
@@ -26,7 +34,20 @@ def update_one(session: Session, id: int, productor: models.ProductorUpdate) ->
new_productor = result.first() new_productor = result.first()
if not new_productor: if not new_productor:
return None return None
productor_updates = productor.model_dump(exclude_unset=True) productor_updates = productor.model_dump(exclude_unset=True)
if "payment_methods" in productor_updates:
new_productor.payment_methods.clear()
for pm in productor_updates["payment_methods"]:
new_productor.payment_methods.append(
models.PaymentMethod(
name=pm["name"],
details=pm["details"],
productor_id=id
)
)
del productor_updates["payment_methods"]
for key, value in productor_updates.items(): for key, value in productor_updates.items():
setattr(new_productor, key, value) setattr(new_productor, key, value)
session.add(new_productor) session.add(new_productor)

BIN
backend/test.pdf Normal file

Binary file not shown.

View File

@@ -39,7 +39,9 @@
"filter by name": "filter by name", "filter by name": "filter by name",
"filter by type": "filter by type", "filter by type": "filter by type",
"address": "address", "address": "address",
"payment": "payment", "payment methods": "payment methods",
"cheque": "cheque",
"transfer": "transfer",
"type": "type", "type": "type",
"create productor": "create productor", "create productor": "create productor",
"productor name": "productor name", "productor name": "productor name",
@@ -61,6 +63,13 @@
"there is": "there is", "there is": "there is",
"for this contract": "for this contact.", "for this contract": "for this contact.",
"shipment date": "shipment date", "shipment date": "shipment date",
"shipment products": "shipment products",
"minimum shipment value": "minimum shipment value",
"shipment form": "shipment form",
"shipment products is necessary only for planned products (if all products are recurrent leave empty)": "shipment products is necessary only for planned products (if all products are recurrent leave empty)",
"recurrent product is for all shipments, planned product is for a specific shipment (see shipment form)": "recurrent product is for all shipments, planned product is for a specific shipment (see shipment form).",
"some contracts require a minimum value per shipment, ignore this field if it's not the case": "some contracts require a minimum value per shipment, ignore this field if it's not the case.",
"minimum price for this shipment should be at least": "minimum price for this shipment should be at least",
"remove shipment": "remove shipment", "remove shipment": "remove shipment",
"productors": "productors", "productors": "productors",
"products": "products", "products": "products",
@@ -83,5 +92,34 @@
"a lastname": "a lastname", "a lastname": "a lastname",
"a email": "a email", "a email": "a email",
"submit contract": "submit contract", "submit contract": "submit contract",
"success": "success",
"successfully edited user": "successfully edited user",
"successfully edited form": "successfully edited form",
"successfully edited product": "successfully edited product",
"successfully edited productor": "successfully edited productor",
"successfully edited shipment": "successfully edited shipment",
"successfully created user": "successfully created user",
"successfully created form": "successfully created form",
"successfully created product": "successfully created product",
"successfully created productor": "successfully created productor",
"successfully created shipment": "successfully created shipment",
"error": "error",
"error editing user": "error editing user",
"error editing form": "error editing form",
"error editing product": "error editing product",
"error editing productor": "error editing productor",
"error editing shipment": "error editing shipment",
"error creating user": "error creating user",
"error creating form": "error creating form",
"error creating product": "error creating product",
"error creating productor": "error creating productor",
"error creating shipment": "error creating shipment",
"error deleting user": "error deleting user",
"error deleting form": "error deleting form",
"error deleting product": "error deleting product",
"error deleting productor": "error deleting productor",
"error deleting shipment": "error deleting shipment",
"there is no contract for now": "there is no contract for now.",
"the product unit will be assigned to the quantity requested in the form": "the product unit will be assigned to the quantity requested in the form",
"all theses informations are for contract generation, no informations is stored outside of contracts": "all theses informations are for contract generation, no informations is stored outside of contracts." "all theses informations are for contract generation, no informations is stored outside of contracts": "all theses informations are for contract generation, no informations is stored outside of contracts."
} }

View File

@@ -2,17 +2,16 @@
"product name": "nom du produit", "product name": "nom du produit",
"product price": "prix du produit", "product price": "prix du produit",
"product quantity": "quantité du produit", "product quantity": "quantité du produit",
"product quantity unit": "Unité de quantité du produit",
"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": "Selectionnez les produits pour chaque livraison.",
"recurrent": "récurrent", "recurrent": "récurent",
"recurrent products": "Produits récurents", "recurrent products": "Produits récurents",
"your selection in this category will apply for all shipments": "votre selection sera appliquée pour chaque livraisons (Exemple: 6 livraisons, le produits sera comptés 6 fois : une fois par livraison).", "your selection in this category will apply for all shipments": "votre selection sera appliquée pour chaque livraisons (Exemple: Pour 6 livraisons, le produits sera comptés 6 fois : une fois par livraison).",
"product price kg": "prix du produit au Kilo", "product price kg": "prix du produit au Kilo",
"product unit": "unité de vente du produit", "product unit": "unité de vente du produit",
"grams": "grammes",
"kilo": "kilo",
"piece": "pièce", "piece": "pièce",
"in": "en", "in": "en",
"enter quantity": "entrez la quantitée", "enter quantity": "entrez la quantitée",
@@ -33,20 +32,24 @@
"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 formulare de contrat",
"edit productor": "modifier le producteur·trice", "create productor": "créer le/la producteur·trice",
"remove productor": "supprimer le producteur·trice", "edit productor": "modifier le/la producteur·trice",
"remove productor": "supprimer le/la producteur·trice",
"home": "accueil", "home": "accueil",
"dashboard": "tableau de bord", "dashboard": "tableau de bord",
"filter by name": "filtrer par nom", "filter by name": "filtrer par nom",
"filter by type": "filtrer par type", "filter by type": "filtrer par type",
"address": "adresse", "address": "adresse",
"payment": "ordre du chèque", "payment methods": "méthodes de paiement",
"type": "type", "type": "type",
"create productor": "créer le producteur·trice", "cheque": "chèque",
"transfer": "virement",
"order name": "Ordre du chèque",
"IBAN": "IBAN",
"productor name": "nom du producteur·trice", "productor name": "nom du producteur·trice",
"productor type": "type du producteur·trice", "productor type": "type du producteur·trice",
"productor address": "adresse du producteur·trice", "productor address": "adresse du producteur·trice",
"productor payment": "ordre du chèque du producteur·trice", "productor payment": "méthodes de paiement du producteur·trice",
"priceKg": "prix au kilo", "priceKg": "prix au kilo",
"quantity": "quantité", "quantity": "quantité",
"quantity unit": "unité de quantité", "quantity unit": "unité de quantité",
@@ -60,6 +63,13 @@
"shipment date": "date de la livraison", "shipment date": "date de la livraison",
"shipments": "livraisons", "shipments": "livraisons",
"shipment": "livraison", "shipment": "livraison",
"shipment products": "produits pour la livraison",
"shipment form": "formulaire lié a la livraison",
"minimum shipment value": "valeur minimum d'une livraison (€)",
"shipment products is necessary only for planned products (if all products are recurrent leave empty)": "il est nécéssaire de configurer les produits pour la livraison uniquement si il y a des produits planifiés (laisser vide si tous les produits sont récurents).",
"recurrent product is for all shipments, planned product is for a specific shipment (see shipment form)": "les produits récurents sont pour toutes les livraisons, les produits planifiés sont pour une livraison particulière (voir formulaire de création de livraison).",
"some contracts require a minimum value per shipment, ignore this field if it's not the case": "certains contrats nécessitent une valeur minimum par livraison. Ce champ peut être ignoré 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",
"there is": "il y a", "there is": "il y a",
"for this contract": "pour ce contrat.", "for this contract": "pour ce contrat.",
"remove shipment": "supprimer la livraison", "remove shipment": "supprimer la livraison",
@@ -79,11 +89,50 @@
"a start date": "une date de début", "a start date": "une date de début",
"a end date": "une date de fin", "a end date": "une date de fin",
"a productor": "un(e) producteur·trice", "a productor": "un(e) producteur·trice",
"a referer": "un référent·e", "a referer": "un(e) référent·e",
"a phone": "un numéro de téléphone", "a phone": "un numéro de téléphone",
"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",
"submit contract": "Envoyer le contrat", "submit contract": "Envoyer le contrat",
"mililiter": "mililitres (ml)",
"grams": "grammes (g)",
"kilo": "kilogrammes (kg)",
"liter": "litres (L)",
"success": "succès",
"successfully edited user": "utilisateur·trice correctement édité",
"successfully edited form": "formulaire correctement édité",
"successfully edited product": "produit correctement édité",
"successfully edited productor": "producteur·trice correctement édité(e)",
"successfully edited shipment": "livaison correctement éditée",
"successfully created user": "utilisateur·trice correctement créé(e)",
"successfully created form": "formulaire correctement créé",
"successfully created product": "produit correctement créé",
"successfully created productor": "producteur·trice correctement créé(e)",
"successfully created shipment": "livaison correctement créée",
"successfully deleted user": "utilisateur·trice correctement supprimé",
"successfully deleted form": "formulaire correctement supprimé",
"successfully deleted product": "produit correctement supprimé",
"successfully deleted productor": "producteur·trice correctement supprimé(e)",
"successfully deleted shipment": "livaison correctement supprimée",
"error": "erreur",
"error editing user": "erreur pendant l'édition de l'utilisateur·trice",
"error editing form": "erreur pendant l'édition du formulaire",
"error editing product": "erreur pendant l'édition du produit",
"error editing productor": "erreur pendant l'édition du producteur·trice",
"error editing shipment": "erreur pendant l'édition de la livraison",
"error creating user": "erreur pendant la création de l'utilisateur·trice",
"error creating form": "erreur pendant la création du formulaire",
"error creating product": "erreur pendant la création du produit",
"error creating productor": "erreur pendant la création du producteur·trice",
"error creating shipment": "erreur pendant la création de la livraison",
"error deleting user": "erreur pendant la suppression de l'utilisateur·trice",
"error deleting form": "erreur pendant la suppression du formulaire",
"error deleting product": "erreur pendant la suppression du produit",
"error deleting productor": "erreur pendant la suppression du producteur·trice",
"error deleting shipment": "erreur pendant la suppression de la livraison",
"there is no contract for now": "Il n'y a pas de contrats pour le moment.",
"the product unit will be assigned to the quantity requested in the form": "L'unité de vente du produit définit l'unité associée a la quantité demandée dans le formulaire des amapiens.",
"all theses informations are for contract generation, no informations is stored outside of contracts": "ces informations sont nécéssaires pour la génération de contrat, aucune information personnelle n'est gardée ailleurs que dans les contrats générés." "all theses informations are for contract generation, no informations is stored outside of contracts": "ces informations sont nécéssaires pour la génération de contrat, aucune information personnelle n'est gardée ailleurs que dans les contrats générés."
} }

View File

@@ -12,6 +12,7 @@
"@mantine/dates": "^8.3.14", "@mantine/dates": "^8.3.14",
"@mantine/form": "^8.3.14", "@mantine/form": "^8.3.14",
"@mantine/hooks": "^8.3.14", "@mantine/hooks": "^8.3.14",
"@mantine/notifications": "^8.3.14",
"@tabler/icons": "^3.36.1", "@tabler/icons": "^3.36.1",
"@tabler/icons-react": "^3.36.1", "@tabler/icons-react": "^3.36.1",
"@tanstack/react-query": "^5.90.20", "@tanstack/react-query": "^5.90.20",
@@ -1147,6 +1148,31 @@
"react": "^18.x || ^19.x" "react": "^18.x || ^19.x"
} }
}, },
"node_modules/@mantine/notifications": {
"version": "8.3.14",
"resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-8.3.14.tgz",
"integrity": "sha512-+ia97wrcU9Zfv+jXYvgr2GdISqKTHbQE9nnEIZvGUBPAqKr9b2JAsaXQS/RsAdoXUI+kKDEtH2fyVYS7zrSi/Q==",
"license": "MIT",
"dependencies": {
"@mantine/store": "8.3.14",
"react-transition-group": "4.4.5"
},
"peerDependencies": {
"@mantine/core": "8.3.14",
"@mantine/hooks": "8.3.14",
"react": "^18.x || ^19.x",
"react-dom": "^18.x || ^19.x"
}
},
"node_modules/@mantine/store": {
"version": "8.3.14",
"resolved": "https://registry.npmjs.org/@mantine/store/-/store-8.3.14.tgz",
"integrity": "sha512-bgW+fYHDOp7Pk4+lcEm3ZF7dD/sIMKHyR985cOqSHAYJPRcVFb+zcEK/SWoFZqlyA4qh08CNrASOaod8N0XKfA==",
"license": "MIT",
"peerDependencies": {
"react": "^18.x || ^19.x"
}
},
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.3", "version": "1.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
@@ -2226,7 +2252,6 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/dayjs": { "node_modules/dayjs": {
@@ -2266,6 +2291,16 @@
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.286", "version": "1.5.286",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz",
@@ -2818,7 +2853,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
@@ -2937,6 +2971,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -3009,6 +3055,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/optionator": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -3277,6 +3332,17 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -3335,6 +3401,12 @@
} }
} }
}, },
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-number-format": { "node_modules/react-number-format": {
"version": "5.4.4", "version": "5.4.4",
"resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.4.tgz", "resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.4.tgz",
@@ -3463,6 +3535,22 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"license": "BSD-3-Clause",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/resolve-from": { "node_modules/resolve-from": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",

View File

@@ -14,6 +14,7 @@
"@mantine/dates": "^8.3.14", "@mantine/dates": "^8.3.14",
"@mantine/form": "^8.3.14", "@mantine/form": "^8.3.14",
"@mantine/hooks": "^8.3.14", "@mantine/hooks": "^8.3.14",
"@mantine/notifications": "^8.3.14",
"@tabler/icons": "^3.36.1", "@tabler/icons": "^3.36.1",
"@tabler/icons-react": "^3.36.1", "@tabler/icons-react": "^3.36.1",
"@tanstack/react-query": "^5.90.20", "@tanstack/react-query": "^5.90.20",

View File

@@ -16,6 +16,7 @@ export function FormCard({form}: FormCardProps) {
<Box <Box
component={Link} component={Link}
to={`/form/${form.id}`} to={`/form/${form.id}`}
style={{textDecoration: "none", color: "black"}}
> >
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<Title <Title

View File

@@ -1,7 +1,7 @@
import { Button, Group, Modal, Select, TextInput, type ModalBaseProps } from "@mantine/core"; import { Button, Group, Modal, NumberInput, Select, TextInput, type ModalBaseProps } 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 { getProductors, getUsers } from "@/services/api"; import { getProductors, getUsers } from "@/services/api";
import { useForm } from "@mantine/form"; import { useForm } from "@mantine/form";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
@@ -20,6 +20,7 @@ export default function FormModal({
}: FormModalProps) { }: FormModalProps) {
const {data: productors} = getProductors(); const {data: productors} = getProductors();
const {data: users} = getUsers(); const {data: users} = getUsers();
const form = useForm<FormInputs>({ const form = useForm<FormInputs>({
initialValues: { initialValues: {
name: "", name: "",
@@ -28,6 +29,7 @@ export default function FormModal({
end: null, end: null,
productor_id: "", productor_id: "",
referer_id: "", referer_id: "",
minimum_shipment_value: null,
}, },
validate: { validate: {
name: (value) => name: (value) =>
@@ -67,10 +69,9 @@ export default function FormModal({
return ( return (
<Modal <Modal
w={{base: "100%", md: "80%", lg: "50%"}}
opened={opened} opened={opened}
onClose={onClose} onClose={onClose}
title={currentForm ? t("edit form") : t('create form')} title={currentForm ? t("edit form", {capfirst: true}) : t('create form', {capfirst: true})}
> >
<TextInput <TextInput
label={t("form name", {capfirst: true})} label={t("form name", {capfirst: true})}
@@ -123,6 +124,13 @@ export default function FormModal({
data={productorsSelect || []} data={productorsSelect || []}
{...form.getInputProps('productor_id')} {...form.getInputProps('productor_id')}
/> />
<NumberInput
label={t("minimum shipment value", {capfirst: true})}
placeholder={t("minimum shipment value", {capfirst: true})}
description={t("some contracts require a minimum value per shipment, ignore this field if it's not the case", {capfirst: true})}
radius="sm"
{...form.getInputProps('minimum_shipment_value')}
/>
<Group mt="sm" justify="space-between"> <Group mt="sm" justify="space-between">
<Button <Button
variant="filled" variant="filled"
@@ -137,6 +145,7 @@ export default function FormModal({
<Button <Button
variant="filled" variant="filled"
aria-label={currentForm ? t("edit form", {capfirst: true}) : t('create form', {capfirst: true})} aria-label={currentForm ? t("edit form", {capfirst: true}) : t('create form', {capfirst: true})}
leftSection={currentForm ? <IconEdit/> : <IconPlus/>}
onClick={() => { onClick={() => {
form.validate(); form.validate();
if (form.isValid()) { if (form.isValid()) {

View File

@@ -0,0 +1,28 @@
import { ActionIcon, Tooltip } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
export type InputLabelProps = {
label: string;
info: string;
isRequired?: boolean;
}
export function InputLabel({label, info, isRequired}: InputLabelProps) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Tooltip label={info}>
<ActionIcon variant="transparent" size="xs" color="gray">
<IconInfoCircle size={16}/>
</ActionIcon>
</Tooltip>
<span>
{label}
{
isRequired ?
<span style={{ color: 'red' }}> *</span> : null
}
</span>
</div>
);
}

View File

@@ -1,12 +1,13 @@
nav { nav {
display: flex; display: flex;
justify-content: space-between;
justify-self: left; justify-self: left;
width: 50%; padding: 1rem;
background-color: var(--mantine-color-blue-4);
} }
a { .navLink {
gap: 1em; color: #fff;
font-weight: bold;
margin-right: 1rem;
text-decoration: none; text-decoration: none;
} }

View File

@@ -5,8 +5,18 @@ import "./index.css";
export function Navbar() { export function Navbar() {
return ( return (
<nav> <nav>
<NavLink to="/">{t("home", {capfirst: true})}</NavLink> <NavLink
<NavLink to="/dashboard/productors">{t("dashboard", {capfirst: true})}</NavLink> className={"navLink"}
to="/"
>
{t("home", {capfirst: true})}
</NavLink>
<NavLink
className={"navLink"}
to="/dashboard/productors"
>
{t("dashboard", {capfirst: true})}
</NavLink>
</nav> </nav>
); );
} }

View File

@@ -1,8 +1,8 @@
import { Button, Group, Modal, TextInput, Title, type ModalBaseProps } from "@mantine/core"; import { Button, Group, Modal, MultiSelect, TextInput, Title, type ModalBaseProps } from "@mantine/core";
import { t } from "@/config/i18n"; import { t } from "@/config/i18n";
import { useForm } from "@mantine/form"; import { useForm } from "@mantine/form";
import { IconCancel } from "@tabler/icons-react"; import { IconCancel } from "@tabler/icons-react";
import type { Productor, ProductorInputs } from "@/services/resources/productors"; import { PaymentMethods, type Productor, type ProductorInputs } from "@/services/resources/productors";
import { useEffect } from "react"; import { useEffect } from "react";
export type ProductorModalProps = ModalBaseProps & { export type ProductorModalProps = ModalBaseProps & {
@@ -20,18 +20,16 @@ export function ProductorModal({
initialValues: { initialValues: {
name: "", name: "",
address: "", address: "",
payment: "", payment_methods: [],
type: "", type: "",
}, },
validate: { validate: {
name: (value) => name: (value) =>
!value ? `${t("name", {capfirst: true})} ${t('is required')}` : null, !value ? `${t("name", {capfirst: true})} ${t("is required")}` : null,
address: (value) => address: (value) =>
!value ? `${t("address", {capfirst: true})} ${t('is required')}` : null, !value ? `${t("address", {capfirst: true})} ${t("is required")}` : null,
payment: (value) =>
!value ? `${t("payment", {capfirst: true})} ${t('is required')}` : null,
type: (value) => type: (value) =>
!value ? `${t("type", {capfirst: true})} ${t('is required')}` : null !value ? `${t("type", {capfirst: true})} ${t("is required")}` : null
} }
}); });
@@ -45,7 +43,6 @@ export function ProductorModal({
return ( return (
<Modal <Modal
w={{base: "100%", md: "80%", lg: "50%"}}
opened={opened} opened={opened}
onClose={onClose} onClose={onClose}
title={t("create productor", {capfirst: true})} title={t("create productor", {capfirst: true})}
@@ -56,30 +53,65 @@ export function ProductorModal({
placeholder={t("productor name", {capfirst: true})} placeholder={t("productor name", {capfirst: true})}
radius="sm" radius="sm"
withAsterisk withAsterisk
{...form.getInputProps('name')} {...form.getInputProps("name")}
/> />
<TextInput <TextInput
label={t("productor type", {capfirst: true})} label={t("productor type", {capfirst: true})}
placeholder={t("productor type", {capfirst: true})} placeholder={t("productor type", {capfirst: true})}
radius="sm" radius="sm"
withAsterisk withAsterisk
{...form.getInputProps('type')} {...form.getInputProps("type")}
/> />
<TextInput <TextInput
label={t("productor address", {capfirst: true})} label={t("productor address", {capfirst: true})}
placeholder={t("productor address", {capfirst: true})} placeholder={t("productor address", {capfirst: true})}
radius="sm" radius="sm"
withAsterisk withAsterisk
{...form.getInputProps('address')} {...form.getInputProps("address")}
/> />
<TextInput <MultiSelect
label={t("productor payment", {capfirst: true})} label={t("payment methods", {capfirst: true})}
placeholder={t("productor payment", {capfirst: true})} placeholder={t("payment methods", {capfirst: true})}
radius="sm" radius="sm"
withAsterisk withAsterisk
{...form.getInputProps('payment')} data={PaymentMethods}
clearable
searchable
value={form.values.payment_methods.map(p => p.name)}
onChange={(names) => {
form.setFieldValue("payment_methods", names.map(name => {
const existing = form.values.payment_methods.find(p => p.name === name);
return existing ?? {
name,
details: ""
};
}));
}}
/> />
{
form.values.payment_methods.map((method, index) => (
<TextInput
key={index}
label={
method.name === "cheque" ?
t("order name", {capfirst: true}) :
method.name === "transfer" ?
t("IBAN") :
t("details", {capfirst: true})
}
placeholder={
method.name === "cheque" ?
t("order name", {capfirst: true}) :
method.name === "transfer" ?
t("IBAN") :
t("details", {capfirst: true})
}
{...form.getInputProps(
`payment_methods.${index}.details`
)}
/>
))
}
<Group mt="sm" justify="space-between"> <Group mt="sm" justify="space-between">
<Button <Button
variant="filled" variant="filled"
@@ -93,14 +125,15 @@ export function ProductorModal({
>{t("cancel", {capfirst: true})}</Button> >{t("cancel", {capfirst: true})}</Button>
<Button <Button
variant="filled" variant="filled"
aria-label={currentProductor ? t("edit productor", {capfirst: true}) : t('create productor', {capfirst: true})} aria-label={currentProductor ? t("edit productor", {capfirst: true}) : t("create productor", {capfirst: true})}
onClick={() => { onClick={() => {
form.validate(); form.validate();
console.log(form.getValues())
if (form.isValid()) { if (form.isValid()) {
handleSubmit(form.getValues(), currentProductor?.id) handleSubmit(form.getValues(), currentProductor?.id)
} }
}} }}
>{currentProductor ? t("edit productor", {capfirst: true}) : t('create productor', {capfirst: true})}</Button> >{currentProductor ? t("edit productor", {capfirst: true}) : t("create productor", {capfirst: true})}</Button>
</Group> </Group>
</Modal> </Modal>
); );

View File

@@ -1,4 +1,4 @@
import { ActionIcon, Table, Tooltip } from "@mantine/core"; import { ActionIcon, Badge, Table, Tooltip } from "@mantine/core";
import { t } from "@/config/i18n"; import { t } from "@/config/i18n";
import { IconEdit, IconX } from "@tabler/icons-react"; import { IconEdit, IconX } from "@tabler/icons-react";
import type { Productor } from "@/services/resources/productors"; import type { Productor } from "@/services/resources/productors";
@@ -21,7 +21,15 @@ export default function ProductorRow({
<Table.Td>{productor.name}</Table.Td> <Table.Td>{productor.name}</Table.Td>
<Table.Td>{productor.type}</Table.Td> <Table.Td>{productor.type}</Table.Td>
<Table.Td>{productor.address}</Table.Td> <Table.Td>{productor.address}</Table.Td>
<Table.Td>{productor.payment}</Table.Td> <Table.Td>
{
productor.payment_methods.map((value) =>(
<Badge ml="xs">
{t(value.name, {capfirst: true})}
</Badge>
))
}
</Table.Td>
<Table.Td> <Table.Td>
<Tooltip label={t("edit productor", {capfirst: true})}> <Tooltip label={t("edit productor", {capfirst: true})}>
<ActionIcon <ActionIcon

View File

@@ -1,10 +1,11 @@
import { Button, Group, Modal, NumberInput, Pill, Select, TextInput, Title, Tooltip, type ModalBaseProps } from "@mantine/core"; import { Button, Group, Modal, NumberInput, Select, TextInput, Title, type ModalBaseProps } from "@mantine/core";
import { t } from "@/config/i18n"; import { t } from "@/config/i18n";
import { useForm } from "@mantine/form"; import { useForm } from "@mantine/form";
import { IconCancel, IconInfoCircle } from "@tabler/icons-react"; import { IconCancel } from "@tabler/icons-react";
import { ProductQuantityUnit, productToProductInputs, ProductUnit, type Product, type ProductInputs } from "@/services/resources/products"; import { ProductQuantityUnit, productToProductInputs, ProductUnit, type Product, type ProductInputs } from "@/services/resources/products";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { getProductors } from "@/services/api"; import { getProductors } from "@/services/api";
import { InputLabel } from "@/components/Label";
export type ProductModalProps = ModalBaseProps & { export type ProductModalProps = ModalBaseProps & {
currentProduct?: Product; currentProduct?: Product;
@@ -42,7 +43,7 @@ export function ProductModal({
!value ? `${t("type", {capfirst: true})} ${t('is required')}` : null, !value ? `${t("type", {capfirst: true})} ${t('is required')}` : null,
productor_id: (value) => productor_id: (value) =>
!value ? `${t("productor", {capfirst: true})} ${t('is required')}` : null !value ? `${t("productor", {capfirst: true})} ${t('is required')}` : null
} },
}); });
useEffect(() => { useEffect(() => {
@@ -57,7 +58,6 @@ export function ProductModal({
return ( return (
<Modal <Modal
w={{base: "100%", md: "80%", lg: "50%"}}
opened={opened} opened={opened}
onClose={onClose} onClose={onClose}
title={t("create product", {capfirst: true})} title={t("create product", {capfirst: true})}
@@ -78,19 +78,23 @@ export function ProductModal({
label={t("product name", {capfirst: true})} label={t("product name", {capfirst: true})}
placeholder={t("product name", {capfirst: true})} placeholder={t("product name", {capfirst: true})}
radius="sm" radius="sm"
withAsterisk
{...form.getInputProps('name')} {...form.getInputProps('name')}
/> />
<Select <Select
label={t("product type", {capfirst: true})} label={
<InputLabel
label={t("product type", {capfirst: true})}
info={t("recurrent product is for all shipments, planned product is for a specific shipment (see shipment form)", {capfirst: true})}
isRequired
/>
}
placeholder={t("product type", {capfirst: true})} placeholder={t("product type", {capfirst: true})}
radius="sm" radius="sm"
withAsterisk
searchable searchable
clearable clearable
data={[ data={[
{value: "1", label: t("planned")}, {value: "1", label: t("planned", {capfirst: true})},
{value: "2", label: t("recurrent")} {value: "2", label: t("recurrent", {capfirst: true})}
]} ]}
{...form.getInputProps('type')} {...form.getInputProps('type')}
/> />
@@ -103,7 +107,7 @@ export function ProductModal({
withAsterisk withAsterisk
searchable searchable
clearable clearable
data={Object.entries(ProductUnit).map(([key, value]) => ({value: key, label: t(value)}))} data={Object.entries(ProductUnit).map(([key, value]) => ({value: key, label: t(value, {capfirst: true})}))}
{...form.getInputProps('unit')} {...form.getInputProps('unit')}
/> />
<Group grow> <Group grow>
@@ -131,7 +135,9 @@ export function ProductModal({
label={t("product quantity unit", {capfirst: true})} label={t("product quantity unit", {capfirst: true})}
placeholder={t("product quantity unit", {capfirst: true})} placeholder={t("product quantity unit", {capfirst: true})}
radius="sm" radius="sm"
data={Object.entries(ProductQuantityUnit).map(([key, value]) => ({value: key, label: t(value)}))} clearable
searchable
data={Object.entries(ProductQuantityUnit).map(([key, value]) => ({value: key, label: t(value, {capfirst: true})}))}
{...form.getInputProps('quantity_unit', {capfirst: true})} {...form.getInputProps('quantity_unit', {capfirst: true})}
/> />
@@ -152,7 +158,6 @@ export function ProductModal({
aria-label={currentProduct ? t("edit product", {capfirst: true}) : t('create product', {capfirst: true})} aria-label={currentProduct ? t("edit product", {capfirst: true}) : t('create product', {capfirst: true})}
onClick={() => { onClick={() => {
form.validate(); form.validate();
console.log(form.isValid(), form.getValues())
if (form.isValid()) { if (form.isValid()) {
handleSubmit(form.getValues(), currentProduct?.id) handleSubmit(form.getValues(), currentProduct?.id)
} }

View File

@@ -20,11 +20,27 @@ export default function ProductRow({
<Table.Tr key={product.id}> <Table.Tr key={product.id}>
<Table.Td>{product.name}</Table.Td> <Table.Td>{product.name}</Table.Td>
<Table.Td>{t(ProductType[product.type])}</Table.Td> <Table.Td>{t(ProductType[product.type])}</Table.Td>
<Table.Td>{product.price}</Table.Td> <Table.Td>
<Table.Td>{product.price_kg}</Table.Td> {
<Table.Td>{product.quantity}</Table.Td> product.price ?
<Table.Td>{product.quantity_unit}</Table.Td> Intl.NumberFormat(
<Table.Td>{t(ProductUnit[product.unit])}</Table.Td> "fr-FR",
{style: "currency", currency: "EUR"}
).format(product.price) : null
}
</Table.Td>
<Table.Td>
{
product.price_kg ?
`${Intl.NumberFormat(
"fr-FR",
{style: "currency", currency: "EUR"}
).format(product.price_kg)}/kg` : null
}
</Table.Td>
<Table.Td>{product.quantity}{product.quantity_unit}</Table.Td>
<Table.Td>{t(ProductUnit[product.unit], {capfirst: true})}</Table.Td>
<Table.Td> <Table.Td>
<Tooltip label={t("edit product", {capfirst: true})}> <Tooltip label={t("edit product", {capfirst: true})}>
<ActionIcon <ActionIcon

View File

@@ -1,13 +1,15 @@
import { Accordion, Group, Text } from "@mantine/core"; import { Accordion, Group, Stack, Text } from "@mantine/core";
import type { Shipment } from "@/services/resources/shipments"; import type { Shipment } from "@/services/resources/shipments";
import { ProductForm } from "@/components/Products/Form"; import { ProductForm } from "@/components/Products/Form";
import type { UseFormReturnType } from "@mantine/form"; import type { UseFormReturnType } from "@mantine/form";
import { useMemo } from "react"; import { useMemo } from "react";
import { computePrices } from "@/pages/Contract"; import { computePrices } from "@/pages/Contract";
import { t } from "@/config/i18n";
export type ShipmentFormProps = { export type ShipmentFormProps = {
inputForm: UseFormReturnType<Record<string, string | number>>; inputForm: UseFormReturnType<Record<string, string | number>>;
shipment: Shipment; shipment: Shipment;
minimumPrice?: number | null;
index: number; index: number;
} }
@@ -15,6 +17,7 @@ export default function ShipmentForm({
shipment, shipment,
index, index,
inputForm, inputForm,
minimumPrice,
}: ShipmentFormProps) { }: ShipmentFormProps) {
const shipmentPrice = useMemo(() => { const shipmentPrice = useMemo(() => {
const values = Object const values = Object
@@ -26,18 +29,42 @@ export default function ShipmentForm({
return computePrices(values, shipment.products); return computePrices(values, shipment.products);
}, [inputForm, shipment.products]); }, [inputForm, shipment.products]);
const priceRequirement = useMemo(() => {
if (!minimumPrice)
return false;
return minimumPrice ? shipmentPrice < minimumPrice : true
}, [shipmentPrice, minimumPrice])
return ( return (
<Accordion.Item value={String(index)}> <Accordion.Item value={String(index)}>
<Accordion.Control> <Accordion.Control>
<Group justify="space-between"> <Group justify="space-between">
<Text>{shipment.name}</Text> <Text>{shipment.name}</Text>
<Text>{ <Stack gap={0}>
<Text c={priceRequirement ? "red" : "green"}>{
Intl.NumberFormat( Intl.NumberFormat(
"fr-FR", "fr-FR",
{style: "currency", currency: "EUR"} {style: "currency", currency: "EUR"}
).format(shipmentPrice) ).format(shipmentPrice)
}</Text> }</Text>
<Text mr="lg">{shipment.date}</Text> {
priceRequirement ?
<Text c="red"size="sm">
{`${t("minimum price for this shipment should be at least", {capfirst: true})} ${minimumPrice}`}
</Text> :
null
}
</Stack>
<Text mr="lg">
{`${
new Date(shipment.date).toLocaleDateString("fr-FR", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
})
}`}
</Text>
</Group> </Group>
</Accordion.Control> </Accordion.Control>
<Accordion.Panel> <Accordion.Panel>

View File

@@ -63,7 +63,6 @@ export default function ShipmentModal({
return ( return (
<Modal <Modal
w={{base: "100%", md: "80%", lg: "50%"}}
opened={opened} opened={opened}
onClose={onClose} onClose={onClose}
title={currentShipment ? t("edit shipment") : t('create shipment')} title={currentShipment ? t("edit shipment") : t('create shipment')}
@@ -93,6 +92,7 @@ export default function ShipmentModal({
<MultiSelect <MultiSelect
label={t("shipment products", {capfirst: true})} label={t("shipment products", {capfirst: true})}
placeholder={t("shipment products", {capfirst: true})} placeholder={t("shipment products", {capfirst: true})}
description={t("shipment products is necessary only for planned products (if all products are recurrent leave empty)", {capfirst: true})}
data={productsSelect || []} data={productsSelect || []}
clearable clearable
searchable searchable

View File

@@ -37,7 +37,6 @@ export function UserModal({
return ( return (
<Modal <Modal
w={{base: "100%", md: "80%", lg: "50%"}}
opened={opened} opened={opened}
onClose={onClose} onClose={onClose}
title={t("create user", {capfirst: true})} title={t("create user", {capfirst: true})}

View File

@@ -6,6 +6,8 @@ import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
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 { Notifications } from "@mantine/notifications";
const queryClient = new QueryClient() const queryClient = new QueryClient()
@@ -13,6 +15,7 @@ createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<MantineProvider> <MantineProvider>
<Notifications />
<RouterProvider router={router} /> <RouterProvider router={router} />
</MantineProvider> </MantineProvider>
</QueryClientProvider> </QueryClientProvider>

View File

@@ -1,12 +1,12 @@
import { ProductForm } from "@/components/Products/Form"; import { ProductForm } from "@/components/Products/Form";
import ShipmentForm from "@/components/Shipments/Form"; import ShipmentForm from "@/components/Shipments/Form";
import { t } from "@/config/i18n"; import { t } from "@/config/i18n";
import { getForm } from "@/services/api"; import { createContract, getForm } from "@/services/api";
import { type Product } from "@/services/resources/products"; import { type Product } from "@/services/resources/products";
import { Accordion, Button, Group, List, Loader, Overlay, Stack, Text, TextInput, Title } from "@mantine/core"; import { Accordion, Button, Group, List, Loader, Overlay, Stack, Text, TextInput, Title } from "@mantine/core";
import { useForm } from "@mantine/form"; import { useForm } from "@mantine/form";
import { IconMail, IconPhone, IconUser } from "@tabler/icons-react"; import { IconMail, IconPhone, IconUser } from "@tabler/icons-react";
import { useMemo } from "react"; import { useCallback, useMemo, useRef } from "react";
import { useParams } from "react-router"; import { useParams } from "react-router";
export function computePrices(values: [string, any][], products: Product[], nbShipment?: number) { export function computePrices(values: [string, any][], products: Product[], nbShipment?: number) {
@@ -15,7 +15,7 @@ export function computePrices(values: [string, any][], products: Product[], nbSh
const productId = Number(keyArray[keyArray.length - 1]); const productId = Number(keyArray[keyArray.length - 1]);
const product = products.find((product) => product.id === productId); const product = products.find((product) => product.id === productId);
if (!product) { if (!product) {
return 0; return prev + 0;
} }
const isRecurent = key.includes("recurrent") && nbShipment; const isRecurent = key.includes("recurrent") && nbShipment;
const productPrice = Number(product.price || product.price_kg); const productPrice = Number(product.price || product.price_kg);
@@ -29,6 +29,12 @@ export function Contract() {
const { id } = useParams(); const { id } = useParams();
const { data: form } = getForm(Number(id), {enabled: !!id}); const { data: form } = getForm(Number(id), {enabled: !!id});
const inputForm = useForm<Record<string, number | string>>({ const inputForm = useForm<Record<string, number | string>>({
initialValues: {
firstname: "",
lastname: "",
email: "",
phone: "",
},
validate: { validate: {
firstname: (value) => !value ? `${t("a firstname", {capfirst: true})} ${t("is required")}` : null, firstname: (value) => !value ? `${t("a firstname", {capfirst: true})} ${t("is required")}` : null,
lastname: (value) => !value ? `${t("a lastname", {capfirst: true})} ${t("is required")}` : null, lastname: (value) => !value ? `${t("a lastname", {capfirst: true})} ${t("is required")}` : null,
@@ -37,6 +43,8 @@ export function Contract() {
} }
}); });
const createContractMutation = createContract();
const productsRecurent = useMemo(() => { const productsRecurent = useMemo(() => {
return form?.productor?.products.filter((el) => el.type === "2") return form?.productor?.products.filter((el) => el.type === "2")
}, [form]); }, [form]);
@@ -50,10 +58,73 @@ export function Contract() {
}, [form]) }, [form])
const price = useMemo(() => { const price = useMemo(() => {
if (!allProducts) {
return 0;
}
const values = Object.entries(inputForm.getValues()); const values = Object.entries(inputForm.getValues());
return computePrices(values, allProducts, form?.shipments.length); return computePrices(values, allProducts, form?.shipments.length);
}, [inputForm, allProducts, form?.shipments]); }, [inputForm, allProducts, form?.shipments]);
const inputRefs: Record<string, React.RefObject<HTMLInputElement | null>> = {
firstname: useRef<HTMLInputElement>(null),
lastname: useRef<HTMLInputElement>(null),
email: useRef<HTMLInputElement>(null),
phone: useRef<HTMLInputElement>(null)
}
const isShipmentsMinimumValue = useCallback(() => {
const shipmentErrors = form.shipments
.map((shipment) => {
const total = computePrices(
Object.entries(inputForm.getValues()),
shipment.products
);
if (total < (form?.minimum_shipment_value || 0)) {
return shipment.id; // mark shipment as invalid
}
return null;
})
.filter(Boolean);
return shipmentErrors.length === 0;
}, [form]);
const withDefaultValues = useCallback((values: Record<string, number | string>) => {
const result = {...values};
productsRecurent.forEach((product: Product) => {
const key = `recurrent-${product.id}`;
if (result[key] === undefined || result[key] === "") {
result[key] = 0;
}
});
form.shipments.forEach((shipment) => {
shipment.products.forEach((product) => {
const key = `planned-${shipment.id}-${product.id}`;
if (result[key] === undefined || result[key] === "") {
result[key] = 0;
}
})
});
return result;
}, [productsRecurent, form]);
const handleSubmit = useCallback(async () => {
const errors = inputForm.validate();
if (inputForm.isValid() && isShipmentsMinimumValue()) {
const contract = {
form_id: form.id,
contract: withDefaultValues(inputForm.getValues()),
}
await createContractMutation.mutateAsync(contract);
} else {
const firstErrorField = Object.keys(errors.errors)[0];
const ref = inputRefs[firstErrorField];
ref?.current?.scrollIntoView({behavior: "smooth", block: "center"});
}
}, [inputForm, inputRefs, isShipmentsMinimumValue, form]);
if (!form) if (!form)
return <Loader/>; return <Loader/>;
@@ -74,6 +145,7 @@ export function Contract() {
required required
leftSection={<IconUser/>} leftSection={<IconUser/>}
{...inputForm.getInputProps('firstname')} {...inputForm.getInputProps('firstname')}
ref={inputRefs.firstname}
/> />
<TextInput <TextInput
label={t("lastname", {capfirst: true})} label={t("lastname", {capfirst: true})}
@@ -83,6 +155,7 @@ export function Contract() {
required required
leftSection={<IconUser/>} leftSection={<IconUser/>}
{...inputForm.getInputProps('lastname')} {...inputForm.getInputProps('lastname')}
ref={inputRefs.lastname}
/> />
</Group> </Group>
<Group grow> <Group grow>
@@ -94,6 +167,7 @@ export function Contract() {
required required
leftSection={<IconMail/>} leftSection={<IconMail/>}
{...inputForm.getInputProps('email')} {...inputForm.getInputProps('email')}
ref={inputRefs.email}
/> />
<TextInput <TextInput
label={t("phone", {capfirst: true})} label={t("phone", {capfirst: true})}
@@ -103,6 +177,7 @@ export function Contract() {
required required
leftSection={<IconPhone/>} leftSection={<IconPhone/>}
{...inputForm.getInputProps('phone')} {...inputForm.getInputProps('phone')}
ref={inputRefs.phone}
/> />
</Group> </Group>
<Title order={3}>{t('shipments', {capfirst: true})}</Title> <Title order={3}>{t('shipments', {capfirst: true})}</Title>
@@ -149,6 +224,7 @@ export function Contract() {
{ {
shipments.map((shipment, index) => ( shipments.map((shipment, index) => (
<ShipmentForm <ShipmentForm
minimumPrice={form.minimum_shipment_value}
shipment={shipment} shipment={shipment}
index={index} index={index}
inputForm={inputForm} inputForm={inputForm}
@@ -180,10 +256,7 @@ export function Contract() {
</Text> </Text>
<Button <Button
aria-label={t('submit contract')} aria-label={t('submit contract')}
onClick={() => { onClick={handleSubmit}
inputForm.validate();
console.log(inputForm.getValues())
}}
> >
{t('submit contract')} {t('submit contract')}
</Button> </Button>

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="templates">{t("templates", {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

@@ -8,6 +8,7 @@ import FormModal from "@/components/Forms/Modal";
import FormRow from "@/components/Forms/Row"; import FormRow from "@/components/Forms/Row";
import type { Form, FormInputs } from "@/services/resources/forms"; import type { Form, FormInputs } from "@/services/resources/forms";
import FilterForms from "@/components/Forms/Filter"; import FilterForms from "@/components/Forms/Filter";
import { notifications } from "@mantine/notifications";
export function Forms() { export function Forms() {
const [ searchParams, setSearchParams ] = useSearchParams(); const [ searchParams, setSearchParams ] = useSearchParams();
@@ -52,11 +53,16 @@ export function Forms() {
await createFormMutation.mutateAsync({ await createFormMutation.mutateAsync({
...form, ...form,
start: form?.start, start: form?.start,
end: form?.start, end: form?.end,
productor_id: Number(form.productor_id), productor_id: Number(form.productor_id),
referer_id: Number(form.referer_id) referer_id: Number(form.referer_id),
minimum_shipment_value: Number(form.minimum_shipment_value),
}); });
closeModal(); closeModal();
notifications.show({
title: t("success", {capfirst: true}),
message: t("successfully created form", {capfirst: true}),
});
}, [createFormMutation]); }, [createFormMutation]);
const handleEditForm = useCallback(async (form: FormInputs, id?: number) => { const handleEditForm = useCallback(async (form: FormInputs, id?: number) => {
@@ -67,12 +73,17 @@ export function Forms() {
form: { form: {
...form, ...form,
start: form.start, start: form.start,
end: form.start, end: form.end,
productor_id: Number(form.productor_id), productor_id: Number(form.productor_id),
referer_id: Number(form.referer_id) referer_id: Number(form.referer_id),
minimum_shipment_value: Number(form.minimum_shipment_value),
} }
}); });
closeModal(); closeModal();
notifications.show({
title: t("success", {capfirst: true}),
message: t("successfully edited form", {capfirst: true}),
});
}, [editFormMutation]); }, [editFormMutation]);
const onFilterChange = useCallback(( const onFilterChange = useCallback((

View File

@@ -1,9 +1,8 @@
import { Flex } from "@mantine/core"; import { Flex, Text } from "@mantine/core";
import { t } from "@/config/i18n";
import { useParams } from "react-router";
import { getForms } from "@/services/api"; import { getForms } from "@/services/api";
import { FormCard } from "@/components/Forms/Card"; import { FormCard } from "@/components/Forms/Card";
import type { Form } from "@/services/resources/forms"; import type { Form } from "@/services/resources/forms";
import { t } from "@/config/i18n";
export function Home() { export function Home() {
const { data: allForms } = getForms(); const { data: allForms } = getForms();
@@ -11,9 +10,11 @@ export function Home() {
return ( return (
<Flex gap="md" wrap="wrap" justify="center"> <Flex gap="md" wrap="wrap" justify="center">
{ {
allForms?.map((form: Form) => ( allForms && allForms?.length > 0 ?
<FormCard form={form} key={form.id}/> allForms.map((form: Form) => (
)) <FormCard form={form} key={form.id}/>
)) :
<Text mt="lg" size="lg">{t("there is no contract for now",{capfirst: true})}</Text>
} }
</Flex> </Flex>
); );

View File

@@ -0,0 +1,24 @@
import { t } from "@/config/i18n";
import { ActionIcon, Stack, Text, Title, Tooltip } from "@mantine/core";
import { IconHome } from "@tabler/icons-react";
import { useNavigate } from "react-router";
export function NotFound() {
const navigate = useNavigate()
return (
<Stack justify="center" align="center">
<Title order={2}>{t("oops", {capfirst: true})}</Title>
<Text>{t('this page does not exists', {capfirst: true})}</Text>
<Tooltip label={t('back to home', {capfirst: true})}>
<ActionIcon
aria-label={t("back to home", {capfirst: true})}
onClick={() => {
navigate('/')
}}
>
<IconHome/>
</ActionIcon>
</Tooltip>
</Stack>
);
}

View File

@@ -8,6 +8,7 @@ import { ProductorModal } from "@/components/Productors/Modal";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import type { Productor, ProductorInputs } from "@/services/resources/productors"; import type { Productor, ProductorInputs } from "@/services/resources/productors";
import ProductorsFilters from "@/components/Productors/Filter"; import ProductorsFilters from "@/components/Productors/Filter";
import { notifications } from "@mantine/notifications";
export default function Productors() { export default function Productors() {
const [ searchParams, setSearchParams ] = useSearchParams(); const [ searchParams, setSearchParams ] = useSearchParams();
@@ -50,6 +51,10 @@ export default function Productors() {
...productor ...productor
}); });
closeModal(); closeModal();
notifications.show({
title: t("success", {capfirst: true}),
message: t("successfully created productor", {capfirst: true}),
});
}, [createProductorMutation]); }, [createProductorMutation]);
const handleEditProductor = useCallback(async (productor: ProductorInputs, id?: number) => { const handleEditProductor = useCallback(async (productor: ProductorInputs, id?: number) => {
@@ -60,6 +65,10 @@ export default function Productors() {
productor: productor productor: productor
}); });
closeModal(); closeModal();
notifications.show({
title: t("success", {capfirst: true}),
message: t("successfully edited productor", {capfirst: true}),
});
}, []); }, []);
const onFilterChange = useCallback((values: string[], filter: string) => { const onFilterChange = useCallback((values: string[], filter: string) => {
@@ -116,7 +125,7 @@ export default function Productors() {
<Table.Th>{t("name", {capfirst: true})}</Table.Th> <Table.Th>{t("name", {capfirst: true})}</Table.Th>
<Table.Th>{t("type", {capfirst: true})}</Table.Th> <Table.Th>{t("type", {capfirst: true})}</Table.Th>
<Table.Th>{t("address", {capfirst: true})}</Table.Th> <Table.Th>{t("address", {capfirst: true})}</Table.Th>
<Table.Th>{t("payment", {capfirst: true})}</Table.Th> <Table.Th>{t("payment methods", {capfirst: true})}</Table.Th>
<Table.Th>{t("actions", {capfirst: true})}</Table.Th> <Table.Th>{t("actions", {capfirst: true})}</Table.Th>
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>

View File

@@ -8,6 +8,7 @@ import { ProductModal } from "@/components/Products/Modal";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { productCreateFromProductInputs, type Product, type ProductInputs } from "@/services/resources/products"; import { productCreateFromProductInputs, type Product, type ProductInputs } from "@/services/resources/products";
import ProductsFilters from "@/components/Products/Filter"; import ProductsFilters from "@/components/Products/Filter";
import { notifications } from "@mantine/notifications";
export default function Products() { export default function Products() {
const [ searchParams, setSearchParams ] = useSearchParams(); const [ searchParams, setSearchParams ] = useSearchParams();
@@ -48,6 +49,10 @@ export default function Products() {
const handleCreateProduct = useCallback(async (product: ProductInputs) => { const handleCreateProduct = useCallback(async (product: ProductInputs) => {
await createProductMutation.mutateAsync(productCreateFromProductInputs(product)); await createProductMutation.mutateAsync(productCreateFromProductInputs(product));
closeModal(); closeModal();
notifications.show({
title: t("success", {capfirst: true}),
message: t("successfully created product", {capfirst: true}),
});
}, [createProductMutation]); }, [createProductMutation]);
const handleEditProduct = useCallback(async (product: ProductInputs, id?: number) => { const handleEditProduct = useCallback(async (product: ProductInputs, id?: number) => {
@@ -58,6 +63,10 @@ export default function Products() {
product: productCreateFromProductInputs(product) product: productCreateFromProductInputs(product)
}); });
closeModal(); closeModal();
notifications.show({
title: t("success", {capfirst: true}),
message: t("successfully edited product", {capfirst: true}),
});
}, []); }, []);
const onFilterChange = useCallback((values: string[], filter: string) => { const onFilterChange = useCallback((values: string[], filter: string) => {
@@ -116,7 +125,6 @@ export default function Products() {
<Table.Th>{t("price", {capfirst: true})}</Table.Th> <Table.Th>{t("price", {capfirst: true})}</Table.Th>
<Table.Th>{t("priceKg", {capfirst: true})}</Table.Th> <Table.Th>{t("priceKg", {capfirst: true})}</Table.Th>
<Table.Th>{t("quantity", {capfirst: true})}</Table.Th> <Table.Th>{t("quantity", {capfirst: true})}</Table.Th>
<Table.Th>{t("quantity unit", {capfirst: true})}</Table.Th>
<Table.Th>{t("unit", {capfirst: true})}</Table.Th> <Table.Th>{t("unit", {capfirst: true})}</Table.Th>
<Table.Th>{t("actions", {capfirst: true})}</Table.Th> <Table.Th>{t("actions", {capfirst: true})}</Table.Th>
</Table.Tr> </Table.Tr>

View File

@@ -8,6 +8,7 @@ import { useCallback, useMemo } from "react";
import { shipmentCreateFromShipmentInputs, type Shipment, type ShipmentInputs } from "@/services/resources/shipments"; import { shipmentCreateFromShipmentInputs, type Shipment, type ShipmentInputs } from "@/services/resources/shipments";
import ShipmentModal from "@/components/Shipments/Modal"; import ShipmentModal from "@/components/Shipments/Modal";
import ShipmentsFilters from "@/components/Shipments/Filter"; import ShipmentsFilters from "@/components/Shipments/Filter";
import { notifications } from "@mantine/notifications";
export default function Shipments() { export default function Shipments() {
const [ searchParams, setSearchParams ] = useSearchParams(); const [ searchParams, setSearchParams ] = useSearchParams();
@@ -43,6 +44,10 @@ export default function Shipments() {
const handleCreateShipment = useCallback(async (shipment: ShipmentInputs) => { const handleCreateShipment = useCallback(async (shipment: ShipmentInputs) => {
await createShipmentMutation.mutateAsync(shipmentCreateFromShipmentInputs(shipment)); await createShipmentMutation.mutateAsync(shipmentCreateFromShipmentInputs(shipment));
closeModal(); closeModal();
notifications.show({
title: t("success", {capfirst: true}),
message: t("successfully created shipment", {capfirst: true}),
});
}, [createShipmentMutation]); }, [createShipmentMutation]);
const handleEditShipment = useCallback(async (shipment: ShipmentInputs, id?: number) => { const handleEditShipment = useCallback(async (shipment: ShipmentInputs, id?: number) => {
@@ -53,6 +58,10 @@ export default function Shipments() {
shipment: shipmentCreateFromShipmentInputs(shipment) shipment: shipmentCreateFromShipmentInputs(shipment)
}); });
closeModal(); closeModal();
notifications.show({
title: t("success", {capfirst: true}),
message: t("successfully edited shipment", {capfirst: true}),
});
}, []); }, []);
const onFilterChange = useCallback((values: string[], filter: string) => { const onFilterChange = useCallback((values: string[], filter: string) => {

View File

@@ -42,18 +42,18 @@ export default function Users() {
const editUserMutation = editUser(); const editUserMutation = editUser();
const handleCreateUser = useCallback(async (user: UserInputs) => { const handleCreateUser = useCallback(async (user: UserInputs) => {
await createUserMutation.mutateAsync(user); await createUserMutation.mutateAsync(user);
closeModal(); closeModal();
}, [createUserMutation]); }, [createUserMutation]);
const handleEditUser = useCallback(async (user: UserInputs, id?: number) => { const handleEditUser = useCallback(async (user: UserInputs, id?: number) => {
if (!id) if (!id)
return; return;
await editUserMutation.mutateAsync({ await editUserMutation.mutateAsync({
id: id, id: id,
user: user user: user
}); });
closeModal(); closeModal();
}, []); }, []);
const onFilterChange = useCallback((values: string[], filter: string) => { const onFilterChange = useCallback((values: string[], filter: string) => {

View File

@@ -8,17 +8,17 @@ import { Forms } from "@/pages/Forms";
import Dashboard from "@/pages/Dashboard"; import Dashboard from "@/pages/Dashboard";
import Productors from "@/pages/Productors"; import Productors from "@/pages/Productors";
import Products from "@/pages/Products"; import Products from "@/pages/Products";
import Templates from "@/pages/Templates";
import Users from "@/pages/Users"; 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 { CreateForms } from "@/pages/Forms/CreateForm"; // import { CreateForms } from "@/pages/Forms/CreateForm";
export const router = createBrowserRouter([ export const router = createBrowserRouter([
{ {
path: "/", path: "/",
Component: Root, Component: Root,
// errorElement: <NotFound />, errorElement: <NotFound />,
children: [ children: [
{ index: true, Component: Home }, { index: true, Component: Home },
{ path: "/forms", Component: Forms }, { path: "/forms", Component: Forms },
@@ -31,7 +31,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: "templates", Component: Templates },
{ 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

@@ -5,6 +5,9 @@ import type { Shipment, ShipmentCreate, ShipmentEditPayload } from "@/services/r
import type { Productor, ProductorCreate, ProductorEditPayload } from "@/services/resources/productors"; import type { Productor, ProductorCreate, ProductorEditPayload } 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 { notifications } from "@mantine/notifications";
import { t } from "@/config/i18n";
export function getShipments(filters?: URLSearchParams): UseQueryResult<Shipment[], Error> { export function getShipments(filters?: URLSearchParams): UseQueryResult<Shipment[], Error> {
const queryString = filters?.toString() const queryString = filters?.toString()
@@ -43,7 +46,18 @@ export function createShipment() {
}).then((res) => res.json()); }).then((res) => res.json());
}, },
onSuccess: async () => { onSuccess: async () => {
notifications.show({
title: t("success", {capfirst: true}),
message: t("successfully created shipment", {capfirst: true}),
});
await queryClient.invalidateQueries({ queryKey: ['shipments'] }) await queryClient.invalidateQueries({ queryKey: ['shipments'] })
},
onError: (error: any) => {
notifications.show({
title: t("error", {capfirst: true}),
message: error?.message || t(`error editing shipment`, {capfirst: true}),
color: "red"
});
} }
}) })
} }
@@ -62,7 +76,18 @@ export function editShipment() {
}).then((res) => res.json()); }).then((res) => res.json());
}, },
onSuccess: async () => { onSuccess: async () => {
notifications.show({
title: t("success", {capfirst: true}),
message: t("successfully edited shipment", {capfirst: true}),
});
await queryClient.invalidateQueries({ queryKey: ['shipments'] }) await queryClient.invalidateQueries({ queryKey: ['shipments'] })
},
onError: (error: any) => {
notifications.show({
title: t("error", {capfirst: true}),
message: error?.message || t(`error editing shipment`, {capfirst: true}),
color: "red"
});
} }
}) })
} }
@@ -79,7 +104,18 @@ export function deleteShipment() {
}).then((res) => res.json()); }).then((res) => res.json());
}, },
onSuccess: async () => { onSuccess: async () => {
notifications.show({
title: t("success", {capfirst: true}),
message: t("successfully deleted shipment", {capfirst: true}),
});
await queryClient.invalidateQueries({ queryKey: ['shipments'] }) await queryClient.invalidateQueries({ queryKey: ['shipments'] })
},
onError: (error: any) => {
notifications.show({
title: t("error", {capfirst: true}),
message: error?.message || t(`error deleting shipment`, {capfirst: true}),
color: "red"
});
} }
}); });
} }
@@ -121,7 +157,18 @@ export function createProductor() {
}).then((res) => res.json()); }).then((res) => res.json());
}, },
onSuccess: async () => { onSuccess: async () => {
notifications.show({
title: t("success", {capfirst: true}),
message: t("successfully created productor", {capfirst: true}),
});
await queryClient.invalidateQueries({ queryKey: ['productors'] }) await queryClient.invalidateQueries({ queryKey: ['productors'] })
},
onError: (error: any) => {
notifications.show({
title: t("error", {capfirst: true}),
message: error?.message || t(`error editing productor`, {capfirst: true}),
color: "red"
});
} }
}) })
} }
@@ -140,7 +187,18 @@ export function editProductor() {
}).then((res) => res.json()); }).then((res) => res.json());
}, },
onSuccess: async () => { onSuccess: async () => {
notifications.show({
title: t("success", {capfirst: true}),
message: t("successfully edited productor", {capfirst: true}),
});
await queryClient.invalidateQueries({ queryKey: ['productors'] }) await queryClient.invalidateQueries({ queryKey: ['productors'] })
},
onError: (error: any) => {
notifications.show({
title: t("error", {capfirst: true}),
message: error?.message || t(`error editing productor`, {capfirst: true}),
color: "red"
});
} }
}) })
} }
@@ -157,7 +215,18 @@ export function deleteProductor() {
}).then((res) => res.json()); }).then((res) => res.json());
}, },
onSuccess: async () => { onSuccess: async () => {
notifications.show({
title: t("success", {capfirst: true}),
message: t("successfully deleted productor", {capfirst: true}),
});
await queryClient.invalidateQueries({ queryKey: ['productors'] }) await queryClient.invalidateQueries({ queryKey: ['productors'] })
},
onError: (error: any) => {
notifications.show({
title: t("error", {capfirst: true}),
message: error?.message || t(`error deleting productor`, {capfirst: true}),
color: "red"
});
} }
}); });
} }
@@ -216,7 +285,18 @@ export function deleteForm() {
}).then((res) => res.json()); }).then((res) => res.json());
}, },
onSuccess: async () => { onSuccess: async () => {
notifications.show({
title: t("success", {capfirst: true}),
message: t("successfully deleted form", {capfirst: true}),
});
await queryClient.invalidateQueries({ queryKey: ['forms'] }) await queryClient.invalidateQueries({ queryKey: ['forms'] })
},
onError: (error: any) => {
notifications.show({
title: t("error", {capfirst: true}),
message: error?.message || t(`error deleting form`, {capfirst: true}),
color: "red"
});
} }
}); });
} }
@@ -235,7 +315,18 @@ export function editForm() {
}).then((res) => res.json()); }).then((res) => res.json());
}, },
onSuccess: async () => { onSuccess: async () => {
notifications.show({
title: t("success", {capfirst: true}),
message: t("successfully edited form", {capfirst: true}),
});
await queryClient.invalidateQueries({ queryKey: ['forms'] }) await queryClient.invalidateQueries({ queryKey: ['forms'] })
},
onError: (error: any) => {
notifications.show({
title: t("error", {capfirst: true}),
message: error?.message || t(`error editing form`, {capfirst: true}),
color: "red"
});
} }
}); });
} }
@@ -277,7 +368,18 @@ export function createProduct() {
}).then((res) => res.json()); }).then((res) => res.json());
}, },
onSuccess: async () => { onSuccess: async () => {
notifications.show({
title: t("success", {capfirst: true}),
message: t("successfully created product", {capfirst: true}),
});
await queryClient.invalidateQueries({ queryKey: ['products'] }) await queryClient.invalidateQueries({ queryKey: ['products'] })
},
onError: (error: any) => {
notifications.show({
title: t("error", {capfirst: true}),
message: error?.message || t(`error editing product`, {capfirst: true}),
color: "red"
});
} }
}); });
} }
@@ -294,7 +396,18 @@ export function deleteProduct() {
}).then((res) => res.json()); }).then((res) => res.json());
}, },
onSuccess: async () => { onSuccess: async () => {
notifications.show({
title: t("success", {capfirst: true}),
message: t("successfully deleted product", {capfirst: true}),
});
await queryClient.invalidateQueries({ queryKey: ['products'] }) await queryClient.invalidateQueries({ queryKey: ['products'] })
},
onError: (error: any) => {
notifications.show({
title: t("error", {capfirst: true}),
message: error?.message || t(`error deleting product`, {capfirst: true}),
color: "red"
});
} }
}); });
} }
@@ -313,7 +426,18 @@ export function editProduct() {
}).then((res) => res.json()); }).then((res) => res.json());
}, },
onSuccess: async () => { onSuccess: async () => {
notifications.show({
title: t("success", {capfirst: true}),
message: t("successfully edited product", {capfirst: true}),
});
await queryClient.invalidateQueries({ queryKey: ['products'] }) await queryClient.invalidateQueries({ queryKey: ['products'] })
},
onError: (error: any) => {
notifications.show({
title: t("error", {capfirst: true}),
message: error?.message || t(`error editing product`, {capfirst: true}),
color: "red"
});
} }
}); });
} }
@@ -355,7 +479,18 @@ export function createUser() {
}).then((res) => res.json()); }).then((res) => res.json());
}, },
onSuccess: async () => { onSuccess: async () => {
notifications.show({
title: t("success", {capfirst: true}),
message: t("successfully created user", {capfirst: true}),
});
await queryClient.invalidateQueries({ queryKey: ['users'] }) await queryClient.invalidateQueries({ queryKey: ['users'] })
},
onError: (error: any) => {
notifications.show({
title: t("error", {capfirst: true}),
message: error?.message || t(`error editing user`, {capfirst: true}),
color: "red"
});
} }
}); });
} }
@@ -372,7 +507,18 @@ export function deleteUser() {
}).then((res) => res.json()); }).then((res) => res.json());
}, },
onSuccess: async () => { onSuccess: async () => {
notifications.show({
title: t("success", {capfirst: true}),
message: t("successfully deleted user", {capfirst: true}),
});
await queryClient.invalidateQueries({ queryKey: ['users'] }) await queryClient.invalidateQueries({ queryKey: ['users'] })
},
onError: (error: any) => {
notifications.show({
title: t("error", {capfirst: true}),
message: error?.message || t(`error deleting user`, {capfirst: true}),
color: "red"
});
} }
}); });
} }
@@ -391,7 +537,44 @@ export function editUser() {
}).then((res) => res.json()); }).then((res) => res.json());
}, },
onSuccess: async () => { onSuccess: async () => {
notifications.show({
title: t("success", {capfirst: true}),
message: t("successfully edited user", {capfirst: true}),
});
await queryClient.invalidateQueries({ queryKey: ['users'] }) await queryClient.invalidateQueries({ queryKey: ['users'] })
},
onError: (error: any) => {
notifications.show({
title: t("error", {capfirst: true}),
message: error?.message || t(`error editing user`, {capfirst: true}),
color: "red"
});
} }
}); });
} }
export function createContract() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (newContract: ContractCreate) => {
return fetch(`${Config.backend_uri}/contracts`, {
method: 'POST',
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(newContract),
}).then(async (res) => await res.blob());
},
onSuccess: async (pdfBlob) => {
const url = URL.createObjectURL(pdfBlob);
const link = document.createElement("a");
link.href = url;
link.download = `contract.pdf`;
link.click();
URL.revokeObjectURL(url);
await queryClient.invalidateQueries({ queryKey: ["contracts"] });
}
});
}

View File

@@ -0,0 +1,4 @@
export type ContractCreate = {
form_id: number;
contract: Record<string, string | number | null>;
}

View File

@@ -1,5 +1,5 @@
import type { Productor } from "@/services/resources/productors"; import type { Productor } from "@/services/resources/productors";
import type { Shipment, ShipmentInputs } from "@/services/resources/shipments"; import type { Shipment } from "@/services/resources/shipments";
import type { User } from "@/services/resources/users"; import type { User } from "@/services/resources/users";
export type Form = { export type Form = {
@@ -11,6 +11,7 @@ export type Form = {
productor: Productor; productor: Productor;
referer: User; referer: User;
shipments: Shipment[]; shipments: Shipment[];
minimum_shipment_value: number | null;
} }
export type FormCreate = { export type FormCreate = {
@@ -20,6 +21,7 @@ export type FormCreate = {
end: string; end: string;
productor_id: number; productor_id: number;
referer_id: number; referer_id: number;
minimum_shipment_value: number | null;
} }
export type FormEdit = { export type FormEdit = {
@@ -29,6 +31,7 @@ export type FormEdit = {
end?: string | null; end?: string | null;
productor_id?: number | null; productor_id?: number | null;
referer_id?: number | null; referer_id?: number | null;
minimum_shipment_value: number | null;
} }
export type FormEditPayload = { export type FormEditPayload = {
@@ -43,4 +46,5 @@ export type FormInputs = {
end: string | null; end: string | null;
productor_id: string; productor_id: string;
referer_id: string; referer_id: string;
minimum_shipment_value: number | string | null;
} }

View File

@@ -1,10 +1,21 @@
import { t } from "@/config/i18n";
import type { Product } from "./products"; import type { Product } from "./products";
export const PaymentMethods = [
{value: "cheque", label: t("cheque", {capfirst: true})},
{value: "transfer", label: t("transfer", {capfirst: true})},
]
export type PaymentMethod = {
name: string;
details: string;
}
export type Productor = { export type Productor = {
id: number; id: number;
name: string; name: string;
address: string; address: string;
payment: string; payment_methods: PaymentMethod[];
type: string; type: string;
products: Product[] products: Product[]
} }
@@ -12,22 +23,22 @@ export type Productor = {
export type ProductorCreate = { export type ProductorCreate = {
name: string; name: string;
address: string; address: string;
payment: string; payment_methods: PaymentMethod[];
type: string; type: string;
} }
export type ProductorEdit = { export type ProductorEdit = {
name: string | null; name: string | null;
address: string | null; address: string | null;
payment: string | null; payment_methods: PaymentMethod[];
type: string | null; type: string | null;
} }
export type ProductorInputs = { export type ProductorInputs = {
name: string; name: string;
address: string; address: string;
payment: string;
type: string; type: string;
payment_methods: PaymentMethod[];
} }
export type ProductorEditPayload = { export type ProductorEditPayload = {

View File

@@ -61,9 +61,9 @@ export type ProductInputs = {
productor_id: string | null; productor_id: string | null;
name: string; name: string;
unit: string | null; unit: string | null;
price: number | null; price: number | string | null;
price_kg: number | null; price_kg: number | string | null;
quantity: number | null; quantity: number | string | null;
quantity_unit: string | null; quantity_unit: string | null;
type: string | null; type: string | null;
} }
@@ -91,9 +91,9 @@ export function productCreateFromProductInputs(productInput: ProductInputs): Pro
productor_id: Number(productInput.productor_id)!, productor_id: Number(productInput.productor_id)!,
name: productInput.name, name: productInput.name,
unit: productInput.unit!, unit: productInput.unit!,
price: productInput.price!, price: productInput.price === "" || !productInput.price ? null : Number(productInput.price),
price_kg: productInput.price_kg, price_kg: productInput.price_kg === "" || !productInput.price_kg ? null : Number(productInput.price_kg),
quantity: productInput.quantity, quantity: productInput.quantity === "" || !productInput.quantity ? null : Number(productInput.quantity),
quantity_unit: productInput.quantity_unit, quantity_unit: productInput.quantity_unit,
type: productInput.type!, type: productInput.type!,
} }