feat: Initialize core application structure including authentication, role-based dashboards, service task management, and integration workflows.
This commit is contained in:
72
resources/js/Components/App/ActivityTimeline.vue
Normal file
72
resources/js/Components/App/ActivityTimeline.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
activities: Array,
|
||||
});
|
||||
|
||||
const formatEvent = (activity) => {
|
||||
const events = {
|
||||
'created': 'Création',
|
||||
'updated': 'Mise à jour',
|
||||
'deleted': 'Suppression',
|
||||
};
|
||||
return events[activity.description] || activity.description;
|
||||
};
|
||||
|
||||
const getBadgeColor = (description) => {
|
||||
if (description === 'created') return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
|
||||
if (description === 'updated') return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200';
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200';
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div class="px-4 py-5 sm:px-6">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-gray-100">Historique d'activité</h3>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 px-4 py-5 sm:p-6">
|
||||
<div class="flow-root">
|
||||
<ul role="list" class="-mb-8">
|
||||
<li v-for="(activity, index) in activities" :key="activity.id">
|
||||
<div class="relative pb-8">
|
||||
<span v-if="index !== activities.length - 1" class="absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200 dark:bg-gray-700" aria-hidden="true"></span>
|
||||
<div class="relative flex space-x-3">
|
||||
<div>
|
||||
<span :class="[getBadgeColor(activity.description), 'h-8 w-8 rounded-full flex items-center justify-center ring-8 ring-white dark:ring-gray-800']">
|
||||
<svg v-if="activity.description === 'created'" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
|
||||
<svg v-else-if="activity.description === 'updated'" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path></svg>
|
||||
<svg v-else class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{ activity.causer?.name || 'Système' }}</span>
|
||||
{{ activity.description === 'created' ? ' a créé ' : ' a mis à jour ' }}
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ activity.subject_type.split('\\').pop() }}
|
||||
</span>
|
||||
</p>
|
||||
<div v-if="activity.properties?.attributes" class="mt-2 text-xs text-gray-400 bg-gray-50 dark:bg-gray-900/50 p-2 rounded">
|
||||
<div v-for="(val, key) in activity.properties.attributes" :key="key">
|
||||
<span v-if="key === 'status'">Nouveau statut: <span class="font-bold text-blue-500">{{ val }}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="whitespace-nowrap text-right text-sm text-gray-500 dark:text-gray-400">
|
||||
<time :datetime="activity.created_at">{{ new Date(activity.created_at).toLocaleString() }}</time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li v-if="activities.length === 0" class="text-sm text-gray-500 italic pb-4">
|
||||
Aucune activité enregistrée.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
92
resources/js/Components/App/AttachmentSection.vue
Normal file
92
resources/js/Components/App/AttachmentSection.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script setup>
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
attachments: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
taskId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
canManage: Boolean,
|
||||
});
|
||||
|
||||
const fileInput = ref(null);
|
||||
|
||||
const form = useForm({
|
||||
file: null,
|
||||
});
|
||||
|
||||
const uploadFile = () => {
|
||||
if (fileInput.value.files[0]) {
|
||||
form.file = fileInput.value.files[0];
|
||||
form.post(route('attachments.store', props.taskId), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
fileInput.value.value = '';
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAttachment = (id) => {
|
||||
if (confirm('Supprimer ce fichier ?')) {
|
||||
router.delete(route('attachments.destroy', id), {
|
||||
preserveScroll: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const formatSize = (bytes) => {
|
||||
if (!bytes) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-4 border-t border-gray-100 dark:border-gray-700 pt-4">
|
||||
<h4 class="text-xs font-bold uppercase text-gray-400 mb-2">Pièces jointes ({{ attachments.length }})</h4>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div v-for="file in attachments" :key="file.id" class="flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-900/30 rounded border border-gray-100 dark:border-gray-700">
|
||||
<div class="flex items-center min-w-0">
|
||||
<svg class="w-4 h-4 text-gray-400 mr-2 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"></path></svg>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300 truncate" :title="file.original_name">
|
||||
{{ file.original_name }}
|
||||
</span>
|
||||
<span class="ml-2 text-[10px] text-gray-400">({{ formatSize(file.size) }})</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3 ml-4">
|
||||
<a :href="route('attachments.download', file.id)" class="text-blue-500 hover:text-blue-700 text-xs font-bold">
|
||||
Télécharger
|
||||
</a>
|
||||
<button v-if="canManage" @click="deleteAttachment(file.id)" class="text-red-400 hover:text-red-600">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="attachments.length === 0" class="text-xs text-gray-500 italic">Aucun fichier joint.</div>
|
||||
|
||||
<div v-if="canManage" class="mt-4">
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Ajouter un fichier</label>
|
||||
<div class="flex items-center space-x-2">
|
||||
<input
|
||||
type="file"
|
||||
ref="fileInput"
|
||||
@change="uploadFile"
|
||||
class="block w-full text-xs text-gray-500 file:mr-4 file:py-1 file:px-2 file:rounded-md file:border-0 file:text-xs file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 dark:file:bg-gray-700 dark:file:text-gray-300"
|
||||
>
|
||||
<div v-if="form.processing" class="text-[10px] text-blue-500 animate-pulse">Upload...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
76
resources/js/Components/App/CommentSection.vue
Normal file
76
resources/js/Components/App/CommentSection.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<script setup>
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { ref } from 'vue';
|
||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||
|
||||
const props = defineProps({
|
||||
comments: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
commentableId: Number,
|
||||
commentableType: String,
|
||||
});
|
||||
|
||||
const isExpanded = ref(false);
|
||||
|
||||
const form = useForm({
|
||||
content: '',
|
||||
commentable_id: props.commentableId,
|
||||
commentable_type: props.commentableType,
|
||||
});
|
||||
|
||||
const submit = () => {
|
||||
form.post(route('comments.store'), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
form.reset('content');
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-4 border-t border-gray-100 dark:border-gray-700 pt-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h4 class="text-xs font-bold uppercase text-gray-400">Commentaires ({{ comments.length }})</h4>
|
||||
<button @click="isExpanded = !isExpanded" class="text-xs text-blue-500 hover:underline">
|
||||
{{ isExpanded ? 'Masquer' : 'Afficher' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="isExpanded" class="space-y-4">
|
||||
<div v-for="comment in comments" :key="comment.id" class="flex space-x-3">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="h-8 w-8 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-xs font-bold text-gray-500">
|
||||
{{ comment.user.name.charAt(0) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 bg-gray-50 dark:bg-gray-900/50 p-3 rounded-lg text-sm">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="font-bold text-gray-900 dark:text-gray-100">{{ comment.user.name }}</span>
|
||||
<span class="text-[10px] text-gray-400">{{ new Date(comment.created_at).toLocaleString() }}</span>
|
||||
</div>
|
||||
<p class="text-gray-700 dark:text-gray-300 whitespace-pre-wrap">{{ comment.content }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submit" class="mt-4">
|
||||
<div>
|
||||
<textarea
|
||||
v-model="form.content"
|
||||
placeholder="Ajouter un commentaire..."
|
||||
class="w-full text-sm border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 focus:border-blue-500 rounded-md shadow-sm"
|
||||
rows="2"
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="mt-2 flex justify-end">
|
||||
<PrimaryButton :class="{ 'opacity-25': form.processing }" :disabled="form.processing" class="!px-3 !py-1 !text-[10px]">
|
||||
Publier
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
188
resources/js/Components/App/ServiceTaskCard.vue
Normal file
188
resources/js/Components/App/ServiceTaskCard.vue
Normal file
@@ -0,0 +1,188 @@
|
||||
<script setup>
|
||||
import StatusBadge from '@/Components/App/StatusBadge.vue';
|
||||
import { computed, reactive } from 'vue';
|
||||
import { router, usePage } from '@inertiajs/vue3';
|
||||
import CommentSection from '@/Components/App/CommentSection.vue';
|
||||
import AttachmentSection from '@/Components/App/AttachmentSection.vue';
|
||||
import TextInput from '@/Components/TextInput.vue';
|
||||
import InputLabel from '@/Components/InputLabel.vue';
|
||||
|
||||
const props = defineProps({
|
||||
task: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const user = computed(() => usePage().props.auth.user);
|
||||
const canManage = computed(() => {
|
||||
return user.value.roles.some(r => r.name === props.task.service.name || r.name === 'Admin');
|
||||
});
|
||||
|
||||
const progress = computed(() => {
|
||||
if (!props.task.task_items.length) return 0;
|
||||
const completed = props.task.task_items.filter(i => i.is_completed).length;
|
||||
return Math.round((completed / props.task.task_items.length) * 100);
|
||||
});
|
||||
|
||||
// Initialize form state for custom fields
|
||||
const fieldsState = reactive({});
|
||||
|
||||
props.task.task_items.forEach(item => {
|
||||
fieldsState[item.id] = {};
|
||||
if (item.fields_definition) {
|
||||
item.fields_definition.forEach(field => {
|
||||
// Load existing data or empty string
|
||||
// We assume field.label is the key.
|
||||
// Check if item.data exists and has this key.
|
||||
const existingValue = item.data && item.data[field.label] ? item.data[field.label] : '';
|
||||
fieldsState[item.id][field.label] = existingValue;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const startTask = () => {
|
||||
router.post(route('service-tasks.start', props.task.id));
|
||||
};
|
||||
|
||||
const toggleItem = (item, event) => {
|
||||
// If we are marking as complete
|
||||
if (!item.is_completed) {
|
||||
// Validate required fields
|
||||
if (item.fields_definition) {
|
||||
for (const field of item.fields_definition) {
|
||||
if (field.required && !fieldsState[item.id][field.label]) {
|
||||
event.preventDefault();
|
||||
alert(`Le champ "${field.label}" est obligatoire.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const data = fieldsState[item.id] || {};
|
||||
|
||||
router.post(route('task-items.toggle', item.id), { data }, {
|
||||
preserveScroll: true,
|
||||
onFinish: () => {
|
||||
// Optional: feedback
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const validateTask = () => {
|
||||
if (confirm('Souhaitez-vous valider cette tâche ?')) {
|
||||
router.post(route('service-tasks.approve', props.task.id));
|
||||
}
|
||||
};
|
||||
|
||||
const rejectTask = () => {
|
||||
const reason = prompt('Raison du refus :');
|
||||
if (reason) {
|
||||
router.post(route('service-tasks.reject', props.task.id), { reason });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overflow-hidden bg-white shadow sm:rounded-lg dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
|
||||
<div class="px-4 py-5 sm:px-6 flex justify-between items-center">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-gray-100">
|
||||
{{ task.service.name }}
|
||||
</h3>
|
||||
<p class="mt-1 max-w-2xl text-sm text-gray-500 dark:text-gray-400">
|
||||
Deadline: {{ task.sla_deadline ? new Date(task.sla_deadline).toLocaleString() : 'N/A' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
v-if="canManage && task.status === 'pending'"
|
||||
@click="startTask"
|
||||
class="px-3 py-1 bg-blue-600 text-white rounded text-xs font-bold hover:bg-blue-700"
|
||||
>
|
||||
Démarrer
|
||||
</button>
|
||||
<div v-if="canManage && task.status === 'waiting_validation'" class="flex space-x-2">
|
||||
<button @click="validateTask" class="px-3 py-1 bg-green-600 text-white rounded text-xs font-bold hover:bg-green-700">Valider</button>
|
||||
<button @click="rejectTask" class="px-3 py-1 bg-red-600 text-white rounded text-xs font-bold hover:bg-red-700">Refuser</button>
|
||||
</div>
|
||||
<StatusBadge :status="task.status" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-700/50">
|
||||
<div class="w-full bg-gray-200 dark:bg-gray-600 rounded-full h-1.5 mb-2">
|
||||
<div class="bg-blue-600 h-1.5 rounded-full transition-all duration-500" :style="{ width: progress + '%' }"></div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 text-right">{{ progress }}% complété</p>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 px-4 py-3 sm:p-0">
|
||||
<dl class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<div v-for="item in task.task_items" :key="item.id" class="py-4 px-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center w-full">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="item.is_completed"
|
||||
:disabled="!canManage || (task.status !== 'in_progress' && task.status !== 'waiting_validation')"
|
||||
@click="toggleItem(item, $event)"
|
||||
class="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 dark:bg-gray-900 dark:border-gray-600 cursor-pointer disabled:cursor-not-allowed shrink-0"
|
||||
>
|
||||
<div class="ml-3 flex flex-col w-full">
|
||||
<span :class="['text-sm font-medium', item.is_completed ? 'line-through text-gray-400' : 'text-gray-900 dark:text-gray-100']">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="item.is_mandatory" class="ml-2 text-[10px] uppercase font-bold text-red-500 dark:text-red-400 shrink-0">Obligatoire</span>
|
||||
</div>
|
||||
|
||||
<!-- Custom Fields Section -->
|
||||
<div v-if="item.fields_definition && item.fields_definition.length > 0" class="mt-3 ml-7 grid grid-cols-1 gap-y-3 gap-x-4 sm:grid-cols-2 bg-gray-50 dark:bg-gray-700/30 p-3 rounded-md">
|
||||
<div v-for="(field, index) in item.fields_definition" :key="index">
|
||||
<InputLabel :value="field.label + (field.required ? ' *' : '')" class="text-xs mb-1" />
|
||||
|
||||
<TextInput
|
||||
v-if="field.type === 'text'"
|
||||
type="text"
|
||||
v-model="fieldsState[item.id][field.label]"
|
||||
:disabled="item.is_completed || !canManage"
|
||||
class="w-full text-xs py-1"
|
||||
/>
|
||||
|
||||
<input
|
||||
v-else-if="field.type === 'date'"
|
||||
type="date"
|
||||
v-model="fieldsState[item.id][field.label]"
|
||||
:disabled="item.is_completed || !canManage"
|
||||
class="block w-full border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 rounded-md shadow-sm text-xs py-1"
|
||||
/>
|
||||
|
||||
<div v-else-if="field.type === 'checkbox'" class="flex items-center mt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="fieldsState[item.id][field.label]"
|
||||
:disabled="item.is_completed || !canManage"
|
||||
class="h-4 w-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900"
|
||||
/>
|
||||
<span class="ml-2 text-xs text-gray-600 dark:text-gray-400">{{ field.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<AttachmentSection
|
||||
:attachments="task.attachments"
|
||||
:task-id="task.id"
|
||||
:can-manage="canManage"
|
||||
class="px-4 pb-2"
|
||||
/>
|
||||
<CommentSection
|
||||
:comments="task.comments"
|
||||
:commentable-id="task.id"
|
||||
commentable-type="App\Models\ServiceTask"
|
||||
class="p-4"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
34
resources/js/Components/App/StatusBadge.vue
Normal file
34
resources/js/Components/App/StatusBadge.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
status: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const statusConfig = {
|
||||
draft: { label: 'Brouillon', class: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200' },
|
||||
pending_rh_validation: { label: 'Attente RH', class: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' },
|
||||
in_progress: { label: 'En cours', class: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 animate-pulse' },
|
||||
waiting_services: { label: 'Attente Services', class: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300' },
|
||||
completed: { label: 'Terminé', class: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' },
|
||||
cancelled: { label: 'Annulé', class: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300' },
|
||||
rejected: { label: 'Refusé', class: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300' },
|
||||
pending: { label: 'En attente', class: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200' },
|
||||
waiting_validation: { label: 'Attente Validation', class: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300 animate-pulse' },
|
||||
};
|
||||
|
||||
const config = computed(() => statusConfig[props.status] || { label: props.status, class: 'bg-gray-100 text-gray-800' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="['inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium', config.class]">
|
||||
<span v-if="status === 'in_progress' || status === 'waiting_validation'" class="flex h-1.5 w-1.5 mr-1.5">
|
||||
<span class="animate-ping absolute inline-flex h-1.5 w-1.5 rounded-full bg-current opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-1.5 w-1.5 bg-current"></span>
|
||||
</span>
|
||||
{{ config.label }}
|
||||
</span>
|
||||
</template>
|
||||
40
resources/js/Components/ApplicationLogo.vue
Normal file
40
resources/js/Components/ApplicationLogo.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="body-gradient" x1="0" y1="100" x2="100" y2="0" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#FFEC00" /> <!-- Yellow -->
|
||||
<stop offset="100%" stop-color="#009EE0" /> <!-- Blue -->
|
||||
</linearGradient>
|
||||
<linearGradient id="head-gradient" x1="20" y1="20" x2="80" y2="80" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#E30613" /> <!-- Red -->
|
||||
<stop offset="100%" stop-color="#B91C1C" /> <!-- Dark Red -->
|
||||
</linearGradient>
|
||||
|
||||
<!-- Glow filter for modern feel -->
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="2" result="blur" />
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Modern Abstract Onboarding Icon -->
|
||||
|
||||
<!-- The "Receptacle" / Organization / Structure (U-shape) -->
|
||||
<!-- Stylized as a smooth, continuous curve representing support/integration -->
|
||||
<path
|
||||
d="M 20 55 C 20 85, 80 85, 80 55"
|
||||
stroke="url(#body-gradient)"
|
||||
stroke-width="12"
|
||||
stroke-linecap="round"
|
||||
fill="none"
|
||||
/>
|
||||
|
||||
<!-- The "User" / Agent (Floating Sphere) -->
|
||||
<!-- Poised to enter or rising up, representing energy/new arrival -->
|
||||
<circle cx="50" cy="35" r="14" fill="url(#head-gradient)" />
|
||||
|
||||
<!-- Dynamic "Connection" Dot (Success/Active status) -->
|
||||
<circle cx="82" cy="35" r="6" fill="#009EE0" stroke="white" stroke-width="2" />
|
||||
|
||||
</svg>
|
||||
</template>
|
||||
34
resources/js/Components/Checkbox.vue
Normal file
34
resources/js/Components/Checkbox.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const emit = defineEmits(['update:checked']);
|
||||
|
||||
const props = defineProps({
|
||||
checked: {
|
||||
type: [Array, Boolean],
|
||||
required: true,
|
||||
},
|
||||
value: {
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const proxyChecked = computed({
|
||||
get() {
|
||||
return props.checked;
|
||||
},
|
||||
|
||||
set(val) {
|
||||
emit('update:checked', val);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="value"
|
||||
v-model="proxyChecked"
|
||||
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:focus:ring-indigo-600 dark:focus:ring-offset-gray-800"
|
||||
/>
|
||||
</template>
|
||||
7
resources/js/Components/DangerButton.vue
Normal file
7
resources/js/Components/DangerButton.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<button
|
||||
class="inline-flex items-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-xs font-semibold uppercase tracking-widest text-white transition duration-150 ease-in-out hover:bg-red-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 active:bg-red-700 dark:focus:ring-offset-gray-800"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
37
resources/js/Components/Dashboard/StatCard.vue
Normal file
37
resources/js/Components/Dashboard/StatCard.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: String,
|
||||
value: [String, Number],
|
||||
subValue: String,
|
||||
icon: String,
|
||||
color: {
|
||||
type: String,
|
||||
default: 'blue'
|
||||
}
|
||||
});
|
||||
|
||||
const colors = {
|
||||
blue: 'text-blue-600 bg-blue-100 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
red: 'text-red-600 bg-red-100 dark:bg-red-900/30 dark:text-red-400',
|
||||
green: 'text-green-600 bg-green-100 dark:bg-green-900/30 dark:text-green-400',
|
||||
yellow: 'text-yellow-600 bg-yellow-100 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<div class="flex items-center">
|
||||
<div :class="['p-3 rounded-full', colors[color]]">
|
||||
<!-- Simple Circle as Placeholder for Icon -->
|
||||
<div class="w-6 h-6 border-2 border-current rounded-full"></div>
|
||||
</div>
|
||||
<div class="ml-4 text-gray-500 dark:text-gray-400">
|
||||
<p class="text-sm font-medium uppercase tracking-wider">{{ title }}</p>
|
||||
<div class="flex items-baseline">
|
||||
<p class="text-2xl font-semibold text-gray-900 dark:text-white">{{ value }}</p>
|
||||
<p v-if="subValue" class="ml-2 text-xs font-semibold text-gray-500">{{ subValue }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
84
resources/js/Components/Dropdown.vue
Normal file
84
resources/js/Components/Dropdown.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
align: {
|
||||
type: String,
|
||||
default: 'right',
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
default: '48',
|
||||
},
|
||||
contentClasses: {
|
||||
type: String,
|
||||
default: 'py-1 bg-white dark:bg-gray-700',
|
||||
},
|
||||
});
|
||||
|
||||
const closeOnEscape = (e) => {
|
||||
if (open.value && e.key === 'Escape') {
|
||||
open.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', closeOnEscape));
|
||||
onUnmounted(() => document.removeEventListener('keydown', closeOnEscape));
|
||||
|
||||
const widthClass = computed(() => {
|
||||
return {
|
||||
48: 'w-48',
|
||||
}[props.width.toString()];
|
||||
});
|
||||
|
||||
const alignmentClasses = computed(() => {
|
||||
if (props.align === 'left') {
|
||||
return 'ltr:origin-top-left rtl:origin-top-right start-0';
|
||||
} else if (props.align === 'right') {
|
||||
return 'ltr:origin-top-right rtl:origin-top-left end-0';
|
||||
} else {
|
||||
return 'origin-top';
|
||||
}
|
||||
});
|
||||
|
||||
const open = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div @click="open = !open">
|
||||
<slot name="trigger" />
|
||||
</div>
|
||||
|
||||
<!-- Full Screen Dropdown Overlay -->
|
||||
<div
|
||||
v-show="open"
|
||||
class="fixed inset-0 z-40"
|
||||
@click="open = false"
|
||||
></div>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition ease-out duration-200"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition ease-in duration-75"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-show="open"
|
||||
class="absolute z-50 mt-2 rounded-md shadow-lg"
|
||||
:class="[widthClass, alignmentClasses]"
|
||||
style="display: none"
|
||||
@click="open = false"
|
||||
>
|
||||
<div
|
||||
class="rounded-md ring-1 ring-black ring-opacity-5"
|
||||
:class="contentClasses"
|
||||
>
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
19
resources/js/Components/DropdownLink.vue
Normal file
19
resources/js/Components/DropdownLink.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup>
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
|
||||
defineProps({
|
||||
href: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Link
|
||||
:href="href"
|
||||
class="block w-full px-4 py-2 text-start text-sm leading-5 text-gray-700 transition duration-150 ease-in-out hover:bg-gray-100 focus:bg-gray-100 focus:outline-none dark:text-gray-300 dark:hover:bg-gray-800 dark:focus:bg-gray-800"
|
||||
>
|
||||
<slot />
|
||||
</Link>
|
||||
</template>
|
||||
15
resources/js/Components/InputError.vue
Normal file
15
resources/js/Components/InputError.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
message: {
|
||||
type: String,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-show="message">
|
||||
<p class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ message }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
14
resources/js/Components/InputLabel.vue
Normal file
14
resources/js/Components/InputLabel.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
value: {
|
||||
type: String,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<span v-if="value">{{ value }}</span>
|
||||
<span v-else><slot /></span>
|
||||
</label>
|
||||
</template>
|
||||
123
resources/js/Components/Modal.vue
Normal file
123
resources/js/Components/Modal.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
maxWidth: {
|
||||
type: String,
|
||||
default: '2xl',
|
||||
},
|
||||
closeable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
const dialog = ref();
|
||||
const showSlot = ref(props.show);
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
() => {
|
||||
if (props.show) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
showSlot.value = true;
|
||||
|
||||
dialog.value?.showModal();
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
|
||||
setTimeout(() => {
|
||||
dialog.value?.close();
|
||||
showSlot.value = false;
|
||||
}, 200);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const close = () => {
|
||||
if (props.closeable) {
|
||||
emit('close');
|
||||
}
|
||||
};
|
||||
|
||||
const closeOnEscape = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
|
||||
if (props.show) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', closeOnEscape));
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', closeOnEscape);
|
||||
|
||||
document.body.style.overflow = '';
|
||||
});
|
||||
|
||||
const maxWidthClass = computed(() => {
|
||||
return {
|
||||
sm: 'sm:max-w-sm',
|
||||
md: 'sm:max-w-md',
|
||||
lg: 'sm:max-w-lg',
|
||||
xl: 'sm:max-w-xl',
|
||||
'2xl': 'sm:max-w-2xl',
|
||||
}[props.maxWidth];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dialog
|
||||
class="z-50 m-0 min-h-full min-w-full overflow-y-auto bg-transparent backdrop:bg-transparent"
|
||||
ref="dialog"
|
||||
>
|
||||
<div
|
||||
class="fixed inset-0 z-50 overflow-y-auto px-4 py-6 sm:px-0"
|
||||
scroll-region
|
||||
>
|
||||
<Transition
|
||||
enter-active-class="ease-out duration-300"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="ease-in duration-200"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-show="show"
|
||||
class="fixed inset-0 transform transition-all"
|
||||
@click="close"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-gray-500 opacity-75 dark:bg-gray-900"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Transition
|
||||
enter-active-class="ease-out duration-300"
|
||||
enter-from-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enter-to-class="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave-active-class="ease-in duration-200"
|
||||
leave-from-class="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave-to-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<div
|
||||
v-show="show"
|
||||
class="mb-6 transform overflow-hidden rounded-lg bg-white shadow-xl transition-all sm:mx-auto sm:w-full dark:bg-gray-800"
|
||||
:class="maxWidthClass"
|
||||
>
|
||||
<slot v-if="showSlot" />
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</dialog>
|
||||
</template>
|
||||
26
resources/js/Components/NavLink.vue
Normal file
26
resources/js/Components/NavLink.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
|
||||
const props = defineProps({
|
||||
href: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
active: {
|
||||
type: Boolean,
|
||||
},
|
||||
});
|
||||
|
||||
const classes = computed(() =>
|
||||
props.active
|
||||
? 'inline-flex items-center px-1 pt-1 border-b-2 border-indigo-400 dark:border-indigo-600 text-sm font-medium leading-5 text-gray-900 dark:text-gray-100 focus:outline-none focus:border-indigo-700 transition duration-150 ease-in-out'
|
||||
: 'inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium leading-5 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-700 focus:outline-none focus:text-gray-700 dark:focus:text-gray-300 focus:border-gray-300 dark:focus:border-gray-700 transition duration-150 ease-in-out',
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Link :href="href" :class="classes">
|
||||
<slot />
|
||||
</Link>
|
||||
</template>
|
||||
7
resources/js/Components/PrimaryButton.vue
Normal file
7
resources/js/Components/PrimaryButton.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<button
|
||||
class="inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold uppercase tracking-widest text-white transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
26
resources/js/Components/ResponsiveNavLink.vue
Normal file
26
resources/js/Components/ResponsiveNavLink.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
|
||||
const props = defineProps({
|
||||
href: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
active: {
|
||||
type: Boolean,
|
||||
},
|
||||
});
|
||||
|
||||
const classes = computed(() =>
|
||||
props.active
|
||||
? 'block w-full ps-3 pe-4 py-2 border-l-4 border-indigo-400 dark:border-indigo-600 text-start text-base font-medium text-indigo-700 dark:text-indigo-300 bg-indigo-50 dark:bg-indigo-900/50 focus:outline-none focus:text-indigo-800 dark:focus:text-indigo-200 focus:bg-indigo-100 dark:focus:bg-indigo-900 focus:border-indigo-700 dark:focus:border-indigo-300 transition duration-150 ease-in-out'
|
||||
: 'block w-full ps-3 pe-4 py-2 border-l-4 border-transparent text-start text-base font-medium text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 hover:border-gray-300 dark:hover:border-gray-600 focus:outline-none focus:text-gray-800 dark:focus:text-gray-200 focus:bg-gray-50 dark:focus:bg-gray-700 focus:border-gray-300 dark:focus:border-gray-600 transition duration-150 ease-in-out',
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Link :href="href" :class="classes">
|
||||
<slot />
|
||||
</Link>
|
||||
</template>
|
||||
17
resources/js/Components/SecondaryButton.vue
Normal file
17
resources/js/Components/SecondaryButton.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: 'button',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:type="type"
|
||||
class="inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-xs font-semibold uppercase tracking-widest text-gray-700 shadow-sm transition duration-150 ease-in-out hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-25 dark:border-gray-500 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 dark:focus:ring-offset-gray-800"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
26
resources/js/Components/TextInput.vue
Normal file
26
resources/js/Components/TextInput.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
const model = defineModel({
|
||||
type: String,
|
||||
required: true,
|
||||
});
|
||||
|
||||
const input = ref(null);
|
||||
|
||||
onMounted(() => {
|
||||
if (input.value.hasAttribute('autofocus')) {
|
||||
input.value.focus();
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose({ focus: () => input.value.focus() });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
class="rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600"
|
||||
v-model="model"
|
||||
ref="input"
|
||||
/>
|
||||
</template>
|
||||
Reference in New Issue
Block a user