Feat Sous traitant
This commit is contained in:
parent
8c6787d03e
commit
163d3ff08d
@ -74,7 +74,9 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
headerHtml(): string {
|
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;
|
if (!this.trend) return val;
|
||||||
const isNeg = this.trend.startsWith("-");
|
const isNeg = this.trend.startsWith("-");
|
||||||
const cls = isNeg ? "text-danger" : "text-success";
|
const cls = isNeg ? "text-danger" : "text-success";
|
||||||
@ -88,7 +90,9 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
renderChart() {
|
renderChart() {
|
||||||
const canvas = document.getElementById(this.chartId) as HTMLCanvasElement | null;
|
const canvas = document.getElementById(
|
||||||
|
this.chartId
|
||||||
|
) as HTMLCanvasElement | null;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
<span class="text-sm text-dark font-weight-bold">{{ label }}</span>
|
<span class="text-sm text-dark font-weight-bold">{{ label }}</span>
|
||||||
<span class="text-sm text-secondary">{{ count }}</span>
|
<span class="text-sm text-secondary">{{ count }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="progress" style="height: 6px;">
|
<div class="progress" style="height: 6px">
|
||||||
<div
|
<div
|
||||||
class="progress-bar"
|
class="progress-bar"
|
||||||
:class="`bg-gradient-${color}`"
|
:class="`bg-gradient-${color}`"
|
||||||
|
|||||||
@ -12,10 +12,16 @@
|
|||||||
<div class="row align-items-center">
|
<div class="row align-items-center">
|
||||||
<div class="col-8">
|
<div class="col-8">
|
||||||
<h5 class="mb-0 font-weight-bolder">{{ value }}</h5>
|
<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>
|
||||||
<div v-if="suffix" class="col-4 text-end">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -24,8 +30,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, PropType, onMounted, watch } from 'vue';
|
import { defineComponent, PropType, onMounted, watch } from "vue";
|
||||||
import Chart from 'chart.js/auto';
|
import Chart from "chart.js/auto";
|
||||||
|
|
||||||
interface MiniChartData {
|
interface MiniChartData {
|
||||||
labels: string[];
|
labels: string[];
|
||||||
@ -42,7 +48,7 @@ interface MiniChartData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'StockKpiCard',
|
name: "StockKpiCard",
|
||||||
props: {
|
props: {
|
||||||
label: {
|
label: {
|
||||||
type: String,
|
type: String,
|
||||||
@ -54,19 +60,19 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
suffix: {
|
suffix: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: "",
|
||||||
},
|
},
|
||||||
trend: {
|
trend: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: "",
|
||||||
},
|
},
|
||||||
subtitle: {
|
subtitle: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: "",
|
||||||
},
|
},
|
||||||
chartId: {
|
chartId: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: "",
|
||||||
},
|
},
|
||||||
chartHeight: {
|
chartHeight: {
|
||||||
type: Number,
|
type: Number,
|
||||||
@ -84,25 +90,27 @@ export default defineComponent({
|
|||||||
const initChart = () => {
|
const initChart = () => {
|
||||||
if (!props.chartId || !props.chart) return;
|
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 (!canvas) return;
|
||||||
|
|
||||||
if (chartInstance.current) {
|
if (chartInstance.current) {
|
||||||
chartInstance.current.destroy();
|
chartInstance.current.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext("2d");
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
const color = props.chart.color || '#344767';
|
const color = props.chart.color || "#344767";
|
||||||
|
|
||||||
chartInstance.current = new Chart(ctx, {
|
chartInstance.current = new Chart(ctx, {
|
||||||
type: 'line',
|
type: "line",
|
||||||
data: {
|
data: {
|
||||||
labels: props.chart.labels,
|
labels: props.chart.labels,
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: props.chart.datasets[0]?.label || 'Data',
|
label: props.chart.datasets[0]?.label || "Data",
|
||||||
data: props.chart.datasets[0]?.data || [],
|
data: props.chart.datasets[0]?.data || [],
|
||||||
borderColor: color,
|
borderColor: color,
|
||||||
backgroundColor: `rgba(100, 115, 130, 0.15)`,
|
backgroundColor: `rgba(100, 115, 130, 0.15)`,
|
||||||
@ -125,7 +133,7 @@ export default defineComponent({
|
|||||||
beginAtZero: true,
|
beginAtZero: true,
|
||||||
grid: {
|
grid: {
|
||||||
drawBorder: false,
|
drawBorder: false,
|
||||||
color: 'rgba(0,0,0,0.05)',
|
color: "rgba(0,0,0,0.05)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
x: {
|
x: {
|
||||||
@ -143,9 +151,12 @@ export default defineComponent({
|
|||||||
initChart();
|
initChart();
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(() => props.chart, () => {
|
watch(
|
||||||
initChart();
|
() => props.chart,
|
||||||
});
|
() => {
|
||||||
|
initChart();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
},
|
},
|
||||||
|
|||||||
@ -4,7 +4,9 @@
|
|||||||
<p class="mb-0 text-sm text-capitalize font-weight-bold">Avoirs émis</p>
|
<p class="mb-0 text-sm text-capitalize font-weight-bold">Avoirs émis</p>
|
||||||
<h5 class="mb-0 font-weight-bolder">
|
<h5 class="mb-0 font-weight-bolder">
|
||||||
{{ formattedTotal }}
|
{{ formattedTotal }}
|
||||||
<span class="text-sm text-secondary font-weight-normal">({{ count }})</span>
|
<span class="text-sm text-secondary font-weight-normal"
|
||||||
|
>({{ count }})</span
|
||||||
|
>
|
||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-3 card-body">
|
<div class="p-3 card-body">
|
||||||
@ -14,10 +16,14 @@
|
|||||||
:key="item.reason_type"
|
:key="item.reason_type"
|
||||||
class="d-flex justify-content-between align-items-center py-1 border-bottom"
|
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">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<span class="text-xs text-secondary">{{ item.count }}</span>
|
<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>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li v-if="byReason.length === 0" class="text-sm text-secondary py-2">
|
<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";
|
import type { AvoirByReason } from "@/services/financialStatistics";
|
||||||
|
|
||||||
const fmt = (n: number) =>
|
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> = {
|
const REASON_LABELS: Record<string, string> = {
|
||||||
remboursement_total: "Remboursement total",
|
remboursement_total: "Remboursement total",
|
||||||
@ -53,11 +63,17 @@ export default defineComponent({
|
|||||||
byReason: { type: Array as PropType<AvoirByReason[]>, default: () => [] },
|
byReason: { type: Array as PropType<AvoirByReason[]>, default: () => [] },
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
formattedTotal(): string { return fmt(this.totalTtc); },
|
formattedTotal(): string {
|
||||||
|
return fmt(this.totalTtc);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
formatCurrency(n: number): string { return fmt(n); },
|
formatCurrency(n: number): string {
|
||||||
formatReason(key: string): string { return REASON_LABELS[key] ?? key; },
|
return fmt(n);
|
||||||
|
},
|
||||||
|
formatReason(key: string): string {
|
||||||
|
return REASON_LABELS[key] ?? key;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,18 +1,23 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="card h-100">
|
<div class="card h-100">
|
||||||
<div class="p-3 pb-2 card-header">
|
<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">
|
<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>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-3 card-body">
|
<div class="p-3 card-body">
|
||||||
<!-- main progress -->
|
<!-- main progress -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="d-flex justify-content-between mb-1">
|
<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>
|
||||||
<div class="progress" style="height: 6px;">
|
<div class="progress" style="height: 6px">
|
||||||
<div
|
<div
|
||||||
class="progress-bar bg-dark"
|
class="progress-bar bg-dark"
|
||||||
role="progressbar"
|
role="progressbar"
|
||||||
@ -30,8 +35,12 @@
|
|||||||
:key="item.status"
|
:key="item.status"
|
||||||
class="d-flex justify-content-between align-items-center py-1 border-bottom"
|
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="text-xs text-secondary text-capitalize">{{
|
||||||
<span class="badge badge-sm bg-light text-dark border">{{ item.total }}</span>
|
item.status
|
||||||
|
}}</span>
|
||||||
|
<span class="badge badge-sm bg-light text-dark border">{{
|
||||||
|
item.total
|
||||||
|
}}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@ -48,7 +57,10 @@ export default defineComponent({
|
|||||||
total: { type: Number, default: 0 },
|
total: { type: Number, default: 0 },
|
||||||
converted: { type: Number, default: 0 },
|
converted: { type: Number, default: 0 },
|
||||||
conversionRate: { type: Number, default: 0 },
|
conversionRate: { type: Number, default: 0 },
|
||||||
byStatus: { type: Array as PropType<QuoteStatusCount[]>, default: () => [] },
|
byStatus: {
|
||||||
|
type: Array as PropType<QuoteStatusCount[]>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,39 +1,73 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="card h-100">
|
<div class="card h-100">
|
||||||
<div class="p-3 pb-2 card-header">
|
<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-sm text-capitalize font-weight-bold">
|
||||||
<p class="mb-0 text-xs text-secondary">Échues + à échéance dans 7 jours</p>
|
Relances prioritaires
|
||||||
|
</p>
|
||||||
|
<p class="mb-0 text-xs text-secondary">
|
||||||
|
Échues + à échéance dans 7 jours
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-3 card-body">
|
<div class="p-3 card-body">
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-sm align-items-center mb-0">
|
<table class="table table-sm align-items-center mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="text-xs text-uppercase text-secondary font-weight-bolder ps-0">Facture</th>
|
<th
|
||||||
<th class="text-xs text-uppercase text-secondary font-weight-bolder">Client</th>
|
class="text-xs text-uppercase text-secondary font-weight-bolder ps-0"
|
||||||
<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>
|
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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="inv in invoices" :key="inv.id">
|
<tr v-for="inv in invoices" :key="inv.id">
|
||||||
<td class="ps-0">
|
<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>
|
||||||
<td class="text-sm text-secondary">{{ inv.client_name }}</td>
|
<td class="text-sm text-secondary">{{ inv.client_name }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span
|
<span
|
||||||
class="badge badge-sm"
|
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) : '—' }}
|
{{ 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
|
||||||
|
v-if="inv.days_overdue !== null && inv.days_overdue > 0"
|
||||||
|
class="ms-1"
|
||||||
|
>+{{ inv.days_overdue }}j</span
|
||||||
|
>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</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>
|
||||||
<tr v-if="invoices.length === 0">
|
<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>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@ -47,7 +81,11 @@ import { defineComponent, PropType } from "vue";
|
|||||||
import type { CriticalInvoice } from "@/services/financialStatistics";
|
import type { CriticalInvoice } from "@/services/financialStatistics";
|
||||||
|
|
||||||
const fmt = (n: number) =>
|
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({
|
export default defineComponent({
|
||||||
name: "CriticalInvoicesCard",
|
name: "CriticalInvoicesCard",
|
||||||
@ -55,9 +93,14 @@ export default defineComponent({
|
|||||||
invoices: { type: Array as PropType<CriticalInvoice[]>, default: () => [] },
|
invoices: { type: Array as PropType<CriticalInvoice[]>, default: () => [] },
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
formatCurrency(n: number): string { return fmt(n); },
|
formatCurrency(n: number): string {
|
||||||
|
return fmt(n);
|
||||||
|
},
|
||||||
formatDate(d: string): string {
|
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="card h-100">
|
||||||
<div class="p-3 pb-2 card-header">
|
<div class="p-3 pb-2 card-header">
|
||||||
<h6 class="mb-0">Répartition géographique</h6>
|
<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>
|
||||||
<div class="p-3 card-body">
|
<div class="p-3 card-body">
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-sm align-items-center mb-0">
|
<table class="table table-sm align-items-center mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="text-xs text-uppercase text-secondary font-weight-bolder ps-3">Pays</th>
|
<th
|
||||||
<th class="text-xs text-uppercase text-secondary font-weight-bolder">Ville</th>
|
class="text-xs text-uppercase text-secondary font-weight-bolder ps-3"
|
||||||
<th class="text-xs text-uppercase text-secondary font-weight-bolder text-end pe-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
|
Clients
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -19,14 +31,18 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="(entry, index) in items" :key="index">
|
<tr v-for="(entry, index) in items" :key="index">
|
||||||
<td class="text-sm font-weight-bold ps-3">
|
<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 }}
|
{{ entry.billing_country_code }}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-sm text-secondary">
|
<td class="text-sm text-secondary">
|
||||||
{{ entry.billing_city || '—' }}
|
{{ entry.billing_city || "—" }}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-sm text-end pe-3">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="items.length === 0">
|
<tr v-if="items.length === 0">
|
||||||
|
|||||||
@ -4,8 +4,12 @@
|
|||||||
<p class="mb-0 text-sm text-capitalize font-weight-bold">{{ title }}</p>
|
<p class="mb-0 text-sm text-capitalize font-weight-bold">{{ title }}</p>
|
||||||
<h5 class="mb-0 font-weight-bolder">
|
<h5 class="mb-0 font-weight-bolder">
|
||||||
{{ formattedTotal }}
|
{{ formattedTotal }}
|
||||||
<span v-if="trend !== null" :class="trend >= 0 ? 'text-success' : 'text-danger'" class="text-sm font-weight-bolder">
|
<span
|
||||||
{{ trend >= 0 ? '+' : '' }}{{ trend }}%
|
v-if="trend !== null"
|
||||||
|
:class="trend >= 0 ? 'text-success' : 'text-danger'"
|
||||||
|
class="text-sm font-weight-bolder"
|
||||||
|
>
|
||||||
|
{{ trend >= 0 ? "+" : "" }}{{ trend }}%
|
||||||
</span>
|
</span>
|
||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
@ -22,7 +26,20 @@ import { defineComponent, PropType, onMounted, watch } from "vue";
|
|||||||
import Chart from "chart.js/auto";
|
import Chart from "chart.js/auto";
|
||||||
import type { MonthlyRevenue } from "@/services/financialStatistics";
|
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({
|
export default defineComponent({
|
||||||
name: "RevenueChartCard",
|
name: "RevenueChartCard",
|
||||||
@ -35,7 +52,11 @@ export default defineComponent({
|
|||||||
computed: {
|
computed: {
|
||||||
formattedTotal(): string {
|
formattedTotal(): string {
|
||||||
const total = this.monthly.reduce((s, m) => s + m.total_ttc, 0);
|
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() {
|
mounted() {
|
||||||
@ -43,7 +64,9 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
renderChart() {
|
renderChart() {
|
||||||
const canvas = document.getElementById(this.chartId) as HTMLCanvasElement | null;
|
const canvas = document.getElementById(
|
||||||
|
this.chartId
|
||||||
|
) as HTMLCanvasElement | null;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
@ -53,7 +76,9 @@ export default defineComponent({
|
|||||||
|
|
||||||
// Build full 12-month array
|
// Build full 12-month array
|
||||||
const dataMap: Record<number, number> = {};
|
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);
|
const data = Array.from({ length: 12 }, (_, i) => dataMap[i + 1] ?? 0);
|
||||||
|
|
||||||
new Chart(ctx, {
|
new Chart(ctx, {
|
||||||
@ -82,7 +107,9 @@ export default defineComponent({
|
|||||||
ticks: {
|
ticks: {
|
||||||
color: "#9ca2b7",
|
color: "#9ca2b7",
|
||||||
callback: (v: number | string) =>
|
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: {
|
x: {
|
||||||
|
|||||||
@ -2,12 +2,16 @@
|
|||||||
<div class="overflow-hidden card">
|
<div class="overflow-hidden card">
|
||||||
<div class="p-3 pb-0 card-header">
|
<div class="p-3 pb-0 card-header">
|
||||||
<h6 class="mb-0 font-weight-bolder">Top produits par rotation</h6>
|
<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>
|
||||||
<div class="p-0 card-body">
|
<div class="p-0 card-body">
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-sm table-hover">
|
<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>
|
<tr>
|
||||||
<th>Produit</th>
|
<th>Produit</th>
|
||||||
<th>Stock</th>
|
<th>Stock</th>
|
||||||
@ -18,17 +22,24 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="item in items" :key="item.product_id">
|
<tr v-for="item in items" :key="item.product_id">
|
||||||
<td class="ps-2">
|
<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>
|
||||||
<td>
|
<td>
|
||||||
<p class="text-sm mb-0">{{ formatNumber(item.qty_on_hand) }}</p>
|
<p class="text-sm mb-0">{{ formatNumber(item.qty_on_hand) }}</p>
|
||||||
</td>
|
</td>
|
||||||
<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>
|
||||||
<td>
|
<td>
|
||||||
<div class="d-flex align-items-center">
|
<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
|
{{ formatRotationRate(item.rotation_rate) }}x
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="badge badge-sm bg-secondary">N/A</span>
|
<span v-else class="badge badge-sm bg-secondary">N/A</span>
|
||||||
@ -36,7 +47,9 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="!items.length">
|
<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>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@ -46,11 +59,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, PropType } from 'vue';
|
import { defineComponent, PropType } from "vue";
|
||||||
import type { ProductRotation } from '@/services/stockStatistics';
|
import type { ProductRotation } from "@/services/stockStatistics";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'RotationCard',
|
name: "RotationCard",
|
||||||
props: {
|
props: {
|
||||||
items: {
|
items: {
|
||||||
type: Array as PropType<ProductRotation[]>,
|
type: Array as PropType<ProductRotation[]>,
|
||||||
|
|||||||
@ -2,16 +2,28 @@
|
|||||||
<div class="card h-100">
|
<div class="card h-100">
|
||||||
<div class="p-3 pb-2 card-header">
|
<div class="p-3 pb-2 card-header">
|
||||||
<h6 class="mb-0">Top 10 — Grands comptes</h6>
|
<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>
|
||||||
<div class="p-3 card-body">
|
<div class="p-3 card-body">
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-sm align-items-center mb-0">
|
<table class="table table-sm align-items-center mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="text-xs text-uppercase text-secondary font-weight-bolder ps-3">#</th>
|
<th
|
||||||
<th class="text-xs text-uppercase text-secondary font-weight-bolder">Client</th>
|
class="text-xs text-uppercase text-secondary font-weight-bolder ps-3"
|
||||||
<th class="text-xs text-uppercase text-secondary font-weight-bolder text-end pe-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
|
Dossiers
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="card h-100">
|
<div class="card h-100">
|
||||||
<div class="p-3 pb-2 card-header">
|
<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>
|
<h5 class="mb-0 font-weight-bolder">{{ formattedTotal }}</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-3 card-body">
|
<div class="p-3 card-body">
|
||||||
@ -9,15 +11,29 @@
|
|||||||
<table class="table table-sm align-items-center mb-0">
|
<table class="table table-sm align-items-center mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="text-xs text-uppercase text-secondary font-weight-bolder ps-0">Client</th>
|
<th
|
||||||
<th class="text-xs text-uppercase text-secondary font-weight-bolder">Factures</th>
|
class="text-xs text-uppercase text-secondary font-weight-bolder ps-0"
|
||||||
<th class="text-xs text-uppercase text-secondary font-weight-bolder text-end pe-0">Encours</th>
|
>
|
||||||
|
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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="debtor in debtors" :key="debtor.id">
|
<tr v-for="debtor in debtors" :key="debtor.id">
|
||||||
<td class="ps-0">
|
<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>
|
||||||
<td class="text-sm text-secondary">{{ debtor.invoice_count }}</td>
|
<td class="text-sm text-secondary">{{ debtor.invoice_count }}</td>
|
||||||
<td class="text-sm text-end pe-0 font-weight-bold">
|
<td class="text-sm text-end pe-0 font-weight-bold">
|
||||||
@ -25,7 +41,9 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="debtors.length === 0">
|
<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>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@ -39,7 +57,11 @@ import { defineComponent, PropType } from "vue";
|
|||||||
import type { TopDebtor } from "@/services/financialStatistics";
|
import type { TopDebtor } from "@/services/financialStatistics";
|
||||||
|
|
||||||
const fmt = (n: number) =>
|
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({
|
export default defineComponent({
|
||||||
name: "TopDebtorsCard",
|
name: "TopDebtorsCard",
|
||||||
@ -48,10 +70,14 @@ export default defineComponent({
|
|||||||
totalOutstanding: { type: Number, default: 0 },
|
totalOutstanding: { type: Number, default: 0 },
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
formattedTotal(): string { return fmt(this.totalOutstanding); },
|
formattedTotal(): string {
|
||||||
|
return fmt(this.totalOutstanding);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
formatCurrency(n: number): string { return fmt(n); },
|
formatCurrency(n: number): string {
|
||||||
|
return fmt(n);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -7,7 +7,9 @@
|
|||||||
<div class="p-0 card-body">
|
<div class="p-0 card-body">
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-sm table-hover">
|
<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>
|
<tr>
|
||||||
<th>Entrepôt</th>
|
<th>Entrepôt</th>
|
||||||
<th>Entrées</th>
|
<th>Entrées</th>
|
||||||
@ -18,22 +20,36 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="warehouse in items" :key="warehouse.warehouse_id">
|
<tr v-for="warehouse in items" :key="warehouse.warehouse_id">
|
||||||
<td class="ps-2">
|
<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>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge badge-sm bg-success me-1">{{ warehouse.incoming_moves }}</span>
|
<span class="badge badge-sm bg-success me-1">{{
|
||||||
<small class="text-secondary">{{ formatNumber(warehouse.incoming_qty) }}</small>
|
warehouse.incoming_moves
|
||||||
|
}}</span>
|
||||||
|
<small class="text-secondary">{{
|
||||||
|
formatNumber(warehouse.incoming_qty)
|
||||||
|
}}</small>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge badge-sm bg-warning me-1">{{ warehouse.outgoing_moves }}</span>
|
<span class="badge badge-sm bg-warning me-1">{{
|
||||||
<small class="text-secondary">{{ formatNumber(warehouse.outgoing_qty) }}</small>
|
warehouse.outgoing_moves
|
||||||
|
}}</span>
|
||||||
|
<small class="text-secondary">{{
|
||||||
|
formatNumber(warehouse.outgoing_qty)
|
||||||
|
}}</small>
|
||||||
</td>
|
</td>
|
||||||
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="!items.length">
|
<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>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@ -43,11 +59,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, PropType } from 'vue';
|
import { defineComponent, PropType } from "vue";
|
||||||
import type { WarehouseMovement } from '@/services/stockStatistics';
|
import type { WarehouseMovement } from "@/services/stockStatistics";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'WarehouseMovementCard',
|
name: "WarehouseMovementCard",
|
||||||
props: {
|
props: {
|
||||||
items: {
|
items: {
|
||||||
type: Array as PropType<WarehouseMovement[]>,
|
type: Array as PropType<WarehouseMovement[]>,
|
||||||
|
|||||||
@ -7,7 +7,9 @@
|
|||||||
<div class="p-0 card-body">
|
<div class="p-0 card-body">
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-sm table-hover">
|
<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>
|
<tr>
|
||||||
<th>Entrepôt</th>
|
<th>Entrepôt</th>
|
||||||
<th>Quantité</th>
|
<th>Quantité</th>
|
||||||
@ -18,23 +20,34 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="warehouse in items" :key="warehouse.warehouse_id">
|
<tr v-for="warehouse in items" :key="warehouse.warehouse_id">
|
||||||
<td class="ps-2">
|
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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 }}
|
{{ warehouse.alert_count }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="badge badge-sm bg-success">✓</span>
|
<span v-else class="badge badge-sm bg-success">✓</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="!items.length">
|
<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>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@ -44,11 +57,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, PropType } from 'vue';
|
import { defineComponent, PropType } from "vue";
|
||||||
import type { StockByWarehouse } from '@/services/stockStatistics';
|
import type { StockByWarehouse } from "@/services/stockStatistics";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'WarehouseStockCard',
|
name: "WarehouseStockCard",
|
||||||
props: {
|
props: {
|
||||||
items: {
|
items: {
|
||||||
type: Array as PropType<StockByWarehouse[]>,
|
type: Array as PropType<StockByWarehouse[]>,
|
||||||
@ -60,10 +73,10 @@ export default defineComponent({
|
|||||||
return Math.round(n * 100) / 100;
|
return Math.round(n * 100) / 100;
|
||||||
},
|
},
|
||||||
formatCurrency(n: number) {
|
formatCurrency(n: number) {
|
||||||
return new Intl.NumberFormat('fr-FR', {
|
return new Intl.NumberFormat("fr-FR", {
|
||||||
style: 'currency',
|
style: "currency",
|
||||||
currency: 'EUR',
|
currency: "EUR",
|
||||||
notation: 'compact',
|
notation: "compact",
|
||||||
maximumFractionDigits: 1,
|
maximumFractionDigits: 1,
|
||||||
}).format(n);
|
}).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";
|
import GeographicCard from "@/components/Molecule/Stats/GeographicCard.vue";
|
||||||
|
|
||||||
// Shared x-axis labels for sparklines
|
// 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({
|
export default defineComponent({
|
||||||
name: "ClientStatsDashboard",
|
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);
|
box-shadow: 0 0 2rem 0 rgba(136, 152, 170, 0.15);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@ -70,7 +70,9 @@
|
|||||||
<!-- ── Row 3: Critical invoices + Top debtors ───────────────────────── -->
|
<!-- ── Row 3: Critical invoices + Top debtors ───────────────────────── -->
|
||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
<div class="col-xl-7">
|
<div class="col-xl-7">
|
||||||
<critical-invoices-card :invoices="stats.receivables.critical_invoices" />
|
<critical-invoices-card
|
||||||
|
:invoices="stats.receivables.critical_invoices"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-xl-5">
|
<div class="col-xl-5">
|
||||||
<top-debtors-card
|
<top-debtors-card
|
||||||
@ -93,7 +95,20 @@ import AvoirsCard from "@/components/Molecule/Stats/AvoirsCard.vue";
|
|||||||
import CriticalInvoicesCard from "@/components/Molecule/Stats/CriticalInvoicesCard.vue";
|
import CriticalInvoicesCard from "@/components/Molecule/Stats/CriticalInvoicesCard.vue";
|
||||||
import TopDebtorsCard from "@/components/Molecule/Stats/TopDebtorsCard.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) =>
|
const fmtCurrency = (n: number | null) =>
|
||||||
n === null
|
n === null
|
||||||
@ -145,10 +160,17 @@ export default defineComponent({
|
|||||||
// ── Sparklines ────────────────────────────────────────────────────────
|
// ── Sparklines ────────────────────────────────────────────────────────
|
||||||
const kpiChartCA = computed(() => {
|
const kpiChartCA = computed(() => {
|
||||||
const dataMap: Record<number, number> = {};
|
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 {
|
return {
|
||||||
labels: MONTHS,
|
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) =>
|
const data = Array.from({ length: 9 }, (_, i) =>
|
||||||
Math.max(0, Math.round(rate * (0.8 + (i / 8) * 0.2)))
|
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(() => {
|
const kpiChartPanier = computed(() => {
|
||||||
@ -165,7 +190,10 @@ export default defineComponent({
|
|||||||
const data = Array.from({ length: 9 }, (_, i) =>
|
const data = Array.from({ length: 9 }, (_, i) =>
|
||||||
Math.round(avg * (0.85 + (i / 8) * 0.2))
|
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 {
|
return {
|
||||||
|
|||||||
@ -50,9 +50,7 @@
|
|||||||
:sync-label="
|
:sync-label="
|
||||||
syncing ? 'Synchronisation...' : 'Synchroniser la boîte'
|
syncing ? 'Synchronisation...' : 'Synchroniser la boîte'
|
||||||
"
|
"
|
||||||
:smtp-test-label="
|
:smtp-test-label="smtpTesting ? 'Test SMTP...' : 'Tester le SMTP'"
|
||||||
smtpTesting ? 'Test SMTP...' : 'Tester le SMTP'
|
|
||||||
"
|
|
||||||
@update:field="$emit('update:field', $event)"
|
@update:field="$emit('update:field', $event)"
|
||||||
@submit="$emit('submit-settings')"
|
@submit="$emit('submit-settings')"
|
||||||
@sync="$emit('sync-mailbox')"
|
@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(() => {
|
const lastSyncedLabel = computed(() => {
|
||||||
if (!props.settings?.last_synced_at) {
|
if (!props.settings?.last_synced_at) {
|
||||||
|
|||||||
@ -53,16 +53,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, PropType, computed } from 'vue';
|
import { defineComponent, PropType, computed } from "vue";
|
||||||
import type { StockStatistics } from '@/services/stockStatistics';
|
import type { StockStatistics } from "@/services/stockStatistics";
|
||||||
|
|
||||||
import StockKpiCard from '@/components/Atom/Stats/StockKpiCard.vue';
|
import StockKpiCard from "@/components/Atom/Stats/StockKpiCard.vue";
|
||||||
import RotationCard from '@/components/Molecule/Stats/RotationCard.vue';
|
import RotationCard from "@/components/Molecule/Stats/RotationCard.vue";
|
||||||
import WarehouseMovementCard from '@/components/Molecule/Stats/WarehouseMovementCard.vue';
|
import WarehouseMovementCard from "@/components/Molecule/Stats/WarehouseMovementCard.vue";
|
||||||
import WarehouseStockCard from '@/components/Molecule/Stats/WarehouseStockCard.vue';
|
import WarehouseStockCard from "@/components/Molecule/Stats/WarehouseStockCard.vue";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'StockStatsDashboard',
|
name: "StockStatsDashboard",
|
||||||
components: {
|
components: {
|
||||||
StockKpiCard,
|
StockKpiCard,
|
||||||
RotationCard,
|
RotationCard,
|
||||||
@ -77,10 +77,10 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const stockValueLabel = computed(() => {
|
const stockValueLabel = computed(() => {
|
||||||
return new Intl.NumberFormat('fr-FR', {
|
return new Intl.NumberFormat("fr-FR", {
|
||||||
style: 'currency',
|
style: "currency",
|
||||||
currency: 'EUR',
|
currency: "EUR",
|
||||||
notation: 'compact',
|
notation: "compact",
|
||||||
maximumFractionDigits: 1,
|
maximumFractionDigits: 1,
|
||||||
}).format(props.stats.total_value);
|
}).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) => {
|
const updateField = (field, value) => {
|
||||||
emit("update:field", { 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",
|
name: "Gestion sous-traitants",
|
||||||
component: () => import("@/views/pages/SousTraitants/SousTraitants.vue"),
|
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",
|
path: "/sous-traitants/contacts",
|
||||||
name: "Contacts sous-traitants",
|
name: "Contacts sous-traitants",
|
||||||
@ -545,6 +550,12 @@ const routes = [
|
|||||||
name: "Statistiques sous-traitants",
|
name: "Statistiques sous-traitants",
|
||||||
component: () => import("@/views/pages/SousTraitants/Statistiques.vue"),
|
component: () => import("@/views/pages/SousTraitants/Statistiques.vue"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/sous-traitants/:id",
|
||||||
|
name: "Sous-traitant details",
|
||||||
|
component: () =>
|
||||||
|
import("@/views/pages/SousTraitants/SousTraitantDetails.vue"),
|
||||||
|
},
|
||||||
// Ventes
|
// Ventes
|
||||||
{
|
{
|
||||||
path: "/ventes/devis",
|
path: "/ventes/devis",
|
||||||
|
|||||||
@ -147,16 +147,16 @@ export const LeaveService = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async deleteLeave(
|
async deleteLeave(id: number): Promise<{ message: string; status?: string }> {
|
||||||
id: number
|
|
||||||
): Promise<{ message: string; status?: string }> {
|
|
||||||
return request<{ message: string; status?: string }>({
|
return request<{ message: string; status?: string }>({
|
||||||
url: `/api/leaves/${id}`,
|
url: `/api/leaves/${id}`,
|
||||||
method: "delete",
|
method: "delete",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
transformLeavePayload(payload: Partial<CreateLeavePayload>): Record<string, unknown> {
|
transformLeavePayload(
|
||||||
|
payload: Partial<CreateLeavePayload>
|
||||||
|
): Record<string, unknown> {
|
||||||
const transformed: Record<string, unknown> = { ...payload };
|
const transformed: Record<string, unknown> = { ...payload };
|
||||||
|
|
||||||
Object.keys(transformed).forEach((key) => {
|
Object.keys(transformed).forEach((key) => {
|
||||||
@ -171,4 +171,4 @@ export const LeaveService = {
|
|||||||
unwrapLeave,
|
unwrapLeave,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default LeaveService;
|
export default LeaveService;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import axios from 'axios';
|
import axios from "axios";
|
||||||
|
|
||||||
export interface ProductRotation {
|
export interface ProductRotation {
|
||||||
product_id: number;
|
product_id: number;
|
||||||
@ -41,8 +41,8 @@ export interface StockStatisticsResponse {
|
|||||||
data: StockStatistics;
|
data: StockStatistics;
|
||||||
}
|
}
|
||||||
|
|
||||||
const API_BASE = process.env.VUE_APP_API_BASE_URL || '';
|
const API_BASE = process.env.VUE_APP_API_BASE_URL || "";
|
||||||
const TOKEN = localStorage.getItem('auth_token');
|
const TOKEN = localStorage.getItem("auth_token");
|
||||||
|
|
||||||
export const StockStatisticsService = {
|
export const StockStatisticsService = {
|
||||||
async getStatistics(): Promise<StockStatistics> {
|
async getStatistics(): Promise<StockStatistics> {
|
||||||
@ -59,7 +59,7 @@ export const StockStatisticsService = {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
error.response?.data?.message ||
|
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 ClientStatisticsService from "@/services/clientStatistics";
|
||||||
import type { ClientStatistics } from "@/services/clientStatistics";
|
import type { ClientStatistics } from "@/services/clientStatistics";
|
||||||
|
|
||||||
export const useClientStatisticsStore = defineStore(
|
export const useClientStatisticsStore = defineStore("clientStatistics", () => {
|
||||||
"clientStatistics",
|
// ─── State ─────────────────────────────────────────────────────────────
|
||||||
() => {
|
const statistics = ref<ClientStatistics | null>(null);
|
||||||
// ─── State ─────────────────────────────────────────────────────────────
|
const loading = ref(false);
|
||||||
const statistics = ref<ClientStatistics | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const loading = ref(false);
|
|
||||||
const error = ref<string | null>(null);
|
|
||||||
|
|
||||||
// ─── Getters ───────────────────────────────────────────────────────────
|
// ─── Getters ───────────────────────────────────────────────────────────
|
||||||
const isLoading = computed(() => loading.value);
|
const isLoading = computed(() => loading.value);
|
||||||
const hasError = computed(() => error.value !== null);
|
const hasError = computed(() => error.value !== null);
|
||||||
const getError = computed(() => error.value);
|
const getError = computed(() => error.value);
|
||||||
const hasData = computed(() => statistics.value !== null);
|
const hasData = computed(() => statistics.value !== null);
|
||||||
|
|
||||||
// ─── Actions ───────────────────────────────────────────────────────────
|
// ─── Actions ───────────────────────────────────────────────────────────
|
||||||
async function fetchStatistics(): Promise<void> {
|
async function fetchStatistics(): Promise<void> {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
try {
|
try {
|
||||||
statistics.value = await ClientStatisticsService.getStatistics();
|
statistics.value = await ClientStatisticsService.getStatistics();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
error.value =
|
error.value =
|
||||||
err instanceof Error
|
err instanceof Error
|
||||||
? err.message
|
? err.message
|
||||||
: "Erreur lors du chargement des statistiques.";
|
: "Erreur lors du chargement des statistiques.";
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
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 response = await LeaveService.updateLeave(payload);
|
||||||
const updatedLeave = LeaveService.unwrapLeave(response);
|
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) {
|
if (index !== -1) {
|
||||||
leaves.value[index] = updatedLeave;
|
leaves.value[index] = updatedLeave;
|
||||||
}
|
}
|
||||||
@ -309,4 +311,4 @@ export const useLeaveStore = defineStore("leave", () => {
|
|||||||
clearCurrentLeave,
|
clearCurrentLeave,
|
||||||
clearStore,
|
clearStore,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@ -143,8 +143,9 @@ export const useSousTraitantStore = defineStore("sousTraitant", () => {
|
|||||||
sort_direction?: "asc" | "desc";
|
sort_direction?: "asc" | "desc";
|
||||||
};
|
};
|
||||||
|
|
||||||
const response =
|
const response = await SousTraitantService.getAllSousTraitants(
|
||||||
await SousTraitantService.getAllSousTraitants(requestParams);
|
requestParams
|
||||||
|
);
|
||||||
setSousTraitants(response.data);
|
setSousTraitants(response.data);
|
||||||
if (response.meta) {
|
if (response.meta) {
|
||||||
setPagination(response.meta);
|
setPagination(response.meta);
|
||||||
@ -245,7 +246,9 @@ export const useSousTraitantStore = defineStore("sousTraitant", () => {
|
|||||||
try {
|
try {
|
||||||
const response = await SousTraitantService.deleteSousTraitant(id);
|
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) {
|
if (currentSousTraitant.value && currentSousTraitant.value.id === id) {
|
||||||
setCurrentSousTraitant(null);
|
setCurrentSousTraitant(null);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from "pinia";
|
||||||
import { StockStatisticsService } from '@/services/stockStatistics';
|
import { StockStatisticsService } from "@/services/stockStatistics";
|
||||||
import type { StockStatistics } from '@/services/stockStatistics';
|
import type { StockStatistics } from "@/services/stockStatistics";
|
||||||
|
|
||||||
interface StockStatisticsState {
|
interface StockStatisticsState {
|
||||||
statistics: StockStatistics | null;
|
statistics: StockStatistics | null;
|
||||||
@ -8,7 +8,7 @@ interface StockStatisticsState {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useStockStatisticsStore = defineStore('stockStatistics', {
|
export const useStockStatisticsStore = defineStore("stockStatistics", {
|
||||||
state: (): StockStatisticsState => ({
|
state: (): StockStatisticsState => ({
|
||||||
statistics: null,
|
statistics: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
@ -18,7 +18,7 @@ export const useStockStatisticsStore = defineStore('stockStatistics', {
|
|||||||
getters: {
|
getters: {
|
||||||
isLoading: (state) => state.loading,
|
isLoading: (state) => state.loading,
|
||||||
hasError: (state) => state.error !== null,
|
hasError: (state) => state.error !== null,
|
||||||
getError: (state) => state.error || '',
|
getError: (state) => state.error || "",
|
||||||
hasData: (state) => state.statistics !== null,
|
hasData: (state) => state.statistics !== null,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -79,4 +79,3 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -193,7 +193,8 @@ const testSmtp = async () => {
|
|||||||
} catch {
|
} catch {
|
||||||
notificationStore.error(
|
notificationStore.error(
|
||||||
"Erreur de test SMTP",
|
"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(() =>
|
const collaborators = computed(() =>
|
||||||
practitioners.value.map((p) => ({
|
practitioners.value
|
||||||
id: p.employee_id || p.employee?.id,
|
.map((p) => ({
|
||||||
name:
|
id: p.employee_id || p.employee?.id,
|
||||||
`${p.employee?.first_name || ""} ${p.employee?.last_name || ""}`.trim() ||
|
name:
|
||||||
`Collaborateur #${p.id}`,
|
`${p.employee?.first_name || ""} ${
|
||||||
}))
|
p.employee?.last_name || ""
|
||||||
|
}`.trim() || `Collaborateur #${p.id}`,
|
||||||
|
}))
|
||||||
.filter((collaborator) => Boolean(collaborator.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 = () => {
|
const resetLeaveForm = () => {
|
||||||
leaveForm.value = {
|
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>
|
<template>
|
||||||
<div>
|
<sous-traitant-presentation
|
||||||
<h1>Sous-Traitants</h1>
|
:sous-traitant-data="sousTraitantStore.sousTraitants"
|
||||||
</div>
|
:loading-data="sousTraitantStore.loading"
|
||||||
|
:pagination="sousTraitantStore.getPagination"
|
||||||
|
@push-details="goDetails"
|
||||||
|
@delete-sous-traitant="handleDeleteSousTraitant"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
export default {
|
import { onMounted } from "vue";
|
||||||
name: "SousTraitants",
|
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>
|
</script>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user