feat: Initialize core application structure including authentication, role-based dashboards, service task management, and integration workflows.

This commit is contained in:
jeremy bayse
2026-02-16 09:30:23 +01:00
commit af060a8847
208 changed files with 26822 additions and 0 deletions

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>