diff --git a/README.md b/README.md index c6dcd4c..e8e84f2 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,6 @@ - Extract recap - Extract all contracts - store total price -- store pdf file - -- fix price display (reccurent in contract template) ## Wording @@ -42,5 +39,3 @@ ## Update contract after register ## Default filter - -## token expired refresh token diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 23ca4f3..1e833d1 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -29,6 +29,7 @@ dependencies = [ "cryptography", "requests", "weasyprint", + "odfdo" ] [project.urls] diff --git a/backend/src/auth/auth.py b/backend/src/auth/auth.py index 9de40ea..6ac89ac 100644 --- a/backend/src/auth/auth.py +++ b/backend/src/auth/auth.py @@ -13,7 +13,7 @@ from src.models import UserCreate, User, UserPublic import secrets import requests - +from urllib.parse import urlencode import src.messages as messages router = APIRouter(prefix='/auth') @@ -23,24 +23,13 @@ security = HTTPBearer() @router.get('/logout') def logout( - id_token: Annotated[str | None, Cookie()] = None, refresh_token: Annotated[str | None, Cookie()] = None, ): - if refresh_token: - print("invalidate tokens") - requests.post(LOGOUT_URL, data={ - "client_id": settings.keycloak_client_id, - "client_secret": settings.keycloak_client_secret, - "refresh_token": refresh_token - }) - - if id_token: - print("redirect keycloak") - response = RedirectResponse(f'{LOGOUT_URL}?post_logout_redirect_uri={settings.origins}&id_token_hint={id_token}') - else: - response = RedirectResponse(settings.origins) - - print("clear cookies") + params = { + 'client_id': settings.keycloak_client_id, + 'post_logout_redirect_uri': settings.origins, + } + response = RedirectResponse(f'{LOGOUT_URL}?{urlencode(params)}') response.delete_cookie( key='access_token', path='/', @@ -59,6 +48,12 @@ def logout( secure=not settings.debug, samesite='lax', ) + # if refresh_token: + # requests.post(LOGOUT_URL, data={ + # 'client_id': settings.keycloak_client_id, + # 'client_secret': settings.keycloak_client_secret, + # 'refresh_token': refresh_token + # }) return response @@ -107,9 +102,9 @@ def callback(code: str, session: Session = Depends(get_session)): 'refresh_token': token_data['refresh_token'], } res = requests.post(LOGOUT_URL, data=data) - resp = RedirectResponse(settings.origins) + resp = RedirectResponse(f'{settings.origins}?userNotAllowed=true') return resp - resource_access.get(settings.keycloak_client_id) + roles = resource_access.get(settings.keycloak_client_id) if not roles: data = { 'client_id': settings.keycloak_client_id, @@ -117,7 +112,7 @@ def callback(code: str, session: Session = Depends(get_session)): 'refresh_token': token_data['refresh_token'], } res = requests.post(LOGOUT_URL, data=data) - resp = RedirectResponse(settings.origins) + resp = RedirectResponse(f'{settings.origins}?userNotAllowed=true') return resp user_create = UserCreate( diff --git a/backend/src/contracts/contracts.py b/backend/src/contracts/contracts.py index 0ab3b03..62f2ea2 100644 --- a/backend/src/contracts/contracts.py +++ b/backend/src/contracts/contracts.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import StreamingResponse from src.database import get_session from sqlmodel import Session -from src.contracts.generate_contract import generate_html_contract +from src.contracts.generate_contract import generate_html_contract, generate_recap from src.auth.auth import get_current_user import src.models as models import src.messages as messages @@ -79,7 +79,8 @@ async def create_contract( occasionals = create_occasional_dict(occasional_contract_products) recurrents = list(map(lambda x: {"product": x.product, "quantity": x.quantity}, filter(lambda contract_product: contract_product.product.type == models.ProductType.RECCURENT, new_contract.products))) recurrent_price = compute_recurrent_prices(recurrents, len(new_contract.form.shipments)) - total_price = '{:10.2f}'.format(recurrent_price + compute_occasional_prices(occasionals)) + price = recurrent_price + compute_occasional_prices(occasionals) + total_price = '{:10.2f}'.format(price) cheques = list(map(lambda x: {"name": x.name, "value": x.value}, new_contract.cheques)) # TODO: send contract to referer @@ -94,7 +95,7 @@ async def create_contract( ) pdf_file = io.BytesIO(pdf_bytes) contract_id = f'{new_contract.firstname}_{new_contract.lastname}_{new_contract.form.productor.type}_{new_contract.form.season}' - service.add_contract_file(session, new_contract.id, pdf_bytes) + service.add_contract_file(session, new_contract.id, pdf_bytes, price) except Exception as e: print(e) raise HTTPException(status_code=400, detail=messages.pdferror) @@ -112,7 +113,7 @@ def get_contracts( session: Session = Depends(get_session), user: models.User = Depends(get_current_user) ): - return service.get_all(session, forms) + return service.get_all(session, user, forms) @router.get('/{id}/file') def get_contract_file( @@ -120,6 +121,8 @@ def get_contract_file( session: Session = Depends(get_session), user: models.User = Depends(get_current_user) ): + if not service.is_allowed(session, user, id): + raise HTTPException(status_code=403, detail=messages.notallowed) contract = service.get_one(session, id) if contract is None: raise HTTPException(status_code=404, detail=messages.notfound) @@ -138,8 +141,10 @@ def get_contract_files( session: Session = Depends(get_session), user: models.User = Depends(get_current_user) ): + if not form_service.is_allowed(session, user, form_id): + raise HTTPException(status_code=403, detail=messages.notallowed) form = form_service.get_one(session, form_id=form_id) - contracts = service.get_all(session, [form.name]) + contracts = service.get_all(session, user, forms=[form.name]) zipped_contracts = io.BytesIO() with zipfile.ZipFile(zipped_contracts, "a", zipfile.ZIP_DEFLATED, False) as zip_file: for contract in contracts: @@ -155,9 +160,29 @@ def get_contract_files( } ) +@router.get('/{form_id}/recap') +def get_contract_recap( + form_id: int, + session: Session = Depends(get_session), + user: models.User = Depends(get_current_user) +): + if not form_service.is_allowed(session, user, form_id): + raise HTTPException(status_code=403, detail=messages.notallowed) + form = form_service.get_one(session, form_id=form_id) + contracts = service.get_all(session, user, forms=[form.name]) + + return StreamingResponse( + io.BytesIO(generate_recap(contracts, form)), + media_type='application/zip', + headers={ + 'Content-Disposition': f'attachment; filename=filename.ods' + } + ) @router.get('/{id}', response_model=models.ContractPublic) def get_contract(id: int, session: Session = Depends(get_session), user: models.User = Depends(get_current_user)): + if not service.is_allowed(session, user, id): + raise HTTPException(status_code=403, detail=messages.notallowed) result = service.get_one(session, id) if result is None: raise HTTPException(status_code=404, detail=messages.notfound) @@ -165,6 +190,8 @@ def get_contract(id: int, session: Session = Depends(get_session), user: models. @router.delete('/{id}', response_model=models.ContractPublic) def delete_contract(id: int, session: Session = Depends(get_session), user: models.User = Depends(get_current_user)): + if not service.is_allowed(session, user, id): + raise HTTPException(status_code=403, detail=messages.notallowed) result = service.delete_one(session, id) if result is None: raise HTTPException(status_code=404, detail=messages.notfound) diff --git a/backend/src/contracts/generate_contract.py b/backend/src/contracts/generate_contract.py index a33905e..9e27046 100644 --- a/backend/src/contracts/generate_contract.py +++ b/backend/src/contracts/generate_contract.py @@ -3,6 +3,7 @@ import jinja2 import src.models as models import html from weasyprint import HTML +import io def generate_html_contract( contract: models.Contract, @@ -57,4 +58,25 @@ def generate_html_contract( return HTML( string=output_text, base_url=template_dir - ).write_pdf() \ No newline at end of file + ).write_pdf() + +from odfdo import Document, Table, Row, Cell + +def generate_recap( + contracts: list[models.Contract], + form: models.Form, +): + data = [ + ["nom", "email"], + ] + doc = Document("spreadsheet") + sheet = Table(name="Recap") + sheet.set_values(data) + + doc.body.append(sheet) + + buffer = io.BytesIO() + doc.save(buffer) + + return buffer.getvalue() + diff --git a/backend/src/contracts/service.py b/backend/src/contracts/service.py index b6d039d..835cc6a 100644 --- a/backend/src/contracts/service.py +++ b/backend/src/contracts/service.py @@ -3,14 +3,19 @@ import src.models as models def get_all( session: Session, + user: models.User, forms: list[str] = [], - form_id: int | None = None, + form_id: int | None = None, ) -> list[models.ContractPublic]: - statement = select(models.Contract) - if form_id: - statement = statement.join(models.Form).where(models.Form.id == form_id) + statement = select(models.Contract)\ + .join(models.Form, models.Contract.form_id == models.Form.id)\ + .join(models.Productor, models.Form.productor_id == models.Productor.id)\ + .where(models.Productor.type.in_([r.name for r in user.roles]))\ + .distinct() if len(forms) > 0: - statement = statement.join(models.Form).where(models.Form.name.in_(forms)) + statement = statement.where(models.Form.name.in_(forms)) + if form_id: + statement = statement.where(models.Form.id == form_id) return session.exec(statement.order_by(models.Contract.id)).all() def get_one(session: Session, contract_id: int) -> models.ContractPublic: @@ -42,11 +47,11 @@ def create_one(session: Session, contract: models.ContractCreate) -> models.Cont session.refresh(new_contract) return new_contract -def add_contract_file(session: Session, id: int, file: bytes): +def add_contract_file(session: Session, id: int, file: bytes, price: float): statement = select(models.Contract).where(models.Contract.id == id) result = session.exec(statement) contract = result.first() - + contract.total_price = price contract.file = file session.add(contract) session.commit() @@ -77,3 +82,12 @@ def delete_one(session: Session, id: int) -> models.ContractPublic: session.delete(contract) session.commit() return result + +def is_allowed(session: Session, user: models.User, id: int) -> bool: + statement = select(models.Contract)\ + .join(models.Form, models.Contract.form_id == models.Form.id)\ + .join(models.Productor, models.Form.productor_id == models.Productor.id)\ + .where(models.Contract.id == id)\ + .where(models.Productor.type.in_([r.name for r in user.roles]))\ + .distinct() + return len(session.exec(statement).all()) > 0 \ No newline at end of file diff --git a/backend/src/forms/forms.py b/backend/src/forms/forms.py index e68d61c..03c65da 100644 --- a/backend/src/forms/forms.py +++ b/backend/src/forms/forms.py @@ -12,9 +12,10 @@ router = APIRouter(prefix='/forms') async def get_forms( seasons: list[str] = Query([]), productors: list[str] = Query([]), + current_season: bool = False, session: Session = Depends(get_session) ): - return service.get_all(session, seasons, productors) + return service.get_all(session, seasons, productors, current_season) @router.get('/{id}', response_model=models.FormPublic) async def get_form(id: int, session: Session = Depends(get_session)): diff --git a/backend/src/forms/service.py b/backend/src/forms/service.py index 7465abd..5156776 100644 --- a/backend/src/forms/service.py +++ b/backend/src/forms/service.py @@ -1,16 +1,35 @@ from sqlmodel import Session, select import src.models as models +from sqlalchemy import func def get_all( session: Session, seasons: list[str], - productors: list[str] + productors: list[str], + current_season: bool, ) -> list[models.FormPublic]: statement = select(models.Form) if len(seasons) > 0: statement = statement.where(models.Form.season.in_(seasons)) if len(productors) > 0: statement = statement.join(models.Productor).where(models.Productor.name.in_(productors)) + if current_season: + subquery = ( + select( + models.Productor.type, + func.max(models.Form.start).label("max_start") + ) + .join(models.Form)\ + .group_by(models.Productor.type)\ + .subquery() + ) + statement = select(models.Form)\ + .join(models.Productor)\ + .join(subquery, + (models.Productor.type == subquery.c.type) & + (models.Form.start == subquery.c.max_start) + ) + return session.exec(statement.order_by(models.Form.name)).all() return session.exec(statement.order_by(models.Form.name)).all() def get_one(session: Session, form_id: int) -> models.FormPublic: @@ -48,3 +67,11 @@ def delete_one(session: Session, id: int) -> models.FormPublic: session.delete(form) session.commit() return result + +def is_allowed(session: Session, user: models.User, id: int) -> bool: + statement = select(models.Form)\ + .join(models.Productor, models.Form.productor_id == models.Productor.id)\ + .where(models.Form.id == id)\ + .where(models.Productor.type.in_([r.name for r in user.roles]))\ + .distinct() + return len(session.exec(statement).all()) > 0 \ No newline at end of file diff --git a/backend/src/messages.py b/backend/src/messages.py index af1c12d..84b02d5 100644 --- a/backend/src/messages.py +++ b/backend/src/messages.py @@ -6,4 +6,5 @@ notauthenticated = "Not authenticated" usernotfound = "User not found" userloggedout = "User logged out" failtogettoken = "Failed to get token" -unauthorized = "Unauthorized" \ No newline at end of file +unauthorized = "Unauthorized" +notallowed = "Not Allowed" \ No newline at end of file diff --git a/backend/src/models.py b/backend/src/models.py index 34c288c..d5e8f6a 100644 --- a/backend/src/models.py +++ b/backend/src/models.py @@ -222,6 +222,7 @@ class Contract(ContractBase, table=True): cascade_delete=True ) file: bytes = Field(sa_column=Column(LargeBinary)) + total_price: float | None class ContractCreate(ContractBase): products: list["ContractProductCreate"] = [] @@ -235,6 +236,7 @@ class ContractPublic(ContractBase): id: int products: list["ContractProduct"] = [] form: Form + total_price: float | None # file: bytes class ContractProductBase(SQLModel): diff --git a/backend/src/users/service.py b/backend/src/users/service.py index d2f8c2d..cf72f66 100644 --- a/backend/src/users/service.py +++ b/backend/src/users/service.py @@ -16,7 +16,7 @@ def get_all( def get_one(session: Session, user_id: int) -> models.UserPublic: return session.get(models.User, user_id) -def get_or_create_roles(session: Session, role_names) -> list[models.ContractType]: +def get_or_create_roles(session: Session, role_names: list[str]) -> list[models.ContractType]: statement = select(models.ContractType).where(models.ContractType.name.in_(role_names)) existing = session.exec(statement).all() existing_roles = {role.name for role in existing} @@ -37,6 +37,9 @@ def get_or_create_user(session: Session, user_create: models.UserCreate): statement = select(models.User).where(models.User.email == user_create.email) user = session.exec(statement).first() if user: + user_role_names = [r.name for r in user.roles] + if user_role_names != user_create.role_names or user.name != user_create.name: + user = update_one(session, user.id, user_create) return user user = create_one(session, user_create) return user @@ -46,7 +49,6 @@ def get_roles(session: Session): return session.exec(statement.order_by(models.ContractType.name)).all() def create_one(session: Session, user: models.UserCreate) -> models.UserPublic: - print("USER CREATE", user) new_user = models.User( name=user.name, email=user.email @@ -60,15 +62,20 @@ def create_one(session: Session, user: models.UserCreate) -> models.UserPublic: session.refresh(new_user) return new_user -def update_one(session: Session, id: int, user: models.UserUpdate) -> models.UserPublic: +def update_one(session: Session, id: int, user: models.UserCreate) -> models.UserPublic: statement = select(models.User).where(models.User.id == id) result = session.exec(statement) new_user = result.first() if not new_user: return None - user_updates = user.model_dump(exclude_unset=True) + + user_updates = user.model_dump(exclude="role_names") for key, value in user_updates.items(): setattr(new_user, key, value) + + roles = get_or_create_roles(session, user.role_names) + new_user.roles = roles + session.add(new_user) session.commit() session.refresh(new_user) @@ -83,4 +90,4 @@ def delete_one(session: Session, id: int) -> models.UserPublic: result = models.UserPublic.model_validate(user) session.delete(user) session.commit() - return result + return result \ No newline at end of file diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 0de9d82..e7370f7 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -29,7 +29,7 @@ "edit form": "edit contract form", "form name": "contract form name", "contract season": "contract season", - "contract season recommandation": "recommendation: - (Example: Winter-2025)", + "contract season recommandation": "recommendation: - (Example: Winter-2025), if a form is already created, reuse it's season name.", "start date": "start date", "end date": "end date", "nothing found": "nothing to display", @@ -58,6 +58,7 @@ "quantity unit": "quantity unit", "unit": "sales unit", "price": "price", + "total price": "total price", "create product": "create product", "informations": "information", "remove product": "remove product", @@ -72,6 +73,13 @@ "shipment products is necessary only for occasional products (if all products are recurrent leave empty)": "shipment products configuration is only necessary for occasional products (leave empty if all products are recurrent).", "recurrent product is for all shipments, occasional product is for a specific shipment (see shipment form)": "recurrent products are for all shipments, occasional products are 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 does not apply to your contract.", + "export contracts": "export contracts", + "download recap": "download recap", + "to export contracts submissions before sending to the productor go to the contracts section": "to export contracts submissions before sending to the productor go to the contracts section.", + "in this page you can view all contracts submissions, you can remove duplicates submission or download a specific contract": "in this page you can view all contracts submissions, you can remove duplicates submission or download a specific contract", + "you can download all contracts for your form using the export all": "you can download all contracts for your form using the export all", + "in the same corner you can download a recap by clicking on the button": "in the same corner you can download a recap by clicking on the", + "once all contracts downloaded, you can delete the form (to avoid new submissions) and hide it from the home page": "once all contracts downloaded, you can delete the form (to avoid new submissions) and hide it from the home page", "contracts": "contracts", "minimum price for this shipment should be at least": "minimum price for this shipment should be at least", "there is": "there is", @@ -130,7 +138,7 @@ "to add a use the": "to add {{section}} use the button", "to edit a use the": "to edit {{section}} use the button", "to delete a use the": "to delete {{section}} use the button", - "button in top left of the page": "at the top left of the {{section}} page.", + "button in top right of the page": "at the top right of the {{section}} page.", "button in front of the line you want to edit": "in front of the line you want to edit (in the actions column).", "button in front of the line you want to delete": "in front of the line you want to delete (in the actions column).", "glossary": "glossary", @@ -188,6 +196,10 @@ "enter payment method": "select your payment method", "number of cheques between 1 and 3 cheques also enter your cheques identifiers, value is calculated automatically": "number of cheques between 1 and 3. Also enter cheque identifiers.", "payment method": "payment method", + "your session has expired please log in again": "your session has expired please log in again", + "session expired": "session expired", + "user not allowed": "user not allowed", + "your keycloak user has no roles, please contact your administrator": "your keycloak user has no roles, please contact your administrator", "choose payment method": "choose your payment method (you do not need to pay now).", "the product unit will be assigned to the quantity requested in the form": "the product unit defines the unit used in the contract form.", "all theses informations are for contract generation": "all this information is required for contract generation." diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 80e376a..0da6741 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -29,7 +29,7 @@ "edit form": "modifier le formulaire de contrat", "form name": "nom du formulaire de contrat", "contract season": "saison du contrat", - "contract season recommandation": "recommandation : - (Exemple: Hiver-2025)", + "contract season recommandation": "recommandation : - (Exemple: Hiver-2025), si un formulaire est déjà créé pour la saison, reprenez son nom de saison si possible.", "start date": "date de début", "end date": "date de fin", "nothing found": "rien à afficher", @@ -58,6 +58,7 @@ "quantity unit": "unité de quantité", "unit": "unité de vente", "price": "prix", + "total price": "prix total", "create product": "créer le produit", "informations": "informations", "remove product": "supprimer le produit", @@ -129,7 +130,7 @@ "to add a use the": "pour ajouter {{section}} utilisez le bouton", "to edit a use the": "pour éditer {{section}} utilisez le bouton", "to delete a use the": "pour supprimer {{section}} utilisez le bouton", - "button in top left of the page": "en haut à gauche de la page {{section}}.", + "button in top right of the page": "en haut à droite de la page {{section}}.", "button in front of the line you want to edit": "en face de la ligne que vous souhaitez éditer. (dans la colonne actions).", "button in front of the line you want to delete": "en face de la ligne que vous souhaitez supprimer. (dans la colonne actions).", "glossary": "glossaire", @@ -137,8 +138,15 @@ "start to create a productor in the productors section": "commencez par créer un(e) producteur·trice dans la section \"Producteur·trices\".", "add all products linked to this productor in the products section": "ajoutez vos produits liés au/à la producteur·trice dans la section \"Produits\".", "create your contract form, it will create a form in the home page (accessible to users)": "créez votre formulaire de contrat dans la section \"Formulaire de contrat\". Ajouter une entrée dans cette section ajoutera un formulaire dans la page d'accueil.", + "export contracts": "Télécharger les contrats", + "download recap": "Télécharger le récapitulatif", + "in this page you can view all contracts submissions, you can remove duplicates submission or download a specific contract": "dans cette page vous pouvez voir tous les contrats, vous pouvez supprimer un contrat en doublon, ou télécharger uniquement un contrat.", "create shipments for your contract form": "créez les livraisons pour votre contrat", "creation order": "ordre de création", + "in the same corner you can download a recap by clicking on the button": "au même endroit vous pouvez exporter votre récapitulatif (format odt) à vérifier et transmettre au producteur en cliquant sur le bouton", + "to export contracts submissions before sending to the productor go to the contracts section": "pour exporter les contrats avant de les envoyer aux producteurs allez dans la section \"contrats\".", + "once all contracts downloaded, you can delete the form (to avoid new submissions) and hide it from the home page": "une fois tous les contrats récupérés vous pouvez supprimer le formulaire (pour éviter les nouvelles demandes de contrat et pour cacher le formulaire de la page principale).", + "you can download all contracts for your form using the export all": "vous pouvez télécharger tous les contrats de votre formulaire en utilisant le bouton", "dashboard is for referers only, with this dashboard you can create productors, products, forms and shipments": "le tableau de bord est visible uniquement pour les référents, vous pouvez créer votre producteur, vos produits, vos formulaires de contrat et vos livraisons.", "is defined by": "est defini par", "a product type define the way it will be organized on the final contract form (showed to users) it can be reccurent or occassional. Recurrent products will be set for all shipments if selected by user, Occasional products can be choosen for each shipments": "un type de produit définit la manière dont un produit va être présenté aux amapiens dans le formulaire de contrat. Il peut être récurrent ou occasionnel. Un produit récurrent si selectionné sera compté pour toutes les livraisons. Un produit occasionnel sera facultatif pour chaques livraison (l'amapien devra selectionner la quantité voulue pour chaque livraisons).", @@ -188,6 +196,10 @@ "enter payment method": "sélectionnez votre méthode de paiement", "number of cheques between 1 and 3 cheques also enter your cheques identifiers, value is calculated automatically": "nombre de chèques entre 1 et 3, entrez également les identifiants des chèques utilisés.", "payment method": "méthode de paiement", + "your session has expired please log in again": "votre session a expiré veuillez vous reconnecter.", + "session expired": "session expirée", + "user not allowed": "utilisateur non authorisé", + "your keycloak user has no roles, please contact your administrator": "votre utilisateur keycloak n'a pas de roles configurés, contactez votre administrateur.", "choose payment method": "choisissez votre méthode de paiement (vous n'avez pas à payer tout de suite, uniquement renseigner comment vous souhaitez régler votre commande).", "the product unit will be assigned to the quantity requested in the form": "l'unité de vente du produit définit l'unité associée à la quantité demandée dans le formulaire des amapiens.", "all theses informations are for contract generation": "ces informations sont nécessaires pour la génération de contrat." diff --git a/frontend/src/components/Contracts/Row/index.tsx b/frontend/src/components/Contracts/Row/index.tsx index 9e9ecf1..aa47bfe 100644 --- a/frontend/src/components/Contracts/Row/index.tsx +++ b/frontend/src/components/Contracts/Row/index.tsx @@ -28,6 +28,14 @@ export default function ContractRow({ contract }: ContractRowProps) { {contract.cheque_quantity > 0 && contract.cheque_quantity} {contract.payment_method} + + { + `${Intl.NumberFormat("fr-FR", { + style: "currency", + currency: "EUR", + }).format(contract.total_price)}` + } + {t("logout", { capfirst: true })} diff --git a/frontend/src/components/PaymentMethods/Cheque/index.tsx b/frontend/src/components/PaymentMethods/Cheque/index.tsx index 9495889..0bbb8dc 100644 --- a/frontend/src/components/PaymentMethods/Cheque/index.tsx +++ b/frontend/src/components/PaymentMethods/Cheque/index.tsx @@ -1,6 +1,6 @@ import { t } from "@/config/i18n"; import type { ContractInputs } from "@/services/resources/contracts"; -import { Group, NumberInput, Stack, TextInput, Title } from "@mantine/core"; +import { Group, NumberInput, Stack, Text, TextInput, Title } from "@mantine/core"; import type { UseFormReturnType } from "@mantine/form"; import { useEffect } from "react"; @@ -56,13 +56,18 @@ export function ContractCheque({ inputForm, price, chequeOrder }: ContractCheque {...inputForm.getInputProps(`cheque_quantity`)} /> - {inputForm.values.cheques.map((_cheque, index) => ( + {inputForm.values.cheques.map((cheque, index) => ( + error={ + cheque.name == "" ? + {inputForm?.errors.cheques} : + null + } + /> { return filters.getAll("types"); }, [filters]); - return ( !value ? `${t("type", { capfirst: true })} ${t("is required")}` : null, + payment_methods: (value) => + value.length === 0 || value.some( + (payment) => + payment.name === "cheque" && + payment.details === "") ? + `${t("a payment method", { capfirst: true })} ${t("is required")}` : null, }, }); @@ -89,6 +95,7 @@ export function ProductorModal({ clearable searchable value={form.values.payment_methods.map((p) => p.name)} + error={form.errors.payment_methods} onChange={(names) => { form.setFieldValue( "payment_methods", diff --git a/frontend/src/pages/Contract/index.tsx b/frontend/src/pages/Contract/index.tsx index 8cee44a..8bec61e 100644 --- a/frontend/src/pages/Contract/index.tsx +++ b/frontend/src/pages/Contract/index.tsx @@ -35,7 +35,7 @@ export function Contract() { email: "", phone: "", payment_method: "", - cheque_quantity: 1, + cheque_quantity: 0, cheques: [], products: {}, }, @@ -50,6 +50,8 @@ export function Contract() { !value ? `${t("a phone", { capfirst: true })} ${t("is required")}` : null, payment_method: (value) => !value ? `${t("a payment method", { capfirst: true })} ${t("is required")}` : null, + cheques: (value, values) => + values.payment_method === "cheque" && value.some((val) => val.name == "") ? `${t("cheque id", {capfirst: true})} ${t("is required")}` : null, }, }); diff --git a/frontend/src/pages/Contracts/index.tsx b/frontend/src/pages/Contracts/index.tsx index 1c67ede..0c066f6 100644 --- a/frontend/src/pages/Contracts/index.tsx +++ b/frontend/src/pages/Contracts/index.tsx @@ -1,7 +1,7 @@ import { ActionIcon, Group, Loader, ScrollArea, Stack, Table, Title, Tooltip } from "@mantine/core"; import { t } from "@/config/i18n"; -import { useGetAllContractFile, useGetContracts } from "@/services/api"; -import { IconDownload } from "@tabler/icons-react"; +import { useGetAllContractFile, useGetContracts, useGetRecap } from "@/services/api"; +import { IconDownload, IconTableExport } from "@tabler/icons-react"; import ContractRow from "@/components/Contracts/Row"; import { useLocation, useNavigate, useSearchParams } from "react-router"; import { ContractModal } from "@/components/Contracts/Modal"; @@ -14,7 +14,9 @@ export default function Contracts() { const location = useLocation(); const navigate = useNavigate(); const getAllContractFilesMutation = useGetAllContractFile(); + const getRecapMutation = useGetRecap(); const isdownload = location.pathname.includes("/download"); + const isrecap = location.pathname.includes("/export"); const closeModal = useCallback(() => { navigate(`/dashboard/contracts${searchParams ? `?${searchParams.toString()}` : ""}`); @@ -52,6 +54,13 @@ export default function Contracts() { [getAllContractFilesMutation], ); + const handleDownloadRecap = useCallback( + async (id: number) => { + await getRecapMutation.mutateAsync(id); + }, + [getAllContractFilesMutation], + ) + if (!contracts || isPending) return ( @@ -63,23 +72,45 @@ export default function Contracts() { {t("all contracts", { capfirst: true })} - - { - e.stopPropagation(); - navigate( - `/dashboard/contracts/download${searchParams ? `?${searchParams.toString()}` : ""}`, - ); - }} + + + { + e.stopPropagation(); + navigate( + `/dashboard/contracts/download${searchParams ? `?${searchParams.toString()}` : ""}`, + ); + }} + > + + + + - - - + { + e.stopPropagation(); + navigate( + `/dashboard/contracts/export${searchParams ? `?${searchParams.toString()}` : ""}`, + ); + }} + > + + + + + + {t("name", { capfirst: true })} {t("email", { capfirst: true })} {t("payment method", { capfirst: true })} + {t("total price", { capfirst: true })} {t("actions", { capfirst: true })} diff --git a/frontend/src/pages/Dashboard/index.tsx b/frontend/src/pages/Dashboard/index.tsx index dbdd0af..d1db331 100644 --- a/frontend/src/pages/Dashboard/index.tsx +++ b/frontend/src/pages/Dashboard/index.tsx @@ -1,11 +1,12 @@ import { Tabs } from "@mantine/core"; import { t } from "@/config/i18n"; import { Link, Outlet, useLocation, useNavigate } from "react-router"; +import { useAuth } from "@/services/auth/AuthProvider"; export default function Dashboard() { const navigate = useNavigate(); const location = useLocation(); - + const {loggedUser} = useAuth(); return ( ()} value="forms">{t("forms", { capfirst: true })} ()} value="shipments">{t("shipments", { capfirst: true })} ()} value="contracts">{t("contracts", { capfirst: true })} - ()} value="users">{t("users", { capfirst: true })} + { + loggedUser?.user?.roles && loggedUser?.user?.roles?.length > 5 ? + ()} value="users">{t("users", { capfirst: true })} : + null + } diff --git a/frontend/src/pages/Help/index.tsx b/frontend/src/pages/Help/index.tsx index f475417..642c7ab 100644 --- a/frontend/src/pages/Help/index.tsx +++ b/frontend/src/pages/Help/index.tsx @@ -3,21 +3,20 @@ import { Accordion, ActionIcon, Blockquote, - Group, NumberInput, - Paper, Select, Stack, TableOfContents, Text, - TextInput, Title, } from "@mantine/core"; import { + IconDownload, IconEdit, IconInfoCircle, IconLink, IconPlus, + IconTableExport, IconTestPipe, IconX, } from "@tabler/icons-react"; @@ -77,7 +76,7 @@ export function Help() { {" "} - {t("button in top left of the page", { section: t("productors") })} + {t("button in top right of the page", { section: t("productors") })} {t("to edit a use the", { capfirst: true, section: t("a productor") })}{" "} @@ -127,7 +126,7 @@ export function Help() { {" "} - {t("button in top left of the page", { section: t("products") })} + {t("button in top right of the page", { section: t("products") })} {t("to edit a use the", { capfirst: true, section: t("a productor") })}{" "} @@ -178,7 +177,7 @@ export function Help() { {" "} - {t("button in top left of the page", { section: t("forms") })} + {t("button in top right of the page", { section: t("forms") })} {t("to edit a use the", { capfirst: true, section: t("a productor") })}{" "} @@ -225,7 +224,7 @@ export function Help() { {" "} - {t("button in top left of the page", { section: t("shipments") })} + {t("button in top right of the page", { section: t("shipments") })} {t("to edit a use the", { capfirst: true, section: t("a productor") })}{" "} @@ -243,6 +242,41 @@ export function Help() { + {t("export contracts", {capfirst: true})} + + + {t("to export contracts submissions before sending to the productor go to the contracts section", {capfirst: true})} + + + + + {t("in this page you can view all contracts submissions, you can remove duplicates submission or download a specific contract", {capfirst: true})} + + {t("you can download all contracts for your form using the export all", {capfirst: true})}{" "} + + + {" "} + {t("button in top right of the page", { section: t("contracts") })}{" "} + {t("in the same corner you can download a recap by clicking on the button", {capfirst: true})}{" "} + + + {" "} + + + + {t("once all contracts downloaded, you can delete the form (to avoid new submissions) and hide it from the home page", {capfirst: true})} + + {t("glossary", { capfirst: true })} diff --git a/frontend/src/pages/Home/index.tsx b/frontend/src/pages/Home/index.tsx index 57cc182..9a98b8f 100644 --- a/frontend/src/pages/Home/index.tsx +++ b/frontend/src/pages/Home/index.tsx @@ -1,21 +1,46 @@ -import { Flex, Text } from "@mantine/core"; +import { Flex, Stack, Text } from "@mantine/core"; import { useGetForms } from "@/services/api"; import { FormCard } from "@/components/Forms/Card"; import type { Form } from "@/services/resources/forms"; import { t } from "@/config/i18n"; +import { useSearchParams } from "react-router"; +import { useEffect } from "react"; +import { showNotification } from "@mantine/notifications"; export function Home() { - const { data: allForms } = useGetForms(); + const { data: allForms } = useGetForms(new URLSearchParams("?current_season=true")); + const [searchParams] = useSearchParams(); + + useEffect(() => { + if (searchParams.get("sessionExpired")) { + showNotification({ + title: t("session expired", {capfirst: true}), + message: t("your session has expired please log in again", {capfirst: true}), + color: "red", + autoClose: 5000, + }); + } + if (searchParams.get("userNotAllowed")) { + showNotification({ + title: t("user not allowed", {capfirst: true}), + message: t("your keycloak user has no roles, please contact your administrator", {capfirst: true}), + color: "red", + autoClose: 5000, + }); + } + }, [searchParams]) return ( - <Flex gap="md" wrap="wrap" justify="center"> - {allForms && allForms?.length > 0 ? ( - 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> + <Stack mt="lg"> + <Flex gap="md" wrap="wrap" justify="center"> + {allForms && allForms?.length > 0 ? ( + 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> + </Stack> ); } diff --git a/frontend/src/pages/Productors/index.tsx b/frontend/src/pages/Productors/index.tsx index c1a1813..19e8ef5 100644 --- a/frontend/src/pages/Productors/index.tsx +++ b/frontend/src/pages/Productors/index.tsx @@ -18,7 +18,9 @@ export default function Productors() { const [searchParams, setSearchParams] = useSearchParams(); const location = useLocation(); const navigate = useNavigate(); - + const { data: productors, isPending } = useGetProductors(searchParams); + + const { data: allProductors } = useGetProductors(); const isCreate = location.pathname === "/dashboard/productors/create"; const isEdit = location.pathname.includes("/edit"); @@ -29,15 +31,13 @@ export default function Productors() { return null; }, [location, isEdit]); - const closeModal = useCallback(() => { - navigate(`/dashboard/productors${searchParams ? `?${searchParams.toString()}` : ""}`); - }, [navigate, searchParams]); - - const { data: productors, isPending } = useGetProductors(searchParams); const { data: currentProductor } = useGetProductor(Number(editId), { enabled: !!editId, }); - const { data: allProductors } = useGetProductors(); + + const closeModal = useCallback(() => { + navigate(`/dashboard/productors${searchParams ? `?${searchParams.toString()}` : ""}`); + }, [navigate, searchParams]); const names = useMemo(() => { return allProductors diff --git a/frontend/src/pages/Products/index.tsx b/frontend/src/pages/Products/index.tsx index 13509f2..5d460a4 100644 --- a/frontend/src/pages/Products/index.tsx +++ b/frontend/src/pages/Products/index.tsx @@ -17,7 +17,6 @@ export default function Products() { const [searchParams, setSearchParams] = useSearchParams(); const location = useLocation(); const navigate = useNavigate(); - const isCreate = location.pathname === "/dashboard/products/create"; const isEdit = location.pathname.includes("/edit"); diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index b20762f..9bde393 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -39,6 +39,7 @@ export const router = createBrowserRouter([ { path: "products/:id/edit", Component: Products }, { path: "contracts", Component: Contracts }, { path: "contracts/download", Component: Contracts }, + { path: "contracts/export", Component: Contracts }, { path: "users", Component: Users }, { path: "users/create", Component: Users }, { path: "users/:id/edit", Component: Users }, diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 35ec175..3034c8e 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -38,7 +38,8 @@ export async function fetchWithAuth(input: RequestInfo, options?: RequestInit) { if (res.status === 401) { const refresh = await refreshToken(); if (refresh.status == 400 || refresh.status == 401) { - window.location.href = `${Config.backend_uri}/auth/logout`; + window.location.href = `/?sessionExpired=True`; + const error = new Error("Unauthorized"); error.cause = 401 throw error; @@ -49,6 +50,9 @@ export async function fetchWithAuth(input: RequestInfo, options?: RequestInit) { }); return newRes; } + if (res.status == 403) { + throw new Error(res.statusText); + } return res; } @@ -693,7 +697,33 @@ export function useGetContractFile() { disposition && disposition?.includes("filename=") ? disposition.split("filename=")[1].replace(/"/g, "") : `contract_${id}.pdf`; - console.log(disposition); + return { blob, filename }; + }, + onSuccess: ({ blob, filename }) => { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + link.click(); + URL.revokeObjectURL(url); + }, + }); +} + +export function useGetRecap() { + return useMutation({ + mutationFn: async (form_id: number) => { + const res = await fetchWithAuth(`${Config.backend_uri}/contracts/${form_id}/recap`, { + credentials: "include", + }).then((res) => res); + + if (!res.ok) throw new Error(); + const blob = await res.blob(); + const disposition = res.headers.get("Content-Disposition"); + const filename = + disposition && disposition?.includes("filename=") + ? disposition.split("filename=")[1].replace(/"/g, "") + : `contract_recap_${form_id}.odt`; return { blob, filename }; }, onSuccess: ({ blob, filename }) => { diff --git a/frontend/src/services/resources/contracts.ts b/frontend/src/services/resources/contracts.ts index 2a04e9f..1f5b4a3 100644 --- a/frontend/src/services/resources/contracts.ts +++ b/frontend/src/services/resources/contracts.ts @@ -14,6 +14,7 @@ export type Contract = { phone: string; payment_method: string; cheque_quantity: number; + total_price: number; }; export type ContractCreate = { diff --git a/frontend/src/services/resources/users.ts b/frontend/src/services/resources/users.ts index a092282..e03a72b 100644 --- a/frontend/src/services/resources/users.ts +++ b/frontend/src/services/resources/users.ts @@ -15,7 +15,7 @@ export type User = { name: string; email: string; products: Product[]; - roles: Role[]; + roles: string[]; }; export type UserInputs = {