Compare commits

...

10 Commits

32 changed files with 931 additions and 101 deletions

74
AGENTS.md Normal file
View File

@@ -0,0 +1,74 @@
# Repository Guidelines
## Project Structure & Module Organization
OpenSem builds on Laravel 9.
Core application code lives in `app/`, while HTTP routes reside in
`routes/` and Blade views in `resources/views/`. Reusable front-end
assets (JS, SCSS, images) sit under `resources/` and are compiled into
`public/` via Laravel Mix.
Database blueprints are versioned in `database/migrations/` with seeds
in `database/seeders/`.
Tests are organised in `tests/Unit/` and `tests/Feature/`; keep large
fixtures in `tests/Fixtures/` to avoid polluting source directories.
## Build, Test, and Development Commands
- `composer install` — install PHP dependencies defined in
`composer.json`.
- `php artisan serve` — start a local HTTP server on port 8000.
- `npm install && npm run dev` — install Node tooling and build UI
assets for development.
- `npm run prod` — generate minified production assets in `public/`.
- `php artisan migrate --seed` — apply database schema and load
default data for demo instances.
- `./build.sh` — builds a `.tar.xz` that contains the production and
deployement ready source to be deployed.
## Coding Style & Naming Conventions
Follow PSR-12 with four-space indentation and `snake_case` database
columns. Controllers, models, and Livewire components use StudlyCase
class names; private methods remain `camelCase`. Run `composer run
inspect` before opening a PR to execute `phpcs` and `phpstan`. For
front-end changes, keep Blade sections in lowercase kebab IDs (for
example, `@section('order-summary')`).
## Testing Guidelines
Use PHPUnit via `php artisan test`; target deterministic tests with
clear Arrange/Act/Assert blocks. Feature tests should mirror top-level
route names (e.g., `OrdersTest.php`). Unit tests belong in
`tests/Unit/` and should stub external services. When adding
migrations or service integrations, include coverage that exercises
failure paths. For granular checks, `./vendor/bin/phpunit --filter
FooTest` is acceptable, but always run the full suite before pushing.
## Commit & Pull Request Guidelines
Commits in this repo mix Conventional Commit prefixes (`new:`, `fix:`,
`chg:`); `fix: prevent null totals`. Keep messages in the imperative
mood and reference ticket IDs when available.
Pull requests must describe scope, list schema or configuration
changes, and note any manual follow-up (cron, storage links,
queues).
Attach screenshots or terminal logs when touching UI or console
output, and ensure CI scripts (when available) pass.
## Environment & Security Notes
Copy `.env.example` to `.env` and run `php artisan key:generate`
before local work. Never commit `.env`, `storage/`, or database dumps
containing sensitive data. Use the Docker resources in `docker/` only
for reproducible environments; keep secrets in your host overrides,
not in version control.

View File

