Feat Sous traitant

This commit is contained in:
kevin 2026-05-22 11:22:26 +03:00
parent 8c6787d03e
commit 163d3ff08d
40 changed files with 2233 additions and 215 deletions

View File

@ -74,7 +74,9 @@ export default defineComponent({
},
computed: {
headerHtml(): string {
const val = this.suffix ? `${this.value}${this.suffix}` : String(this.value);
const val = this.suffix
? `${this.value}${this.suffix}`
: String(this.value);
if (!this.trend) return val;
const isNeg = this.trend.startsWith("-");
const cls = isNeg ? "text-danger" : "text-success";
@ -88,7 +90,9 @@ export default defineComponent({
},
methods: {
renderChart() {
const canvas = document.getElementById(this.chartId) as HTMLCanvasElement | null;
const canvas = document.getElementById(
this.chartId
) as HTMLCanvasElement | null;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;

View File

@ -5,7 +5,7 @@
<span class="text-sm text-dark font-weight-bold">{{ label }}</span>
<span class="text-sm text-secondary">{{ count }}</span>
</div>
<div class="progress" style="height: 6px;">
<div class="progress" style="height: 6px">
<div
class="progress-bar"
:class="`bg-gradient-${color}`"

View File

@ -12,10 +12,16 @@
<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>
<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>
<span class="text-lg font-weight-bolder text-secondary">{{
suffix
}}</span>
</div>
</div>
</div>
@ -24,8 +30,8 @@
</template>
<script lang="ts">
import { defineComponent, PropType, onMounted, watch } from 'vue';
import Chart from 'chart.js/auto';
import { defineComponent, PropType, onMounted, watch } from "vue";
import Chart from "chart.js/auto";
interface MiniChartData {
labels: string[];
@ -42,7 +48,7 @@ interface MiniChartData {
}
export default defineComponent({
name: 'StockKpiCard',
name: "StockKpiCard",
props: {
label: {
type: String,
@ -54,19 +60,19 @@ export default defineComponent({
},
suffix: {
type: String,
default: '',
default: "",
},
trend: {
type: String,
default: '',
default: "",
},
subtitle: {
type: String,
default: '',
default: "",
},
chartId: {
type: String,
default: '',
default: "",
},
chartHeight: {
type: Number,
@ -84,25 +90,27 @@ export default defineComponent({
const initChart = () => {
if (!props.chartId || !props.chart) return;
const canvas = document.getElementById(props.chartId) as HTMLCanvasElement;
const canvas = document.getElementById(
props.chartId
) as HTMLCanvasElement;
if (!canvas) return;
if (chartInstance.current) {
chartInstance.current.destroy();
}
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext("2d");
if (!ctx) return;
const color = props.chart.color || '#344767';
const color = props.chart.color || "#344767";
chartInstance.current = new Chart(ctx, {
type: 'line',
type: "line",
data: {
labels: props.chart.labels,
datasets: [
{
label: props.chart.datasets[0]?.label || 'Data',
label: props.chart.datasets[0]?.label || "Data",
data: props.chart.datasets[0]?.data || [],
borderColor: color,
backgroundColor: `rgba(100, 115, 130, 0.15)`,
@ -125,7 +133,7 @@ export default defineComponent({
beginAtZero: true,
grid: {
drawBorder: false,
color: 'rgba(0,0,0,0.05)',
color: "rgba(0,0,0,0.05)",
},
},
x: {
@ -143,9 +151,12 @@ export default defineComponent({
initChart();
});
watch(() => props.chart, () => {
initChart();
});
watch(
() => props.chart,
() => {
initChart();
}
);
return {};
},

View File

@ -4,7 +4,9 @@
<p class="mb-0 text-sm text-capitalize font-weight-bold">Avoirs émis</p>
<h5 class="mb-0 font-weight-bolder">
{{ formattedTotal }}
<span class="text-sm text-secondary font-weight-normal">({{ count }})</span>
<span class="text-sm text-secondary font-weight-normal"
>({{ count }})</span
>
</h5>
</div>
<div class="p-3 card-body">
@ -14,10 +16,14 @@
:key="item.reason_type"
class="d-flex justify-content-between align-items-center py-1 border-bottom"
>
<span class="text-xs text-secondary text-capitalize">{{ formatReason(item.reason_type) }}</span>
<span class="text-xs text-secondary text-capitalize">{{
formatReason(item.reason_type)
}}</span>
<div class="d-flex align-items-center gap-2">
<span class="text-xs text-secondary">{{ item.count }}</span>
<span class="text-xs font-weight-bold">{{ formatCurrency(item.total_ttc) }}</span>
<span class="text-xs font-weight-bold">{{
formatCurrency(item.total_ttc)
}}</span>
</div>
</li>
<li v-if="byReason.length === 0" class="text-sm text-secondary py-2">
@ -33,7 +39,11 @@ import { defineComponent, PropType } from "vue";
import type { AvoirByReason } from "@/services/financialStatistics";
const fmt = (n: number) =>
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(n);
new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
maximumFractionDigits: 0,
}).format(n);
const REASON_LABELS: Record<string, string> = {
remboursement_total: "Remboursement total",
@ -53,11 +63,17 @@ export default defineComponent({
byReason: { type: Array as PropType<AvoirByReason[]>, default: () => [] },
},
computed: {
formattedTotal(): string { return fmt(this.totalTtc); },
formattedTotal(): string {
return fmt(this.totalTtc);
},
},
methods: {
formatCurrency(n: number): string { return fmt(n); },
formatReason(key: string): string { return REASON_LABELS[key] ?? key; },
formatCurrency(n: number): string {
return fmt(n);
},
formatReason(key: string): string {
return REASON_LABELS[key] ?? key;
},
},
});
</script>

View File

@ -1,18 +1,23 @@
<template>
<div class="card h-100">
<div class="p-3 pb-2 card-header">
<p class="mb-0 text-sm text-capitalize font-weight-bold">Conversion devis facture</p>
<p class="mb-0 text-sm text-capitalize font-weight-bold">
Conversion devis facture
</p>
<h5 class="mb-0 font-weight-bolder">
{{ conversionRate }}<span class="text-sm text-secondary font-weight-normal">%</span>
{{ conversionRate
}}<span class="text-sm text-secondary font-weight-normal">%</span>
</h5>
</div>
<div class="p-3 card-body">
<!-- main progress -->
<div class="mb-3">
<div class="d-flex justify-content-between mb-1">
<span class="text-sm text-dark">{{ converted }} convertis / {{ total }} devis</span>
<span class="text-sm text-dark"
>{{ converted }} convertis / {{ total }} devis</span
>
</div>
<div class="progress" style="height: 6px;">
<div class="progress" style="height: 6px">
<div
class="progress-bar bg-dark"
role="progressbar"
@ -30,8 +35,12 @@
:key="item.status"
class="d-flex justify-content-between align-items-center py-1 border-bottom"
>
<span class="text-xs text-secondary text-capitalize">{{ item.status }}</span>
<span class="badge badge-sm bg-light text-dark border">{{ item.total }}</span>
<span class="text-xs text-secondary text-capitalize">{{
item.status
}}</span>
<span class="badge badge-sm bg-light text-dark border">{{
item.total
}}</span>
</li>
</ul>
</div>
@ -48,7 +57,10 @@ export default defineComponent({
total: { type: Number, default: 0 },
converted: { type: Number, default: 0 },
conversionRate: { type: Number, default: 0 },
byStatus: { type: Array as PropType<QuoteStatusCount[]>, default: () => [] },
byStatus: {
type: Array as PropType<QuoteStatusCount[]>,
default: () => [],
},
},
});
</script>

View File

@ -1,39 +1,73 @@
<template>
<div class="card h-100">
<div class="p-3 pb-2 card-header">
<p class="mb-0 text-sm text-capitalize font-weight-bold">Relances prioritaires</p>
<p class="mb-0 text-xs text-secondary">Échues + à échéance dans 7 jours</p>
<p class="mb-0 text-sm text-capitalize font-weight-bold">
Relances prioritaires
</p>
<p class="mb-0 text-xs text-secondary">
Échues + à échéance dans 7 jours
</p>
</div>
<div class="p-3 card-body">
<div class="table-responsive">
<table class="table table-sm align-items-center mb-0">
<thead>
<tr>
<th class="text-xs text-uppercase text-secondary font-weight-bolder ps-0">Facture</th>
<th class="text-xs text-uppercase text-secondary font-weight-bolder">Client</th>
<th class="text-xs text-uppercase text-secondary font-weight-bolder">Échéance</th>
<th class="text-xs text-uppercase text-secondary font-weight-bolder text-end pe-0">Montant</th>
<th
class="text-xs text-uppercase text-secondary font-weight-bolder ps-0"
>
Facture
</th>
<th
class="text-xs text-uppercase text-secondary font-weight-bolder"
>
Client
</th>
<th
class="text-xs text-uppercase text-secondary font-weight-bolder"
>
Échéance
</th>
<th
class="text-xs text-uppercase text-secondary font-weight-bolder text-end pe-0"
>
Montant
</th>
</tr>
</thead>
<tbody>
<tr v-for="inv in invoices" :key="inv.id">
<td class="ps-0">
<span class="text-sm font-weight-bold">{{ inv.invoice_number }}</span>
<span class="text-sm font-weight-bold">{{
inv.invoice_number
}}</span>
</td>
<td class="text-sm text-secondary">{{ inv.client_name }}</td>
<td>
<span
class="badge badge-sm"
:class="inv.days_overdue !== null && inv.days_overdue > 0 ? 'bg-light text-danger border border-danger' : 'bg-light text-secondary border'"
:class="
inv.days_overdue !== null && inv.days_overdue > 0
? 'bg-light text-danger border border-danger'
: 'bg-light text-secondary border'
"
>
{{ inv.due_date ? formatDate(inv.due_date) : '—' }}
<span v-if="inv.days_overdue !== null && inv.days_overdue > 0" class="ms-1">+{{ inv.days_overdue }}j</span>
{{ inv.due_date ? formatDate(inv.due_date) : "—" }}
<span
v-if="inv.days_overdue !== null && inv.days_overdue > 0"
class="ms-1"
>+{{ inv.days_overdue }}j</span
>
</span>
</td>
<td class="text-sm text-end pe-0 font-weight-bold">{{ formatCurrency(inv.total_ttc) }}</td>
<td class="text-sm text-end pe-0 font-weight-bold">
{{ formatCurrency(inv.total_ttc) }}
</td>
</tr>
<tr v-if="invoices.length === 0">
<td colspan="4" class="text-sm text-secondary text-center py-3">Aucune facture urgente.</td>
<td colspan="4" class="text-sm text-secondary text-center py-3">
Aucune facture urgente.
</td>
</tr>
</tbody>
</table>
@ -47,7 +81,11 @@ import { defineComponent, PropType } from "vue";
import type { CriticalInvoice } from "@/services/financialStatistics";
const fmt = (n: number) =>
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(n);
new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
maximumFractionDigits: 0,
}).format(n);
export default defineComponent({
name: "CriticalInvoicesCard",
@ -55,9 +93,14 @@ export default defineComponent({
invoices: { type: Array as PropType<CriticalInvoice[]>, default: () => [] },
},
methods: {
formatCurrency(n: number): string { return fmt(n); },
formatCurrency(n: number): string {
return fmt(n);
},
formatDate(d: string): string {
return new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short" }).format(new Date(d));
return new Intl.DateTimeFormat("fr-FR", {
day: "2-digit",
month: "short",
}).format(new Date(d));
},
},
});

View File

@ -2,16 +2,28 @@
<div class="card h-100">
<div class="p-3 pb-2 card-header">
<h6 class="mb-0">Répartition géographique</h6>
<p class="mb-0 text-xs text-secondary">Par pays et ville de facturation</p>
<p class="mb-0 text-xs text-secondary">
Par pays et ville de facturation
</p>
</div>
<div class="p-3 card-body">
<div class="table-responsive">
<table class="table table-sm align-items-center mb-0">
<thead>
<tr>
<th class="text-xs text-uppercase text-secondary font-weight-bolder ps-3">Pays</th>
<th class="text-xs text-uppercase text-secondary font-weight-bolder">Ville</th>
<th class="text-xs text-uppercase text-secondary font-weight-bolder text-end pe-3">
<th
class="text-xs text-uppercase text-secondary font-weight-bolder ps-3"
>
Pays
</th>
<th
class="text-xs text-uppercase text-secondary font-weight-bolder"
>
Ville
</th>
<th
class="text-xs text-uppercase text-secondary font-weight-bolder text-end pe-3"
>
Clients
</th>
</tr>
@ -19,14 +31,18 @@
<tbody>
<tr v-for="(entry, index) in items" :key="index">
<td class="text-sm font-weight-bold ps-3">
<span class="me-1">{{ flagEmoji(entry.billing_country_code) }}</span>
<span class="me-1">{{
flagEmoji(entry.billing_country_code)
}}</span>
{{ entry.billing_country_code }}
</td>
<td class="text-sm text-secondary">
{{ entry.billing_city || '—' }}
{{ entry.billing_city || "—" }}
</td>
<td class="text-sm text-end pe-3">
<span class="badge badge-sm bg-gradient-info">{{ entry.total }}</span>
<span class="badge badge-sm bg-gradient-info">{{
entry.total
}}</span>
</td>
</tr>
<tr v-if="items.length === 0">

View File

@ -4,8 +4,12 @@
<p class="mb-0 text-sm text-capitalize font-weight-bold">{{ title }}</p>
<h5 class="mb-0 font-weight-bolder">
{{ formattedTotal }}
<span v-if="trend !== null" :class="trend >= 0 ? 'text-success' : 'text-danger'" class="text-sm font-weight-bolder">
&nbsp;{{ trend >= 0 ? '+' : '' }}{{ trend }}%
<span
v-if="trend !== null"
:class="trend >= 0 ? 'text-success' : 'text-danger'"
class="text-sm font-weight-bolder"
>
&nbsp;{{ trend >= 0 ? "+" : "" }}{{ trend }}%
</span>
</h5>
</div>
@ -22,7 +26,20 @@ import { defineComponent, PropType, onMounted, watch } from "vue";
import Chart from "chart.js/auto";
import type { MonthlyRevenue } from "@/services/financialStatistics";
const MONTH_LABELS = ["Jan", "Fév", "Mar", "Avr", "Mai", "Jun", "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"];
const MONTH_LABELS = [
"Jan",
"Fév",
"Mar",
"Avr",
"Mai",
"Jun",
"Jul",
"Aoû",
"Sep",
"Oct",
"Nov",
"Déc",
];
export default defineComponent({
name: "RevenueChartCard",
@ -35,7 +52,11 @@ export default defineComponent({
computed: {
formattedTotal(): string {
const total = this.monthly.reduce((s, m) => s + m.total_ttc, 0);
return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(total);
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
maximumFractionDigits: 0,
}).format(total);
},
},
mounted() {
@ -43,7 +64,9 @@ export default defineComponent({
},
methods: {
renderChart() {
const canvas = document.getElementById(this.chartId) as HTMLCanvasElement | null;
const canvas = document.getElementById(
this.chartId
) as HTMLCanvasElement | null;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
@ -53,7 +76,9 @@ export default defineComponent({
// Build full 12-month array
const dataMap: Record<number, number> = {};
this.monthly.forEach((m) => { dataMap[m.month] = m.total_ttc; });
this.monthly.forEach((m) => {
dataMap[m.month] = m.total_ttc;
});
const data = Array.from({ length: 12 }, (_, i) => dataMap[i + 1] ?? 0);
new Chart(ctx, {
@ -82,7 +107,9 @@ export default defineComponent({
ticks: {
color: "#9ca2b7",
callback: (v: number | string) =>
new Intl.NumberFormat("fr-FR", { notation: "compact" }).format(Number(v)),
new Intl.NumberFormat("fr-FR", {
notation: "compact",
}).format(Number(v)),
},
},
x: {

View File

@ -2,12 +2,16 @@
<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>
<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">
<thead
class="text-uppercase text-secondary text-xxs font-weight-bolder ps-2"
>
<tr>
<th>Produit</th>
<th>Stock</th>
@ -18,17 +22,24 @@
<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>
<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>
<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">
<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>
@ -36,7 +47,9 @@
</td>
</tr>
<tr v-if="!items.length">
<td colspan="4" class="text-center text-secondary py-3">Aucun mouvement de stock</td>
<td colspan="4" class="text-center text-secondary py-3">
Aucun mouvement de stock
</td>
</tr>
</tbody>
</table>
@ -46,11 +59,11 @@
</template>
<script lang="ts">
import { defineComponent, PropType } from 'vue';
import type { ProductRotation } from '@/services/stockStatistics';
import { defineComponent, PropType } from "vue";
import type { ProductRotation } from "@/services/stockStatistics";
export default defineComponent({
name: 'RotationCard',
name: "RotationCard",
props: {
items: {
type: Array as PropType<ProductRotation[]>,

View File

@ -2,16 +2,28 @@
<div class="card h-100">
<div class="p-3 pb-2 card-header">
<h6 class="mb-0">Top 10 Grands comptes</h6>
<p class="mb-0 text-xs text-secondary">Clients avec le plus de dossiers</p>
<p class="mb-0 text-xs text-secondary">
Clients avec le plus de dossiers
</p>
</div>
<div class="p-3 card-body">
<div class="table-responsive">
<table class="table table-sm align-items-center mb-0">
<thead>
<tr>
<th class="text-xs text-uppercase text-secondary font-weight-bolder ps-3">#</th>
<th class="text-xs text-uppercase text-secondary font-weight-bolder">Client</th>
<th class="text-xs text-uppercase text-secondary font-weight-bolder text-end pe-3">
<th
class="text-xs text-uppercase text-secondary font-weight-bolder ps-3"
>
#
</th>
<th
class="text-xs text-uppercase text-secondary font-weight-bolder"
>
Client
</th>
<th
class="text-xs text-uppercase text-secondary font-weight-bolder text-end pe-3"
>
Dossiers
</th>
</tr>

View File

@ -1,7 +1,9 @@
<template>
<div class="card h-100">
<div class="p-3 pb-2 card-header">
<p class="mb-0 text-sm text-capitalize font-weight-bold">Créances en cours</p>
<p class="mb-0 text-sm text-capitalize font-weight-bold">
Créances en cours
</p>
<h5 class="mb-0 font-weight-bolder">{{ formattedTotal }}</h5>
</div>
<div class="p-3 card-body">
@ -9,15 +11,29 @@
<table class="table table-sm align-items-center mb-0">
<thead>
<tr>
<th class="text-xs text-uppercase text-secondary font-weight-bolder ps-0">Client</th>
<th class="text-xs text-uppercase text-secondary font-weight-bolder">Factures</th>
<th class="text-xs text-uppercase text-secondary font-weight-bolder text-end pe-0">Encours</th>
<th
class="text-xs text-uppercase text-secondary font-weight-bolder ps-0"
>
Client
</th>
<th
class="text-xs text-uppercase text-secondary font-weight-bolder"
>
Factures
</th>
<th
class="text-xs text-uppercase text-secondary font-weight-bolder text-end pe-0"
>
Encours
</th>
</tr>
</thead>
<tbody>
<tr v-for="debtor in debtors" :key="debtor.id">
<td class="ps-0">
<span class="text-sm font-weight-bold text-dark">{{ debtor.name }}</span>
<span class="text-sm font-weight-bold text-dark">{{
debtor.name
}}</span>
</td>
<td class="text-sm text-secondary">{{ debtor.invoice_count }}</td>
<td class="text-sm text-end pe-0 font-weight-bold">
@ -25,7 +41,9 @@
</td>
</tr>
<tr v-if="debtors.length === 0">
<td colspan="3" class="text-sm text-secondary text-center py-3">Aucune créance.</td>
<td colspan="3" class="text-sm text-secondary text-center py-3">
Aucune créance.
</td>
</tr>
</tbody>
</table>
@ -39,7 +57,11 @@ import { defineComponent, PropType } from "vue";
import type { TopDebtor } from "@/services/financialStatistics";
const fmt = (n: number) =>
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(n);
new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
maximumFractionDigits: 0,
}).format(n);
export default defineComponent({
name: "TopDebtorsCard",
@ -48,10 +70,14 @@ export default defineComponent({
totalOutstanding: { type: Number, default: 0 },
},
computed: {
formattedTotal(): string { return fmt(this.totalOutstanding); },
formattedTotal(): string {
return fmt(this.totalOutstanding);
},
},
methods: {
formatCurrency(n: number): string { return fmt(n); },
formatCurrency(n: number): string {
return fmt(n);
},
},
});
</script>

View File

@ -7,7 +7,9 @@
<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">
<thead
class="text-uppercase text-secondary text-xxs font-weight-bolder ps-2"
>
<tr>
<th>Entrepôt</th>
<th>Entrées</th>
@ -18,22 +20,36 @@
<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>
<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>
<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>
<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>
<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>
<td colspan="4" class="text-center text-secondary py-3">
Aucun entrepôt
</td>
</tr>
</tbody>
</table>
@ -43,11 +59,11 @@
</template>
<script lang="ts">
import { defineComponent, PropType } from 'vue';
import type { WarehouseMovement } from '@/services/stockStatistics';
import { defineComponent, PropType } from "vue";
import type { WarehouseMovement } from "@/services/stockStatistics";
export default defineComponent({
name: 'WarehouseMovementCard',
name: "WarehouseMovementCard",
props: {
items: {
type: Array as PropType<WarehouseMovement[]>,

View File

@ -7,7 +7,9 @@
<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">
<thead
class="text-uppercase text-secondary text-xxs font-weight-bolder ps-2"
>
<tr>
<th>Entrepôt</th>
<th>Quantité</th>
@ -18,23 +20,34 @@
<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>
<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>
<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>
<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">
<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>
<td colspan="4" class="text-center text-secondary py-3">
Aucun entrepôt
</td>
</tr>
</tbody>
</table>
@ -44,11 +57,11 @@
</template>
<script lang="ts">
import { defineComponent, PropType } from 'vue';
import type { StockByWarehouse } from '@/services/stockStatistics';
import { defineComponent, PropType } from "vue";
import type { StockByWarehouse } from "@/services/stockStatistics";
export default defineComponent({
name: 'WarehouseStockCard',
name: "WarehouseStockCard",
props: {
items: {
type: Array as PropType<StockByWarehouse[]>,
@ -60,10 +73,10 @@ export default defineComponent({
return Math.round(n * 100) / 100;
},
formatCurrency(n: number) {
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
notation: 'compact',
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
notation: "compact",
maximumFractionDigits: 1,
}).format(n);
},

View File

@ -0,0 +1,39 @@
<template>
<new-sous-traitant-template>
<template #sous-traitant-form>
<new-sous-traitant-form
:loading="loading"
:validation-errors="validationErrors"
:success="success"
@submit="handleSubmit"
/>
</template>
</new-sous-traitant-template>
</template>
<script setup>
import { defineEmits, defineProps } from "vue";
import NewSousTraitantTemplate from "@/components/templates/CRM/NewSousTraitantTemplate.vue";
import NewSousTraitantForm from "@/components/molecules/form/NewSousTraitantForm.vue";
defineProps({
loading: {
type: Boolean,
default: false,
},
validationErrors: {
type: Object,
default: () => ({}),
},
success: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["createSousTraitant"]);
const handleSubmit = (data) => {
emit("createSousTraitant", data);
};
</script>

View File

@ -90,7 +90,20 @@ import TopClientsCard from "@/components/Molecule/Stats/TopClientsCard.vue";
import GeographicCard from "@/components/Molecule/Stats/GeographicCard.vue";
// Shared x-axis labels for sparklines
const MONTHS = ["Jan", "Fév", "Mar", "Avr", "Mai", "Juin", "Juil", "Aoû", "Sep", "Oct", "Nov", "Déc"];
const MONTHS = [
"Jan",
"Fév",
"Mar",
"Avr",
"Mai",
"Juin",
"Juil",
"Aoû",
"Sep",
"Oct",
"Nov",
"Déc",
];
export default defineComponent({
name: "ClientStatsDashboard",

View File

@ -0,0 +1,218 @@
<template>
<sous-traitant-detail-template>
<template #header-right>
<span class="badge bg-white text-primary px-3 py-2">
{{ sousTraitant.type_prestation || "Sous-traitant" }}
</span>
<span class="badge px-3 py-2" :class="statusBadgeClass">
{{ statusLabel }}
</span>
</template>
<template #summary-cards>
<div class="col-12 col-md-6 col-xl-3">
<div class="card stat-card border-0">
<div class="card-body py-3">
<p class="text-sm text-secondary mb-1">Contact</p>
<h6 class="mb-0 text-truncate">
{{ sousTraitant.contact_principal }}
</h6>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-xl-3">
<div class="card stat-card border-0">
<div class="card-body py-3">
<p class="text-sm text-secondary mb-1">Montant contrat</p>
<h6 class="mb-0">{{ contractAmount }}</h6>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-xl-3">
<div class="card stat-card border-0">
<div class="card-body py-3">
<p class="text-sm text-secondary mb-1">Note qualité</p>
<h6 class="mb-0">{{ qualityLabel }}</h6>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-xl-3">
<div class="card stat-card border-0">
<div class="card-body py-3">
<p class="text-sm text-secondary mb-1">Certifications</p>
<h6 class="mb-0">{{ certificationCount }}</h6>
</div>
</div>
</div>
</template>
<template #button-return>
<div class="col-12">
<router-link
to="/sous-traitants"
class="btn btn-outline-secondary btn-sm mb-3"
>
<i class="fas fa-arrow-left me-2"></i>Retour aux sous-traitants
</router-link>
</div>
</template>
<template #sidebar>
<div class="card border-0 shadow-sm">
<div class="card-body">
<h5 class="mb-1">{{ sousTraitant.nom_entreprise }}</h5>
<p class="text-sm text-secondary mb-3">
{{
sousTraitant.forme_juridique || "Forme juridique non renseignée"
}}
</p>
<div class="info-row">
<span class="label">Email</span>
<span>{{ sousTraitant.email || "Non renseigné" }}</span>
</div>
<div class="info-row">
<span class="label">Téléphone</span>
<span>{{ sousTraitant.telephone || "Non renseigné" }}</span>
</div>
<div class="info-row">
<span class="label">SIRET</span>
<span>{{ sousTraitant.siret || "Non renseigné" }}</span>
</div>
<div class="info-row">
<span class="label">Code APE</span>
<span>{{ sousTraitant.code_ape || "Non renseigné" }}</span>
</div>
<div class="info-row">
<span class="label">Adresse</span>
<span>{{ sousTraitant.adresse || "Non renseignée" }}</span>
</div>
<div class="d-grid gap-2 mt-4">
<soft-button
color="danger"
variant="outline"
class="mb-0"
:disabled="loading"
@click="emit('deleteSousTraitant')"
>
<i class="fas fa-trash me-2"></i>Supprimer
</soft-button>
</div>
</div>
</div>
</template>
<template #content>
<div v-if="loading" class="card border-0 shadow-sm">
<div class="card-body text-center p-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
</div>
</div>
<new-sous-traitant-form
v-else
mode="edit"
:loading="loading"
:initial-data="sousTraitant"
:validation-errors="validationErrors"
@submit="handleSubmit"
/>
</template>
</sous-traitant-detail-template>
</template>
<script setup>
import { computed, defineEmits, defineProps } from "vue";
import { RouterLink } from "vue-router";
import SoftButton from "@/components/SoftButton.vue";
import NewSousTraitantForm from "@/components/molecules/form/NewSousTraitantForm.vue";
import SousTraitantDetailTemplate from "@/components/templates/CRM/SousTraitantDetailTemplate.vue";
const props = defineProps({
sousTraitant: {
type: Object,
required: true,
},
loading: {
type: Boolean,
default: false,
},
validationErrors: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(["updateSousTraitant", "deleteSousTraitant"]);
const statusLabel = computed(() => {
const labels = {
actif: "Actif",
inactif: "Inactif",
en_evaluation: "En évaluation",
};
return labels[props.sousTraitant.statut] || "Inconnu";
});
const statusBadgeClass = computed(() => {
const classes = {
actif: "bg-white text-success",
inactif: "bg-white text-danger",
en_evaluation: "bg-white text-warning",
};
return classes[props.sousTraitant.statut] || "bg-white text-secondary";
});
const contractAmount = computed(() => {
const value = props.sousTraitant.montant_contrat;
return value ? `${Number(value).toFixed(2)} EUR` : "Non renseigné";
});
const qualityLabel = computed(() => {
return props.sousTraitant.note_qualite !== null &&
props.sousTraitant.note_qualite !== undefined &&
props.sousTraitant.note_qualite !== ""
? `${props.sousTraitant.note_qualite}/5`
: "Non noté";
});
const certificationCount = computed(
() => props.sousTraitant.certifications_labels?.length || 0
);
const handleSubmit = (data) => {
const payload = {
id: props.sousTraitant.id,
...data,
};
emit("updateSousTraitant", payload);
};
</script>
<style scoped>
.stat-card {
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.06);
}
.info-row {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.75rem 0;
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
}
.info-row:last-of-type {
border-bottom: 0;
}
.label {
font-size: 0.75rem;
font-weight: 700;
color: #8392ab;
text-transform: uppercase;
}
</style>

View File

@ -0,0 +1,129 @@
<template>
<sous-traitant-template>
<template #summary-cards>
<div class="col-12 col-md-6 col-xl-3">
<div class="card stat-card border-0">
<div class="card-body py-3">
<p class="text-sm text-secondary mb-1">Sous-traitants</p>
<h5 class="mb-0">{{ totalSousTraitants }}</h5>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-xl-3">
<div class="card stat-card border-0">
<div class="card-body py-3">
<p class="text-sm text-secondary mb-1">Actifs</p>
<h5 class="mb-0 text-success">{{ activeSousTraitants }}</h5>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-xl-3">
<div class="card stat-card border-0">
<div class="card-body py-3">
<p class="text-sm text-secondary mb-1">En évaluation</p>
<h5 class="mb-0 text-warning">{{ evaluatingSousTraitants }}</h5>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-xl-3">
<div class="card stat-card border-0">
<div class="card-body py-3">
<p class="text-sm text-secondary mb-1">Inactifs</p>
<h5 class="mb-0 text-danger">{{ inactiveSousTraitants }}</h5>
</div>
</div>
</div>
</template>
<template #sous-traitant-new-action>
<add-button text="Ajouter" @click="goToSousTraitant" />
</template>
<template #select-filter>
<filter-table />
</template>
<template #sous-traitant-other-action>
<table-action />
</template>
<template #sous-traitant-table>
<sous-traitant-table
:data="sousTraitantData"
:loading="loadingData"
:pagination="pagination"
@view="goToDetails"
@delete="deleteSousTraitant"
@page-change="onPageChange"
/>
</template>
</sous-traitant-template>
</template>
<script setup>
import { computed, defineEmits, defineProps } from "vue";
import { useRouter } from "vue-router";
import { useSousTraitantStore } from "@/stores/sousTraitantStore";
import SousTraitantTemplate from "@/components/templates/CRM/SousTraitantTemplate.vue";
import SousTraitantTable from "@/components/molecules/Tables/CRM/SousTraitantTable.vue";
import addButton from "@/components/molecules/new-button/addButton.vue";
import FilterTable from "@/components/molecules/Tables/FilterTable.vue";
import TableAction from "@/components/molecules/Tables/TableAction.vue";
const router = useRouter();
const sousTraitantStore = useSousTraitantStore();
const emit = defineEmits(["pushDetails", "deleteSousTraitant"]);
const props = defineProps({
sousTraitantData: {
type: Array,
default: () => [],
},
loadingData: {
type: Boolean,
default: false,
},
pagination: {
type: Object,
default: () => ({}),
},
});
const totalSousTraitants = computed(
() => props.pagination?.total || props.sousTraitantData.length || 0
);
const activeSousTraitants = computed(
() => props.sousTraitantData.filter((item) => item.statut === "actif").length
);
const inactiveSousTraitants = computed(
() =>
props.sousTraitantData.filter((item) => item.statut === "inactif").length
);
const evaluatingSousTraitants = computed(
() =>
props.sousTraitantData.filter((item) => item.statut === "en_evaluation")
.length
);
const goToSousTraitant = () => {
router.push({ name: "Creation sous-traitant" });
};
const goToDetails = (id) => {
emit("pushDetails", id);
};
const deleteSousTraitant = (id) => {
emit("deleteSousTraitant", id);
};
const onPageChange = (page) => {
sousTraitantStore.fetchSousTraitants({
...sousTraitantStore.filters,
page,
});
};
</script>
<style scoped>
.stat-card {
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.06);
}
</style>

View File

@ -181,4 +181,3 @@ const tabs = [
box-shadow: 0 0 2rem 0 rgba(136, 152, 170, 0.15);
}
</style>

View File

@ -70,7 +70,9 @@
<!-- Row 3: Critical invoices + Top debtors -->
<div class="row g-4">
<div class="col-xl-7">
<critical-invoices-card :invoices="stats.receivables.critical_invoices" />
<critical-invoices-card
:invoices="stats.receivables.critical_invoices"
/>
</div>
<div class="col-xl-5">
<top-debtors-card
@ -93,7 +95,20 @@ import AvoirsCard from "@/components/Molecule/Stats/AvoirsCard.vue";
import CriticalInvoicesCard from "@/components/Molecule/Stats/CriticalInvoicesCard.vue";
import TopDebtorsCard from "@/components/Molecule/Stats/TopDebtorsCard.vue";
const MONTHS = ["Jan", "Fév", "Mar", "Avr", "Mai", "Jun", "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"];
const MONTHS = [
"Jan",
"Fév",
"Mar",
"Avr",
"Mai",
"Jun",
"Jul",
"Aoû",
"Sep",
"Oct",
"Nov",
"Déc",
];
const fmtCurrency = (n: number | null) =>
n === null
@ -145,10 +160,17 @@ export default defineComponent({
// Sparklines
const kpiChartCA = computed(() => {
const dataMap: Record<number, number> = {};
props.stats.revenue.monthly.forEach((m) => { dataMap[m.month] = m.total_ttc; });
props.stats.revenue.monthly.forEach((m) => {
dataMap[m.month] = m.total_ttc;
});
return {
labels: MONTHS,
datasets: [{ label: "CA", data: Array.from({ length: 12 }, (_, i) => dataMap[i + 1] ?? 0) }],
datasets: [
{
label: "CA",
data: Array.from({ length: 12 }, (_, i) => dataMap[i + 1] ?? 0),
},
],
};
});
@ -157,7 +179,10 @@ export default defineComponent({
const data = Array.from({ length: 9 }, (_, i) =>
Math.max(0, Math.round(rate * (0.8 + (i / 8) * 0.2)))
);
return { labels: MONTHS.slice(0, 9), datasets: [{ label: "Conversion %", data }] };
return {
labels: MONTHS.slice(0, 9),
datasets: [{ label: "Conversion %", data }],
};
});
const kpiChartPanier = computed(() => {
@ -165,7 +190,10 @@ export default defineComponent({
const data = Array.from({ length: 9 }, (_, i) =>
Math.round(avg * (0.85 + (i / 8) * 0.2))
);
return { labels: MONTHS.slice(0, 9), datasets: [{ label: "Panier moyen", data }] };
return {
labels: MONTHS.slice(0, 9),
datasets: [{ label: "Panier moyen", data }],
};
});
return {

View File

@ -50,9 +50,7 @@
:sync-label="
syncing ? 'Synchronisation...' : 'Synchroniser la boîte'
"
:smtp-test-label="
smtpTesting ? 'Test SMTP...' : 'Tester le SMTP'
"
:smtp-test-label="smtpTesting ? 'Test SMTP...' : 'Tester le SMTP'"
@update:field="$emit('update:field', $event)"
@submit="$emit('submit-settings')"
@sync="$emit('sync-mailbox')"
@ -185,7 +183,13 @@ const props = defineProps({
},
});
defineEmits(["update:field", "submit-settings", "sync-mailbox", "test-smtp", "reset-form"]);
defineEmits([
"update:field",
"submit-settings",
"sync-mailbox",
"test-smtp",
"reset-form",
]);
const lastSyncedLabel = computed(() => {
if (!props.settings?.last_synced_at) {

View File

@ -53,16 +53,16 @@
</template>
<script lang="ts">
import { defineComponent, PropType, computed } from 'vue';
import type { StockStatistics } from '@/services/stockStatistics';
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';
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',
name: "StockStatsDashboard",
components: {
StockKpiCard,
RotationCard,
@ -77,10 +77,10 @@ export default defineComponent({
},
setup(props) {
const stockValueLabel = computed(() => {
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
notation: 'compact',
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
notation: "compact",
maximumFractionDigits: 1,
}).format(props.stats.total_value);
});

View File

@ -0,0 +1,510 @@
<template>
<div class="table-container">
<div v-if="loading" class="loading-container">
<div class="loading-spinner">
<div
class="spinner-border text-success loading-spinner-circle"
role="status"
>
<span class="visually-hidden">Chargement...</span>
</div>
</div>
<div class="loading-content">
<div class="table-responsive">
<table class="table table-flush">
<thead class="thead-light">
<tr>
<th>Sous-traitant</th>
<th>Contact</th>
<th>Prestation</th>
<th>Contrat</th>
<th>Statut</th>
<th>Qualité</th>
</tr>
</thead>
<tbody>
<tr v-for="i in skeletonRows" :key="i">
<td><div class="skeleton-text long"></div></td>
<td><div class="skeleton-text medium"></div></td>
<td><div class="skeleton-text medium"></div></td>
<td><div class="skeleton-text short"></div></td>
<td><div class="skeleton-text short"></div></td>
<td><div class="skeleton-text short"></div></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div v-else class="table-responsive">
<table id="sous-traitant-list" class="table table-flush">
<thead class="thead-light">
<tr>
<th>Sous-traitant</th>
<th>Contact</th>
<th>Prestation</th>
<th>Contrat</th>
<th>Statut</th>
<th>Qualité</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<tr v-for="item in data" :key="item.id">
<td class="font-weight-bold">
<div class="d-flex flex-column">
<span>{{ item.nom_entreprise }}</span>
<span class="text-xs text-secondary">
{{ item.forme_juridique || item.siret || "Non renseigné" }}
</span>
</div>
</td>
<td class="text-xs font-weight-bold">
<div class="d-flex flex-column">
<span>{{ item.contact_principal }}</span>
<span class="text-secondary">{{ item.email || "N/A" }}</span>
<span>{{ item.telephone || "N/A" }}</span>
</div>
</td>
<td class="text-xs font-weight-bold">
{{ item.type_prestation || "Non renseigné" }}
</td>
<td class="text-xs font-weight-bold">
<div class="d-flex flex-column">
<span>{{ item.numero_contrat || "Sans numéro" }}</span>
<span class="text-secondary">
{{ formatAmount(item.montant_contrat) }}
</span>
</div>
</td>
<td class="text-xs font-weight-bold">
<soft-button
:color="getStatusColor(item.statut)"
variant="outline"
class="btn-sm"
>
<i :class="getStatusIcon(item.statut)" class="me-1"></i>
{{ getStatusLabel(item.statut) }}
</soft-button>
</td>
<td class="text-xs font-weight-bold">
{{ formatQuality(item.note_qualite) }}
</td>
<td>
<div class="d-flex align-items-center gap-2">
<soft-button
color="info"
variant="outline"
title="Voir le sous-traitant"
:data-sous-traitant-id="item.id"
class="btn-icon-only btn-rounded mb-0 btn-sm d-flex align-items-center justify-content-center"
>
<i class="fas fa-eye" aria-hidden="true"></i>
</soft-button>
<soft-button
color="danger"
variant="outline"
title="Supprimer le sous-traitant"
:data-sous-traitant-id="item.id"
class="btn-icon-only btn-rounded mb-0 btn-sm d-flex align-items-center justify-content-center"
>
<i class="fas fa-trash" aria-hidden="true"></i>
</soft-button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div
v-if="!loading && data.length > 0 && (pagination?.last_page || 1) > 1"
class="d-flex justify-content-between align-items-center mt-3 px-3 flex-wrap gap-3"
>
<div class="text-xs text-secondary font-weight-bold">
Affichage de {{ safeFrom }} à {{ safeTo }} sur
{{ pagination.total || data.length }} sous-traitants
</div>
<nav aria-label="Pagination sous-traitants">
<ul class="pagination pagination-sm pagination-success mb-0">
<li
class="page-item"
:class="{ disabled: (pagination.current_page || 1) === 1 }"
>
<a
class="page-link"
href="#"
@click.prevent="changePage((pagination.current_page || 1) - 1)"
>
<i class="fa fa-angle-left" aria-hidden="true"></i>
</a>
</li>
<li
v-for="page in displayedPages"
:key="`page-${page}`"
class="page-item"
:class="{
active: (pagination.current_page || 1) === page,
disabled: page === '...',
}"
>
<a class="page-link" href="#" @click.prevent="changePage(page)">
{{ page }}
</a>
</li>
<li
class="page-item"
:class="{
disabled:
(pagination.current_page || 1) === (pagination.last_page || 1),
}"
>
<a
class="page-link"
href="#"
@click.prevent="changePage((pagination.current_page || 1) + 1)"
>
<i class="fa fa-angle-right" aria-hidden="true"></i>
</a>
</li>
</ul>
</nav>
</div>
<div v-if="!loading && data.length === 0" class="empty-state">
<div class="empty-icon">
<i class="fas fa-people-carry fa-3x text-muted"></i>
</div>
<h5 class="empty-title">Aucun sous-traitant trouvé</h5>
<p class="empty-text text-muted">
Aucun sous-traitant à afficher pour le moment.
</p>
</div>
</div>
</template>
<script setup>
import {
computed,
defineEmits,
defineProps,
onMounted,
onUnmounted,
ref,
watch,
} from "vue";
import { DataTable } from "simple-datatables";
import SoftButton from "@/components/SoftButton.vue";
const emit = defineEmits(["view", "delete", "page-change"]);
const dataTableInstance = ref(null);
const props = defineProps({
data: {
type: Array,
default: () => [],
},
loading: {
type: Boolean,
default: false,
},
skeletonRows: {
type: Number,
default: 5,
},
pagination: {
type: Object,
default: () => ({
current_page: 1,
last_page: 1,
per_page: 10,
total: 0,
from: 0,
to: 0,
}),
},
});
const displayedPages = computed(() => {
const total = Number(props.pagination?.last_page) || 1;
const current = Number(props.pagination?.current_page) || 1;
if (total <= 1) {
return [1];
}
const delta = 2;
const range = [];
for (
let page = Math.max(2, current - delta);
page <= Math.min(total - 1, current + delta);
page++
) {
range.push(page);
}
if (current - delta > 2) {
range.unshift("...");
}
if (current + delta < total - 1) {
range.push("...");
}
range.unshift(1);
if (total > 1) {
range.push(total);
}
return range.filter(
(value, index, self) =>
value !== "..." || (value === "..." && self[index - 1] !== "...")
);
});
const safeFrom = computed(() => {
if (props.pagination?.from) {
return props.pagination.from;
}
if (!props.pagination?.total || props.data.length === 0) {
return 0;
}
return (
((Number(props.pagination.current_page) || 1) - 1) *
(Number(props.pagination.per_page) || 10) +
1
);
});
const safeTo = computed(() => {
if (props.pagination?.to) {
return props.pagination.to;
}
if (!props.pagination?.total || props.data.length === 0) {
return 0;
}
return Math.min(
(Number(props.pagination.current_page) || 1) *
(Number(props.pagination.per_page) || 10),
Number(props.pagination.total) || 0
);
});
const getStatusLabel = (status) => {
const labels = {
actif: "Actif",
inactif: "Inactif",
en_evaluation: "Évaluation",
};
return labels[status] || "Inconnu";
};
const getStatusColor = (status) => {
const colors = {
actif: "success",
inactif: "danger",
en_evaluation: "warning",
};
return colors[status] || "secondary";
};
const getStatusIcon = (status) => {
const icons = {
actif: "fas fa-check",
inactif: "fas fa-times",
en_evaluation: "fas fa-hourglass-half",
};
return icons[status] || "fas fa-circle";
};
const formatAmount = (value) => {
if (value === null || value === undefined || value === "") {
return "Montant non renseigné";
}
return `${Number(value).toFixed(2)} EUR`;
};
const formatQuality = (value) => {
if (value === null || value === undefined || value === "") {
return "Non noté";
}
return `${value}/5`;
};
const handleTableClick = (event) => {
const button = event.target.closest("button");
if (!button) return;
const itemId = button.getAttribute("data-sous-traitant-id");
if (!itemId) return;
if (
button.title === "Supprimer le sous-traitant" ||
button.querySelector(".fa-trash")
) {
emit("delete", itemId);
} else if (
button.title === "Voir le sous-traitant" ||
button.querySelector(".fa-eye")
) {
emit("view", itemId);
}
};
const initializeDataTable = () => {
if (dataTableInstance.value) {
dataTableInstance.value.destroy();
dataTableInstance.value = null;
}
const dataTableEl = document.getElementById("sous-traitant-list");
if (dataTableEl) {
dataTableInstance.value = new DataTable(dataTableEl, {
searchable: true,
fixedHeight: true,
paging: false,
perPage: Number(props.pagination?.per_page) || 10,
perPageSelect: false,
});
dataTableEl.addEventListener("click", handleTableClick);
}
};
const changePage = (page) => {
if (
page !== "..." &&
page >= 1 &&
page <= (Number(props.pagination?.last_page) || 1) &&
page !== Number(props.pagination?.current_page)
) {
emit("page-change", page);
}
};
watch(
() => props.data,
() => {
if (!props.loading) {
setTimeout(() => {
initializeDataTable();
}, 100);
}
},
{ deep: true }
);
onMounted(() => {
if (!props.loading && props.data.length > 0) {
initializeDataTable();
}
});
onUnmounted(() => {
const dataTableEl = document.getElementById("sous-traitant-list");
if (dataTableEl) {
dataTableEl.removeEventListener("click", handleTableClick);
}
if (dataTableInstance.value) {
dataTableInstance.value.destroy();
}
});
</script>
<style scoped>
.table-container {
position: relative;
min-height: 200px;
}
.loading-container {
position: relative;
min-height: 260px;
}
.loading-spinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 10;
}
.loading-spinner-circle {
width: 2.25rem;
height: 2.25rem;
border-width: 0.28em;
}
.loading-content {
opacity: 0.55;
pointer-events: none;
}
.skeleton-text {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 2s infinite;
border-radius: 4px;
height: 12px;
}
.skeleton-text.short {
width: 60px;
}
.skeleton-text.medium {
width: 100px;
}
.skeleton-text.long {
width: 150px;
}
.empty-state {
text-align: center;
padding: 3rem 1rem;
}
.empty-icon {
margin-bottom: 1rem;
opacity: 0.5;
}
.empty-title {
margin-bottom: 0.5rem;
color: #6c757d;
}
.empty-text {
max-width: 320px;
margin: 0 auto;
}
.text-xs {
font-size: 0.75rem;
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
</style>

View File

@ -0,0 +1,541 @@
<template>
<div class="card p-3 border-radius-xl bg-white" data-animation="FadeIn">
<div
class="d-flex justify-content-between align-items-center flex-wrap gap-2"
>
<div>
<h5 class="font-weight-bolder mb-0">{{ title }}</h5>
<p class="mb-0 text-sm">{{ subtitle }}</p>
</div>
<slot name="header-actions" />
</div>
<div class="multisteps-form__content">
<div class="section-block mt-4">
<h6 class="section-title">Identité</h6>
<div class="row mt-3">
<div class="col-12 col-md-6">
<label class="form-label">
Nom de l'entreprise <span class="text-danger">*</span>
</label>
<soft-input
v-model="form.nom_entreprise"
:error="!!fieldErrors.nom_entreprise"
type="text"
placeholder="ex. Thana Services"
/>
<div v-if="fieldErrors.nom_entreprise" class="invalid-feedback">
{{ fieldErrors.nom_entreprise }}
</div>
</div>
<div class="col-12 col-md-6 mt-3 mt-md-0">
<label class="form-label">SIRET</label>
<soft-input
v-model="form.siret"
:error="!!fieldErrors.siret"
type="text"
maxlength="20"
placeholder="ex. 12345678901234"
/>
<div v-if="fieldErrors.siret" class="invalid-feedback">
{{ fieldErrors.siret }}
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-12 col-md-6">
<label class="form-label">Forme juridique</label>
<soft-input
v-model="form.forme_juridique"
:error="!!fieldErrors.forme_juridique"
type="text"
placeholder="SARL, SAS, EURL..."
/>
</div>
<div class="col-12 col-md-6 mt-3 mt-md-0">
<label class="form-label">Code APE</label>
<soft-input
v-model="form.code_ape"
:error="!!fieldErrors.code_ape"
type="text"
maxlength="20"
/>
</div>
</div>
<div class="row mt-3">
<div class="col-12">
<label class="form-label">Adresse</label>
<textarea
v-model="form.adresse"
class="form-control"
:class="{ 'is-invalid': fieldErrors.adresse }"
rows="3"
placeholder="Adresse complète..."
></textarea>
<div v-if="fieldErrors.adresse" class="invalid-feedback">
{{ fieldErrors.adresse }}
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-12 col-md-6">
<label class="form-label">
Contact principal <span class="text-danger">*</span>
</label>
<soft-input
v-model="form.contact_principal"
:error="!!fieldErrors.contact_principal"
type="text"
/>
<div v-if="fieldErrors.contact_principal" class="invalid-feedback">
{{ fieldErrors.contact_principal }}
</div>
</div>
<div class="col-12 col-md-6 mt-3 mt-md-0">
<label class="form-label">Téléphone</label>
<soft-input
v-model="form.telephone"
:error="!!fieldErrors.telephone"
type="text"
maxlength="50"
/>
</div>
</div>
<div class="row mt-3">
<div class="col-12 col-md-6">
<label class="form-label">Email</label>
<soft-input
v-model="form.email"
:error="!!fieldErrors.email"
type="email"
placeholder="contact@societe.com"
/>
</div>
<div class="col-12 col-md-6 mt-3 mt-md-0">
<label class="form-label">Site web</label>
<soft-input
v-model="form.site_web"
:error="!!fieldErrors.site_web"
type="text"
placeholder="https://..."
/>
</div>
</div>
</div>
<div class="section-block mt-4">
<h6 class="section-title">Contrat</h6>
<div class="row mt-3">
<div class="col-12 col-md-6">
<label class="form-label">Numéro de contrat</label>
<soft-input v-model="form.numero_contrat" type="text" />
</div>
<div class="col-12 col-md-6 mt-3 mt-md-0">
<label class="form-label">Montant du contrat (EUR)</label>
<soft-input
v-model="form.montant_contrat"
:error="!!fieldErrors.montant_contrat"
type="number"
step="0.01"
min="0"
/>
</div>
</div>
<div class="row mt-3">
<div class="col-12 col-md-6">
<label class="form-label">Date de début</label>
<soft-input
v-model="form.date_debut_contrat"
type="date"
:error="!!fieldErrors.date_debut_contrat"
/>
</div>
<div class="col-12 col-md-6 mt-3 mt-md-0">
<label class="form-label">Date de fin</label>
<soft-input
v-model="form.date_fin_contrat"
type="date"
:error="!!fieldErrors.date_fin_contrat"
/>
<div v-if="fieldErrors.date_fin_contrat" class="invalid-feedback">
{{ fieldErrors.date_fin_contrat }}
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-12 col-md-6">
<label class="form-label">Type de prestation</label>
<soft-input
v-model="form.type_prestation"
:error="!!fieldErrors.type_prestation"
type="text"
placeholder="Transport, maintenance, nettoyage..."
/>
</div>
<div class="col-12 col-md-6 mt-3 mt-md-0">
<label class="form-label">Statut</label>
<select
v-model="form.statut"
class="form-control"
:class="{ 'is-invalid': fieldErrors.statut }"
>
<option value="actif">Actif</option>
<option value="inactif">Inactif</option>
<option value="en_evaluation">En évaluation</option>
</select>
</div>
</div>
<div class="row mt-3">
<div class="col-12">
<label class="form-label">Conditions de paiement</label>
<textarea
v-model="form.conditions_paiement"
class="form-control"
rows="3"
placeholder="Conditions, échéances, modalités..."
></textarea>
</div>
</div>
</div>
<div class="section-block mt-4">
<h6 class="section-title">Qualité</h6>
<div class="row mt-3">
<div class="col-12 col-md-4">
<label class="form-label">Note de qualité (0-5)</label>
<soft-input
v-model="form.note_qualite"
:error="!!fieldErrors.note_qualite"
type="number"
min="0"
max="5"
step="0.1"
/>
</div>
<div class="col-12 col-md-8 mt-3 mt-md-0">
<label class="form-label">Certifications et labels</label>
<div class="d-flex gap-2">
<soft-input
v-model="newCertification"
type="text"
placeholder="Ajouter une certification..."
@keyup.enter="addCertification"
/>
<soft-button
color="primary"
variant="gradient"
class="mb-0"
@click="addCertification"
>
<i class="fas fa-plus"></i>
</soft-button>
</div>
<div
v-if="form.certifications_labels.length"
class="mt-3 d-flex flex-wrap gap-2"
>
<span
v-for="(label, index) in form.certifications_labels"
:key="`${label}-${index}`"
class="badge bg-light text-dark certification-badge"
>
{{ label }}
<button
type="button"
class="btn btn-link btn-sm text-danger p-0 ms-2"
@click="removeCertification(index)"
>
<i class="fas fa-times"></i>
</button>
</span>
</div>
</div>
</div>
</div>
<div class="button-row d-flex mt-4 flex-wrap gap-2">
<soft-button
type="button"
color="secondary"
variant="outline"
class="mb-0"
@click="resetForm"
>
Réinitialiser
</soft-button>
<soft-button
type="button"
color="dark"
variant="gradient"
class="mb-0 ms-auto"
:disabled="loading"
@click="submitForm"
>
<span
v-if="loading"
class="spinner-border spinner-border-sm me-2"
role="status"
></span>
{{ submitButtonLabel }}
</soft-button>
</div>
</div>
</div>
</template>
<script setup>
import { computed, defineEmits, defineProps, ref, watch } from "vue";
import SoftButton from "@/components/SoftButton.vue";
import SoftInput from "@/components/SoftInput.vue";
const createDefaultForm = () => ({
nom_entreprise: "",
siret: "",
forme_juridique: "",
code_ape: "",
adresse: "",
contact_principal: "",
telephone: "",
email: "",
site_web: "",
numero_contrat: "",
montant_contrat: "",
date_debut_contrat: "",
date_fin_contrat: "",
type_prestation: "",
conditions_paiement: "",
statut: "actif",
note_qualite: "",
certifications_labels: [],
});
const props = defineProps({
loading: {
type: Boolean,
default: false,
},
validationErrors: {
type: Object,
default: () => ({}),
},
success: {
type: Boolean,
default: false,
},
initialData: {
type: Object,
default: () => ({}),
},
mode: {
type: String,
default: "create",
},
});
const emit = defineEmits(["submit"]);
const form = ref(createDefaultForm());
const fieldErrors = ref({});
const newCertification = ref("");
const title = computed(() =>
props.mode === "edit" ? "Modifier le sous-traitant" : "Nouveau sous-traitant"
);
const subtitle = computed(() =>
props.mode === "edit"
? "Ajustez les informations, le contrat et la qualité."
: "Renseignez l'identité, le contrat et les critères qualité."
);
const submitButtonLabel = computed(() =>
props.loading
? props.mode === "edit"
? "Mise à jour..."
: "Création..."
: props.mode === "edit"
? "Enregistrer les modifications"
: "Créer le sous-traitant"
);
const normalizeFormData = (source = {}) => {
const defaults = createDefaultForm();
return {
...defaults,
...source,
certifications_labels: Array.isArray(source.certifications_labels)
? [...source.certifications_labels]
: [],
statut: source.statut || "actif",
montant_contrat:
source.montant_contrat === null || source.montant_contrat === undefined
? ""
: source.montant_contrat,
note_qualite:
source.note_qualite === null || source.note_qualite === undefined
? ""
: source.note_qualite,
date_debut_contrat: source.date_debut_contrat || "",
date_fin_contrat: source.date_fin_contrat || "",
};
};
watch(
() => props.validationErrors,
(newErrors) => {
fieldErrors.value = { ...newErrors };
},
{ deep: true, immediate: true }
);
watch(
() => props.initialData,
(newData) => {
form.value = normalizeFormData(newData);
},
{ deep: true, immediate: true }
);
watch(
() => props.success,
(newSuccess) => {
if (newSuccess && props.mode === "create") {
resetForm();
}
}
);
const addCertification = () => {
const value = newCertification.value.trim();
if (!value) return;
if (!form.value.certifications_labels.includes(value)) {
form.value.certifications_labels.push(value);
}
newCertification.value = "";
};
const removeCertification = (index) => {
form.value.certifications_labels.splice(index, 1);
};
const validateForm = () => {
const errors = {};
let isValid = true;
if (!form.value.nom_entreprise?.trim()) {
errors.nom_entreprise = "Le nom de l'entreprise est obligatoire";
isValid = false;
}
if (!form.value.contact_principal?.trim()) {
errors.contact_principal = "Le contact principal est obligatoire";
isValid = false;
}
if (form.value.email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(form.value.email)) {
errors.email = "L'adresse email doit être valide";
isValid = false;
}
}
if (form.value.site_web) {
try {
new URL(form.value.site_web);
} catch {
errors.site_web = "Le site web doit être une URL valide";
isValid = false;
}
}
if (
form.value.date_debut_contrat &&
form.value.date_fin_contrat &&
form.value.date_fin_contrat < form.value.date_debut_contrat
) {
errors.date_fin_contrat =
"La date de fin doit être postérieure ou égale à la date de début";
isValid = false;
}
if (
form.value.note_qualite !== "" &&
(Number(form.value.note_qualite) < 0 || Number(form.value.note_qualite) > 5)
) {
errors.note_qualite = "La note de qualité doit être comprise entre 0 et 5";
isValid = false;
}
return { isValid, errors };
};
const submitForm = () => {
fieldErrors.value = {};
const validationResult = validateForm();
if (!validationResult.isValid) {
fieldErrors.value = validationResult.errors;
return;
}
const cleanedForm = {};
Object.entries(form.value).forEach(([key, value]) => {
if (Array.isArray(value)) {
cleanedForm[key] = value;
return;
}
cleanedForm[key] = value === "" || value === undefined ? null : value;
});
emit("submit", cleanedForm);
};
const resetForm = () => {
form.value =
props.mode === "edit"
? normalizeFormData(props.initialData)
: createDefaultForm();
newCertification.value = "";
fieldErrors.value = {};
};
</script>
<style scoped>
.section-block {
border-top: 1px solid rgba(148, 163, 184, 0.2);
padding-top: 1rem;
}
.section-title {
font-weight: 700;
color: #344767;
}
.form-label {
font-weight: 600;
margin-bottom: 0.5rem;
}
.invalid-feedback {
display: block;
}
.spinner-border-sm {
width: 1rem;
height: 1rem;
}
.certification-badge {
border: 1px solid rgba(15, 23, 42, 0.08);
}
</style>

View File

@ -320,7 +320,13 @@ const props = defineProps({
},
});
const emit = defineEmits(["update:field", "submit", "sync", "test-smtp", "reset"]);
const emit = defineEmits([
"update:field",
"submit",
"sync",
"test-smtp",
"reset",
]);
const updateField = (field, value) => {
emit("update:field", { field, value });

View File

@ -0,0 +1,15 @@
<template>
<div class="container-fluid py-4">
<div class="row">
<div class="col-12">
<div class="multisteps-form mb-5">
<div class="row">
<div class="col-12 col-lg-10 m-auto">
<slot name="sous-traitant-form" />
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,49 @@
<template>
<div class="container-fluid py-4 sous-traitant-detail-shell">
<div class="card border-0 mb-4 hero-shell">
<div class="card-body p-4">
<div
class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3"
>
<div>
<h4 class="text-white mb-1">
<slot name="page-title">Détail sous-traitant</slot>
</h4>
<p class="text-white-50 mb-0">
<slot name="page-subtitle">
Consultez et mettez à jour les informations du sous-traitant.
</slot>
</p>
</div>
<div class="d-flex align-items-center flex-wrap gap-2">
<slot name="header-right" />
</div>
</div>
</div>
</div>
<div class="row g-3 mb-3">
<slot name="summary-cards" />
</div>
<div class="row mb-3">
<slot name="button-return" />
</div>
<div class="row g-4">
<div class="col-xl-4 col-lg-5">
<slot name="sidebar" />
</div>
<div class="col-xl-8 col-lg-7">
<slot name="content" />
</div>
</div>
</div>
</template>
<style scoped>
.hero-shell {
background: linear-gradient(135deg, #344767 0%, #5e72e4 100%);
box-shadow: 0 10px 30px rgba(52, 71, 103, 0.2);
}
</style>

View File

@ -0,0 +1,67 @@
<template>
<div class="container-fluid py-4 sous-traitant-shell">
<div class="card border-0 mb-4 hero-shell">
<div class="card-body p-4">
<div
class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3"
>
<div>
<h4 class="text-white mb-1">
<slot name="page-title">Gestion des sous-traitants</slot>
</h4>
<p class="text-white-50 mb-0">
<slot name="page-subtitle">
Centralisez vos partenaires, contrats et indicateurs qualité.
</slot>
</p>
</div>
<div class="d-flex gap-2 flex-wrap">
<slot name="header-right" />
</div>
</div>
</div>
</div>
<div class="row g-3 mb-3">
<slot name="summary-cards" />
</div>
<div class="card border-0 content-shell">
<div class="card-body p-3 p-md-4">
<div
class="d-sm-flex justify-content-between align-items-center gap-2 action-row"
>
<div>
<slot name="sous-traitant-new-action"></slot>
</div>
<div class="d-flex align-items-center flex-wrap gap-2">
<div class="dropdown d-inline">
<slot name="select-filter"></slot>
</div>
<slot name="sous-traitant-other-action"></slot>
</div>
</div>
<div class="mt-3 table-shell">
<slot name="sous-traitant-table"></slot>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.hero-shell {
background: linear-gradient(135deg, #344767 0%, #5e72e4 100%);
box-shadow: 0 10px 30px rgba(52, 71, 103, 0.2);
}
.content-shell {
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
}
.table-shell {
border-top: 1px solid rgba(148, 163, 184, 0.2);
padding-top: 1rem;
}
</style>

View File

@ -525,6 +525,11 @@ const routes = [
name: "Gestion sous-traitants",
component: () => import("@/views/pages/SousTraitants/SousTraitants.vue"),
},
{
path: "/sous-traitants/new",
name: "Creation sous-traitant",
component: () => import("@/views/pages/SousTraitants/AddSousTraitant.vue"),
},
{
path: "/sous-traitants/contacts",
name: "Contacts sous-traitants",
@ -545,6 +550,12 @@ const routes = [
name: "Statistiques sous-traitants",
component: () => import("@/views/pages/SousTraitants/Statistiques.vue"),
},
{
path: "/sous-traitants/:id",
name: "Sous-traitant details",
component: () =>
import("@/views/pages/SousTraitants/SousTraitantDetails.vue"),
},
// Ventes
{
path: "/ventes/devis",

View File

@ -147,16 +147,16 @@ export const LeaveService = {
});
},
async deleteLeave(
id: number
): Promise<{ message: string; status?: string }> {
async deleteLeave(id: number): Promise<{ message: string; status?: string }> {
return request<{ message: string; status?: string }>({
url: `/api/leaves/${id}`,
method: "delete",
});
},
transformLeavePayload(payload: Partial<CreateLeavePayload>): Record<string, unknown> {
transformLeavePayload(
payload: Partial<CreateLeavePayload>
): Record<string, unknown> {
const transformed: Record<string, unknown> = { ...payload };
Object.keys(transformed).forEach((key) => {

View File

@ -1,4 +1,4 @@
import axios from 'axios';
import axios from "axios";
export interface ProductRotation {
product_id: number;
@ -41,8 +41,8 @@ export interface StockStatisticsResponse {
data: StockStatistics;
}
const API_BASE = process.env.VUE_APP_API_BASE_URL || '';
const TOKEN = localStorage.getItem('auth_token');
const API_BASE = process.env.VUE_APP_API_BASE_URL || "";
const TOKEN = localStorage.getItem("auth_token");
export const StockStatisticsService = {
async getStatistics(): Promise<StockStatistics> {
@ -59,7 +59,7 @@ export const StockStatisticsService = {
} catch (error: any) {
throw new Error(
error.response?.data?.message ||
'Erreur lors de la récupération des statistiques de stock'
"Erreur lors de la récupération des statistiques de stock"
);
}
},

View File

@ -3,54 +3,51 @@ import { ref, computed } from "vue";
import ClientStatisticsService from "@/services/clientStatistics";
import type { ClientStatistics } from "@/services/clientStatistics";
export const useClientStatisticsStore = defineStore(
"clientStatistics",
() => {
// ─── State ─────────────────────────────────────────────────────────────
const statistics = ref<ClientStatistics | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
export const useClientStatisticsStore = defineStore("clientStatistics", () => {
// ─── State ─────────────────────────────────────────────────────────────
const statistics = ref<ClientStatistics | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
// ─── Getters ───────────────────────────────────────────────────────────
const isLoading = computed(() => loading.value);
const hasError = computed(() => error.value !== null);
const getError = computed(() => error.value);
const hasData = computed(() => statistics.value !== null);
// ─── Getters ───────────────────────────────────────────────────────────
const isLoading = computed(() => loading.value);
const hasError = computed(() => error.value !== null);
const getError = computed(() => error.value);
const hasData = computed(() => statistics.value !== null);
// ─── Actions ───────────────────────────────────────────────────────────
async function fetchStatistics(): Promise<void> {
loading.value = true;
error.value = null;
try {
statistics.value = await ClientStatisticsService.getStatistics();
} catch (err: unknown) {
error.value =
err instanceof Error
? err.message
: "Erreur lors du chargement des statistiques.";
} finally {
loading.value = false;
}
// ─── Actions ───────────────────────────────────────────────────────────
async function fetchStatistics(): Promise<void> {
loading.value = true;
error.value = null;
try {
statistics.value = await ClientStatisticsService.getStatistics();
} catch (err: unknown) {
error.value =
err instanceof Error
? err.message
: "Erreur lors du chargement des statistiques.";
} finally {
loading.value = false;
}
function clearStatistics(): void {
statistics.value = null;
error.value = null;
}
return {
// state
statistics,
loading,
error,
// getters
isLoading,
hasError,
getError,
hasData,
// actions
fetchStatistics,
clearStatistics,
};
}
);
function clearStatistics(): void {
statistics.value = null;
error.value = null;
}
return {
// state
statistics,
loading,
error,
// getters
isLoading,
hasError,
getError,
hasData,
// actions
fetchStatistics,
clearStatistics,
};
});

View File

@ -223,7 +223,9 @@ export const useLeaveStore = defineStore("leave", () => {
const response = await LeaveService.updateLeave(payload);
const updatedLeave = LeaveService.unwrapLeave(response);
const index = leaves.value.findIndex((leave) => leave.id === updatedLeave.id);
const index = leaves.value.findIndex(
(leave) => leave.id === updatedLeave.id
);
if (index !== -1) {
leaves.value[index] = updatedLeave;
}

View File

@ -143,8 +143,9 @@ export const useSousTraitantStore = defineStore("sousTraitant", () => {
sort_direction?: "asc" | "desc";
};
const response =
await SousTraitantService.getAllSousTraitants(requestParams);
const response = await SousTraitantService.getAllSousTraitants(
requestParams
);
setSousTraitants(response.data);
if (response.meta) {
setPagination(response.meta);
@ -245,7 +246,9 @@ export const useSousTraitantStore = defineStore("sousTraitant", () => {
try {
const response = await SousTraitantService.deleteSousTraitant(id);
sousTraitants.value = sousTraitants.value.filter((item) => item.id !== id);
sousTraitants.value = sousTraitants.value.filter(
(item) => item.id !== id
);
if (currentSousTraitant.value && currentSousTraitant.value.id === id) {
setCurrentSousTraitant(null);

View File

@ -1,6 +1,6 @@
import { defineStore } from 'pinia';
import { StockStatisticsService } from '@/services/stockStatistics';
import type { StockStatistics } from '@/services/stockStatistics';
import { defineStore } from "pinia";
import { StockStatisticsService } from "@/services/stockStatistics";
import type { StockStatistics } from "@/services/stockStatistics";
interface StockStatisticsState {
statistics: StockStatistics | null;
@ -8,7 +8,7 @@ interface StockStatisticsState {
error: string | null;
}
export const useStockStatisticsStore = defineStore('stockStatistics', {
export const useStockStatisticsStore = defineStore("stockStatistics", {
state: (): StockStatisticsState => ({
statistics: null,
loading: false,
@ -18,7 +18,7 @@ export const useStockStatisticsStore = defineStore('stockStatistics', {
getters: {
isLoading: (state) => state.loading,
hasError: (state) => state.error !== null,
getError: (state) => state.error || '',
getError: (state) => state.error || "",
hasData: (state) => state.statistics !== null,
},

View File

@ -79,4 +79,3 @@ export default defineComponent({
},
});
</script>

View File

@ -193,7 +193,8 @@ const testSmtp = async () => {
} catch {
notificationStore.error(
"Erreur de test SMTP",
mailboxSettingsStore.error || "Impossible de tester la configuration SMTP."
mailboxSettingsStore.error ||
"Impossible de tester la configuration SMTP."
);
}
};

View File

@ -100,12 +100,14 @@ const practitioners = computed(
);
const collaborators = computed(() =>
practitioners.value.map((p) => ({
id: p.employee_id || p.employee?.id,
name:
`${p.employee?.first_name || ""} ${p.employee?.last_name || ""}`.trim() ||
`Collaborateur #${p.id}`,
}))
practitioners.value
.map((p) => ({
id: p.employee_id || p.employee?.id,
name:
`${p.employee?.first_name || ""} ${
p.employee?.last_name || ""
}`.trim() || `Collaborateur #${p.id}`,
}))
.filter((collaborator) => Boolean(collaborator.id))
);
@ -136,7 +138,9 @@ const currentEmployee = computed(() => {
);
});
const currentEmployeeName = computed(() => currentEmployee.value?.full_name || "");
const currentEmployeeName = computed(
() => currentEmployee.value?.full_name || ""
);
const resetLeaveForm = () => {
leaveForm.value = {

View File

@ -0,0 +1,54 @@
<template>
<add-sous-traitant-presentation
:loading="sousTraitantStore.isLoading"
:validation-errors="validationErrors"
:success="showSuccess"
@create-sous-traitant="handleCreateSousTraitant"
/>
</template>
<script setup>
import { ref } from "vue";
import { useRouter } from "vue-router";
import { useSousTraitantStore } from "@/stores/sousTraitantStore";
import { useNotificationStore } from "@/stores/notification";
import AddSousTraitantPresentation from "@/components/Organism/CRM/AddSousTraitantPresentation.vue";
const router = useRouter();
const sousTraitantStore = useSousTraitantStore();
const notificationStore = useNotificationStore();
const validationErrors = ref({});
const showSuccess = ref(false);
const handleCreateSousTraitant = async (form) => {
try {
validationErrors.value = {};
showSuccess.value = false;
await sousTraitantStore.createSousTraitant(form);
notificationStore.created("Sous-traitant");
showSuccess.value = true;
setTimeout(() => {
router.push({ name: "Gestion sous-traitants" });
}, 1200);
} catch (error) {
console.error("Error creating sous-traitant:", error);
if (error.response && error.response.status === 422) {
validationErrors.value = error.response.data.errors || {};
notificationStore.error(
"Erreur de validation",
"Veuillez corriger les erreurs dans le formulaire"
);
} else if (error.response && error.response.data) {
notificationStore.error(
"Erreur",
error.response.data.message || "Une erreur est survenue"
);
} else {
notificationStore.error("Erreur", "Une erreur inattendue s'est produite");
}
}
};
</script>

View File

@ -0,0 +1,74 @@
<template>
<sous-traitant-detail-presentation
v-if="sousTraitantStore.currentSousTraitant"
:sous-traitant="sousTraitantStore.currentSousTraitant"
:loading="sousTraitantStore.isLoading"
:validation-errors="validationErrors"
@update-sous-traitant="handleUpdateSousTraitant"
@delete-sous-traitant="handleDeleteSousTraitant"
/>
</template>
<script setup>
import { onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useSousTraitantStore } from "@/stores/sousTraitantStore";
import { useNotificationStore } from "@/stores/notification";
import SousTraitantDetailPresentation from "@/components/Organism/CRM/SousTraitantDetailPresentation.vue";
const route = useRoute();
const router = useRouter();
const sousTraitantStore = useSousTraitantStore();
const notificationStore = useNotificationStore();
const validationErrors = ref({});
const sousTraitantId = Number(route.params.id);
onMounted(async () => {
if (sousTraitantId) {
await sousTraitantStore.fetchSousTraitant(sousTraitantId);
}
});
const handleUpdateSousTraitant = async (payload) => {
try {
validationErrors.value = {};
await sousTraitantStore.updateSousTraitant(payload);
notificationStore.updated("Sous-traitant");
} catch (error) {
console.error("Error updating sous-traitant:", error);
if (error.response && error.response.status === 422) {
validationErrors.value = error.response.data.errors || {};
notificationStore.error(
"Erreur de validation",
"Veuillez corriger les erreurs dans le formulaire"
);
return;
}
notificationStore.error(
"Erreur",
"Impossible de mettre à jour le sous-traitant"
);
}
};
const handleDeleteSousTraitant = async () => {
if (!confirm("Êtes-vous sûr de vouloir supprimer ce sous-traitant ?")) {
return;
}
try {
await sousTraitantStore.deleteSousTraitant(sousTraitantId);
notificationStore.deleted("Sous-traitant");
router.push({ name: "Gestion sous-traitants" });
} catch (error) {
console.error("Error deleting sous-traitant:", error);
notificationStore.error(
"Erreur",
"Impossible de supprimer le sous-traitant"
);
}
};
</script>

View File

@ -1,11 +1,57 @@
<template>
<div>
<h1>Sous-Traitants</h1>
</div>
<sous-traitant-presentation
:sous-traitant-data="sousTraitantStore.sousTraitants"
:loading-data="sousTraitantStore.loading"
:pagination="sousTraitantStore.getPagination"
@push-details="goDetails"
@delete-sous-traitant="handleDeleteSousTraitant"
/>
</template>
<script>
export default {
name: "SousTraitants",
<script setup>
import { onMounted } from "vue";
import { useRouter } from "vue-router";
import { useSousTraitantStore } from "@/stores/sousTraitantStore";
import { useNotificationStore } from "@/stores/notification";
import SousTraitantPresentation from "@/components/Organism/CRM/SousTraitantPresentation.vue";
const router = useRouter();
const sousTraitantStore = useSousTraitantStore();
const notificationStore = useNotificationStore();
onMounted(async () => {
await sousTraitantStore.fetchSousTraitants();
});
const goDetails = (id) => {
router.push({
name: "Sous-traitant details",
params: { id },
});
};
const handleDeleteSousTraitant = async (id) => {
if (!confirm("Êtes-vous sûr de vouloir supprimer ce sous-traitant ?")) {
return;
}
try {
await sousTraitantStore.deleteSousTraitant(Number(id));
await sousTraitantStore.fetchSousTraitants({
...sousTraitantStore.filters,
page:
sousTraitantStore.sousTraitants.length === 0 &&
sousTraitantStore.filters.page > 1
? sousTraitantStore.filters.page - 1
: sousTraitantStore.filters.page,
});
notificationStore.deleted("Sous-traitant");
} catch (error) {
console.error("Error deleting sous-traitant:", error);
notificationStore.error(
"Erreur",
"Impossible de supprimer le sous-traitant"
);
}
};
</script>