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

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

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 {
display: flex;
justify-content: space-between;
justify-self: left;
width: 50%;
padding: 1rem;
background-color: var(--mantine-color-blue-4);
}
a {
gap: 1em;
.navLink {
color: #fff;
font-weight: bold;
margin-right: 1rem;
text-decoration: none;
}
}

View File

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

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

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

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

View File

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

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

View File

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

View File

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