@@ -1,10 +1,19 @@
## A propos de OpenSem
OpenSem est une solution de commerce électronique et un ERP développé pour les besoins exprimés.
OpenSem est une solution de commerce électronique et un ERP développé
pour les besoins exprimés.
Développée par Ludovic Candellier en étroite relation avec
Jardin'Envie.
L'application est écrite en PHP et est basée sur Laravel.
## About Laravel
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
Laravel is a web application framework with expressive, elegant
syntax. We believe development must be an enjoyable and creative
experience to be truly fulfilling. Laravel takes the pain out of
development by easing common tasks used in many web projects, such as:
- [Simple, fast routing engine](https://laravel.com/docs/routing).
- [Powerful dependency injection container](https://laravel.com/docs/container).
@@ -14,12 +23,21 @@ Laravel is a web application framework with expressive, elegant syntax. We belie
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
Laravel is accessible, powerful, and provides tools required for large, robust applications.
Laravel is accessible, powerful, and provides tools required for
large, robust applications.
## Learning Laravel
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
Laravel has the most extensive and thorough
[documentation](https://laravel.com/docs) and video tutorial library
of all modern web application frameworks, making it a breeze to get
started with the framework.
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains over 1500 video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
If you don't feel like reading, [Laracasts](https://laracasts.com) can
help. Laracasts contains over 1500 video tutorials on a range of
topics including Laravel, modern PHP, unit testing, and
JavaScript. Boost your skills by digging into our comprehensive video
library.
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
The Laravel framework is open-sourced software licensed under the [MIT
license](https://opensource.org/licenses/MIT).

View File

@@ -11,7 +11,7 @@ use Yajra\DataTables\Html\Column;
class CustomerInvoicesDataTable extends DataTable
{
public $model_name = 'invoices';
public $model_name = 'customer_invoices';
public $sortedColumn = 1;

View File

@@ -12,15 +12,6 @@ class CustomerInvoiceController extends Controller
return $dataTable->render('Admin.Shop.CustomerInvoices.list');
}
public function show($id)
{
$data = [
'invoice' => Invoices::get($id),
];
return view('Admin.Shop.CustomerInvoices.view', $data);
}
public function destroy($id)
{
return Invoices::destroy($id);

View File

@@ -2,9 +2,13 @@
namespace App\Http\Controllers\Shop;
use App\Repositories\Core\User\ShopCart;
use App\Repositories\Shop\Baskets;
use App\Repositories\Shop\CustomerAddresses;
use App\Repositories\Shop\Customers;
use App\Repositories\Shop\Offers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
class CustomerController extends Controller
@@ -44,8 +48,69 @@ class CustomerController extends Controller
public function storeProfileAjax(Request $request)
{
$data = $request->all();
if (array_key_exists('default_sale_channel_id', $data)) {
$customer = Customers::get(Customers::getId());
$newSaleChannelId = (int) $data['default_sale_channel_id'];
$currentSaleChannelId = (int) ($customer->default_sale_channel_id ?? 0);
if ($newSaleChannelId && $newSaleChannelId !== $currentSaleChannelId && ShopCart::count() > 0) {
$cartItems = ShopCart::getContent();
$unavailable = [];
foreach ($cartItems as $item) {
$offerId = (int) $item->id;
if (! Offers::getPrice($offerId, 1, $newSaleChannelId)) {
$offer = Offers::get($offerId, ['article']);
$unavailable[] = $offer->article->name ?? $item->name;
if (count($unavailable) >= 3) {
break;
}
}
}
if (! empty($unavailable)) {
$list = implode(', ', $unavailable);
return response()->json([
'error' => 1,
'message' => __('Certains articles de votre panier ne sont pas disponibles dans ce canal : :products. Merci de finaliser votre commande ou de retirer ces articles avant de changer de canal.', ['products' => $list]),
], 422);
}
}
}
$customerId = $data['id'] ?? Customers::getId();
$requestedDefaultSaleChannelId = $data['default_sale_channel_id'] ?? null;
$hasDefaultSaleChannelColumn = Schema::hasColumn('shop_customers', 'default_sale_channel_id');
if (! $hasDefaultSaleChannelColumn) {
unset($data['default_sale_channel_id']);
}
$customer = Customers::store($data);
if (! $customer) {
return response()->json([
'error' => 1,
'message' => __('Impossible de mettre à jour votre profil pour le moment.'),
], 422);
}
if ($hasDefaultSaleChannelColumn && $requestedDefaultSaleChannelId !== null) {
Customers::setDefaultSaleChannel($customerId, $requestedDefaultSaleChannelId);
}
$freshCustomer = Customers::get($customerId, ['sale_channels']);
Customers::guard()->setUser($freshCustomer);
if ($requestedDefaultSaleChannelId !== null) {
session(['shop.default_sale_channel_id' => $requestedDefaultSaleChannelId]);
Baskets::refreshPrices((int) $requestedDefaultSaleChannelId);
}
return response()->json(['error' => 0]);
}

View File

@@ -15,8 +15,14 @@ class InvoiceController extends Controller
public function view($uuid)
{
$invoice = Invoices::view($uuid);
if (! $invoice) {
abort(404);
}
$data = [
'invoice' => Invoices::view($uuid),
'invoice' => $invoice,
];
return view('Shop.Invoices.view', $data);

View File

@@ -49,12 +49,22 @@ class OrderController extends Controller
{
if (ShopCart::count()) {
$customer = Customers::getWithAddresses();
$deliveries = Deliveries::getByCustomer();
$customerId = $customer ? $customer->id : false;
$defaultSaleChannelId = SaleChannels::getDefaultID($customerId);
$deliveries = $defaultSaleChannelId
? Deliveries::getBySaleChannels([$defaultSaleChannelId])
: Deliveries::getByCustomer($customerId);
$deliveries = $deliveries ? $deliveries->values() : collect();
$customerData = $customer ? $customer->toArray() : false;
if ($customerData && $defaultSaleChannelId) {
$customerData['default_sale_channel_id'] = $defaultSaleChannelId;
}
$data = [
'customer' => $customer ? $customer->toArray() : false,
'customer' => $customerData,
'basket' => Baskets::getBasketTotal(),
'deliveries' => $deliveries ? $deliveries->toArray() : [],
'deliveries' => $deliveries->toArray(),
'delivery_types' => DeliveryTypes::getWithPrice(Baskets::getWeight()),
];

View File

@@ -5,6 +5,9 @@ namespace App\Repositories\Shop;
use App\Models\Shop\Article;
use App\Repositories\Botanic\Species;
use App\Repositories\Botanic\Varieties;
use App\Repositories\Shop\SaleChannels;
use App\Repositories\Shop\Customers;
use Illuminate\Support\Facades\Schema;
use App\Repositories\Core\Comments;
use App\Traits\Model\Basic;
use App\Traits\Repository\Imageable;
@@ -70,9 +73,33 @@ class Articles
public static function getArticleToSell($id, $saleChannelId = false)
{
$saleChannelId = $saleChannelId ?: SaleChannels::getDefaultID();
$sessionSaleChannelId = session('shop.default_sale_channel_id');
$customer = Customers::getAuth();
$hasDefaultSaleChannelColumn = Schema::hasColumn('shop_customers', 'default_sale_channel_id');
$customerDefaultSaleChannelId = ($customer && $hasDefaultSaleChannelColumn)
? $customer->default_sale_channel_id
: null;
$customerSaleChannelIds = [];
if ($customer) {
$customer->loadMissing('sale_channels:id');
$customerSaleChannelIds = $customer->sale_channels->pluck('id')->toArray();
}
$data = self::getArticle($id);
$data['offers'] = self::getOffersGroupedByNature($id, $saleChannelId);
$currentSaleChannel = $saleChannelId ? SaleChannels::get($saleChannelId) : null;
$data['current_sale_channel'] = $currentSaleChannel ? $currentSaleChannel->toArray() : null;
$data['available_sale_channels'] = Offers::getSaleChannelsForArticle($id);
$data['debug_sale_channel'] = [
'session_default_sale_channel_id' => $sessionSaleChannelId,
'customer_default_sale_channel_id' => $customerDefaultSaleChannelId,
'customer_linked_sale_channel_ids' => $customerSaleChannelIds,
'resolved_sale_channel_id' => $saleChannelId,
'has_default_sale_channel_column' => $hasDefaultSaleChannelColumn,
];
return $data;
}

View File

@@ -100,12 +100,19 @@ class Baskets
$offers = Offers::getWithPricesByIds(self::getIds(), $saleChannelId);
foreach ($basket as $item) {
$offer = $offers->where('id', $item->id)->first();
if (! $offer) {
continue;
}
$priceValue = Offers::getPrice($item->id, $item->quantity, $saleChannelId);
$unitPrice = $priceValue ? (float) $priceValue->price_taxed : (float) $item->price;
$article_nature = strtolower($offer->article->article_nature->name);
$data[$article_nature][] = [
'id' => (int) $item->id,
'name' => $item->name,
'quantity' => (int) $item->quantity,
'price' => $item->price,
'price' => $unitPrice,
'variation' => $offer->variation->name,
'image' => Articles::getPreviewSrc(ArticleImages::getFullImageByArticle($offer->article)),
'latin' => $offer->article->product->specie->latin ?? false,
@@ -115,6 +122,24 @@ class Baskets
return $data ?? false;
}
public static function refreshPrices($saleChannelId = false)
{
$saleChannelId = $saleChannelId ? $saleChannelId : SaleChannels::getDefaultID();
$basket = ShopCart::getContent();
foreach ($basket as $item) {
$priceValue = Offers::getPrice($item->id, $item->quantity, $saleChannelId);
if (! $priceValue) {
continue;
}
ShopCart::get()->update($item->id, [
'price' => $priceValue->price_taxed,
]);
}
}
public static function getBasketData($id, $quantity = 1)
{
$offer = Offers::get($id, ['article', 'variation']);

View File

@@ -31,18 +31,33 @@ class Customers
public static function getSaleChannels($customerId = false)
{
$customer = $customerId ? self::get($customerId) : self::getAuth();
$saleChannels = $customer ? $customer->sale_channels : false;
$saleChannels = collect();
return $saleChannels ? $saleChannels : SaleChannels::getDefault();
if ($customer) {
$customer->loadMissing('sale_channels');
$saleChannels = $customer->sale_channels ?? collect();
if ($saleChannels instanceof \Illuminate\Support\Collection && $saleChannels->isNotEmpty()) {
return $saleChannels;
}
}
$default = SaleChannels::getDefault($customerId);
return $default ? collect([$default]) : collect();
}
public static function getSaleChannel($customerId = false)
{
$saleChannels = self::getSaleChannels($customerId);
if ($saleChannels instanceof \Illuminate\Support\Collection) {
return $saleChannels->first();
}
return $saleChannels;
}
public static function getDeliveries()
{
$customer = self::getAuth();
@@ -58,12 +73,22 @@ class Customers
public static function editProfile($id = false)
{
return $id ? [
'customer' => self::get($id, ['addresses', 'deliveries'])->toArray(),
'deliveries' => Deliveries::getAllWithSaleChannel()->toArray(),
if (! $id) {
abort('403');
}
$customer = self::get($id, ['addresses', 'deliveries', 'sale_channels']);
$saleChannels = self::getSaleChannels($id);
return [
'customer' => $customer->toArray(),
'sale_channels' => $saleChannels->toArray(),
'deliveries' => Deliveries::getByCustomer($id)->toArray(),
'sale_channel_checks' => Shop::getSaleChannelAvailabilitySummary($saleChannels->pluck('id')->toArray()),
'orders' => (new CustomerOrdersDataTable())->html(),
'invoices' => (new CustomerInvoicesDataTable())->html(),
] : abort('403');
];
}
public static function getAddresses($id = false)
@@ -154,6 +179,24 @@ class Customers
return $customer->sale_channels()->sync($saleChannels);
}
public static function setDefaultSaleChannel($customerId, $saleChannelId)
{
if (! $customerId) {
return false;
}
$customer = self::get($customerId);
if (! $customer) {
return false;
}
$customer->default_sale_channel_id = $saleChannelId ?: null;
$customer->save();
return $customer->fresh(['sale_channels']);
}
public static function create($data)
{
$data['uuid'] = Str::uuid();

View File

@@ -21,12 +21,12 @@ class Deliveries
$customer = $customerId ? Customers::get($customerId) : Customers::getAuth();
$saleChannels = $customer ? $customer->sale_channels->pluck('id')->toArray() : [SaleChannels::getDefaultID()];
return $saleChannels ? self::getBySaleChannels($saleChannels) : false;
return $saleChannels ? self::getBySaleChannels($saleChannels) : collect();
}
public static function getBySaleChannels($saleChannels)
{
return Delivery::bySaleChannels($saleChannels)->with('sale_channel')->get();
return Delivery::bySaleChannels($saleChannels)->active()->with('sale_channel')->get();
}
public static function getSaleChannelId($deliveryId)
@@ -41,7 +41,7 @@ class Deliveries
public static function getAllWithSaleChannel()
{
return Delivery::orderBy('name', 'asc')->active()->public()->with('sale_channel')->get();
return Delivery::orderBy('name', 'asc')->active()->with('sale_channel')->get();
}
public static function toggleActive($id, $active)

View File

@@ -15,9 +15,15 @@ class DeliveryTypes
$types = self::getAll();
foreach ($types as $type) {
$price = self::getPrice($type->id, $weight);
if ($price === false) {
continue;
}
$data[$type->id] = [
'name' => $type->name,
'price' => self::getPrice($type->id, $weight),
'price' => $price,
];
}

View File

@@ -36,7 +36,13 @@ class Invoices
public static function view($uuid)
{
$data = self::getFullByUUID($uuid)->toArray();
$invoice = self::getFullByUUID($uuid);
if (! $invoice) {
return false;
}
$data = $invoice->toArray();
$data['payment_type'] = InvoicePayments::getPaymentType($data['payment_type']);
$data['status'] = self::getStatus($data['status']);

View File

@@ -3,6 +3,8 @@
namespace App\Repositories\Shop;
use App\Models\Shop\Offer;
use App\Models\Shop\PriceListValue;
use App\Models\Shop\SaleChannel;
use App\Traits\Model\Basic;
class Offers
@@ -166,4 +168,60 @@ class Offers
{
return Offer::query();
}
public static function getSaleChannelsForArticle($articleId)
{
$channels = SaleChannel::query()
->whereHas('price_lists', function ($query) use ($articleId) {
$query->whereHas('tariff.offers', function ($subQuery) use ($articleId) {
$subQuery->byArticle($articleId);
})->whereHas('price_list_values');
})
->orderBy('name')
->get();
$offers = Offer::query()
->byArticle($articleId)
->with([
'article',
'tariff:id,status_id',
])
->get();
return $channels->map(function ($channel) use ($offers) {
$priceValue = null;
$candidateOffer = null;
foreach ($offers as $offer) {
$priceCandidate = self::getPrice($offer->id, 1, $channel->id);
if ($priceCandidate && (float) $priceCandidate->price_taxed > 0) {
$priceValue = $priceCandidate;
$candidateOffer = $offer;
break;
}
}
$offerId = $candidateOffer ? $candidateOffer->id : null;
$offerStock = $candidateOffer ? (int) $candidateOffer->stock_current : null;
$offerIsActive = $candidateOffer ? (int) $candidateOffer->status_id === 1 : false;
$offerTariffStatus = $candidateOffer && $candidateOffer->tariff ? (int) $candidateOffer->tariff->status_id : null;
$offerHasStock = $candidateOffer && $candidateOffer->stock_current !== null
? (float) $candidateOffer->stock_current > 0
: null;
return [
'id' => $channel->id,
'name' => $channel->name,
'code' => $channel->code,
'price_taxed' => $priceValue ? (float) $priceValue->price_taxed : null,
'quantity' => $priceValue ? (int) $priceValue->quantity : null,
'offer_id' => $offerId,
'offer_is_active' => $offerIsActive,
'offer_stock_current' => $offerStock,
'offer_has_stock' => $offerHasStock,
'tariff_status_id' => $offerTariffStatus,
];
})->toArray();
}
}

View File

@@ -3,21 +3,52 @@
namespace App\Repositories\Shop;
use App\Models\Shop\SaleChannel;
use App\Repositories\Shop\Customers;
use App\Traits\Model\Basic;
class SaleChannels
{
use Basic;
public static function getDefaultID()
public static function getDefaultID($customerId = false)
{
$default = self::getDefault();
$default = self::getDefault($customerId);
return $default ? self::getDefault()->id : false;
return $default ? $default->id : false;
}
public static function getDefault()
public static function getDefault($customerId = false)
{
$sessionChannelId = session('shop.default_sale_channel_id');
if ($sessionChannelId) {
$sessionChannel = SaleChannel::find($sessionChannelId);
if ($sessionChannel) {
return $sessionChannel;
}
}
$customer = $customerId ? Customers::get($customerId) : Customers::getAuth();
if ($customer) {
$customer->loadMissing('sale_channels');
if ($customer->default_sale_channel_id) {
$preferred = $customer->sale_channels->firstWhere('id', $customer->default_sale_channel_id);
if (! $preferred) {
$preferred = SaleChannel::find($customer->default_sale_channel_id);
}
if ($preferred) {
session(['shop.default_sale_channel_id' => $preferred->id]);
return $preferred;
}
}
if ($customer->sale_channels->isNotEmpty()) {
session(['shop.default_sale_channel_id' => $customer->sale_channels->first()->id]);
return $customer->sale_channels->first();
}
}
return self::getByCode('EXP');
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Repositories\Shop;
use App\Repositories\Core\User\ShopCart;
class Shop
{
public static function getSaleChannelAvailabilitySummary(array $saleChannelIds): array
{
if (empty($saleChannelIds) || ShopCart::count() === 0) {
return [];
}
$cartItems = ShopCart::getContent();
$summary = [];
foreach ($saleChannelIds as $saleChannelId) {
$saleChannelId = (int) $saleChannelId;
$issues = [];
$issueCount = 0;
foreach ($cartItems as $item) {
$offerId = (int) $item->id;
if (! Offers::getPrice($offerId, 1, $saleChannelId)) {
$offer = Offers::get($offerId, ['article']);
$issues[] = $offer->article->name ?? $item->name;
$issueCount++;
if (count($issues) >= 3) {
continue;
}
}
}
if (! empty($issues)) {
$summary[$saleChannelId] = [
'full_count' => $issueCount,
'names' => array_slice($issues, 0, 3),
];
}
}
return $summary;
}
}

View File

@@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('shop_deliveries')) {
return;
}
$columns = ['created_by', 'updated_by', 'deleted_by'];
$columnsToDrop = [];
foreach ($columns as $column) {
if (Schema::hasColumn('shop_deliveries', $column)) {
$columnsToDrop[] = $column;
}
}
if ($columnsToDrop) {
Schema::table('shop_deliveries', function (Blueprint $table) use ($columnsToDrop) {
$table->dropColumn($columnsToDrop);
});
}
Schema::table('shop_deliveries', function (Blueprint $table) {
$table->unsignedBigInteger('created_by')->nullable()->after('event_date_end');
$table->unsignedBigInteger('updated_by')->nullable()->after('created_by');
$table->unsignedBigInteger('deleted_by')->nullable()->after('updated_by');
});
}
public function down(): void
{
if (! Schema::hasTable('shop_deliveries')) {
return;
}
Schema::table('shop_deliveries', function (Blueprint $table) {
$table->dropColumn(['created_by', 'updated_by', 'deleted_by']);
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::table('shop_customers', function (Blueprint $table) {
if (! Schema::hasColumn('shop_customers', 'default_sale_channel_id')) {
$table->unsignedInteger('default_sale_channel_id')->nullable()->after('settings');
$table->index('default_sale_channel_id', 'shop_customers_default_sale_channel_id_index');
}
});
}
public function down()
{
Schema::table('shop_customers', function (Blueprint $table) {
if (Schema::hasColumn('shop_customers', 'default_sale_channel_id')) {
$table->dropIndex('shop_customers_default_sale_channel_id_index');
$table->dropColumn('default_sale_channel_id');
}
});
}
};

View File

@@ -223,6 +223,9 @@ return [
'successmod' => 'Le canal de vente a été correctement modifié',
'successdel' => 'Le canal de vente a été correctement effacé',
'confirmdelete' => 'Confirmez-vous la suppression du canal de vente ?',
'missing_offers' => '{1} Ce canal de vente n\'a pas d\'offre pour :count produit.|[2,*] Ce canal de vente n\'a pas d\'offre pour :count produits.',
'missing_offers_all' => 'Ce canal de vente n\'a aucune offre pour tous les produits de votre panier.',
'cannot_select_with_cart' => 'Vous ne pouvez pas sélectionner ce mode d\'achat tant que votre panier contient des produits non disponibles dans ce mode.',
],
'shelves' => [
'title' => 'Rayons',

View File

@@ -4,8 +4,28 @@
'model' => 'customer_invoices',
'with_print' => false,
'with_filters' => false,
'show_callback' => 'AdminCustomerInvoiceView(id);',
])
<x-layout.modal title="Filtres" id="modal-customer_invoices-filters">
@include('Admin.Shop.CustomerInvoices.partials.filters', ['model' => 'customer_invoices'])
</x-layout.modal>
</x-card>
@include('load.layout.modal')
@push('js')
<script>
(function() {
const customerInvoiceShowTemplate = "{{ route('Shop.Invoices.view', ['uuid' => '__UUID__']) }}";
window.AdminCustomerInvoiceView = function(id) {
if (!id) {
return;
}
const url = customerInvoiceShowTemplate.replace('__UUID__', id);
openModal('Voir une facture', '#invoice-form', url, false, false, 'xl', true);
};
})();
</script>
@endpush

View File

@@ -15,6 +15,7 @@
<div class="row">
<div class="col-5">
{{ Form::label('active', __('Actif')) }}<br/>
<input type="hidden" name="active" value="0">
@include("components.form.toggle", [
'name' => 'active',
'value' => $delivery['active'] ?? false,
@@ -24,6 +25,7 @@
</div>
<div class="col-3">
{{ Form::label('is_public', __('Type')) }}
<input type="hidden" name="is_public" value="0">
@include('components.form.toggle', [
'name' => 'is_public',
'value' => $delivery['is_public'] ?? false,

View File

@@ -48,6 +48,55 @@
</div>
<div class="col-lg-3 col-xs-12">
@if (config('app.debug') && !empty($article['available_sale_channels']))
<div class="alert alert-info p-2 mb-3">
<strong class="d-block">Offres :</strong>
<ul class="list-unstyled mb-0 small">
@php
$currentSaleChannelId = $article['current_sale_channel']['id'] ?? null;
@endphp
@foreach ($article['available_sale_channels'] as $channel)
@php
$isCurrentChannel = $currentSaleChannelId === $channel['id'];
$priceTaxed = $channel['price_taxed'] ?? null;
$quantity = $channel['quantity'] ?? null;
$offerStock = $channel['offer_stock_current'] ?? null;
$offerIsActive = $channel['offer_is_active'] ?? false;
$offerHasStock = $channel['offer_has_stock'] ?? null;
$highlightStyle = $isCurrentChannel ? 'background-color: rgba(0, 0, 0, 0.06);' : '';
$nameClass = ($offerIsActive && $offerHasStock !== false) ? '' : 'text-muted';
$flags = [];
if (! $offerIsActive) {
$flags[] = 'inactive';
}
if ($offerHasStock === false) {
$flags[] = 'no-stock';
}
@endphp
<li style="{{ $highlightStyle }}">
<div class="d-flex justify-content-between align-items-start">
<span class="{{ $nameClass }}">
{{ $channel['name'] }}
<span class="d-block text-muted" style="font-size: 0.85em; padding-left: 0.9em;">
Code {{ $channel['code'] }}{!! $flags ? ' · <strong class="text-dark">'.implode('</strong> · <strong class="text-dark">', $flags).'</strong>' : '' !!}
</span>
</span>
@if ($priceTaxed !== null)
<span class="ml-2 text-nowrap text-right {{ $nameClass }}">
{{ number_format($priceTaxed, 2, ',', ' ') }} TTC
@if (! empty($quantity))
<span class="d-block text-muted" style="font-size: 0.85em;">Qté min. {{ $quantity }}</span>
@endif
</span>
@else
<span class="ml-2 text-muted"></span>
@endif
</div>
</li>
@endforeach
</ul>
</div>
@endif
@include('Shop.Articles.partials.ArticleAddBasket')
</div>
</div>

View File

@@ -58,7 +58,7 @@
updateBasket(offer_id, quantity, function() {
calculatePrice($row);
calculateTotal();
});
}, $row);
});
$('.basket-delete').click(function() {
@@ -70,13 +70,20 @@
});
}
function updateBasket(offer_id, quantity, callback) {
function updateBasket(offer_id, quantity, callback, $row) {
var data = {
offer_id: offer_id,
quantity: quantity,
update: true
};
$.post("{{ route('Shop.Basket.addBasket') }}", data, callback);
$.post("{{ route('Shop.Basket.addBasket') }}", data, function(response) {
if ($row && response && response.added && typeof response.added.price !== 'undefined') {
$row.find('.basket-price').text(fixNumber(response.added.price));
$row.find('.basket-total-row').text(fixNumber(response.added.price * $row.find('.basket-quantity').val()));
}
callback(response);
refreshBasketTop();
});
}
function calculatePrice($that) {

View File

@@ -1,25 +1,174 @@
@foreach ($deliveries as $delivery)
<div class="row">
<div class="col-1 text-right pt-1">
@push('styles')
<style>
.sale-channel-wrapper {
border: none;
background-color: transparent;
}
.sale-channel-wrapper:not(.blocked) .card-body {
border: 1px solid #e5e7eb;
border-radius: .75rem;
background-color: #ffffff;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.sale-channel-wrapper:not(.blocked) .card-body:hover {
border-color: #3b82f6;
box-shadow: 0 0.35rem 0.8rem rgba(37, 99, 235, 0.12);
}
.sale-channel-wrapper.blocked .card-body {
border: 1px solid #d1d5db;
border-radius: .75rem;
background-color: #f3f4f6;
}
.sale-channel-wrapper .card-body {
padding: 1.25rem;
}
.sale-channel-toggle {
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 0.25rem;
}
.sale-channel-content strong {
font-size: 1.05rem;
}
.sale-channel-warning {
font-size: 0.85rem;
}
.sale-channel-wrapper .icheck-success > input:first-child + label::before,
.sale-channel-wrapper .icheck-primary > input:first-child + label::before,
.sale-channel-wrapper .icheck-danger > input:first-child + label::before {
opacity: 1;
border-width: 2px;
border-color: #9ca3af;
}
.sale-channel-wrapper.blocked .icheck-success > input:first-child + label::before,
.sale-channel-wrapper.blocked .icheck-primary > input:first-child + label::before,
.sale-channel-wrapper.blocked .icheck-danger > input:first-child + label::before {
border-color: #cbd5f5;
background-color: #f8fafc;
}
.sale-channel-wrapper .icheck-success > input:first-child + label,
.sale-channel-wrapper .icheck-primary > input:first-child + label,
.sale-channel-wrapper .icheck-danger > input:first-child + label {
opacity: 1;
}
.sale-channel-wrapper [class*="icheck-"] > input:first-child:disabled + label {
opacity: 1;
}
</style>
@endpush
@php
$saleChannelsCollection = collect($sale_channels);
$firstSaleChannel = $saleChannelsCollection->first();
$selectedSaleChannelId = $customer['default_sale_channel_id'] ?? ($firstSaleChannel['id'] ?? null);
$cartCount = app('App\\Repositories\\Core\\User\\ShopCart')::count();
@endphp
@if ($cartCount > 0)
<div class="alert alert-warning">
<strong>Note :</strong> en changeant votre mode d'achat, les articles de votre panier seront transférés sur la liste de prix correspondant au nouveau canal de vente sélectionné.
</div>
@endif
@foreach ($saleChannelsCollection as $saleChannel)
@php
$check = $sale_channel_checks[$saleChannel['id']] ?? null;
$isBlocked = $check && $saleChannel['id'] !== $selectedSaleChannelId;
@endphp
<div class="card sale-channel-wrapper mb-3 @if($isBlocked) blocked @endif">
<div class="card-body py-3">
<div class="row align-items-start">
<div class="col-1 sale-channel-toggle">
@include('components.form.radios.icheck', [
'name' => 'delivery_id',
'id_name' => 'delivery_id_' . $delivery['id'],
'value' => $delivery['id'],
'checked' => $customer['sale_delivery_id'] ?? false,
'class' => 'delivery',
'name' => 'sale_channel_id',
'id_name' => 'sale_channel_id_' . $saleChannel['id'],
'val' => $saleChannel['id'],
'value' => $selectedSaleChannelId,
'class' => 'sale-channel',
'disabled' => $isBlocked,
])
</div>
<div class="col-11 pt-3">
<strong>{{ $delivery['name'] }} - {{ $delivery['sale_channel']['name'] }}</strong><br />
<p>{{ $delivery['description'] }}</p>
<div class="col-11 sale-channel-content @if($isBlocked) text-muted @endif">
<strong>{{ $saleChannel['name'] }}</strong><br />
<p class="mb-2">{!! $saleChannel['description'] ?? '' !!}</p>
@if ($check)
<div class="text-danger small mb-0 sale-channel-warning">
@php $missingCount = $check['full_count'] ?? count($check['names']); @endphp
@if ($cartCount > 0 && $missingCount >= $cartCount)
{{ __('shop.sale_channels.missing_offers_all') }}
@else
{{ trans_choice('shop.sale_channels.missing_offers', $missingCount, ['count' => $missingCount]) }}
<br>
@if ($missingCount > 3)
<span class="d-block">{{ implode(', ', array_slice($check['names'], 0, 3)) }}, …</span>
@else
<span class="d-block">{{ implode(', ', $check['names']) }}</span>
@endif
@endif
<div class="d-flex align-items-start mt-1">
<span class="mr-1">⚠️</span>
<span>{{ __('shop.sale_channels.cannot_select_with_cart') }}</span>
</div>
</div>
@endif
</div>
</div>
</div>
</div>
@endforeach
@push('js')
<script>
$('.delivery').off().change(function() {
console.log($(this).val());
const $saleChannels = $('.sale-channel');
const updateUrl = '{{ route('Shop.Customers.storeProfileAjax') }}';
const token = '{{ csrf_token() }}';
const customerId = {{ $customer['id'] ?? 'null' }};
let currentSaleChannelId = '{{ $selectedSaleChannelId }}';
$saleChannels.off().change(function() {
if (!customerId) {
return;
}
const selectedSaleChannel = $(this).val();
$.post(updateUrl, {
_token: token,
id: customerId,
default_sale_channel_id: selectedSaleChannel,
}).done(function() {
currentSaleChannelId = selectedSaleChannel;
window.location.reload();
}).fail(function(xhr) {
const message = xhr.responseJSON && xhr.responseJSON.message
? xhr.responseJSON.message
: "{{ __('Une erreur est survenue lors de l\'enregistrement du canal de vente préféré.') }}";
console.error('Sale channel update failed', {
status: xhr.status,
response: xhr.responseJSON || xhr.responseText,
selectedSaleChannel
});
alert(message);
if (currentSaleChannelId) {
$saleChannels.filter('[value="' + currentSaleChannelId + '"]').prop('checked', true);
}
});
});
</script>
@endpush

View File

@@ -1,3 +1,8 @@
@php
$saleChannels = $sale_channels ?? [];
@endphp
@if (count($saleChannels) > 1)
<nav>
<div class="nav nav-tabs pl-2">
<a href="#deliveriesTab" data-toggle="tab" class="nav-item nav-link active" role="tab" aria-selected="true">
@@ -33,3 +38,32 @@
</x-card>
</div>
</div>
@else
<nav>
<div class="nav nav-tabs pl-2">
<a href="#ordersTab" data-toggle="tab" class="nav-item nav-link active" role="tab" aria-selected="true">
SUIVI DE COMMANDES
</a>
<a href="#invoicesTab" data-toggle="tab" class="nav-item nav-link" role="tab" aria-selected="false">
FACTURES
</a>
</div>
</nav>
<div class="tab-content">
<div class="tab-pane fade show active pt-0 pb-0" id="ordersTab">
<x-card classBody="bg-light">
@include('Shop.Orders.partials.list', [
'dataTable' => $orders,
])
</x-card>
</div>
<div class="tab-pane fade show pt-0 pb-0" id="invoicesTab">
<x-card classBody="bg-light">
@include('Shop.Invoices.partials.list', [
'dataTable' => $invoices,
])
</x-card>
</div>
</div>
@endif

View File

@@ -50,7 +50,7 @@
@include('load.layout.chevron')
@push('js')
@prepend('js')
<script>
$('#customer').click(function() {
$(".personal_data").addClass('d-none');
@@ -65,7 +65,9 @@
});
function refreshBasketTotal(deliveryId, deliveryTypeId) {
options = deliveryId + '/' + deliveryTypeId;
var safeDeliveryId = deliveryId !== undefined && deliveryId !== null ? deliveryId : '';
var safeDeliveryTypeId = deliveryTypeId !== undefined && deliveryTypeId !== null ? deliveryTypeId : '';
var options = safeDeliveryId + '/' + safeDeliveryTypeId;
$.get("{{ Route('Shop.Basket.getBasketTotal') }}/" + options, function(data) {
$('#basketTotal').html(data);
});
@@ -73,4 +75,4 @@
initChevron();
</script>
@endpush
@endprepend

View File

@@ -1,13 +1,51 @@
@php
$addresses = collect($addresses ?? []);
$preselectedAddressId = old($name);
if ($preselectedAddressId === null && is_string($name) && str_contains($name, '[')) {
$dotName = preg_replace('/\[(.*?)\]/', '.$1', $name);
$dotName = trim($dotName, '.');
$preselectedAddressId = $dotName ? old($dotName) : null;
}
if (($preselectedAddressId === null || $preselectedAddressId === '') && $addresses->isNotEmpty()) {
$defaultAddress = $addresses->firstWhere('priority', 1);
if (! $defaultAddress) {
$defaultAddress = $addresses
->filter(function ($address) {
return (int) ($address['priority'] ?? 0) > 0;
})
->sortByDesc(function ($address) {
return (int) ($address['priority'] ?? 0);
})
->first();
}
if (! $defaultAddress) {
$defaultAddress = $addresses->firstWhere('is_default', true)
?? $addresses->firstWhere('default', true);
}
if (! $defaultAddress) {
$defaultAddress = $addresses->first();
}
$preselectedAddressId = $defaultAddress['id'] ?? null;
}
$addresses = $addresses->all();
@endphp
@if ($addresses)
@foreach ($addresses ?? [] as $address)
@foreach ($addresses as $address)
<div class="row mb-3">
<div class="col-1">
@include('components.form.radios.icheck', [
'name' => $name,
'val' => $address['id'],
'id' => $prefix . '_address_' . $address['id'],
'value' =>
old($name) ?? $address['priority'] || count($addresses) === 1 ? $address['id'] : false,
'value' => $preselectedAddressId,
])
</div>
<div class="col-11">

View File

@@ -1,10 +1,20 @@
@php
$defaultSaleChannelId = $customer['default_sale_channel_id'] ?? null;
$preselectedDeliveryId = old('delivery_id');
if (! $preselectedDeliveryId && $defaultSaleChannelId) {
$match = collect($deliveries)->firstWhere('sale_channel_id', $defaultSaleChannelId);
$preselectedDeliveryId = $match['id'] ?? null;
}
@endphp
@foreach ($deliveries as $delivery)
<div class="row">
<div class="col-1">
@include('components.form.radios.icheck', [
'name' => 'delivery_id',
'val' => $delivery['id'],
'value' => (int) old('delivery_id') === $delivery['id'] ? $delivery['id'] : null,
'value' => $preselectedDeliveryId,
'id' => 'delivery_' . $delivery['id'],
'class' => 'delivery_mode' . ($delivery['at_house'] ? ' at_house' : ''),
])
@@ -29,16 +39,29 @@ ci-contre
@push('js')
<script>
function handleDeliveries() {
$('#delivery_mode input.delivery_mode').change(function() {
if ($(this).hasClass('at_house')) {
var $deliveryInputs = $('#delivery_mode input.delivery_mode');
$deliveryInputs.change(function() {
var $currentDelivery = $(this);
var deliveryTypeId = $('input[name=delivery_type_id]:checked').val();
if ($currentDelivery.hasClass('at_house')) {
$('#delivery_addresses').closest('.card').removeClass('d-none');
var deliveryTypeId = $('input[name=delivery_type_id]:checked').val()
} else {
$('#delivery_addresses').closest('.card').addClass('d-none');
}
var deliveryId = $(this).val();
var deliveryId = $currentDelivery.val();
refreshBasketTotal(deliveryId, deliveryTypeId);
});
var $preselected = $deliveryInputs.filter(':checked').first();
if ($preselected.length) {
$preselected.trigger('change');
} else {
$('#delivery_addresses').closest('.card').addClass('d-none');
}
}
handleDeliveries();

View File

@@ -7,7 +7,18 @@
</th>
</tr>
</thead>
@foreach ($delivery_types as $delivery_type_id => $delivery_type)
@php
$deliveryTypes = collect($delivery_types);
$preselectedDeliveryTypeId = old('delivery_type_id');
if ($preselectedDeliveryTypeId === null || $preselectedDeliveryTypeId === '') {
$preselectedDeliveryTypeId = $deliveryTypes->keys()->first();
}
$deliveryTypes = $deliveryTypes->all();
@endphp
@foreach ($deliveryTypes as $delivery_type_id => $delivery_type)
<tr>
<td>
@include('components.form.radios.icheck', [
@@ -15,6 +26,7 @@
'val' => $delivery_type_id,
'id' => 'delivery_type_' . $delivery_type_id,
'class' => 'delivery_type',
'value' => $preselectedDeliveryTypeId,
])
</td>
<td>
@@ -31,11 +43,19 @@
@push('js')
<script>
function handleDeliveryTypes() {
$('input.delivery_type').change(function() {
var $deliveryTypeInputs = $('input.delivery_type');
$deliveryTypeInputs.change(function() {
var deliveryTypeId = $(this).val();
var deliveryId = $('input[name=delivery_id]:checked').val()
refreshBasketTotal(deliveryId, deliveryTypeId);
});
var $preselected = $deliveryTypeInputs.filter(':checked').first();
if ($preselected.length) {
$preselected.trigger('change');
}
}
handleDeliveryTypes();
</script>

View File

@@ -17,6 +17,7 @@
<link rel="shortcut icon" type="image/x-icon" href="{{ asset('img/favicon.ico') }}">
<link rel="stylesheet" href="/css/site.min.css?{{ date('Ymd') }}" type="text/css" media="all">
@stack('styles')
@stack('css')
</head>

View File

@@ -38,7 +38,8 @@
/<(p|a|div|span|strike|strong|i|u)[^>]*?>(\s|&nbsp;|<br\/>|\r|\n)*?<\/(p|a|div|span|strike|strong|i|u)>/gi,
''); // Empty tags
},
skin: "boilerplate",
skin: "oxide",
content_css: 'oxide',
language: '{{ App::getLocale() }}',
file_picker_callback: function(callback, value, meta) {
var x = window.innerWidth || document.documentElement.clientWidth || document.getElementsByTagName(

View File

@@ -3,5 +3,4 @@
Route::prefix('CustomerInvoices')->name('CustomerInvoices.')->group(function () {
Route::get('', 'CustomerInvoiceController@index')->name('index');
Route::delete('destroy/{id?}', 'CustomerInvoiceController@destroy')->name('destroy');
Route::get('view/{id?}', 'CustomerInvoiceController@view')->name('view');
});