Compare commits
22 Commits
v1.9.22
...
refactor/s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27d51ffdb1 | ||
|
|
53d4d5768b | ||
|
|
3ff89d43ed | ||
|
|
5c55441e6c | ||
|
|
e432153083 | ||
|
|
b16b619fc9 | ||
|
|
c88333b052 | ||
| 8f5cd98b82 | |||
| 48f7e4c6ac | |||
| c46769a67d | |||
|
|
28394ce1b4 | ||
|
|
8cfcb41a39 | ||
|
|
980a7c310e | ||
|
|
00f18d1c7d | ||
|
|
6e2c5179a9 | ||
| 3cd18a721a | |||
|
|
191e071957 | ||
| f964df76b9 | |||
|
|
6744542f84 | ||
| 3e0e9d5270 | |||
|
|
4e0efc11ba | ||
| 9fc88df3ff |
@@ -7,6 +7,13 @@
|
|||||||
"X-Profile-Id": "admin-default-profile",
|
"X-Profile-Id": "admin-default-profile",
|
||||||
"X-Profile-Password": "A123"
|
"X-Profile-Password": "A123"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"lesstime": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "http://project.malio-dev.fr/_mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer b355c6cbf27d2a86d7eba1c3132c99bb3133f94cfd9e9243ffcc3c5ae1dc82c8"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -264,3 +264,12 @@ make test-setup # Créer/mettre à jour le schéma test
|
|||||||
- Nuxt dev : `http://localhost:3001`
|
- Nuxt dev : `http://localhost:3001`
|
||||||
- Adminer (PG) : `http://localhost:5050`
|
- Adminer (PG) : `http://localhost:5050`
|
||||||
- PG direct : `localhost:5433` (user: root, pass: root, db: inventory)
|
- PG direct : `localhost:5433` (user: root, pass: root, db: inventory)
|
||||||
|
|
||||||
|
## Delegation Codex
|
||||||
|
|
||||||
|
Pour les taches mecaniques (tests, boilerplate, renommages, refacto repetitif), delegue a Codex via le plugin `codex`. Garde Claude pour la reflexion, l'architecture et la verification.
|
||||||
|
|
||||||
|
- **Codex** = junior dev rapide et pas cher (executions mecaniques)
|
||||||
|
- **Claude** = senior dev qui verifie et reflechit (design, review, decisions)
|
||||||
|
|
||||||
|
C'est le meilleur ratio qualite/credits.
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"symfony/framework-bundle": "8.0.*",
|
"symfony/framework-bundle": "8.0.*",
|
||||||
"symfony/mcp-bundle": "^0.6.0",
|
"symfony/mcp-bundle": "^0.6.0",
|
||||||
"symfony/mime": "8.0.*",
|
"symfony/mime": "8.0.*",
|
||||||
|
"symfony/monolog-bundle": "^4.0.2",
|
||||||
"symfony/property-access": "8.0.*",
|
"symfony/property-access": "8.0.*",
|
||||||
"symfony/property-info": "8.0.*",
|
"symfony/property-info": "8.0.*",
|
||||||
"symfony/rate-limiter": "8.0.*",
|
"symfony/rate-limiter": "8.0.*",
|
||||||
|
|||||||
261
composer.lock
generated
261
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "f94dc3c05e9ba6be99c510aad3d17182",
|
"content-hash": "5c54b1589d9e815f4c9b7e5e1d2d69c7",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "api-platform/doctrine-common",
|
"name": "api-platform/doctrine-common",
|
||||||
@@ -2437,6 +2437,109 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-02-23T21:42:54+00:00"
|
"time": "2026-02-23T21:42:54+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "monolog/monolog",
|
||||||
|
"version": "3.10.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/Seldaek/monolog.git",
|
||||||
|
"reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0",
|
||||||
|
"reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.1",
|
||||||
|
"psr/log": "^2.0 || ^3.0"
|
||||||
|
},
|
||||||
|
"provide": {
|
||||||
|
"psr/log-implementation": "3.0.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"aws/aws-sdk-php": "^3.0",
|
||||||
|
"doctrine/couchdb": "~1.0@dev",
|
||||||
|
"elasticsearch/elasticsearch": "^7 || ^8",
|
||||||
|
"ext-json": "*",
|
||||||
|
"graylog2/gelf-php": "^1.4.2 || ^2.0",
|
||||||
|
"guzzlehttp/guzzle": "^7.4.5",
|
||||||
|
"guzzlehttp/psr7": "^2.2",
|
||||||
|
"mongodb/mongodb": "^1.8 || ^2.0",
|
||||||
|
"php-amqplib/php-amqplib": "~2.4 || ^3",
|
||||||
|
"php-console/php-console": "^3.1.8",
|
||||||
|
"phpstan/phpstan": "^2",
|
||||||
|
"phpstan/phpstan-deprecation-rules": "^2",
|
||||||
|
"phpstan/phpstan-strict-rules": "^2",
|
||||||
|
"phpunit/phpunit": "^10.5.17 || ^11.0.7",
|
||||||
|
"predis/predis": "^1.1 || ^2",
|
||||||
|
"rollbar/rollbar": "^4.0",
|
||||||
|
"ruflin/elastica": "^7 || ^8",
|
||||||
|
"symfony/mailer": "^5.4 || ^6",
|
||||||
|
"symfony/mime": "^5.4 || ^6"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB",
|
||||||
|
"doctrine/couchdb": "Allow sending log messages to a CouchDB server",
|
||||||
|
"elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client",
|
||||||
|
"ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)",
|
||||||
|
"ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler",
|
||||||
|
"ext-mbstring": "Allow to work properly with unicode symbols",
|
||||||
|
"ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)",
|
||||||
|
"ext-openssl": "Required to send log messages using SSL",
|
||||||
|
"ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)",
|
||||||
|
"graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server",
|
||||||
|
"mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)",
|
||||||
|
"php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib",
|
||||||
|
"rollbar/rollbar": "Allow sending log messages to Rollbar",
|
||||||
|
"ruflin/elastica": "Allow sending log messages to an Elastic Search server"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-main": "3.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Monolog\\": "src/Monolog"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Jordi Boggiano",
|
||||||
|
"email": "j.boggiano@seld.be",
|
||||||
|
"homepage": "https://seld.be"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Sends your logs to files, sockets, inboxes, databases and various web services",
|
||||||
|
"homepage": "https://github.com/Seldaek/monolog",
|
||||||
|
"keywords": [
|
||||||
|
"log",
|
||||||
|
"logging",
|
||||||
|
"psr-3"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/Seldaek/monolog/issues",
|
||||||
|
"source": "https://github.com/Seldaek/monolog/tree/3.10.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/Seldaek",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/monolog/monolog",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-01-02T08:56:05+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "nelmio/cors-bundle",
|
"name": "nelmio/cors-bundle",
|
||||||
"version": "2.6.0",
|
"version": "2.6.0",
|
||||||
@@ -5427,6 +5530,162 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-03-30T15:14:47+00:00"
|
"time": "2026-03-30T15:14:47+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/monolog-bridge",
|
||||||
|
"version": "v8.0.8",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/monolog-bridge.git",
|
||||||
|
"reference": "c6efdcbd5cc17cf7618fb4447053b792df6ae724"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/c6efdcbd5cc17cf7618fb4447053b792df6ae724",
|
||||||
|
"reference": "c6efdcbd5cc17cf7618fb4447053b792df6ae724",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"monolog/monolog": "^3",
|
||||||
|
"php": ">=8.4",
|
||||||
|
"symfony/http-kernel": "^7.4|^8.0",
|
||||||
|
"symfony/service-contracts": "^2.5|^3"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"symfony/console": "^7.4|^8.0",
|
||||||
|
"symfony/http-client": "^7.4|^8.0",
|
||||||
|
"symfony/mailer": "^7.4|^8.0",
|
||||||
|
"symfony/messenger": "^7.4|^8.0",
|
||||||
|
"symfony/mime": "^7.4|^8.0",
|
||||||
|
"symfony/security-core": "^7.4|^8.0",
|
||||||
|
"symfony/var-dumper": "^7.4|^8.0"
|
||||||
|
},
|
||||||
|
"type": "symfony-bridge",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Bridge\\Monolog\\": ""
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"/Tests/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Fabien Potencier",
|
||||||
|
"email": "fabien@symfony.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Provides integration for Monolog with various Symfony components",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/monolog-bridge/tree/v8.0.8"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nicolas-grekas",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-03-30T15:14:47+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/monolog-bundle",
|
||||||
|
"version": "v4.0.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/monolog-bundle.git",
|
||||||
|
"reference": "c012c6aba13129eb02aa7dd61e66e720911d8598"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/c012c6aba13129eb02aa7dd61e66e720911d8598",
|
||||||
|
"reference": "c012c6aba13129eb02aa7dd61e66e720911d8598",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"composer-runtime-api": "^2.0",
|
||||||
|
"monolog/monolog": "^3.5",
|
||||||
|
"php": ">=8.2",
|
||||||
|
"symfony/config": "^7.3 || ^8.0",
|
||||||
|
"symfony/dependency-injection": "^7.3 || ^8.0",
|
||||||
|
"symfony/http-kernel": "^7.3 || ^8.0",
|
||||||
|
"symfony/monolog-bridge": "^7.3 || ^8.0",
|
||||||
|
"symfony/polyfill-php84": "^1.30"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^11.5.41 || ^12.3",
|
||||||
|
"symfony/console": "^7.3 || ^8.0",
|
||||||
|
"symfony/yaml": "^7.3 || ^8.0"
|
||||||
|
},
|
||||||
|
"type": "symfony-bundle",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Bundle\\MonologBundle\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Fabien Potencier",
|
||||||
|
"email": "fabien@symfony.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Symfony MonologBundle",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"keywords": [
|
||||||
|
"log",
|
||||||
|
"logging"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/symfony/monolog-bundle/issues",
|
||||||
|
"source": "https://github.com/symfony/monolog-bundle/tree/v4.0.2"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nicolas-grekas",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-04-02T18:27:21+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/options-resolver",
|
"name": "symfony/options-resolver",
|
||||||
"version": "v8.0.0",
|
"version": "v8.0.0",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
|
|||||||
use Nelmio\CorsBundle\NelmioCorsBundle;
|
use Nelmio\CorsBundle\NelmioCorsBundle;
|
||||||
use Symfony\AI\McpBundle\McpBundle;
|
use Symfony\AI\McpBundle\McpBundle;
|
||||||
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
|
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
|
||||||
|
use Symfony\Bundle\MonologBundle\MonologBundle;
|
||||||
use Symfony\Bundle\SecurityBundle\SecurityBundle;
|
use Symfony\Bundle\SecurityBundle\SecurityBundle;
|
||||||
use Symfony\Bundle\TwigBundle\TwigBundle;
|
use Symfony\Bundle\TwigBundle\TwigBundle;
|
||||||
|
|
||||||
@@ -22,4 +23,5 @@ return [
|
|||||||
ApiPlatformBundle::class => ['all' => true],
|
ApiPlatformBundle::class => ['all' => true],
|
||||||
DAMADoctrineTestBundle::class => ['test' => true],
|
DAMADoctrineTestBundle::class => ['test' => true],
|
||||||
McpBundle::class => ['all' => true],
|
McpBundle::class => ['all' => true],
|
||||||
|
MonologBundle::class => ['all' => true],
|
||||||
];
|
];
|
||||||
|
|||||||
56
config/packages/monolog.yaml
Normal file
56
config/packages/monolog.yaml
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
monolog:
|
||||||
|
channels:
|
||||||
|
- deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists
|
||||||
|
|
||||||
|
when@dev:
|
||||||
|
monolog:
|
||||||
|
handlers:
|
||||||
|
main:
|
||||||
|
type: rotating_file
|
||||||
|
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||||
|
level: debug
|
||||||
|
max_files: 7
|
||||||
|
channels: ["!event"]
|
||||||
|
console:
|
||||||
|
type: console
|
||||||
|
process_psr_3_messages: false
|
||||||
|
channels: ["!event", "!doctrine", "!console"]
|
||||||
|
|
||||||
|
when@test:
|
||||||
|
monolog:
|
||||||
|
handlers:
|
||||||
|
main:
|
||||||
|
type: fingers_crossed
|
||||||
|
action_level: error
|
||||||
|
handler: nested
|
||||||
|
excluded_http_codes: [404, 405]
|
||||||
|
channels: ["!event"]
|
||||||
|
nested:
|
||||||
|
type: stream
|
||||||
|
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||||
|
level: debug
|
||||||
|
|
||||||
|
when@prod:
|
||||||
|
monolog:
|
||||||
|
handlers:
|
||||||
|
main:
|
||||||
|
type: fingers_crossed
|
||||||
|
action_level: error
|
||||||
|
handler: nested
|
||||||
|
excluded_http_codes: [404, 405]
|
||||||
|
channels: ["!deprecation"]
|
||||||
|
buffer_size: 50
|
||||||
|
nested:
|
||||||
|
type: rotating_file
|
||||||
|
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||||
|
level: debug
|
||||||
|
max_files: 30
|
||||||
|
console:
|
||||||
|
type: console
|
||||||
|
process_psr_3_messages: false
|
||||||
|
channels: ["!event", "!doctrine"]
|
||||||
|
deprecation:
|
||||||
|
type: rotating_file
|
||||||
|
channels: [deprecation]
|
||||||
|
path: "%kernel.logs_dir%/deprecations.log"
|
||||||
|
max_files: 7
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '1.9.22'
|
app.version: '1.9.29'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-4">
|
<div class="space-y-3">
|
||||||
<!-- Root Components -->
|
<!-- Root Components -->
|
||||||
<div v-for="component in components" :key="component.id" class="border border-gray-200 rounded-lg p-4">
|
<div v-for="component in components" :key="component.id">
|
||||||
<ComponentItem
|
<ComponentItem
|
||||||
:component="component"
|
:component="component"
|
||||||
:is-edit-mode="isEditMode"
|
:is-edit-mode="isEditMode"
|
||||||
|
|||||||
@@ -13,29 +13,42 @@
|
|||||||
@updated="handleDocumentUpdated"
|
@updated="handleDocumentUpdated"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Component Header -->
|
<!-- ═══ HEADER BAR ═══ -->
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-shadow"
|
class="group/header flex items-center gap-3 px-4 py-3 rounded-xl cursor-pointer select-none transition-all duration-200"
|
||||||
:class="[
|
:class="[
|
||||||
component.pendingEntity ? 'bg-error/10 border border-error' : 'bg-base-200',
|
component.pendingEntity
|
||||||
!isCollapsed ? 'sticky top-16 z-10 shadow-sm' : '',
|
? 'bg-error/10 border border-error/40 hover:border-error/60'
|
||||||
|
: 'bg-base-200/70 border border-base-300/30 hover:border-base-300/60 hover:bg-base-200',
|
||||||
|
!isCollapsed ? 'sticky top-16 z-10 shadow-md ring-1 ring-base-300/20' : 'shadow-sm',
|
||||||
]"
|
]"
|
||||||
@click="toggleCollapse"
|
@click="toggleCollapse"
|
||||||
>
|
>
|
||||||
<IconLucideChevronRight
|
<!-- Chevron -->
|
||||||
class="w-4 h-4 shrink-0 transition-transform text-base-content/50"
|
<div
|
||||||
:class="{ 'rotate-90': !isCollapsed }"
|
class="w-6 h-6 rounded-md grid place-items-center shrink-0 transition-all duration-200"
|
||||||
aria-hidden="true"
|
:class="isCollapsed ? 'bg-base-300/40' : 'bg-primary/15'"
|
||||||
/>
|
>
|
||||||
<div class="flex-1 min-w-0">
|
<IconLucideChevronRight
|
||||||
|
class="w-3.5 h-3.5 transition-transform duration-200"
|
||||||
|
:class="[
|
||||||
|
isCollapsed ? 'text-base-content/40' : 'rotate-90 text-primary',
|
||||||
|
]"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="flex-1 min-w-0 space-y-1.5">
|
||||||
|
<!-- Row 1: Name + identifiers -->
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
<h3 class="text-sm font-semibold truncate" :class="component.pendingEntity ? 'text-error' : 'text-base-content'">
|
<h3 class="text-sm font-bold tracking-tight truncate" :class="component.pendingEntity ? 'text-error' : 'text-base-content'">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-if="!isEditMode && !component.pendingEntity && component.composantId"
|
v-if="!isEditMode && !component.pendingEntity && component.composantId"
|
||||||
:to="machineId
|
:to="machineId
|
||||||
? { path: `/component/${component.composantId}`, query: { from: 'machine', machineId } }
|
? { path: `/component/${component.composantId}`, query: { from: 'machine', machineId } }
|
||||||
: `/component/${component.composantId}`"
|
: `/component/${component.composantId}`"
|
||||||
class="hover:underline hover:text-primary transition-colors"
|
class="hover:text-primary transition-colors"
|
||||||
@click.stop
|
@click.stop
|
||||||
>
|
>
|
||||||
{{ component.name }}
|
{{ component.name }}
|
||||||
@@ -51,232 +64,282 @@
|
|||||||
>
|
>
|
||||||
À remplir
|
À remplir
|
||||||
</button>
|
</button>
|
||||||
<span v-if="component.reference" class="badge badge-outline badge-xs">{{ component.reference }}</span>
|
<span v-if="component.reference" class="text-[0.65rem] font-mono text-base-content/70 bg-base-300/50 px-1.5 py-0.5 rounded border border-base-300/40">{{ component.reference }}</span>
|
||||||
<span v-if="component.prix" class="badge badge-primary badge-xs">{{ component.prix }}€</span>
|
<span v-if="component.referenceAuto" class="text-[0.65rem] font-mono font-semibold text-secondary bg-secondary/20 px-1.5 py-0.5 rounded border border-secondary/30" title="Référence auto">{{ component.referenceAuto }}</span>
|
||||||
|
<span v-if="component.prix" class="text-[0.65rem] font-bold text-primary bg-primary/20 px-1.5 py-0.5 rounded border border-primary/30">{{ component.prix }}€</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="componentConstructeursDisplay.length || displayProductName" class="flex flex-wrap gap-1.5 mt-1">
|
|
||||||
|
<!-- Row 2: Metadata tags -->
|
||||||
|
<div
|
||||||
|
v-if="componentConstructeursDisplay.length || displayProductName || (!isEditMode && visibleContextFieldTags.length)"
|
||||||
|
class="flex flex-wrap items-center gap-1.5"
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
v-for="constructeur in componentConstructeursDisplay"
|
v-for="constructeur in componentConstructeursDisplay"
|
||||||
:key="constructeur.id"
|
:key="constructeur.id"
|
||||||
class="text-xs text-base-content/50"
|
class="text-[0.65rem] text-base-content/45"
|
||||||
>
|
>
|
||||||
{{ constructeur.name }}
|
{{ constructeur.name }}
|
||||||
<span v-if="supplierReferenceMap.get(constructeur.id)" class="opacity-70">({{ supplierReferenceMap.get(constructeur.id) }})</span>
|
<span v-if="supplierReferenceMap.get(constructeur.id)" class="opacity-60">({{ supplierReferenceMap.get(constructeur.id) }})</span>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="displayProductName" class="badge badge-info badge-xs">
|
<span v-if="displayProductName" class="text-[0.65rem] font-semibold text-info bg-info/20 px-1.5 py-0.5 rounded border border-info/30">
|
||||||
{{ displayProductName }}
|
{{ displayProductName }}
|
||||||
</span>
|
</span>
|
||||||
|
<!-- Context field tags (consultation only) -->
|
||||||
|
<template v-if="!isEditMode">
|
||||||
|
<span
|
||||||
|
v-for="field in visibleContextFieldTags"
|
||||||
|
:key="field.name"
|
||||||
|
class="text-[0.65rem] font-semibold px-1.5 py-0.5 rounded"
|
||||||
|
:class="contextFieldBadgeClass(field)"
|
||||||
|
>
|
||||||
|
{{ field.name }} : {{ field.value }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete button -->
|
||||||
<button
|
<button
|
||||||
v-if="showDelete"
|
v-if="showDelete"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-ghost btn-xs text-error shrink-0"
|
class="btn btn-ghost btn-xs btn-circle text-error opacity-0 group-hover/header:opacity-100 transition-opacity shrink-0"
|
||||||
title="Supprimer ce composant"
|
title="Supprimer ce composant"
|
||||||
@click.stop="$emit('delete')"
|
@click.stop="$emit('delete')"
|
||||||
>
|
>
|
||||||
Supprimer
|
<IconLucideTrash2 class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Expanded content -->
|
<!-- ═══ EXPANDED PANEL ═══ -->
|
||||||
<div v-show="!isCollapsed && !component.pendingEntity" class="mt-3 space-y-4 pl-7">
|
<div v-show="!isCollapsed && !component.pendingEntity" class="ml-[1.125rem] border-l-2 border-primary/30 pl-5 pt-3 pb-1 space-y-3">
|
||||||
<!-- Info fields -->
|
|
||||||
<div v-if="isEditMode" class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<!-- ── Section: Informations ── -->
|
||||||
<div class="form-control">
|
<div class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||||
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Nom</span></label>
|
<div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
|
||||||
<input v-model="component.name" type="text" class="input input-bordered input-sm" @blur="updateComponent">
|
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">Informations</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="p-4">
|
||||||
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Référence</span></label>
|
<!-- Edit mode -->
|
||||||
<input v-model="component.reference" type="text" class="input input-bordered input-sm" @blur="updateComponent">
|
<div v-if="isEditMode" class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Nom</span></label>
|
||||||
|
<input v-model="component.name" type="text" class="input input-bordered input-sm" @blur="updateComponent">
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Référence</span></label>
|
||||||
|
<input v-model="component.reference" type="text" class="input input-bordered input-sm" @blur="updateComponent">
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Prix</span></label>
|
||||||
|
<input v-model="component.prix" type="number" step="0.01" class="input input-bordered input-sm" @blur="updateComponent">
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Fournisseur</span></label>
|
||||||
|
<ConstructeurSelect
|
||||||
|
class="w-full"
|
||||||
|
:model-value="componentConstructeurIds"
|
||||||
|
:initial-options="componentConstructeursDisplay"
|
||||||
|
@update:model-value="handleConstructeurChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Read-only mode -->
|
||||||
|
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Nom</p>
|
||||||
|
<p class="text-sm text-base-content font-medium">{{ component.name }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Référence</p>
|
||||||
|
<p class="text-sm text-base-content" :class="component.reference ? 'font-mono' : 'text-base-content/30'">{{ component.reference || '—' }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="component.referenceAuto">
|
||||||
|
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Réf. auto</p>
|
||||||
|
<p class="text-sm text-base-content font-mono">{{ component.referenceAuto }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Prix</p>
|
||||||
|
<p class="text-sm" :class="component.prix ? 'text-base-content font-semibold' : 'text-base-content/30'">{{ component.prix ? `${component.prix} €` : '—' }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Fournisseur</p>
|
||||||
|
<div v-if="componentConstructeursDisplay.length" class="space-y-1">
|
||||||
|
<p
|
||||||
|
v-for="constructeur in componentConstructeursDisplay"
|
||||||
|
:key="constructeur.id"
|
||||||
|
class="text-sm text-base-content"
|
||||||
|
>
|
||||||
|
{{ constructeur.name }}
|
||||||
|
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-xs text-base-content/50">
|
||||||
|
— Réf. {{ supplierReferenceMap.get(constructeur.id) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="formatConstructeurContact(constructeur)" class="text-[0.65rem] text-base-content/40 block">
|
||||||
|
{{ formatConstructeurContact(constructeur) }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-sm text-base-content/30">—</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
</div>
|
||||||
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Prix</span></label>
|
|
||||||
<input v-model="component.prix" type="number" step="0.01" class="input input-bordered input-sm" @blur="updateComponent">
|
<!-- ── Section: Produit catalogue ── -->
|
||||||
|
<div v-if="displayProduct" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||||
|
<div class="px-4 py-2 bg-info/10 border-b border-info/20">
|
||||||
|
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-info">Produit catalogue</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="p-4">
|
||||||
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Fournisseur</span></label>
|
<div class="flex items-start justify-between gap-3">
|
||||||
<ConstructeurSelect
|
<div class="space-y-1.5">
|
||||||
class="w-full"
|
<p class="text-sm font-bold text-base-content">{{ displayProductName }}</p>
|
||||||
:model-value="componentConstructeurIds"
|
<div class="flex flex-wrap gap-x-4 gap-y-1">
|
||||||
:initial-options="componentConstructeursDisplay"
|
<p
|
||||||
@update:model-value="handleConstructeurChange"
|
v-for="info in productInfoRows"
|
||||||
|
:key="info.label"
|
||||||
|
class="text-xs text-base-content/55"
|
||||||
|
>
|
||||||
|
<span class="font-semibold">{{ info.label }}</span> : {{ info.value }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NuxtLink
|
||||||
|
v-if="component.product?.id"
|
||||||
|
:to="`/product/${component.product.id}`"
|
||||||
|
class="btn btn-ghost btn-xs shrink-0 gap-1"
|
||||||
|
>
|
||||||
|
<IconLucideExternalLink class="w-3 h-3" aria-hidden="true" />
|
||||||
|
Voir
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
<!-- Product documents -->
|
||||||
|
<div v-if="productDocuments.length" class="mt-3 pt-3 border-t border-base-200/50 space-y-2">
|
||||||
|
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/35">Documents du produit</p>
|
||||||
|
<div
|
||||||
|
v-for="document in productDocuments"
|
||||||
|
:key="document.id || document.path || document.name"
|
||||||
|
class="flex items-center justify-between gap-3 text-xs"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
|
<div class="flex-shrink-0 overflow-hidden rounded border border-base-200 bg-base-200/70 flex items-center justify-center h-8 w-7">
|
||||||
|
<img
|
||||||
|
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
|
||||||
|
:src="document.fileUrl || document.path"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
:alt="`Aperçu de ${document.name}`"
|
||||||
|
>
|
||||||
|
<iframe
|
||||||
|
v-else-if="shouldInlinePdf(document)"
|
||||||
|
:src="documentPreviewSrc(document)"
|
||||||
|
class="h-full w-full border-0 bg-white"
|
||||||
|
title="Aperçu PDF"
|
||||||
|
/>
|
||||||
|
<component
|
||||||
|
v-else
|
||||||
|
:is="documentIcon(document).component"
|
||||||
|
class="h-4 w-4"
|
||||||
|
:class="documentIcon(document).colorClass"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="truncate text-base-content">{{ document.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
:disabled="!canPreviewDocument(document)"
|
||||||
|
@click="openPreview(document)"
|
||||||
|
>
|
||||||
|
Consulter
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
|
||||||
|
Télécharger
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Section: Champs personnalisés ── -->
|
||||||
|
<div v-if="displayedCustomFields.length" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||||
|
<div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
|
||||||
|
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">Champs personnalisés item</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<CustomFieldDisplay
|
||||||
|
:fields="displayedCustomFields"
|
||||||
|
:is-edit-mode="isEditMode"
|
||||||
|
:columns="2"
|
||||||
|
:show-header="false"
|
||||||
|
:with-top-border="false"
|
||||||
|
:editable="false"
|
||||||
|
@field-blur="updateComponentCustomField"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Read-only info -->
|
<div v-if="mergedContextFields.length" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||||
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-3 text-sm">
|
<div class="px-4 py-2 bg-secondary/10 border-b border-secondary/20">
|
||||||
<div>
|
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-secondary">Champs personnalisés machine</p>
|
||||||
<p class="text-xs text-base-content/40 mb-0.5">Nom</p>
|
|
||||||
<p class="text-base-content">{{ component.name }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="p-4">
|
||||||
<p class="text-xs text-base-content/40 mb-0.5">Référence</p>
|
<CustomFieldDisplay
|
||||||
<p class="text-base-content">{{ component.reference || '—' }}</p>
|
:fields="mergedContextFields"
|
||||||
</div>
|
:is-edit-mode="isEditMode"
|
||||||
<div>
|
:columns="2"
|
||||||
<p class="text-xs text-base-content/40 mb-0.5">Prix</p>
|
:show-header="false"
|
||||||
<p class="text-base-content">{{ component.prix ? `${component.prix} €` : '—' }}</p>
|
:with-top-border="false"
|
||||||
</div>
|
:editable="true"
|
||||||
<div>
|
:emit-blur="false"
|
||||||
<p class="text-xs text-base-content/40 mb-0.5">Fournisseur</p>
|
@field-input="queueContextCustomFieldUpdate"
|
||||||
<div v-if="componentConstructeursDisplay.length">
|
/>
|
||||||
<p
|
|
||||||
v-for="constructeur in componentConstructeursDisplay"
|
|
||||||
:key="constructeur.id"
|
|
||||||
class="text-base-content"
|
|
||||||
>
|
|
||||||
{{ constructeur.name }}
|
|
||||||
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-sm text-base-content/60">
|
|
||||||
— Réf. {{ supplierReferenceMap.get(constructeur.id) }}
|
|
||||||
</span>
|
|
||||||
<span v-if="formatConstructeurContact(constructeur)" class="text-xs text-base-content/50 block">
|
|
||||||
{{ formatConstructeurContact(constructeur) }}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p v-else class="text-base-content">—</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Product -->
|
<!-- ── Section: Documents ── -->
|
||||||
<div v-if="displayProduct" class="rounded-lg border border-base-200 bg-base-100 p-3">
|
<div class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50 flex items-center justify-between">
|
||||||
<div class="space-y-1">
|
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">Documents</p>
|
||||||
<p class="text-xs text-base-content/40">Produit catalogue</p>
|
|
||||||
<p class="text-sm font-semibold text-base-content">{{ displayProductName }}</p>
|
|
||||||
<p
|
|
||||||
v-for="info in productInfoRows"
|
|
||||||
:key="info.label"
|
|
||||||
class="text-xs text-base-content/60"
|
|
||||||
>
|
|
||||||
{{ info.label }} : {{ info.value }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<NuxtLink
|
|
||||||
v-if="component.product?.id"
|
|
||||||
:to="`/product/${component.product.id}`"
|
|
||||||
class="btn btn-ghost btn-xs shrink-0"
|
|
||||||
>
|
|
||||||
Voir le produit
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
<!-- Product documents -->
|
|
||||||
<div v-if="productDocuments.length" class="mt-3 pt-3 border-t border-base-200 space-y-2">
|
|
||||||
<p class="text-xs font-medium text-base-content/50">Documents du produit</p>
|
|
||||||
<div
|
|
||||||
v-for="document in productDocuments"
|
|
||||||
:key="document.id || document.path || document.name"
|
|
||||||
class="flex items-center justify-between gap-3 text-xs"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
|
||||||
<div class="flex-shrink-0 overflow-hidden rounded border border-base-200 bg-base-200/70 flex items-center justify-center h-8 w-7">
|
|
||||||
<img
|
|
||||||
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
|
|
||||||
:src="document.fileUrl || document.path"
|
|
||||||
class="h-full w-full object-cover"
|
|
||||||
:alt="`Aperçu de ${document.name}`"
|
|
||||||
>
|
|
||||||
<iframe
|
|
||||||
v-else-if="shouldInlinePdf(document)"
|
|
||||||
:src="documentPreviewSrc(document)"
|
|
||||||
class="h-full w-full border-0 bg-white"
|
|
||||||
title="Aperçu PDF"
|
|
||||||
/>
|
|
||||||
<component
|
|
||||||
v-else
|
|
||||||
:is="documentIcon(document).component"
|
|
||||||
class="h-4 w-4"
|
|
||||||
:class="documentIcon(document).colorClass"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span class="truncate text-base-content">{{ document.name }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1 shrink-0">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-ghost btn-xs"
|
|
||||||
:disabled="!canPreviewDocument(document)"
|
|
||||||
@click="openPreview(document)"
|
|
||||||
>
|
|
||||||
Consulter
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
|
|
||||||
Télécharger
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Custom Fields -->
|
|
||||||
<CustomFieldDisplay
|
|
||||||
:fields="displayedCustomFields"
|
|
||||||
:is-edit-mode="isEditMode"
|
|
||||||
:columns="2"
|
|
||||||
title="Champs personnalisés item"
|
|
||||||
:editable="false"
|
|
||||||
@field-blur="updateComponentCustomField"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<template v-if="mergedContextFields.length">
|
|
||||||
<div class="divider my-4 text-xs text-base-content/50">
|
|
||||||
Champs personnalisés machine
|
|
||||||
</div>
|
|
||||||
<CustomFieldDisplay
|
|
||||||
:fields="mergedContextFields"
|
|
||||||
:is-edit-mode="isEditMode"
|
|
||||||
:columns="2"
|
|
||||||
:show-header="false"
|
|
||||||
:with-top-border="false"
|
|
||||||
:editable="true"
|
|
||||||
:emit-blur="false"
|
|
||||||
@field-input="queueContextCustomFieldUpdate"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Documents -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Documents</p>
|
|
||||||
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline badge-xs">
|
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline badge-xs">
|
||||||
{{ selectedFiles.length }} fichier{{ selectedFiles.length > 1 ? 's' : '' }}
|
{{ selectedFiles.length }} fichier{{ selectedFiles.length > 1 ? 's' : '' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="p-4 space-y-3">
|
||||||
|
<p v-if="loadingDocuments" class="text-xs text-base-content/50">Chargement...</p>
|
||||||
|
|
||||||
<p v-if="loadingDocuments" class="text-xs text-base-content/50">
|
<DocumentUpload
|
||||||
Chargement...
|
v-if="isEditMode"
|
||||||
</p>
|
v-model="selectedFiles"
|
||||||
|
title="Déposer des fichiers pour ce composant"
|
||||||
|
subtitle="Formats acceptés : PDF, images, documents..."
|
||||||
|
@files-added="handleFilesAdded"
|
||||||
|
/>
|
||||||
|
|
||||||
<DocumentUpload
|
<DocumentListInline
|
||||||
v-if="isEditMode"
|
:documents="componentDocuments"
|
||||||
v-model="selectedFiles"
|
:can-delete="isEditMode"
|
||||||
title="Déposer des fichiers pour ce composant"
|
:can-edit="isEditMode"
|
||||||
subtitle="Formats acceptés : PDF, images, documents..."
|
:delete-disabled="uploadingDocuments"
|
||||||
@files-added="handleFilesAdded"
|
empty-text="Aucun document lié à ce composant."
|
||||||
/>
|
@preview="openPreview"
|
||||||
|
@edit="openEditModal"
|
||||||
<DocumentListInline
|
@delete="removeDocument"
|
||||||
:documents="componentDocuments"
|
/>
|
||||||
:can-delete="isEditMode"
|
</div>
|
||||||
:can-edit="isEditMode"
|
|
||||||
:delete-disabled="uploadingDocuments"
|
|
||||||
empty-text="Aucun document lié à ce composant."
|
|
||||||
@preview="openPreview"
|
|
||||||
@edit="openEditModal"
|
|
||||||
@delete="removeDocument"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Component Pieces (real MachinePieceLinks) -->
|
<!-- ── Section: Pièces du composant ── -->
|
||||||
<div v-if="linkedPieces.length > 0" class="space-y-2">
|
<div v-if="linkedPieces.length > 0" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||||
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
|
<div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
|
||||||
Pièces du composant
|
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">
|
||||||
</p>
|
Pièces du composant
|
||||||
<div class="space-y-2">
|
<span class="ml-1 text-base-content/25">({{ linkedPieces.length }})</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 space-y-2">
|
||||||
<PieceItem
|
<PieceItem
|
||||||
v-for="piece in linkedPieces"
|
v-for="piece in linkedPieces"
|
||||||
:key="piece.id"
|
:key="piece.id"
|
||||||
@@ -290,12 +353,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Structure pieces (read-only, from composant definition) -->
|
<!-- ── Section: Pièces structure ── -->
|
||||||
<div v-if="structurePieces.length > 0" class="space-y-2">
|
<div v-if="structurePieces.length > 0" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||||
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
|
<div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
|
||||||
Pièces incluses par défaut
|
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">
|
||||||
</p>
|
Pièces incluses par défaut
|
||||||
<div class="space-y-2">
|
<span class="ml-1 text-base-content/25">({{ structurePieces.length }})</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 space-y-2">
|
||||||
<PieceItem
|
<PieceItem
|
||||||
v-for="piece in structurePieces"
|
v-for="piece in structurePieces"
|
||||||
:key="piece.id"
|
:key="piece.id"
|
||||||
@@ -305,12 +371,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sub Components -->
|
<!-- ── Section: Sous-composants ── -->
|
||||||
<div v-if="childComponents.length > 0" class="space-y-2">
|
<div v-if="childComponents.length > 0" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||||
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
|
<div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
|
||||||
Sous-composants
|
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">
|
||||||
</p>
|
Sous-composants
|
||||||
<div class="space-y-2 pl-4 border-l-2 border-base-200">
|
<span class="ml-1 text-base-content/25">({{ childComponents.length }})</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 space-y-2">
|
||||||
<ComponentItem
|
<ComponentItem
|
||||||
v-for="subComponent in childComponents"
|
v-for="subComponent in childComponents"
|
||||||
:key="subComponent.id"
|
:key="subComponent.id"
|
||||||
@@ -336,6 +405,8 @@ import DocumentUpload from './DocumentUpload.vue'
|
|||||||
import ConstructeurSelect from './ConstructeurSelect.vue'
|
import ConstructeurSelect from './ConstructeurSelect.vue'
|
||||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||||
|
import IconLucideTrash2 from '~icons/lucide/trash-2'
|
||||||
|
import IconLucideExternalLink from '~icons/lucide/external-link'
|
||||||
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
|
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
|
||||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||||
import {
|
import {
|
||||||
@@ -461,6 +532,24 @@ const mergedContextFields = computed(() => {
|
|||||||
return mergeDefinitionsWithValues(definitions, values)
|
return mergeDefinitionsWithValues(definitions, values)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Context fields shown as tags on the header (consultation mode)
|
||||||
|
const visibleContextFieldTags = computed(() =>
|
||||||
|
mergedContextFields.value.filter(f => f.value !== null && f.value !== undefined && String(f.value).trim() !== ''),
|
||||||
|
)
|
||||||
|
|
||||||
|
const CONTEXT_FIELD_COLORS = [
|
||||||
|
'bg-secondary/25 text-secondary border border-secondary/35',
|
||||||
|
'bg-accent/25 text-accent border border-accent/35',
|
||||||
|
'bg-info/25 text-info border border-info/35',
|
||||||
|
'bg-success/25 text-success border border-success/35',
|
||||||
|
'bg-warning/25 text-warning border border-warning/35',
|
||||||
|
]
|
||||||
|
|
||||||
|
const contextFieldBadgeClass = (field: any) => {
|
||||||
|
const idx = visibleContextFieldTags.value.indexOf(field)
|
||||||
|
return CONTEXT_FIELD_COLORS[idx % CONTEXT_FIELD_COLORS.length]
|
||||||
|
}
|
||||||
|
|
||||||
const queueContextCustomFieldUpdate = (field, value) => {
|
const queueContextCustomFieldUpdate = (field, value) => {
|
||||||
const linkId = props.component?.linkId
|
const linkId = props.component?.linkId
|
||||||
if (!linkId || !field) return
|
if (!linkId || !field) return
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-4">
|
<div>
|
||||||
<DocumentPreviewModal
|
<DocumentPreviewModal
|
||||||
:document="previewDocument"
|
:document="previewDocument"
|
||||||
:visible="previewVisible"
|
:visible="previewVisible"
|
||||||
@@ -13,303 +13,333 @@
|
|||||||
@updated="handleDocumentUpdated"
|
@updated="handleDocumentUpdated"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Piece Header (collapsible, same pattern as ComponentItem) -->
|
<!-- ═══ HEADER BAR ═══ -->
|
||||||
<div class="flex items-start justify-between p-4 rounded-lg" :class="piece._emptySlot || piece.pendingEntity ? 'bg-error/10 border border-error' : 'bg-base-200'">
|
<div
|
||||||
<div class="flex items-start gap-3 flex-1 min-w-0">
|
class="group/header flex items-center gap-3 px-4 py-3 rounded-xl cursor-pointer select-none transition-all duration-200"
|
||||||
<button
|
:class="[
|
||||||
type="button"
|
piece._emptySlot || piece.pendingEntity
|
||||||
class="btn btn-ghost btn-sm btn-circle shrink-0 transition-transform"
|
? 'bg-error/10 border border-error/40 hover:border-error/60'
|
||||||
:class="{ 'rotate-90': !isCollapsed }"
|
: 'bg-base-200/70 border border-base-300/30 hover:border-base-300/60 hover:bg-base-200',
|
||||||
:aria-expanded="!isCollapsed"
|
!isCollapsed ? 'shadow-md ring-1 ring-base-300/20' : 'shadow-sm',
|
||||||
:title="isCollapsed ? 'Déplier les détails de la pièce' : 'Replier les détails de la pièce'"
|
]"
|
||||||
@click="toggleCollapse"
|
@click="toggleCollapse"
|
||||||
>
|
>
|
||||||
<IconLucideChevronRight class="w-5 h-5 transition-transform" aria-hidden="true" />
|
<!-- Chevron -->
|
||||||
<span class="sr-only">{{ isCollapsed ? 'Déplier' : 'Replier' }} la pièce</span>
|
<div
|
||||||
</button>
|
class="w-6 h-6 rounded-md grid place-items-center shrink-0 transition-all duration-200"
|
||||||
<div class="flex-1 min-w-0">
|
:class="isCollapsed ? 'bg-base-300/40' : 'bg-primary/15'"
|
||||||
<h3 class="text-lg font-semibold" :class="{ 'text-error': piece._emptySlot || piece.pendingEntity }">
|
>
|
||||||
|
<IconLucideChevronRight
|
||||||
|
class="w-3.5 h-3.5 transition-transform duration-200"
|
||||||
|
:class="[
|
||||||
|
isCollapsed ? 'text-base-content/40' : 'rotate-90 text-primary',
|
||||||
|
]"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="flex-1 min-w-0 space-y-1.5">
|
||||||
|
<!-- Row 1: Name + identifiers -->
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<h3 class="text-sm font-bold tracking-tight truncate" :class="{ 'text-error': piece._emptySlot || piece.pendingEntity }">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-if="!isEditMode && !piece.pendingEntity && !piece._emptySlot && piece.pieceId"
|
v-if="!isEditMode && !piece.pendingEntity && !piece._emptySlot && piece.pieceId"
|
||||||
:to="machineId
|
:to="machineId
|
||||||
? { path: `/piece/${piece.pieceId}`, query: { from: 'machine', machineId } }
|
? { path: `/piece/${piece.pieceId}`, query: { from: 'machine', machineId } }
|
||||||
: `/piece/${piece.pieceId}`"
|
: `/piece/${piece.pieceId}`"
|
||||||
class="hover:underline hover:text-primary transition-colors"
|
class="hover:text-primary transition-colors"
|
||||||
@click.stop
|
@click.stop
|
||||||
>
|
>
|
||||||
{{ pieceData.name }}
|
{{ pieceData.name }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<template v-else>{{ pieceData.name }}</template>
|
<template v-else>{{ pieceData.name }}</template>
|
||||||
<span v-if="piece._emptySlot" class="text-sm font-semibold text-error ml-1">— manquant</span>
|
|
||||||
<button
|
|
||||||
v-if="piece.pendingEntity"
|
|
||||||
type="button"
|
|
||||||
class="badge badge-error badge-sm cursor-pointer hover:badge-outline transition-colors ml-1"
|
|
||||||
title="Cliquer pour associer un item"
|
|
||||||
@click.stop="$emit('fill-entity', piece.linkId, piece.modelTypeId)"
|
|
||||||
>
|
|
||||||
À remplir
|
|
||||||
</button>
|
|
||||||
<span
|
|
||||||
v-if="displayQuantity > 1"
|
|
||||||
class="text-sm font-normal text-base-content/60 ml-1"
|
|
||||||
>
|
|
||||||
×{{ displayQuantity }}
|
|
||||||
</span>
|
|
||||||
</h3>
|
</h3>
|
||||||
<div class="flex flex-wrap gap-2 mt-2">
|
<span v-if="piece._emptySlot" class="text-[0.65rem] font-bold text-error bg-error/10 px-1.5 py-0.5 rounded">manquant</span>
|
||||||
<span v-if="piece.parentComponentName" class="badge badge-ghost badge-sm">
|
<button
|
||||||
Rattachée à {{ piece.parentComponentName }}
|
v-if="piece.pendingEntity"
|
||||||
</span>
|
type="button"
|
||||||
<span v-if="pieceData.reference" class="badge badge-outline badge-sm">{{ pieceData.reference }}</span>
|
class="badge badge-error badge-sm cursor-pointer hover:badge-outline transition-colors"
|
||||||
<span v-if="pieceData.referenceAuto" class="badge badge-secondary badge-sm" title="Référence auto">{{ pieceData.referenceAuto }}</span>
|
title="Cliquer pour associer un item"
|
||||||
<template v-if="pieceConstructeursDisplay.length">
|
@click.stop="$emit('fill-entity', piece.linkId, piece.modelTypeId)"
|
||||||
<span
|
>
|
||||||
v-for="constructeur in pieceConstructeursDisplay"
|
À remplir
|
||||||
:key="constructeur.id"
|
</button>
|
||||||
class="badge badge-outline badge-sm"
|
<span v-if="displayQuantity > 1" class="text-[0.65rem] font-bold text-base-content/70 bg-base-300/50 px-1.5 py-0.5 rounded border border-base-300/40">×{{ displayQuantity }}</span>
|
||||||
>
|
<span v-if="pieceData.reference" class="text-[0.65rem] font-mono text-base-content/70 bg-base-300/50 px-1.5 py-0.5 rounded border border-base-300/40">{{ pieceData.reference }}</span>
|
||||||
{{ constructeur.name }}
|
<span v-if="pieceData.referenceAuto" class="text-[0.65rem] font-mono font-semibold text-secondary bg-secondary/20 px-1.5 py-0.5 rounded border border-secondary/30" title="Référence auto">{{ pieceData.referenceAuto }}</span>
|
||||||
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-xs opacity-60 ml-0.5">
|
<span v-if="pieceData.prix" class="text-[0.65rem] font-bold text-primary bg-primary/20 px-1.5 py-0.5 rounded border border-primary/30">{{ pieceData.prix }}€</span>
|
||||||
({{ supplierReferenceMap.get(constructeur.id) }})
|
</div>
|
||||||
</span>
|
|
||||||
</span>
|
<!-- Row 2: Metadata tags -->
|
||||||
</template>
|
<div
|
||||||
<span v-if="pieceData.prix" class="badge badge-primary badge-sm">{{ pieceData.prix }}€</span>
|
v-if="piece.parentComponentName || pieceConstructeursDisplay.length || displayProductName || (!isEditMode && visibleContextFieldTags.length)"
|
||||||
|
class="flex flex-wrap items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<span v-if="piece.parentComponentName" class="text-[0.65rem] text-base-content/40 bg-base-300/20 px-1.5 py-0.5 rounded">
|
||||||
|
{{ piece.parentComponentName }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-for="constructeur in pieceConstructeursDisplay"
|
||||||
|
:key="constructeur.id"
|
||||||
|
class="text-[0.65rem] text-base-content/45"
|
||||||
|
>
|
||||||
|
{{ constructeur.name }}
|
||||||
|
<span v-if="supplierReferenceMap.get(constructeur.id)" class="opacity-60">({{ supplierReferenceMap.get(constructeur.id) }})</span>
|
||||||
|
</span>
|
||||||
|
<span v-if="displayProductName" class="text-[0.65rem] font-semibold text-info bg-info/20 px-1.5 py-0.5 rounded border border-info/30">
|
||||||
|
{{ displayProductName }}
|
||||||
|
</span>
|
||||||
|
<!-- Context field tags (consultation only) -->
|
||||||
|
<template v-if="!isEditMode">
|
||||||
<span
|
<span
|
||||||
v-if="displayProductName"
|
v-for="field in visibleContextFieldTags"
|
||||||
class="badge badge-info badge-sm"
|
:key="field.name"
|
||||||
|
class="text-[0.65rem] font-semibold px-1.5 py-0.5 rounded"
|
||||||
|
:class="contextFieldBadgeClass(field)"
|
||||||
>
|
>
|
||||||
Produit : {{ displayProductName }}
|
{{ field.name }} : {{ field.value }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete button -->
|
||||||
<button
|
<button
|
||||||
v-if="showDelete"
|
v-if="showDelete"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-ghost btn-xs text-error shrink-0"
|
class="btn btn-ghost btn-xs btn-circle text-error opacity-0 group-hover/header:opacity-100 transition-opacity shrink-0"
|
||||||
title="Supprimer cette pièce"
|
title="Supprimer cette pièce"
|
||||||
@click="$emit('delete')"
|
@click.stop="$emit('delete')"
|
||||||
>
|
>
|
||||||
Supprimer
|
<IconLucideTrash2 class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-show="!isCollapsed && !piece.pendingEntity" class="space-y-4">
|
<!-- ═══ EXPANDED PANEL ═══ -->
|
||||||
<div class="p-4 bg-base-100 border border-base-200 rounded-lg">
|
<div v-show="!isCollapsed && !piece.pendingEntity" class="ml-[1.125rem] border-l-2 border-primary/30 pl-5 pt-3 pb-1 space-y-3">
|
||||||
<div class="space-y-2 text-sm">
|
|
||||||
<div v-if="isEditMode" class="form-control">
|
<!-- ── Section: Informations ── -->
|
||||||
<label class="label">
|
<div class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||||
<span class="label-text text-sm">Quantité</span>
|
<div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
|
||||||
</label>
|
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">Informations</p>
|
||||||
<input
|
</div>
|
||||||
v-model.number="pieceData.quantity"
|
<div class="p-4">
|
||||||
type="number"
|
<!-- Edit mode -->
|
||||||
min="1"
|
<div v-if="isEditMode" class="space-y-3">
|
||||||
step="1"
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
class="input input-bordered input-sm md:input-md w-24"
|
<div class="form-control">
|
||||||
@blur="updatePiece"
|
<label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Quantité</span></label>
|
||||||
/>
|
<input
|
||||||
</div>
|
v-model.number="pieceData.quantity"
|
||||||
<div v-else-if="displayQuantity > 1">
|
type="number"
|
||||||
<span class="font-medium">Quantité:</span>
|
min="1"
|
||||||
<span class="ml-2">{{ displayQuantity }}</span>
|
step="1"
|
||||||
</div>
|
class="input input-bordered input-sm w-full"
|
||||||
<div>
|
@blur="updatePiece"
|
||||||
<span class="font-medium">Référence:</span>
|
/>
|
||||||
<input
|
</div>
|
||||||
v-if="isEditMode"
|
<div class="form-control">
|
||||||
:id="`piece-reference-${piece.id}`"
|
<label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Référence</span></label>
|
||||||
v-model="pieceData.reference"
|
<input
|
||||||
type="text"
|
:id="`piece-reference-${piece.id}`"
|
||||||
class="input input-sm input-bordered ml-2"
|
v-model="pieceData.reference"
|
||||||
@blur="updatePiece"
|
type="text"
|
||||||
/>
|
class="input input-bordered input-sm w-full"
|
||||||
<span v-else class="ml-2">{{
|
@blur="updatePiece"
|
||||||
pieceData.reference || "Non définie"
|
/>
|
||||||
}}</span>
|
</div>
|
||||||
</div>
|
<div class="form-control">
|
||||||
<div v-if="pieceData.referenceAuto">
|
<label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Prix</span></label>
|
||||||
<span class="font-medium">Référence auto:</span>
|
<input
|
||||||
<span class="ml-2">{{ pieceData.referenceAuto }}</span>
|
:id="`piece-prix-${piece.id}`"
|
||||||
</div>
|
v-model="pieceData.prix"
|
||||||
<div>
|
type="number"
|
||||||
<span class="font-medium">Fournisseur:</span>
|
step="0.01"
|
||||||
<div v-if="!isEditMode" class="ml-2">
|
class="input input-bordered input-sm w-full"
|
||||||
<div v-if="pieceConstructeursDisplay.length" class="space-y-1">
|
@blur="updatePiece"
|
||||||
<div
|
/>
|
||||||
v-for="constructeur in pieceConstructeursDisplay"
|
</div>
|
||||||
:key="constructeur.id"
|
</div>
|
||||||
class="flex flex-col"
|
<div class="form-control">
|
||||||
>
|
<label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Fournisseur</span></label>
|
||||||
<span class="font-medium">
|
<ConstructeurSelect
|
||||||
{{ constructeur.name }}
|
class="w-full"
|
||||||
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-sm font-normal text-base-content/60">
|
:model-value="pieceConstructeurIds"
|
||||||
— Réf. {{ supplierReferenceMap.get(constructeur.id) }}
|
:initial-options="pieceConstructeursDisplay"
|
||||||
</span>
|
placeholder="Sélectionner un ou plusieurs fournisseurs..."
|
||||||
</span>
|
@update:model-value="handleConstructeurChange"
|
||||||
<span
|
/>
|
||||||
v-if="formatConstructeurContact(constructeur)"
|
|
||||||
class="text-xs text-base-content/50"
|
|
||||||
>
|
|
||||||
{{ formatConstructeurContact(constructeur) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span v-else class="font-medium">
|
<!-- Read-only mode -->
|
||||||
Non défini
|
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-4">
|
||||||
</span>
|
<div v-if="displayQuantity > 1">
|
||||||
</div>
|
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Quantité</p>
|
||||||
<ConstructeurSelect
|
<p class="text-sm text-base-content font-medium">{{ displayQuantity }}</p>
|
||||||
v-else
|
</div>
|
||||||
class="w-full"
|
<div>
|
||||||
:model-value="pieceConstructeurIds"
|
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Référence</p>
|
||||||
:initial-options="pieceConstructeursDisplay"
|
<p class="text-sm" :class="pieceData.reference ? 'text-base-content font-mono' : 'text-base-content/30'">{{ pieceData.reference || '—' }}</p>
|
||||||
placeholder="Sélectionner un ou plusieurs fournisseurs..."
|
</div>
|
||||||
@update:model-value="handleConstructeurChange"
|
<div v-if="pieceData.referenceAuto">
|
||||||
/>
|
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Réf. auto</p>
|
||||||
</div>
|
<p class="text-sm text-base-content font-mono">{{ pieceData.referenceAuto }}</p>
|
||||||
<div>
|
</div>
|
||||||
<span class="font-medium">Prix:</span>
|
<div>
|
||||||
<input
|
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Prix</p>
|
||||||
v-if="isEditMode"
|
<p class="text-sm" :class="pieceData.prix ? 'text-base-content font-semibold' : 'text-base-content/30'">{{ pieceData.prix ? `${pieceData.prix} €` : '—' }}</p>
|
||||||
:id="`piece-prix-${piece.id}`"
|
</div>
|
||||||
v-model="pieceData.prix"
|
<div>
|
||||||
type="number"
|
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Fournisseur</p>
|
||||||
step="0.01"
|
<div v-if="pieceConstructeursDisplay.length" class="space-y-1">
|
||||||
class="input input-sm input-bordered ml-2"
|
<p
|
||||||
@blur="updatePiece"
|
v-for="constructeur in pieceConstructeursDisplay"
|
||||||
/>
|
:key="constructeur.id"
|
||||||
<span v-else class="ml-2">{{
|
class="text-sm text-base-content"
|
||||||
pieceData.prix ? `${pieceData.prix}€` : "Non défini"
|
>
|
||||||
}}</span>
|
{{ constructeur.name }}
|
||||||
</div>
|
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-xs text-base-content/50">
|
||||||
<div>
|
— Réf. {{ supplierReferenceMap.get(constructeur.id) }}
|
||||||
<span class="font-medium">Produit catalogue:</span>
|
</span>
|
||||||
<div v-if="isEditMode" class="mt-2 space-y-2">
|
<span v-if="formatConstructeurContact(constructeur)" class="text-[0.65rem] text-base-content/40 block">
|
||||||
<ProductSelect
|
{{ formatConstructeurContact(constructeur) }}
|
||||||
:model-value="pieceData.productId"
|
</span>
|
||||||
placeholder="Associer un produit…"
|
</p>
|
||||||
helper-text="Optionnel : reliez cette pièce à un produit catalogue."
|
</div>
|
||||||
@update:modelValue="handleProductChange"
|
<p v-else class="text-sm text-base-content/30">—</p>
|
||||||
/>
|
</div>
|
||||||
<div
|
|
||||||
v-if="selectedProduct"
|
|
||||||
class="rounded-md border border-base-200 bg-base-100 p-3 text-xs space-y-1"
|
|
||||||
>
|
|
||||||
<p class="text-sm font-semibold text-base-content">
|
|
||||||
{{ selectedProduct.name }}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
v-for="info in productInfoRows"
|
|
||||||
:key="info.label"
|
|
||||||
class="flex flex-wrap gap-1"
|
|
||||||
>
|
|
||||||
<span class="font-semibold">{{ info.label }} :</span>
|
|
||||||
<span>{{ info.value }}</span>
|
|
||||||
</p>
|
|
||||||
<NuxtLink
|
|
||||||
v-if="selectedProduct.id"
|
|
||||||
:to="`/product/${selectedProduct.id}`"
|
|
||||||
class="link link-primary text-xs"
|
|
||||||
>
|
|
||||||
Ouvrir la fiche produit
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
</div>
|
||||||
<p v-else class="text-xs text-base-content/60">
|
|
||||||
Aucun produit associé.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-2">
|
</div>
|
||||||
<div v-if="displayProduct" class="space-y-1">
|
|
||||||
<p class="font-medium text-base-content">
|
<!-- ── Section: Produit catalogue ── -->
|
||||||
{{ displayProductName || 'Produit catalogue' }}
|
<div v-if="isEditMode || displayProduct" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||||
</p>
|
<div class="px-4 py-2 bg-info/10 border-b border-info/20">
|
||||||
<p
|
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-info">Produit catalogue</p>
|
||||||
v-for="info in productInfoRows"
|
</div>
|
||||||
:key="info.label"
|
<div class="p-4">
|
||||||
class="text-xs text-base-content/70"
|
<!-- Edit mode -->
|
||||||
|
<div v-if="isEditMode" class="space-y-3">
|
||||||
|
<ProductSelect
|
||||||
|
:model-value="pieceData.productId"
|
||||||
|
placeholder="Associer un produit…"
|
||||||
|
helper-text="Optionnel : reliez cette pièce à un produit catalogue."
|
||||||
|
@update:modelValue="handleProductChange"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="selectedProduct"
|
||||||
|
class="rounded-lg border border-base-200/60 bg-base-200/20 p-3 space-y-1.5"
|
||||||
>
|
>
|
||||||
<span class="font-semibold">{{ info.label }} :</span>
|
<p class="text-sm font-bold text-base-content">{{ selectedProduct.name }}</p>
|
||||||
<span class="ml-1">{{ info.value }}</span>
|
<div class="flex flex-wrap gap-x-4 gap-y-1">
|
||||||
</p>
|
<p v-for="info in productInfoRows" :key="info.label" class="text-xs text-base-content/55">
|
||||||
|
<span class="font-semibold">{{ info.label }}</span> : {{ info.value }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<NuxtLink v-if="selectedProduct.id" :to="`/product/${selectedProduct.id}`" class="link link-primary text-xs">
|
||||||
|
Ouvrir la fiche produit
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Read-only mode -->
|
||||||
|
<div v-else-if="displayProduct">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<p class="text-sm font-bold text-base-content">{{ displayProductName }}</p>
|
||||||
|
<div class="flex flex-wrap gap-x-4 gap-y-1">
|
||||||
|
<p v-for="info in productInfoRows" :key="info.label" class="text-xs text-base-content/55">
|
||||||
|
<span class="font-semibold">{{ info.label }}</span> : {{ info.value }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NuxtLink
|
||||||
|
v-if="piece.product?.id || piece.productId"
|
||||||
|
:to="`/product/${piece.product?.id || piece.productId}`"
|
||||||
|
class="btn btn-ghost btn-xs shrink-0 gap-1"
|
||||||
|
>
|
||||||
|
<IconLucideExternalLink class="w-3 h-3" aria-hidden="true" />
|
||||||
|
Voir
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
<ProductDocumentsInline
|
<ProductDocumentsInline
|
||||||
|
v-if="productDocuments.length"
|
||||||
|
class="mt-3 pt-3 border-t border-base-200/50"
|
||||||
:documents="productDocuments"
|
:documents="productDocuments"
|
||||||
@preview="openPreview"
|
@preview="openPreview"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span v-else class="font-medium">
|
|
||||||
Non défini
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<!-- ── Section: Champs personnalisés item ── -->
|
||||||
|
<div v-if="displayedCustomFields.length" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||||
|
<div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
|
||||||
|
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">Champs personnalisés item</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<CustomFieldDisplay
|
||||||
|
:fields="displayedCustomFields"
|
||||||
|
:is-edit-mode="isEditMode"
|
||||||
|
:show-header="false"
|
||||||
|
:with-top-border="false"
|
||||||
|
:editable="false"
|
||||||
|
@field-input="handleCustomFieldInput"
|
||||||
|
@field-blur="handleCustomFieldBlur"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Champs personnalisés de la pièce -->
|
<!-- ── Section: Champs personnalisés machine ── -->
|
||||||
<CustomFieldDisplay
|
<div v-if="mergedContextFields.length" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||||
:fields="displayedCustomFields"
|
<div class="px-4 py-2 bg-secondary/10 border-b border-secondary/20">
|
||||||
:is-edit-mode="isEditMode"
|
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-secondary">Champs personnalisés machine</p>
|
||||||
title="Champs personnalisés item"
|
</div>
|
||||||
:editable="false"
|
<div class="p-4">
|
||||||
@field-input="handleCustomFieldInput"
|
<CustomFieldDisplay
|
||||||
@field-blur="handleCustomFieldBlur"
|
:fields="mergedContextFields"
|
||||||
/>
|
:is-edit-mode="isEditMode"
|
||||||
|
:columns="2"
|
||||||
<template v-if="mergedContextFields.length">
|
:show-header="false"
|
||||||
<div class="divider my-4 text-xs text-base-content/50">
|
:with-top-border="false"
|
||||||
Champs personnalisés machine
|
:editable="true"
|
||||||
</div>
|
:emit-blur="false"
|
||||||
<CustomFieldDisplay
|
@field-input="queueContextCustomFieldUpdate"
|
||||||
:fields="mergedContextFields"
|
/>
|
||||||
:is-edit-mode="isEditMode"
|
</div>
|
||||||
:columns="2"
|
|
||||||
:show-header="false"
|
|
||||||
:with-top-border="false"
|
|
||||||
:editable="true"
|
|
||||||
:emit-blur="false"
|
|
||||||
@field-input="queueContextCustomFieldUpdate"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="mt-4 pt-4 border-t border-base-200 space-y-3">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h5 class="text-sm font-medium text-base-content/80">Documents</h5>
|
|
||||||
<span
|
|
||||||
v-if="isEditMode && selectedFiles.length"
|
|
||||||
class="badge badge-outline"
|
|
||||||
>
|
|
||||||
{{ selectedFiles.length }} fichier{{
|
|
||||||
selectedFiles.length > 1 ? "s" : ""
|
|
||||||
}}
|
|
||||||
sélectionné{{ selectedFiles.length > 1 ? "s" : "" }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="loadingDocuments" class="text-xs text-base-content/50">
|
<!-- ── Section: Documents ── -->
|
||||||
Chargement des documents...
|
<div class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||||
</p>
|
<div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50 flex items-center justify-between">
|
||||||
|
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">Documents</p>
|
||||||
|
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline badge-xs">
|
||||||
|
{{ selectedFiles.length }} fichier{{ selectedFiles.length > 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 space-y-3">
|
||||||
|
<p v-if="loadingDocuments" class="text-xs text-base-content/50">Chargement des documents...</p>
|
||||||
|
|
||||||
<DocumentUpload
|
<DocumentUpload
|
||||||
v-if="isEditMode"
|
v-if="isEditMode"
|
||||||
v-model="selectedFiles"
|
v-model="selectedFiles"
|
||||||
title="Déposer des fichiers pour cette pièce"
|
title="Déposer des fichiers pour cette pièce"
|
||||||
subtitle="Formats acceptés : PDF, images, documents..."
|
subtitle="Formats acceptés : PDF, images, documents..."
|
||||||
@files-added="handleFilesAdded"
|
@files-added="handleFilesAdded"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DocumentListInline
|
<DocumentListInline
|
||||||
:documents="pieceDocuments"
|
:documents="pieceDocuments"
|
||||||
:can-delete="isEditMode"
|
:can-delete="isEditMode"
|
||||||
:can-edit="isEditMode"
|
:can-edit="isEditMode"
|
||||||
:delete-disabled="uploadingDocuments"
|
:delete-disabled="uploadingDocuments"
|
||||||
empty-text="Aucun document lié à cette pièce."
|
empty-text="Aucun document lié à cette pièce."
|
||||||
@preview="openPreview"
|
@preview="openPreview"
|
||||||
@edit="openEditModal"
|
@edit="openEditModal"
|
||||||
@delete="removeDocument"
|
@delete="removeDocument"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -321,6 +351,8 @@ import ProductSelect from '~/components/ProductSelect.vue'
|
|||||||
import DocumentUpload from '~/components/DocumentUpload.vue'
|
import DocumentUpload from '~/components/DocumentUpload.vue'
|
||||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||||
|
import IconLucideTrash2 from '~icons/lucide/trash-2'
|
||||||
|
import IconLucideExternalLink from '~icons/lucide/external-link'
|
||||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||||
import { useProducts } from '~/composables/useProducts'
|
import { useProducts } from '~/composables/useProducts'
|
||||||
import {
|
import {
|
||||||
@@ -468,6 +500,24 @@ const mergedContextFields = computed(() => {
|
|||||||
return mergeDefinitionsWithValues(definitions, values)
|
return mergeDefinitionsWithValues(definitions, values)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Context fields shown as tags on the header (consultation mode)
|
||||||
|
const visibleContextFieldTags = computed(() =>
|
||||||
|
mergedContextFields.value.filter(f => f.value !== null && f.value !== undefined && String(f.value).trim() !== ''),
|
||||||
|
)
|
||||||
|
|
||||||
|
const CONTEXT_FIELD_COLORS = [
|
||||||
|
'bg-secondary/25 text-secondary border border-secondary/35',
|
||||||
|
'bg-accent/25 text-accent border border-accent/35',
|
||||||
|
'bg-info/25 text-info border border-info/35',
|
||||||
|
'bg-success/25 text-success border border-success/35',
|
||||||
|
'bg-warning/25 text-warning border border-warning/35',
|
||||||
|
]
|
||||||
|
|
||||||
|
const contextFieldBadgeClass = (field) => {
|
||||||
|
const idx = visibleContextFieldTags.value.indexOf(field)
|
||||||
|
return CONTEXT_FIELD_COLORS[idx % CONTEXT_FIELD_COLORS.length]
|
||||||
|
}
|
||||||
|
|
||||||
const queueContextCustomFieldUpdate = (field, value) => {
|
const queueContextCustomFieldUpdate = (field, value) => {
|
||||||
const linkId = props.piece?.linkId
|
const linkId = props.piece?.linkId
|
||||||
if (!linkId || !field) return
|
if (!linkId || !field) return
|
||||||
@@ -689,12 +739,7 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
pieceData.name = props.piece.name || ''
|
|
||||||
pieceData.reference = props.piece.reference || ''
|
|
||||||
pieceData.prix = props.piece.prix || ''
|
|
||||||
pieceData.quantity = props.piece.quantity ?? 1
|
|
||||||
loadProducts().catch(() => {})
|
loadProducts().catch(() => {})
|
||||||
if (pieceData.productId) ensureProductLoaded(pieceData.productId)
|
|
||||||
if (!props.piece.documents?.length) refreshDocuments()
|
if (!props.piece.documents?.length) refreshDocuments()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -57,16 +57,6 @@
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button
|
|
||||||
v-if="canEdit"
|
|
||||||
type="button"
|
|
||||||
class="btn btn-primary btn-sm"
|
|
||||||
:disabled="loading"
|
|
||||||
@click="openCreatePage"
|
|
||||||
>
|
|
||||||
<IconLucidePlus class="w-4 h-4" aria-hidden="true" />
|
|
||||||
Créer
|
|
||||||
</button>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-name="{ row }">
|
<template #cell-name="{ row }">
|
||||||
@@ -78,19 +68,15 @@
|
|||||||
<span v-else class="text-base-content/50">—</span>
|
<span v-else class="text-base-content/50">—</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template #cell-createdAt="{ row }">
|
||||||
|
<span class="whitespace-nowrap">{{ formatDate(row.createdAt) }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #cell-actions="{ row }">
|
<template #cell-actions="{ row }">
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<button type="button" class="btn btn-ghost btn-xs" @click="openRelatedModal(row)">
|
<button type="button" class="btn btn-ghost btn-xs" @click="openRelatedModal(row)">
|
||||||
Liés
|
Liés
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
v-if="canEdit && showConvertButton"
|
|
||||||
type="button"
|
|
||||||
class="btn btn-ghost btn-xs text-warning"
|
|
||||||
@click="openConversionModal(row)"
|
|
||||||
>
|
|
||||||
Convertir
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-ghost btn-xs" @click="openEditPage(row)">
|
<button type="button" class="btn btn-ghost btn-xs" @click="openEditPage(row)">
|
||||||
Éditer
|
Éditer
|
||||||
</button>
|
</button>
|
||||||
@@ -101,13 +87,6 @@
|
|||||||
</template>
|
</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
|
|
||||||
<ConversionModal
|
|
||||||
:open="conversionModalOpen"
|
|
||||||
:model-type="conversionTarget"
|
|
||||||
@close="closeConversionModal"
|
|
||||||
@converted="onConverted"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<RelatedItemsModal
|
<RelatedItemsModal
|
||||||
:open="relatedModalOpen"
|
:open="relatedModalOpen"
|
||||||
:model-type="relatedType"
|
:model-type="relatedType"
|
||||||
@@ -121,7 +100,6 @@
|
|||||||
import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from 'vue'
|
||||||
import { useHead, useRouter } from '#imports'
|
import { useHead, useRouter } from '#imports'
|
||||||
import DataTable from '~/components/common/DataTable.vue'
|
import DataTable from '~/components/common/DataTable.vue'
|
||||||
import ConversionModal from '~/components/model-types/ConversionModal.vue'
|
|
||||||
import { useUrlState } from '~/composables/useUrlState'
|
import { useUrlState } from '~/composables/useUrlState'
|
||||||
import type { DataTableSort } from '~/shared/types/dataTable'
|
import type { DataTableSort } from '~/shared/types/dataTable'
|
||||||
import {
|
import {
|
||||||
@@ -135,7 +113,7 @@ import { useToast } from '~/composables/useToast'
|
|||||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
|
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
|
||||||
import IconLucideSearch from '~icons/lucide/search'
|
import IconLucideSearch from '~icons/lucide/search'
|
||||||
import IconLucidePlus from '~icons/lucide/plus'
|
import { formatFrenchDate } from '~/utils/date'
|
||||||
|
|
||||||
const DEFAULT_DESCRIPTION
|
const DEFAULT_DESCRIPTION
|
||||||
= 'Gérez les catégories utilisées pour structurer les catalogues de composants, de pièces et de produits. Ajoutez, modifiez ou supprimez des entrées avec tri, recherche et pagination.'
|
= 'Gérez les catégories utilisées pour structurer les catalogues de composants, de pièces et de produits. Ajoutez, modifiez ou supprimez des entrées avec tri, recherche et pagination.'
|
||||||
@@ -199,12 +177,11 @@ useHead(() => ({ title: headingText.value }))
|
|||||||
const columns = [
|
const columns = [
|
||||||
{ key: 'name', label: 'Nom', sortable: true },
|
{ key: 'name', label: 'Nom', sortable: true },
|
||||||
{ key: 'notes', label: 'Notes' },
|
{ key: 'notes', label: 'Notes' },
|
||||||
|
{ key: 'createdAt', label: 'Date', sortable: true },
|
||||||
{ key: 'actions', label: 'Actions', align: 'right' as const, width: 'w-48' },
|
{ key: 'actions', label: 'Actions', align: 'right' as const, width: 'w-48' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const showConvertButton = computed(() =>
|
const formatDate = formatFrenchDate
|
||||||
selectedCategory.value === 'PIECE' || selectedCategory.value === 'COMPONENT',
|
|
||||||
)
|
|
||||||
|
|
||||||
const categories: Array<{ label: string, value: ModelCategory }> = [
|
const categories: Array<{ label: string, value: ModelCategory }> = [
|
||||||
{ label: 'Composants', value: 'COMPONENT' },
|
{ label: 'Composants', value: 'COMPONENT' },
|
||||||
@@ -339,13 +316,6 @@ const resolveCategoryBasePath = (category: ModelCategory) => {
|
|||||||
return '/product-category'
|
return '/product-category'
|
||||||
}
|
}
|
||||||
|
|
||||||
const openCreatePage = () => {
|
|
||||||
const basePath = resolveCategoryBasePath(selectedCategory.value)
|
|
||||||
router.push(`${basePath}/new`).catch(() => {
|
|
||||||
showError('Navigation impossible vers la page de création.')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const openEditPage = (item: ModelType) => {
|
const openEditPage = (item: ModelType) => {
|
||||||
const category = item.category ?? selectedCategory.value
|
const category = item.category ?? selectedCategory.value
|
||||||
const basePath = resolveCategoryBasePath(category)
|
const basePath = resolveCategoryBasePath(category)
|
||||||
@@ -400,26 +370,6 @@ const openRelatedEdit = (entry: { id: string }) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const conversionModalOpen = ref(false)
|
|
||||||
const conversionTarget = ref<ModelType | null>(null)
|
|
||||||
|
|
||||||
const openConversionModal = (item: ModelType) => {
|
|
||||||
conversionTarget.value = item
|
|
||||||
conversionModalOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeConversionModal = () => {
|
|
||||||
conversionModalOpen.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const onConverted = () => {
|
|
||||||
conversionModalOpen.value = false
|
|
||||||
invalidateEntityTypeCache('PIECE')
|
|
||||||
invalidateEntityTypeCache('COMPONENT')
|
|
||||||
showSuccess('Catégorie convertie avec succès.')
|
|
||||||
doRefresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => searchInput.value,
|
() => searchInput.value,
|
||||||
(value) => {
|
(value) => {
|
||||||
|
|||||||
@@ -119,7 +119,6 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
if (!machineName.value.trim()) return false
|
if (!machineName.value.trim()) return false
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
const debug = ref(false)
|
|
||||||
|
|
||||||
const componentsCollapsed = ref(true)
|
const componentsCollapsed = ref(true)
|
||||||
const collapseToggleToken = ref(0)
|
const collapseToggleToken = ref(0)
|
||||||
@@ -227,22 +226,6 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
const componentTypeOptions = computed(() => componentTypes.value || [])
|
const componentTypeOptions = computed(() => componentTypes.value || [])
|
||||||
const pieceTypeOptions = computed(() => pieceTypes.value || [])
|
const pieceTypeOptions = computed(() => pieceTypes.value || [])
|
||||||
|
|
||||||
const componentTypeLabelMap = computed(() => {
|
|
||||||
const map = new Map<string, string>()
|
|
||||||
componentTypeOptions.value.forEach((type) => {
|
|
||||||
if (type?.id) map.set(type.id as string, (type.name as string) || '')
|
|
||||||
})
|
|
||||||
return map
|
|
||||||
})
|
|
||||||
|
|
||||||
const pieceTypeLabelMap = computed(() => {
|
|
||||||
const map = new Map<string, string>()
|
|
||||||
pieceTypeOptions.value.forEach((type) => {
|
|
||||||
if (type?.id) map.set(type.id as string, (type.name as string) || '')
|
|
||||||
})
|
|
||||||
return map
|
|
||||||
})
|
|
||||||
|
|
||||||
// Machine field methods
|
// Machine field methods
|
||||||
const initMachineFields = () => {
|
const initMachineFields = () => {
|
||||||
if (machine.value) {
|
if (machine.value) {
|
||||||
@@ -306,7 +289,6 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
// UI methods
|
// UI methods
|
||||||
const toggleEditMode = () => {
|
const toggleEditMode = () => {
|
||||||
isEditMode.value = !isEditMode.value
|
isEditMode.value = !isEditMode.value
|
||||||
debug.value = !debug.value
|
|
||||||
if (isEditMode.value && !machineDocumentsLoaded.value) {
|
if (isEditMode.value && !machineDocumentsLoaded.value) {
|
||||||
refreshMachineDocuments()
|
refreshMachineDocuments()
|
||||||
}
|
}
|
||||||
@@ -432,12 +414,6 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
await productsPromise
|
await productsPromise
|
||||||
const linksApplied = applyMachineLinks(machineResult.data)
|
const linksApplied = applyMachineLinks(machineResult.data)
|
||||||
|
|
||||||
if (machine.value) {
|
|
||||||
machine.value.componentLinks = machineComponentLinks.value
|
|
||||||
machine.value.pieceLinks = machinePieceLinks.value
|
|
||||||
machine.value.productLinks = machineProductLinks.value
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!linksApplied) {
|
if (!linksApplied) {
|
||||||
components.value = transformComponentCustomFields(machinePayload.components || [])
|
components.value = transformComponentCustomFields(machinePayload.components || [])
|
||||||
pieces.value = transformCustomFields(machinePayload.pieces || [])
|
pieces.value = transformCustomFields(machinePayload.pieces || [])
|
||||||
@@ -447,6 +423,8 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (machine.value) {
|
if (machine.value) {
|
||||||
|
machine.value.componentLinks = machineComponentLinks.value
|
||||||
|
machine.value.pieceLinks = machinePieceLinks.value
|
||||||
machine.value.productLinks = machineProductLinks.value
|
machine.value.productLinks = machineProductLinks.value
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,11 +474,11 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
// UI state
|
// UI state
|
||||||
machineDocumentFiles, machineDocumentsUploading, machineDocumentsLoaded,
|
machineDocumentFiles, machineDocumentsUploading, machineDocumentsLoaded,
|
||||||
machineCustomFields, pendingContextFieldUpdates, previewDocument, previewVisible,
|
machineCustomFields, pendingContextFieldUpdates, previewDocument, previewVisible,
|
||||||
isEditMode, debug,
|
isEditMode,
|
||||||
componentsCollapsed, collapseToggleToken, piecesCollapsed, pieceCollapseToggleToken,
|
componentsCollapsed, collapseToggleToken, piecesCollapsed, pieceCollapseToggleToken,
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
componentTypeOptions, pieceTypeOptions, componentTypeLabelMap, pieceTypeLabelMap,
|
componentTypeOptions, pieceTypeOptions,
|
||||||
productInventory, productById, flattenedComponents, machinePieces,
|
productInventory, productById, flattenedComponents, machinePieces,
|
||||||
machineDirectProducts, machineDocumentsList, visibleMachineCustomFields,
|
machineDirectProducts, machineDocumentsList, visibleMachineCustomFields,
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
buildProductRequirementDescriptions,
|
buildProductRequirementDescriptions,
|
||||||
buildProductRequirementEntries,
|
buildProductRequirementEntries,
|
||||||
resizeProductSelections,
|
resizeProductSelections,
|
||||||
areProductSelectionsFilled,
|
|
||||||
applyProductSelection,
|
applyProductSelection,
|
||||||
collectNormalizedProductIds,
|
collectNormalizedProductIds,
|
||||||
} from '~/shared/utils/pieceProductSelectionUtils'
|
} from '~/shared/utils/pieceProductSelectionUtils'
|
||||||
@@ -199,13 +198,7 @@ export function usePieceEdit(pieceId: string) {
|
|||||||
buildProductRequirementEntries(structureProducts.value, 'piece-product-requirement'),
|
buildProductRequirementEntries(structureProducts.value, 'piece-product-requirement'),
|
||||||
)
|
)
|
||||||
|
|
||||||
const productSelectionsFilled = computed(() =>
|
const productSelectionsFilled = computed(() => true)
|
||||||
areProductSelectionsFilled(
|
|
||||||
requiresProductSelection.value,
|
|
||||||
productRequirementEntries.value,
|
|
||||||
productSelections.value,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const setProductSelection = (index: number, value: string | null) => {
|
const setProductSelection = (index: number, value: string | null) => {
|
||||||
productSelections.value = applyProductSelection(productSelections.value, index, value)
|
productSelections.value = applyProductSelection(productSelections.value, index, value)
|
||||||
@@ -355,11 +348,6 @@ export function usePieceEdit(pieceId: string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!productSelectionsFilled.value) {
|
|
||||||
toast.showError('Sélectionnez un produit conforme au squelette.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawPrice = typeof editionForm.prix === 'string'
|
const rawPrice = typeof editionForm.prix === 'string'
|
||||||
? editionForm.prix.trim()
|
? editionForm.prix.trim()
|
||||||
: editionForm.prix === null || editionForm.prix === undefined
|
: editionForm.prix === null || editionForm.prix === undefined
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function useToast() {
|
|||||||
message,
|
message,
|
||||||
type,
|
type,
|
||||||
visible: true,
|
visible: true,
|
||||||
duration: type === 'error' ? 0 : duration,
|
duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toasts.value.length >= MAX_TOASTS) {
|
if (toasts.value.length >= MAX_TOASTS) {
|
||||||
@@ -42,8 +42,7 @@ export function useToast() {
|
|||||||
|
|
||||||
toasts.value.push(toast)
|
toasts.value.push(toast)
|
||||||
|
|
||||||
// Only auto-dismiss non-error toasts
|
if (duration > 0) {
|
||||||
if (type !== 'error' && duration > 0) {
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
removeToast(id)
|
removeToast(id)
|
||||||
}, duration)
|
}, duration)
|
||||||
@@ -56,8 +55,8 @@ export function useToast() {
|
|||||||
return showToast(message, 'success', duration)
|
return showToast(message, 'success', duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
const showError = (message: string): number => {
|
const showError = (message: string, duration = 8000): number => {
|
||||||
return showToast(message, 'error', 0)
|
return showToast(message, 'error', duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
const showWarning = (message: string, duration = 6000): number => {
|
const showWarning = (message: string, duration = 6000): number => {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
<option value="piece">Pièce</option>
|
<option value="piece">Pièce</option>
|
||||||
<option value="product">Produit</option>
|
<option value="product">Produit</option>
|
||||||
<option value="composant">Composant</option>
|
<option value="composant">Composant</option>
|
||||||
|
<option value="machine">Machine</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -89,13 +90,16 @@
|
|||||||
|
|
||||||
<template #cell-entity="{ row }">
|
<template #cell-entity="{ row }">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-if="row.action !== 'delete'"
|
v-if="row.action !== 'delete' && entityEditLink(row) !== '#'"
|
||||||
:to="entityEditLink(row)"
|
:to="entityEditLink(row)"
|
||||||
class="link link-hover link-primary"
|
class="link link-hover link-primary"
|
||||||
>
|
>
|
||||||
{{ row.entityName || 'Sans nom' }}
|
{{ row.entityName || 'Sans nom' }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<span v-else class="text-base-content/50 line-through">
|
<span v-else-if="row.action === 'delete'" class="text-base-content/50 line-through">
|
||||||
|
{{ row.entityName || 'Sans nom' }}
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
{{ row.entityName || 'Sans nom' }}
|
{{ row.entityName || 'Sans nom' }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
@@ -195,19 +199,23 @@ const ENTITY_TYPE_LABELS: Record<string, string> = {
|
|||||||
piece: 'Pièce',
|
piece: 'Pièce',
|
||||||
product: 'Produit',
|
product: 'Produit',
|
||||||
composant: 'Composant',
|
composant: 'Composant',
|
||||||
|
machine: 'Machine',
|
||||||
|
document: 'Document',
|
||||||
|
model_type: 'Modèle',
|
||||||
}
|
}
|
||||||
|
|
||||||
const entityTypeLabel = (type: string) => ENTITY_TYPE_LABELS[type] ?? type
|
const entityTypeLabel = (type: string) => ENTITY_TYPE_LABELS[type] ?? type
|
||||||
|
|
||||||
const ENTITY_EDIT_ROUTES: Record<string, string> = {
|
const ENTITY_ROUTES: Record<string, string> = {
|
||||||
piece: '/pieces',
|
piece: '/piece',
|
||||||
product: '/product',
|
product: '/product',
|
||||||
composant: '/component',
|
composant: '/component',
|
||||||
|
machine: '/machine',
|
||||||
}
|
}
|
||||||
|
|
||||||
const entityEditLink = (entry: ActivityLogEntry) => {
|
const entityEditLink = (entry: ActivityLogEntry) => {
|
||||||
const base = ENTITY_EDIT_ROUTES[entry.entityType] ?? ''
|
const base = ENTITY_ROUTES[entry.entityType] ?? ''
|
||||||
return base ? `${base}/${entry.entityId}/edit` : '#'
|
return base ? `${base}/${entry.entityId}` : '#'
|
||||||
}
|
}
|
||||||
|
|
||||||
const actionBadgeClass = (action: string) => {
|
const actionBadgeClass = (action: string) => {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
<h1 class="text-3xl font-bold tracking-tight">Composants</h1>
|
<h1 class="text-3xl font-bold tracking-tight">Composants</h1>
|
||||||
<p class="text-sm text-base-content/70">Catalogue et catégories de composants.</p>
|
<p class="text-sm text-base-content/70">Catalogue et catégories de composants.</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink v-if="canEdit" to="/component/create" class="btn btn-primary btn-sm md:btn-md">
|
<NuxtLink v-if="canEdit" :to="activeTab === 'categories' ? '/component-category/new' : '/component/create'" class="btn btn-primary btn-sm md:btn-md">
|
||||||
Ajouter un composant
|
{{ activeTab === 'categories' ? 'Ajouter une catégorie' : 'Ajouter un composant' }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
<h1 class="text-3xl font-bold tracking-tight">Pièces</h1>
|
<h1 class="text-3xl font-bold tracking-tight">Pièces</h1>
|
||||||
<p class="text-sm text-base-content/70">Catalogue et catégories de pièces.</p>
|
<p class="text-sm text-base-content/70">Catalogue et catégories de pièces.</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink v-if="canEdit" to="/pieces/create" class="btn btn-primary btn-sm md:btn-md">
|
<NuxtLink v-if="canEdit" :to="activeTab === 'categories' ? '/piece-category/new' : '/pieces/create'" class="btn btn-primary btn-sm md:btn-md">
|
||||||
Ajouter une pièce
|
{{ activeTab === 'categories' ? 'Ajouter une catégorie' : 'Ajouter une pièce' }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
<h1 class="text-3xl font-bold tracking-tight">Produits</h1>
|
<h1 class="text-3xl font-bold tracking-tight">Produits</h1>
|
||||||
<p class="text-sm text-base-content/70">Catalogue et catégories de produits.</p>
|
<p class="text-sm text-base-content/70">Catalogue et catégories de produits.</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink v-if="canEdit" to="/product/create" class="btn btn-primary btn-sm md:btn-md">
|
<NuxtLink v-if="canEdit" :to="activeTab === 'categories' ? '/product-category/new' : '/product/create'" class="btn btn-primary btn-sm md:btn-md">
|
||||||
Ajouter un produit
|
{{ activeTab === 'categories' ? 'Ajouter une catégorie' : 'Ajouter un produit' }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -408,6 +408,9 @@
|
|||||||
</header>
|
</header>
|
||||||
<template v-if="isEditMode">
|
<template v-if="isEditMode">
|
||||||
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
|
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
|
||||||
|
<p v-if="hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-warning">
|
||||||
|
Certains champs personnalisés sont obligatoires. Veuillez les renseigner avant de valider.
|
||||||
|
</p>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
@@ -468,6 +471,9 @@
|
|||||||
Enregistrer les modifications
|
Enregistrer les modifications
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="isEditMode && hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
|
||||||
|
Merci de renseigner tous les champs personnalisés obligatoires.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@@ -511,6 +517,7 @@ const {
|
|||||||
constructeurLinks,
|
constructeurLinks,
|
||||||
constructeurIdsFromForm,
|
constructeurIdsFromForm,
|
||||||
customFieldInputs,
|
customFieldInputs,
|
||||||
|
requiredCustomFieldsFilled,
|
||||||
historyFieldLabels,
|
historyFieldLabels,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
componentTypeList,
|
componentTypeList,
|
||||||
@@ -538,6 +545,8 @@ const {
|
|||||||
formatStructurePreview,
|
formatStructurePreview,
|
||||||
} = useComponentEdit(String(route.params.id))
|
} = useComponentEdit(String(route.params.id))
|
||||||
|
|
||||||
|
const hasRequiredCustomFields = computed(() => customFieldInputs.value.some(f => f.required))
|
||||||
|
|
||||||
const submitEdition = async () => {
|
const submitEdition = async () => {
|
||||||
await _submitEdition()
|
await _submitEdition()
|
||||||
if (!saving.value) {
|
if (!saving.value) {
|
||||||
|
|||||||
@@ -223,6 +223,9 @@
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
|
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
|
||||||
|
<p v-if="hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-warning">
|
||||||
|
Certains champs personnalisés sont obligatoires. Veuillez les renseigner avant de valider.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
v-else
|
v-else
|
||||||
@@ -242,6 +245,9 @@
|
|||||||
Créer le composant
|
Créer le composant
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="selectedType && hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
|
||||||
|
Merci de renseigner tous les champs personnalisés obligatoires avant de créer le composant.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@@ -290,8 +296,11 @@ const {
|
|||||||
resolveProductLabel,
|
resolveProductLabel,
|
||||||
resolveSubcomponentLabel,
|
resolveSubcomponentLabel,
|
||||||
submitCreation,
|
submitCreation,
|
||||||
|
requiredCustomFieldsFilled,
|
||||||
} = useComponentCreate()
|
} = useComponentCreate()
|
||||||
|
|
||||||
|
const hasRequiredCustomFields = computed(() => customFieldInputs.value.some(f => f.required))
|
||||||
|
|
||||||
const entityTabs = computed(() => [
|
const entityTabs = computed(() => [
|
||||||
{ key: 'general', label: 'Général' },
|
{ key: 'general', label: 'Général' },
|
||||||
{ key: 'structure', label: 'Structure' },
|
{ key: 'structure', label: 'Structure' },
|
||||||
|
|||||||
@@ -715,10 +715,12 @@
|
|||||||
</p>
|
</p>
|
||||||
<p class="text-base-content/70 leading-relaxed mb-4">
|
<p class="text-base-content/70 leading-relaxed mb-4">
|
||||||
<strong>Pourquoi ?</strong> Certaines informations n'ont de sens que quand
|
<strong>Pourquoi ?</strong> Certaines informations n'ont de sens que quand
|
||||||
l'element est monte sur une machine. Par exemple, la "position sur la machine"
|
l'element est monte sur une machine. Prenons l'exemple d'un palier : sur une
|
||||||
d'une pompe : dans le catalogue, la pompe n'est montee nulle part, donc ce champ
|
machine, vous en avez souvent deux, un en haut (le palier de tete) et un en
|
||||||
ne sert a rien. Mais quand on regarde cette pompe depuis la fiche d'une machine,
|
bas (le palier de pied). Dans le catalogue, le palier n'est monte nulle part,
|
||||||
on veut savoir ou elle est installee.
|
donc savoir s'il est "en haut" ou "en bas" ne veut rien dire. Mais des qu'on
|
||||||
|
regarde ce palier depuis la fiche d'une machine, on veut savoir lequel des
|
||||||
|
deux c'est.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -731,16 +733,16 @@
|
|||||||
<p class="text-xs text-base-content/40 mb-4">Quand on consulte l'element tout seul</p>
|
<p class="text-xs text-base-content/40 mb-4">Quand on consulte l'element tout seul</p>
|
||||||
<div class="space-y-2 text-sm">
|
<div class="space-y-2 text-sm">
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span class="text-base-content/70">Debit max</span>
|
<span class="text-base-content/70">Diametre interieur</span>
|
||||||
<span>120 L/min</span>
|
<span>50 mm</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span class="text-base-content/70">ATEX</span>
|
<span class="text-base-content/70">Type</span>
|
||||||
<span>Oui</span>
|
<span>Roulement a billes</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="border-t border-dashed border-base-300 pt-2 mt-2">
|
<div class="border-t border-dashed border-base-300 pt-2 mt-2">
|
||||||
<div class="flex justify-between opacity-30">
|
<div class="flex justify-between opacity-30">
|
||||||
<span class="line-through">Position sur la machine</span>
|
<span class="line-through">Emplacement</span>
|
||||||
<span class="text-xs italic">pas affiche ici</span>
|
<span class="text-xs italic">pas affiche ici</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -753,17 +755,17 @@
|
|||||||
<p class="text-xs text-base-content/40 mb-4">Quand on regarde l'element dans sa machine</p>
|
<p class="text-xs text-base-content/40 mb-4">Quand on regarde l'element dans sa machine</p>
|
||||||
<div class="space-y-2 text-sm">
|
<div class="space-y-2 text-sm">
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span class="text-base-content/70">Debit max</span>
|
<span class="text-base-content/70">Diametre interieur</span>
|
||||||
<span>120 L/min</span>
|
<span>50 mm</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span class="text-base-content/70">ATEX</span>
|
<span class="text-base-content/70">Type</span>
|
||||||
<span>Oui</span>
|
<span>Roulement a billes</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="border-t border-primary/20 pt-2 mt-2">
|
<div class="border-t border-primary/20 pt-2 mt-2">
|
||||||
<div class="flex justify-between bg-primary/10 rounded px-2 py-1.5">
|
<div class="flex justify-between bg-primary/10 rounded px-2 py-1.5">
|
||||||
<span class="text-base-content font-medium">Position sur la machine</span>
|
<span class="text-base-content font-medium">Emplacement</span>
|
||||||
<span class="font-bold">Secteur B - Ligne 3</span>
|
<span class="font-bold">Haut (palier de tete)</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control md:w-64">
|
<div class="form-control md:w-48">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Site</span>
|
<span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Site</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -58,6 +58,24 @@
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Date de création</span>
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model="dateFrom"
|
||||||
|
type="date"
|
||||||
|
class="input input-bordered input-sm"
|
||||||
|
>
|
||||||
|
<span class="text-xs text-base-content/50">à</span>
|
||||||
|
<input
|
||||||
|
v-model="dateTo"
|
||||||
|
type="date"
|
||||||
|
class="input input-bordered input-sm"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -277,6 +295,8 @@ const showAddSiteModal = ref(false)
|
|||||||
const showAddMachineModal = ref(false)
|
const showAddMachineModal = ref(false)
|
||||||
const searchTerm = ref('')
|
const searchTerm = ref('')
|
||||||
const selectedSiteFilter = ref('')
|
const selectedSiteFilter = ref('')
|
||||||
|
const dateFrom = ref('')
|
||||||
|
const dateTo = ref('')
|
||||||
const collapsedSites = ref([])
|
const collapsedSites = ref([])
|
||||||
const preselectedSiteId = ref('')
|
const preselectedSiteId = ref('')
|
||||||
|
|
||||||
@@ -327,6 +347,25 @@ const filteredSites = computed(() => {
|
|||||||
filtered = filtered.filter(site => site.id === selectedSiteFilter.value)
|
filtered = filtered.filter(site => site.id === selectedSiteFilter.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filtrer les machines par date de création
|
||||||
|
if (dateFrom.value || dateTo.value) {
|
||||||
|
const from = dateFrom.value ? new Date(dateFrom.value) : null
|
||||||
|
const to = dateTo.value ? new Date(dateTo.value) : null
|
||||||
|
if (from) from.setHours(0, 0, 0, 0)
|
||||||
|
if (to) to.setHours(23, 59, 59, 999)
|
||||||
|
|
||||||
|
filtered = filtered.map((site) => {
|
||||||
|
const filteredMachines = (site.machines || []).filter((machine) => {
|
||||||
|
if (!machine.createdAt) return false
|
||||||
|
const created = new Date(machine.createdAt)
|
||||||
|
if (from && created < from) return false
|
||||||
|
if (to && created > to) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return { ...site, machines: filteredMachines }
|
||||||
|
}).filter(site => site.machines.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
// Filtrer par terme de recherche
|
// Filtrer par terme de recherche
|
||||||
if (searchTerm.value) {
|
if (searchTerm.value) {
|
||||||
filtered = filtered.filter((site) => {
|
filtered = filtered.filter((site) => {
|
||||||
|
|||||||
@@ -5,108 +5,93 @@
|
|||||||
<h2 class="text-2xl font-bold">
|
<h2 class="text-2xl font-bold">
|
||||||
Parc Machines
|
Parc Machines
|
||||||
</h2>
|
</h2>
|
||||||
<NuxtLink to="/machines/new" class="btn btn-primary">
|
<NuxtLink v-if="canEdit" to="/machines/new" class="btn btn-primary">
|
||||||
<IconLucidePlus class="w-5 h-5 mr-2" aria-hidden="true" />
|
<IconLucidePlus class="w-5 h-5 mr-2" aria-hidden="true" />
|
||||||
Ajouter une machine
|
Ajouter une machine
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card bg-base-100 shadow-sm mb-6">
|
<div class="card bg-base-100 shadow-sm">
|
||||||
<div class="card-body">
|
<div class="card-body space-y-4">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<DataTable
|
||||||
<div class="form-control">
|
:columns="columns"
|
||||||
<label class="label">
|
:rows="filteredMachines"
|
||||||
<span class="label-text">Sites</span>
|
:loading="loading"
|
||||||
</label>
|
:sort="currentSort"
|
||||||
<div class="flex flex-wrap gap-3">
|
:show-counter="true"
|
||||||
<label
|
empty-message="Aucune machine trouvée."
|
||||||
v-for="site in sites"
|
no-results-message="Aucune machine ne correspond à vos filtres."
|
||||||
:key="site.id"
|
@sort="handleSort"
|
||||||
class="flex items-center gap-2 cursor-pointer"
|
>
|
||||||
|
<template #toolbar>
|
||||||
|
<label class="w-full sm:w-72">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="search"
|
||||||
|
class="input input-bordered input-sm w-full mt-1"
|
||||||
|
placeholder="Nom ou référence..."
|
||||||
>
|
>
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
class="checkbox checkbox-sm"
|
|
||||||
:checked="selectedSites.has(site.id)"
|
|
||||||
@change="selectedSites.has(site.id) ? selectedSites.delete(site.id) : selectedSites.add(site.id)"
|
|
||||||
>
|
|
||||||
<span class="text-sm">{{ site.name }}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Recherche</span>
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
|
||||||
v-model="searchQuery"
|
<div class="flex flex-col">
|
||||||
type="text"
|
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Site</span>
|
||||||
placeholder="Rechercher par nom ou référence..."
|
<select v-model="selectedSiteId" class="select select-bordered select-sm mt-1">
|
||||||
class="input input-bordered"
|
<option value="">Tous les sites</option>
|
||||||
|
<option v-for="site in sites" :key="site.id" :value="site.id">
|
||||||
|
{{ site.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Date de création</span>
|
||||||
|
<div class="flex items-center gap-2 mt-1">
|
||||||
|
<input
|
||||||
|
v-model="dateFrom"
|
||||||
|
type="date"
|
||||||
|
class="input input-bordered input-sm"
|
||||||
|
>
|
||||||
|
<span class="text-xs text-base-content/50">à</span>
|
||||||
|
<input
|
||||||
|
v-model="dateTo"
|
||||||
|
type="date"
|
||||||
|
class="input input-bordered input-sm"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-site="{ row }">
|
||||||
|
<span
|
||||||
|
v-if="row.site"
|
||||||
|
class="badge badge-sm font-bold"
|
||||||
|
:style="row.site.color ? { backgroundColor: row.site.color + '30', color: row.site.color, borderColor: row.site.color + '50' } : {}"
|
||||||
|
:class="!row.site.color ? 'badge-ghost' : ''"
|
||||||
>
|
>
|
||||||
</div>
|
{{ row.site.name }}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
<span v-else class="text-base-content/30">—</span>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<div v-if="loading" class="flex justify-center items-center py-12">
|
<template #cell-createdAt="{ row }">
|
||||||
<span class="loading loading-spinner loading-lg" />
|
<span class="whitespace-nowrap">{{ formatDate(row.createdAt) }}</span>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<EmptyState
|
<template #cell-actions="{ row }">
|
||||||
v-else-if="filteredMachines.length === 0"
|
<div class="flex items-center justify-end gap-2">
|
||||||
:icon="IconLucideFactory"
|
<button v-if="canEdit" class="btn btn-ghost btn-xs" @click="editMachine(row)">
|
||||||
title="Aucune machine trouvée"
|
Modifier
|
||||||
description="Commencez par ajouter votre première machine."
|
</button>
|
||||||
action-label="Ajouter une machine"
|
<button v-if="canEdit" class="btn btn-ghost btn-xs text-error" @click="confirmDeleteMachine(row)">
|
||||||
action-to="/machines/new"
|
Supprimer
|
||||||
/>
|
</button>
|
||||||
|
<NuxtLink :to="`/machine/${row.id}`" class="btn btn-primary btn-xs">
|
||||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
Détails
|
||||||
<div
|
</NuxtLink>
|
||||||
v-for="machine in filteredMachines"
|
|
||||||
:key="machine.id"
|
|
||||||
class="card site-card shadow-md hover:shadow-xl transition-shadow cursor-pointer overflow-hidden"
|
|
||||||
:style="{
|
|
||||||
borderTop: machine.site?.color ? `4px solid ${machine.site.color}` : '4px solid transparent',
|
|
||||||
background: machine.site?.color ? `linear-gradient(160deg, ${machine.site.color}30 0%, ${machine.site.color}08 40%, var(--color-base-100) 100%)` : undefined,
|
|
||||||
}"
|
|
||||||
@click="viewMachineDetails(machine)"
|
|
||||||
>
|
|
||||||
<div class="card-body flex flex-col">
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<h3 class="card-title text-lg">
|
|
||||||
{{ machine.name }}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2 text-sm">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<IconLucideMapPin class="w-4 h-4" :style="{ color: machine.site?.color || '#3b82f6' }" aria-hidden="true" />
|
|
||||||
<span
|
|
||||||
class="font-bold text-sm px-2.5 py-1 rounded-lg text-base-content"
|
|
||||||
:style="machine.site?.color ? { backgroundColor: machine.site.color + '30', border: `1px solid ${machine.site.color}40` } : {}"
|
|
||||||
>{{ machine.site?.name || 'Site inconnu' }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
<div v-if="machine.reference" class="flex items-center gap-2">
|
</DataTable>
|
||||||
<IconLucideTag class="w-4 h-4 text-orange-500" aria-hidden="true" />
|
|
||||||
<span class="text-gray-600">{{ machine.reference }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-auto pt-3 flex items-center justify-end gap-2">
|
|
||||||
<button v-if="canEdit" class="btn btn-ghost btn-sm" @click.stop="editMachine(machine)">
|
|
||||||
Modifier
|
|
||||||
</button>
|
|
||||||
<button v-if="canEdit" class="btn btn-ghost btn-sm text-error" @click.stop="confirmDeleteMachine(machine)">
|
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
<NuxtLink :to="`/machine/${machine.id}`" class="btn btn-primary btn-sm">
|
|
||||||
Détails
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,16 +99,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import DataTable from '~/components/common/DataTable.vue'
|
||||||
import { useMachines } from '~/composables/useMachines'
|
import { useMachines } from '~/composables/useMachines'
|
||||||
import { useSites } from '~/composables/useSites'
|
import { useSites } from '~/composables/useSites'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
import { useUrlState } from '~/composables/useUrlState'
|
import { useUrlState } from '~/composables/useUrlState'
|
||||||
|
import { usePersistedValue } from '~/composables/usePersistedValue'
|
||||||
|
import { formatFrenchDate } from '~/utils/date'
|
||||||
import IconLucidePlus from '~icons/lucide/plus'
|
import IconLucidePlus from '~icons/lucide/plus'
|
||||||
import IconLucideFactory from '~icons/lucide/factory'
|
|
||||||
import IconLucideMapPin from '~icons/lucide/map-pin'
|
|
||||||
import IconLucideTag from '~icons/lucide/tag'
|
|
||||||
|
|
||||||
const { canEdit } = usePermissions()
|
const { canEdit } = usePermissions()
|
||||||
const { machines, loading, loadMachines, deleteMachine } = useMachines()
|
const { machines, loading, loadMachines, deleteMachine } = useMachines()
|
||||||
@@ -132,34 +117,46 @@ const toast = useToast()
|
|||||||
|
|
||||||
const urlState = useUrlState({
|
const urlState = useUrlState({
|
||||||
q: { default: '', debounce: 300 },
|
q: { default: '', debounce: 300 },
|
||||||
sites: { default: '' },
|
site: { default: '' },
|
||||||
|
from: { default: '' },
|
||||||
|
to: { default: '' },
|
||||||
})
|
})
|
||||||
|
|
||||||
const searchQuery = urlState.q
|
const searchQuery = urlState.q
|
||||||
const selectedSites = reactive(new Set())
|
const selectedSiteId = urlState.site
|
||||||
|
const dateFrom = urlState.from
|
||||||
|
const dateTo = urlState.to
|
||||||
|
|
||||||
// Sync URL → selectedSites on load and back/forward
|
const sortKey = usePersistedValue('machines-sort', 'name')
|
||||||
watch(urlState.sites, (val) => {
|
const sortDir = ref('asc')
|
||||||
selectedSites.clear()
|
|
||||||
if (val) {
|
|
||||||
for (const id of String(val).split(',')) {
|
|
||||||
if (id) selectedSites.add(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
// Sync selectedSites → URL
|
const currentSort = computed(() => ({
|
||||||
watch(() => [...selectedSites], (ids) => {
|
field: sortKey.value,
|
||||||
urlState.sites.value = ids.join(',')
|
direction: sortDir.value,
|
||||||
})
|
}))
|
||||||
|
|
||||||
|
const handleSort = (sort) => {
|
||||||
|
sortKey.value = sort.field
|
||||||
|
sortDir.value = sort.direction
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'name', label: 'Nom', sortable: true },
|
||||||
|
{ key: 'reference', label: 'Référence', sortable: true },
|
||||||
|
{ key: 'site', label: 'Site', sortable: true, sortKey: 'siteName' },
|
||||||
|
{ key: 'createdAt', label: 'Date de création', sortable: true },
|
||||||
|
{ key: 'actions', label: 'Actions', align: 'right' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const formatDate = formatFrenchDate
|
||||||
|
|
||||||
// Enrichir les machines avec les objets site complets
|
|
||||||
const enrichedMachines = computed(() => {
|
const enrichedMachines = computed(() => {
|
||||||
return machines.value.map((machine) => {
|
return machines.value.map((machine) => {
|
||||||
const site = sites.value.find(s => s.id === machine.siteId)
|
const site = sites.value.find(s => s.id === machine.siteId)
|
||||||
return {
|
return {
|
||||||
...machine,
|
...machine,
|
||||||
site: site || null,
|
site: site || null,
|
||||||
|
siteName: site?.name || '',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -167,29 +164,44 @@ const enrichedMachines = computed(() => {
|
|||||||
const filteredMachines = computed(() => {
|
const filteredMachines = computed(() => {
|
||||||
let filtered = enrichedMachines.value
|
let filtered = enrichedMachines.value
|
||||||
|
|
||||||
if (selectedSites.size > 0) {
|
if (selectedSiteId.value) {
|
||||||
filtered = filtered.filter(machine => selectedSites.has(machine.siteId))
|
filtered = filtered.filter(m => m.siteId === selectedSiteId.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchQuery.value.trim()) {
|
if (searchQuery.value.trim()) {
|
||||||
const term = searchQuery.value.trim().toLowerCase()
|
const term = searchQuery.value.trim().toLowerCase()
|
||||||
filtered = filtered.filter(machine =>
|
filtered = filtered.filter(m =>
|
||||||
machine.name?.toLowerCase().includes(term)
|
m.name?.toLowerCase().includes(term)
|
||||||
|| machine.reference?.toLowerCase().includes(term),
|
|| m.reference?.toLowerCase().includes(term),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
filtered = [...filtered].sort((a, b) =>
|
if (dateFrom.value) {
|
||||||
(a.name || '').localeCompare(b.name || '', 'fr')
|
const from = new Date(dateFrom.value)
|
||||||
)
|
from.setHours(0, 0, 0, 0)
|
||||||
|
filtered = filtered.filter(m => m.createdAt && new Date(m.createdAt) >= from)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateTo.value) {
|
||||||
|
const to = new Date(dateTo.value)
|
||||||
|
to.setHours(23, 59, 59, 999)
|
||||||
|
filtered = filtered.filter(m => m.createdAt && new Date(m.createdAt) <= to)
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = sortKey.value
|
||||||
|
const dir = sortDir.value === 'desc' ? -1 : 1
|
||||||
|
filtered = [...filtered].sort((a, b) => {
|
||||||
|
if (key === 'createdAt') {
|
||||||
|
return dir * (new Date(a[key] || 0).getTime() - new Date(b[key] || 0).getTime())
|
||||||
|
}
|
||||||
|
const valA = (key === 'siteName' ? a.siteName : a[key]) || ''
|
||||||
|
const valB = (key === 'siteName' ? b.siteName : b[key]) || ''
|
||||||
|
return dir * String(valA).localeCompare(String(valB), 'fr')
|
||||||
|
})
|
||||||
|
|
||||||
return filtered
|
return filtered
|
||||||
})
|
})
|
||||||
|
|
||||||
const viewMachineDetails = (machine) => {
|
|
||||||
navigateTo(`/machine/${machine.id}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const editMachine = (machine) => {
|
const editMachine = (machine) => {
|
||||||
navigateTo(`/machine/${machine.id}?edit=true`)
|
navigateTo(`/machine/${machine.id}?edit=true`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -261,7 +261,7 @@
|
|||||||
:model-value="productSelections[entry.index] || null"
|
:model-value="productSelections[entry.index] || null"
|
||||||
:disabled="!canEdit || saving"
|
:disabled="!canEdit || saving"
|
||||||
:type-product-id="entry.typeProductId"
|
:type-product-id="entry.typeProductId"
|
||||||
helper-text="Un produit valide est requis pour cette pièce."
|
helper-text="Sélectionnez un produit (optionnel)."
|
||||||
@update:model-value="(value) => setProductSelection(entry.index, value)"
|
@update:model-value="(value) => setProductSelection(entry.index, value)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -359,6 +359,9 @@
|
|||||||
</header>
|
</header>
|
||||||
<template v-if="isEditMode">
|
<template v-if="isEditMode">
|
||||||
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
|
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
|
||||||
|
<p v-if="hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-warning">
|
||||||
|
Certains champs personnalisés sont obligatoires. Veuillez les renseigner avant de valider.
|
||||||
|
</p>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
@@ -420,6 +423,9 @@
|
|||||||
Enregistrer les modifications
|
Enregistrer les modifications
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="isEditMode && hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
|
||||||
|
Merci de renseigner tous les champs personnalisés obligatoires.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@@ -460,6 +466,7 @@ const {
|
|||||||
constructeurLinks,
|
constructeurLinks,
|
||||||
productSelections,
|
productSelections,
|
||||||
customFieldInputs,
|
customFieldInputs,
|
||||||
|
requiredCustomFieldsFilled,
|
||||||
pieceTypeList,
|
pieceTypeList,
|
||||||
selectedType,
|
selectedType,
|
||||||
resolvedStructure,
|
resolvedStructure,
|
||||||
@@ -481,6 +488,8 @@ const {
|
|||||||
formatPieceStructurePreview,
|
formatPieceStructurePreview,
|
||||||
} = usePieceEdit(String(route.params.id))
|
} = usePieceEdit(String(route.params.id))
|
||||||
|
|
||||||
|
const hasRequiredCustomFields = computed(() => customFieldInputs.value.some(f => f.required))
|
||||||
|
|
||||||
const entityTabs = computed(() => [
|
const entityTabs = computed(() => [
|
||||||
{ key: 'general', label: 'Général' },
|
{ key: 'general', label: 'Général' },
|
||||||
{ key: 'products', label: 'Produits liés', count: structureProducts.value.length },
|
{ key: 'products', label: 'Produits liés', count: structureProducts.value.length },
|
||||||
|
|||||||
@@ -168,7 +168,7 @@
|
|||||||
:model-value="productSelections[entry.index] || null"
|
:model-value="productSelections[entry.index] || null"
|
||||||
:disabled="!canEdit || submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
:type-product-id="entry.typeProductId"
|
:type-product-id="entry.typeProductId"
|
||||||
helper-text="Un produit est requis pour cette pièce."
|
helper-text="Sélectionnez un produit (optionnel)."
|
||||||
@update:model-value="(value) => setProductSelection(entry.index, value)"
|
@update:model-value="(value) => setProductSelection(entry.index, value)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -273,7 +273,6 @@ import {
|
|||||||
buildProductRequirementDescriptions,
|
buildProductRequirementDescriptions,
|
||||||
buildProductRequirementEntries,
|
buildProductRequirementEntries,
|
||||||
resizeProductSelections,
|
resizeProductSelections,
|
||||||
areProductSelectionsFilled,
|
|
||||||
applyProductSelection,
|
applyProductSelection,
|
||||||
collectNormalizedProductIds,
|
collectNormalizedProductIds,
|
||||||
} from '~/shared/utils/pieceProductSelectionUtils'
|
} from '~/shared/utils/pieceProductSelectionUtils'
|
||||||
@@ -379,13 +378,7 @@ const productRequirementEntries = computed(() =>
|
|||||||
buildProductRequirementEntries(structureProducts.value, 'piece-create-product-requirement'),
|
buildProductRequirementEntries(structureProducts.value, 'piece-create-product-requirement'),
|
||||||
)
|
)
|
||||||
|
|
||||||
const productSelectionsFilled = computed(() =>
|
const productSelectionsFilled = computed(() => true)
|
||||||
areProductSelectionsFilled(
|
|
||||||
requiresProductSelection.value,
|
|
||||||
productRequirementEntries.value,
|
|
||||||
productSelections.value,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const setProductSelection = (index: number, value: string | null) => {
|
const setProductSelection = (index: number, value: string | null) => {
|
||||||
productSelections.value = applyProductSelection(productSelections.value, index, value)
|
productSelections.value = applyProductSelection(productSelections.value, index, value)
|
||||||
@@ -444,11 +437,6 @@ const submitCreation = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!productSelectionsFilled.value) {
|
|
||||||
toast.showError('Sélectionnez un produit conforme au squelette.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload: Record<string, any> = {
|
const payload: Record<string, any> = {
|
||||||
name: creationForm.name.trim(),
|
name: creationForm.name.trim(),
|
||||||
typePieceId: selectedType.value.id,
|
typePieceId: selectedType.value.id,
|
||||||
|
|||||||
19
frontend/package-lock.json
generated
19
frontend/package-lock.json
generated
@@ -98,6 +98,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz",
|
||||||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.3",
|
"@babel/generator": "^7.28.3",
|
||||||
@@ -2092,6 +2093,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
|
||||||
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
|
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alloc/quick-lru": "^5.2.0",
|
"@alloc/quick-lru": "^5.2.0",
|
||||||
"arg": "^5.0.2",
|
"arg": "^5.0.2",
|
||||||
@@ -4112,6 +4114,7 @@
|
|||||||
"integrity": "sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg==",
|
"integrity": "sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
@@ -4181,6 +4184,7 @@
|
|||||||
"integrity": "sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw==",
|
"integrity": "sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.44.1",
|
"@typescript-eslint/scope-manager": "8.44.1",
|
||||||
"@typescript-eslint/types": "8.44.1",
|
"@typescript-eslint/types": "8.44.1",
|
||||||
@@ -4977,6 +4981,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.22.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.22.tgz",
|
||||||
"integrity": "sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ==",
|
"integrity": "sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.28.4",
|
"@babel/parser": "^7.28.4",
|
||||||
"@vue/compiler-core": "3.5.22",
|
"@vue/compiler-core": "3.5.22",
|
||||||
@@ -5207,6 +5212,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -5637,6 +5643,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.8.3",
|
"baseline-browser-mapping": "^2.8.3",
|
||||||
"caniuse-lite": "^1.0.30001741",
|
"caniuse-lite": "^1.0.30001741",
|
||||||
@@ -7069,6 +7076,7 @@
|
|||||||
"integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==",
|
"integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -10490,6 +10498,7 @@
|
|||||||
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
|
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"deep-is": "^0.1.3",
|
"deep-is": "^0.1.3",
|
||||||
"fast-levenshtein": "^2.0.6",
|
"fast-levenshtein": "^2.0.6",
|
||||||
@@ -10536,6 +10545,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.87.0.tgz",
|
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.87.0.tgz",
|
||||||
"integrity": "sha512-uc47XrtHwkBoES4HFgwgfH9sqwAtJXgAIBq4fFBMZ4hWmgVZoExyn+L4g4VuaecVKXkz1bvlaHcfwHAJPQb5Gw==",
|
"integrity": "sha512-uc47XrtHwkBoES4HFgwgfH9sqwAtJXgAIBq4fFBMZ4hWmgVZoExyn+L4g4VuaecVKXkz1bvlaHcfwHAJPQb5Gw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oxc-project/types": "^0.87.0"
|
"@oxc-project/types": "^0.87.0"
|
||||||
},
|
},
|
||||||
@@ -10937,6 +10947,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -11376,6 +11387,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
|
||||||
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
|
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cssesc": "^3.0.0",
|
"cssesc": "^3.0.0",
|
||||||
"util-deprecate": "^1.0.2"
|
"util-deprecate": "^1.0.2"
|
||||||
@@ -12118,6 +12130,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.2.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.2.tgz",
|
||||||
"integrity": "sha512-I25/2QgoROE1vYV+NQ1En9T9UFB9Cmfm2CJ83zZOlaDpvz29wGQSZXWKw7MiNXau7wYgB/T9fVIdIuEQ+KbiiA==",
|
"integrity": "sha512-I25/2QgoROE1vYV+NQ1En9T9UFB9Cmfm2CJ83zZOlaDpvz29wGQSZXWKw7MiNXau7wYgB/T9fVIdIuEQ+KbiiA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
@@ -13180,6 +13193,7 @@
|
|||||||
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
|
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -13537,6 +13551,7 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"napi-postinstall": "^0.3.0"
|
"napi-postinstall": "^0.3.0"
|
||||||
},
|
},
|
||||||
@@ -13783,6 +13798,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.7.tgz",
|
||||||
"integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==",
|
"integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -14186,6 +14202,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz",
|
||||||
"integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==",
|
"integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.22",
|
"@vue/compiler-dom": "3.5.22",
|
||||||
"@vue/compiler-sfc": "3.5.22",
|
"@vue/compiler-sfc": "3.5.22",
|
||||||
@@ -14230,6 +14247,7 @@
|
|||||||
"integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==",
|
"integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "^4.4.0",
|
"debug": "^4.4.0",
|
||||||
"eslint-scope": "^8.2.0",
|
"eslint-scope": "^8.2.0",
|
||||||
@@ -14253,6 +14271,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz",
|
||||||
"integrity": "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==",
|
"integrity": "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/devtools-api": "^6.6.4"
|
"@vue/devtools-api": "^6.6.4"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ services:
|
|||||||
- "8082:80"
|
- "8082:80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./storage:/var/www/html/var/storage/documents
|
- ./storage:/var/www/html/var/storage/documents
|
||||||
|
- inventory_logs:/var/www/html/var/log
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
inventory_logs:
|
||||||
|
|||||||
6
makefile
6
makefile
@@ -127,6 +127,12 @@ php-cs-fixer-allow-risky:
|
|||||||
test:
|
test:
|
||||||
$(EXEC_PHP) php -d memory_limit="512M" vendor/bin/phpunit $(FILES)
|
$(EXEC_PHP) php -d memory_limit="512M" vendor/bin/phpunit $(FILES)
|
||||||
|
|
||||||
|
test-front:
|
||||||
|
cd frontend && npx vitest run $(FILES)
|
||||||
|
|
||||||
|
test-front-watch:
|
||||||
|
cd frontend && npx vitest --watch $(FILES)
|
||||||
|
|
||||||
test-setup:
|
test-setup:
|
||||||
$(SYMFONY_CONSOLE) doctrine:database:create --if-not-exists --env=test
|
$(SYMFONY_CONSOLE) doctrine:database:create --if-not-exists --env=test
|
||||||
$(SYMFONY_CONSOLE) doctrine:schema:update --force --env=test
|
$(SYMFONY_CONSOLE) doctrine:schema:update --force --env=test
|
||||||
|
|||||||
106
migrations/Version20260506140000_FixComposantCascadeFKs.php
Normal file
106
migrations/Version20260506140000_FixComposantCascadeFKs.php
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260506140000_FixComposantCascadeFKs extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add missing CASCADE FKs documents.composantid and machine_component_links.composantid; cleanup pre-existing orphan rows';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// 1. Trace des suppressions à venir dans audit_logs (actor = NULL = "system").
|
||||||
|
// On copie un snapshot minimal avant DELETE pour cohérence avec les autres "delete".
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||||
|
SELECT
|
||||||
|
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||||
|
'document',
|
||||||
|
d.id,
|
||||||
|
'delete',
|
||||||
|
json_build_object(
|
||||||
|
'id', d.id,
|
||||||
|
'name', d.name,
|
||||||
|
'filename', d.filename,
|
||||||
|
'composantId', d.composantid,
|
||||||
|
'note', 'Cleaned by FK cascade fix migration (Version20260506140000) - referenced composant no longer existed'
|
||||||
|
),
|
||||||
|
NULL,
|
||||||
|
NOW()
|
||||||
|
FROM documents d
|
||||||
|
WHERE d.composantid IS NOT NULL
|
||||||
|
AND d.composantid NOT IN (SELECT id FROM composants)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||||
|
SELECT
|
||||||
|
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||||
|
'machine_component_link',
|
||||||
|
l.id,
|
||||||
|
'delete',
|
||||||
|
json_build_object(
|
||||||
|
'id', l.id,
|
||||||
|
'machineId', l.machineid,
|
||||||
|
'composantId', l.composantid,
|
||||||
|
'note', 'Cleaned by FK cascade fix migration (Version20260506140000) - referenced composant no longer existed'
|
||||||
|
),
|
||||||
|
NULL,
|
||||||
|
NOW()
|
||||||
|
FROM machine_component_links l
|
||||||
|
WHERE l.composantid IS NOT NULL
|
||||||
|
AND l.composantid NOT IN (SELECT id FROM composants)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
// 2. Nettoyage des orphelins.
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
DELETE FROM documents
|
||||||
|
WHERE composantid IS NOT NULL
|
||||||
|
AND composantid NOT IN (SELECT id FROM composants)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
DELETE FROM machine_component_links
|
||||||
|
WHERE composantid IS NOT NULL
|
||||||
|
AND composantid NOT IN (SELECT id FROM composants)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
// 3. Ajout idempotent des 2 FK manquantes (alignement avec les entités Doctrine).
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.table_constraints
|
||||||
|
WHERE constraint_name = 'fk_documents_composant' AND table_name = 'documents'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE documents ADD CONSTRAINT fk_documents_composant
|
||||||
|
FOREIGN KEY (composantid) REFERENCES composants(id) ON DELETE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.table_constraints
|
||||||
|
WHERE constraint_name = 'fk_mcl_composant' AND table_name = 'machine_component_links'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE machine_component_links ADD CONSTRAINT fk_mcl_composant
|
||||||
|
FOREIGN KEY (composantid) REFERENCES composants(id) ON DELETE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE documents DROP CONSTRAINT IF EXISTS fk_documents_composant');
|
||||||
|
$this->addSql('ALTER TABLE machine_component_links DROP CONSTRAINT IF EXISTS fk_mcl_composant');
|
||||||
|
}
|
||||||
|
}
|
||||||
320
src/Command/ConvertMoteurPieceToComponentCommand.php
Normal file
320
src/Command/ConvertMoteurPieceToComponentCommand.php
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:convert-moteur-piece-to-component',
|
||||||
|
description: 'Convertit la catégorie "Moteur" (PIECE) en COMPONENT et migre toutes les pièces liées en composants.',
|
||||||
|
)]
|
||||||
|
class ConvertMoteurPieceToComponentCommand extends Command
|
||||||
|
{
|
||||||
|
private const MODEL_TYPE_ID = 'cmgytewe0002447ffup09bscr';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly Connection $connection,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Affiche les actions sans les exécuter');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
$dryRun = $input->getOption('dry-run');
|
||||||
|
|
||||||
|
$io->title('Conversion catégorie "Moteur" : PIECE → COMPONENT');
|
||||||
|
|
||||||
|
// ── 1. Vérifications ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
$modelType = $this->connection->fetchAssociative(
|
||||||
|
'SELECT id, name, code, category FROM model_types WHERE id = :id',
|
||||||
|
['id' => self::MODEL_TYPE_ID],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$modelType) {
|
||||||
|
$io->error('ModelType "Moteur" introuvable (id: '.self::MODEL_TYPE_ID.')');
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('PIECE' !== $modelType['category']) {
|
||||||
|
$io->error(sprintf('Le ModelType "Moteur" est déjà de catégorie %s — rien à faire.', $modelType['category']));
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pieces = $this->connection->fetchAllAssociative(
|
||||||
|
'SELECT id, name, reference FROM pieces WHERE typepieceid = :id ORDER BY name',
|
||||||
|
['id' => self::MODEL_TYPE_ID],
|
||||||
|
);
|
||||||
|
|
||||||
|
$pieceCount = count($pieces);
|
||||||
|
$io->info(sprintf('Pièces à convertir : %d', $pieceCount));
|
||||||
|
|
||||||
|
if ($pieceCount > 0) {
|
||||||
|
$io->table(
|
||||||
|
['ID', 'Nom', 'Référence'],
|
||||||
|
array_map(fn (array $p) => [$p['id'], $p['name'], $p['reference'] ?? '—'], $pieces),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check blockers
|
||||||
|
$blockers = [];
|
||||||
|
|
||||||
|
$machineLinked = (int) $this->connection->fetchOne(
|
||||||
|
'SELECT COUNT(*) FROM machine_piece_links mpl
|
||||||
|
JOIN pieces p ON mpl.pieceid = p.id
|
||||||
|
WHERE p.typepieceid = :id',
|
||||||
|
['id' => self::MODEL_TYPE_ID],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($machineLinked > 0) {
|
||||||
|
$blockers[] = sprintf('%d pièce(s) liée(s) à des machines — conversion impossible.', $machineLinked);
|
||||||
|
}
|
||||||
|
|
||||||
|
$nameCollisions = $this->connection->fetchFirstColumn(
|
||||||
|
'SELECT p.name FROM pieces p
|
||||||
|
WHERE p.typepieceid = :id
|
||||||
|
AND p.name IN (SELECT c.name FROM composants c)',
|
||||||
|
['id' => self::MODEL_TYPE_ID],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ([] !== $nameCollisions) {
|
||||||
|
$blockers[] = sprintf('Collision de noms avec des composants existants : %s', implode(', ', $nameCollisions));
|
||||||
|
}
|
||||||
|
|
||||||
|
$categoryCollision = (int) $this->connection->fetchOne(
|
||||||
|
"SELECT COUNT(*) FROM model_types WHERE category = 'COMPONENT' AND name = :name AND id != :id",
|
||||||
|
['name' => $modelType['name'], 'id' => self::MODEL_TYPE_ID],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($categoryCollision > 0) {
|
||||||
|
$blockers[] = sprintf('Un ModelType composant « %s » existe déjà.', $modelType['name']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([] !== $blockers) {
|
||||||
|
$io->error($blockers);
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary of related data
|
||||||
|
$relatedCounts = $this->countRelatedData();
|
||||||
|
$io->section('Données liées à migrer');
|
||||||
|
$io->table(
|
||||||
|
['Table', 'Nombre'],
|
||||||
|
array_map(fn (string $k, int $v) => [$k, $v], array_keys($relatedCounts), array_values($relatedCounts)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$io->warning('Mode dry-run : aucune modification effectuée.');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Exécution ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
$this->connection->beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$now = new DateTimeImmutable()->format('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
// 2a. Copier les pièces dans composants
|
||||||
|
$converted = $this->connection->executeStatement(
|
||||||
|
'INSERT INTO composants (id, name, reference, referenceauto, description, prix, typecomposantid, productid, version, createdat, updatedat)
|
||||||
|
SELECT id, name, reference, referenceauto, description, prix, typepieceid, productid, version, createdat, :now
|
||||||
|
FROM pieces
|
||||||
|
WHERE typepieceid = :id',
|
||||||
|
['id' => self::MODEL_TYPE_ID, 'now' => $now],
|
||||||
|
);
|
||||||
|
$io->text(sprintf('✓ %d pièce(s) copiée(s) dans composants', $converted));
|
||||||
|
|
||||||
|
// 2b. Transférer les documents
|
||||||
|
$docs = $this->connection->executeStatement(
|
||||||
|
'UPDATE documents SET composantid = pieceid, pieceid = NULL
|
||||||
|
WHERE pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)',
|
||||||
|
['id' => self::MODEL_TYPE_ID],
|
||||||
|
);
|
||||||
|
$io->text(sprintf('✓ %d document(s) transféré(s)', $docs));
|
||||||
|
|
||||||
|
// 2c. Transférer les custom_field_values
|
||||||
|
$cfv = $this->connection->executeStatement(
|
||||||
|
'UPDATE custom_field_values SET composantid = pieceid, pieceid = NULL
|
||||||
|
WHERE pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)',
|
||||||
|
['id' => self::MODEL_TYPE_ID],
|
||||||
|
);
|
||||||
|
$io->text(sprintf('✓ %d valeur(s) de champs perso transférée(s)', $cfv));
|
||||||
|
|
||||||
|
// 2d. Transférer les custom_fields (définitions)
|
||||||
|
$cf = $this->connection->executeStatement(
|
||||||
|
'UPDATE custom_fields SET typecomposantid = typepieceid, typepieceid = NULL
|
||||||
|
WHERE typepieceid = :id',
|
||||||
|
['id' => self::MODEL_TYPE_ID],
|
||||||
|
);
|
||||||
|
$io->text(sprintf('✓ %d définition(s) de champs perso transférée(s)', $cf));
|
||||||
|
|
||||||
|
// 2e. Transférer les constructeur links
|
||||||
|
$ctorLinks = $this->connection->executeStatement(
|
||||||
|
"INSERT INTO composant_constructeur_links (id, composantid, constructeurid, supplierreference, createdat, updatedat)
|
||||||
|
SELECT 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||||
|
pcl.pieceid, pcl.constructeurid, pcl.supplierreference, pcl.createdat, :now
|
||||||
|
FROM piece_constructeur_links pcl
|
||||||
|
WHERE pcl.pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)",
|
||||||
|
['id' => self::MODEL_TYPE_ID, 'now' => $now],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($ctorLinks > 0) {
|
||||||
|
$this->connection->executeStatement(
|
||||||
|
'DELETE FROM piece_constructeur_links
|
||||||
|
WHERE pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)',
|
||||||
|
['id' => self::MODEL_TYPE_ID],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$io->text(sprintf('✓ %d lien(s) constructeur transféré(s)', $ctorLinks));
|
||||||
|
|
||||||
|
// 2f. Convertir composant_piece_slots → composant_subcomponent_slots
|
||||||
|
$slots = $this->connection->executeStatement(
|
||||||
|
"INSERT INTO composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat)
|
||||||
|
SELECT 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||||
|
cps.composantid,
|
||||||
|
COALESCE(sp.name, 'Moteur'),
|
||||||
|
'moteur',
|
||||||
|
cps.typepieceid,
|
||||||
|
cps.selectedpieceid,
|
||||||
|
cps.position,
|
||||||
|
cps.createdat,
|
||||||
|
:now
|
||||||
|
FROM composant_piece_slots cps
|
||||||
|
LEFT JOIN pieces sp ON sp.id = cps.selectedpieceid
|
||||||
|
WHERE cps.typepieceid = :id",
|
||||||
|
['id' => self::MODEL_TYPE_ID, 'now' => $now],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->connection->executeStatement(
|
||||||
|
'DELETE FROM composant_piece_slots WHERE typepieceid = :id',
|
||||||
|
['id' => self::MODEL_TYPE_ID],
|
||||||
|
);
|
||||||
|
$io->text(sprintf('✓ %d slot(s) pièce convertis en slots sous-composant', $slots));
|
||||||
|
|
||||||
|
// 2g. Convertir skeleton_piece_requirements → skeleton_subcomponent_requirements
|
||||||
|
$skelReqs = $this->connection->executeStatement(
|
||||||
|
"INSERT INTO skeleton_subcomponent_requirements (id, modeltypeid, alias, familycode, typecomposantid, position, createdat, updatedat)
|
||||||
|
SELECT 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||||
|
spr.modeltypeid,
|
||||||
|
'Moteur',
|
||||||
|
'moteur',
|
||||||
|
spr.typepieceid,
|
||||||
|
spr.position,
|
||||||
|
spr.createdat,
|
||||||
|
:now
|
||||||
|
FROM skeleton_piece_requirements spr
|
||||||
|
WHERE spr.typepieceid = :id",
|
||||||
|
['id' => self::MODEL_TYPE_ID, 'now' => $now],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->connection->executeStatement(
|
||||||
|
'DELETE FROM skeleton_piece_requirements WHERE typepieceid = :id',
|
||||||
|
['id' => self::MODEL_TYPE_ID],
|
||||||
|
);
|
||||||
|
$io->text(sprintf('✓ %d skeleton requirement(s) convertis', $skelReqs));
|
||||||
|
|
||||||
|
// 2h. Mettre à jour audit_logs entity_type
|
||||||
|
$auditUpdated = $this->connection->executeStatement(
|
||||||
|
"UPDATE audit_logs SET entitytype = 'composant'
|
||||||
|
WHERE entitytype = 'piece'
|
||||||
|
AND entityid IN (SELECT id FROM pieces WHERE typepieceid = :id)",
|
||||||
|
['id' => self::MODEL_TYPE_ID],
|
||||||
|
);
|
||||||
|
$io->text(sprintf('✓ %d audit log(s) mis à jour', $auditUpdated));
|
||||||
|
|
||||||
|
// 2i. Mettre à jour comments entity_type
|
||||||
|
$commentsUpdated = $this->connection->executeStatement(
|
||||||
|
"UPDATE comments SET entity_type = 'composant'
|
||||||
|
WHERE entity_type = 'piece'
|
||||||
|
AND entity_id IN (SELECT id FROM pieces WHERE typepieceid = :id)",
|
||||||
|
['id' => self::MODEL_TYPE_ID],
|
||||||
|
);
|
||||||
|
$io->text(sprintf('✓ %d commentaire(s) mis à jour', $commentsUpdated));
|
||||||
|
|
||||||
|
// 2j. Supprimer les pièces originales
|
||||||
|
$deleted = $this->connection->executeStatement(
|
||||||
|
'DELETE FROM pieces WHERE typepieceid = :id',
|
||||||
|
['id' => self::MODEL_TYPE_ID],
|
||||||
|
);
|
||||||
|
$io->text(sprintf('✓ %d pièce(s) supprimée(s)', $deleted));
|
||||||
|
|
||||||
|
// 2k. Changer la catégorie du ModelType
|
||||||
|
$this->connection->executeStatement(
|
||||||
|
"UPDATE model_types SET category = 'COMPONENT', updatedat = :now WHERE id = :id",
|
||||||
|
['id' => self::MODEL_TYPE_ID, 'now' => $now],
|
||||||
|
);
|
||||||
|
$io->text('✓ ModelType "Moteur" passé en COMPONENT');
|
||||||
|
|
||||||
|
$this->connection->commit();
|
||||||
|
|
||||||
|
$io->success(sprintf('Conversion terminée : %d pièces → composants.', $converted));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->connection->rollBack();
|
||||||
|
$io->error('Erreur — rollback effectué : '.$e->getMessage());
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, int>
|
||||||
|
*/
|
||||||
|
private function countRelatedData(): array
|
||||||
|
{
|
||||||
|
$id = self::MODEL_TYPE_ID;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'pieces' => (int) $this->connection->fetchOne(
|
||||||
|
'SELECT COUNT(*) FROM pieces WHERE typepieceid = :id',
|
||||||
|
['id' => $id],
|
||||||
|
),
|
||||||
|
'documents' => (int) $this->connection->fetchOne(
|
||||||
|
'SELECT COUNT(*) FROM documents WHERE pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)',
|
||||||
|
['id' => $id],
|
||||||
|
),
|
||||||
|
'custom_field_values' => (int) $this->connection->fetchOne(
|
||||||
|
'SELECT COUNT(*) FROM custom_field_values WHERE pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)',
|
||||||
|
['id' => $id],
|
||||||
|
),
|
||||||
|
'custom_fields' => (int) $this->connection->fetchOne(
|
||||||
|
'SELECT COUNT(*) FROM custom_fields WHERE typepieceid = :id',
|
||||||
|
['id' => $id],
|
||||||
|
),
|
||||||
|
'piece_constructeur_links' => (int) $this->connection->fetchOne(
|
||||||
|
'SELECT COUNT(*) FROM piece_constructeur_links WHERE pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)',
|
||||||
|
['id' => $id],
|
||||||
|
),
|
||||||
|
'composant_piece_slots (→ subcomponent_slots)' => (int) $this->connection->fetchOne(
|
||||||
|
'SELECT COUNT(*) FROM composant_piece_slots WHERE typepieceid = :id',
|
||||||
|
['id' => $id],
|
||||||
|
),
|
||||||
|
'skeleton_piece_requirements (→ subcomponent_reqs)' => (int) $this->connection->fetchOne(
|
||||||
|
'SELECT COUNT(*) FROM skeleton_piece_requirements WHERE typepieceid = :id',
|
||||||
|
['id' => $id],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -162,9 +162,6 @@ class MachineStructureController extends AbstractController
|
|||||||
// Copy product links
|
// Copy product links
|
||||||
$this->cloneProductLinks($source, $newMachine, $componentLinkMap, $pieceLinkMap);
|
$this->cloneProductLinks($source, $newMachine, $componentLinkMap, $pieceLinkMap);
|
||||||
|
|
||||||
// Copy context field values
|
|
||||||
$this->cloneContextFieldValues($componentLinkMap, $pieceLinkMap);
|
|
||||||
|
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
$componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $newMachine], ['createdAt' => 'ASC']);
|
$componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $newMachine], ['createdAt' => 'ASC']);
|
||||||
@@ -230,6 +227,17 @@ class MachineStructureController extends AbstractController
|
|||||||
$newLink->setReferenceOverride($link->getReferenceOverride());
|
$newLink->setReferenceOverride($link->getReferenceOverride());
|
||||||
$newLink->setPrixOverride($link->getPrixOverride());
|
$newLink->setPrixOverride($link->getPrixOverride());
|
||||||
$this->entityManager->persist($newLink);
|
$this->entityManager->persist($newLink);
|
||||||
|
|
||||||
|
foreach ($link->getContextFieldValues() as $cfv) {
|
||||||
|
$newValue = new CustomFieldValue();
|
||||||
|
$newValue->setCustomField($cfv->getCustomField());
|
||||||
|
$newValue->setValue($cfv->getValue());
|
||||||
|
$newValue->setMachineComponentLink($newLink);
|
||||||
|
$newValue->setComposant($newLink->getComposant());
|
||||||
|
$this->entityManager->persist($newValue);
|
||||||
|
$newLink->getContextFieldValues()->add($newValue);
|
||||||
|
}
|
||||||
|
|
||||||
$linkMap[$link->getId()] = $newLink;
|
$linkMap[$link->getId()] = $newLink;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,6 +277,17 @@ class MachineStructureController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->entityManager->persist($newLink);
|
$this->entityManager->persist($newLink);
|
||||||
|
|
||||||
|
foreach ($link->getContextFieldValues() as $cfv) {
|
||||||
|
$newValue = new CustomFieldValue();
|
||||||
|
$newValue->setCustomField($cfv->getCustomField());
|
||||||
|
$newValue->setValue($cfv->getValue());
|
||||||
|
$newValue->setMachinePieceLink($newLink);
|
||||||
|
$newValue->setPiece($newLink->getPiece());
|
||||||
|
$this->entityManager->persist($newValue);
|
||||||
|
$newLink->getContextFieldValues()->add($newValue);
|
||||||
|
}
|
||||||
|
|
||||||
$linkMap[$link->getId()] = $newLink;
|
$linkMap[$link->getId()] = $newLink;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,45 +336,6 @@ class MachineStructureController extends AbstractController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, MachineComponentLink> $componentLinkMap
|
|
||||||
* @param array<string, MachinePieceLink> $pieceLinkMap
|
|
||||||
*/
|
|
||||||
private function cloneContextFieldValues(
|
|
||||||
array $componentLinkMap,
|
|
||||||
array $pieceLinkMap,
|
|
||||||
): void {
|
|
||||||
foreach ($componentLinkMap as $oldLinkId => $newLink) {
|
|
||||||
$oldLink = $this->machineComponentLinkRepository->find($oldLinkId);
|
|
||||||
if (!$oldLink) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
foreach ($oldLink->getContextFieldValues() as $cfv) {
|
|
||||||
$newValue = new CustomFieldValue();
|
|
||||||
$newValue->setCustomField($cfv->getCustomField());
|
|
||||||
$newValue->setValue($cfv->getValue());
|
|
||||||
$newValue->setMachineComponentLink($newLink);
|
|
||||||
$newValue->setComposant($newLink->getComposant());
|
|
||||||
$this->entityManager->persist($newValue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($pieceLinkMap as $oldLinkId => $newLink) {
|
|
||||||
$oldLink = $this->machinePieceLinkRepository->find($oldLinkId);
|
|
||||||
if (!$oldLink) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
foreach ($oldLink->getContextFieldValues() as $cfv) {
|
|
||||||
$newValue = new CustomFieldValue();
|
|
||||||
$newValue->setCustomField($cfv->getCustomField());
|
|
||||||
$newValue->setValue($cfv->getValue());
|
|
||||||
$newValue->setMachinePieceLink($newLink);
|
|
||||||
$newValue->setPiece($newLink->getPiece());
|
|
||||||
$this->entityManager->persist($newValue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function normalizePayloadList(mixed $value): array
|
private function normalizePayloadList(mixed $value): array
|
||||||
{
|
{
|
||||||
if (!is_array($value)) {
|
if (!is_array($value)) {
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ use App\Entity\Machine;
|
|||||||
use App\Entity\ModelType;
|
use App\Entity\ModelType;
|
||||||
use App\Entity\Piece;
|
use App\Entity\Piece;
|
||||||
use App\Entity\Product;
|
use App\Entity\Product;
|
||||||
use App\Entity\Profile;
|
|
||||||
use App\Entity\Site;
|
use App\Entity\Site;
|
||||||
|
use App\Service\ActorProfileResolver;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use DateTimeInterface;
|
use DateTimeInterface;
|
||||||
use Doctrine\Common\Collections\Collection;
|
use Doctrine\Common\Collections\Collection;
|
||||||
@@ -22,10 +22,6 @@ use Doctrine\ORM\Event\OnFlushEventArgs;
|
|||||||
use Doctrine\ORM\Events;
|
use Doctrine\ORM\Events;
|
||||||
use Doctrine\ORM\UnitOfWork;
|
use Doctrine\ORM\UnitOfWork;
|
||||||
use Error;
|
use Error;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
|
||||||
use Symfony\Component\HttpFoundation\RequestStack;
|
|
||||||
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
use function is_array;
|
use function is_array;
|
||||||
use function is_object;
|
use function is_object;
|
||||||
@@ -35,8 +31,7 @@ use function method_exists;
|
|||||||
abstract class AbstractAuditSubscriber implements EventSubscriber
|
abstract class AbstractAuditSubscriber implements EventSubscriber
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly RequestStack $requestStack,
|
protected readonly ActorProfileResolver $actorProfileResolver,
|
||||||
private readonly Security $security,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function getSubscribedEvents(): array
|
public function getSubscribedEvents(): array
|
||||||
@@ -61,7 +56,7 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$actorProfileId = $this->resolveActorProfileId();
|
$actorProfileId = $this->actorProfileResolver->resolve();
|
||||||
$entityType = $this->entityType();
|
$entityType = $this->entityType();
|
||||||
|
|
||||||
if ($this->hasCollectionTracking()) {
|
if ($this->hasCollectionTracking()) {
|
||||||
@@ -278,28 +273,6 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
|
|||||||
return $entity->getVersion();
|
return $entity->getVersion();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function resolveActorProfileId(): ?string
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$session = $this->requestStack->getSession();
|
|
||||||
if ($session instanceof SessionInterface) {
|
|
||||||
$profileId = $session->get('profileId');
|
|
||||||
if ($profileId) {
|
|
||||||
return (string) $profileId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (Throwable) {
|
|
||||||
// No session available (CLI context, etc.)
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = $this->security->getUser();
|
|
||||||
if ($user instanceof Profile) {
|
|
||||||
return $user->getId();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function onFlushSimple(EntityManagerInterface $em, UnitOfWork $uow, ?string $actorProfileId, string $entityType): void
|
private function onFlushSimple(EntityManagerInterface $em, UnitOfWork $uow, ?string $actorProfileId, string $entityType): void
|
||||||
{
|
{
|
||||||
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
||||||
@@ -385,13 +358,10 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
|
|||||||
$this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
|
$this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($uow->getScheduledCollectionUpdates() as $collection) {
|
// Note: scheduled collection updates/deletions are intentionally not
|
||||||
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingEntities);
|
// tracked here — constructeurs are now persisted as ConstructeurLink
|
||||||
}
|
// entities (OneToMany), so Doctrine no longer fires collection events
|
||||||
foreach ($uow->getScheduledCollectionDeletions() as $collection) {
|
// for them. Custom field values are handled below.
|
||||||
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingEntities);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingEntities);
|
$this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingEntities);
|
||||||
|
|
||||||
foreach ($pendingUpdates as $entityId => $diff) {
|
foreach ($pendingUpdates as $entityId => $diff) {
|
||||||
@@ -411,17 +381,6 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* No-op: constructeurs are now tracked as ConstructeurLink entities (OneToMany),
|
|
||||||
* so Doctrine no longer fires collection update events for them.
|
|
||||||
*/
|
|
||||||
private function collectCollectionUpdate(
|
|
||||||
object $collection,
|
|
||||||
array &$pendingUpdates,
|
|
||||||
array &$pendingSnapshots,
|
|
||||||
array &$pendingEntities,
|
|
||||||
): void {}
|
|
||||||
|
|
||||||
private function collectCustomFieldValueChanges(
|
private function collectCustomFieldValueChanges(
|
||||||
UnitOfWork $uow,
|
UnitOfWork $uow,
|
||||||
array &$pendingUpdates,
|
array &$pendingUpdates,
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ final class MachineAuditSubscriber extends AbstractAuditSubscriber
|
|||||||
}
|
}
|
||||||
|
|
||||||
$uow = $em->getUnitOfWork();
|
$uow = $em->getUnitOfWork();
|
||||||
$actorProfileId = $this->resolveActorProfileId();
|
$actorProfileId = $this->actorProfileResolver->resolve();
|
||||||
|
|
||||||
$this->processLinkChanges($em, $uow, $actorProfileId);
|
$this->processLinkChanges($em, $uow, $actorProfileId);
|
||||||
}
|
}
|
||||||
|
|||||||
57
src/EventSubscriber/ModelTypeReferenceCleanupSubscriber.php
Normal file
57
src/EventSubscriber/ModelTypeReferenceCleanupSubscriber.php
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\EventSubscriber;
|
||||||
|
|
||||||
|
use App\Entity\ModelType;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
|
||||||
|
use Doctrine\ORM\Event\PreRemoveEventArgs;
|
||||||
|
use Doctrine\ORM\Events;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Belt-and-suspenders cleanup of weak references to a ModelType before deletion:
|
||||||
|
* runs the equivalent of every "ON DELETE SET NULL" cascade applicatively, in case
|
||||||
|
* the database FK fails to fire (observed on prod in 2026-04 — the deletion of
|
||||||
|
* ModelType "Paliers" left an orphan in skeleton_subcomponent_requirements).
|
||||||
|
*/
|
||||||
|
#[AsDoctrineListener(event: Events::preRemove)]
|
||||||
|
final class ModelTypeReferenceCleanupSubscriber
|
||||||
|
{
|
||||||
|
/** @var list<array{0: string, 1: string}> */
|
||||||
|
private const NULLABLE_REFERENCES = [
|
||||||
|
['skeleton_subcomponent_requirements', 'typecomposantid'],
|
||||||
|
['skeleton_piece_requirements', 'typepieceid'],
|
||||||
|
['skeleton_product_requirements', 'typeproductid'],
|
||||||
|
['composant_piece_slots', 'typepieceid'],
|
||||||
|
['composant_product_slots', 'typeproductid'],
|
||||||
|
['composant_subcomponent_slots', 'typecomposantid'],
|
||||||
|
['piece_product_slots', 'typeproductid'],
|
||||||
|
['machine_component_links', 'modeltypeid'],
|
||||||
|
['machine_piece_links', 'modeltypeid'],
|
||||||
|
['machine_product_links', 'modeltypeid'],
|
||||||
|
];
|
||||||
|
|
||||||
|
public function preRemove(PreRemoveEventArgs $args): void
|
||||||
|
{
|
||||||
|
$entity = $args->getObject();
|
||||||
|
if (!$entity instanceof ModelType) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $entity->getId();
|
||||||
|
if (!$id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$conn = $args->getObjectManager()->getConnection();
|
||||||
|
foreach (self::NULLABLE_REFERENCES as [$table, $column]) {
|
||||||
|
$conn->executeStatement(
|
||||||
|
sprintf('UPDATE %s SET %s = NULL WHERE %s = ?', $table, $column, $column),
|
||||||
|
[$id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/Service/ActorProfileResolver.php
Normal file
41
src/Service/ActorProfileResolver.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Entity\Profile;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class ActorProfileResolver
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly RequestStack $requestStack,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function resolve(): ?string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$session = $this->requestStack->getSession();
|
||||||
|
if ($session instanceof SessionInterface) {
|
||||||
|
$profileId = $session->get('profileId');
|
||||||
|
if ($profileId) {
|
||||||
|
return (string) $profileId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Throwable) {
|
||||||
|
// No session available (CLI context, etc.)
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->security->getUser();
|
||||||
|
if ($user instanceof Profile) {
|
||||||
|
return $user->getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,7 +34,6 @@ use DateTimeInterface;
|
|||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use LogicException;
|
use LogicException;
|
||||||
use Symfony\Component\HttpFoundation\RequestStack;
|
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
final class EntityVersionService
|
final class EntityVersionService
|
||||||
@@ -56,7 +55,7 @@ final class EntityVersionService
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly AuditLogRepository $auditLogs,
|
private readonly AuditLogRepository $auditLogs,
|
||||||
private readonly EntityManagerInterface $em,
|
private readonly EntityManagerInterface $em,
|
||||||
private readonly RequestStack $requestStack,
|
private readonly ActorProfileResolver $actorProfileResolver,
|
||||||
private readonly MachineRepository $machines,
|
private readonly MachineRepository $machines,
|
||||||
private readonly ComposantRepository $composants,
|
private readonly ComposantRepository $composants,
|
||||||
private readonly PieceRepository $pieces,
|
private readonly PieceRepository $pieces,
|
||||||
@@ -187,7 +186,7 @@ final class EntityVersionService
|
|||||||
'restore',
|
'restore',
|
||||||
['restoredFromVersion' => $version, 'restoreMode' => $restoreMode],
|
['restoredFromVersion' => $version, 'restoreMode' => $restoreMode],
|
||||||
$this->buildCurrentSnapshot($entityType, $entity),
|
$this->buildCurrentSnapshot($entityType, $entity),
|
||||||
$this->resolveActorProfileId(),
|
$this->actorProfileResolver->resolve(),
|
||||||
$newVersion,
|
$newVersion,
|
||||||
);
|
);
|
||||||
$this->em->persist($restoreAuditLog);
|
$this->em->persist($restoreAuditLog);
|
||||||
@@ -917,25 +916,11 @@ final class EntityVersionService
|
|||||||
'position' => $slot->getPosition(),
|
'position' => $slot->getPosition(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
$snapshot['productSlots'] = [];
|
$snapshot['productSlots'] = $this->serializeProductSlots($entity->getProductSlots());
|
||||||
foreach ($entity->getProductSlots() as $slot) {
|
|
||||||
$snapshot['productSlots'][] = [
|
|
||||||
'id' => $slot->getId(), 'typeProductId' => $slot->getTypeProduct()?->getId(),
|
|
||||||
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
|
|
||||||
'familyCode' => $slot->getFamilyCode(), 'position' => $slot->getPosition(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('piece' === $entityType) {
|
if ('piece' === $entityType) {
|
||||||
$snapshot['productSlots'] = [];
|
$snapshot['productSlots'] = $this->serializeProductSlots($entity->getProductSlots());
|
||||||
foreach ($entity->getProductSlots() as $slot) {
|
|
||||||
$snapshot['productSlots'][] = [
|
|
||||||
'id' => $slot->getId(), 'typeProductId' => $slot->getTypeProduct()?->getId(),
|
|
||||||
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
|
|
||||||
'familyCode' => $slot->getFamilyCode(), 'position' => $slot->getPosition(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom field values
|
// Custom field values
|
||||||
@@ -953,21 +938,23 @@ final class EntityVersionService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the current actor profile ID from the session.
|
* @param iterable<ComposantProductSlot|PieceProductSlot> $slots
|
||||||
* Mirrors AbstractAuditSubscriber::resolveActorProfileId().
|
*
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
private function resolveActorProfileId(): ?string
|
private function serializeProductSlots(iterable $slots): array
|
||||||
{
|
{
|
||||||
try {
|
$serialized = [];
|
||||||
$session = $this->requestStack->getSession();
|
foreach ($slots as $slot) {
|
||||||
$profileId = $session->get('profileId');
|
$serialized[] = [
|
||||||
if ($profileId) {
|
'id' => $slot->getId(),
|
||||||
return (string) $profileId;
|
'typeProductId' => $slot->getTypeProduct()?->getId(),
|
||||||
}
|
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
|
||||||
} catch (Throwable) {
|
'familyCode' => $slot->getFamilyCode(),
|
||||||
// No session available (CLI context, etc.)
|
'position' => $slot->getPosition(),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return $serialized;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,23 +4,17 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Service;
|
namespace App\Service;
|
||||||
|
|
||||||
use App\Entity\Profile;
|
|
||||||
use App\Enum\ModelCategory;
|
use App\Enum\ModelCategory;
|
||||||
use App\Repository\ModelTypeRepository;
|
use App\Repository\ModelTypeRepository;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\DBAL\Connection;
|
use Doctrine\DBAL\Connection;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
|
||||||
use Symfony\Component\HttpFoundation\RequestStack;
|
|
||||||
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
final class ModelTypeCategoryConversionService
|
final class ModelTypeCategoryConversionService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly Connection $connection,
|
private readonly Connection $connection,
|
||||||
private readonly ModelTypeRepository $modelTypes,
|
private readonly ModelTypeRepository $modelTypes,
|
||||||
private readonly RequestStack $requestStack,
|
private readonly ActorProfileResolver $actorProfileResolver,
|
||||||
private readonly Security $security,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -327,17 +321,7 @@ final class ModelTypeCategoryConversionService
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 7. Update ModelType
|
// 7. Update ModelType
|
||||||
$this->connection->executeStatement(
|
$this->updateModelTypeCategory($modelTypeId, ModelCategory::COMPONENT);
|
||||||
'UPDATE model_types
|
|
||||||
SET category = :cat,
|
|
||||||
updatedat = :now
|
|
||||||
WHERE id = :id',
|
|
||||||
[
|
|
||||||
'cat' => ModelCategory::COMPONENT->value,
|
|
||||||
'now' => new DateTimeImmutable()->format('Y-m-d H:i:s'),
|
|
||||||
'id' => $modelTypeId,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
return $count;
|
return $count;
|
||||||
}
|
}
|
||||||
@@ -406,19 +390,24 @@ final class ModelTypeCategoryConversionService
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 7. Update ModelType
|
// 7. Update ModelType
|
||||||
|
$this->updateModelTypeCategory($modelTypeId, ModelCategory::PIECE);
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateModelTypeCategory(string $modelTypeId, ModelCategory $category): void
|
||||||
|
{
|
||||||
$this->connection->executeStatement(
|
$this->connection->executeStatement(
|
||||||
'UPDATE model_types
|
'UPDATE model_types
|
||||||
SET category = :cat,
|
SET category = :cat,
|
||||||
updatedat = :now
|
updatedat = :now
|
||||||
WHERE id = :id',
|
WHERE id = :id',
|
||||||
[
|
[
|
||||||
'cat' => ModelCategory::PIECE->value,
|
'cat' => $category->value,
|
||||||
'now' => new DateTimeImmutable()->format('Y-m-d H:i:s'),
|
'now' => new DateTimeImmutable()->format('Y-m-d H:i:s'),
|
||||||
'id' => $modelTypeId,
|
'id' => $modelTypeId,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return $count;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -457,30 +446,9 @@ final class ModelTypeCategoryConversionService
|
|||||||
'action' => 'convert',
|
'action' => 'convert',
|
||||||
'diff' => json_encode($diff),
|
'diff' => json_encode($diff),
|
||||||
'snapshot' => json_encode($snapshot),
|
'snapshot' => json_encode($snapshot),
|
||||||
'actor' => $this->resolveActorProfileId(),
|
'actor' => $this->actorProfileResolver->resolve(),
|
||||||
'now' => $now,
|
'now' => $now,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveActorProfileId(): ?string
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$session = $this->requestStack->getSession();
|
|
||||||
if ($session instanceof SessionInterface) {
|
|
||||||
$profileId = $session->get('profileId');
|
|
||||||
if ($profileId) {
|
|
||||||
return (string) $profileId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (Throwable) {
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = $this->security->getUser();
|
|
||||||
if ($user instanceof Profile) {
|
|
||||||
return $user->getId();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
12
symfony.lock
12
symfony.lock
@@ -157,6 +157,18 @@
|
|||||||
"symfony/mcp-bundle": {
|
"symfony/mcp-bundle": {
|
||||||
"version": "v0.6.0"
|
"version": "v0.6.0"
|
||||||
},
|
},
|
||||||
|
"symfony/monolog-bundle": {
|
||||||
|
"version": "4.0",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "3.7",
|
||||||
|
"ref": "1b9efb10c54cb51c713a9391c9300ff8bceda459"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/monolog.yaml"
|
||||||
|
]
|
||||||
|
},
|
||||||
"symfony/property-info": {
|
"symfony/property-info": {
|
||||||
"version": "8.0",
|
"version": "8.0",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ namespace App\Tests;
|
|||||||
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
|
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
|
||||||
use ApiPlatform\Symfony\Bundle\Test\Client;
|
use ApiPlatform\Symfony\Bundle\Test\Client;
|
||||||
use App\Entity\Composant;
|
use App\Entity\Composant;
|
||||||
|
use App\Entity\ComposantConstructeurLink;
|
||||||
use App\Entity\ComposantPieceSlot;
|
use App\Entity\ComposantPieceSlot;
|
||||||
use App\Entity\ComposantProductSlot;
|
use App\Entity\ComposantProductSlot;
|
||||||
use App\Entity\ComposantSubcomponentSlot;
|
use App\Entity\ComposantSubcomponentSlot;
|
||||||
use App\Entity\ComposantConstructeurLink;
|
|
||||||
use App\Entity\Constructeur;
|
use App\Entity\Constructeur;
|
||||||
use App\Entity\CustomField;
|
use App\Entity\CustomField;
|
||||||
use App\Entity\CustomFieldValue;
|
use App\Entity\CustomFieldValue;
|
||||||
@@ -467,6 +467,14 @@ abstract class AbstractApiTestCase extends ApiTestCase
|
|||||||
$em->persist($cfv);
|
$em->persist($cfv);
|
||||||
$em->flush();
|
$em->flush();
|
||||||
|
|
||||||
|
// Keep inverse-side collections in sync so identity-mapped entities reflect the new CFV.
|
||||||
|
if (null !== $machineComponentLink && !$machineComponentLink->getContextFieldValues()->contains($cfv)) {
|
||||||
|
$machineComponentLink->getContextFieldValues()->add($cfv);
|
||||||
|
}
|
||||||
|
if (null !== $machinePieceLink && !$machinePieceLink->getContextFieldValues()->contains($cfv)) {
|
||||||
|
$machinePieceLink->getContextFieldValues()->add($cfv);
|
||||||
|
}
|
||||||
|
|
||||||
return $cfv;
|
return $cfv;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ namespace App\Tests\Api\Entity;
|
|||||||
use App\Enum\ModelCategory;
|
use App\Enum\ModelCategory;
|
||||||
use App\Tests\AbstractApiTestCase;
|
use App\Tests\AbstractApiTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
class MachineContextCustomFieldTest extends AbstractApiTestCase
|
class MachineContextCustomFieldTest extends AbstractApiTestCase
|
||||||
{
|
{
|
||||||
public function testStructureReturnsContextFieldsOnComponentLink(): void
|
public function testStructureReturnsContextFieldsOnComponentLink(): void
|
||||||
@@ -56,7 +59,7 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
|
|||||||
|
|
||||||
$normalFields = array_filter(
|
$normalFields = array_filter(
|
||||||
$componentLink['composant']['customFields'],
|
$componentLink['composant']['customFields'],
|
||||||
fn (array $f) => $f['name'] === 'Serial',
|
fn (array $f) => 'Serial' === $f['name'],
|
||||||
);
|
);
|
||||||
$this->assertCount(1, $normalFields);
|
$this->assertCount(1, $normalFields);
|
||||||
}
|
}
|
||||||
@@ -65,8 +68,8 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
|
|||||||
{
|
{
|
||||||
$client = $this->createGestionnaireClient();
|
$client = $this->createGestionnaireClient();
|
||||||
|
|
||||||
$site = $this->createSite('Site B');
|
$site = $this->createSite('Site B');
|
||||||
$modelType = $this->createModelType('Bearing', 'BRG', ModelCategory::PIECE);
|
$modelType = $this->createModelType('Bearing', 'BRG', ModelCategory::PIECE);
|
||||||
$contextField = $this->createCustomField(
|
$contextField = $this->createCustomField(
|
||||||
name: 'Wear Level',
|
name: 'Wear Level',
|
||||||
type: 'select',
|
type: 'select',
|
||||||
@@ -101,8 +104,8 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
|
|||||||
{
|
{
|
||||||
$client = $this->createGestionnaireClient();
|
$client = $this->createGestionnaireClient();
|
||||||
|
|
||||||
$site = $this->createSite('Site C');
|
$site = $this->createSite('Site C');
|
||||||
$modelType = $this->createModelType('Pump', 'PMP', ModelCategory::COMPONENT);
|
$modelType = $this->createModelType('Pump', 'PMP', ModelCategory::COMPONENT);
|
||||||
$contextField = $this->createCustomField(
|
$contextField = $this->createCustomField(
|
||||||
name: 'Flow Rate',
|
name: 'Flow Rate',
|
||||||
type: 'number',
|
type: 'number',
|
||||||
@@ -131,8 +134,8 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
|
|||||||
{
|
{
|
||||||
$client = $this->createGestionnaireClient();
|
$client = $this->createGestionnaireClient();
|
||||||
|
|
||||||
$site = $this->createSite('Site D');
|
$site = $this->createSite('Site D');
|
||||||
$modelType = $this->createModelType('Valve', 'VLV', ModelCategory::COMPONENT);
|
$modelType = $this->createModelType('Valve', 'VLV', ModelCategory::COMPONENT);
|
||||||
$contextField = $this->createCustomField(
|
$contextField = $this->createCustomField(
|
||||||
name: 'Pressure',
|
name: 'Pressure',
|
||||||
type: 'number',
|
type: 'number',
|
||||||
@@ -171,8 +174,8 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
|
|||||||
{
|
{
|
||||||
$client = $this->createGestionnaireClient();
|
$client = $this->createGestionnaireClient();
|
||||||
|
|
||||||
$site = $this->createSite('Site E');
|
$site = $this->createSite('Site E');
|
||||||
$modelType = $this->createModelType('Sensor', 'SNS', ModelCategory::COMPONENT);
|
$modelType = $this->createModelType('Sensor', 'SNS', ModelCategory::COMPONENT);
|
||||||
$contextField = $this->createCustomField(
|
$contextField = $this->createCustomField(
|
||||||
name: 'Calibration Date',
|
name: 'Calibration Date',
|
||||||
type: 'date',
|
type: 'date',
|
||||||
@@ -190,8 +193,8 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
|
|||||||
{
|
{
|
||||||
$client = $this->createGestionnaireClient();
|
$client = $this->createGestionnaireClient();
|
||||||
|
|
||||||
$site = $this->createSite('Site F');
|
$site = $this->createSite('Site F');
|
||||||
$modelType = $this->createModelType('Motor Clone', 'MOTC', ModelCategory::COMPONENT);
|
$modelType = $this->createModelType('Motor Clone', 'MOTC', ModelCategory::COMPONENT);
|
||||||
$contextField = $this->createCustomField(
|
$contextField = $this->createCustomField(
|
||||||
name: 'RPM Setting',
|
name: 'RPM Setting',
|
||||||
type: 'number',
|
type: 'number',
|
||||||
@@ -225,4 +228,84 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
|
|||||||
$this->assertCount(1, $clonedLink['contextCustomFieldValues']);
|
$this->assertCount(1, $clonedLink['contextCustomFieldValues']);
|
||||||
$this->assertSame('3000', $clonedLink['contextCustomFieldValues'][0]['value']);
|
$this->assertSame('3000', $clonedLink['contextCustomFieldValues'][0]['value']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testCloneMachineCopiesPieceContextFieldValues(): void
|
||||||
|
{
|
||||||
|
$client = $this->createGestionnaireClient();
|
||||||
|
|
||||||
|
$site = $this->createSite('Site G');
|
||||||
|
$modelType = $this->createModelType('Bearing Clone', 'BRGC', ModelCategory::PIECE);
|
||||||
|
$contextField = $this->createCustomField(
|
||||||
|
name: 'Wear Level',
|
||||||
|
type: 'text',
|
||||||
|
typePiece: $modelType,
|
||||||
|
machineContextOnly: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
$source = $this->createMachine('Source Piece Machine', $site);
|
||||||
|
$piece = $this->createPiece('Bearing C', 'BRGC-001', $modelType);
|
||||||
|
$link = $this->createMachinePieceLink($source, $piece);
|
||||||
|
|
||||||
|
$this->createCustomFieldValue(
|
||||||
|
customField: $contextField,
|
||||||
|
value: 'Fair',
|
||||||
|
piece: $piece,
|
||||||
|
machinePieceLink: $link,
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $client->request('POST', '/api/machines/'.$source->getId().'/clone', [
|
||||||
|
'json' => [
|
||||||
|
'name' => 'Cloned Piece Machine',
|
||||||
|
'siteId' => $site->getId(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertResponseStatusCodeSame(201);
|
||||||
|
$data = $response->toArray();
|
||||||
|
|
||||||
|
$clonedLink = $data['pieceLinks'][0] ?? null;
|
||||||
|
$this->assertNotNull($clonedLink, 'Clone should expose at least one pieceLink');
|
||||||
|
$this->assertCount(1, $clonedLink['contextCustomFieldValues']);
|
||||||
|
$this->assertSame('Fair', $clonedLink['contextCustomFieldValues'][0]['value']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCloneMachineLeavesSourceContextFieldValuesIntact(): void
|
||||||
|
{
|
||||||
|
$client = $this->createGestionnaireClient();
|
||||||
|
|
||||||
|
$site = $this->createSite('Site H');
|
||||||
|
$modelType = $this->createModelType('Motor Source', 'MOTS', ModelCategory::COMPONENT);
|
||||||
|
$contextField = $this->createCustomField(
|
||||||
|
name: 'RPM',
|
||||||
|
type: 'number',
|
||||||
|
typeComposant: $modelType,
|
||||||
|
machineContextOnly: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
$source = $this->createMachine('Original Machine', $site);
|
||||||
|
$composant = $this->createComposant('Motor S', 'MOTS-001', $modelType);
|
||||||
|
$link = $this->createMachineComponentLink($source, $composant);
|
||||||
|
|
||||||
|
$this->createCustomFieldValue(
|
||||||
|
customField: $contextField,
|
||||||
|
value: '1500',
|
||||||
|
composant: $composant,
|
||||||
|
machineComponentLink: $link,
|
||||||
|
);
|
||||||
|
|
||||||
|
$client->request('POST', '/api/machines/'.$source->getId().'/clone', [
|
||||||
|
'json' => [
|
||||||
|
'name' => 'Clone Machine',
|
||||||
|
'siteId' => $site->getId(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$this->assertResponseStatusCodeSame(201);
|
||||||
|
|
||||||
|
// Source must still expose its original context field value
|
||||||
|
$sourceData = $client->request('GET', '/api/machines/'.$source->getId().'/structure')->toArray();
|
||||||
|
$sourceLink = $sourceData['componentLinks'][0] ?? null;
|
||||||
|
$this->assertNotNull($sourceLink, 'Source machine should still expose its component link');
|
||||||
|
$this->assertCount(1, $sourceLink['contextCustomFieldValues']);
|
||||||
|
$this->assertSame('1500', $sourceLink['contextCustomFieldValues'][0]['value']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ class SessionProfileTest extends AbstractApiTestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertResponseStatusCodeSame(401);
|
$this->assertResponseStatusCodeSame(401);
|
||||||
$this->assertJsonContains(['message' => 'Mot de passe incorrect.']);
|
$this->assertJsonContains(['message' => 'Identifiants invalides.']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testLoginMissingPassword(): void
|
public function testLoginMissingPassword(): void
|
||||||
@@ -103,7 +103,7 @@ class SessionProfileTest extends AbstractApiTestCase
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertResponseStatusCodeSame(403);
|
$this->assertResponseStatusCodeSame(401);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testGetActiveProfileAuthenticated(): void
|
public function testGetActiveProfileAuthenticated(): void
|
||||||
|
|||||||
Reference in New Issue
Block a user