Feat: stat front end
This commit is contained in:
parent
39c21d3d09
commit
9a52bddd1a
@ -30,5 +30,10 @@ class DatabaseSeeder extends Seeder
|
|||||||
$this->call(QuoteSeeder::class);
|
$this->call(QuoteSeeder::class);
|
||||||
$this->call(InvoiceSeeder::class);
|
$this->call(InvoiceSeeder::class);
|
||||||
$this->call(AvoirSeeder::class);
|
$this->call(AvoirSeeder::class);
|
||||||
|
|
||||||
|
// ── Stock data ────────────────────────────────────────────────────────
|
||||||
|
$this->call(WarehouseSeeder::class);
|
||||||
|
$this->call(StockItemSeeder::class);
|
||||||
|
$this->call(StockMoveSeeder::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
74
thanasoft-back/database/seeders/StockItemSeeder.php
Normal file
74
thanasoft-back/database/seeders/StockItemSeeder.php
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\Product;
|
||||||
|
use App\Models\Warehouse;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class StockItemSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$products = Product::all();
|
||||||
|
$warehouses = Warehouse::all();
|
||||||
|
|
||||||
|
// Define realistic stock quantities for each product × warehouse
|
||||||
|
$stockData = [
|
||||||
|
// Product 1: High rotation, variable stock
|
||||||
|
1 => [
|
||||||
|
['warehouse_id' => 1, 'qty_on_hand' => 150.000, 'safety_stock' => 50.000], // Paris: Normal
|
||||||
|
['warehouse_id' => 2, 'qty_on_hand' => 45.000, 'safety_stock' => 50.000], // Marseille: LOW STOCK - ALERT
|
||||||
|
['warehouse_id' => 3, 'qty_on_hand' => 120.000, 'safety_stock' => 40.000], // Lyon: Normal
|
||||||
|
],
|
||||||
|
// Product 2: Medium rotation
|
||||||
|
2 => [
|
||||||
|
['warehouse_id' => 1, 'qty_on_hand' => 80.000, 'safety_stock' => 30.000], // Paris: Normal
|
||||||
|
['warehouse_id' => 2, 'qty_on_hand' => 25.000, 'safety_stock' => 35.000], // Marseille: LOW STOCK - ALERT
|
||||||
|
['warehouse_id' => 3, 'qty_on_hand' => 90.000, 'safety_stock' => 30.000], // Lyon: Normal
|
||||||
|
],
|
||||||
|
// Product 3: Low rotation, strategic stock
|
||||||
|
3 => [
|
||||||
|
['warehouse_id' => 1, 'qty_on_hand' => 200.000, 'safety_stock' => 80.000], // Paris: Normal
|
||||||
|
['warehouse_id' => 2, 'qty_on_hand' => 75.000, 'safety_stock' => 80.000], // Marseille: LOW STOCK - ALERT
|
||||||
|
['warehouse_id' => 3, 'qty_on_hand' => 180.000, 'safety_stock' => 70.000], // Lyon: Normal
|
||||||
|
],
|
||||||
|
// Product 4: High value, low volume
|
||||||
|
4 => [
|
||||||
|
['warehouse_id' => 1, 'qty_on_hand' => 15.000, 'safety_stock' => 8.000], // Paris: Normal
|
||||||
|
['warehouse_id' => 2, 'qty_on_hand' => 5.000, 'safety_stock' => 10.000], // Marseille: LOW STOCK - ALERT
|
||||||
|
['warehouse_id' => 3, 'qty_on_hand' => 12.000, 'safety_stock' => 8.000], // Lyon: Normal
|
||||||
|
],
|
||||||
|
// Product 5: Medium rotation
|
||||||
|
5 => [
|
||||||
|
['warehouse_id' => 1, 'qty_on_hand' => 120.000, 'safety_stock' => 40.000], // Paris: Normal
|
||||||
|
['warehouse_id' => 2, 'qty_on_hand' => 35.000, 'safety_stock' => 45.000], // Marseille: LOW STOCK - ALERT
|
||||||
|
['warehouse_id' => 3, 'qty_on_hand' => 110.000, 'safety_stock' => 40.000], // Lyon: Normal
|
||||||
|
],
|
||||||
|
// Product 6: High rotation, high volume
|
||||||
|
6 => [
|
||||||
|
['warehouse_id' => 1, 'qty_on_hand' => 500.000, 'safety_stock' => 150.000], // Paris: Normal
|
||||||
|
['warehouse_id' => 2, 'qty_on_hand' => 140.000, 'safety_stock' => 160.000], // Marseille: LOW STOCK - ALERT
|
||||||
|
['warehouse_id' => 3, 'qty_on_hand' => 480.000, 'safety_stock' => 150.000], // Lyon: Normal
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($stockData as $productId => $warehouseStocks) {
|
||||||
|
foreach ($warehouseStocks as $stock) {
|
||||||
|
DB::table('stock_items')->updateOrInsert(
|
||||||
|
[
|
||||||
|
'product_id' => $productId,
|
||||||
|
'warehouse_id' => $stock['warehouse_id'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'qty_on_hand_base' => $stock['qty_on_hand'],
|
||||||
|
'safety_stock_base' => $stock['safety_stock'],
|
||||||
|
'updated_at' => now(),
|
||||||
|
'created_at' => now(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
74
thanasoft-back/database/seeders/StockMoveSeeder.php
Normal file
74
thanasoft-back/database/seeders/StockMoveSeeder.php
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\Product;
|
||||||
|
use App\Models\Warehouse;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class StockMoveSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$products = Product::all();
|
||||||
|
$warehouses = Warehouse::all();
|
||||||
|
|
||||||
|
// Sample movements over the past 12 months
|
||||||
|
// Mix of transfers, intakes, adjustments, sales
|
||||||
|
$movements = [
|
||||||
|
// May 2025
|
||||||
|
['product_id' => 1, 'from_id' => null, 'to_id' => 1, 'qty' => 500, 'type' => 'intake', 'date' => '2025-05-10'],
|
||||||
|
['product_id' => 2, 'from_id' => 1, 'to_id' => 2, 'qty' => 120, 'type' => 'transfer', 'date' => '2025-05-15'],
|
||||||
|
['product_id' => 3, 'from_id' => null, 'to_id' => 2, 'qty' => 300, 'type' => 'intake', 'date' => '2025-05-20'],
|
||||||
|
|
||||||
|
// June 2025
|
||||||
|
['product_id' => 4, 'from_id' => 1, 'to_id' => null, 'qty' => 45, 'type' => 'sale', 'date' => '2025-06-05'],
|
||||||
|
['product_id' => 5, 'from_id' => 2, 'to_id' => 3, 'qty' => 200, 'type' => 'transfer', 'date' => '2025-06-12'],
|
||||||
|
['product_id' => 6, 'from_id' => null, 'to_id' => 1, 'qty' => 1000, 'type' => 'intake', 'date' => '2025-06-18'],
|
||||||
|
|
||||||
|
// July 2025
|
||||||
|
['product_id' => 1, 'from_id' => 1, 'to_id' => null, 'qty' => 150, 'type' => 'sale', 'date' => '2025-07-08'],
|
||||||
|
['product_id' => 2, 'from_id' => 2, 'to_id' => 1, 'qty' => 80, 'type' => 'transfer', 'date' => '2025-07-14'],
|
||||||
|
['product_id' => 3, 'from_id' => 2, 'to_id' => null, 'qty' => 100, 'type' => 'sale', 'date' => '2025-07-22'],
|
||||||
|
|
||||||
|
// August 2025
|
||||||
|
['product_id' => 4, 'from_id' => null, 'to_id' => 2, 'qty' => 50, 'type' => 'intake', 'date' => '2025-08-05'],
|
||||||
|
['product_id' => 5, 'from_id' => 3, 'to_id' => 1, 'qty' => 90, 'type' => 'transfer', 'date' => '2025-08-11'],
|
||||||
|
|
||||||
|
// ... skip ahead to recent months for better visibility ...
|
||||||
|
|
||||||
|
// March 2026
|
||||||
|
['product_id' => 6, 'from_id' => null, 'to_id' => 3, 'qty' => 450, 'type' => 'intake', 'date' => '2026-03-10'],
|
||||||
|
['product_id' => 1, 'from_id' => 3, 'to_id' => 2, 'qty' => 160, 'type' => 'transfer', 'date' => '2026-03-18'],
|
||||||
|
['product_id' => 2, 'from_id' => 1, 'to_id' => null, 'qty' => 95, 'type' => 'sale', 'date' => '2026-03-25'],
|
||||||
|
|
||||||
|
// April 2026
|
||||||
|
['product_id' => 3, 'from_id' => null, 'to_id' => 1, 'qty' => 280, 'type' => 'intake', 'date' => '2026-04-02'],
|
||||||
|
['product_id' => 4, 'from_id' => 2, 'to_id' => 3, 'qty' => 15, 'type' => 'transfer', 'date' => '2026-04-08'],
|
||||||
|
['product_id' => 5, 'from_id' => 1, 'to_id' => null, 'qty' => 110, 'type' => 'sale', 'date' => '2026-04-15'],
|
||||||
|
|
||||||
|
// May 2026 (Recent - visible in dashboard)
|
||||||
|
['product_id' => 6, 'from_id' => 3, 'to_id' => 1, 'qty' => 320, 'type' => 'transfer', 'date' => '2026-05-02'],
|
||||||
|
['product_id' => 1, 'from_id' => null, 'to_id' => 2, 'qty' => 200, 'type' => 'intake', 'date' => '2026-05-05'],
|
||||||
|
['product_id' => 2, 'from_id' => 2, 'to_id' => null, 'qty' => 75, 'type' => 'sale', 'date' => '2026-05-08'],
|
||||||
|
['product_id' => 3, 'from_id' => 1, 'to_id' => 3, 'qty' => 140, 'type' => 'transfer', 'date' => '2026-05-10'],
|
||||||
|
['product_id' => 4, 'from_id' => null, 'to_id' => 3, 'qty' => 25, 'type' => 'intake', 'date' => '2026-05-12'],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($movements as $movement) {
|
||||||
|
DB::table('stock_moves')->insert([
|
||||||
|
'product_id' => $movement['product_id'],
|
||||||
|
'from_warehouse_id' => $movement['from_id'],
|
||||||
|
'to_warehouse_id' => $movement['to_id'],
|
||||||
|
'qty_base' => $movement['qty'],
|
||||||
|
'move_type' => $movement['type'],
|
||||||
|
'ref_type' => null,
|
||||||
|
'ref_id' => null,
|
||||||
|
'moved_at' => $movement['date'],
|
||||||
|
'created_at' => $movement['date'],
|
||||||
|
'updated_at' => $movement['date'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
46
thanasoft-back/database/seeders/WarehouseSeeder.php
Normal file
46
thanasoft-back/database/seeders/WarehouseSeeder.php
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\Warehouse;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class WarehouseSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$warehouses = [
|
||||||
|
[
|
||||||
|
'name' => 'Entrepôt Paris (Principal)',
|
||||||
|
'address_line1' => '123 Rue de la Paix',
|
||||||
|
'address_line2' => 'Bâtiment A',
|
||||||
|
'postal_code' => '75001',
|
||||||
|
'city' => 'Paris',
|
||||||
|
'country_code' => 'FR',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Entrepôt Marseille',
|
||||||
|
'address_line1' => '456 Avenue de la Mer',
|
||||||
|
'address_line2' => 'Zone logistique Sud',
|
||||||
|
'postal_code' => '13000',
|
||||||
|
'city' => 'Marseille',
|
||||||
|
'country_code' => 'FR',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Entrepôt Lyon',
|
||||||
|
'address_line1' => '789 Boulevard de l\'Est',
|
||||||
|
'address_line2' => 'Parc industriel',
|
||||||
|
'postal_code' => '69000',
|
||||||
|
'city' => 'Lyon',
|
||||||
|
'country_code' => 'FR',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($warehouses as $warehouse) {
|
||||||
|
Warehouse::firstOrCreate(
|
||||||
|
['name' => $warehouse['name']],
|
||||||
|
$warehouse
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
153
thanasoft-front/src/components/Atom/Stats/StockKpiCard.vue
Normal file
153
thanasoft-front/src/components/Atom/Stats/StockKpiCard.vue
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
<template>
|
||||||
|
<div class="overflow-hidden card">
|
||||||
|
<div class="p-3 pb-0 card-header">
|
||||||
|
<h6 class="mb-0 font-weight-bolder">{{ label }}</h6>
|
||||||
|
<p class="mb-0 text-sm text-secondary">{{ subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-0 card-body">
|
||||||
|
<div v-if="chartId && chart" class="p-3">
|
||||||
|
<canvas :id="chartId" />
|
||||||
|
</div>
|
||||||
|
<div class="p-3 pt-0">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col-8">
|
||||||
|
<h5 class="mb-0 font-weight-bolder">{{ value }}</h5>
|
||||||
|
<span v-if="trend" class="text-sm text-secondary" v-html="trend"></span>
|
||||||
|
</div>
|
||||||
|
<div v-if="suffix" class="col-4 text-end">
|
||||||
|
<span class="text-lg font-weight-bolder text-secondary">{{ suffix }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, PropType, onMounted, watch } from 'vue';
|
||||||
|
import Chart from 'chart.js/auto';
|
||||||
|
|
||||||
|
interface MiniChartData {
|
||||||
|
labels: string[];
|
||||||
|
datasets: Array<{
|
||||||
|
label: string;
|
||||||
|
data: number[];
|
||||||
|
borderColor?: string;
|
||||||
|
backgroundColor?: string;
|
||||||
|
tension?: number;
|
||||||
|
fill?: boolean;
|
||||||
|
borderWidth?: number;
|
||||||
|
}>;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'StockKpiCard',
|
||||||
|
props: {
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
type: [String, Number],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
suffix: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
trend: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
chartId: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
chartHeight: {
|
||||||
|
type: Number,
|
||||||
|
default: 100,
|
||||||
|
},
|
||||||
|
chart: {
|
||||||
|
type: Object as PropType<MiniChartData>,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
setup(props) {
|
||||||
|
const chartInstance = { current: null as Chart | null };
|
||||||
|
|
||||||
|
const initChart = () => {
|
||||||
|
if (!props.chartId || !props.chart) return;
|
||||||
|
|
||||||
|
const canvas = document.getElementById(props.chartId) as HTMLCanvasElement;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
if (chartInstance.current) {
|
||||||
|
chartInstance.current.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const color = props.chart.color || '#344767';
|
||||||
|
|
||||||
|
chartInstance.current = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: props.chart.labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: props.chart.datasets[0]?.label || 'Data',
|
||||||
|
data: props.chart.datasets[0]?.data || [],
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor: `rgba(100, 115, 130, 0.15)`,
|
||||||
|
tension: 0.4,
|
||||||
|
fill: true,
|
||||||
|
borderWidth: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
grid: {
|
||||||
|
drawBorder: false,
|
||||||
|
color: 'rgba(0,0,0,0.05)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
grid: {
|
||||||
|
drawBorder: false,
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initChart();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => props.chart, () => {
|
||||||
|
initChart();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@ -0,0 +1,69 @@
|
|||||||
|
<template>
|
||||||
|
<div class="overflow-hidden card">
|
||||||
|
<div class="p-3 pb-0 card-header">
|
||||||
|
<h6 class="mb-0 font-weight-bolder">Top produits par rotation</h6>
|
||||||
|
<p class="mb-0 text-sm text-secondary">Produits les plus consommés (12 mois)</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-0 card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover">
|
||||||
|
<thead class="text-uppercase text-secondary text-xxs font-weight-bolder ps-2">
|
||||||
|
<tr>
|
||||||
|
<th>Produit</th>
|
||||||
|
<th>Stock</th>
|
||||||
|
<th>Rotations</th>
|
||||||
|
<th>Taux</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in items" :key="item.product_id">
|
||||||
|
<td class="ps-2">
|
||||||
|
<p class="text-sm font-weight-bold mb-0">{{ item.product_name }}</p>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<p class="text-sm mb-0">{{ formatNumber(item.qty_on_hand) }}</p>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<p class="text-sm mb-0">{{ formatNumber(item.moved_qty_last_12_months) }}</p>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<span v-if="item.rotation_rate" class="badge badge-sm bg-info">
|
||||||
|
{{ formatRotationRate(item.rotation_rate) }}x
|
||||||
|
</span>
|
||||||
|
<span v-else class="badge badge-sm bg-secondary">N/A</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!items.length">
|
||||||
|
<td colspan="4" class="text-center text-secondary py-3">Aucun mouvement de stock</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, PropType } from 'vue';
|
||||||
|
import type { ProductRotation } from '@/services/stockStatistics';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'RotationCard',
|
||||||
|
props: {
|
||||||
|
items: {
|
||||||
|
type: Array as PropType<ProductRotation[]>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
formatNumber(n: number) {
|
||||||
|
return Math.round(n * 100) / 100;
|
||||||
|
},
|
||||||
|
formatRotationRate(rate: number) {
|
||||||
|
return Math.round(rate * 100) / 100;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
<template>
|
||||||
|
<div class="overflow-hidden card">
|
||||||
|
<div class="p-3 pb-0 card-header">
|
||||||
|
<h6 class="mb-0 font-weight-bolder">Mouvements par entrepôt</h6>
|
||||||
|
<p class="mb-0 text-sm text-secondary">Flux entrants et sortants</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-0 card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover">
|
||||||
|
<thead class="text-uppercase text-secondary text-xxs font-weight-bolder ps-2">
|
||||||
|
<tr>
|
||||||
|
<th>Entrepôt</th>
|
||||||
|
<th>Entrées</th>
|
||||||
|
<th>Sorties</th>
|
||||||
|
<th>Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="warehouse in items" :key="warehouse.warehouse_id">
|
||||||
|
<td class="ps-2">
|
||||||
|
<p class="text-sm font-weight-bold mb-0">{{ warehouse.warehouse_name }}</p>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-sm bg-success me-1">{{ warehouse.incoming_moves }}</span>
|
||||||
|
<small class="text-secondary">{{ formatNumber(warehouse.incoming_qty) }}</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-sm bg-warning me-1">{{ warehouse.outgoing_moves }}</span>
|
||||||
|
<small class="text-secondary">{{ formatNumber(warehouse.outgoing_qty) }}</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<p class="text-sm font-weight-bold mb-0">{{ warehouse.total_moves }}</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!items.length">
|
||||||
|
<td colspan="4" class="text-center text-secondary py-3">Aucun entrepôt</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, PropType } from 'vue';
|
||||||
|
import type { WarehouseMovement } from '@/services/stockStatistics';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'WarehouseMovementCard',
|
||||||
|
props: {
|
||||||
|
items: {
|
||||||
|
type: Array as PropType<WarehouseMovement[]>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
formatNumber(n: number) {
|
||||||
|
return Math.round(n * 100) / 100;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@ -0,0 +1,72 @@
|
|||||||
|
<template>
|
||||||
|
<div class="overflow-hidden card">
|
||||||
|
<div class="p-3 pb-0 card-header">
|
||||||
|
<h6 class="mb-0 font-weight-bolder">Stock par entrepôt</h6>
|
||||||
|
<p class="mb-0 text-sm text-secondary">Valeur et alertes</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-0 card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover">
|
||||||
|
<thead class="text-uppercase text-secondary text-xxs font-weight-bolder ps-2">
|
||||||
|
<tr>
|
||||||
|
<th>Entrepôt</th>
|
||||||
|
<th>Quantité</th>
|
||||||
|
<th>Valeur</th>
|
||||||
|
<th>Alertes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="warehouse in items" :key="warehouse.warehouse_id">
|
||||||
|
<td class="ps-2">
|
||||||
|
<p class="text-sm font-weight-bold mb-0">{{ warehouse.warehouse_name }}</p>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<p class="text-sm mb-0">{{ formatNumber(warehouse.qty_on_hand) }}</p>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<p class="text-sm font-weight-bold mb-0">{{ formatCurrency(warehouse.stock_value) }}</p>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="warehouse.alert_count > 0" class="badge badge-sm bg-danger">
|
||||||
|
{{ warehouse.alert_count }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="badge badge-sm bg-success">✓</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!items.length">
|
||||||
|
<td colspan="4" class="text-center text-secondary py-3">Aucun entrepôt</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, PropType } from 'vue';
|
||||||
|
import type { StockByWarehouse } from '@/services/stockStatistics';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'WarehouseStockCard',
|
||||||
|
props: {
|
||||||
|
items: {
|
||||||
|
type: Array as PropType<StockByWarehouse[]>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
formatNumber(n: number) {
|
||||||
|
return Math.round(n * 100) / 100;
|
||||||
|
},
|
||||||
|
formatCurrency(n: number) {
|
||||||
|
return new Intl.NumberFormat('fr-FR', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR',
|
||||||
|
notation: 'compact',
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
}).format(n);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@ -0,0 +1,101 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Row 1: KPI cards -->
|
||||||
|
<div class="row g-4 mb-4">
|
||||||
|
<div class="col-xl-3 col-sm-6">
|
||||||
|
<stock-kpi-card
|
||||||
|
label="Total produits"
|
||||||
|
:value="stats.total_products"
|
||||||
|
subtitle="En stock"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-3 col-sm-6">
|
||||||
|
<stock-kpi-card
|
||||||
|
label="Stock faible"
|
||||||
|
:value="stats.low_stock_products"
|
||||||
|
:trend="lowStockTrend"
|
||||||
|
subtitle="À ≤ minimum"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-3 col-sm-6">
|
||||||
|
<stock-kpi-card
|
||||||
|
label="Alertes actives"
|
||||||
|
:value="stats.stock_alerts_count"
|
||||||
|
subtitle="Dessous du seuil de sécurité"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-3 col-sm-6">
|
||||||
|
<stock-kpi-card
|
||||||
|
label="Valeur totale"
|
||||||
|
:value="stockValueLabel"
|
||||||
|
subtitle="Immobilisation financière"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 2: Top rotation + Warehouse movements -->
|
||||||
|
<div class="row g-4 mb-4">
|
||||||
|
<div class="col-xl-6">
|
||||||
|
<rotation-card :items="stats.stock_rotation_by_product" />
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-6">
|
||||||
|
<warehouse-movement-card :items="stats.warehouse_movements" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 3: Warehouse stock -->
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<warehouse-stock-card :items="stats.stock_by_warehouse" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, PropType, computed } from 'vue';
|
||||||
|
import type { StockStatistics } from '@/services/stockStatistics';
|
||||||
|
|
||||||
|
import StockKpiCard from '@/components/Atom/Stats/StockKpiCard.vue';
|
||||||
|
import RotationCard from '@/components/Molecule/Stats/RotationCard.vue';
|
||||||
|
import WarehouseMovementCard from '@/components/Molecule/Stats/WarehouseMovementCard.vue';
|
||||||
|
import WarehouseStockCard from '@/components/Molecule/Stats/WarehouseStockCard.vue';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'StockStatsDashboard',
|
||||||
|
components: {
|
||||||
|
StockKpiCard,
|
||||||
|
RotationCard,
|
||||||
|
WarehouseMovementCard,
|
||||||
|
WarehouseStockCard,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
stats: {
|
||||||
|
type: Object as PropType<StockStatistics>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const stockValueLabel = computed(() => {
|
||||||
|
return new Intl.NumberFormat('fr-FR', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR',
|
||||||
|
notation: 'compact',
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
}).format(props.stats.total_value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const lowStockTrend = computed(() => {
|
||||||
|
const total = props.stats.total_products;
|
||||||
|
const lowStock = props.stats.low_stock_products;
|
||||||
|
const pct = total > 0 ? Math.round((lowStock / total) * 100) : 0;
|
||||||
|
return `<span class="text-danger">${pct}%</span> du catalogue`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
stockValueLabel,
|
||||||
|
lowStockTrend,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
66
thanasoft-front/src/services/stockStatistics.ts
Normal file
66
thanasoft-front/src/services/stockStatistics.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
export interface ProductRotation {
|
||||||
|
product_id: number;
|
||||||
|
product_name: string;
|
||||||
|
moved_qty_last_12_months: number;
|
||||||
|
qty_on_hand: number;
|
||||||
|
rotation_rate: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WarehouseMovement {
|
||||||
|
warehouse_id: number;
|
||||||
|
warehouse_name: string;
|
||||||
|
incoming_moves: number;
|
||||||
|
incoming_qty: number;
|
||||||
|
outgoing_moves: number;
|
||||||
|
outgoing_qty: number;
|
||||||
|
total_moves: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StockByWarehouse {
|
||||||
|
warehouse_id: number;
|
||||||
|
warehouse_name: string;
|
||||||
|
qty_on_hand: number;
|
||||||
|
stock_value: number;
|
||||||
|
alert_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StockStatistics {
|
||||||
|
total_products: number;
|
||||||
|
low_stock_products: number;
|
||||||
|
expiring_products: number;
|
||||||
|
total_value: number;
|
||||||
|
stock_alerts_count: number;
|
||||||
|
stock_rotation_by_product: ProductRotation[];
|
||||||
|
warehouse_movements: WarehouseMovement[];
|
||||||
|
stock_by_warehouse: StockByWarehouse[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StockStatisticsResponse {
|
||||||
|
data: StockStatistics;
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_BASE = process.env.VUE_APP_API_BASE_URL || '';
|
||||||
|
const TOKEN = localStorage.getItem('auth_token');
|
||||||
|
|
||||||
|
export const StockStatisticsService = {
|
||||||
|
async getStatistics(): Promise<StockStatistics> {
|
||||||
|
try {
|
||||||
|
const response = await axios.get<StockStatisticsResponse>(
|
||||||
|
`${API_BASE}/api/products/statistics`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${TOKEN}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(
|
||||||
|
error.response?.data?.message ||
|
||||||
|
'Erreur lors de la récupération des statistiques de stock'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
45
thanasoft-front/src/stores/stockStatisticsStore.ts
Normal file
45
thanasoft-front/src/stores/stockStatisticsStore.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { StockStatisticsService } from '@/services/stockStatistics';
|
||||||
|
import type { StockStatistics } from '@/services/stockStatistics';
|
||||||
|
|
||||||
|
interface StockStatisticsState {
|
||||||
|
statistics: StockStatistics | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useStockStatisticsStore = defineStore('stockStatistics', {
|
||||||
|
state: (): StockStatisticsState => ({
|
||||||
|
statistics: null,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
isLoading: (state) => state.loading,
|
||||||
|
hasError: (state) => state.error !== null,
|
||||||
|
getError: (state) => state.error || '',
|
||||||
|
hasData: (state) => state.statistics !== null,
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async fetchStatistics() {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
try {
|
||||||
|
this.statistics = await StockStatisticsService.getStatistics();
|
||||||
|
} catch (err: any) {
|
||||||
|
this.error = err.message;
|
||||||
|
this.statistics = null;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearStatistics() {
|
||||||
|
this.statistics = null;
|
||||||
|
this.error = null;
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -1,11 +1,81 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<main
|
||||||
|
class="main-content position-relative max-height-vh-100 h-100 border-radius-lg"
|
||||||
|
>
|
||||||
|
<div class="py-4 container-fluid">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="d-flex align-items-center justify-content-between">
|
||||||
<div>
|
<div>
|
||||||
<h1>Gestion stock</h1>
|
<h4 class="mb-0 font-weight-bolder">Statistiques stock</h4>
|
||||||
|
<p class="mb-0 text-sm text-secondary">
|
||||||
|
Analyse des stocks, rotations, et mouvements d'entrepôt
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline-secondary mb-0"
|
||||||
|
:disabled="store.isLoading"
|
||||||
|
@click="store.fetchStatistics()"
|
||||||
|
>
|
||||||
|
<i class="fas fa-sync-alt me-1" />
|
||||||
|
Actualiser
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="store.isLoading" class="row">
|
||||||
|
<div class="col-12 text-center py-6">
|
||||||
|
<div class="spinner-border text-secondary" role="status">
|
||||||
|
<span class="visually-hidden">Chargement…</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-secondary">Chargement des statistiques…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div v-else-if="store.hasError" class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="alert alert-danger" role="alert">
|
||||||
|
<i class="fas fa-exclamation-triangle me-2" />
|
||||||
|
{{ store.getError }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dashboard -->
|
||||||
|
<stock-stats-dashboard
|
||||||
|
v-else-if="store.hasData"
|
||||||
|
:stats="store.statistics"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<app-footer />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
export default {
|
import { defineComponent, onMounted } from "vue";
|
||||||
name: "GestionStock",
|
import { useStockStatisticsStore } from "@/stores/stockStatisticsStore";
|
||||||
};
|
import StockStatsDashboard from "@/components/Organism/Stock/StockStatsDashboard.vue";
|
||||||
|
import AppFooter from "@/examples/Footer.vue";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: "StatistiquesStock",
|
||||||
|
components: {
|
||||||
|
StockStatsDashboard,
|
||||||
|
AppFooter,
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
const store = useStockStatisticsStore();
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.fetchStatistics();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { store };
|
||||||
|
},
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user