diff --git a/app/Casts/NutritionalValuesCast.php b/app/Casts/NutritionalValuesCast.php index e4cda6bb..87150fe0 100644 --- a/app/Casts/NutritionalValuesCast.php +++ b/app/Casts/NutritionalValuesCast.php @@ -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 */ +/** @implements CastsAttributes|null> */ class NutritionalValuesCast implements CastsAttributes { /** @param array $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 $attributes - * - * @throws \InvalidArgumentException + * @param array $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), + ); } } diff --git a/app/Http/Controllers/Back/ProductController.php b/app/Http/Controllers/Back/ProductController.php index c14ceff3..c9048a99 100644 --- a/app/Http/Controllers/Back/ProductController.php +++ b/app/Http/Controllers/Back/ProductController.php @@ -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(), ]); } diff --git a/app/Http/Requests/Back/Products/StoreProductRequest.php b/app/Http/Requests/Back/Products/StoreProductRequest.php index b816d603..4f93869f 100644 --- a/app/Http/Requests/Back/Products/StoreProductRequest.php +++ b/app/Http/Requests/Back/Products/StoreProductRequest.php @@ -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'), ]; } } diff --git a/database/seeders/Mandatory/ImportProductsCsvSeeder.php b/database/seeders/Mandatory/ImportProductsCsvSeeder.php index b6700927..6fbaac93 100644 --- a/database/seeders/Mandatory/ImportProductsCsvSeeder.php +++ b/database/seeders/Mandatory/ImportProductsCsvSeeder.php @@ -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 diff --git a/domain/Models/ValueObjects/Products/NutritionalValue.php b/domain/Models/ValueObjects/Products/NutritionalValue.php new file mode 100644 index 00000000..9c4fc7a3 --- /dev/null +++ b/domain/Models/ValueObjects/Products/NutritionalValue.php @@ -0,0 +1,42 @@ + + */ +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(); + } +} diff --git a/domain/Models/ValueObjects/Products/NutritionalValues.php b/domain/Models/ValueObjects/Products/NutritionalValues.php index 8b72a4bf..eb52d544 100644 --- a/domain/Models/ValueObjects/Products/NutritionalValues.php +++ b/domain/Models/ValueObjects/Products/NutritionalValues.php @@ -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 + */ +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 + * @return array */ 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 + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } } diff --git a/domain/Models/ValueObjects/Products/NutritionnalUnit.php b/domain/Models/ValueObjects/Products/NutritionnalUnit.php new file mode 100644 index 00000000..94ec3d66 --- /dev/null +++ b/domain/Models/ValueObjects/Products/NutritionnalUnit.php @@ -0,0 +1,27 @@ +name => 'g', + self::KCAL->name => 'Kcal', + self::KJ->name => 'Kj', + ]; +} diff --git a/lang/fr/validation.php b/lang/fr/validation.php index 9f8b7f21..eb1db91e 100644 --- a/lang/fr/validation.php +++ b/lang/fr/validation.php @@ -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', diff --git a/resources/ts/back/Components/Inputs/NutritionalValuesInput.tsx b/resources/ts/back/Components/Inputs/NutritionalValuesInput.tsx new file mode 100644 index 00000000..4acf4e34 --- /dev/null +++ b/resources/ts/back/Components/Inputs/NutritionalValuesInput.tsx @@ -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; + +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 | null; +} + +export default function NutritionalValuesInput({ + value, + setData, + parentErrors, +}: Readonly) { + const initialKeys = useMemo(() => { + 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(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 ( +
+ + + + + {/* Poids de référence */} +
+ +
+ handleRefWeightChange(e.target.value)} + className="w-32" + /> + + {refWeightUnit} + +
+
+ + {/* Gestion des lignes */} +
+
+

+ Valeurs de référence +

+ {hasAvailableNutrients && ( + + )} +
+ + {activeKeys.length === 0 ? ( +

+ Aucune ligne nutritionnelle renseignée. Cliquez sur "Ajouter + une ligne" pour en ajouter une. +

+ ) : ( +
+ {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 ( +
+ {/* Select Libellé valeur / Unité */} +
+ + +
+ + {/* Champ Valeur */} +
+ +
+ handleValueChange(key, e.target.value)} + className="bg-white pr-14" + /> + + {unitLabel} + +
+
+ + +
+ ); + })} +
+ )} +
+ + {parentErrors?.nutritional_values && ( +

+ {parentErrors.nutritional_values} +

+ )} +
+ ); +} diff --git a/resources/ts/back/Pages/Products/Form.tsx b/resources/ts/back/Pages/Products/Form.tsx index bb47d64b..4c317ebc 100644 --- a/resources/ts/back/Pages/Products/Form.tsx +++ b/resources/ts/back/Pages/Products/Form.tsx @@ -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 */} + + r.show) : []; @@ -347,8 +322,12 @@ export default function Show({ {rows.length > 0 && (
- Pour 100g + Pour{" "} + {product.nutritionalValues?.refWeightG?.value + ? `${product.nutritionalValues.refWeightG.value} ${product.nutritionalValues.refWeightG.unit}` + : "100g"}
+
{rows.map((row, idx) => (
-
+
{row.label}
{row.subLabel && row.val2 !== null && ( @@ -366,10 +345,36 @@ export default function Show({ )}
- {row.val1 !== null &&
{row.val1}
} + {row.val1 !== null && ( +
+ + {row.val1.value} + + 1 + ? "capitalize" + : "" + } + > + {row.val1.unit} + +
+ )} {row.val2 !== null && ( -
- {row.val2} +
+ + {row.val2.value} + + 1 + ? "capitalize" + : "" + } + > + {row.val2.unit} +
)}
diff --git a/resources/ts/front/schemas/product.schema.ts b/resources/ts/front/schemas/product.schema.ts index d4f0df09..6b6d479e 100644 --- a/resources/ts/front/schemas/product.schema.ts +++ b/resources/ts/front/schemas/product.schema.ts @@ -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({