Feat Sous traitant
This commit is contained in:
parent
8c6787d03e
commit
163d3ff08d
@ -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;
|
||||
|
||||
@ -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}`"
|
||||
|
||||
@ -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 {};
|
||||
},
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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));
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
{{ trend >= 0 ? '+' : '' }}{{ trend }}%
|
||||
<span
|
||||
v-if="trend !== null"
|
||||
:class="trend >= 0 ? 'text-success' : 'text-danger'"
|
||||
class="text-sm font-weight-bolder"
|
||||
>
|
||||
{{ 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: {
|
||||
|
||||
@ -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[]>,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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[]>,
|
||||
|
||||
@ -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);
|
||||
},
|
||||
|
||||
@ -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>
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -181,4 +181,3 @@ const tabs = [
|
||||
box-shadow: 0 0 2rem 0 rgba(136, 152, 170, 0.15);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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 });
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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",
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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"
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
|
||||
@ -79,4 +79,3 @@ export default defineComponent({
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@ -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."
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user