feat(Product): Ajoute la publication des produits et les périodes bloquées

Ajoute la possibilité de publier ou masquer un produit.
Permet de bloquer des périodes pour un produit.
Corrige des bugs liés à la suppression des produits du panier.
Mise à jour de l'affichage du calendrier pour les blocages.
```
This commit is contained in:
Serreau Jovann
2026-02-03 14:53:11 +01:00
parent 6c6324addc
commit d993a545d9
14 changed files with 850 additions and 25 deletions

View File

@@ -194,10 +194,19 @@ export default class PlaningLogistics extends HTMLElement {
fetch(`/crm/reservation/data?${params.toString()}`)
.then(response => response.json())
.then(data => {
const formattedData = data.map(item => ({
const formattedData = data.map(item => {
let bgColor = '#2563eb';
if (item.extendedProps.type === 'blocked') {
bgColor = '#ef4444';
} else if (item.extendedProps.isSigned) {
bgColor = '#059669';
}
return {
...item,
backgroundColor: item.extendedProps.isSigned ? '#059669' : '#2563eb'
}));
backgroundColor: bgColor,
borderColor: bgColor
};
});
successCallback(formattedData);
this.updateStats(formattedData);
loader.classList.add('hidden');
@@ -208,7 +217,22 @@ export default class PlaningLogistics extends HTMLElement {
});
},
eventContent: (arg) => {
const { caution, acompte, solde, isSigned } = arg.event.extendedProps;
const props = arg.event.extendedProps;
if (props.type === 'blocked') {
return {
html: `
<div class="fc-event-main-frame">
<div class="text-[10px] font-black uppercase tracking-tight leading-tight line-clamp-2 flex items-center gap-1 text-white">
${arg.event.title.replace('BLOCAGE: ', '')}
</div>
<div class="text-[9px] text-white/80 italic truncate mt-0.5">${props.reason || 'Pas de raison'}</div>
</div>
`
};
}
const { caution, acompte, solde, isSigned } = props;
return {
html: `
<div class="fc-event-main-frame">
@@ -244,6 +268,32 @@ export default class PlaningLogistics extends HTMLElement {
const props = event.extendedProps;
const modalStatus = this.querySelector('#modal-status-container');
// Reset visibility
this.querySelector('#link-phone').style.display = 'flex';
this.querySelector('#link-email').style.display = 'flex';
this.querySelector('#modal-link-contrat').parentElement.style.display = 'flex';
this.querySelector('.bg-slate-900\\/50.rounded-2xl.border').style.display = 'block'; // Address container
if (props.type === 'blocked') {
this.querySelector('#modal-contract-number').innerText = 'INDISPONIBILITÉ';
this.querySelector('#modal-title').innerText = props.productName;
this.querySelector('#modal-client').innerText = props.reason || 'Aucune raison spécifiée';
this.querySelector('#modal-email').innerText = '-';
this.querySelector('#modal-phone').innerText = '-';
this.querySelector('#modal-start').innerText = props.start;
this.querySelector('#modal-end').innerText = props.end;
// Hide irrelevant sections
this.querySelector('#link-phone').style.display = 'none';
this.querySelector('#link-email').style.display = 'none';
this.querySelector('#modal-link-contrat').parentElement.style.display = 'none';
this.querySelector('.bg-slate-900\\/50.rounded-2xl.border').style.display = 'none';
modalStatus.innerHTML = '<span class="px-2.5 py-1 rounded-lg text-[9px] font-black uppercase tracking-widest bg-rose-500/10 text-rose-500 border border-rose-500/20">Blocage Matériel</span>';
modal.classList.replace('hidden', 'flex');
return;
}
this.querySelector('#modal-contract-number').innerText = `Contrat n°${props.contractNumber || '?'}`;
this.querySelector('#modal-title').innerText = event.title;
this.querySelector('#modal-client').innerText = props.client || 'Client inconnu';

View File

@@ -128,6 +128,21 @@ export class FlowReserve extends HTMLAnchorElement {
const data = await response.json();
this.productsAvailable = data.available;
if (data.unavailable_products_ids && data.unavailable_products_ids.length > 0) {
let currentList = this.getList();
const initialLength = currentList.length;
// Filter out unavailable items
currentList = currentList.filter(id => !data.unavailable_products_ids.includes(parseInt(id)) && !data.unavailable_products_ids.includes(String(id)));
if (currentList.length !== initialLength) {
localStorage.setItem(this.storageKey, JSON.stringify(currentList));
window.dispatchEvent(new CustomEvent('cart:updated'));
console.warn('Produits indisponibles retirés du panier:', data.unavailable_products_ids);
// Force refresh to update UI immediately
this.refreshContent();
}
}
} catch (error) {
console.error('Erreur lors de la vérification de disponibilité:', error);
this.productsAvailable = false;
@@ -242,6 +257,20 @@ export class FlowReserve extends HTMLAnchorElement {
const data = await response.json();
// Handle removed products (deleted from DB)
if (data.unavailable_products_ids && data.unavailable_products_ids.length > 0) {
let currentList = this.getList();
const initialLength = currentList.length;
currentList = currentList.filter(id => !data.unavailable_products_ids.includes(parseInt(id)) && !data.unavailable_products_ids.includes(String(id))); // Handle string/int types
if (currentList.length !== initialLength) {
localStorage.setItem(this.storageKey, JSON.stringify(currentList));
window.dispatchEvent(new CustomEvent('cart:updated'));
// We don't recurse here to avoid infinite loops, but the UI will update next time or we could just use the returned 'products' which already excludes them.
console.warn('Certains produits ont été retirés car ils n\'existent plus:', data.unavailable_products_ids);
}
}
// Merge client-side dates if server didn't return them (or to prioritize client choice)
if (!data.start_date && dates.start) data.start_date = this.formatDate(dates.start);
if (!data.end_date && dates.end) data.end_date = this.formatDate(dates.end);

View File

@@ -0,0 +1,244 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260203131644 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE product_blocked (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, date_start TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, date_end TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, reason TEXT DEFAULT NULL, product_id INT NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_B8CF0304584665A ON product_blocked (product_id)');
$this->addSql('ALTER TABLE product_blocked ADD CONSTRAINT FK_B8CF0304584665A FOREIGN KEY (product_id) REFERENCES product (id) NOT DEFERRABLE');
$this->addSql('ALTER TABLE account ALTER id DROP DEFAULT');
$this->addSql('ALTER TABLE account ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('COMMENT ON COLUMN account.update_at IS \'\'');
$this->addSql('ALTER TABLE account_login_register ALTER id DROP DEFAULT');
$this->addSql('ALTER TABLE account_login_register ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('COMMENT ON COLUMN account_login_register.login_at IS \'\'');
$this->addSql('ALTER TABLE account_reset_password_request ALTER id DROP DEFAULT');
$this->addSql('ALTER TABLE account_reset_password_request ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('COMMENT ON COLUMN account_reset_password_request.requested_at IS \'\'');
$this->addSql('COMMENT ON COLUMN account_reset_password_request.expires_at IS \'\'');
$this->addSql('ALTER TABLE audit_log ALTER id DROP DEFAULT');
$this->addSql('ALTER TABLE audit_log ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('COMMENT ON COLUMN audit_log.action_at IS \'\'');
$this->addSql('ALTER TABLE backup ALTER id DROP DEFAULT');
$this->addSql('ALTER TABLE backup ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('COMMENT ON COLUMN backup.created_at IS \'\'');
$this->addSql('ALTER TABLE contrats ALTER id DROP DEFAULT');
$this->addSql('ALTER TABLE contrats ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('COMMENT ON COLUMN contrats.create_at IS \'\'');
$this->addSql('COMMENT ON COLUMN contrats.date_at IS \'\'');
$this->addSql('COMMENT ON COLUMN contrats.end_at IS \'\'');
$this->addSql('COMMENT ON COLUMN contrats.update_at IS \'\'');
$this->addSql('ALTER TABLE contrats_line ALTER id DROP DEFAULT');
$this->addSql('ALTER TABLE contrats_line ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('ALTER TABLE contrats_option ALTER id DROP DEFAULT');
$this->addSql('ALTER TABLE contrats_option ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('ALTER TABLE contrats_payments ALTER id DROP DEFAULT');
$this->addSql('ALTER TABLE contrats_payments ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('ALTER TABLE contrats_payments ALTER card TYPE JSON USING card::json');
$this->addSql('COMMENT ON COLUMN contrats_payments.payment_at IS \'\'');
$this->addSql('COMMENT ON COLUMN contrats_payments.validate_at IS \'\'');
$this->addSql('COMMENT ON COLUMN contrats_payments.card IS \'\'');
$this->addSql('COMMENT ON COLUMN contrats_payments.update_at IS \'\'');
$this->addSql('ALTER TABLE customer ALTER id DROP DEFAULT');
$this->addSql('ALTER TABLE customer ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('COMMENT ON COLUMN customer.verification_code_expires_at IS \'\'');
$this->addSql('ALTER TABLE customer_address ALTER id DROP DEFAULT');
$this->addSql('ALTER TABLE customer_address ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('ALTER TABLE customer_tracking ALTER id DROP DEFAULT');
$this->addSql('ALTER TABLE customer_tracking ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('ALTER TABLE devis ALTER id DROP DEFAULT');
$this->addSql('ALTER TABLE devis ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('COMMENT ON COLUMN devis.create_a IS \'\'');
$this->addSql('COMMENT ON COLUMN devis.update_at IS \'\'');
$this->addSql('COMMENT ON COLUMN devis.start_at IS \'\'');
$this->addSql('COMMENT ON COLUMN devis.end_at IS \'\'');
$this->addSql('ALTER TABLE devis_line ALTER id DROP DEFAULT');
$this->addSql('ALTER TABLE devis_line ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('ALTER TABLE devis_options ALTER id DROP DEFAULT');
$this->addSql('ALTER TABLE devis_options ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('ALTER TABLE etat_lieux ALTER id DROP DEFAULT');
$this->addSql('ALTER TABLE etat_lieux ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('ALTER TABLE facture ALTER id DROP DEFAULT');
$this->addSql('ALTER TABLE facture ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('COMMENT ON COLUMN facture.create_at IS \'\'');
$this->addSql('COMMENT ON COLUMN facture.update_at IS \'\'');
$this->addSql('ALTER TABLE formules ALTER id DROP DEFAULT');
$this->addSql('ALTER TABLE formules ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('COMMENT ON COLUMN formules.updated_at IS \'\'');
$this->addSql('ALTER TABLE formules_options_inclus ALTER id DROP DEFAULT');
$this->addSql('ALTER TABLE formules_options_inclus ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('ALTER TABLE formules_product_inclus ALTER id DROP DEFAULT');
$this->addSql('ALTER TABLE formules_product_inclus ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('ALTER TABLE formules_product_inclus ALTER config TYPE JSON USING config::json');
$this->addSql('COMMENT ON COLUMN formules_product_inclus.config IS \'\'');
$this->addSql('ALTER TABLE formules_restriction ALTER id DROP DEFAULT');
$this->addSql('ALTER TABLE formules_restriction ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('ALTER TABLE formules_restriction ALTER restriction_config TYPE JSON USING restriction_config::json');
$this->addSql('COMMENT ON COLUMN formules_restriction.restriction_config IS \'\'');
$this->addSql('ALTER TABLE options ALTER id DROP DEFAULT');
$this->addSql('ALTER TABLE options ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('COMMENT ON COLUMN options.updated_at IS \'\'');
$this->addSql('ALTER TABLE order_session ALTER id DROP DEFAULT');
$this->addSql('ALTER TABLE order_session ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('COMMENT ON COLUMN order_session.created_at IS \'\'');
$this->addSql('COMMENT ON COLUMN order_session.updated_at IS \'\'');
$this->addSql('ALTER TABLE prestaire ALTER id DROP DEFAULT');
$this->addSql('ALTER TABLE prestaire ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('ALTER TABLE product ADD is_publish BOOLEAN DEFAULT true');
$this->addSql('ALTER TABLE product ALTER id DROP DEFAULT');
$this->addSql('ALTER TABLE product ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('COMMENT ON COLUMN product.updated_at IS \'\'');
$this->addSql('ALTER TABLE product_doc ALTER id DROP DEFAULT');
$this->addSql('ALTER TABLE product_doc ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('COMMENT ON COLUMN product_doc.updated_at IS \'\'');
$this->addSql('ALTER TABLE product_photos ALTER id DROP DEFAULT');
$this->addSql('ALTER TABLE product_photos ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('COMMENT ON COLUMN product_photos.updated_at IS \'\'');
$this->addSql('ALTER TABLE product_reserve ALTER id DROP DEFAULT');
$this->addSql('ALTER TABLE product_reserve ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('COMMENT ON COLUMN product_reserve.start_at IS \'\'');
$this->addSql('COMMENT ON COLUMN product_reserve.end_at IS \'\'');
$this->addSql('ALTER TABLE product_video ALTER id DROP DEFAULT');
$this->addSql('ALTER TABLE product_video ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('COMMENT ON COLUMN product_video.updated_at IS \'\'');
$this->addSql('ALTER TABLE site_performance ALTER id DROP DEFAULT');
$this->addSql('ALTER TABLE site_performance ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('COMMENT ON COLUMN site_performance.created_at IS \'\'');
$this->addSql('ALTER TABLE stripe_config ALTER id DROP DEFAULT');
$this->addSql('ALTER TABLE stripe_config ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('ALTER TABLE messenger_messages ALTER id DROP DEFAULT');
$this->addSql('ALTER TABLE messenger_messages ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('COMMENT ON COLUMN messenger_messages.created_at IS \'\'');
$this->addSql('COMMENT ON COLUMN messenger_messages.available_at IS \'\'');
$this->addSql('COMMENT ON COLUMN messenger_messages.delivered_at IS \'\'');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE product_blocked DROP CONSTRAINT FK_B8CF0304584665A');
$this->addSql('DROP TABLE product_blocked');
$this->addSql('ALTER TABLE "account" ALTER id SET DEFAULT nextval(\'account_id_seq\'::regclass)');
$this->addSql('ALTER TABLE "account" ALTER id DROP IDENTITY');
$this->addSql('COMMENT ON COLUMN "account".update_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE account_login_register ALTER id SET DEFAULT nextval(\'account_login_register_id_seq\'::regclass)');
$this->addSql('ALTER TABLE account_login_register ALTER id DROP IDENTITY');
$this->addSql('COMMENT ON COLUMN account_login_register.login_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE account_reset_password_request ALTER id SET DEFAULT nextval(\'account_reset_password_request_id_seq\'::regclass)');
$this->addSql('ALTER TABLE account_reset_password_request ALTER id DROP IDENTITY');
$this->addSql('COMMENT ON COLUMN account_reset_password_request.requested_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN account_reset_password_request.expires_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE audit_log ALTER id SET DEFAULT nextval(\'audit_log_id_seq\'::regclass)');
$this->addSql('ALTER TABLE audit_log ALTER id DROP IDENTITY');
$this->addSql('COMMENT ON COLUMN audit_log.action_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE backup ALTER id SET DEFAULT nextval(\'backup_id_seq\'::regclass)');
$this->addSql('ALTER TABLE backup ALTER id DROP IDENTITY');
$this->addSql('COMMENT ON COLUMN backup.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE contrats ALTER id SET DEFAULT nextval(\'contrats_id_seq\'::regclass)');
$this->addSql('ALTER TABLE contrats ALTER id DROP IDENTITY');
$this->addSql('COMMENT ON COLUMN contrats.create_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN contrats.date_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN contrats.end_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN contrats.update_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE contrats_line ALTER id SET DEFAULT nextval(\'contrats_line_id_seq\'::regclass)');
$this->addSql('ALTER TABLE contrats_line ALTER id DROP IDENTITY');
$this->addSql('ALTER TABLE contrats_option ALTER id SET DEFAULT nextval(\'contrats_option_id_seq\'::regclass)');
$this->addSql('ALTER TABLE contrats_option ALTER id DROP IDENTITY');
$this->addSql('ALTER TABLE contrats_payments ALTER id SET DEFAULT nextval(\'contrats_payments_id_seq\'::regclass)');
$this->addSql('ALTER TABLE contrats_payments ALTER id DROP IDENTITY');
$this->addSql('ALTER TABLE contrats_payments ALTER card TYPE TEXT');
$this->addSql('COMMENT ON COLUMN contrats_payments.payment_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN contrats_payments.validate_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN contrats_payments.card IS \'(DC2Type:array)\'');
$this->addSql('COMMENT ON COLUMN contrats_payments.update_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE customer ALTER id SET DEFAULT nextval(\'customer_id_seq\'::regclass)');
$this->addSql('ALTER TABLE customer ALTER id DROP IDENTITY');
$this->addSql('COMMENT ON COLUMN customer.verification_code_expires_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE customer_address ALTER id SET DEFAULT nextval(\'customer_address_id_seq\'::regclass)');
$this->addSql('ALTER TABLE customer_address ALTER id DROP IDENTITY');
$this->addSql('ALTER TABLE customer_tracking ALTER id SET DEFAULT nextval(\'customer_tracking_id_seq\'::regclass)');
$this->addSql('ALTER TABLE customer_tracking ALTER id DROP IDENTITY');
$this->addSql('ALTER TABLE devis ALTER id SET DEFAULT nextval(\'devis_id_seq\'::regclass)');
$this->addSql('ALTER TABLE devis ALTER id DROP IDENTITY');
$this->addSql('COMMENT ON COLUMN devis.create_a IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN devis.update_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN devis.start_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN devis.end_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE devis_line ALTER id SET DEFAULT nextval(\'devis_line_id_seq\'::regclass)');
$this->addSql('ALTER TABLE devis_line ALTER id DROP IDENTITY');
$this->addSql('ALTER TABLE devis_options ALTER id SET DEFAULT nextval(\'devis_options_id_seq\'::regclass)');
$this->addSql('ALTER TABLE devis_options ALTER id DROP IDENTITY');
$this->addSql('ALTER TABLE etat_lieux ALTER id SET DEFAULT nextval(\'etat_lieux_id_seq\'::regclass)');
$this->addSql('ALTER TABLE etat_lieux ALTER id DROP IDENTITY');
$this->addSql('ALTER TABLE facture ALTER id SET DEFAULT nextval(\'facture_id_seq\'::regclass)');
$this->addSql('ALTER TABLE facture ALTER id DROP IDENTITY');
$this->addSql('COMMENT ON COLUMN facture.create_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN facture.update_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE formules ALTER id SET DEFAULT nextval(\'formules_id_seq\'::regclass)');
$this->addSql('ALTER TABLE formules ALTER id DROP IDENTITY');
$this->addSql('COMMENT ON COLUMN formules.updated_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE formules_options_inclus ALTER id SET DEFAULT nextval(\'formules_options_inclus_id_seq\'::regclass)');
$this->addSql('ALTER TABLE formules_options_inclus ALTER id DROP IDENTITY');
$this->addSql('ALTER TABLE formules_product_inclus ALTER id SET DEFAULT nextval(\'formules_product_inclus_id_seq\'::regclass)');
$this->addSql('ALTER TABLE formules_product_inclus ALTER id DROP IDENTITY');
$this->addSql('ALTER TABLE formules_product_inclus ALTER config TYPE TEXT');
$this->addSql('COMMENT ON COLUMN formules_product_inclus.config IS \'(DC2Type:array)\'');
$this->addSql('ALTER TABLE formules_restriction ALTER id SET DEFAULT nextval(\'formules_restriction_id_seq\'::regclass)');
$this->addSql('ALTER TABLE formules_restriction ALTER id DROP IDENTITY');
$this->addSql('ALTER TABLE formules_restriction ALTER restriction_config TYPE TEXT');
$this->addSql('COMMENT ON COLUMN formules_restriction.restriction_config IS \'(DC2Type:array)\'');
$this->addSql('ALTER TABLE messenger_messages ALTER id SET DEFAULT nextval(\'messenger_messages_id_seq\'::regclass)');
$this->addSql('ALTER TABLE messenger_messages ALTER id DROP IDENTITY');
$this->addSql('COMMENT ON COLUMN messenger_messages.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN messenger_messages.available_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN messenger_messages.delivered_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE options ALTER id SET DEFAULT nextval(\'options_id_seq\'::regclass)');
$this->addSql('ALTER TABLE options ALTER id DROP IDENTITY');
$this->addSql('COMMENT ON COLUMN options.updated_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE order_session ALTER id SET DEFAULT nextval(\'order_session_id_seq\'::regclass)');
$this->addSql('ALTER TABLE order_session ALTER id DROP IDENTITY');
$this->addSql('COMMENT ON COLUMN order_session.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN order_session.updated_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE prestaire ALTER id SET DEFAULT nextval(\'prestaire_id_seq\'::regclass)');
$this->addSql('ALTER TABLE prestaire ALTER id DROP IDENTITY');
$this->addSql('ALTER TABLE product DROP is_publish');
$this->addSql('ALTER TABLE product ALTER id SET DEFAULT nextval(\'product_id_seq\'::regclass)');
$this->addSql('ALTER TABLE product ALTER id DROP IDENTITY');
$this->addSql('COMMENT ON COLUMN product.updated_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE product_doc ALTER id SET DEFAULT nextval(\'product_doc_id_seq\'::regclass)');
$this->addSql('ALTER TABLE product_doc ALTER id DROP IDENTITY');
$this->addSql('COMMENT ON COLUMN product_doc.updated_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE product_photos ALTER id SET DEFAULT nextval(\'product_photos_id_seq\'::regclass)');
$this->addSql('ALTER TABLE product_photos ALTER id DROP IDENTITY');
$this->addSql('COMMENT ON COLUMN product_photos.updated_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE product_reserve ALTER id SET DEFAULT nextval(\'product_reserve_id_seq\'::regclass)');
$this->addSql('ALTER TABLE product_reserve ALTER id DROP IDENTITY');
$this->addSql('COMMENT ON COLUMN product_reserve.start_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN product_reserve.end_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE product_video ALTER id SET DEFAULT nextval(\'product_video_id_seq\'::regclass)');
$this->addSql('ALTER TABLE product_video ALTER id DROP IDENTITY');
$this->addSql('COMMENT ON COLUMN product_video.updated_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE site_performance ALTER id SET DEFAULT nextval(\'site_performance_id_seq\'::regclass)');
$this->addSql('ALTER TABLE site_performance ALTER id DROP IDENTITY');
$this->addSql('COMMENT ON COLUMN site_performance.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE stripe_config ALTER id SET DEFAULT nextval(\'stripe_config_id_seq\'::regclass)');
$this->addSql('ALTER TABLE stripe_config ALTER id DROP IDENTITY');
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260203133249 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE options ADD is_publish BOOLEAN DEFAULT true NOT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE options DROP is_publish');
}
}

View File

@@ -4,10 +4,13 @@ namespace App\Controller\Dashboard;
use App\Entity\Options;
use App\Entity\Product;
use App\Entity\ProductBlocked;
use App\Entity\ProductDoc;
use App\Entity\ProductPhotos;
use App\Entity\ProductReserve;
use App\Entity\ProductVideo;
use App\Form\OptionsType;
use App\Form\ProductBlockedType;
use App\Form\ProductDocType;
use App\Form\ProductPhotosType;
use App\Form\ProductType;
@@ -128,8 +131,19 @@ class ProductController extends AbstractController
}
#[Route(path: '/crm/products/edit/{id}', name: 'app_crm_product_edit', methods: ['GET', 'POST'])]
public function productEdit(Product $product, EntityManagerInterface $em, AppLogger $logger, Request $request, Client $stripe): Response
public function productEdit(Product $product, EntityManagerInterface $em, AppLogger $logger, Request $request, Client $stripe, \App\Repository\ProductBlockedRepository $blockedRepo, \App\Repository\ProductReserveRepository $reserveRepo): Response
{
// 0. Toggle Publish
if ($request->query->get('act') === 'togglePublish') {
$status = $request->query->get('status') === 'true';
$product->setIsPublish($status);
$em->flush();
$logger->record('UPDATE', "Statut de publication modifié pour {$product->getName()} : " . ($status ? 'En ligne' : 'Hors ligne'));
$this->addFlash('success', 'Le statut du produit a été mis à jour.');
return $this->redirectToRoute('app_crm_product_edit', ['id' => $product->getId()]);
}
// 1. Suppression de Document
if ($idDoc = $request->query->get('idDoc')) {
$doc = $em->getRepository(ProductDoc::class)->find($idDoc);
@@ -167,7 +181,23 @@ class ProductController extends AbstractController
return $this->redirectToRoute('app_crm_product_edit', ['id' => $product->getId()]);
}
// 4. Formulaire Ajout Photo
// 4. Suppression de Blocage
if ($request->query->get('act') === 'deleteBlocked' && $idBlocked = $request->query->get('idBlocked')) {
if ($this->isCsrfTokenValid('delete' . $idBlocked, $request->request->get('_token'))) {
$blocked = $em->getRepository(ProductBlocked::class)->find($idBlocked);
if ($blocked && $blocked->getProduct() === $product) {
$em->remove($blocked);
$em->flush();
$logger->record('DELETE', "Période bloquée supprimée sur {$product->getName()}");
$this->addFlash('success', 'Période bloquée supprimée.');
}
} else {
$this->addFlash('error', 'Action non autorisée (Token invalide).');
}
return $this->redirectToRoute('app_crm_product_edit', ['id' => $product->getId()]);
}
// 5. Formulaire Ajout Photo
$photo = new ProductPhotos();
$photo->setProduct($product);
$formPhotos = $this->createForm(ProductPhotosType::class, $photo);
@@ -210,7 +240,37 @@ class ProductController extends AbstractController
return $this->redirectToRoute('app_crm_product_edit', ['id' => $product->getId()]);
}
// 7. Formulaire Principal Produit
// 7. Formulaire Ajout Blocage
$blocked = new ProductBlocked();
$blocked->setProduct($product);
$formBlocked = $this->createForm(ProductBlockedType::class, $blocked);
$formBlocked->handleRequest($request);
if ($formBlocked->isSubmitted() && $formBlocked->isValid()) {
// Check for overlaps with other BLOCKS
$overlaps = $blockedRepo->findOverlappingForProduct($product, $blocked->getDateStart(), $blocked->getDateEnd());
// Check for overlaps with RESERVATIONS
$dummyReserve = new ProductReserve();
$dummyReserve->setProduct($product);
$dummyReserve->setStartAt($blocked->getDateStart());
$dummyReserve->setEndAt($blocked->getDateEnd());
$isAvailable = $reserveRepo->checkAvailability($dummyReserve);
if (count($overlaps) > 0) {
$this->addFlash('error', 'Impossible de bloquer cette période : elle chevauche une période DÉJÀ BLOQUÉE.');
} elseif (!$isAvailable) {
$this->addFlash('error', 'Impossible de bloquer cette période : elle chevauche une RÉSERVATION existante.');
} else {
$em->persist($blocked);
$em->flush();
$logger->record('CREATE', "Période bloquée ajoutée sur {$product->getName()}");
$this->addFlash('success', 'Période bloquée ajoutée.');
return $this->redirectToRoute('app_crm_product_edit', ['id' => $product->getId()]);
}
}
// 8. Formulaire Principal Produit
$form = $this->createForm(ProductType::class, $product);
$form->handleRequest($request);
@@ -229,6 +289,7 @@ class ProductController extends AbstractController
'formDoc' => $formDoc->createView(),
'formPhoto' => $formPhotos->createView(),
'formVideo' => $formVideo->createView(),
'formBlocked' => $formBlocked->createView(),
'product' => $product,
'is_edit' => true
]);

View File

@@ -34,13 +34,15 @@ class ReservationController extends AbstractController
* Endpoint pour alimenter le calendrier en JSON
*/
#[Route(path: '/crm/reservation/data', name: 'app_crm_reservation_data', methods: ['GET'])]
public function getReservationData(Request $request,ContratsRepository $contratsRepository): JsonResponse
public function getReservationData(Request $request, ContratsRepository $contratsRepository, \App\Repository\ProductBlockedRepository $productBlockedRepository): JsonResponse
{
$start = new \DateTimeImmutable($request->query->get('start'));
$end = new \DateTimeImmutable($request->query->get('end'));
/** @var Contrats[] $reservations */
$reservations = $contratsRepository->findBetweenDates($start, $end);
$events = [];
foreach ($reservations as $reservation) {
if($reservation->getProductReserves()->count() >0) {
$events[] = [
@@ -48,6 +50,7 @@ class ReservationController extends AbstractController
'start' => $reservation->getDateAt()->format(\DateTimeInterface::ATOM),
'end' => $reservation->getEndAt()->format(\DateTimeInterface::ATOM),
'extendedProps' => [
'type' => 'reservation',
'start' => $reservation->getDateAt()->format('d/m/Y'),
'end' => $reservation->getEndAt()->format('d/m/Y'),
'contractNumber' => $reservation->getCustomer()->getPhone(),
@@ -71,8 +74,22 @@ class ReservationController extends AbstractController
}
}
$blockedProducts = $productBlockedRepository->findBetweenDates($start, $end);
foreach ($blockedProducts as $blocked) {
$events[] = [
'title' => 'BLOCAGE: ' . $blocked->getProduct()->getName(),
'start' => $blocked->getDateStart()->format(\DateTimeInterface::ATOM),
'end' => $blocked->getDateEnd()->format(\DateTimeInterface::ATOM),
'color' => '#ef4444', // Red for blocked
'extendedProps' => [
'type' => 'blocked',
'reason' => $blocked->getReason(),
'productName' => $blocked->getProduct()->getName(),
'start' => $blocked->getDateStart()->format('d/m/Y H:i'),
'end' => $blocked->getDateEnd()->format('d/m/Y H:i'),
]
];
}
return new JsonResponse($events);
}

View File

@@ -171,7 +171,7 @@ class ReserverController extends AbstractController
#[Route('/', name: 'reservation')]
public function revervation(FormulesRepository $formulesRepository, ProductRepository $productRepository): Response
{
$products = $productRepository->findBy(['category' => '3-15 ans'], ['updatedAt' => 'DESC'], 3);
$products = $productRepository->findBy(['category' => '3-15 ans', 'isPublish' => true], ['updatedAt' => 'DESC'], 3);
$formules = $formulesRepository->findBy(['isPublish' => true], ['pos' => 'ASC'], 3);
return $this->render('revervation/home.twig', [
@@ -339,15 +339,14 @@ class ReserverController extends AbstractController
}
}
if (!is_array($ids)) {
$ids = [];
}
$products = [];
if (!empty($ids)) {
$products = $productRepository->findBy(['id' => $ids]);
}
$foundIds = array_map(fn($p) => $p->getId(), $products);
$removedIds = array_values(array_diff($ids, $foundIds));
$items = [];
$totalHT = 0;
$tvaEnabled = isset($_ENV['TVA_ENABLED']) && $_ENV['TVA_ENABLED'] === "true";
@@ -382,6 +381,7 @@ class ReserverController extends AbstractController
'start_date' => $startStr,
'end_date' => $endStr,
'products' => $items,
'unavailable_products_ids' => $removedIds,
'total' => [
'totalHT' => $totalHT,
'totalTva' => $totalTva,
@@ -431,7 +431,8 @@ class ReserverController extends AbstractController
OrderSessionRepository $repository,
ProductRepository $productRepository,
UploaderHelper $uploaderHelper,
ProductReserveRepository $productReserveRepository
ProductReserveRepository $productReserveRepository,
EntityManagerInterface $em
): Response {
$session = $repository->findOneBy(['uuid' => $sessionId]);
if (!$session) {
@@ -470,6 +471,14 @@ class ReserverController extends AbstractController
$products = $productRepository->findBy(['id' => $ids]);
}
// Cleanup missing products from session
$foundIds = array_map(fn($p) => $p->getId(), $products);
if (count($foundIds) !== count($ids)) {
$sessionData['ids'] = $foundIds;
$session->setProducts($sessionData);
$em->flush();
}
$items = [];
$totalHT = 0;
$tvaEnabled = isset($_ENV['TVA_ENABLED']) && $_ENV['TVA_ENABLED'] === "true";
@@ -520,7 +529,8 @@ class ReserverController extends AbstractController
OrderSessionRepository $repository,
ProductRepository $productRepository,
UploaderHelper $uploaderHelper,
ProductReserveRepository $productReserveRepository // Added dependency
ProductReserveRepository $productReserveRepository, // Added dependency
EntityManagerInterface $em
): Response {
// This is the POST target for the login form, but also the GET page.
// The authenticator handles the POST. For GET, we just render the page.
@@ -561,6 +571,14 @@ class ReserverController extends AbstractController
$products = $productRepository->findBy(['id' => $ids]);
}
// Cleanup missing products from session
$foundIds = array_map(fn($p) => $p->getId(), $products);
if (count($foundIds) !== count($ids)) {
$sessionData['ids'] = $foundIds;
$session->setProducts($sessionData);
$em->flush();
}
$items = [];
$totalHT = 0;
$tvaEnabled = isset($_ENV['TVA_ENABLED']) && $_ENV['TVA_ENABLED'] === "true";
@@ -685,7 +703,7 @@ class ReserverController extends AbstractController
public function revervationCatalogue(ProductRepository $productRepository): Response
{
return $this->render('revervation/catalogue.twig', [
'products' => $productRepository->findAll(),
'products' => $productRepository->findBy(['isPublish' => true]),
'tvaEnabled' => isset($_ENV['TVA_ENABLED']) && $_ENV['TVA_ENABLED'] === "true",
]);
}
@@ -801,7 +819,7 @@ class ReserverController extends AbstractController
$customer->getName() . " " . $customer->getSurname(),
"[Ludikevent] - Code de récupération",
"mails/welcome.twig",
['account' => $customer]
['customer' => $customer]
);
$em->persist($customer);
@@ -933,7 +951,7 @@ class ReserverController extends AbstractController
foreach ($results['hits'] as $result) {
$p = $productRepository->find($result['id']);
if ($p instanceof Product) {
if ($p instanceof Product && $p->isPublish()) {
$items[] = [
'image' => $uploaderHelper->asset($p, 'imageFile') ?: "/provider/images/favicon.png",
"name" => $p->getName(),

View File

@@ -41,11 +41,26 @@ class Options
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $updatedAt = null;
#[ORM\Column(type: 'boolean', options: ['default' => true])]
private ?bool $isPublish = true;
public function __construct()
{
}
public function isPublish(): ?bool
{
return $this->isPublish;
}
public function setIsPublish(bool $isPublish): static
{
$this->isPublish = $isPublish;
return $this;
}
public function getId(): ?int
{

View File

@@ -99,6 +99,15 @@ class Product
#[ORM\OneToMany(targetEntity: ProductVideo::class, mappedBy: 'product')]
private Collection $productVideos;
/**
* @var Collection<int, ProductBlocked>
*/
#[ORM\OneToMany(targetEntity: ProductBlocked::class, mappedBy: 'product', orphanRemoval: true)]
private Collection $productBlockeds;
#[ORM\Column(nullable: true, options: ['default' => true])]
private ?bool $isPublish = true;
public function __construct()
{
$this->productReserves = new ArrayCollection();
@@ -106,6 +115,8 @@ class Product
$this->formulesProductIncluses = new ArrayCollection();
$this->productPhotos = new ArrayCollection();
$this->productVideos = new ArrayCollection();
$this->productBlockeds = new ArrayCollection();
$this->isPublish = true;
}
public function slug()
{
@@ -474,4 +485,46 @@ class Product
return $this;
}
/**
* @return Collection<int, ProductBlocked>
*/
public function getProductBlockeds(): Collection
{
return $this->productBlockeds;
}
public function addProductBlocked(ProductBlocked $productBlocked): static
{
if (!$this->productBlockeds->contains($productBlocked)) {
$this->productBlockeds->add($productBlocked);
$productBlocked->setProduct($this);
}
return $this;
}
public function removeProductBlocked(ProductBlocked $productBlocked): static
{
if ($this->productBlockeds->removeElement($productBlocked)) {
// set the owning side to null (unless already changed)
if ($productBlocked->getProduct() === $this) {
$productBlocked->setProduct(null);
}
}
return $this;
}
public function isPublish(): ?bool
{
return $this->isPublish;
}
public function setIsPublish(?bool $isPublish): static
{
$this->isPublish = $isPublish;
return $this;
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace App\Entity;
use App\Repository\ProductBlockedRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ProductBlockedRepository::class)]
class ProductBlocked
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'productBlockeds')]
#[ORM\JoinColumn(nullable: false)]
private ?Product $product = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private ?\DateTimeImmutable $dateStart = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private ?\DateTimeImmutable $dateEnd = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $reason = null;
public function getId(): ?int
{
return $this->id;
}
public function getProduct(): ?Product
{
return $this->product;
}
public function setProduct(?Product $product): static
{
$this->product = $product;
return $this;
}
public function getDateStart(): ?\DateTimeImmutable
{
return $this->dateStart;
}
public function setDateStart(\DateTimeImmutable $dateStart): static
{
$this->dateStart = $dateStart;
return $this;
}
public function getDateEnd(): ?\DateTimeImmutable
{
return $this->dateEnd;
}
public function setDateEnd(\DateTimeImmutable $dateEnd): static
{
$this->dateEnd = $dateEnd;
return $this;
}
public function getReason(): ?string
{
return $this->reason;
}
public function setReason(?string $reason): static
{
$this->reason = $reason;
return $this;
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Form;
use App\Entity\ProductBlocked;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ProductBlockedType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('dateStart', DateTimeType::class, [
'widget' => 'single_text',
'label' => 'Début du blocage',
'input' => 'datetime_immutable',
])
->add('dateEnd', DateTimeType::class, [
'widget' => 'single_text',
'label' => 'Fin du blocage',
'input' => 'datetime_immutable',
])
->add('reason', TextareaType::class, [
'label' => 'Raison (optionnel)',
'required' => false,
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => ProductBlocked::class,
]);
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Repository;
use App\Entity\ProductBlocked;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ProductBlocked>
*
* @method ProductBlocked|null find($id, $lockMode = null, $lockVersion = null)
* @method ProductBlocked|null findOneBy(array $criteria, array $orderBy = null)
* @method ProductBlocked[] findAll()
* @method ProductBlocked[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class ProductBlockedRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ProductBlocked::class);
}
/**
* @return ProductBlocked[]
*/
public function findBetweenDates(\DateTimeInterface $start, \DateTimeInterface $end): array
{
return $this->createQueryBuilder('pb')
->where('pb.dateStart < :end')
->andWhere('pb.dateEnd > :start')
->setParameter('start', $start)
->setParameter('end', $end)
->getQuery()
->getResult();
}
public function findOverlappingForProduct(\App\Entity\Product $product, \DateTimeInterface $start, \DateTimeInterface $end): array
{
return $this->createQueryBuilder('pb')
->where('pb.product = :product')
->andWhere('pb.dateStart < :end')
->andWhere('pb.dateEnd > :start')
->setParameter('product', $product)
->setParameter('start', $start)
->setParameter('end', $end)
->getQuery()
->getResult();
}
// /**
// * @return ProductBlocked[] Returns an array of ProductBlocked objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('p')
// ->andWhere('p.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('p.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?ProductBlocked
// {
// return $this->createQueryBuilder('p')
// ->andWhere('p.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}

View File

@@ -127,7 +127,7 @@ class SiteMapListener implements EventSubscriberInterface
$urlContainer->addUrl($decoratedUrl, 'reservation_formules');
}
// --- 2. PRODUITS & IMAGES ---
foreach ($this->productRepository->findAll() as $product) {
foreach ($this->productRepository->findBy(['isPublish' => true]) as $product) {
$productUrl = $urlGenerator->generate(
'reservation_product_show',
['id' => $product->slug()],
@@ -148,7 +148,7 @@ class SiteMapListener implements EventSubscriberInterface
$urlContainer->addUrl($decoratedUrl, 'reservation_product');
}
foreach ($this->optionsRepository->findAll() as $product) {
foreach ($this->optionsRepository->findBy(['isPublish' => true]) as $product) {
$productUrl = $urlGenerator->generate(
'reservation_options_show',
['id' => $product->slug()],

View File

@@ -5,6 +5,35 @@
{% block actions %}
<div class="flex items-center gap-4">
{% if product is defined and product is not null and product.id %}
<div class="flex items-center">
{% if not product.isPublish %}
<div class="flex items-center bg-white/5 backdrop-blur-xl border border-rose-500/30 rounded-2xl overflow-hidden shadow-xl">
<div class="flex items-center px-5 py-3 space-x-3 bg-rose-500/5">
<div class="w-2 h-2 rounded-full bg-rose-500 shadow-[0_0_8px_rgba(244,63,94,0.8)]"></div>
<span class="text-[10px] font-black text-rose-500 uppercase tracking-widest">Hors ligne</span>
</div>
<a data-turbo="false" href="{{ path('app_crm_product_edit', {id: product.id, act: 'togglePublish', status: 'true'}) }}"
class="px-6 py-3 bg-emerald-600 hover:bg-emerald-500 text-white text-[10px] font-black uppercase tracking-widest transition-all active:scale-95 border-l border-white/5">
Publier
</a>
</div>
{% else %}
<div class="flex items-center bg-white/5 backdrop-blur-xl border border-emerald-500/30 rounded-2xl overflow-hidden shadow-xl">
<div class="flex items-center px-5 py-3 space-x-3 bg-emerald-500/5">
<div class="w-2 h-2 rounded-full bg-emerald-400 shadow-[0_0_8px_rgba(52,211,153,0.8)] animate-pulse"></div>
<span class="text-[10px] font-black text-emerald-400 uppercase tracking-widest">En ligne</span>
</div>
<a data-turbo="false" href="{{ path('app_crm_product_edit', {id: product.id, act: 'togglePublish', status: 'false'}) }}"
onclick="return confirm('Voulez-vous vraiment masquer ce produit ?')"
class="px-6 py-3 bg-white/5 hover:bg-rose-600 text-slate-400 hover:text-white text-[10px] font-black uppercase tracking-widest transition-all active:scale-95 border-l border-white/10">
Désactiver
</a>
</div>
{% endif %}
</div>
{% endif %}
{% if product.slug != "" %}
<a target="_blank" rel="nofollow"
href="https://reservation.ludikevent.fr{{ path('reservation_product_show', {id: product.slug}) }}"
@@ -172,7 +201,7 @@
<div class="flex justify-between items-center mt-1 px-1 {{ form.priceDay.vars.value ? '' : 'hidden' }}">
<p class="text-[9px] text-slate-500 italic" title="Pour les cartes standard de l'Espace économique européen">Com. Stripe (EEE) : 1,5% + 0,25€</p>
<p class="text-[10px] font-bold text-slate-400 commission-display">
~ {{ ((form.priceDay.vars.value * 0.015) + 0.25)|number_format(2, ',', ' ') }}
~ {{ ((form.priceDay.vars.value|default(0) * 0.015) + 0.25)|number_format(2, ',', ' ') }}
</p>
</div>
</div>
@@ -186,7 +215,7 @@
<div class="flex justify-between items-center mt-1 px-1 {{ form.priceSup.vars.value ? '' : 'hidden' }}">
<p class="text-[9px] text-slate-500 italic" title="Pour les cartes standard de l'Espace économique européen">Com. Stripe (EEE) : 1,5% + 0,25€</p>
<p class="text-[10px] font-bold text-slate-400 commission-display">
~ {{ ((form.priceSup.vars.value * 0.015) + 0.25)|number_format(2, ',', ' ') }}
~ {{ ((form.priceSup.vars.value|default(0) * 0.015) + 0.25)|number_format(2, ',', ' ') }}
</p>
</div>
</div>
@@ -425,5 +454,86 @@
{{ form_end(formPhoto) }}
</div>
{% endif %}
{# 07. PÉRIODES BLOQUÉES #}
{% if formBlocked is defined %}
<div class="backdrop-blur-xl bg-[#1e293b]/40 border border-white/5 rounded-[2.5rem] p-8 shadow-2xl mt-8">
<h3 class="text-lg font-bold text-white mb-6 flex items-center">
<span class="w-8 h-8 bg-rose-600/20 text-rose-500 rounded-lg flex items-center justify-center mr-3 text-[10px] font-black">07</span>
Indisponibilités & Blocages
</h3>
{# LISTE DES BLOCAGES #}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-10">
{% for blocked in product.productBlockeds %}
<div class="relative group p-6 rounded-2xl border border-white/5 bg-white/5 hover:bg-white/10 transition-all flex flex-col justify-between">
<div>
<div class="flex items-center gap-3 mb-4">
<div class="w-8 h-8 rounded-lg bg-rose-500/20 text-rose-500 flex items-center justify-center">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</div>
<span class="text-[10px] font-black uppercase tracking-widest text-white">Indisponible</span>
</div>
<div class="space-y-1">
<p class="text-xs text-slate-400">Du <strong class="text-white">{{ blocked.dateStart|date('d/m/Y H:i') }}</strong></p>
<p class="text-xs text-slate-400">Au <strong class="text-white">{{ blocked.dateEnd|date('d/m/Y H:i') }}</strong></p>
</div>
{% if blocked.reason %}
<p class="mt-4 text-[10px] text-slate-500 italic border-l-2 border-slate-700 pl-3">
{{ blocked.reason }}
</p>
{% endif %}
</div>
{# DELETE BUTTON BLOCKED #}
<div class="absolute top-3 right-3 opacity-0 group-hover:opacity-100 transition-all">
<form data-turbo="false" method="post" action="{{ path('app_crm_product_edit', {'id': product.id, act:'deleteBlocked', idBlocked: blocked.id}) }}"
onsubmit="return confirm('Supprimer ce blocage ?');" class="inline-block">
<input type="hidden" name="_token" value="{{ csrf_token('delete' ~ blocked.id) }}">
<button type="submit" class="p-2 text-slate-400 hover:text-rose-500 transition-colors" title="Supprimer">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</form>
</div>
</div>
{% else %}
<div class="col-span-full py-8 text-center border-2 border-dashed border-white/5 rounded-3xl">
<p class="text-[10px] font-black text-slate-600 uppercase tracking-[0.2em]">Aucune période bloquée</p>
</div>
{% endfor %}
</div>
<div class="h-px bg-white/5 w-full mb-10"></div>
{# FORMULAIRE D'AJOUT BLOCAGE #}
{{ form_start(formBlocked) }}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 items-start">
<div>
{{ form_label(formBlocked.dateStart, 'Début du blocage', {'label_attr': {'class': 'text-[10px] font-black text-slate-300 uppercase tracking-[0.2em] ml-1 mb-2 block'}}) }}
{{ form_widget(formBlocked.dateStart, {'attr': {'class': 'w-full bg-slate-900/50 border-white/5 rounded-2xl text-white focus:ring-rose-500/20 focus:border-rose-500 transition-all py-4 px-5 font-bold text-sm'}}) }}
</div>
<div>
{{ form_label(formBlocked.dateEnd, 'Fin du blocage', {'label_attr': {'class': 'text-[10px] font-black text-slate-300 uppercase tracking-[0.2em] ml-1 mb-2 block'}}) }}
{{ form_widget(formBlocked.dateEnd, {'attr': {'class': 'w-full bg-slate-900/50 border-white/5 rounded-2xl text-white focus:ring-rose-500/20 focus:border-rose-500 transition-all py-4 px-5 font-bold text-sm'}}) }}
</div>
<div class="md:col-span-2">
{{ form_label(formBlocked.reason, 'Raison (Optionnel)', {'label_attr': {'class': 'text-[10px] font-black text-slate-300 uppercase tracking-[0.2em] ml-1 mb-2 block'}}) }}
{{ form_widget(formBlocked.reason, {'attr': {'placeholder': 'Ex: Maintenance annuelle, Réparation...', 'class': 'w-full bg-slate-900/50 border-white/5 rounded-2xl text-white focus:ring-rose-500/20 focus:border-rose-500 transition-all py-4 px-5 font-medium text-sm min-h-[100px]'}}) }}
</div>
</div>
<div class="mt-8 flex justify-end">
<button type="submit" class="group px-8 py-4 bg-rose-600/10 hover:bg-rose-600 text-rose-500 hover:text-white text-[10px] font-black uppercase tracking-widest rounded-2xl transition-all border border-rose-500/20 flex items-center">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M12 4v16m8-8H4"/></svg>
Bloquer cette période
</button>
</div>
{{ form_end(formBlocked) }}
</div>
{% endif %}
</div>
{% endblock %}