Initial commit with contrats and domaines modules
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user