feat(convoys): add convoys feature with UI, routes, store, services
Introduces convoys management pages, including list and add forms, along with corresponding Vuex store and API service.
This commit is contained in:
parent
284d228dc5
commit
3e6ac4055c
128
.opencode/package-lock.json
generated
Normal file
128
.opencode/package-lock.json
generated
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
{
|
||||||
|
"name": ".opencode",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"dependencies": {
|
||||||
|
"@kilocode/plugin": "7.2.10",
|
||||||
|
"@opencode-ai/plugin": "1.1.31"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@kilocode/plugin": {
|
||||||
|
"version": "7.2.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@kilocode/plugin/-/plugin-7.2.10.tgz",
|
||||||
|
"integrity": "sha512-VJPhJC+E5WWu7XgEJzrVOxKJlwJ+OATwxEzgjqEPj8KN5N38YxUPBY/rzUTjv90x7nkzyk1rFGfCVqXdA/Koug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@kilocode/sdk": "7.2.10",
|
||||||
|
"zod": "4.1.8"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@opentui/core": ">=0.1.97",
|
||||||
|
"@opentui/solid": ">=0.1.97"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@opentui/core": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@opentui/solid": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@kilocode/sdk": {
|
||||||
|
"version": "7.2.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@kilocode/sdk/-/sdk-7.2.10.tgz",
|
||||||
|
"integrity": "sha512-H6jGXYAhN/yjOGX3MRZ0OxyEAuRGY3VOwDbLTh4O6ljpgutFHaLvomDZ82qNVy7gl7AjJgi3SAQAt9UQpeGl/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cross-spawn": "7.0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@opencode-ai/plugin": {
|
||||||
|
"version": "1.1.31",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@opencode-ai/sdk": "1.1.31",
|
||||||
|
"zod": "4.1.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@opencode-ai/sdk": {
|
||||||
|
"version": "1.1.31",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/cross-spawn": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"path-key": "^3.1.0",
|
||||||
|
"shebang-command": "^2.0.0",
|
||||||
|
"which": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/isexe": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/path-key": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/shebang-command": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"shebang-regex": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/shebang-regex": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/which": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"isexe": "^2.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"node-which": "bin/node-which"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zod": {
|
||||||
|
"version": "4.1.8",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
<template>
|
||||||
|
<new-convoy-template>
|
||||||
|
<template #multi-step></template>
|
||||||
|
<template #convoy-form>
|
||||||
|
<new-convoy-form :loading="loading" @create-convoy="$emit('create-convoy', $event)" />
|
||||||
|
</template>
|
||||||
|
</new-convoy-template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { defineProps, defineEmits } from "vue";
|
||||||
|
import NewConvoyTemplate from "@/components/templates/Convoys/NewConvoyTemplate.vue";
|
||||||
|
import NewConvoyForm from "@/components/molecules/form/NewConvoyForm.vue";
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
loading: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(["createConvoy"]);
|
||||||
|
</script>
|
||||||
@ -0,0 +1,107 @@
|
|||||||
|
<template>
|
||||||
|
<convoy-template>
|
||||||
|
<template #convoy-new-action>
|
||||||
|
<soft-button color="success" variant="gradient" @click="goToCreate">
|
||||||
|
<i class="fas fa-plus me-2"></i> Ajouter convoi
|
||||||
|
</soft-button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #header-pagination>
|
||||||
|
<div v-if="pagination && pagination.last_page > 1" class="d-flex justify-content-center">
|
||||||
|
<soft-pagination color="success" size="sm">
|
||||||
|
<soft-pagination-item prev :disabled="pagination.current_page <= 1" @click="changePage(pagination.current_page - 1)" />
|
||||||
|
<soft-pagination-item
|
||||||
|
v-for="page in visiblePages"
|
||||||
|
:key="page"
|
||||||
|
:label="page.toString()"
|
||||||
|
:active="pagination.current_page === page"
|
||||||
|
@click="typeof page === 'number' && changePage(page)"
|
||||||
|
/>
|
||||||
|
<soft-pagination-item next :disabled="pagination.current_page >= pagination.last_page" @click="changePage(pagination.current_page + 1)" />
|
||||||
|
</soft-pagination>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #select-filter>
|
||||||
|
<soft-button color="dark" variant="outline" class="dropdown-toggle" data-bs-toggle="dropdown">
|
||||||
|
<i class="fas fa-filter me-2"></i> Filtrer
|
||||||
|
</soft-button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end px-2 py-3">
|
||||||
|
<li><button class="dropdown-item border-radius-md" @click="$emit('filter-status', 'planned')">Planifiés</button></li>
|
||||||
|
<li><button class="dropdown-item border-radius-md" @click="$emit('filter-status', 'in_progress')">En cours</button></li>
|
||||||
|
<li><button class="dropdown-item border-radius-md" @click="$emit('filter-status', 'completed')">Terminés</button></li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #convoy-list>
|
||||||
|
<div v-if="loading" class="text-center py-5">
|
||||||
|
<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Chargement...</span></div>
|
||||||
|
<p class="mt-2">Chargement des convois...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="error" class="alert alert-danger text-center py-4">
|
||||||
|
<p>{{ error }}</p>
|
||||||
|
<button class="btn btn-outline-danger" @click="$emit('retry')">Réessayer</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!convoys.length" class="card border-0 shadow-sm text-center py-5">
|
||||||
|
<div class="card-body">
|
||||||
|
<i class="fas fa-road fa-3x text-secondary mb-3"></i>
|
||||||
|
<h5>Aucun convoi trouvé</h5>
|
||||||
|
<p class="text-sm text-secondary mb-0">Créez votre premier convoi pour commencer.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="row g-4">
|
||||||
|
<div v-for="convoy in convoys" :key="convoy.id" class="col-12 col-md-6 col-xl-4">
|
||||||
|
<convoy-event-card :convoy="convoy" @view="$emit('view', $event)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</convoy-template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, defineProps, defineEmits } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import ConvoyTemplate from "@/components/templates/Convoys/ConvoyTemplate.vue";
|
||||||
|
import ConvoyEventCard from "@/components/molecules/Convoy/ConvoyEventCard.vue";
|
||||||
|
import SoftButton from "@/components/SoftButton.vue";
|
||||||
|
import SoftPagination from "@/components/SoftPagination.vue";
|
||||||
|
import SoftPaginationItem from "@/components/SoftPaginationItem.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
convoys: { type: Array, default: () => [] },
|
||||||
|
loading: { type: Boolean, default: false },
|
||||||
|
error: { type: String, default: null },
|
||||||
|
pagination: { type: Object, default: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const goToCreate = () => router.push({ name: "Ajouter convoi" });
|
||||||
|
|
||||||
|
const changePage = (page) => {
|
||||||
|
if (typeof page !== "number") return;
|
||||||
|
if (page < 1 || (props.pagination && page > props.pagination.last_page)) return;
|
||||||
|
if (page === props.pagination.current_page) return;
|
||||||
|
emit("page-change", page);
|
||||||
|
};
|
||||||
|
|
||||||
|
const emit = defineEmits(["retry", "page-change", "view", "filter-status"]);
|
||||||
|
|
||||||
|
const visiblePages = computed(() => {
|
||||||
|
if (!props.pagination) return [];
|
||||||
|
const total = props.pagination.last_page;
|
||||||
|
const current = props.pagination.current_page;
|
||||||
|
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
|
||||||
|
const pages = [1];
|
||||||
|
if (current > 3) pages.push("...");
|
||||||
|
const start = Math.max(2, current - 1);
|
||||||
|
const end = Math.min(total - 1, current + 1);
|
||||||
|
for (let i = start; i <= end; i++) pages.push(i);
|
||||||
|
if (current < total - 2) pages.push("...");
|
||||||
|
pages.push(total);
|
||||||
|
return pages;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@ -0,0 +1,105 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card h-100 convoy-card border-0 shadow-sm">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-uppercase text-muted mb-1">Convoi</p>
|
||||||
|
<h5 class="mb-1">{{ convoy.mission_title || defaultTitle }}</h5>
|
||||||
|
<p class="text-sm text-secondary mb-0">
|
||||||
|
{{ deceasedName }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge" :class="statusClass">{{ statusLabel }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="convoy-card__meta">
|
||||||
|
<div class="convoy-card__meta-item">
|
||||||
|
<i class="fas fa-calendar-alt text-info"></i>
|
||||||
|
<span>{{ formattedDate }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="convoy-card__meta-item">
|
||||||
|
<i class="fas fa-route text-info"></i>
|
||||||
|
<span>{{ transportLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="convoy-card__meta-item">
|
||||||
|
<i class="fas fa-map-marker-alt text-info"></i>
|
||||||
|
<span>{{ departureLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="convoy-card__meta-item">
|
||||||
|
<i class="fas fa-truck text-info"></i>
|
||||||
|
<span>{{ vehicleLabel }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-4 pt-2 border-top">
|
||||||
|
<div class="d-flex gap-2 text-xs text-secondary">
|
||||||
|
<span>{{ convoyTypeLabel }}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{{ notificationLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<soft-button color="info" variant="outline" size="sm" @click="$emit('view', convoy.id)">
|
||||||
|
Voir
|
||||||
|
</soft-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { defineEmits, defineProps } from "vue";
|
||||||
|
import { computed } from "vue";
|
||||||
|
import SoftButton from "@/components/SoftButton.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
convoy: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(["view"]);
|
||||||
|
|
||||||
|
const deceasedName = computed(() => {
|
||||||
|
const deceased = props.convoy.deceased;
|
||||||
|
if (!deceased) return "Défunt non renseigné";
|
||||||
|
return deceased.full_name || [deceased.first_name, deceased.last_name].filter(Boolean).join(" ") || "Défunt";
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultTitle = computed(() => `Convoi #${props.convoy.id}`);
|
||||||
|
|
||||||
|
const formattedDate = computed(() => {
|
||||||
|
return new Date(props.convoy.planned_start_at).toLocaleDateString("fr-FR", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const transportLabel = computed(() => ({ road: "Route", air: "Aérien", sea: "Maritime", rail: "Ferroviaire" }[props.convoy.transport_mode] || props.convoy.transport_mode));
|
||||||
|
const convoyTypeLabel = computed(() => ({ local: "Local", national: "National", international: "International" }[props.convoy.convoy_type] || props.convoy.convoy_type));
|
||||||
|
const statusLabel = computed(() => ({ planned: "Planifié", in_progress: "En cours", completed: "Terminé", cancelled: "Annulé" }[props.convoy.status] || props.convoy.status));
|
||||||
|
const statusClass = computed(() => ({ planned: "bg-gradient-secondary", in_progress: "bg-gradient-info", completed: "bg-gradient-success", cancelled: "bg-gradient-danger" }[props.convoy.status] || "bg-gradient-secondary"));
|
||||||
|
const departureLabel = computed(() => props.convoy.departure?.city || props.convoy.departure?.name || "Départ non défini");
|
||||||
|
const vehicleLabel = computed(() => props.convoy.vehicle ? `${props.convoy.vehicle.brand} ${props.convoy.vehicle.model}` : "Aucun véhicule");
|
||||||
|
const notificationLabel = computed(() => props.convoy.automatic_notifications ? "Notifications actives" : "Notifications inactives");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.convoy-card {
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #f9fbff 100%);
|
||||||
|
}
|
||||||
|
.convoy-card__meta {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.convoy-card__meta-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #67748e;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
113
thanasoft-front/src/components/molecules/form/NewConvoyForm.vue
Normal file
113
thanasoft-front/src/components/molecules/form/NewConvoyForm.vue
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card p-3 border-radius-xl bg-white" data-animation="FadeIn">
|
||||||
|
<h5 class="font-weight-bolder mb-0">Nouveau convoi</h5>
|
||||||
|
<p class="mb-0 text-sm">Informations générales de la mission</p>
|
||||||
|
|
||||||
|
<div class="multisteps-form__content">
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col-12 col-sm-6">
|
||||||
|
<label class="form-label">ID défunt <span class="text-danger">*</span></label>
|
||||||
|
<soft-input v-model="form.deceased_id" type="number" min="1" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
|
||||||
|
<label class="form-label">Titre de mission</label>
|
||||||
|
<soft-input v-model="form.mission_title" type="text" placeholder="Ex. Convoi Mme DUPONT" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col-12 col-sm-6">
|
||||||
|
<label class="form-label">Type de convoi</label>
|
||||||
|
<select v-model="form.convoy_type" class="form-control">
|
||||||
|
<option value="local">Local</option>
|
||||||
|
<option value="national">National</option>
|
||||||
|
<option value="international">International</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
|
||||||
|
<label class="form-label">Mode de transport</label>
|
||||||
|
<select v-model="form.transport_mode" class="form-control">
|
||||||
|
<option value="road">Route</option>
|
||||||
|
<option value="air">Aérien</option>
|
||||||
|
<option value="sea">Maritime</option>
|
||||||
|
<option value="rail">Ferroviaire</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col-12 col-sm-6">
|
||||||
|
<label class="form-label">Début prévu <span class="text-danger">*</span></label>
|
||||||
|
<soft-input v-model="form.planned_start_at" type="datetime-local" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
|
||||||
|
<label class="form-label">Fin estimée</label>
|
||||||
|
<soft-input v-model="form.estimated_end_at" type="datetime-local" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col-12 col-sm-6">
|
||||||
|
<label class="form-label">Ville de départ</label>
|
||||||
|
<soft-input v-model="form.departure_city" type="text" placeholder="Ex. Paris" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
|
||||||
|
<label class="form-label">Email famille</label>
|
||||||
|
<soft-input v-model="form.family_email" type="email" placeholder="famille@email.fr" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="button-row d-flex mt-4">
|
||||||
|
<soft-button type="button" color="secondary" variant="outline" class="me-2 mb-0" @click="resetForm">
|
||||||
|
Réinitialiser
|
||||||
|
</soft-button>
|
||||||
|
<soft-button type="button" color="dark" variant="gradient" class="ms-auto mb-0" :disabled="loading" @click="submitForm">
|
||||||
|
{{ loading ? "Création..." : "Créer le convoi" }}
|
||||||
|
</soft-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { defineEmits, defineProps } from "vue";
|
||||||
|
import { ref } from "vue";
|
||||||
|
import SoftInput from "@/components/SoftInput.vue";
|
||||||
|
import SoftButton from "@/components/SoftButton.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
loading: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["createConvoy"]);
|
||||||
|
|
||||||
|
const defaultForm = () => ({
|
||||||
|
deceased_id: "",
|
||||||
|
mission_title: "",
|
||||||
|
convoy_type: "local",
|
||||||
|
transport_mode: "road",
|
||||||
|
planned_start_at: "",
|
||||||
|
estimated_end_at: "",
|
||||||
|
departure_city: "",
|
||||||
|
family_email: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = ref(defaultForm());
|
||||||
|
|
||||||
|
const submitForm = () => {
|
||||||
|
emit("createConvoy", {
|
||||||
|
deceased_id: Number(form.value.deceased_id),
|
||||||
|
mission_title: form.value.mission_title || null,
|
||||||
|
convoy_type: form.value.convoy_type,
|
||||||
|
transport_mode: form.value.transport_mode,
|
||||||
|
planned_start_at: form.value.planned_start_at,
|
||||||
|
estimated_end_at: form.value.estimated_end_at || null,
|
||||||
|
departure_city: form.value.departure_city || null,
|
||||||
|
family_email: form.value.family_email || null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
form.value = defaultForm();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container-fluid py-4">
|
||||||
|
<div class="d-sm-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<slot name="convoy-new-action"></slot>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
<slot name="header-pagination"></slot>
|
||||||
|
<div class="dropdown d-inline">
|
||||||
|
<slot name="select-filter"></slot>
|
||||||
|
</div>
|
||||||
|
<slot name="convoy-other-action"></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<slot name="convoy-list"></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
<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-8 mx-auto my-5">
|
||||||
|
<slot name="multi-step" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 col-lg-8 m-auto">
|
||||||
|
<slot name="convoy-form" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -381,6 +381,28 @@ export default {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "convois",
|
||||||
|
type: "collapse",
|
||||||
|
text: "Convois",
|
||||||
|
icon: "Office",
|
||||||
|
collapseRef: "convoisMenu",
|
||||||
|
routeKey: "convois",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: "convois-list",
|
||||||
|
route: { name: "Liste convois" },
|
||||||
|
miniIcon: "L",
|
||||||
|
text: "Liste convois",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "convois-add",
|
||||||
|
route: { name: "Ajouter convoi" },
|
||||||
|
miniIcon: "A",
|
||||||
|
text: "Ajouter convoi",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "parametrage",
|
id: "parametrage",
|
||||||
type: "collapse",
|
type: "collapse",
|
||||||
|
|||||||
@ -406,6 +406,16 @@ const routes = [
|
|||||||
name: "Agenda",
|
name: "Agenda",
|
||||||
component: () => import("@/views/pages/Agenda.vue"),
|
component: () => import("@/views/pages/Agenda.vue"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/convois",
|
||||||
|
name: "Liste convois",
|
||||||
|
component: () => import("@/views/pages/Convoys/Convoys.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/convois/new",
|
||||||
|
name: "Ajouter convoi",
|
||||||
|
component: () => import("@/views/pages/Convoys/AddConvoy.vue"),
|
||||||
|
},
|
||||||
// Planning
|
// Planning
|
||||||
{
|
{
|
||||||
path: "/planning",
|
path: "/planning",
|
||||||
|
|||||||
148
thanasoft-front/src/services/convoy.ts
Normal file
148
thanasoft-front/src/services/convoy.ts
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import { request } from "./http";
|
||||||
|
|
||||||
|
export interface ConvoyDepartureLocation {
|
||||||
|
id: number;
|
||||||
|
client_id: number;
|
||||||
|
name: string;
|
||||||
|
address_line1: string | null;
|
||||||
|
address_line2: string | null;
|
||||||
|
postal_code: string | null;
|
||||||
|
city: string | null;
|
||||||
|
country_code: string | null;
|
||||||
|
gps_lat: string | null;
|
||||||
|
gps_lng: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConvoyDeparture {
|
||||||
|
location_selection_mode: "place" | "manual";
|
||||||
|
location_id: number | null;
|
||||||
|
location?: ConvoyDepartureLocation | null;
|
||||||
|
name: string | null;
|
||||||
|
address: string | null;
|
||||||
|
city: string | null;
|
||||||
|
postal_code: string | null;
|
||||||
|
country_code: string | null;
|
||||||
|
latitude: string | null;
|
||||||
|
longitude: string | null;
|
||||||
|
additional_details: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Convoy {
|
||||||
|
id: number;
|
||||||
|
deceased_id: number;
|
||||||
|
client_id: number | null;
|
||||||
|
vehicle_id: number | null;
|
||||||
|
mission_title: string | null;
|
||||||
|
convoy_type: "local" | "national" | "international";
|
||||||
|
transport_mode: "road" | "air" | "sea" | "rail";
|
||||||
|
status: "planned" | "in_progress" | "completed" | "cancelled";
|
||||||
|
planned_start_at: string;
|
||||||
|
estimated_end_at: string | null;
|
||||||
|
family_email: string | null;
|
||||||
|
automatic_notifications: boolean;
|
||||||
|
departure: ConvoyDeparture;
|
||||||
|
tabs: Record<string, boolean>;
|
||||||
|
deceased?: any;
|
||||||
|
client?: any;
|
||||||
|
vehicle?: any;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConvoyListResponse {
|
||||||
|
data: Convoy[];
|
||||||
|
meta: {
|
||||||
|
current_page: number;
|
||||||
|
last_page: number;
|
||||||
|
per_page: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConvoyResponse {
|
||||||
|
data: Convoy;
|
||||||
|
message?: string;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateConvoyPayload {
|
||||||
|
deceased_id: number;
|
||||||
|
client_id?: number | null;
|
||||||
|
vehicle_id?: number | null;
|
||||||
|
mission_title?: string | null;
|
||||||
|
convoy_type?: "local" | "national" | "international";
|
||||||
|
transport_mode?: "road" | "air" | "sea" | "rail";
|
||||||
|
status?: "planned" | "in_progress" | "completed" | "cancelled";
|
||||||
|
planned_start_at: string;
|
||||||
|
estimated_end_at?: string | null;
|
||||||
|
family_email?: string | null;
|
||||||
|
automatic_notifications?: boolean;
|
||||||
|
departure_location_selection_mode?: "place" | "manual";
|
||||||
|
departure_location_id?: number | null;
|
||||||
|
departure_name?: string | null;
|
||||||
|
departure_address?: string | null;
|
||||||
|
departure_city?: string | null;
|
||||||
|
departure_postal_code?: string | null;
|
||||||
|
departure_country_code?: string | null;
|
||||||
|
departure_latitude?: number | null;
|
||||||
|
departure_longitude?: number | null;
|
||||||
|
departure_additional_details?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateConvoyPayload extends Partial<CreateConvoyPayload> {
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConvoyService = {
|
||||||
|
async getAllConvoys(params?: {
|
||||||
|
page?: number;
|
||||||
|
per_page?: number;
|
||||||
|
search?: string;
|
||||||
|
status?: string;
|
||||||
|
convoy_type?: string;
|
||||||
|
vehicle_id?: number;
|
||||||
|
deceased_id?: number;
|
||||||
|
sort_by?: string;
|
||||||
|
sort_direction?: string;
|
||||||
|
}): Promise<ConvoyListResponse> {
|
||||||
|
return await request<ConvoyListResponse>({
|
||||||
|
url: "/api/convoys",
|
||||||
|
method: "get",
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async getConvoy(id: number): Promise<ConvoyResponse> {
|
||||||
|
return await request<ConvoyResponse>({
|
||||||
|
url: `/api/convoys/${id}`,
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async createConvoy(payload: CreateConvoyPayload): Promise<ConvoyResponse> {
|
||||||
|
return await request<ConvoyResponse>({
|
||||||
|
url: "/api/convoys",
|
||||||
|
method: "post",
|
||||||
|
data: payload,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateConvoy(payload: UpdateConvoyPayload): Promise<ConvoyResponse> {
|
||||||
|
const { id, ...updateData } = payload;
|
||||||
|
return await request<ConvoyResponse>({
|
||||||
|
url: `/api/convoys/${id}`,
|
||||||
|
method: "put",
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteConvoy(id: number): Promise<{ message: string; status: string }> {
|
||||||
|
return await request<{ message: string; status: string }>({
|
||||||
|
url: `/api/convoys/${id}`,
|
||||||
|
method: "delete",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConvoyService;
|
||||||
145
thanasoft-front/src/stores/convoyStore.ts
Normal file
145
thanasoft-front/src/stores/convoyStore.ts
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import ConvoyService from "@/services/convoy";
|
||||||
|
import type {
|
||||||
|
Convoy,
|
||||||
|
CreateConvoyPayload,
|
||||||
|
UpdateConvoyPayload,
|
||||||
|
} from "@/services/convoy";
|
||||||
|
|
||||||
|
export const useConvoyStore = defineStore("convoy", () => {
|
||||||
|
const convoys = ref<Convoy[]>([]);
|
||||||
|
const currentConvoy = ref<Convoy | null>(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
|
const pagination = ref({
|
||||||
|
current_page: 1,
|
||||||
|
last_page: 1,
|
||||||
|
per_page: 10,
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const allConvoys = computed(() => convoys.value);
|
||||||
|
const isLoading = computed(() => loading.value);
|
||||||
|
const hasError = computed(() => error.value !== null);
|
||||||
|
const getError = computed(() => error.value);
|
||||||
|
const getPagination = computed(() => pagination.value);
|
||||||
|
|
||||||
|
const setPagination = (meta: any) => {
|
||||||
|
if (!meta) return;
|
||||||
|
pagination.value = {
|
||||||
|
current_page: Number(meta.current_page) || 1,
|
||||||
|
last_page: Number(meta.last_page) || 1,
|
||||||
|
per_page: Number(meta.per_page) || 10,
|
||||||
|
total: Number(meta.total) || 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchConvoys = async (params?: {
|
||||||
|
page?: number;
|
||||||
|
per_page?: number;
|
||||||
|
search?: string;
|
||||||
|
status?: string;
|
||||||
|
convoy_type?: string;
|
||||||
|
vehicle_id?: number;
|
||||||
|
deceased_id?: number;
|
||||||
|
sort_by?: string;
|
||||||
|
sort_direction?: string;
|
||||||
|
}) => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const response = await ConvoyService.getAllConvoys(params);
|
||||||
|
convoys.value = response.data;
|
||||||
|
setPagination(response.meta);
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.response?.data?.message || err.message || "Failed to fetch convoys";
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchConvoy = async (id: number) => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const response = await ConvoyService.getConvoy(id);
|
||||||
|
currentConvoy.value = response.data;
|
||||||
|
return response.data;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.response?.data?.message || err.message || "Failed to fetch convoy";
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createConvoy = async (payload: CreateConvoyPayload) => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const response = await ConvoyService.createConvoy(payload);
|
||||||
|
convoys.value.unshift(response.data);
|
||||||
|
currentConvoy.value = response.data;
|
||||||
|
return response.data;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.response?.data?.message || err.message || "Failed to create convoy";
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateConvoy = async (payload: UpdateConvoyPayload) => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const response = await ConvoyService.updateConvoy(payload);
|
||||||
|
const index = convoys.value.findIndex((convoy) => convoy.id === response.data.id);
|
||||||
|
if (index !== -1) convoys.value[index] = response.data;
|
||||||
|
if (currentConvoy.value?.id === response.data.id) currentConvoy.value = response.data;
|
||||||
|
return response.data;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.response?.data?.message || err.message || "Failed to update convoy";
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteConvoy = async (id: number) => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const response = await ConvoyService.deleteConvoy(id);
|
||||||
|
convoys.value = convoys.value.filter((convoy) => convoy.id !== id);
|
||||||
|
if (currentConvoy.value?.id === id) currentConvoy.value = null;
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.response?.data?.message || err.message || "Failed to delete convoy";
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
convoys,
|
||||||
|
currentConvoy,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
allConvoys,
|
||||||
|
isLoading,
|
||||||
|
hasError,
|
||||||
|
getError,
|
||||||
|
getPagination,
|
||||||
|
fetchConvoys,
|
||||||
|
fetchConvoy,
|
||||||
|
createConvoy,
|
||||||
|
updateConvoy,
|
||||||
|
deleteConvoy,
|
||||||
|
};
|
||||||
|
});
|
||||||
25
thanasoft-front/src/views/pages/Convoys/AddConvoy.vue
Normal file
25
thanasoft-front/src/views/pages/Convoys/AddConvoy.vue
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<template>
|
||||||
|
<add-convoy-presentation :loading="convoyStore.isLoading" @create-convoy="handleCreateConvoy" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import AddConvoyPresentation from "@/components/Organism/Convoys/AddConvoyPresentation.vue";
|
||||||
|
import { useConvoyStore } from "@/stores/convoyStore";
|
||||||
|
import { useNotificationStore } from "@/stores/notification";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const convoyStore = useConvoyStore();
|
||||||
|
const notificationStore = useNotificationStore();
|
||||||
|
|
||||||
|
const handleCreateConvoy = async (payload) => {
|
||||||
|
try {
|
||||||
|
await convoyStore.createConvoy(payload);
|
||||||
|
notificationStore.created("Convoi");
|
||||||
|
router.push({ name: "Liste convois" });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating convoy:", error);
|
||||||
|
notificationStore.error("Erreur", "Impossible de créer le convoi");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
41
thanasoft-front/src/views/pages/Convoys/Convoys.vue
Normal file
41
thanasoft-front/src/views/pages/Convoys/Convoys.vue
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<template>
|
||||||
|
<convoy-list-presentation
|
||||||
|
:convoys="convoyStore.convoys"
|
||||||
|
:loading="convoyStore.loading"
|
||||||
|
:error="convoyStore.error"
|
||||||
|
:pagination="convoyStore.getPagination"
|
||||||
|
@retry="loadConvoys"
|
||||||
|
@page-change="changePage"
|
||||||
|
@filter-status="applyStatusFilter"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from "vue";
|
||||||
|
import ConvoyListPresentation from "@/components/Organism/Convoys/ConvoyListPresentation.vue";
|
||||||
|
import { useConvoyStore } from "@/stores/convoyStore";
|
||||||
|
|
||||||
|
const convoyStore = useConvoyStore();
|
||||||
|
const currentStatus = ref(undefined);
|
||||||
|
|
||||||
|
const loadConvoys = async (page = 1) => {
|
||||||
|
await convoyStore.fetchConvoys({
|
||||||
|
page,
|
||||||
|
per_page: 9,
|
||||||
|
status: currentStatus.value,
|
||||||
|
sort_by: "planned_start_at",
|
||||||
|
sort_direction: "desc",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const changePage = async (page) => {
|
||||||
|
await loadConvoys(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyStatusFilter = async (status) => {
|
||||||
|
currentStatus.value = status;
|
||||||
|
await loadConvoys(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(loadConvoys);
|
||||||
|
</script>
|
||||||
Loading…
x
Reference in New Issue
Block a user