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",
|
||||
type: "collapse",
|
||||
|
||||
@ -406,6 +406,16 @@ const routes = [
|
||||
name: "Agenda",
|
||||
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
|
||||
{
|
||||
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