refactor: add missing nutritional values on products

This commit is contained in:
Jérémy Munsch 2026-10-20 12:23:15 +02:00
parent 325c7a7fd6
commit 597e1c720a
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
14 changed files with 733 additions and 374 deletions

View file

@ -4,13 +4,14 @@ declare(strict_types=1);
namespace App\Casts;
use Domain\Models\ValueObjects\Products\NutritionalValue;
use Domain\Models\ValueObjects\Products\NutritionalValues;
use Domain\Models\ValueObjects\Products\NutritionnalUnit;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use InvalidArgumentException;
/** @implements CastsAttributes<NutritionalValues|null,NutritionalValues|null> */
/** @implements CastsAttributes<NutritionalValues|null, NutritionalValues|array<string, mixed>|null> */
class NutritionalValuesCast implements CastsAttributes
{
/** @param array<string, mixed> $attributes */
@ -23,43 +24,10 @@ class NutritionalValuesCast implements CastsAttributes
$data = is_string($value) ? json_decode($value, true) : $value;
if (! is_array($data)) {
throw new \UnexpectedValueException(
'Expected data, should have been an array'
);
return null;
}
$data = array_filter($data, fn ($key) => is_string($key), \ARRAY_FILTER_USE_KEY);
return new NutritionalValues(
refWeightG: $this->getFloatOrNull($data, 'nv_ref_weight_g'),
kj: $this->getFloatOrNull($data, 'nv_kj'),
kcal: $this->getFloatOrNull($data, 'nv_kcal'),
fatG: $this->getFloatOrNull($data, 'nv_fat_g'),
satFatG: $this->getFloatOrNull($data, 'nv_sat_fat_g'),
carbG: $this->getFloatOrNull($data, 'nv_carb_g'),
sugarG: $this->getFloatOrNull($data, 'nv_sugar_g'),
fiberG: $this->getFloatOrNull($data, 'nv_fiber_g'),
proteinG: $this->getFloatOrNull($data, 'nv_protein_g'),
saltG: $this->getFloatOrNull($data, 'nv_salt_g'),
servingSizeG: $this->getFloatOrNull($data, 'nv_serving_size_g'),
servingKj: $this->getFloatOrNull($data, 'nv_serving_kj'),
servingKcal: $this->getFloatOrNull($data, 'nv_serving_kcal'),
servingFatG: $this->getFloatOrNull($data, 'nv_serving_fat_g'),
servingSatFatG: $this->getFloatOrNull($data, 'nv_serving_sat_fat_g'),
servingCarbG: $this->getFloatOrNull($data, 'nv_serving_carb_g'),
servingSugarG: $this->getFloatOrNull($data, 'nv_serving_sugar_g'),
servingFiberG: $this->getFloatOrNull($data, 'nv_serving_fiber_g'),
servingProteinG: $this->getFloatOrNull($data, 'nv_serving_protein_g'),
servingSaltG: $this->getFloatOrNull($data, 'nv_serving_salt_g'),
dailyEnergy: $this->getFloatOrNull($data, 'nv_daily_energy'),
dailyFat: $this->getFloatOrNull($data, 'nv_daily_fat'),
dailySatFat: $this->getFloatOrNull($data, 'nv_daily_sat_fat'),
dailyCarb: $this->getFloatOrNull($data, 'nv_daily_carb'),
dailySugar: $this->getFloatOrNull($data, 'nv_daily_sugar'),
dailyFiber: $this->getFloatOrNull($data, 'nv_daily_fiber'),
dailyProtein: $this->getFloatOrNull($data, 'nv_daily_protein'),
dailySalt: $this->getFloatOrNull($data, 'nv_daily_salt'),
);
return $this->fromArray($data);
}
/**
@ -72,6 +40,14 @@ class NutritionalValuesCast implements CastsAttributes
return [$key => null];
}
if (is_string($value)) {
$value = json_decode($value, true);
}
if (is_array($value)) {
$value = $this->fromArray($value);
}
if (! $value instanceof NutritionalValues) {
throw new InvalidArgumentException('The given value is not a NutritionalValues instance.');
}
@ -80,24 +56,63 @@ class NutritionalValuesCast implements CastsAttributes
}
/**
* @param array<string, mixed> $attributes
*
* @throws \InvalidArgumentException
* @param array<array-key, mixed> $data
*/
private function getFloatOrNull(array $attributes, string $key): ?float
private function fromArray(array $data): ?NutritionalValues
{
$value = Arr::get($attributes, $key);
if (is_numeric($value)) {
return (float) $value;
$refWeightData = $data['refWeightG'] ?? null;
if (! is_array($refWeightData) || ! isset($refWeightData['value'], $refWeightData['unit'])) {
return null;
}
throw_if(
$value !== null && $value !== '',
InvalidArgumentException::class,
"Expected `{$key}` to be numeric or null"
);
$rawVal = $refWeightData['value'];
$rawUnit = $refWeightData['unit'];
return null;
if ((! is_string($rawVal) && ! is_numeric($rawVal)) || ! is_string($rawUnit)) {
return null;
}
$refWeightUnit = NutritionnalUnit::tryFrom($rawUnit);
if ($refWeightUnit === null) {
return null;
}
$parseItem = function (mixed $item): ?NutritionalValue {
if (! is_array($item) || ! isset($item['value'], $item['unit'])) {
return null;
}
$rawItemVal = $item['value'];
$rawItemUnit = $item['unit'];
if ((! is_string($rawItemVal) && ! is_numeric($rawItemVal)) || ! is_string($rawItemUnit)) {
return null;
}
$value = trim((string) $rawItemVal);
if ($value === '') {
return null;
}
$unit = NutritionnalUnit::tryFrom($rawItemUnit);
if ($unit === null) {
return null;
}
return new NutritionalValue($value, $unit);
};
return new NutritionalValues(
refWeightG: new NutritionalValue((string) $rawVal, $refWeightUnit),
kj: $parseItem($data['kj'] ?? null),
kcal: $parseItem($data['kcal'] ?? null),
fatG: $parseItem($data['fatG'] ?? null),
satFatG: $parseItem($data['satFatG'] ?? null),
carbG: $parseItem($data['carbG'] ?? null),
sugarG: $parseItem($data['sugarG'] ?? null),
fiberG: $parseItem($data['fiberG'] ?? null),
proteinG: $parseItem($data['proteinG'] ?? null),
saltG: $parseItem($data['saltG'] ?? null),
);
}
}

