feat: Ollama Vision OCR for bill/invoice scanning
- Ollama container running llama3.2-vision:11b on server - OCR page in ops app: camera/upload → Ollama extracts vendor, date, amounts, line items → editable form → create Purchase Invoice - nginx proxies /ollama/ to Ollama API (both ops + field containers) - Added createDoc to erp.js API layer Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
dc63462c0c
commit
2453bc6ef2
|
|
@ -16,6 +16,15 @@ server {
|
||||||
proxy_set_header X-Forwarded-Proto https;
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Ollama Vision API proxy — for bill/invoice OCR
|
||||||
|
location /ollama/ {
|
||||||
|
proxy_pass http://ollama:11434/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
client_max_body_size 20m;
|
||||||
|
}
|
||||||
|
|
||||||
# SPA fallback
|
# SPA fallback
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|
|
||||||
83
apps/field/src/api/ocr.js
Normal file
83
apps/field/src/api/ocr.js
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { authFetch } from './auth'
|
||||||
|
|
||||||
|
const OLLAMA_URL = '/ollama/api/generate'
|
||||||
|
|
||||||
|
const OCR_PROMPT = `You are an invoice/bill OCR assistant. Extract the following fields from this image of a bill or invoice. Return ONLY valid JSON, no markdown, no explanation.
|
||||||
|
|
||||||
|
{
|
||||||
|
"vendor": "company name on the bill",
|
||||||
|
"vendor_address": "full address if visible",
|
||||||
|
"invoice_number": "invoice/bill number",
|
||||||
|
"date": "YYYY-MM-DD format",
|
||||||
|
"due_date": "YYYY-MM-DD if visible, null otherwise",
|
||||||
|
"subtotal": 0.00,
|
||||||
|
"tax_gst": 0.00,
|
||||||
|
"tax_qst": 0.00,
|
||||||
|
"total": 0.00,
|
||||||
|
"currency": "CAD",
|
||||||
|
"items": [
|
||||||
|
{ "description": "line item description", "qty": 1, "rate": 0.00, "amount": 0.00 }
|
||||||
|
],
|
||||||
|
"notes": "any other relevant text (account number, payment terms, etc.)"
|
||||||
|
}
|
||||||
|
|
||||||
|
If a field is not visible, set it to null. Always return valid JSON.`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an image to Ollama Vision for bill/invoice OCR.
|
||||||
|
* @param {string} base64Image — base64 encoded image (no data: prefix)
|
||||||
|
* @returns {object} Parsed invoice data
|
||||||
|
*/
|
||||||
|
export async function ocrBill (base64Image) {
|
||||||
|
// Strip data:image/...;base64, prefix if present
|
||||||
|
const clean = base64Image.replace(/^data:image\/[^;]+;base64,/, '')
|
||||||
|
|
||||||
|
const res = await authFetch(OLLAMA_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'llama3.2-vision:11b',
|
||||||
|
prompt: OCR_PROMPT,
|
||||||
|
images: [clean],
|
||||||
|
stream: false,
|
||||||
|
options: {
|
||||||
|
temperature: 0.1,
|
||||||
|
num_predict: 2048,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text()
|
||||||
|
throw new Error('OCR failed: ' + (text || res.status))
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
const raw = data.response || ''
|
||||||
|
|
||||||
|
// Extract JSON from response (model might wrap it in markdown)
|
||||||
|
const jsonMatch = raw.match(/\{[\s\S]*\}/)
|
||||||
|
if (!jsonMatch) throw new Error('No JSON in OCR response')
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(jsonMatch[0])
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error('Invalid JSON from OCR: ' + e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Ollama is running and the vision model is available.
|
||||||
|
*/
|
||||||
|
export async function checkOllamaStatus () {
|
||||||
|
try {
|
||||||
|
const res = await authFetch('/ollama/api/tags')
|
||||||
|
if (!res.ok) return { online: false, error: 'HTTP ' + res.status }
|
||||||
|
const data = await res.json()
|
||||||
|
const models = (data.models || []).map(m => m.name)
|
||||||
|
const hasVision = models.some(m => m.includes('llama3.2-vision'))
|
||||||
|
return { online: true, models, hasVision }
|
||||||
|
} catch (e) {
|
||||||
|
return { online: false, error: e.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,15 @@ server {
|
||||||
proxy_set_header X-Forwarded-Proto https;
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Ollama Vision API proxy — for bill/invoice OCR
|
||||||
|
location /ollama/ {
|
||||||
|
proxy_pass http://ollama:11434/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
client_max_body_size 20m;
|
||||||
|
}
|
||||||
|
|
||||||
# SPA fallback — all routes serve index.html
|
# SPA fallback — all routes serve index.html
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,18 @@ export async function searchDocs (doctype, text, { filters = {}, fields = ['name
|
||||||
return data.message || []
|
return data.message || []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a new document
|
||||||
|
export async function createDoc (doctype, data) {
|
||||||
|
const res = await authFetch(BASE_URL + '/api/resource/' + doctype, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error('Create failed: ' + res.status)
|
||||||
|
const json = await res.json()
|
||||||
|
return json.data
|
||||||
|
}
|
||||||
|
|
||||||
// Update a document (partial update)
|
// Update a document (partial update)
|
||||||
export async function updateDoc (doctype, name, data) {
|
export async function updateDoc (doctype, name, data) {
|
||||||
const res = await authFetch(BASE_URL + '/api/resource/' + doctype + '/' + encodeURIComponent(name), {
|
const res = await authFetch(BASE_URL + '/api/resource/' + doctype + '/' + encodeURIComponent(name), {
|
||||||
|
|
|
||||||
83
apps/ops/src/api/ocr.js
Normal file
83
apps/ops/src/api/ocr.js
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { authFetch } from './auth'
|
||||||
|
|
||||||
|
const OLLAMA_URL = '/ollama/api/generate'
|
||||||
|
|
||||||
|
const OCR_PROMPT = `You are an invoice/bill OCR assistant. Extract the following fields from this image of a bill or invoice. Return ONLY valid JSON, no markdown, no explanation.
|
||||||
|
|
||||||
|
{
|
||||||
|
"vendor": "company name on the bill",
|
||||||
|
"vendor_address": "full address if visible",
|
||||||
|
"invoice_number": "invoice/bill number",
|
||||||
|
"date": "YYYY-MM-DD format",
|
||||||
|
"due_date": "YYYY-MM-DD if visible, null otherwise",
|
||||||
|
"subtotal": 0.00,
|
||||||
|
"tax_gst": 0.00,
|
||||||
|
"tax_qst": 0.00,
|
||||||
|
"total": 0.00,
|
||||||
|
"currency": "CAD",
|
||||||
|
"items": [
|
||||||
|
{ "description": "line item description", "qty": 1, "rate": 0.00, "amount": 0.00 }
|
||||||
|
],
|
||||||
|
"notes": "any other relevant text (account number, payment terms, etc.)"
|
||||||
|
}
|
||||||
|
|
||||||
|
If a field is not visible, set it to null. Always return valid JSON.`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an image to Ollama Vision for bill/invoice OCR.
|
||||||
|
* @param {string} base64Image — base64 encoded image (no data: prefix)
|
||||||
|
* @returns {object} Parsed invoice data
|
||||||
|
*/
|
||||||
|
export async function ocrBill (base64Image) {
|
||||||
|
// Strip data:image/...;base64, prefix if present
|
||||||
|
const clean = base64Image.replace(/^data:image\/[^;]+;base64,/, '')
|
||||||
|
|
||||||
|
const res = await authFetch(OLLAMA_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'llama3.2-vision:11b',
|
||||||
|
prompt: OCR_PROMPT,
|
||||||
|
images: [clean],
|
||||||
|
stream: false,
|
||||||
|
options: {
|
||||||
|
temperature: 0.1,
|
||||||
|
num_predict: 2048,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text()
|
||||||
|
throw new Error('OCR failed: ' + (text || res.status))
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
const raw = data.response || ''
|
||||||
|
|
||||||
|
// Extract JSON from response (model might wrap it in markdown)
|
||||||
|
const jsonMatch = raw.match(/\{[\s\S]*\}/)
|
||||||
|
if (!jsonMatch) throw new Error('No JSON in OCR response')
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(jsonMatch[0])
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error('Invalid JSON from OCR: ' + e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Ollama is running and the vision model is available.
|
||||||
|
*/
|
||||||
|
export async function checkOllamaStatus () {
|
||||||
|
try {
|
||||||
|
const res = await authFetch('/ollama/api/tags')
|
||||||
|
if (!res.ok) return { online: false, error: 'HTTP ' + res.status }
|
||||||
|
const data = await res.json()
|
||||||
|
const models = (data.models || []).map(m => m.name)
|
||||||
|
const hasVision = models.some(m => m.includes('llama3.2-vision'))
|
||||||
|
return { online: true, models, hasVision }
|
||||||
|
} catch (e) {
|
||||||
|
return { online: false, error: e.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -263,6 +263,7 @@ const navItems = [
|
||||||
{ path: '/tickets', icon: 'confirmation_number', label: 'Tickets' },
|
{ path: '/tickets', icon: 'confirmation_number', label: 'Tickets' },
|
||||||
{ path: '/equipe', icon: 'groups', label: 'Équipe' },
|
{ path: '/equipe', icon: 'groups', label: 'Équipe' },
|
||||||
{ path: '/rapports', icon: 'bar_chart', label: 'Rapports' },
|
{ path: '/rapports', icon: 'bar_chart', label: 'Rapports' },
|
||||||
|
{ path: '/ocr', icon: 'document_scanner', label: 'OCR Factures' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const currentNav = computed(() => navItems.find(n => n.path === route.path) || navItems.find(n => route.path.startsWith(n.path) && n.path !== '/'))
|
const currentNav = computed(() => navItems.find(n => n.path === route.path) || navItems.find(n => route.path.startsWith(n.path) && n.path !== '/'))
|
||||||
|
|
|
||||||
230
apps/ops/src/pages/OcrPage.vue
Normal file
230
apps/ops/src/pages/OcrPage.vue
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
<template>
|
||||||
|
<q-page padding>
|
||||||
|
<div class="row items-center q-mb-md">
|
||||||
|
<q-icon name="document_scanner" size="28px" class="q-mr-sm" color="indigo-6" />
|
||||||
|
<div class="text-h6">Scanner une facture</div>
|
||||||
|
<q-space />
|
||||||
|
<q-badge v-if="ollamaStatus" :color="ollamaStatus.online ? 'green' : 'red'"
|
||||||
|
:label="ollamaStatus.online ? 'Ollama en ligne' : 'Ollama hors ligne'" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload / Camera -->
|
||||||
|
<q-card class="q-mb-md" v-if="!result">
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-center q-gutter-md">
|
||||||
|
<q-btn color="primary" icon="photo_camera" label="Prendre une photo" size="lg" @click="$refs.camera.click()" />
|
||||||
|
<q-btn color="secondary" icon="upload_file" label="Importer un fichier" size="lg" @click="$refs.fileInput.click()" />
|
||||||
|
</div>
|
||||||
|
<input ref="camera" type="file" accept="image/*" capture="environment" class="hidden" @change="onFile" />
|
||||||
|
<input ref="fileInput" type="file" accept="image/*,.pdf" class="hidden" @change="onFile" />
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- Preview -->
|
||||||
|
<q-card-section v-if="preview" class="text-center">
|
||||||
|
<img :src="preview" style="max-width:100%;max-height:400px;border-radius:8px" />
|
||||||
|
<div class="q-mt-md">
|
||||||
|
<q-btn color="indigo-6" icon="document_scanner" label="Analyser avec Ollama Vision" :loading="processing" @click="runOcr" size="lg" />
|
||||||
|
</div>
|
||||||
|
<q-linear-progress v-if="processing" indeterminate color="indigo-6" class="q-mt-sm" />
|
||||||
|
<div v-if="processing" class="text-caption text-grey q-mt-xs">Analyse en cours... (peut prendre 30-60s sur CPU)</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<q-banner v-if="error" type="negative" class="q-mb-md text-negative" rounded>
|
||||||
|
<template v-slot:avatar><q-icon name="error" color="negative" /></template>
|
||||||
|
{{ error }}
|
||||||
|
<template v-slot:action>
|
||||||
|
<q-btn flat label="Réessayer" @click="error = null" />
|
||||||
|
</template>
|
||||||
|
</q-banner>
|
||||||
|
|
||||||
|
<!-- Results -->
|
||||||
|
<template v-if="result">
|
||||||
|
<q-card class="q-mb-md">
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row items-center q-mb-sm">
|
||||||
|
<q-icon name="check_circle" color="positive" size="24px" class="q-mr-sm" />
|
||||||
|
<div class="text-subtitle1 text-weight-bold">Résultat OCR</div>
|
||||||
|
<q-space />
|
||||||
|
<q-btn flat dense icon="restart_alt" label="Nouvelle" @click="reset" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Side by side: image + data -->
|
||||||
|
<div class="row q-gutter-md">
|
||||||
|
<div class="col-12 col-md-5">
|
||||||
|
<img :src="preview" style="width:100%;border-radius:8px" />
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="ocr-grid">
|
||||||
|
<div class="ocr-field">
|
||||||
|
<label>Fournisseur</label>
|
||||||
|
<q-input v-model="result.vendor" dense outlined />
|
||||||
|
</div>
|
||||||
|
<div class="ocr-field">
|
||||||
|
<label>No facture</label>
|
||||||
|
<q-input v-model="result.invoice_number" dense outlined />
|
||||||
|
</div>
|
||||||
|
<div class="ocr-field">
|
||||||
|
<label>Date</label>
|
||||||
|
<q-input v-model="result.date" dense outlined type="date" />
|
||||||
|
</div>
|
||||||
|
<div class="ocr-field">
|
||||||
|
<label>Échéance</label>
|
||||||
|
<q-input v-model="result.due_date" dense outlined type="date" />
|
||||||
|
</div>
|
||||||
|
<div class="ocr-field">
|
||||||
|
<label>Sous-total</label>
|
||||||
|
<q-input v-model.number="result.subtotal" dense outlined type="number" prefix="$" />
|
||||||
|
</div>
|
||||||
|
<div class="ocr-field">
|
||||||
|
<label>TPS (5%)</label>
|
||||||
|
<q-input v-model.number="result.tax_gst" dense outlined type="number" prefix="$" />
|
||||||
|
</div>
|
||||||
|
<div class="ocr-field">
|
||||||
|
<label>TVQ (9.975%)</label>
|
||||||
|
<q-input v-model.number="result.tax_qst" dense outlined type="number" prefix="$" />
|
||||||
|
</div>
|
||||||
|
<div class="ocr-field">
|
||||||
|
<label>Total</label>
|
||||||
|
<q-input v-model.number="result.total" dense outlined type="number" prefix="$" class="text-weight-bold" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Line items -->
|
||||||
|
<div v-if="result.items?.length" class="q-mt-md">
|
||||||
|
<div class="text-caption text-weight-bold text-grey-7 q-mb-xs">ARTICLES</div>
|
||||||
|
<q-table :rows="result.items" :columns="itemCols" row-key="description" flat dense hide-pagination
|
||||||
|
:pagination="{ rowsPerPage: 0 }" class="ops-table">
|
||||||
|
<template #body-cell-rate="p">
|
||||||
|
<q-td :props="p" class="text-right">{{ formatMoney(p.row.rate) }}</q-td>
|
||||||
|
</template>
|
||||||
|
<template #body-cell-amount="p">
|
||||||
|
<q-td :props="p" class="text-right">{{ formatMoney(p.row.amount) }}</q-td>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes -->
|
||||||
|
<div v-if="result.notes" class="q-mt-md">
|
||||||
|
<div class="text-caption text-weight-bold text-grey-7 q-mb-xs">NOTES</div>
|
||||||
|
<div class="text-body2" style="white-space:pre-line">{{ result.notes }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-actions align="right" class="q-px-md q-pb-md">
|
||||||
|
<q-btn flat label="Annuler" @click="reset" />
|
||||||
|
<q-btn color="primary" icon="save" label="Créer dans ERPNext" :loading="saving" @click="createInErpNext" />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<!-- Raw JSON toggle -->
|
||||||
|
<q-expansion-item label="JSON brut" dense header-class="text-caption text-grey-7">
|
||||||
|
<pre class="q-pa-sm" style="background:#f1f5f9;border-radius:8px;font-size:12px;overflow-x:auto">{{ JSON.stringify(result, null, 2) }}</pre>
|
||||||
|
</q-expansion-item>
|
||||||
|
</template>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { ocrBill, checkOllamaStatus } from 'src/api/ocr'
|
||||||
|
import { createDoc } from 'src/api/erp'
|
||||||
|
import { formatMoney } from 'src/composables/useFormatters'
|
||||||
|
import { Notify } from 'quasar'
|
||||||
|
|
||||||
|
const preview = ref(null)
|
||||||
|
const imageBase64 = ref(null)
|
||||||
|
const processing = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const result = ref(null)
|
||||||
|
const error = ref(null)
|
||||||
|
const ollamaStatus = ref(null)
|
||||||
|
|
||||||
|
const itemCols = [
|
||||||
|
{ name: 'description', label: 'Description', field: 'description', align: 'left' },
|
||||||
|
{ name: 'qty', label: 'Qté', field: 'qty', align: 'center' },
|
||||||
|
{ name: 'rate', label: 'Prix', field: 'rate', align: 'right' },
|
||||||
|
{ name: 'amount', label: 'Montant', field: 'amount', align: 'right' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function onFile (e) {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
error.value = null
|
||||||
|
result.value = null
|
||||||
|
preview.value = URL.createObjectURL(file)
|
||||||
|
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => { imageBase64.value = reader.result }
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runOcr () {
|
||||||
|
if (!imageBase64.value) return
|
||||||
|
processing.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
result.value = await ocrBill(imageBase64.value)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
processing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createInErpNext () {
|
||||||
|
if (!result.value) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const r = result.value
|
||||||
|
const doc = await createDoc('Purchase Invoice', {
|
||||||
|
supplier: r.vendor || 'Unknown',
|
||||||
|
bill_no: r.invoice_number || '',
|
||||||
|
bill_date: r.date || new Date().toISOString().slice(0, 10),
|
||||||
|
due_date: r.due_date || null,
|
||||||
|
items: (r.items || []).map((item, i) => ({
|
||||||
|
item_code: 'Expense - OCR',
|
||||||
|
description: item.description,
|
||||||
|
qty: item.qty || 1,
|
||||||
|
rate: item.rate || item.amount || 0,
|
||||||
|
idx: i + 1,
|
||||||
|
})),
|
||||||
|
remarks: r.notes || 'Imported via OCR',
|
||||||
|
})
|
||||||
|
Notify.create({ type: 'positive', message: 'Facture créée: ' + doc.name })
|
||||||
|
} catch (e) {
|
||||||
|
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset () {
|
||||||
|
preview.value = null
|
||||||
|
imageBase64.value = null
|
||||||
|
result.value = null
|
||||||
|
error.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
ollamaStatus.value = await checkOllamaStatus()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ocr-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.ocr-field label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6b7280;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -12,6 +12,7 @@ const routes = [
|
||||||
{ path: 'tickets', component: () => import('src/pages/TicketsPage.vue') },
|
{ path: 'tickets', component: () => import('src/pages/TicketsPage.vue') },
|
||||||
{ path: 'equipe', component: () => import('src/pages/EquipePage.vue') },
|
{ path: 'equipe', component: () => import('src/pages/EquipePage.vue') },
|
||||||
{ path: 'rapports', component: () => import('src/pages/RapportsPage.vue') },
|
{ path: 'rapports', component: () => import('src/pages/RapportsPage.vue') },
|
||||||
|
{ path: 'ocr', component: () => import('src/pages/OcrPage.vue') },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// Dispatch V2 — full-screen immersive UI with its own header/sidebar
|
// Dispatch V2 — full-screen immersive UI with its own header/sidebar
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user