Files

386 lines
23 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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' || selectedModel.value === 'mistral') && 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', selectedModel.value);
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 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="mistral">Mistral AI</option>
<option v-if="ollamaAvailable" 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>