View file

@ -11,6 +11,7 @@ use App\Http\Resources\CommonResource;
use App\Models\Product;
use Domain\Models\ValueObjects\Products\BreedingMethod;
use Domain\Models\ValueObjects\Products\Business;
use Domain\Models\ValueObjects\Products\NutritionnalUnit;
use Domain\Models\ValueObjects\Products\ProductCategory;
use Domain\Models\ValueObjects\Products\ProductFormat;
use Domain\Models\ValueObjects\Products\Technology;
@ -85,12 +86,13 @@ class ProductController extends BackendController
$this->authorize('create', Product::class);
return $this->inertiaRender('back/Pages/Products/Create', [
'data' => CommonResource::make($product),
'enumProductCategory' => ProductCategory::toArray(),
'enumTechnology' => Technology::toArray(),
'enumBreedingMethod' => BreedingMethod::toArray(),
'enumBusiness' => Business::toArray(),
'enumProductFormat' => ProductFormat::toArray(),
'data' => CommonResource::make($product),
'enumProductCategory' => ProductCategory::toArray(),
'enumTechnology' => Technology::toArray(),
'enumBreedingMethod' => BreedingMethod::toArray(),
'enumBusiness' => Business::toArray(),
'enumProductFormat' => ProductFormat::toArray(),
'enumNutritionnalUnit' => NutritionnalUnit::toArray(),
]);
}
@ -123,12 +125,13 @@ class ProductController extends BackendController
$this->authorize('update', $product);
return $this->inertiaRender('back/Pages/Products/Edit', [
'data' => CommonResource::make($product),
'enumProductCategory' => ProductCategory::toArray(),
'enumTechnology' => Technology::toArray(),
'enumBreedingMethod' => BreedingMethod::toArray(),
'enumBusiness' => Business::toArray(),
'enumProductFormat' => ProductFormat::toArray(),
'data' => CommonResource::make($product),
'enumProductCategory' => ProductCategory::toArray(),
'enumTechnology' => Technology::toArray(),
'enumBreedingMethod' => BreedingMethod::toArray(),
'enumBusiness' => Business::toArray(),
'enumProductFormat' => ProductFormat::toArray(),
'enumNutritionnalUnit' => NutritionnalUnit::toArray(),
]);
}

View file

@ -7,6 +7,7 @@ namespace App\Http\Requests\Back\Products;
use App\Services\AdminAuthService;
use Domain\Models\ValueObjects\Products\BreedingMethod;
use Domain\Models\ValueObjects\Products\Business;
use Domain\Models\ValueObjects\Products\NutritionnalUnit;
use Domain\Models\ValueObjects\Products\ProductCategory;
use Domain\Models\ValueObjects\Products\ProductFormat;
use Domain\Models\ValueObjects\Products\Technology;
@ -79,6 +80,9 @@ class StoreProductRequest extends FormRequest
->max(self::MAX_FILE_SIZE_KB)
->types('pdf'),
],
'nutritional_values' => ['nullable', 'array'],
'nutritional_values.*.value' => ['required_with:nutritional_values.*.unit', 'string', 'min:1', 'max:50'],
'nutritional_values.*.unit' => ['required_with:nutritional_values.*.value', new Enum(NutritionnalUnit::class)],
];
}
@ -90,28 +94,40 @@ class StoreProductRequest extends FormRequest
public function attributes(): array
{
return [
'name' => trans('validation.attributes.name'),
'seo_title' => trans('validation.attributes.seo_title'),
'seo_description' => trans('validation.attributes.seo_description'),
'category' => trans('validation.attributes.category'),
'technology' => trans('validation.attributes.technology'),
'is_egalim' => trans('validation.attributes.is_egalim'),
'breeding_methods' => trans('validation.attributes.breeding_methods'),
'breeding_methods.*' => trans('validation.attributes.breeding_methods'),
'businesses' => trans('validation.attributes.businesses'),
'businesses.*' => trans('validation.attributes.businesses'),
'formats' => trans('validation.attributes.formats'),
'formats.*' => trans('validation.attributes.formats'),
'description' => trans('validation.attributes.description'),
'details_and_advantages' => trans('validation.attributes.details_and_advantages'),
'handling_advices' => trans('validation.attributes.handling_advices'),
'ingredients' => trans('validation.attributes.ingredients'),
'conservation_before_opening' => trans('validation.attributes.conservation_before_opening'),
'conservation_after_opening' => trans('validation.attributes.conservation_after_opening'),
'picture' => trans('validation.attributes.picture'),
'picture_alt' => trans('validation.attributes.picture_alt'),
'picture_title' => trans('validation.attributes.picture_title'),
'pdf' => trans('validation.attributes.pdf'),
'name' => trans('validation.attributes.name'),
'seo_title' => trans('validation.attributes.seo_title'),
'seo_description' => trans('validation.attributes.seo_description'),
'category' => trans('validation.attributes.category'),
'technology' => trans('validation.attributes.technology'),
'is_egalim' => trans('validation.attributes.is_egalim'),
'breeding_methods' => trans('validation.attributes.breeding_methods'),
'breeding_methods.*' => trans('validation.attributes.breeding_methods'),
'businesses' => trans('validation.attributes.businesses'),
'businesses.*' => trans('validation.attributes.businesses'),
'formats' => trans('validation.attributes.formats'),
'formats.*' => trans('validation.attributes.formats'),
'description' => trans('validation.attributes.description'),
'details_and_advantages' => trans('validation.attributes.details_and_advantages'),
'handling_advices' => trans('validation.attributes.handling_advices'),
'ingredients' => trans('validation.attributes.ingredients'),
'conservation_before_opening' => trans('validation.attributes.conservation_before_opening'),
'conservation_after_opening' => trans('validation.attributes.conservation_after_opening'),
'picture' => trans('validation.attributes.picture'),
'picture_alt' => trans('validation.attributes.picture_alt'),
'picture_title' => trans('validation.attributes.picture_title'),
'pdf' => trans('validation.attributes.pdf'),
'nutritional_values' => trans('validation.attributes.nutritional_values'),
'nutritional_values.refWeightG.value' => trans('validation.attributes.nutritional_refWeightG'),
'nutritional_values.kj.value' => trans('validation.attributes.nutritional_kj'),
'nutritional_values.kcal.value' => trans('validation.attributes.nutritional_kcal'),
'nutritional_values.fatG.value' => trans('validation.attributes.nutritional_fatG'),
'nutritional_values.satFatG.value' => trans('validation.attributes.nutritional_satFatG'),
'nutritional_values.carbG.value' => trans('validation.attributes.nutritional_carbG'),
'nutritional_values.sugarG.value' => trans('validation.attributes.nutritional_sugarG'),
'nutritional_values.fiberG.value' => trans('validation.attributes.nutritional_fiberG'),
'nutritional_values.proteinG.value' => trans('validation.attributes.nutritional_proteinG'),
'nutritional_values.saltG.value' => trans('validation.attributes.nutritional_saltG'),
'nutritional_values.*.unit' => trans('validation.attributes.nutritional_values_unit'),
];
}
}

