385 lines
23 KiB
Vue
385 lines
23 KiB
Vue
<script setup>
|
||
import { ref, computed } from 'vue';
|
||
import { Head, Link, useForm, usePage } from '@inertiajs/vue3';
|
||
|
||
const page = usePage();
|
||
const ollamaAvailable = computed(() => page.props.ollamaAvailable);
|
||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
|
||
import axios from 'axios';
|
||
|
||
const props = defineProps({
|
||
commande: Object,
|
||
});
|
||
|
||
const isOcrLoading = ref(false);
|
||
const ocrError = ref(null);
|
||
const selectedModel = ref('gemini');
|
||
|
||
const loadPdfJs = () => {
|
||
return new Promise((resolve, reject) => {
|
||
if (window.pdfjsLib) {
|
||
resolve(window.pdfjsLib);
|
||
return;
|
||
}
|
||
const script = document.createElement('script');
|
||
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.4.120/pdf.min.js';
|
||
script.onload = () => {
|
||
window.pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.4.120/pdf.worker.min.js';
|
||
resolve(window.pdfjsLib);
|
||
};
|
||
script.onerror = reject;
|
||
document.head.appendChild(script);
|
||
});
|
||
};
|
||
|
||
const convertPdfBlobToImageFile = async (blob, fileName) => {
|
||
const pdfjsLib = await loadPdfJs();
|
||
const arrayBuffer = await blob.arrayBuffer();
|
||
const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
|
||
const pdf = await loadingTask.promise;
|
||
const page = await pdf.getPage(1);
|
||
|
||
const viewport = page.getViewport({ scale: 2.0 });
|
||
const canvas = document.createElement('canvas');
|
||
const context = canvas.getContext('2d');
|
||
canvas.height = viewport.height;
|
||
canvas.width = viewport.width;
|
||
|
||
await page.render({ canvasContext: context, viewport }).promise;
|
||
|
||
return new Promise((resolve) => {
|
||
canvas.toBlob((convertedBlob) => {
|
||
resolve(new File([convertedBlob], fileName.replace(/\.pdf$/i, '.png'), { type: 'image/png' }));
|
||
}, 'image/png');
|
||
});
|
||
};
|
||
|
||
const runOcr = async (att) => {
|
||
isOcrLoading.value = true;
|
||
ocrError.value = null;
|
||
try {
|
||
let response;
|
||
const isPdf = /\.pdf$/i.test(att.file_name);
|
||
|
||
if (selectedModel.value === 'ollama' && isPdf) {
|
||
const pdfResponse = await axios.get(route('attachments.show', att.id), { responseType: 'blob' });
|
||
const convertedFile = await convertPdfBlobToImageFile(pdfResponse.data, att.file_name);
|
||
|
||
const formData = new FormData();
|
||
formData.append('file', convertedFile);
|
||
formData.append('mode', 'ollama');
|
||
|
||
response = await axios.post(route('attachments.ocr-upload'), formData, {
|
||
headers: { 'Content-Type': 'multipart/form-data' }
|
||
});
|
||
} else {
|
||
response = await axios.post(route('attachments.ocr', att.id), {
|
||
mode: selectedModel.value
|
||
});
|
||
}
|
||
if (response.data.success && response.data.data) {
|
||
const data = response.data.data;
|
||
if (data.title) form.title = data.title;
|
||
if (data.date_devis) form.date_devis = data.date_devis;
|
||
if (data.tva_rate !== undefined) form.tva_rate = data.tva_rate;
|
||
if (data.items && data.items.length > 0) {
|
||
form.items = data.items.map(item => ({
|
||
type: item.type || 'Matériel',
|
||
designation: item.designation || '',
|
||
quantite: item.quantite || 1,
|
||
prix_unitaire: item.prix_unitaire || 0
|
||
}));
|
||
}
|
||
} else {
|
||
ocrError.value = response.data.message || "Une erreur est survenue lors de l'analyse.";
|
||
}
|
||
} catch (e) {
|
||
ocrError.value = e.response?.data?.message || "Erreur de connexion avec le serveur ou la clé API est incorrecte.";
|
||
console.error(e);
|
||
} finally {
|
||
isOcrLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const itemTypes = ['Matériel', 'Licence', 'Prestation', 'Infrastructure', 'Autre'];
|
||
|
||
const form = useForm({
|
||
title: '',
|
||
destinataire_type: 'commune',
|
||
destinataire_nom: '',
|
||
referent: '',
|
||
date_devis: new Date().toISOString().split('T')[0],
|
||
status: 'draft',
|
||
items: [
|
||
{ type: 'Matériel', designation: '', quantite: 1, prix_unitaire: 0 }
|
||
],
|
||
tva_rate: 20,
|
||
notes: '',
|
||
});
|
||
|
||
const addItem = () => {
|
||
form.items.push({ type: 'Matériel', designation: '', quantite: 1, prix_unitaire: 0 });
|
||
};
|
||
|
||
const removeItem = (index) => {
|
||
form.items.splice(index, 1);
|
||
if (form.items.length === 0) addItem();
|
||
};
|
||
|
||
const totalHt = computed(() =>
|
||
form.items.reduce((sum, i) =>
|
||
sum + (parseFloat(i.quantite) || 0) * (parseFloat(i.prix_unitaire) || 0), 0)
|
||
);
|
||
|
||
const tvaAmount = computed(() =>
|
||
totalHt.value * ((parseFloat(form.tva_rate) || 0) / 100)
|
||
);
|
||
|
||
const totalTtc = computed(() => totalHt.value + tvaAmount.value);
|
||
|
||
const formatCurrency = (v) =>
|
||
new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(v);
|
||
|
||
const submit = () => {
|
||
form.post(route('commandes.sous-devis.store', props.commande.id));
|
||
};
|
||
</script>
|
||
|
||
<template>
|
||
<Head title="Créer un Sous-Devis" />
|
||
|
||
<AuthenticatedLayout>
|
||
<template #header>
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<h2 class="text-xl font-bold leading-tight text-slate-800 dark:text-slate-200">
|
||
Nouveau Sous-Devis
|
||
</h2>
|
||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
|
||
Commande
|
||
<Link :href="route('commandes.show', commande.id)" class="text-sky-600 dark:text-sky-400 font-semibold hover:underline">
|
||
{{ commande.number }}
|
||
</Link>
|
||
— {{ commande.label }}
|
||
<span v-if="commande.supplier" class="text-slate-400"> · {{ commande.supplier }}</span>
|
||
<span v-if="commande.quote_number" class="font-mono text-xs text-slate-400"> (Réf. devis fournisseur : {{ commande.quote_number }})</span>
|
||
</p>
|
||
</div>
|
||
<Link
|
||
:href="route('commandes.show', commande.id)"
|
||
class="inline-flex items-center px-4 py-2 text-sm font-semibold text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 dark:bg-slate-900 dark:text-slate-300 dark:border-slate-850 dark:hover:bg-slate-800 transition-colors"
|
||
>
|
||
Retour à la commande
|
||
</Link>
|
||
</div>
|
||
</template>
|
||
|
||
<div class="py-6">
|
||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
|
||
<form @submit.prevent="submit" class="space-y-6">
|
||
|
||
<!-- Bannière OCR Gemini -->
|
||
<div v-if="commande.quote_attachments && commande.quote_attachments.length > 0" class="bg-gradient-to-r from-violet-50 to-indigo-50 dark:from-violet-950/20 dark:to-indigo-950/20 border border-violet-100 dark:border-violet-900/40 rounded-xl shadow-sm p-6 flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||
<div class="flex items-start gap-3">
|
||
<span class="text-2xl mt-0.5">✨</span>
|
||
<div>
|
||
<h4 class="font-bold text-slate-800 dark:text-slate-200 text-sm">Remplissage automatique intelligent (OCR)</h4>
|
||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||
Un devis fournisseur est joint à cette commande. Vous pouvez l'analyser pour remplir automatiquement ce formulaire.
|
||
</p>
|
||
<p v-if="ocrError" class="text-xs font-semibold text-rose-600 mt-1 flex items-center gap-1">
|
||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
|
||
{{ ocrError }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div class="flex flex-wrap items-center gap-3">
|
||
<!-- Sélecteur de modèle -->
|
||
<div v-if="ollamaAvailable" class="flex items-center gap-2 bg-white/80 dark:bg-slate-900/80 border border-violet-100 dark:border-violet-900/40 rounded-lg px-2.5 py-1.5 shadow-sm">
|
||
<span class="text-xs text-slate-500 dark:text-slate-400">Modèle OCR :</span>
|
||
<select v-model="selectedModel" class="text-xs font-semibold border-0 p-0 pr-6 focus:ring-0 bg-transparent text-slate-700 dark:text-slate-300">
|
||
<option value="gemini">Gemini API</option>
|
||
<option value="ollama">GLM-OCR (Ollama)</option>
|
||
</select>
|
||
</div>
|
||
<button
|
||
v-for="att in commande.quote_attachments"
|
||
:key="att.id"
|
||
type="button"
|
||
@click="runOcr(att)"
|
||
:disabled="isOcrLoading"
|
||
class="inline-flex items-center gap-2 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-violet-600 to-indigo-600 hover:from-violet-500 hover:to-indigo-500 rounded-lg shadow-sm focus:outline-none transition-all disabled:opacity-50"
|
||
>
|
||
<svg v-if="isOcrLoading" class="animate-spin h-3 w-3 text-white" fill="none" viewBox="0 0 24 24">
|
||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||
</svg>
|
||
<span v-else>🔍</span>
|
||
{{ isOcrLoading ? 'Analyse en cours...' : 'Analyser : ' + att.file_name }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Informations générales -->
|
||
<div class="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm p-6 space-y-6">
|
||
<h3 class="text-md font-bold text-slate-800 dark:text-slate-200 border-b border-slate-100 dark:border-slate-850 pb-3">
|
||
Informations Générales
|
||
</h3>
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||
<div class="md:col-span-2">
|
||
<label class="block text-sm font-bold text-slate-750 dark:text-slate-300 mb-1">Objet du sous-devis :</label>
|
||
<input
|
||
v-model="form.title"
|
||
type="text"
|
||
class="w-full text-sm rounded-lg border-slate-300 shadow-sm focus:border-sky-500 focus:ring-sky-500 dark:bg-slate-950 dark:border-slate-800 dark:text-slate-200"
|
||
placeholder="Ex: Ventilation postes de travail – Mairie de Corneilhan"
|
||
required
|
||
>
|
||
<p v-if="form.errors.title" class="text-xs text-rose-600 mt-1">{{ form.errors.title }}</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-sm font-bold text-slate-755 dark:text-slate-300 mb-1">Type de destinataire :</label>
|
||
<select v-model="form.destinataire_type" class="w-full text-sm rounded-lg border-slate-300 shadow-sm focus:border-sky-500 focus:ring-sky-500 dark:bg-slate-950 dark:border-slate-800 dark:text-slate-200">
|
||
<option value="commune">Commune (Collectivité extérieure)</option>
|
||
<option value="service_interne">Service Interne (Direction / Service)</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-sm font-bold text-slate-755 dark:text-slate-300 mb-1">Nom du destinataire :</label>
|
||
<input
|
||
v-model="form.destinataire_nom"
|
||
type="text"
|
||
class="w-full text-sm rounded-lg border-slate-300 shadow-sm focus:border-sky-500 focus:ring-sky-500 dark:bg-slate-950 dark:border-slate-800 dark:text-slate-200"
|
||
placeholder="Ex: Mairie de Corneilhan"
|
||
required
|
||
>
|
||
<p v-if="form.errors.destinataire_nom" class="text-xs text-rose-600 mt-1">{{ form.errors.destinataire_nom }}</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-sm font-bold text-slate-755 dark:text-slate-300 mb-1">Référent / Contact :</label>
|
||
<input
|
||
v-model="form.referent"
|
||
type="text"
|
||
class="w-full text-sm rounded-lg border-slate-300 shadow-sm focus:border-sky-500 focus:ring-sky-500 dark:bg-slate-950 dark:border-slate-800 dark:text-slate-200"
|
||
placeholder="Ex: Jean Dupont"
|
||
>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-sm font-bold text-slate-755 dark:text-slate-300 mb-1">Date du devis :</label>
|
||
<input v-model="form.date_devis" type="date" class="w-full text-sm rounded-lg border-slate-300 shadow-sm focus:border-sky-500 focus:ring-sky-500 dark:bg-slate-950 dark:border-slate-800 dark:text-slate-200" required>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-sm font-bold text-slate-755 dark:text-slate-300 mb-1">Statut :</label>
|
||
<select v-model="form.status" class="w-full text-sm rounded-lg border-slate-300 shadow-sm focus:border-sky-500 focus:ring-sky-500 dark:bg-slate-950 dark:border-slate-800 dark:text-slate-200">
|
||
<option value="draft">Brouillon</option>
|
||
<option value="sent">Envoyé</option>
|
||
<option value="accepted">Accepté</option>
|
||
<option value="rejected">Refusé</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-sm font-bold text-slate-755 dark:text-slate-300 mb-1">Taux TVA (%) :</label>
|
||
<input v-model="form.tva_rate" type="number" min="0" max="100" step="0.1" class="w-full text-sm rounded-lg border-slate-300 shadow-sm focus:border-sky-500 focus:ring-sky-500 dark:bg-slate-950 dark:border-slate-800 dark:text-slate-200">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Lignes à ventiler -->
|
||
<div class="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm p-6 space-y-4">
|
||
<div class="flex items-center justify-between border-b border-slate-100 dark:border-slate-850 pb-3">
|
||
<div>
|
||
<h3 class="text-md font-bold text-slate-800 dark:text-slate-200">
|
||
Lignes à ventiler
|
||
</h3>
|
||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||
Saisissez les éléments du devis fournisseur ({{ commande.quote_number || commande.number }}) à refacturer à cette collectivité.
|
||
</p>
|
||
</div>
|
||
<button type="button" @click="addItem" class="text-xs font-semibold text-sky-600 hover:text-sky-700 bg-sky-50 dark:bg-sky-950/40 px-3 py-1.5 rounded-lg border border-sky-100 dark:border-sky-900/60 transition-colors">
|
||
+ Ajouter une ligne
|
||
</button>
|
||
</div>
|
||
|
||
<div class="space-y-3">
|
||
<div v-for="(item, index) in form.items" :key="index" class="flex items-start gap-3 bg-slate-50 dark:bg-slate-950/50 p-4 rounded-xl border border-slate-100 dark:border-slate-850">
|
||
<div class="flex-1 grid grid-cols-1 sm:grid-cols-12 gap-3">
|
||
<div class="sm:col-span-3">
|
||
<label class="block text-xxs font-bold text-slate-400 uppercase tracking-wide mb-1">Catégorie</label>
|
||
<select v-model="item.type" class="w-full text-xs rounded-lg border-slate-300 shadow-sm focus:border-sky-500 focus:ring-sky-500 dark:bg-slate-900 dark:border-slate-800 dark:text-slate-200">
|
||
<option v-for="t in itemTypes" :key="t" :value="t">{{ t }}</option>
|
||
</select>
|
||
</div>
|
||
<div class="sm:col-span-5">
|
||
<label class="block text-xxs font-bold text-slate-400 uppercase tracking-wide mb-1">Description</label>
|
||
<input v-model="item.designation" type="text" placeholder="Désignation" class="w-full text-xs rounded-lg border-slate-300 shadow-sm focus:border-sky-500 focus:ring-sky-500 dark:bg-slate-900 dark:border-slate-800 dark:text-slate-200" required>
|
||
</div>
|
||
<div class="sm:col-span-2">
|
||
<label class="block text-xxs font-bold text-slate-400 uppercase tracking-wide mb-1">Quantité</label>
|
||
<input v-model="item.quantite" type="number" min="1" step="1" class="w-full text-xs rounded-lg border-slate-300 shadow-sm focus:border-sky-500 focus:ring-sky-500 dark:bg-slate-900 dark:border-slate-800 dark:text-slate-200" required>
|
||
</div>
|
||
<div class="sm:col-span-2">
|
||
<label class="block text-xxs font-bold text-slate-400 uppercase tracking-wide mb-1">P.U. HT (€)</label>
|
||
<input v-model="item.prix_unitaire" type="number" step="0.01" class="w-full text-xs rounded-lg border-slate-300 shadow-sm focus:border-sky-500 focus:ring-sky-500 dark:bg-slate-900 dark:border-slate-800 dark:text-slate-200" required>
|
||
</div>
|
||
</div>
|
||
<button type="button" @click="removeItem(index)" class="mt-5 p-1.5 text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/40 rounded-md border border-transparent hover:border-rose-200 dark:hover:border-rose-900/60">
|
||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<p v-if="form.errors.items" class="text-xs text-rose-600">{{ form.errors.items }}</p>
|
||
|
||
<!-- Récapitulatif -->
|
||
<div class="bg-slate-50 dark:bg-slate-950 p-4 rounded-xl border border-slate-200 dark:border-slate-800 flex flex-col items-end space-y-2 text-sm">
|
||
<div class="flex justify-between w-64">
|
||
<span class="text-slate-500">Total HT :</span>
|
||
<span class="font-bold text-slate-850 dark:text-slate-200">{{ formatCurrency(totalHt) }}</span>
|
||
</div>
|
||
<div class="flex justify-between w-64 border-b border-slate-200 dark:border-slate-800 pb-2">
|
||
<span class="text-slate-500">TVA ({{ form.tva_rate }}%) :</span>
|
||
<span class="font-semibold text-slate-850 dark:text-slate-200">{{ formatCurrency(tvaAmount) }}</span>
|
||
</div>
|
||
<div class="flex justify-between w-64 text-base font-extrabold text-sky-600 dark:text-sky-400 pt-1">
|
||
<span>Total TTC :</span>
|
||
<span>{{ formatCurrency(totalTtc) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Notes -->
|
||
<div class="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm p-6">
|
||
<label class="block text-sm font-bold text-slate-755 dark:text-slate-300 mb-1">Notes / Conditions :</label>
|
||
<textarea v-model="form.notes" rows="3" class="w-full text-sm rounded-lg border-slate-300 shadow-sm focus:border-sky-500 focus:ring-sky-500 dark:bg-slate-950 dark:border-slate-800 dark:text-slate-200" placeholder="Ex: Devis valable 30 jours."></textarea>
|
||
</div>
|
||
|
||
<!-- Actions -->
|
||
<div class="flex items-center justify-end gap-3 pt-6 border-t border-slate-200 dark:border-slate-800">
|
||
<Link
|
||
:href="route('commandes.show', commande.id)"
|
||
class="inline-flex items-center px-4 py-2 border border-slate-300 text-sm font-semibold rounded-lg text-slate-700 bg-white hover:bg-slate-50 dark:bg-slate-900 dark:text-slate-300 dark:border-slate-850 dark:hover:bg-slate-800 transition-colors shadow-sm"
|
||
>
|
||
Annuler
|
||
</Link>
|
||
<button
|
||
type="submit"
|
||
:disabled="form.processing"
|
||
class="inline-flex items-center px-4 py-2 text-sm font-semibold text-white bg-violet-600 hover:bg-violet-500 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-violet-500 transition-colors disabled:opacity-50"
|
||
>
|
||
Créer le Sous-Devis
|
||
</button>
|
||
</div>
|
||
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</AuthenticatedLayout>
|
||
</template>
|