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(InvoiceSeeder::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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "GestionStock",
|
||||
};
|
||||
<script lang="ts">
|
||||
import { defineComponent, onMounted } from "vue";
|
||||
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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user