View file

@ -7,7 +7,9 @@ namespace Database\Seeders\Mandatory;
use App\Models\Product;
use Domain\Models\ValueObjects\Products\BreedingMethod;
use Domain\Models\ValueObjects\Products\Business;
use Domain\Models\ValueObjects\Products\NutritionalValue;
use Domain\Models\ValueObjects\Products\NutritionalValues;
use Domain\Models\ValueObjects\Products\NutritionnalUnit;
use Domain\Models\ValueObjects\Products\ProductCategory;
use Domain\Models\ValueObjects\Products\ProductFormat;
use Domain\Models\ValueObjects\Products\Technology;
@ -349,29 +351,48 @@ class ImportProductsCsvSeeder extends Seeder
*/
private function extractNutritionalValues(array $paddedRow): ?NutritionalValues
{
$nvRefWeight = $this->toFloat($this->strOf($paddedRow, self::COL_NV_REF_WEIGHT));
$nvKcal = $this->toFloat($this->strOf($paddedRow, self::COL_NV_KCAL));
$nvKj = $this->toFloat($this->strOf($paddedRow, self::COL_NV_KJ));
$nvProtein = $this->toFloat($this->strOf($paddedRow, self::COL_NV_PROTEIN));
$nvCarb = $this->toFloat($this->strOf($paddedRow, self::COL_NV_CARB));
$nvSugar = $this->toFloat($this->strOf($paddedRow, self::COL_NV_SUGAR));
$nvFat = $this->toFloat($this->strOf($paddedRow, self::COL_NV_FAT));
$nvSatFat = $this->toFloat($this->strOf($paddedRow, self::COL_NV_SAT_FAT));
$nvFiber = $this->toFloat($this->strOf($paddedRow, self::COL_NV_FIBER));
$nvSalt = $this->toFloat($this->strOf($paddedRow, self::COL_NV_SALT));
$nvRefWeight = $this->toNutritionalValue(
$this->strOf($paddedRow, self::COL_NV_REF_WEIGHT),
NutritionnalUnit::GRAM
);
$nvKcal = $this->toNutritionalValue(
$this->strOf($paddedRow, self::COL_NV_KCAL),
NutritionnalUnit::KCAL
);
$nvKj = $this->toNutritionalValue(
$this->strOf($paddedRow, self::COL_NV_KJ),
NutritionnalUnit::KJ
);
$nvProtein = $this->toNutritionalValue(
$this->strOf($paddedRow, self::COL_NV_PROTEIN),
NutritionnalUnit::GRAM
);
$nvCarb = $this->toNutritionalValue(
$this->strOf($paddedRow, self::COL_NV_CARB),
NutritionnalUnit::GRAM
);
$nvSugar = $this->toNutritionalValue(
$this->strOf($paddedRow, self::COL_NV_SUGAR),
NutritionnalUnit::GRAM
);
$nvFat = $this->toNutritionalValue(
$this->strOf($paddedRow, self::COL_NV_FAT),
NutritionnalUnit::GRAM
);
$nvSatFat = $this->toNutritionalValue(
$this->strOf($paddedRow, self::COL_NV_SAT_FAT),
NutritionnalUnit::GRAM
);
$nvFiber = $this->toNutritionalValue(
$this->strOf($paddedRow, self::COL_NV_FIBER),
NutritionnalUnit::GRAM
);
$nvSalt = $this->toNutritionalValue(
$this->strOf($paddedRow, self::COL_NV_SALT),
NutritionnalUnit::GRAM
);
$hasNutritionalData = ! empty(array_filter([
$nvRefWeight,
$nvKcal,
$nvKj,
$nvProtein,
$nvCarb,
$nvSugar,
$nvFat,
$nvSatFat,
$nvFiber,
$nvSalt,
], fn ($val) => $val !== null));
$hasNutritionalData = $nvRefWeight !== null;
return $hasNutritionalData ? new NutritionalValues(
refWeightG: $nvRefWeight,
@ -500,19 +521,25 @@ class ImportProductsCsvSeeder extends Seeder
}
/**
* Nettoie les valeurs numériques françaises (12,5 -> 12.5) et gère les cas comme "<0,5"
* Normalise et convertit une valeur nutritionnelle en objet NutritionalValue.
*/
private function toFloat(Stringable $value): ?float
private function toNutritionalValue(Stringable $value, NutritionnalUnit $unit): ?NutritionalValue
{
$value = $value->trim();
if ($value->isEmpty()) {
return null;
}
$value = $value->replace(['<', '>'], '');
$value = $value->replace(',', '.');
$numericValue = $value
->replace('.', ',')
->replace(['<', '>'], '')
->toFloat();
$prefix = $value->startsWith(['<', '>']) ? $value->take(1) : '';
return $value->toFloat();
return new NutritionalValue(
"{$prefix} " . number_format($numericValue, 2, ',', ''),
$unit
);
}
private function applyLabelMappings(Stringable $label): Stringable

View file

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Products;
use Illuminate\Contracts\Support\Arrayable;
use JsonSerializable;
/**
* @implements Arrayable<string, string>
*/
final readonly class NutritionalValue implements Arrayable, JsonSerializable
{
public function __construct(
public string $value,
public NutritionnalUnit $unit
) {
if (empty(mb_trim($value))) {
throw new \DomainException('Value cannot be empty.');
}
}
/**
* @return array{value: string, unit: string}
*/
public function toArray(): array
{
return [
'value' => $this->value,
'unit' => $this->unit->value,
];
}
/**
* @return array{value: string, unit: string}
*/
public function jsonSerialize(): array
{
return $this->toArray();
}
}

View file

@ -4,116 +4,54 @@ declare(strict_types=1);
namespace Domain\Models\ValueObjects\Products;
final readonly class NutritionalValues
use Illuminate\Contracts\Support\Arrayable;
use JsonSerializable;
/**
* @implements Arrayable<string, array{value: string, unit: string}|null>
*/
final readonly class NutritionalValues implements Arrayable, JsonSerializable
{
public function __construct(
// * Base
public ?float $refWeightG = null,
public NutritionalValue $refWeightG,
// * Pour 100g ou par référence
public ?float $kj = null,
public ?float $kcal = null,
public ?float $fatG = null,
public ?float $satFatG = null,
public ?float $carbG = null,
public ?float $sugarG = null,
public ?float $fiberG = null,
public ?float $proteinG = null,
public ?float $saltG = null,
// * Par portion
public ?float $servingSizeG = null,
public ?float $servingKj = null,
public ?float $servingKcal = null,
public ?float $servingFatG = null,
public ?float $servingSatFatG = null,
public ?float $servingCarbG = null,
public ?float $servingSugarG = null,
public ?float $servingFiberG = null,
public ?float $servingProteinG = null,
public ?float $servingSaltG = null,
// * Apports journaliers (% AR)
public ?float $dailyEnergy = null,
public ?float $dailyFat = null,
public ?float $dailySatFat = null,
public ?float $dailyCarb = null,
public ?float $dailySugar = null,
public ?float $dailyFiber = null,
public ?float $dailyProtein = null,
public ?float $dailySalt = null,
public ?NutritionalValue $kj = null,
public ?NutritionalValue $kcal = null,
public ?NutritionalValue $fatG = null,
public ?NutritionalValue $satFatG = null,
public ?NutritionalValue $carbG = null,
public ?NutritionalValue $sugarG = null,
public ?NutritionalValue $fiberG = null,
public ?NutritionalValue $proteinG = null,
public ?NutritionalValue $saltG = null,
) {
$values = [
$refWeightG,
$kj,
$kcal,
$fatG,
$satFatG,
$carbG,
$sugarG,
$fiberG,
$proteinG,
$saltG,
$servingSizeG,
$servingKj,
$servingKcal,
$servingFatG,
$servingSatFatG,
$servingCarbG,
$servingSugarG,
$servingFiberG,
$servingProteinG,
$servingSaltG,
$dailyEnergy,
$dailyFat,
$dailySatFat,
$dailyCarb,
$dailySugar,
$dailyFiber,
$dailyProtein,
$dailySalt,
];
foreach ($values as $val) {
if ($val !== null && $val < 0) {
throw new \DomainException('Nutritional values cannot be negative');
}
}
}
/**
* @return array<string,float|null>
* @return array<string, array{value: string, unit: string}|null>
*/
public function toArray(): array
{
return [
'nv_ref_weight_g' => $this->refWeightG,
'nv_kj' => $this->kj,
'nv_kcal' => $this->kcal,
'nv_fat_g' => $this->fatG,
'nv_sat_fat_g' => $this->satFatG,
'nv_carb_g' => $this->carbG,
'nv_sugar_g' => $this->sugarG,
'nv_fiber_g' => $this->fiberG,
'nv_protein_g' => $this->proteinG,
'nv_salt_g' => $this->saltG,
'nv_serving_size_g' => $this->servingSizeG,
'nv_serving_kj' => $this->servingKj,
'nv_serving_kcal' => $this->servingKcal,
'nv_serving_fat_g' => $this->servingFatG,
'nv_serving_sat_fat_g' => $this->servingSatFatG,
'nv_serving_carb_g' => $this->servingCarbG,
'nv_serving_sugar_g' => $this->servingSugarG,
'nv_serving_fiber_g' => $this->servingFiberG,
'nv_serving_protein_g' => $this->servingProteinG,
'nv_serving_salt_g' => $this->servingSaltG,
'nv_daily_energy' => $this->dailyEnergy,
'nv_daily_fat' => $this->dailyFat,
'nv_daily_sat_fat' => $this->dailySatFat,
'nv_daily_carb' => $this->dailyCarb,
'nv_daily_sugar' => $this->dailySugar,
'nv_daily_fiber' => $this->dailyFiber,
'nv_daily_protein' => $this->dailyProtein,
'nv_daily_salt' => $this->dailySalt,
'refWeightG' => $this->refWeightG->toArray(),
'kj' => $this->kj?->toArray(),
'kcal' => $this->kcal?->toArray(),
'fatG' => $this->fatG?->toArray(),
'satFatG' => $this->satFatG?->toArray(),
'carbG' => $this->carbG?->toArray(),
'sugarG' => $this->sugarG?->toArray(),
'fiberG' => $this->fiberG?->toArray(),
'proteinG' => $this->proteinG?->toArray(),
'saltG' => $this->saltG?->toArray(),
];
}
/**
* @return array<string, array{value: string, unit: string}|null>
*/
public function jsonSerialize(): array
{
return $this->toArray();
}
}

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Products;
use Uwil\LaravelCommon\Traits\Enums\BaseEnum;
enum NutritionnalUnit: string
{
use BaseEnum;
case GRAM = 'g';
case KCAL = 'kcal';
case KJ = 'kj';
/**
* Optionnal labels definition.
*
* @phpstan-ignore classConstant.unused
*/
private const LABELS = [
self::GRAM->name => 'g',
self::KCAL->name => 'Kcal',
self::KJ->name => 'Kj',
];
}

View file

@ -244,6 +244,17 @@ return [
'national_code' => 'code national',
'number' => 'numéro',
'nutritional_values' => 'valeurs nutritionnelles',
'nutritional_refWeightG' => 'poids de référence (g)',
'nutritional_kj' => 'kJ',
'nutritional_kcal' => 'kcal',
'nutritional_fatG' => 'graisses (g)',
'nutritional_satFatG' => 'acides gras saturés (g)',
'nutritional_carbG' => 'glucides (g)',
'nutritional_sugarG' => 'sucres (g)',
'nutritional_fiberG' => 'fibres (g)',
'nutritional_proteinG' => 'protéines (g)',
'nutritional_saltG' => 'sel (g)',
'nutritional_values_unit' => 'unité des valeurs nutritionnelles',
'password' => 'mot de passe',
'password_confirmation' => 'confirmation du mot de passe',
'pdf' => 'fiche technique',

View file

@ -0,0 +1,319 @@
import { PlusIcon, Trash2Icon } from "lucide-react";
import { useMemo, useState } from "react";
import { NutritionnalValues } from "@/back/types/products";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export type NutrientKey = keyof Omit<NutritionnalValues, "refWeightG">;
export interface NutrientDefinition {
key: NutrientKey;
label: string;
defaultUnit: "g" | "kcal" | "kj";
unitLabel: string;
}
export const NUTRIENT_DEFINITIONS: readonly NutrientDefinition[] = [
{
key: "kcal",
label: "Énergie (Kilocalorie)",
defaultUnit: "kcal",
unitLabel: "Kcal",
},
{
key: "kj",
label: "Énergie (Kilojoule)",
defaultUnit: "kj",
unitLabel: "Kj",
},
{
key: "proteinG",
label: "Protéines",
defaultUnit: "g",
unitLabel: "g",
},
{
key: "carbG",
label: "Glucides",
defaultUnit: "g",
unitLabel: "g",
},
{
key: "sugarG",
label: "Sucres",
defaultUnit: "g",
unitLabel: "g",
},
{
key: "fatG",
label: "Matières grasses",
defaultUnit: "g",
unitLabel: "g",
},
{
key: "satFatG",
label: "Acides gras saturés",
defaultUnit: "g",
unitLabel: "g",
},
{
key: "fiberG",
label: "Fibres alimentaires",
defaultUnit: "g",
unitLabel: "g",
},
{
key: "saltG",
label: "Sel",
defaultUnit: "g",
unitLabel: "g",
},
] as const;
interface NutritionalValuesInputProps {
value: NutritionnalValues | null | undefined;
setData: (key: string, value: unknown) => void;
parentErrors?: Record<string, string> | null;
}
export default function NutritionalValuesInput({
value,
setData,
parentErrors,
}: Readonly<NutritionalValuesInputProps>) {
const initialKeys = useMemo<NutrientKey[]>(() => {
if (!value) return [];
return NUTRIENT_DEFINITIONS.filter(
(def) =>
value[def.key] !== null &&
value[def.key] !== undefined &&
(value[def.key]?.value !== "" || value[def.key]?.value !== undefined),
).map((def) => def.key);
}, [value]);
const [activeKeys, setActiveKeys] = useState<NutrientKey[]>(initialKeys);
const refWeight = value?.refWeightG?.value ?? "100";
const refWeightUnit = value?.refWeightG?.unit ?? "g";
const handleRefWeightChange = (newVal: string) => {
setData("nutritional_values", {
...value,
refWeightG: {
value: newVal,
unit: refWeightUnit,
},
});
};
const handleAddLine = () => {
const nextDef = NUTRIENT_DEFINITIONS.find(
(def) => !activeKeys.includes(def.key),
);
if (!nextDef) return;
setActiveKeys((prev) => [...prev, nextDef.key]);
setData("nutritional_values", {
...value,
refWeightG: value?.refWeightG ?? { value: refWeight, unit: "g" },
[nextDef.key]: {
value: "",
unit: nextDef.defaultUnit,
},
});
};
const handleRemoveLine = (keyToRemove: NutrientKey) => {
setActiveKeys((prev) => prev.filter((k) => k !== keyToRemove));
const updatedValues = { ...value };
delete updatedValues[keyToRemove];
setData("nutritional_values", updatedValues);
};
const handleNutrientChange = (oldKey: NutrientKey, newKeyString: string) => {
const newKey = newKeyString as NutrientKey;
if (oldKey === newKey) return;
const newDef = NUTRIENT_DEFINITIONS.find((def) => def.key === newKey);
if (!newDef) return;
const prevNutrientVal = value?.[oldKey]?.value ?? "";
setActiveKeys((prev) => prev.map((k) => (k === oldKey ? newKey : k)));
const updatedValues = { ...value };
delete updatedValues[oldKey];
updatedValues[newKey] = {
value: prevNutrientVal,
unit: newDef.defaultUnit,
};
setData("nutritional_values", updatedValues);
};
const handleValueChange = (key: NutrientKey, val: string) => {
const def = NUTRIENT_DEFINITIONS.find((d) => d.key === key);
if (!def) return;
const currentUnit = value?.[key]?.unit ?? def.defaultUnit;
setData("nutritional_values", {
...(value ?? {}),
refWeightG: value?.refWeightG ?? { value: refWeight, unit: "g" },
[key]: {
value: val,
unit: currentUnit,
},
});
};
const hasAvailableNutrients = activeKeys.length < NUTRIENT_DEFINITIONS.length;
return (
<fieldset className="border-2 border-slate-200 p-4 rounded-lg space-y-4">
<legend>
<Label className="font-bold text-base">Valeurs nutritionnelles</Label>
</legend>
{/* Poids de référence */}
<div className="max-w-xs space-y-1">
<Label htmlFor="nut_ref_weight" className="text-sm font-semibold">
Poids de référence
</Label>
<div className="flex items-center gap-2">
<Input
id="nut_ref_weight"
type="text"
value={refWeight}
placeholder="100"
onChange={(e) => handleRefWeightChange(e.target.value)}
className="w-32"
/>
<span className="text-sm font-medium text-slate-600">
{refWeightUnit}
</span>
</div>
</div>
{/* Gestion des lignes */}
<div className="space-y-3 pt-2">
<div className="flex items-center justify-between border-b pb-2">
<h4 className="font-semibold text-sm text-slate-700">
Valeurs de référence
</h4>
{hasAvailableNutrients && (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddLine}
className="flex items-center gap-1.5"
>
<PlusIcon className="w-4 h-4" />
<span>Ajouter une ligne</span>
</Button>
)}
</div>
{activeKeys.length === 0 ? (
<p className="text-sm text-slate-500 italic py-2">
Aucune ligne nutritionnelle renseignée. Cliquez sur &quot;Ajouter
une ligne&quot; pour en ajouter une.
</p>
) : (
<div className="space-y-3">
{activeKeys.map((key) => {
const def = NUTRIENT_DEFINITIONS.find((d) => d.key === key);
if (!def) return null;
const nutrientVal = value?.[key]?.value ?? "";
const unitLabel = def.unitLabel;
return (
<div
key={key}
className="flex flex-col sm:flex-row items-start sm:items-center gap-3 bg-slate-50 p-3 rounded-md border border-slate-200"
>
{/* Select Libellé valeur / Unité */}
<div className="w-full sm:w-72">
<Label className="text-xs text-slate-500 sm:hidden mb-1 block">
Libellé valeur / Unité
</Label>
<Select
value={key}
onValueChange={(newVal) =>
handleNutrientChange(key, newVal)
}
>
<SelectTrigger className="bg-white">
<SelectValue placeholder="Sélectionner..." />
</SelectTrigger>
<SelectContent>
{NUTRIENT_DEFINITIONS.map((option) => (
<SelectItem
key={option.key}
value={option.key}
disabled={
activeKeys.includes(option.key) &&
option.key !== key
}
>
{option.label} / {option.unitLabel}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Champ Valeur */}
<div className="flex items-center gap-2 flex-1 w-full sm:w-auto">
<Label className="text-xs text-slate-500 sm:hidden">
Valeur
</Label>
<div className="relative flex-1">
<Input
type="text"
value={nutrientVal}
placeholder="Ex : <0,5 ou 14"
onChange={(e) => handleValueChange(key, e.target.value)}
className="bg-white pr-14"
/>
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs font-semibold text-slate-400 select-none">
{unitLabel}
</span>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveLine(key)}
className="text-red-500 hover:text-red-700 hover:bg-red-50 self-end sm:self-center"
title="Supprimer cette ligne"
>
<Trash2Icon className="w-4 h-4" />
</Button>
</div>
);
})}
</div>
)}
</div>
{parentErrors?.nutritional_values && (
<p className="text-sm text-red-500 mt-1">
{parentErrors.nutritional_values}
</p>
)}
</fieldset>
);
}

View file

@ -20,7 +20,9 @@ import SubmitButton from "@/back/Components/SubmitButton";
import useCrudSubmit from "@/back/hooks/use-crud-submit";
// * Types & Global
import CompleteInputFile from "@/back/Components/Inputs/CompleteInputFile";
import { RawProductModel } from "@/back/types/products";
import NutritionalValuesInput from "@/back/Components/Inputs/NutritionalValuesInput";
import { NutritionnalValues, RawProductModel } from "@/back/types/products";
import { ModelId } from "@/domain";
import str from "@/hooks/use-string";
import trans from "@/hooks/use-translations";
@ -46,6 +48,7 @@ interface FormData {
ingredients: string;
conservation_before_opening: string;
conservation_after_opening: string;
nutritional_values: NutritionnalValues | null;
pdf: string | File | null;
}
@ -114,6 +117,7 @@ export default function Form({
ingredients: model?.ingredients ?? "",
conservation_before_opening: model?.conservation_before_opening ?? "",
conservation_after_opening: model?.conservation_after_opening ?? "",
nutritional_values: model?.nutritional_values ?? null,
pdf: model?.pdf ?? null,
}).withPrecognition(precognitionMethod, precognitionUrl);
@ -271,6 +275,13 @@ export default function Form({
setData={setData}
/>
{/* Nutritionnal Values */}
<NutritionalValuesInput
value={data.nutritional_values}
setData={setData}
parentErrors={formErrors}
/>
<SimpleWysiwyg
name="details_and_advantages"
tableName="products"

View file

@ -12,38 +12,22 @@ import {
} from "@/domain";
import * as zod from "zod";
export interface NutritionnalValue {
value: string;
unit: string;
}
export interface NutritionnalValues {
refWeightG: number;
// * Pour 100g ou par référence
kj?: number | null;
kcal?: number | null;
fatG?: number | null;
satFatG?: number | null;
carbG?: number | null;
sugarG?: number | null;
fiberG?: number | null;
proteinG?: number | null;
saltG?: number | null;
// * Par portion
servingSizeG?: number | null;
servingKj?: number | null;
servingKcal?: number | null;
servingFatG?: number | null;
servingSatFatG?: number | null;
servingCarbG?: number | null;
servingSugarG?: number | null;
servingFiberG?: number | null;
servingProteinG?: number | null;
servingSaltG?: number | null;
// * Apports journaliers (% AR)
dailyEnergy?: number | null;
dailyFat?: number | null;
dailySatFat?: number | null;
dailyCarb?: number | null;
dailySugar?: number | null;
dailyFiber?: number | null;
dailyProtein?: number | null;
dailySalt?: number | null;
refWeightG: NutritionnalValue;
kj?: NutritionnalValue | null;
kcal?: NutritionnalValue | null;
fatG?: NutritionnalValue | null;
satFatG?: NutritionnalValue | null;
carbG?: NutritionnalValue | null;
sugarG?: NutritionnalValue | null;
fiberG?: NutritionnalValue | null;
proteinG?: NutritionnalValue | null;
saltG?: NutritionnalValue | null;
}
export interface RawProductModel extends ModelWithPolicies {
@ -77,35 +61,22 @@ export interface ProductModel extends RawProductModel {
deleted_at: Date | null;
}
const nutritionalValueSchema = zod.object({
value: zod.string(),
unit: zod.string(),
});
const nutritionalValuesSchema = zod.object({
refWeightG: zod.number(),
kj: zod.number().nullable(),
kcal: zod.number().nullable(),
fatG: zod.number().nullable(),
satFatG: zod.number().nullable(),
carbG: zod.number().nullable(),
sugarG: zod.number().nullable(),
fiberG: zod.number().nullable(),
proteinG: zod.number().nullable(),
saltG: zod.number().nullable(),
servingSizeG: zod.number().nullable(),
servingKj: zod.number().nullable(),
servingKcal: zod.number().nullable(),
servingFatG: zod.number().nullable(),
servingSatFatG: zod.number().nullable(),
servingCarbG: zod.number().nullable(),
servingSugarG: zod.number().nullable(),
servingFiberG: zod.number().nullable(),
servingProteinG: zod.number().nullable(),
servingSaltG: zod.number().nullable(),
dailyEnergy: zod.number().nullable(),
dailyFat: zod.number().nullable(),
dailySatFat: zod.number().nullable(),
dailyCarb: zod.number().nullable(),
dailySugar: zod.number().nullable(),
dailyFiber: zod.number().nullable(),
dailyProtein: zod.number().nullable(),
dailySalt: zod.number().nullable(),
refWeightG: nutritionalValueSchema,
kj: nutritionalValueSchema.nullable().optional(),
kcal: nutritionalValueSchema.nullable().optional(),
fatG: nutritionalValueSchema.nullable().optional(),
satFatG: nutritionalValueSchema.nullable().optional(),
carbG: nutritionalValueSchema.nullable().optional(),
sugarG: nutritionalValueSchema.nullable().optional(),
fiberG: nutritionalValueSchema.nullable().optional(),
proteinG: nutritionalValueSchema.nullable().optional(),
saltG: nutritionalValueSchema.nullable().optional(),
});
export const rawProductModelSchema = zod.object({

View file

@ -4,35 +4,22 @@ import { BreedingMethod, Business, Technology } from "../value-objects";
import ProductCategory from "../value-objects/product-category";
export interface NutritionalValue {
value: string;
unit: string;
}
export interface NutritionalValues {
refWeightG: number | null;
kj: number | null;
kcal: number | null;
fatG: number | null;
satFatG: number | null;
carbG: number | null;
sugarG: number | null;
fiberG: number | null;
proteinG: number | null;
saltG: number | null;
servingSizeG: number | null;
servingKj: number | null;
servingKcal: number | null;
servingFatG: number | null;
servingSatFatG: number | null;
servingCarbG: number | null;
servingSugarG: number | null;
servingFiberG: number | null;
servingProteinG: number | null;
servingSaltG: number | null;
dailyEnergy: number | null;
dailyFat: number | null;
dailySatFat: number | null;
dailyCarb: number | null;
dailySugar: number | null;
dailyFiber: number | null;
dailyProtein: number | null;
dailySalt: number | null;
refWeightG: NutritionalValue;
kj: NutritionalValue | null;
kcal: NutritionalValue | null;
fatG: NutritionalValue | null;
satFatG: NutritionalValue | null;
carbG: NutritionalValue | null;
sugarG: NutritionalValue | null;
fiberG: NutritionalValue | null;
proteinG: NutritionalValue | null;
saltG: NutritionalValue | null;
}
export interface Product {

View file

@ -248,73 +248,48 @@ export default function Show({
{
label: "Énergie",
subLabel: null,
val1:
product.nutritionalValues.kj !== null
? `${product.nutritionalValues.kj} Kj`
: null,
val2:
product.nutritionalValues.kcal !== null
? `${product.nutritionalValues.kcal} Kcal`
: null,
val1: product.nutritionalValues.kj,
val2: product.nutritionalValues.kcal,
show:
product.nutritionalValues.kj !== null ||
product.nutritionalValues.kcal !== null,
Boolean(product.nutritionalValues.kj?.value) ||
Boolean(product.nutritionalValues.kcal?.value),
},
{
label: "Matières Grasses",
subLabel: "dont Acides Gras Saturés",
val1:
product.nutritionalValues.fatG !== null
? `${product.nutritionalValues.fatG} g`
: null,
val2:
product.nutritionalValues.satFatG !== null
? `${product.nutritionalValues.satFatG} g`
: null,
show: product.nutritionalValues.fatG !== null,
val1: product.nutritionalValues.fatG,
val2: product.nutritionalValues.satFatG,
show: Boolean(product.nutritionalValues.fatG?.value),
},
{
label: "Glucides",
subLabel: "dont sucres",
val1:
product.nutritionalValues.carbG !== null
? `${product.nutritionalValues.carbG} g`
: null,
val2:
product.nutritionalValues.sugarG !== null
? `${product.nutritionalValues.sugarG} g`
: null,
show: product.nutritionalValues.carbG !== null,
val1: product.nutritionalValues.carbG,
val2: product.nutritionalValues.sugarG,
show: Boolean(product.nutritionalValues.carbG?.value),
},
{
label: "Fibres alimentaires",
subLabel: null,
val1:
product.nutritionalValues.fiberG !== null
? `${product.nutritionalValues.fiberG} g`
: null,
val1: product.nutritionalValues.fiberG,
val2: null,
show: product.nutritionalValues.fiberG !== null,
show: Boolean(product.nutritionalValues.fiberG?.value),
},
{
label: "Protéines",
subLabel: null,
val1:
product.nutritionalValues.proteinG !== null
? `${product.nutritionalValues.proteinG} g`
: null,
val1: product.nutritionalValues.proteinG,
val2: null,
show: product.nutritionalValues.proteinG !== null,
show: Boolean(
product.nutritionalValues.proteinG?.value,
),
},
{
label: "Sel",
subLabel: null,
val1:
product.nutritionalValues.saltG !== null
? `${product.nutritionalValues.saltG} g`
: null,
val1: product.nutritionalValues.saltG,
val2: null,
show: product.nutritionalValues.saltG !== null,
show: Boolean(product.nutritionalValues.saltG?.value),
},
].filter((r) => r.show)
: [];
@ -347,8 +322,12 @@ export default function Show({
{rows.length > 0 && (
<div className="w-full">
<div className="text-right font-bold text-primary-foreground mb-2 text-xs md:text-sm">
Pour 100g
Pour{" "}
{product.nutritionalValues?.refWeightG?.value
? `${product.nutritionalValues.refWeightG.value} ${product.nutritionalValues.refWeightG.unit}`
: "100g"}
</div>
<div className="flex flex-col">
{rows.map((row, idx) => (
<div
@ -356,7 +335,7 @@ export default function Show({
className={`flex justify-between items-center p-2.5 md:p-4 font-bold ${idx % 2 === 0 ? "bg-primary-foreground text-primary" : "bg-white text-primary-foreground"}`}
>
<div>
<div className="text-sm md:text-base">
<div className="text-sm md:text-base capitalize">
{row.label}
</div>
{row.subLabel && row.val2 !== null && (
@ -366,10 +345,36 @@ export default function Show({
)}
</div>
<div className="text-right text-sm md:text-base">
{row.val1 !== null && <div>{row.val1}</div>}
{row.val1 !== null && (
<div>
<span className="pe-1">
{row.val1.value}
</span>
<span
className={
row.val1.unit.length > 1
? "capitalize"
: ""
}
>
{row.val1.unit}
</span>
</div>
)}
{row.val2 !== null && (
<div className="font-normal text-xs">
{row.val2}
<div>
<span className="pe-1">
{row.val2.value}
</span>
<span
className={
row.val2.unit.length > 1
? "capitalize"
: ""
}
>
{row.val2.unit}
</span>
</div>
)}
</div>

View file

@ -10,35 +10,22 @@ import {
} from "@/domain";
import ProductCategory from "../../domain/value-objects/product-category";
export const NutritionalValueSchema = zod.object({
value: zod.string(),
unit: zod.string(),
});
export const NutritionalValuesSchema = zod.object({
refWeightG: zod.number().nullable(),
kj: zod.number().nullable(),
kcal: zod.number().nullable(),
fatG: zod.number().nullable(),
satFatG: zod.number().nullable(),
carbG: zod.number().nullable(),
sugarG: zod.number().nullable(),
fiberG: zod.number().nullable(),
proteinG: zod.number().nullable(),
saltG: zod.number().nullable(),
servingSizeG: zod.number().nullable(),
servingKj: zod.number().nullable(),
servingKcal: zod.number().nullable(),
servingFatG: zod.number().nullable(),
servingSatFatG: zod.number().nullable(),
servingCarbG: zod.number().nullable(),
servingSugarG: zod.number().nullable(),
servingFiberG: zod.number().nullable(),
servingProteinG: zod.number().nullable(),
servingSaltG: zod.number().nullable(),
dailyEnergy: zod.number().nullable(),
dailyFat: zod.number().nullable(),
dailySatFat: zod.number().nullable(),
dailyCarb: zod.number().nullable(),
dailySugar: zod.number().nullable(),
dailyFiber: zod.number().nullable(),
dailyProtein: zod.number().nullable(),
dailySalt: zod.number().nullable(),
refWeightG: NutritionalValueSchema,
kj: NutritionalValueSchema.nullable(),
kcal: NutritionalValueSchema.nullable(),
fatG: NutritionalValueSchema.nullable(),
satFatG: NutritionalValueSchema.nullable(),
carbG: NutritionalValueSchema.nullable(),
sugarG: NutritionalValueSchema.nullable(),
fiberG: NutritionalValueSchema.nullable(),
proteinG: NutritionalValueSchema.nullable(),
saltG: NutritionalValueSchema.nullable(),
});
export const ProductSchema = zod.object({