feat: implement user management system and integrate AI services with Ollama and Gemini
This commit is contained in:
@@ -1,11 +1,15 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||
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 InputLabel from '@/Components/InputLabel.vue';
|
||||
import TextInput from '@/Components/TextInput.vue';
|
||||
import InputError from '@/Components/InputError.vue';
|
||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||
import axios from 'axios';
|
||||
|
||||
const props = defineProps({
|
||||
order: {
|
||||
@@ -77,6 +81,112 @@ const submit = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const isOcrLoading = ref(false);
|
||||
const ocrError = ref(null);
|
||||
const ocrSuccess = ref(false);
|
||||
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 (file) => {
|
||||
const pdfjsLib = await loadPdfJs();
|
||||
const arrayBuffer = await file.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], file.name.replace(/\.pdf$/i, '.png'), { type: 'image/png' }));
|
||||
}, 'image/png');
|
||||
});
|
||||
};
|
||||
|
||||
const handleQuoteOcr = async () => {
|
||||
if (!form.quote_file) return;
|
||||
|
||||
isOcrLoading.value = true;
|
||||
ocrError.value = null;
|
||||
ocrSuccess.value = false;
|
||||
|
||||
try {
|
||||
let fileToSend = form.quote_file;
|
||||
const isPdf = /\.pdf$/i.test(form.quote_file.name);
|
||||
|
||||
if (selectedModel.value === 'ollama' && isPdf) {
|
||||
fileToSend = await convertPdfBlobToImageFile(form.quote_file);
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', fileToSend);
|
||||
formData.append('mode', selectedModel.value);
|
||||
|
||||
const response = await axios.post(route('attachments.ocr-upload'), formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data.success && response.data.data) {
|
||||
const data = response.data.data;
|
||||
if (data.supplier) form.supplier = data.supplier;
|
||||
if (data.quote_number) form.quote_number = data.quote_number;
|
||||
|
||||
let totalHt = data.total_ht;
|
||||
if ((totalHt === undefined || totalHt === null || parseFloat(totalHt) === 0) && data.items && data.items.length > 0) {
|
||||
totalHt = data.items.reduce((sum, item) => sum + (parseFloat(item.quantite) || 0) * (parseFloat(item.prix_unitaire) || 0), 0);
|
||||
}
|
||||
if (totalHt !== undefined && totalHt !== null) {
|
||||
form.amount_ht = String(parseFloat(totalHt).toFixed(2));
|
||||
}
|
||||
|
||||
if (data.title && !form.label) {
|
||||
form.label = data.title;
|
||||
}
|
||||
|
||||
if (data.items && data.items.length > 0) {
|
||||
let itemsStr = "\n\n--- Détails du devis extraits par OCR ---\n";
|
||||
data.items.forEach(item => {
|
||||
itemsStr += `- [${item.type}] ${item.designation} : ${item.quantite} x ${item.prix_unitaire} € HT\n`;
|
||||
});
|
||||
form.notes = (form.notes + itemsStr).trim();
|
||||
}
|
||||
|
||||
ocrSuccess.value = true;
|
||||
} else {
|
||||
ocrError.value = response.data.message || "Une erreur est survenue lors de l'analyse.";
|
||||
}
|
||||
} catch (e) {
|
||||
ocrError.value = e.response?.data?.message || "Erreur lors de l'analyse du fichier.";
|
||||
console.error(e);
|
||||
} finally {
|
||||
isOcrLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const orderTypes = [
|
||||
'Matériel réseau / serveur',
|
||||
'Licences logicielles',
|
||||
@@ -296,6 +406,30 @@ const demandeurs = ['Jérémy', 'Sylvain', 'Kévin'];
|
||||
@input="form.quote_file = $event.target.files[0]"
|
||||
class="block w-full text-xs text-slate-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-xs file:font-semibold file:bg-sky-50 file:text-sky-700 hover:file:bg-sky-100 dark:file:bg-slate-800 dark:file:text-slate-300"
|
||||
/>
|
||||
<div v-if="form.quote_file" class="mt-2 flex flex-col gap-2">
|
||||
<!-- Sélecteur de modèle -->
|
||||
<div v-if="ollamaAvailable" class="flex items-center gap-2 bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-lg px-2.5 py-1.5 shadow-sm">
|
||||
<span class="text-xxs text-slate-500 dark:text-slate-400">Modèle OCR :</span>
|
||||
<select v-model="selectedModel" class="text-xxs 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
|
||||
type="button"
|
||||
@click="handleQuoteOcr"
|
||||
:disabled="isOcrLoading"
|
||||
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 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.5 w-3.5 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>✨ {{ isOcrLoading ? 'Analyse...' : 'Analyser le devis (OCR)' }}</span>
|
||||
</button>
|
||||
<p v-if="ocrError" class="text-xxs font-semibold text-rose-600 mt-1">{{ ocrError }}</p>
|
||||
<p v-if="ocrSuccess" class="text-xxs font-semibold text-emerald-600 mt-1">✓ Devis analysé avec succès !</p>
|
||||
</div>
|
||||
<div v-if="getExistingFile('quote')" class="text-xs mt-1 text-slate-500">
|
||||
Fichier existant :
|
||||
<a :href="getExistingFile('quote').url" target="_blank" class="text-sky-600 dark:text-sky-400 hover:underline inline-flex items-center">
|
||||
|
||||
@@ -67,6 +67,14 @@ const getTimelineColor = (status) => {
|
||||
default: return 'bg-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
// Calculer le pourcentage du sous-devis par rapport au devis parent (commande)
|
||||
const getPercentage = (sdTotal) => {
|
||||
const parentTotal = parseFloat(props.order.data.amount_ttc);
|
||||
if (!parentTotal) return '0%';
|
||||
const pct = (parseFloat(sdTotal) / parentTotal) * 100;
|
||||
return `${pct.toFixed(1)} %`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -378,6 +386,9 @@ const getTimelineColor = (status) => {
|
||||
<span class="text-sm font-bold text-slate-900 dark:text-slate-100">
|
||||
{{ new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(sd.total_ttc) }}
|
||||
</span>
|
||||
<span class="text-xs font-semibold text-slate-500 bg-slate-150/80 dark:bg-slate-800 dark:text-slate-400 px-1.5 py-0.5 rounded">
|
||||
{{ getPercentage(sd.total_ttc) }}
|
||||
</span>
|
||||
<Link :href="route('devis.show', sd.id)" class="text-xs font-semibold text-sky-600 dark:text-sky-400 hover:underline">
|
||||
Voir →
|
||||
</Link>
|
||||
|
||||
Reference in New Issue
Block a user