Initial commit with contrats and domaines modules
This commit is contained in:
7
resources/js/Components/ApplicationLogo.vue
Normal file
7
resources/js/Components/ApplicationLogo.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg viewBox="0 0 316 316" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M305.8 81.125C305.77 80.995 305.69 80.885 305.65 80.755C305.56 80.525 305.49 80.285 305.37 80.075C305.29 79.935 305.17 79.815 305.07 79.685C304.94 79.515 304.83 79.325 304.68 79.175C304.55 79.045 304.39 78.955 304.25 78.845C304.09 78.715 303.95 78.575 303.77 78.475L251.32 48.275C249.97 47.495 248.31 47.495 246.96 48.275L194.51 78.475C194.33 78.575 194.19 78.725 194.03 78.845C193.89 78.955 193.73 79.045 193.6 79.175C193.45 79.325 193.34 79.515 193.21 79.685C193.11 79.815 192.99 79.935 192.91 80.075C192.79 80.285 192.71 80.525 192.63 80.755C192.58 80.875 192.51 80.995 192.48 81.125C192.38 81.495 192.33 81.875 192.33 82.265V139.625L148.62 164.795V52.575C148.62 52.185 148.57 51.805 148.47 51.435C148.44 51.305 148.36 51.195 148.32 51.065C148.23 50.835 148.16 50.595 148.04 50.385C147.96 50.245 147.84 50.125 147.74 49.995C147.61 49.825 147.5 49.635 147.35 49.485C147.22 49.355 147.06 49.265 146.92 49.155C146.76 49.025 146.62 48.885 146.44 48.785L93.99 18.585C92.64 17.805 90.98 17.805 89.63 18.585L37.18 48.785C37 48.885 36.86 49.035 36.7 49.155C36.56 49.265 36.4 49.355 36.27 49.485C36.12 49.635 36.01 49.825 35.88 49.995C35.78 50.125 35.66 50.245 35.58 50.385C35.46 50.595 35.38 50.835 35.3 51.065C35.25 51.185 35.18 51.305 35.15 51.435C35.05 51.805 35 52.185 35 52.575V232.235C35 233.795 35.84 235.245 37.19 236.025L142.1 296.425C142.33 296.555 142.58 296.635 142.82 296.725C142.93 296.765 143.04 296.835 143.16 296.865C143.53 296.965 143.9 297.015 144.28 297.015C144.66 297.015 145.03 296.965 145.4 296.865C145.5 296.835 145.59 296.775 145.69 296.745C145.95 296.655 146.21 296.565 146.45 296.435L251.36 236.035C252.72 235.255 253.55 233.815 253.55 232.245V174.885L303.81 145.945C305.17 145.165 306 143.725 306 142.155V82.265C305.95 81.875 305.89 81.495 305.8 81.125ZM144.2 227.205L100.57 202.515L146.39 176.135L196.66 147.195L240.33 172.335L208.29 190.625L144.2 227.205ZM244.75 114.995V164.795L226.39 154.225L201.03 139.625V89.825L219.39 100.395L244.75 114.995ZM249.12 57.105L292.81 82.265L249.12 107.425L205.43 82.265L249.12 57.105ZM114.49 184.425L96.13 194.995V85.305L121.49 70.705L139.85 60.135V169.815L114.49 184.425ZM91.76 27.425L135.45 52.585L91.76 77.745L48.07 52.585L91.76 27.425ZM43.67 60.135L62.03 70.705L87.39 85.305V202.545V202.555V202.565C87.39 202.735 87.44 202.895 87.46 203.055C87.49 203.265 87.49 203.485 87.55 203.695V203.705C87.6 203.875 87.69 204.035 87.76 204.195C87.84 204.375 87.89 204.575 87.99 204.745C87.99 204.745 87.99 204.755 88 204.755C88.09 204.905 88.22 205.035 88.33 205.175C88.45 205.335 88.55 205.495 88.69 205.635L88.7 205.645C88.82 205.765 88.98 205.855 89.12 205.965C89.28 206.085 89.42 206.225 89.59 206.325C89.6 206.325 89.6 206.325 89.61 206.335C89.62 206.335 89.62 206.345 89.63 206.345L139.87 234.775V285.065L43.67 229.705V60.135ZM244.75 229.705L148.58 285.075V234.775L219.8 194.115L244.75 179.875V229.705ZM297.2 139.625L253.49 164.795V114.995L278.85 100.395L297.21 89.825V139.625H297.2Z"
|
||||
/>
|
||||
</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"
|
||||
/>
|
||||
</template>
|
||||
57
resources/js/Components/Commandes/HistoriqueTimeline.vue
Normal file
57
resources/js/Components/Commandes/HistoriqueTimeline.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<script setup>
|
||||
import { usePage } from '@inertiajs/vue3'
|
||||
import StatutBadge from './StatutBadge.vue'
|
||||
|
||||
defineProps({
|
||||
historique: { type: Array, required: true },
|
||||
})
|
||||
|
||||
const page = usePage()
|
||||
const statuts = page.props.config?.statuts ?? {}
|
||||
|
||||
function formatDate(dt) {
|
||||
if (!dt) return ''
|
||||
return new Intl.DateTimeFormat('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date(dt))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flow-root">
|
||||
<ul class="-mb-8">
|
||||
<li v-for="(entry, idx) in historique" :key="entry.id" class="relative pb-8">
|
||||
<!-- Vertical line (except last) -->
|
||||
<span v-if="idx !== historique.length - 1"
|
||||
class="absolute left-4 top-4 -ml-px h-full w-0.5 bg-gray-200" aria-hidden="true" />
|
||||
|
||||
<div class="relative flex items-start space-x-3">
|
||||
<!-- Dot -->
|
||||
<div class="relative flex h-8 w-8 items-center justify-center rounded-full bg-white ring-8 ring-white">
|
||||
<div class="h-3 w-3 rounded-full bg-blue-500 ring-2 ring-white" />
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span class="font-medium text-gray-900">{{ entry.user?.name ?? 'Système' }}</span>
|
||||
<template v-if="entry.ancien_statut">
|
||||
<StatutBadge :statut="entry.ancien_statut" size="sm" />
|
||||
<svg class="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</template>
|
||||
<StatutBadge :statut="entry.nouveau_statut" size="sm" />
|
||||
</div>
|
||||
<p v-if="entry.commentaire" class="mt-1 text-sm text-gray-600 italic">
|
||||
"{{ entry.commentaire }}"
|
||||
</p>
|
||||
<time class="mt-0.5 block text-xs text-gray-400">{{ formatDate(entry.created_at) }}</time>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p v-if="!historique.length" class="text-sm text-gray-400 italic text-center py-4">
|
||||
Aucun historique disponible.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
224
resources/js/Components/Commandes/LignesCommandeForm.vue
Normal file
224
resources/js/Components/Commandes/LignesCommandeForm.vue
Normal file
@@ -0,0 +1,224 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
categories: { type: Array, default: () => [] },
|
||||
articles: { type: Array, default: () => [] },
|
||||
readonly: { type: Boolean, default: false },
|
||||
showReceived: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const lignes = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
})
|
||||
|
||||
function addLigne() {
|
||||
emit('update:modelValue', [
|
||||
...props.modelValue,
|
||||
{ designation: '', reference: '', quantite: 1, quantite_recue: 0, unite: 'unité', prix_unitaire_ht: null, taux_tva: 20, categorie_id: null, article_id: null, notes: '' }
|
||||
])
|
||||
}
|
||||
|
||||
function removeLigne(idx) {
|
||||
const updated = [...props.modelValue]
|
||||
updated.splice(idx, 1)
|
||||
emit('update:modelValue', updated)
|
||||
}
|
||||
|
||||
function updateLigne(idx, field, value) {
|
||||
const updated = [...props.modelValue]
|
||||
updated[idx] = { ...updated[idx], [field]: value }
|
||||
emit('update:modelValue', updated)
|
||||
}
|
||||
|
||||
function onArticleSelect(idx, articleId) {
|
||||
const article = props.articles.find(a => a.id == articleId)
|
||||
if (!article) return
|
||||
const updated = [...props.modelValue]
|
||||
updated[idx] = {
|
||||
...updated[idx],
|
||||
article_id: article.id,
|
||||
designation: article.designation,
|
||||
reference: article.reference ?? '',
|
||||
prix_unitaire_ht: article.prix_unitaire_ht,
|
||||
unite: article.unite,
|
||||
categorie_id: article.categorie_id,
|
||||
}
|
||||
emit('update:modelValue', updated)
|
||||
}
|
||||
|
||||
function montantHT(ligne) {
|
||||
const q = parseFloat(ligne.quantite) || 0
|
||||
const p = parseFloat(ligne.prix_unitaire_ht) || 0
|
||||
return (q * p).toFixed(2)
|
||||
}
|
||||
|
||||
function montantTTC(ligne) {
|
||||
const ht = parseFloat(montantHT(ligne))
|
||||
const tva = parseFloat(ligne.taux_tva) || 0
|
||||
return (ht * (1 + tva / 100)).toFixed(2)
|
||||
}
|
||||
|
||||
const totalHT = computed(() => props.modelValue.reduce((sum, l) => sum + parseFloat(montantHT(l)), 0).toFixed(2))
|
||||
const totalTTC = computed(() => props.modelValue.reduce((sum, l) => sum + parseFloat(montantTTC(l)), 0).toFixed(2))
|
||||
|
||||
function formatCurrency(val) {
|
||||
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(val)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Désignation</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Catégorie</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Réf.</th>
|
||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-20">Qté</th>
|
||||
<th v-if="showReceived" class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-20">Reçu</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase w-20">Unité</th>
|
||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-28">P.U. HT</th>
|
||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-16">TVA %</th>
|
||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-28">Total HT</th>
|
||||
<th v-if="!readonly" class="px-3 py-2 w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 bg-white">
|
||||
<tr v-for="(ligne, idx) in lignes" :key="idx">
|
||||
<!-- Désignation -->
|
||||
<td class="px-3 py-2">
|
||||
<div v-if="!readonly" class="space-y-1">
|
||||
<select v-if="articles.length"
|
||||
class="block w-full rounded border border-gray-200 px-2 py-1 text-xs text-gray-600 focus:border-blue-500 focus:outline-none"
|
||||
@change="onArticleSelect(idx, $event.target.value)">
|
||||
<option value="">— Choisir un article —</option>
|
||||
<option v-for="art in articles" :key="art.id" :value="art.id">{{ art.designation }}</option>
|
||||
</select>
|
||||
<input :value="ligne.designation" @input="updateLigne(idx, 'designation', $event.target.value)"
|
||||
type="text" placeholder="Désignation *" required
|
||||
class="block w-full rounded border border-gray-200 px-2 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" />
|
||||
</div>
|
||||
<span v-else class="font-medium">{{ ligne.designation }}</span>
|
||||
</td>
|
||||
|
||||
<!-- Catégorie -->
|
||||
<td class="px-3 py-2">
|
||||
<select v-if="!readonly"
|
||||
:value="ligne.categorie_id"
|
||||
@change="updateLigne(idx, 'categorie_id', $event.target.value || null)"
|
||||
class="block w-full rounded border border-gray-200 px-2 py-1.5 text-sm focus:border-blue-500 focus:outline-none">
|
||||
<option value="">—</option>
|
||||
<option v-for="cat in categories" :key="cat.id" :value="cat.id">{{ cat.nom }}</option>
|
||||
</select>
|
||||
<span v-else class="text-gray-600">{{ categories.find(c => c.id == ligne.categorie_id)?.nom ?? '—' }}</span>
|
||||
</td>
|
||||
|
||||
<!-- Référence -->
|
||||
<td class="px-3 py-2">
|
||||
<input v-if="!readonly" :value="ligne.reference"
|
||||
@input="updateLigne(idx, 'reference', $event.target.value)"
|
||||
type="text" placeholder="Réf."
|
||||
class="block w-full rounded border border-gray-200 px-2 py-1.5 text-sm focus:border-blue-500 focus:outline-none" />
|
||||
<span v-else class="text-gray-600">{{ ligne.reference || '—' }}</span>
|
||||
</td>
|
||||
|
||||
<!-- Quantité -->
|
||||
<td class="px-3 py-2">
|
||||
<input v-if="!readonly" :value="ligne.quantite"
|
||||
@input="updateLigne(idx, 'quantite', $event.target.value)"
|
||||
type="number" min="0.001" step="0.001" required
|
||||
class="block w-full rounded border border-gray-200 px-2 py-1.5 text-sm text-right focus:border-blue-500 focus:outline-none" />
|
||||
<span v-else class="block text-right">{{ ligne.quantite }}</span>
|
||||
</td>
|
||||
|
||||
<!-- Quantité reçue -->
|
||||
<td v-if="showReceived" class="px-3 py-2">
|
||||
<input v-if="!readonly" :value="ligne.quantite_recue"
|
||||
@input="updateLigne(idx, 'quantite_recue', $event.target.value)"
|
||||
type="number" min="0" :max="ligne.quantite" step="0.001"
|
||||
class="block w-full rounded border border-gray-200 px-2 py-1.5 text-sm text-right focus:border-blue-500 focus:outline-none" />
|
||||
<span v-else class="block text-right">{{ ligne.quantite_recue }}</span>
|
||||
</td>
|
||||
|
||||
<!-- Unité -->
|
||||
<td class="px-3 py-2">
|
||||
<input v-if="!readonly" :value="ligne.unite"
|
||||
@input="updateLigne(idx, 'unite', $event.target.value)"
|
||||
type="text" placeholder="unité"
|
||||
class="block w-full rounded border border-gray-200 px-2 py-1.5 text-sm focus:border-blue-500 focus:outline-none" />
|
||||
<span v-else class="text-gray-600">{{ ligne.unite }}</span>
|
||||
</td>
|
||||
|
||||
<!-- Prix HT -->
|
||||
<td class="px-3 py-2">
|
||||
<input v-if="!readonly" :value="ligne.prix_unitaire_ht"
|
||||
@input="updateLigne(idx, 'prix_unitaire_ht', $event.target.value)"
|
||||
type="number" min="0" step="0.01" placeholder="0.00"
|
||||
class="block w-full rounded border border-gray-200 px-2 py-1.5 text-sm text-right focus:border-blue-500 focus:outline-none" />
|
||||
<span v-else class="block text-right">{{ ligne.prix_unitaire_ht != null ? formatCurrency(ligne.prix_unitaire_ht) : '—' }}</span>
|
||||
</td>
|
||||
|
||||
<!-- TVA -->
|
||||
<td class="px-3 py-2">
|
||||
<input v-if="!readonly" :value="ligne.taux_tva"
|
||||
@input="updateLigne(idx, 'taux_tva', $event.target.value)"
|
||||
type="number" min="0" max="100" step="0.1"
|
||||
class="block w-full rounded border border-gray-200 px-2 py-1.5 text-sm text-right focus:border-blue-500 focus:outline-none" />
|
||||
<span v-else class="block text-right">{{ ligne.taux_tva }}%</span>
|
||||
</td>
|
||||
|
||||
<!-- Montant HT -->
|
||||
<td class="px-3 py-2 text-right font-medium text-gray-900">
|
||||
{{ ligne.prix_unitaire_ht != null ? formatCurrency(montantHT(ligne)) : '—' }}
|
||||
</td>
|
||||
|
||||
<!-- Supprimer -->
|
||||
<td v-if="!readonly" class="px-3 py-2 text-center">
|
||||
<button type="button" @click="removeLigne(idx)"
|
||||
class="text-gray-400 hover:text-red-500 transition-colors">
|
||||
<svg class="h-4 w-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" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr v-if="!lignes.length">
|
||||
<td :colspan="readonly ? 8 : 9" class="px-3 py-6 text-center text-sm text-gray-400 italic">
|
||||
Aucune ligne de commande.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
<!-- Totaux -->
|
||||
<tfoot v-if="lignes.length" class="bg-gray-50 font-medium">
|
||||
<tr>
|
||||
<td :colspan="readonly ? 6 : 7" class="px-3 py-2 text-right text-sm text-gray-600">Total HT</td>
|
||||
<td colspan="2" class="px-3 py-2 text-right text-sm">{{ formatCurrency(totalHT) }}</td>
|
||||
<td v-if="!readonly"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td :colspan="readonly ? 6 : 7" class="px-3 py-2 text-right text-sm text-gray-600">Total TTC</td>
|
||||
<td colspan="2" class="px-3 py-2 text-right font-bold text-blue-700">{{ formatCurrency(totalTTC) }}</td>
|
||||
<td v-if="!readonly"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<button v-if="!readonly" type="button" @click="addLigne"
|
||||
class="mt-3 flex items-center gap-2 text-sm font-medium text-blue-600 hover:text-blue-800 transition-colors">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Ajouter une ligne
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
244
resources/js/Components/Commandes/PiecesJointesSection.vue
Normal file
244
resources/js/Components/Commandes/PiecesJointesSection.vue
Normal file
@@ -0,0 +1,244 @@
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useForm, router } from '@inertiajs/vue3'
|
||||
import ConfirmModal from '@/Components/ConfirmModal.vue'
|
||||
|
||||
const props = defineProps({
|
||||
commande: Object,
|
||||
})
|
||||
|
||||
// ── Upload form ────────────────────────────────────────────────────────────────
|
||||
const showUploadForm = ref(false)
|
||||
|
||||
const form = useForm({
|
||||
type: '',
|
||||
fichier: null,
|
||||
description: '',
|
||||
})
|
||||
|
||||
const fileInput = ref(null)
|
||||
|
||||
function selectFile(event) {
|
||||
form.fichier = event.target.files[0] ?? null
|
||||
}
|
||||
|
||||
function submitUpload() {
|
||||
form.post(route('pieces-jointes.store', props.commande.id), {
|
||||
forceFormData: true,
|
||||
onSuccess: () => {
|
||||
form.reset()
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
showUploadForm.value = false
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function cancelUpload() {
|
||||
form.reset()
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
showUploadForm.value = false
|
||||
}
|
||||
|
||||
// ── Delete ─────────────────────────────────────────────────────────────────────
|
||||
const confirmDelete = ref(false)
|
||||
const deletingId = ref(null)
|
||||
|
||||
function askDelete(id) {
|
||||
deletingId.value = id
|
||||
confirmDelete.value = true
|
||||
}
|
||||
|
||||
function doDelete() {
|
||||
router.delete(route('pieces-jointes.destroy', deletingId.value), {
|
||||
preserveScroll: true,
|
||||
onFinish: () => {
|
||||
confirmDelete.value = false
|
||||
deletingId.value = null
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
const TYPES_LABELS = {
|
||||
devis: 'Devis',
|
||||
bon_commande: 'Bon de commande',
|
||||
bon_livraison: 'Bon de livraison',
|
||||
facture: 'Facture',
|
||||
autre: 'Autre',
|
||||
}
|
||||
|
||||
const TYPE_COLORS = {
|
||||
devis: 'bg-purple-100 text-purple-700',
|
||||
bon_commande: 'bg-blue-100 text-blue-700',
|
||||
bon_livraison: 'bg-orange-100 text-orange-700',
|
||||
facture: 'bg-green-100 text-green-700',
|
||||
autre: 'bg-gray-100 text-gray-600',
|
||||
}
|
||||
|
||||
function formatDate(d) {
|
||||
if (!d) return '—'
|
||||
return new Intl.DateTimeFormat('fr-FR', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
}).format(new Date(d))
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (!bytes) return ''
|
||||
if (bytes < 1024) return bytes + ' o'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' Ko'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' Mo'
|
||||
}
|
||||
|
||||
function fileIcon(mimeType) {
|
||||
if (!mimeType) return '📎'
|
||||
if (mimeType === 'application/pdf') return '📄'
|
||||
if (mimeType.startsWith('image/')) return '🖼️'
|
||||
if (['application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.oasis.opendocument.text'].includes(mimeType)) return '📝'
|
||||
if (['application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.oasis.opendocument.spreadsheet',
|
||||
'text/csv'].includes(mimeType)) return '📊'
|
||||
if (mimeType === 'application/zip') return '🗜️'
|
||||
return '📎'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ── Section header ──────────────────────────────────────────────────── -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wide text-gray-500">Pièces jointes</h2>
|
||||
<button v-if="!showUploadForm"
|
||||
@click="showUploadForm = true"
|
||||
class="flex items-center gap-1.5 rounded-lg bg-blue-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-700 transition-colors">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Ajouter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Upload form ─────────────────────────────────────────────────────── -->
|
||||
<div v-if="showUploadForm"
|
||||
class="mb-4 rounded-lg border border-blue-200 bg-blue-50 p-4 space-y-3">
|
||||
<p class="text-xs font-semibold text-blue-800 uppercase tracking-wide">Nouveau document</p>
|
||||
|
||||
<!-- Type -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Type <span class="text-red-500">*</span></label>
|
||||
<select v-model="form.type"
|
||||
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
:class="{ 'border-red-400': form.errors.type }">
|
||||
<option value="">— Sélectionner un type —</option>
|
||||
<option v-for="(label, key) in TYPES_LABELS" :key="key" :value="key">{{ label }}</option>
|
||||
</select>
|
||||
<p v-if="form.errors.type" class="mt-1 text-xs text-red-500">{{ form.errors.type }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Fichier -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Fichier <span class="text-red-500">*</span></label>
|
||||
<input ref="fileInput" type="file"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.gif,.webp,.doc,.docx,.xls,.xlsx,.odt,.ods,.csv,.zip"
|
||||
@change="selectFile"
|
||||
class="block w-full text-sm text-gray-700
|
||||
file:mr-3 file:rounded-md file:border-0
|
||||
file:bg-blue-600 file:px-3 file:py-1.5
|
||||
file:text-xs file:font-medium file:text-white
|
||||
hover:file:bg-blue-700 file:cursor-pointer"
|
||||
:class="{ 'ring-1 ring-red-400 rounded-lg': form.errors.fichier }" />
|
||||
<p class="mt-1 text-xs text-gray-400">PDF, images, Word, Excel, OpenDocument, CSV, ZIP — 20 Mo max</p>
|
||||
<p v-if="form.errors.fichier" class="mt-1 text-xs text-red-500">{{ form.errors.fichier }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Description (optionnel)</label>
|
||||
<input v-model="form.description" type="text" maxlength="500"
|
||||
placeholder="ex : Devis n°DEV-2026-042 du 01/04/2026"
|
||||
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" />
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button type="button" @click="cancelUpload"
|
||||
class="rounded-lg border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-50 transition-colors">
|
||||
Annuler
|
||||
</button>
|
||||
<button type="button" @click="submitUpload"
|
||||
:disabled="form.processing || !form.type || !form.fichier"
|
||||
class="rounded-lg bg-blue-600 px-4 py-1.5 text-xs font-medium text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
|
||||
<span v-if="form.processing">Envoi…</span>
|
||||
<span v-else>Envoyer</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Liste des pièces jointes ───────────────────────────────────────── -->
|
||||
<div v-if="commande.pieces_jointes && commande.pieces_jointes.length" class="divide-y divide-gray-100">
|
||||
<div v-for="pj in commande.pieces_jointes" :key="pj.id"
|
||||
class="flex items-start gap-3 py-3 first:pt-0 last:pb-0">
|
||||
<!-- Icône fichier -->
|
||||
<span class="text-2xl leading-none mt-0.5 shrink-0">{{ fileIcon(pj.mime_type) }}</span>
|
||||
|
||||
<!-- Infos -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<!-- Badge type -->
|
||||
<span :class="['rounded-full px-2 py-0.5 text-xs font-medium', TYPE_COLORS[pj.type] ?? 'bg-gray-100 text-gray-600']">
|
||||
{{ TYPES_LABELS[pj.type] ?? pj.type }}
|
||||
</span>
|
||||
<!-- Nom du fichier -->
|
||||
<span class="truncate text-sm font-medium text-gray-900">{{ pj.nom_original }}</span>
|
||||
<!-- Taille -->
|
||||
<span class="text-xs text-gray-400">{{ formatSize(pj.taille) }}</span>
|
||||
</div>
|
||||
<!-- Description -->
|
||||
<p v-if="pj.description" class="mt-0.5 text-xs text-gray-500">{{ pj.description }}</p>
|
||||
<!-- Métadonnées -->
|
||||
<p class="mt-0.5 text-xs text-gray-400">
|
||||
Ajouté par <span class="font-medium text-gray-600">{{ pj.user?.name ?? '—' }}</span>
|
||||
le {{ formatDate(pj.created_at) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<!-- Télécharger -->
|
||||
<a :href="route('pieces-jointes.download', pj.id)"
|
||||
class="rounded p-1.5 text-gray-400 hover:bg-gray-100 hover:text-blue-600 transition-colors"
|
||||
title="Télécharger">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
</a>
|
||||
<!-- Supprimer -->
|
||||
<button @click="askDelete(pj.id)"
|
||||
class="rounded p-1.5 text-gray-400 hover:bg-red-50 hover:text-red-600 transition-colors"
|
||||
title="Supprimer">
|
||||
<svg class="h-4 w-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" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── État vide ───────────────────────────────────────────────────────── -->
|
||||
<p v-else-if="!showUploadForm" class="text-sm text-gray-400 italic">
|
||||
Aucun document joint pour l'instant.
|
||||
</p>
|
||||
|
||||
<!-- ── Modal de confirmation suppression ──────────────────────────────── -->
|
||||
<ConfirmModal
|
||||
:show="confirmDelete"
|
||||
title="Supprimer la pièce jointe"
|
||||
message="Cette action est irréversible. Le fichier sera définitivement supprimé du serveur."
|
||||
confirm-label="Supprimer"
|
||||
@confirm="doDelete"
|
||||
@cancel="confirmDelete = false" />
|
||||
</template>
|
||||
19
resources/js/Components/Commandes/PrioriteBadge.vue
Normal file
19
resources/js/Components/Commandes/PrioriteBadge.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
priorite: { type: String, required: true },
|
||||
})
|
||||
|
||||
const labels = { normale: 'Normale', haute: 'Haute', urgente: 'Urgente' }
|
||||
const colors = {
|
||||
normale: 'bg-gray-100 text-gray-600',
|
||||
haute: 'bg-amber-100 text-amber-700',
|
||||
urgente: 'bg-red-100 text-red-700',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="['inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium whitespace-nowrap', colors[priorite] ?? colors.normale]">
|
||||
<span v-if="priorite === 'urgente'" class="mr-1">⚡</span>
|
||||
{{ labels[priorite] ?? priorite }}
|
||||
</span>
|
||||
</template>
|
||||
40
resources/js/Components/Commandes/StatutBadge.vue
Normal file
40
resources/js/Components/Commandes/StatutBadge.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script setup>
|
||||
import { usePage } from '@inertiajs/vue3'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
statut: { type: String, required: true },
|
||||
size: { type: String, default: 'md' },
|
||||
})
|
||||
|
||||
const page = usePage()
|
||||
|
||||
const label = computed(() => page.props.config?.statuts?.[props.statut] ?? props.statut)
|
||||
|
||||
const colorClasses = {
|
||||
brouillon: 'bg-gray-100 text-gray-700',
|
||||
en_attente_validation: 'bg-yellow-100 text-yellow-700',
|
||||
validee: 'bg-blue-100 text-blue-700',
|
||||
commandee: 'bg-indigo-100 text-indigo-700',
|
||||
partiellement_recue: 'bg-orange-100 text-orange-700',
|
||||
recue_complete: 'bg-green-100 text-green-700',
|
||||
cloturee: 'bg-slate-100 text-slate-600',
|
||||
annulee: 'bg-red-100 text-red-700',
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-2 py-0.5 text-xs',
|
||||
md: 'px-2.5 py-1 text-xs',
|
||||
lg: 'px-3 py-1.5 text-sm',
|
||||
}
|
||||
|
||||
const classes = computed(() => [
|
||||
'inline-flex items-center rounded-full font-medium whitespace-nowrap',
|
||||
colorClasses[props.statut] ?? 'bg-gray-100 text-gray-700',
|
||||
sizeClasses[props.size] ?? sizeClasses.md,
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="classes">{{ label }}</span>
|
||||
</template>
|
||||
73
resources/js/Components/Commandes/TransitionModal.vue
Normal file
73
resources/js/Components/Commandes/TransitionModal.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useForm, usePage } from '@inertiajs/vue3'
|
||||
|
||||
const props = defineProps({
|
||||
show: Boolean,
|
||||
commande: Object,
|
||||
targetStatut: String,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
const page = usePage()
|
||||
|
||||
const form = useForm({ statut: '', commentaire: '' })
|
||||
|
||||
function submit() {
|
||||
form.statut = props.targetStatut
|
||||
form.patch(route('commandes.transition', props.commande.id), {
|
||||
onSuccess: () => {
|
||||
form.reset()
|
||||
emit('close')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function close() {
|
||||
form.reset()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const statutLabel = (s) => page.props.config?.statuts?.[s] ?? s
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition enter-from-class="opacity-0" enter-active-class="transition-opacity duration-150"
|
||||
leave-to-class="opacity-0" leave-active-class="transition-opacity duration-150">
|
||||
<div v-if="show" class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/50" @click="close" />
|
||||
<div class="relative w-full max-w-md rounded-xl bg-white p-6 shadow-xl mx-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Changer le statut</h3>
|
||||
<p class="mt-1 text-sm text-gray-600">
|
||||
Commande <strong>{{ commande?.numero_commande }}</strong> →
|
||||
<strong>{{ statutLabel(targetStatut) }}</strong>
|
||||
</p>
|
||||
|
||||
<form @submit.prevent="submit" class="mt-4 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">
|
||||
Commentaire
|
||||
<span v-if="targetStatut === 'annulee'" class="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea v-model="form.commentaire" rows="3"
|
||||
:required="targetStatut === 'annulee'"
|
||||
placeholder="Optionnel — précisez la raison si nécessaire"
|
||||
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" />
|
||||
<p v-if="form.errors.commentaire" class="mt-1 text-xs text-red-600">{{ form.errors.commentaire }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" @click="close"
|
||||
class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors">
|
||||
Annuler
|
||||
</button>
|
||||
<button type="submit" :disabled="form.processing"
|
||||
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50 transition-colors">
|
||||
{{ form.processing ? 'Mise à jour...' : 'Confirmer' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
34
resources/js/Components/ConfirmModal.vue
Normal file
34
resources/js/Components/ConfirmModal.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
show: Boolean,
|
||||
title: { type: String, default: 'Confirmer la suppression' },
|
||||
message: { type: String, default: 'Cette action est irréversible.' },
|
||||
confirmLabel: { type: String, default: 'Supprimer' },
|
||||
processing: Boolean,
|
||||
})
|
||||
|
||||
defineEmits(['confirm', 'cancel'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition enter-from-class="opacity-0" enter-active-class="transition-opacity duration-150"
|
||||
leave-to-class="opacity-0" leave-active-class="transition-opacity duration-150">
|
||||
<div v-if="show" class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/50" @click="$emit('cancel')" />
|
||||
<div class="relative w-full max-w-md rounded-xl bg-white p-6 shadow-xl mx-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">{{ title }}</h3>
|
||||
<p class="mt-2 text-sm text-gray-600">{{ message }}</p>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button @click="$emit('cancel')"
|
||||
class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors">
|
||||
Annuler
|
||||
</button>
|
||||
<button @click="$emit('confirm')" :disabled="processing"
|
||||
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50 transition-colors">
|
||||
{{ processing ? 'Suppression...' : confirmLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
243
resources/js/Components/Contrats/PiecesJointesSection.vue
Normal file
243
resources/js/Components/Contrats/PiecesJointesSection.vue
Normal file
@@ -0,0 +1,243 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useForm, router } from '@inertiajs/vue3'
|
||||
import ConfirmModal from '@/Components/ConfirmModal.vue'
|
||||
|
||||
const props = defineProps({
|
||||
contrat: Object,
|
||||
})
|
||||
|
||||
// ── Upload form ────────────────────────────────────────────────────────────────
|
||||
const showUploadForm = ref(false)
|
||||
|
||||
const form = useForm({
|
||||
type: '',
|
||||
fichier: null,
|
||||
description: '',
|
||||
})
|
||||
|
||||
const fileInput = ref(null)
|
||||
|
||||
function selectFile(event) {
|
||||
form.fichier = event.target.files[0] ?? null
|
||||
}
|
||||
|
||||
function submitUpload() {
|
||||
form.post(route('pieces-jointes.contrat.store', props.contrat.id), {
|
||||
forceFormData: true,
|
||||
onSuccess: () => {
|
||||
form.reset()
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
showUploadForm.value = false
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function cancelUpload() {
|
||||
form.reset()
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
showUploadForm.value = false
|
||||
}
|
||||
|
||||
// ── Delete ─────────────────────────────────────────────────────────────────────
|
||||
const confirmDelete = ref(false)
|
||||
const deletingId = ref(null)
|
||||
|
||||
function askDelete(id) {
|
||||
deletingId.value = id
|
||||
confirmDelete.value = true
|
||||
}
|
||||
|
||||
function doDelete() {
|
||||
router.delete(route('pieces-jointes.destroy', deletingId.value), {
|
||||
preserveScroll: true,
|
||||
onFinish: () => {
|
||||
confirmDelete.value = false
|
||||
deletingId.value = null
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
const TYPES_LABELS = {
|
||||
contrat: 'Contrat',
|
||||
avenant: 'Avenant',
|
||||
facture: 'Facture',
|
||||
autre: 'Autre',
|
||||
}
|
||||
|
||||
const TYPE_COLORS = {
|
||||
contrat: 'bg-indigo-100 text-indigo-700',
|
||||
avenant: 'bg-teal-100 text-teal-700',
|
||||
facture: 'bg-green-100 text-green-700',
|
||||
autre: 'bg-gray-100 text-gray-600',
|
||||
}
|
||||
|
||||
function formatDate(d) {
|
||||
if (!d) return '—'
|
||||
return new Intl.DateTimeFormat('fr-FR', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
}).format(new Date(d))
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (!bytes) return ''
|
||||
if (bytes < 1024) return bytes + ' o'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' Ko'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' Mo'
|
||||
}
|
||||
|
||||
function fileIcon(mimeType) {
|
||||
if (!mimeType) return '📎'
|
||||
if (mimeType === 'application/pdf') return '📄'
|
||||
if (mimeType.startsWith('image/')) return '🖼️'
|
||||
if (['application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.oasis.opendocument.text'].includes(mimeType)) return '📝'
|
||||
if (['application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.oasis.opendocument.spreadsheet',
|
||||
'text/csv'].includes(mimeType)) return '📊'
|
||||
if (mimeType === 'application/zip') return '🗜️'
|
||||
return '📎'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ── Section header ──────────────────────────────────────────────────── -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wide text-gray-500">Pièces jointes</h2>
|
||||
<button v-if="!showUploadForm"
|
||||
@click="showUploadForm = true"
|
||||
class="flex items-center gap-1.5 rounded-lg bg-blue-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-700 transition-colors">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Ajouter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Upload form ─────────────────────────────────────────────────────── -->
|
||||
<div v-if="showUploadForm"
|
||||
class="mb-4 rounded-lg border border-blue-200 bg-blue-50 p-4 space-y-3">
|
||||
<p class="text-xs font-semibold text-blue-800 uppercase tracking-wide">Nouveau document</p>
|
||||
|
||||
<!-- Type -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Type <span class="text-red-500">*</span></label>
|
||||
<select v-model="form.type"
|
||||
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
:class="{ 'border-red-400': form.errors.type }">
|
||||
<option value="">— Sélectionner un type —</option>
|
||||
<option v-for="(label, key) in TYPES_LABELS" :key="key" :value="key">{{ label }}</option>
|
||||
</select>
|
||||
<p v-if="form.errors.type" class="mt-1 text-xs text-red-500">{{ form.errors.type }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Fichier -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Fichier <span class="text-red-500">*</span></label>
|
||||
<input ref="fileInput" type="file"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.gif,.webp,.doc,.docx,.xls,.xlsx,.odt,.ods,.csv,.zip"
|
||||
@change="selectFile"
|
||||
class="block w-full text-sm text-gray-700
|
||||
file:mr-3 file:rounded-md file:border-0
|
||||
file:bg-blue-600 file:px-3 file:py-1.5
|
||||
file:text-xs file:font-medium file:text-white
|
||||
hover:file:bg-blue-700 file:cursor-pointer"
|
||||
:class="{ 'ring-1 ring-red-400 rounded-lg': form.errors.fichier }" />
|
||||
<p class="mt-1 text-xs text-gray-400">PDF, images, Word, Excel, OpenDocument, CSV, ZIP — 20 Mo max</p>
|
||||
<p v-if="form.errors.fichier" class="mt-1 text-xs text-red-500">{{ form.errors.fichier }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Description (optionnel)</label>
|
||||
<input v-model="form.description" type="text" maxlength="500"
|
||||
placeholder="ex : Contrat final signé"
|
||||
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" />
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button type="button" @click="cancelUpload"
|
||||
class="rounded-lg border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-50 transition-colors">
|
||||
Annuler
|
||||
</button>
|
||||
<button type="button" @click="submitUpload"
|
||||
:disabled="form.processing || !form.type || !form.fichier"
|
||||
class="rounded-lg bg-blue-600 px-4 py-1.5 text-xs font-medium text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
|
||||
<span v-if="form.processing">Envoi…</span>
|
||||
<span v-else>Envoyer</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Liste des pièces jointes ───────────────────────────────────────── -->
|
||||
<div v-if="contrat.pieces_jointes && contrat.pieces_jointes.length" class="divide-y divide-gray-100">
|
||||
<div v-for="pj in contrat.pieces_jointes" :key="pj.id"
|
||||
class="flex items-start gap-3 py-3 first:pt-0 last:pb-0">
|
||||
<!-- Icône fichier -->
|
||||
<span class="text-2xl leading-none mt-0.5 shrink-0">{{ fileIcon(pj.mime_type) }}</span>
|
||||
|
||||
<!-- Infos -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<!-- Badge type -->
|
||||
<span :class="['rounded-full px-2 py-0.5 text-xs font-medium', TYPE_COLORS[pj.type] ?? 'bg-gray-100 text-gray-600']">
|
||||
{{ TYPES_LABELS[pj.type] ?? pj.type }}
|
||||
</span>
|
||||
<!-- Nom du fichier -->
|
||||
<span class="truncate text-sm font-medium text-gray-900">{{ pj.nom_original }}</span>
|
||||
<!-- Taille -->
|
||||
<span class="text-xs text-gray-400">{{ formatSize(pj.taille) }}</span>
|
||||
</div>
|
||||
<!-- Description -->
|
||||
<p v-if="pj.description" class="mt-0.5 text-xs text-gray-500">{{ pj.description }}</p>
|
||||
<!-- Métadonnées -->
|
||||
<p class="mt-0.5 text-xs text-gray-400">
|
||||
Ajouté par <span class="font-medium text-gray-600">{{ pj.user?.name ?? '—' }}</span>
|
||||
le {{ formatDate(pj.created_at) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<!-- Télécharger -->
|
||||
<a :href="route('pieces-jointes.download', pj.id)"
|
||||
class="rounded p-1.5 text-gray-400 hover:bg-gray-100 hover:text-blue-600 transition-colors"
|
||||
title="Télécharger">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
</a>
|
||||
<!-- Supprimer -->
|
||||
<button v-if="$page.props.auth.user?.roles?.some(r => r.name === 'admin') || pj.user_id === $page.props.auth.user.id"
|
||||
@click="askDelete(pj.id)"
|
||||
class="rounded p-1.5 text-gray-400 hover:bg-red-50 hover:text-red-600 transition-colors"
|
||||
title="Supprimer">
|
||||
<svg class="h-4 w-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" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── État vide ───────────────────────────────────────────────────────── -->
|
||||
<p v-else-if="!showUploadForm" class="text-sm text-gray-400 italic">
|
||||
Aucun document joint pour l'instant.
|
||||
</p>
|
||||
|
||||
<!-- ── Modal de confirmation suppression ──────────────────────────────── -->
|
||||
<ConfirmModal
|
||||
:show="confirmDelete"
|
||||
title="Supprimer la pièce jointe"
|
||||
message="Cette action est irréversible. Le fichier sera définitivement supprimé du serveur."
|
||||
confirm-label="Supprimer"
|
||||
@confirm="doDelete"
|
||||
@cancel="confirmDelete = false" />
|
||||
</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"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</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',
|
||||
},
|
||||
});
|
||||
|
||||
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"
|
||||
>
|
||||
<slot />
|
||||
</Link>
|
||||
</template>
|
||||
51
resources/js/Components/FlashMessage.vue
Normal file
51
resources/js/Components/FlashMessage.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { usePage } from '@inertiajs/vue3'
|
||||
|
||||
const page = usePage()
|
||||
const visible = ref(false)
|
||||
const type = ref('success')
|
||||
const message = ref('')
|
||||
|
||||
watch(() => page.props.flash, (flash) => {
|
||||
if (flash?.success) {
|
||||
type.value = 'success'
|
||||
message.value = flash.success
|
||||
visible.value = true
|
||||
setTimeout(() => visible.value = false, 4000)
|
||||
} else if (flash?.error) {
|
||||
type.value = 'error'
|
||||
message.value = flash.error
|
||||
visible.value = true
|
||||
setTimeout(() => visible.value = false, 6000)
|
||||
}
|
||||
}, { immediate: true, deep: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition enter-from-class="opacity-0 -translate-y-2" enter-active-class="transition-all duration-300"
|
||||
leave-to-class="opacity-0 -translate-y-2" leave-active-class="transition-all duration-200">
|
||||
<div v-if="visible"
|
||||
:class="['mx-6 mt-4 flex items-center gap-3 rounded-lg px-4 py-3 text-sm font-medium shadow-sm',
|
||||
type === 'success' ? 'bg-green-50 text-green-800 border border-green-200' : 'bg-red-50 text-red-800 border border-red-200']">
|
||||
<svg v-if="type === 'success'" class="h-5 w-5 flex-shrink-0 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
<svg v-else class="h-5 w-5 flex-shrink-0 text-red-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span class="flex-1">{{ message }}</span>
|
||||
<button @click="visible = false" class="flex-shrink-0 opacity-60 hover:opacity-100">
|
||||
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</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">
|
||||
{{ 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">
|
||||
<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"
|
||||
/>
|
||||
</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"
|
||||
: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 text-sm font-medium leading-5 text-gray-900 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 hover:text-gray-700 hover:border-gray-300 focus:outline-none focus:text-gray-700 focus:border-gray-300 transition duration-150 ease-in-out',
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Link :href="href" :class="classes">
|
||||
<slot />
|
||||
</Link>
|
||||
</template>
|
||||
28
resources/js/Components/Pagination.vue
Normal file
28
resources/js/Components/Pagination.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup>
|
||||
import { Link } from '@inertiajs/vue3'
|
||||
|
||||
defineProps({
|
||||
links: { type: Array, required: true },
|
||||
meta: { type: Object, default: null },
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="links.length > 3" class="flex flex-wrap items-center justify-between gap-4 mt-6">
|
||||
<p v-if="meta" class="text-sm text-gray-600">
|
||||
Affichage de {{ meta.from }} à {{ meta.to }} sur {{ meta.total }} résultats
|
||||
</p>
|
||||
<div class="flex gap-1">
|
||||
<template v-for="link in links" :key="link.label">
|
||||
<Link v-if="link.url" :href="link.url" preserve-scroll
|
||||
:class="['px-3 py-1.5 text-sm rounded-md border transition-colors',
|
||||
link.active
|
||||
? 'bg-blue-600 border-blue-600 text-white'
|
||||
: 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50']"
|
||||
v-html="link.label" />
|
||||
<span v-else :class="['px-3 py-1.5 text-sm rounded-md border bg-white border-gray-200 text-gray-400 cursor-not-allowed']"
|
||||
v-html="link.label" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</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"
|
||||
>
|
||||
<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 text-start text-base font-medium text-indigo-700 bg-indigo-50 focus:outline-none focus:text-indigo-800 focus:bg-indigo-100 focus:border-indigo-700 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 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300 focus:outline-none focus:text-gray-800 focus:bg-gray-50 focus:border-gray-300 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"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
22
resources/js/Components/StatCard.vue
Normal file
22
resources/js/Components/StatCard.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
label: String,
|
||||
value: [Number, String],
|
||||
color: { type: String, default: 'blue' },
|
||||
icon: { type: String, default: 'chart' },
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-xl bg-white p-5 shadow-sm border border-gray-100">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500">{{ label }}</p>
|
||||
<p class="mt-1 text-3xl font-bold text-gray-900">{{ value }}</p>
|
||||
</div>
|
||||
<div :class="['flex h-12 w-12 items-center justify-center rounded-xl', `bg-${color}-100`]">
|
||||
<span :class="[`text-${color}-600`, 'text-xl font-bold']">{{ typeof value === 'number' ? '#' : '•' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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"
|
||||
v-model="model"
|
||||
ref="input"
|
||||
/>
|
||||
</template>
|
||||
Reference in New Issue
Block a user