Compare commits
2 Commits
main
...
codex/demo
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e2501721a | |||
| 865c4a78ea |
50
mini-services/hermes-mcp/README.md
Normal file
50
mini-services/hermes-mcp/README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# OptiqueStock Hermes MCP
|
||||
|
||||
This MCP server gives a Hermes agent safe, token-scoped access to OptiqueStock.
|
||||
|
||||
## Environment
|
||||
|
||||
Set the same key on the Next.js app and the MCP server:
|
||||
|
||||
```bash
|
||||
HERMES_API_KEY=change-me
|
||||
OPTICZ_API_BASE=http://192.168.1.30:3000
|
||||
```
|
||||
|
||||
In local development only, the fallback key is:
|
||||
|
||||
```text
|
||||
hermes-demo-key-change-me
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
node mini-services/hermes-mcp/server.mjs
|
||||
```
|
||||
|
||||
## MCP Tools
|
||||
|
||||
- `opticz_status`
|
||||
- `opticz_summary`
|
||||
- `opticz_search_clients`
|
||||
- `opticz_search_products`
|
||||
- `opticz_create_client`
|
||||
- `opticz_create_repair_sale`
|
||||
|
||||
## Direct REST API
|
||||
|
||||
All endpoints require:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <HERMES_API_KEY>
|
||||
```
|
||||
|
||||
Available endpoints:
|
||||
|
||||
- `GET /api/hermes/status`
|
||||
- `GET /api/hermes/summary`
|
||||
- `GET /api/hermes/clients?q=amina`
|
||||
- `POST /api/hermes/clients`
|
||||
- `GET /api/hermes/products?q=monture`
|
||||
- `POST /api/hermes/sales/repair`
|
||||
242
mini-services/hermes-mcp/server.mjs
Normal file
242
mini-services/hermes-mcp/server.mjs
Normal file
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const apiBase = (process.env.OPTICZ_API_BASE || 'http://127.0.0.1:3000').replace(/\/$/, '')
|
||||
const apiKey = process.env.HERMES_API_KEY || 'hermes-demo-key-change-me'
|
||||
|
||||
const tools = [
|
||||
{
|
||||
name: 'opticz_status',
|
||||
description: 'Check OptiqueStock Hermes API health.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'opticz_summary',
|
||||
description: 'Get store counts, low stock products, pending workshop count, and recent sales.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'opticz_search_clients',
|
||||
description: 'Search OptiqueStock clients by name, phone, or email.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
q: { type: 'string', description: 'Search query.' },
|
||||
limit: { type: 'number', description: 'Maximum results, default 20, max 50.' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'opticz_search_products',
|
||||
description: 'Search active products by reference, designation, brand, or category.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
q: { type: 'string', description: 'Search query.' },
|
||||
category: { type: 'string', description: 'Optional category filter, e.g. MONTURE or VERRE.' },
|
||||
inStockOnly: { type: 'boolean', description: 'Only include products with stock. Defaults true.' },
|
||||
limit: { type: 'number', description: 'Maximum results, default 25, max 75.' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'opticz_create_client',
|
||||
description: 'Create a client, or return the existing client when the phone number already exists.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['nom', 'prenom', 'telephone'],
|
||||
properties: {
|
||||
nom: { type: 'string' },
|
||||
prenom: { type: 'string' },
|
||||
telephone: { type: 'string' },
|
||||
email: { type: 'string' },
|
||||
adresse: { type: 'string' },
|
||||
ville: { type: 'string' },
|
||||
codePostal: { type: 'string' },
|
||||
notes: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'opticz_create_repair_sale',
|
||||
description: 'Create a paid repair sale for an existing client.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['clientId', 'repairType', 'repairDescription', 'repairPrice'],
|
||||
properties: {
|
||||
clientId: { type: 'string' },
|
||||
repairType: { type: 'string' },
|
||||
repairDescription: { type: 'string' },
|
||||
repairPrice: { type: 'number' },
|
||||
additionalCharges: { type: 'number' },
|
||||
paymentMode: {
|
||||
type: 'string',
|
||||
enum: ['ESPECES', 'CARTE', 'CHEQUE', 'VIREMENT', 'BON_CAISSE'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const response = await fetch(`${apiBase}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
},
|
||||
})
|
||||
const text = await response.text()
|
||||
const data = text ? JSON.parse(text) : null
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.error || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
function query(params) {
|
||||
const search = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(params || {})) {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
search.set(key, String(value))
|
||||
}
|
||||
}
|
||||
const value = search.toString()
|
||||
return value ? `?${value}` : ''
|
||||
}
|
||||
|
||||
async function callTool(name, args) {
|
||||
switch (name) {
|
||||
case 'opticz_status':
|
||||
return api('/api/hermes/status')
|
||||
case 'opticz_summary':
|
||||
return api('/api/hermes/summary')
|
||||
case 'opticz_search_clients':
|
||||
return api(`/api/hermes/clients${query(args)}`)
|
||||
case 'opticz_search_products':
|
||||
return api(`/api/hermes/products${query(args)}`)
|
||||
case 'opticz_create_client':
|
||||
return api('/api/hermes/clients', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(args || {}),
|
||||
})
|
||||
case 'opticz_create_repair_sale':
|
||||
return api('/api/hermes/sales/repair', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(args || {}),
|
||||
})
|
||||
default:
|
||||
throw new Error(`Unknown tool: ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
function send(message) {
|
||||
process.stdout.write(`${JSON.stringify(message)}\n`)
|
||||
}
|
||||
|
||||
async function handle(message) {
|
||||
const { id, method, params } = message
|
||||
|
||||
if (method === 'initialize') {
|
||||
send({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
protocolVersion: params?.protocolVersion || '2024-11-05',
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
serverInfo: {
|
||||
name: 'opticz-hermes-mcp',
|
||||
version: '0.1.0',
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (method === 'notifications/initialized') return
|
||||
|
||||
if (method === 'tools/list') {
|
||||
send({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: { tools },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (method === 'tools/call') {
|
||||
try {
|
||||
const result = await callTool(params?.name, params?.arguments || {})
|
||||
send({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(result, null, 2),
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
send({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
isError: true,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
send({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: {
|
||||
code: -32601,
|
||||
message: `Unsupported method: ${method}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
let buffer = ''
|
||||
process.stdin.setEncoding('utf8')
|
||||
process.stdin.on('data', (chunk) => {
|
||||
buffer += chunk
|
||||
const lines = buffer.split(/\r?\n/)
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
try {
|
||||
handle(JSON.parse(line))
|
||||
} catch (error) {
|
||||
send({
|
||||
jsonrpc: '2.0',
|
||||
id: null,
|
||||
error: {
|
||||
code: -32700,
|
||||
message: error instanceof Error ? error.message : 'Parse error',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -10,7 +10,8 @@
|
||||
"db:push": "prisma db push",
|
||||
"db:generate": "prisma generate",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:reset": "prisma migrate reset"
|
||||
"db:reset": "prisma migrate reset",
|
||||
"hermes:mcp": "node mini-services/hermes-mcp/server.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
||||
272
src/app/api/demo/seed/route.ts
Normal file
272
src/app/api/demo/seed/route.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { ModePaiement, StatutAtelier, StatutVente } from '@prisma/client'
|
||||
import { db } from '@/lib/db'
|
||||
import { getSession } from '@/lib/auth-utils'
|
||||
|
||||
async function requireAdmin() {
|
||||
const session = await getSession()
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Non authentifie' }, { status: 401 })
|
||||
}
|
||||
|
||||
if ((session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Acces reserve aux administrateurs' }, { status: 403 })
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function htFromTtc(ttc: number, tva = 20) {
|
||||
return ttc / (1 + tva / 100)
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
const authError = await requireAdmin()
|
||||
if (authError) return authError
|
||||
|
||||
try {
|
||||
const suppliers = await Promise.all([
|
||||
db.fournisseur.upsert({
|
||||
where: { id: 'demo-fournisseur-lumioptic' },
|
||||
update: {
|
||||
nom: 'LumiOptic Distribution',
|
||||
contact: 'Nadia Benali',
|
||||
email: 'contact@demo-lumioptic.local',
|
||||
telephone: '0522000001',
|
||||
ville: 'Casablanca',
|
||||
actif: true,
|
||||
},
|
||||
create: {
|
||||
id: 'demo-fournisseur-lumioptic',
|
||||
nom: 'LumiOptic Distribution',
|
||||
contact: 'Nadia Benali',
|
||||
email: 'contact@demo-lumioptic.local',
|
||||
telephone: '0522000001',
|
||||
ville: 'Casablanca',
|
||||
},
|
||||
}),
|
||||
db.fournisseur.upsert({
|
||||
where: { id: 'demo-fournisseur-clairverre' },
|
||||
update: {
|
||||
nom: 'ClairVerre Pro',
|
||||
contact: 'Yassine El Amrani',
|
||||
email: 'contact@demo-clairverre.local',
|
||||
telephone: '0522000002',
|
||||
ville: 'Rabat',
|
||||
actif: true,
|
||||
},
|
||||
create: {
|
||||
id: 'demo-fournisseur-clairverre',
|
||||
nom: 'ClairVerre Pro',
|
||||
contact: 'Yassine El Amrani',
|
||||
email: 'contact@demo-clairverre.local',
|
||||
telephone: '0522000002',
|
||||
ville: 'Rabat',
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const productsData = [
|
||||
['DEMO-MNT-001', 'Monture acetate noir Atlas', 'MONTURE', 42, 129, 12, 4, 'Atlas', 'COMPLET', suppliers[0].id],
|
||||
['DEMO-MNT-002', 'Monture metal fine Sofia', 'MONTURE', 38, 115, 9, 3, 'Sofia', 'COMPLET', suppliers[0].id],
|
||||
['DEMO-MNT-003', 'Monture enfant Flex Kids', 'MONTURE', 25, 79, 14, 5, 'Flex Kids', 'NATUREL', suppliers[0].id],
|
||||
['DEMO-VR-001', 'Verres simple vision anti-reflet', 'VERRE', 18, 59, 40, 10, null, null, suppliers[1].id],
|
||||
['DEMO-VR-002', 'Verres progressifs confort', 'VERRE', 58, 189, 18, 6, null, null, suppliers[1].id],
|
||||
['DEMO-VR-003', 'Verres anti-lumiere bleue', 'VERRE', 24, 89, 22, 8, null, null, suppliers[1].id],
|
||||
['DEMO-LEN-001', 'Lentilles mensuelles confort', 'LENTILLE', 9, 29, 30, 8, 'ClearDay', null, suppliers[1].id],
|
||||
['DEMO-ACC-001', 'Kit nettoyage premium', 'ACCESSOIRE', 3, 12, 50, 12, null, null, suppliers[0].id],
|
||||
['DEMO-ACC-002', 'Etui rigide signature', 'ACCESSOIRE', 5, 18, 35, 10, null, null, suppliers[0].id],
|
||||
['DEMO-ACC-003', 'Cordon lunettes sport', 'ACCESSOIRE', 2, 9, 5, 10, null, null, suppliers[0].id],
|
||||
] as const
|
||||
|
||||
const products = await Promise.all(
|
||||
productsData.map(([reference, designation, categorie, prixAchatHT, prixVenteTTC, stock, stockMin, marque, typeMonture, fournisseurId]) =>
|
||||
db.produit.upsert({
|
||||
where: { reference },
|
||||
update: {
|
||||
designation,
|
||||
categorie,
|
||||
prixAchatHT,
|
||||
prixVenteTTC,
|
||||
stock,
|
||||
stockMin,
|
||||
marque,
|
||||
typeMonture: typeMonture as any,
|
||||
fournisseurId,
|
||||
actif: true,
|
||||
},
|
||||
create: {
|
||||
reference,
|
||||
designation,
|
||||
categorie,
|
||||
prixAchatHT,
|
||||
prixVenteTTC,
|
||||
tva: 20,
|
||||
stock,
|
||||
stockMin,
|
||||
marque,
|
||||
typeMonture: typeMonture as any,
|
||||
fournisseurId,
|
||||
actif: true,
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const clientsData = [
|
||||
['demo-client-1', 'Amina', 'Rachid', '0600000101', 'amina.rachid@demo.local', 'Casablanca'],
|
||||
['demo-client-2', 'Karim', 'Bennani', '0600000102', 'karim.bennani@demo.local', 'Rabat'],
|
||||
['demo-client-3', 'Sara', 'El Fassi', '0600000103', 'sara.elfassi@demo.local', 'Marrakech'],
|
||||
['demo-client-4', 'Youssef', 'Idrissi', '0600000104', null, 'Tanger'],
|
||||
['demo-client-5', 'Leila', 'Mansouri', '0600000105', 'leila.mansouri@demo.local', 'Fes'],
|
||||
] as const
|
||||
|
||||
const clients = await Promise.all(
|
||||
clientsData.map(([id, prenom, nom, telephone, email, ville]) =>
|
||||
db.client.upsert({
|
||||
where: { telephone },
|
||||
update: {
|
||||
prenom,
|
||||
nom,
|
||||
email,
|
||||
ville,
|
||||
},
|
||||
create: {
|
||||
id,
|
||||
prenom,
|
||||
nom,
|
||||
telephone,
|
||||
email,
|
||||
ville,
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const password = await bcrypt.hash('demo123', 12)
|
||||
const employees = await Promise.all([
|
||||
db.employe.upsert({
|
||||
where: { email: 'vendeur.demo@optiquestock.local' },
|
||||
update: {
|
||||
nom: 'Demo',
|
||||
prenom: 'Vendeur',
|
||||
role: 'VENDEUR',
|
||||
actif: true,
|
||||
},
|
||||
create: {
|
||||
email: 'vendeur.demo@optiquestock.local',
|
||||
nom: 'Demo',
|
||||
prenom: 'Vendeur',
|
||||
role: 'VENDEUR',
|
||||
actif: true,
|
||||
motDePasse: password,
|
||||
},
|
||||
}),
|
||||
db.employe.upsert({
|
||||
where: { email: 'responsable.demo@optiquestock.local' },
|
||||
update: {
|
||||
nom: 'Demo',
|
||||
prenom: 'Responsable',
|
||||
role: 'RESPONSABLE',
|
||||
actif: true,
|
||||
},
|
||||
create: {
|
||||
email: 'responsable.demo@optiquestock.local',
|
||||
nom: 'Demo',
|
||||
prenom: 'Responsable',
|
||||
role: 'RESPONSABLE',
|
||||
actif: true,
|
||||
motDePasse: password,
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const saleSpecs = [
|
||||
{
|
||||
numero: 'DEMO-VENTE-001',
|
||||
client: clients[0],
|
||||
statutAtelier: StatutAtelier.EN_COURS,
|
||||
items: [products[0], products[3], products[7]],
|
||||
payment: ModePaiement.CARTE,
|
||||
},
|
||||
{
|
||||
numero: 'DEMO-VENTE-002',
|
||||
client: clients[1],
|
||||
statutAtelier: StatutAtelier.PRET,
|
||||
items: [products[1], products[4]],
|
||||
payment: ModePaiement.ESPECES,
|
||||
},
|
||||
{
|
||||
numero: 'DEMO-VENTE-003',
|
||||
client: clients[2],
|
||||
statutAtelier: StatutAtelier.EN_ATTENTE,
|
||||
items: [products[2], products[5]],
|
||||
payment: ModePaiement.CHEQUE,
|
||||
},
|
||||
]
|
||||
|
||||
let salesCreated = 0
|
||||
for (const spec of saleSpecs) {
|
||||
const existingSale = await db.vente.findUnique({ where: { numero: spec.numero } })
|
||||
if (existingSale) continue
|
||||
|
||||
const montantTTC = spec.items.reduce((sum, product) => sum + product.prixVenteTTC, 0)
|
||||
const montantHT = spec.items.reduce((sum, product) => sum + htFromTtc(product.prixVenteTTC), 0)
|
||||
|
||||
await db.vente.create({
|
||||
data: {
|
||||
numero: spec.numero,
|
||||
clientId: spec.client.id,
|
||||
employeId: employees[0].id,
|
||||
statut: StatutVente.PAYEE,
|
||||
statutAtelier: spec.statutAtelier,
|
||||
montantHT,
|
||||
montantTVA: montantTTC - montantHT,
|
||||
montantTTC,
|
||||
notes: 'Vente demo generee automatiquement',
|
||||
dateAtelier: new Date(),
|
||||
lignes: {
|
||||
create: spec.items.map((product) => ({
|
||||
produitId: product.id,
|
||||
quantite: 1,
|
||||
prixUnitaireHT: htFromTtc(product.prixVenteTTC),
|
||||
prixUnitaireTTC: product.prixVenteTTC,
|
||||
remise: 0,
|
||||
montantHT: htFromTtc(product.prixVenteTTC),
|
||||
montantTTC: product.prixVenteTTC,
|
||||
})),
|
||||
},
|
||||
paiements: {
|
||||
create: {
|
||||
mode: spec.payment,
|
||||
montant: montantTTC,
|
||||
employeId: employees[0].id,
|
||||
reference: spec.numero,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
salesCreated += 1
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Demo data ready',
|
||||
clients: clients.length,
|
||||
products: products.length,
|
||||
suppliers: suppliers.length,
|
||||
employees: employees.length,
|
||||
salesCreated,
|
||||
demoLogins: [
|
||||
{ email: 'vendeur.demo@optiquestock.local', password: 'demo123' },
|
||||
{ email: 'responsable.demo@optiquestock.local', password: 'demo123' },
|
||||
],
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Demo seed error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to seed demo data' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
76
src/app/api/hermes/clients/route.ts
Normal file
76
src/app/api/hermes/clients/route.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
import { requireHermesAccess } from '@/lib/hermes-auth'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const authError = requireHermesAccess(request)
|
||||
if (authError) return authError
|
||||
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const q = (searchParams.get('q') || '').trim()
|
||||
const limit = Math.min(Number(searchParams.get('limit') || 20), 50)
|
||||
|
||||
const clients = await db.client.findMany({
|
||||
where: q
|
||||
? {
|
||||
OR: [
|
||||
{ nom: { contains: q } },
|
||||
{ prenom: { contains: q } },
|
||||
{ telephone: { contains: q } },
|
||||
{ email: { contains: q } },
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
orderBy: [{ nom: 'asc' }, { prenom: 'asc' }],
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
nom: true,
|
||||
prenom: true,
|
||||
email: true,
|
||||
telephone: true,
|
||||
ville: true,
|
||||
codePostal: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ clients })
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const authError = requireHermesAccess(request)
|
||||
if (authError) return authError
|
||||
|
||||
const body = await request.json()
|
||||
const nom = String(body.nom || '').trim()
|
||||
const prenom = String(body.prenom || '').trim()
|
||||
const telephone = String(body.telephone || '').trim()
|
||||
|
||||
if (!nom || !prenom || !telephone) {
|
||||
return NextResponse.json(
|
||||
{ error: 'nom, prenom and telephone are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const existingClient = await db.client.findUnique({ where: { telephone } })
|
||||
if (existingClient) {
|
||||
return NextResponse.json({ client: existingClient, existed: true })
|
||||
}
|
||||
|
||||
const client = await db.client.create({
|
||||
data: {
|
||||
nom,
|
||||
prenom,
|
||||
telephone,
|
||||
email: body.email || null,
|
||||
adresse: body.adresse || null,
|
||||
ville: body.ville || null,
|
||||
codePostal: body.codePostal || null,
|
||||
notes: body.notes || null,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ client, existed: false }, { status: 201 })
|
||||
}
|
||||
48
src/app/api/hermes/products/route.ts
Normal file
48
src/app/api/hermes/products/route.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
import { requireHermesAccess } from '@/lib/hermes-auth'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const authError = requireHermesAccess(request)
|
||||
if (authError) return authError
|
||||
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const q = (searchParams.get('q') || '').trim()
|
||||
const category = (searchParams.get('category') || '').trim()
|
||||
const inStockOnly = searchParams.get('inStockOnly') !== 'false'
|
||||
const limit = Math.min(Number(searchParams.get('limit') || 25), 75)
|
||||
|
||||
const products = await db.produit.findMany({
|
||||
where: {
|
||||
actif: true,
|
||||
...(inStockOnly ? { stock: { gt: 0 } } : {}),
|
||||
...(category ? { categorie: category } : {}),
|
||||
...(q
|
||||
? {
|
||||
OR: [
|
||||
{ reference: { contains: q } },
|
||||
{ designation: { contains: q } },
|
||||
{ marque: { contains: q } },
|
||||
{ categorie: { contains: q } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: [{ categorie: 'asc' }, { designation: 'asc' }],
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
reference: true,
|
||||
designation: true,
|
||||
categorie: true,
|
||||
marque: true,
|
||||
prixVenteTTC: true,
|
||||
tva: true,
|
||||
stock: true,
|
||||
stockMin: true,
|
||||
emplacement: true,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ products })
|
||||
}
|
||||
125
src/app/api/hermes/sales/repair/route.ts
Normal file
125
src/app/api/hermes/sales/repair/route.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { ModePaiement } from '@prisma/client'
|
||||
import { db } from '@/lib/db'
|
||||
import { requireHermesAccess } from '@/lib/hermes-auth'
|
||||
|
||||
const SERVICE_TVA = 20
|
||||
|
||||
async function generateSaleNumber(): Promise<string> {
|
||||
const today = new Date()
|
||||
const year = today.getFullYear()
|
||||
const month = String(today.getMonth() + 1).padStart(2, '0')
|
||||
const salesThisMonth = await db.vente.count({
|
||||
where: {
|
||||
date: {
|
||||
gte: new Date(year, today.getMonth(), 1),
|
||||
lt: new Date(year, today.getMonth() + 1, 1),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return `H${year}${month}${String(salesThisMonth + 1).padStart(4, '0')}`
|
||||
}
|
||||
|
||||
function toHt(ttc: number) {
|
||||
return ttc / (1 + SERVICE_TVA / 100)
|
||||
}
|
||||
|
||||
async function getRepairProduct() {
|
||||
return db.produit.upsert({
|
||||
where: { reference: 'HERMES_SERVICE_REPAIR' },
|
||||
update: {
|
||||
designation: 'Hermes service reparation',
|
||||
actif: true,
|
||||
stock: { increment: 1 },
|
||||
},
|
||||
create: {
|
||||
reference: 'HERMES_SERVICE_REPAIR',
|
||||
designation: 'Hermes service reparation',
|
||||
categorie: 'SERVICE',
|
||||
prixAchatHT: 0,
|
||||
prixVenteTTC: 0,
|
||||
tva: SERVICE_TVA,
|
||||
stock: 999999,
|
||||
stockMin: 0,
|
||||
actif: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const authError = requireHermesAccess(request)
|
||||
if (authError) return authError
|
||||
|
||||
const body = await request.json()
|
||||
const clientId = String(body.clientId || '').trim()
|
||||
const repairType = String(body.repairType || '').trim()
|
||||
const repairDescription = String(body.repairDescription || '').trim()
|
||||
const repairPrice = Math.max(0, Number(body.repairPrice || 0))
|
||||
const additionalCharges = Math.max(0, Number(body.additionalCharges || 0))
|
||||
const paymentMode = (body.paymentMode || 'ESPECES') as ModePaiement
|
||||
|
||||
if (!clientId || !repairType || !repairDescription) {
|
||||
return NextResponse.json(
|
||||
{ error: 'clientId, repairType and repairDescription are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const client = await db.client.findUnique({ where: { id: clientId } })
|
||||
if (!client) {
|
||||
return NextResponse.json({ error: 'Client not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const repairProduct = await getRepairProduct()
|
||||
const totalTTC = repairPrice + additionalCharges
|
||||
const totalHT = toHt(totalTTC)
|
||||
const numero = await generateSaleNumber()
|
||||
|
||||
const sale = await db.vente.create({
|
||||
data: {
|
||||
numero,
|
||||
clientId,
|
||||
statut: 'PAYEE',
|
||||
montantHT: totalHT,
|
||||
montantTVA: totalTTC - totalHT,
|
||||
montantTTC: totalTTC,
|
||||
notes: [
|
||||
`Hermes repair: ${repairType}`,
|
||||
`Description: ${repairDescription}`,
|
||||
additionalCharges > 0 ? `Additional charges: ${additionalCharges.toFixed(2)}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
lignes: {
|
||||
create: {
|
||||
produitId: repairProduct.id,
|
||||
quantite: 1,
|
||||
prixUnitaireHT: totalHT,
|
||||
prixUnitaireTTC: totalTTC,
|
||||
remise: 0,
|
||||
montantHT: totalHT,
|
||||
montantTTC: totalTTC,
|
||||
},
|
||||
},
|
||||
paiements: {
|
||||
create: {
|
||||
mode: paymentMode,
|
||||
montant: totalTTC,
|
||||
reference: 'HERMES',
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
client: true,
|
||||
lignes: {
|
||||
include: {
|
||||
produit: true,
|
||||
},
|
||||
},
|
||||
paiements: true,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ sale }, { status: 201 })
|
||||
}
|
||||
16
src/app/api/hermes/status/route.ts
Normal file
16
src/app/api/hermes/status/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
import { requireHermesAccess } from '@/lib/hermes-auth'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const authError = requireHermesAccess(request)
|
||||
if (authError) return authError
|
||||
|
||||
await db.$queryRaw`SELECT 1`
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
service: 'OptiqueStock Hermes API',
|
||||
time: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
66
src/app/api/hermes/summary/route.ts
Normal file
66
src/app/api/hermes/summary/route.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
import { requireHermesAccess } from '@/lib/hermes-auth'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const authError = requireHermesAccess(request)
|
||||
if (authError) return authError
|
||||
|
||||
const [clients, activeProducts, lowStock, pendingWorkshop, sales] = await Promise.all([
|
||||
db.client.count(),
|
||||
db.produit.count({ where: { actif: true } }),
|
||||
db.produit.findMany({
|
||||
where: { actif: true },
|
||||
orderBy: { stock: 'asc' },
|
||||
take: 10,
|
||||
select: {
|
||||
id: true,
|
||||
reference: true,
|
||||
designation: true,
|
||||
categorie: true,
|
||||
stock: true,
|
||||
stockMin: true,
|
||||
prixVenteTTC: true,
|
||||
},
|
||||
}),
|
||||
db.vente.count({
|
||||
where: {
|
||||
statutAtelier: {
|
||||
in: ['EN_ATTENTE', 'EN_COURS', 'TERMINE', 'PRET'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
db.vente.findMany({
|
||||
orderBy: { date: 'desc' },
|
||||
take: 8,
|
||||
include: {
|
||||
client: {
|
||||
select: {
|
||||
id: true,
|
||||
nom: true,
|
||||
prenom: true,
|
||||
telephone: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
return NextResponse.json({
|
||||
counts: {
|
||||
clients,
|
||||
activeProducts,
|
||||
pendingWorkshop,
|
||||
},
|
||||
lowStock: lowStock.filter((item) => item.stock <= item.stockMin),
|
||||
recentSales: sales.map((sale) => ({
|
||||
id: sale.id,
|
||||
numero: sale.numero,
|
||||
date: sale.date,
|
||||
statut: sale.statut,
|
||||
statutAtelier: sale.statutAtelier,
|
||||
montantTTC: sale.montantTTC,
|
||||
client: sale.client,
|
||||
})),
|
||||
})
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import "./globals.css";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import SessionProvider from "@/components/auth/SessionProvider";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { CurrencyProvider } from "@/components/currency-provider";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -46,9 +47,11 @@ export default function RootLayout({
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`}
|
||||
>
|
||||
<ThemeProvider>
|
||||
<SessionProvider>
|
||||
{children}
|
||||
</SessionProvider>
|
||||
<CurrencyProvider>
|
||||
<SessionProvider>
|
||||
{children}
|
||||
</SessionProvider>
|
||||
</CurrencyProvider>
|
||||
</ThemeProvider>
|
||||
<Toaster />
|
||||
</body>
|
||||
|
||||
@@ -29,6 +29,8 @@ function LoginForm() {
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
await requestFullscreen()
|
||||
|
||||
const callbackUrl = getLocalCallbackPath(searchParams.get('callbackUrl'))
|
||||
const result = await signIn('credentials', {
|
||||
email,
|
||||
@@ -96,6 +98,11 @@ function LoginForm() {
|
||||
)
|
||||
}
|
||||
|
||||
async function requestFullscreen() {
|
||||
if (document.fullscreenElement || !document.documentElement.requestFullscreen) return
|
||||
await document.documentElement.requestFullscreen().catch(() => undefined)
|
||||
}
|
||||
|
||||
function getLocalCallbackPath(callbackUrl: string | null) {
|
||||
if (!callbackUrl) return '/'
|
||||
|
||||
|
||||
@@ -9,8 +9,10 @@ import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/ca
|
||||
import {
|
||||
BarChart3,
|
||||
BrainCircuit,
|
||||
Database,
|
||||
Eye,
|
||||
LayoutDashboard,
|
||||
Loader2,
|
||||
LogOut,
|
||||
Package,
|
||||
PanelTop,
|
||||
@@ -32,6 +34,7 @@ import { EmployeeManagement } from '@/components/employees/EmployeeManagement'
|
||||
import { AIAssistant } from '@/components/ai/AIAssistant'
|
||||
import { ThemeToggle } from '@/components/theme-toggle'
|
||||
import { SellerWizard } from '@/components/seller-wizard/SellerWizard'
|
||||
import { CurrencySelect } from '@/components/currency-select'
|
||||
|
||||
type RoleEmploye = 'VENDEUR' | 'RESPONSABLE' | 'ADMIN'
|
||||
type Module =
|
||||
@@ -149,6 +152,8 @@ export default function Home() {
|
||||
const { data: session, status } = useSession()
|
||||
const router = useRouter()
|
||||
const [currentModule, setCurrentModule] = useState<Module>('HOME')
|
||||
const [demoGenerating, setDemoGenerating] = useState(false)
|
||||
const [demoMessage, setDemoMessage] = useState('')
|
||||
|
||||
const currentRole = ((session?.user as any)?.role || 'VENDEUR') as RoleEmploye
|
||||
const visibleModules = modules.filter((module) => module.roles.includes(currentRole))
|
||||
@@ -166,6 +171,28 @@ export default function Home() {
|
||||
}
|
||||
}, [currentModule, currentRole])
|
||||
|
||||
async function generateDemoData() {
|
||||
setDemoGenerating(true)
|
||||
setDemoMessage('')
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/demo/seed', { method: 'POST' })
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Generation demo impossible')
|
||||
}
|
||||
|
||||
setDemoMessage(
|
||||
`Demo prete: ${data.clients} clients, ${data.products} produits, ${data.salesCreated} nouvelles ventes.`
|
||||
)
|
||||
} catch (error) {
|
||||
setDemoMessage(error instanceof Error ? error.message : 'Erreur demo inconnue')
|
||||
} finally {
|
||||
setDemoGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||
@@ -206,6 +233,26 @@ export default function Home() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{currentRole === 'ADMIN' && (
|
||||
<div className="mx-auto flex max-w-2xl flex-col items-center gap-3 rounded-md border bg-card p-4 text-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={generateDemoData}
|
||||
disabled={demoGenerating}
|
||||
className="h-12 gap-2"
|
||||
>
|
||||
{demoGenerating ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<Database className="h-5 w-5" />
|
||||
)}
|
||||
Generer les donnees demo
|
||||
</Button>
|
||||
{demoMessage && <p className="text-sm text-muted-foreground">{demoMessage}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{visibleModules.map((module) => (
|
||||
<Card
|
||||
@@ -341,6 +388,7 @@ export default function Home() {
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<ThemeToggle />
|
||||
<CurrencySelect />
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<User className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{session?.user?.name}</span>
|
||||
|
||||
@@ -241,35 +241,6 @@ export default function AtelierModule() {
|
||||
}
|
||||
}
|
||||
|
||||
// Seed sample data
|
||||
const seedSampleData = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/atelier/seed?XTransformPort=3000', {
|
||||
method: 'POST'
|
||||
})
|
||||
if (response.ok) {
|
||||
toast({
|
||||
title: 'Données ajoutées',
|
||||
description: 'Les données de test ont été créées avec succès'
|
||||
})
|
||||
loadWorkOrders()
|
||||
} else {
|
||||
const error = await response.json()
|
||||
toast({
|
||||
title: 'Erreur',
|
||||
description: error.error || 'Impossible de créer les données de test',
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error seeding data:', error)
|
||||
toast({
|
||||
title: 'Erreur',
|
||||
description: 'Impossible de créer les données de test',
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadWorkOrders()
|
||||
@@ -480,12 +451,6 @@ export default function AtelierModule() {
|
||||
: `Aucune commande avec le statut "${statusFilter === 'EN_ATTENTE' ? 'En attente' : statusFilter === 'EN_COURS' ? 'En cours' : statusFilter === 'TERMINE' ? 'Terminé' : 'Prêt'}"`
|
||||
}
|
||||
</p>
|
||||
{statusFilter === 'ALL' && workOrders.length === 0 && (
|
||||
<Button onClick={seedSampleData} variant="outline">
|
||||
<Wrench className="h-4 w-4 mr-2" />
|
||||
Ajouter des données de test
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="max-h-[600px]">
|
||||
|
||||
57
src/components/currency-provider.tsx
Normal file
57
src/components/currency-provider.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
export type CurrencyCode = 'MAD' | 'EUR' | 'USD'
|
||||
|
||||
const currencyLocales: Record<CurrencyCode, string> = {
|
||||
MAD: 'fr-MA',
|
||||
EUR: 'fr-FR',
|
||||
USD: 'en-US',
|
||||
}
|
||||
|
||||
interface CurrencyContextValue {
|
||||
currency: CurrencyCode
|
||||
setCurrency: (currency: CurrencyCode) => void
|
||||
formatCurrency: (value: number) => string
|
||||
}
|
||||
|
||||
const CurrencyContext = createContext<CurrencyContextValue | null>(null)
|
||||
|
||||
export function CurrencyProvider({ children }: { children: React.ReactNode }) {
|
||||
const [currency, setCurrencyState] = useState<CurrencyCode>('MAD')
|
||||
|
||||
useEffect(() => {
|
||||
const stored = window.localStorage.getItem('optiquestock-currency') as CurrencyCode | null
|
||||
if (stored === 'MAD' || stored === 'EUR' || stored === 'USD') {
|
||||
setCurrencyState(stored)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const value = useMemo<CurrencyContextValue>(() => {
|
||||
return {
|
||||
currency,
|
||||
setCurrency(nextCurrency) {
|
||||
setCurrencyState(nextCurrency)
|
||||
window.localStorage.setItem('optiquestock-currency', nextCurrency)
|
||||
},
|
||||
formatCurrency(amount) {
|
||||
return new Intl.NumberFormat(currencyLocales[currency], {
|
||||
style: 'currency',
|
||||
currency,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount)
|
||||
},
|
||||
}
|
||||
}, [currency])
|
||||
|
||||
return <CurrencyContext.Provider value={value}>{children}</CurrencyContext.Provider>
|
||||
}
|
||||
|
||||
export function useCurrency() {
|
||||
const context = useContext(CurrencyContext)
|
||||
if (!context) {
|
||||
throw new Error('useCurrency must be used within CurrencyProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
25
src/components/currency-select.tsx
Normal file
25
src/components/currency-select.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
'use client'
|
||||
|
||||
import { BadgeDollarSign } from 'lucide-react'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { CurrencyCode, useCurrency } from '@/components/currency-provider'
|
||||
|
||||
export function CurrencySelect() {
|
||||
const { currency, setCurrency } = useCurrency()
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<BadgeDollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
<Select value={currency} onValueChange={(value: CurrencyCode) => setCurrency(value)}>
|
||||
<SelectTrigger className="h-9 w-[92px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="MAD">MAD</SelectItem>
|
||||
<SelectItem value="EUR">EUR</SelectItem>
|
||||
<SelectItem value="USD">USD</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
Wrench,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { useCurrency } from '@/components/currency-provider'
|
||||
|
||||
type Step = 'client' | 'service' | 'details' | 'final'
|
||||
type ServiceType = 'COMBO' | 'REPAIR'
|
||||
@@ -104,11 +105,8 @@ const paymentLabels: Record<PaymentMode, string> = {
|
||||
BON_CAISSE: 'Bon de caisse',
|
||||
}
|
||||
|
||||
function currency(value: number) {
|
||||
return `${value.toFixed(2)} EUR`
|
||||
}
|
||||
|
||||
export function SellerWizard() {
|
||||
const { formatCurrency } = useCurrency()
|
||||
const [step, setStep] = useState<Step>('client')
|
||||
const [clients, setClients] = useState<Client[]>([])
|
||||
const [products, setProducts] = useState<Product[]>([])
|
||||
@@ -266,18 +264,26 @@ export function SellerWizard() {
|
||||
setError('')
|
||||
if (clientFieldIndex < clientFields.length - 1) {
|
||||
setClientFieldIndex((index) => index + 1)
|
||||
focusActiveClientInput()
|
||||
holdKeyboardOpen()
|
||||
} else {
|
||||
createClient()
|
||||
}
|
||||
}
|
||||
|
||||
function focusActiveClientInput() {
|
||||
window.requestAnimationFrame(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
activeClientInputRef.current?.focus({ preventScroll: true })
|
||||
})
|
||||
})
|
||||
activeClientInputRef.current?.focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
function holdKeyboardOpen() {
|
||||
focusActiveClientInput()
|
||||
window.requestAnimationFrame(focusActiveClientInput)
|
||||
window.setTimeout(focusActiveClientInput, 40)
|
||||
window.setTimeout(focusActiveClientInput, 120)
|
||||
}
|
||||
|
||||
function keepTouchOnInput(event: React.MouseEvent | React.PointerEvent | React.TouchEvent) {
|
||||
event.preventDefault()
|
||||
holdKeyboardOpen()
|
||||
}
|
||||
|
||||
async function createClient() {
|
||||
@@ -440,7 +446,7 @@ export function SellerWizard() {
|
||||
nextClientField()
|
||||
} else {
|
||||
setClientFieldIndex((index) => Math.max(0, index - 1))
|
||||
focusActiveClientInput()
|
||||
holdKeyboardOpen()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -582,6 +588,11 @@ export function SellerWizard() {
|
||||
nextClientField()
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (createClientOpen && !loading) {
|
||||
holdKeyboardOpen()
|
||||
}
|
||||
}}
|
||||
className={`${
|
||||
keyboardOpen ? 'h-16 text-2xl' : 'h-20 text-3xl'
|
||||
} min-w-0`}
|
||||
@@ -589,7 +600,11 @@ export function SellerWizard() {
|
||||
/>
|
||||
<Button
|
||||
className={`${keyboardOpen ? 'h-16 px-5' : 'h-20 px-6'} text-xl`}
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onMouseDown={keepTouchOnInput}
|
||||
onPointerDown={keepTouchOnInput}
|
||||
onTouchStart={keepTouchOnInput}
|
||||
onClick={nextClientField}
|
||||
disabled={loading}
|
||||
>
|
||||
@@ -615,10 +630,14 @@ export function SellerWizard() {
|
||||
variant="outline"
|
||||
className={`${keyboardOpen ? 'h-12 text-base' : 'h-16 text-xl'}`}
|
||||
disabled={clientFieldIndex === 0}
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onMouseDown={keepTouchOnInput}
|
||||
onPointerDown={keepTouchOnInput}
|
||||
onTouchStart={keepTouchOnInput}
|
||||
onClick={() => {
|
||||
setClientFieldIndex((index) => Math.max(0, index - 1))
|
||||
focusActiveClientInput()
|
||||
holdKeyboardOpen()
|
||||
}}
|
||||
>
|
||||
<ArrowLeft className="h-6 w-6" />
|
||||
@@ -627,7 +646,11 @@ export function SellerWizard() {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`${keyboardOpen ? 'h-12 text-base' : 'h-16 text-xl'}`}
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onMouseDown={keepTouchOnInput}
|
||||
onPointerDown={keepTouchOnInput}
|
||||
onTouchStart={keepTouchOnInput}
|
||||
onClick={nextClientField}
|
||||
disabled={loading}
|
||||
>
|
||||
@@ -712,7 +735,7 @@ export function SellerWizard() {
|
||||
{product.reference} - Stock {product.stock}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xl font-bold">{currency(product.prixVenteTTC)}</div>
|
||||
<div className="text-xl font-bold">{formatCurrency(product.prixVenteTTC)}</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
@@ -798,7 +821,7 @@ export function SellerWizard() {
|
||||
|
||||
<div className="rounded-md bg-muted p-4">
|
||||
<div className="text-muted-foreground">Total</div>
|
||||
<div className="text-4xl font-bold">{currency(total)}</div>
|
||||
<div className="text-4xl font-bold">{formatCurrency(total)}</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -839,21 +862,21 @@ export function SellerWizard() {
|
||||
<span>
|
||||
{line.quantite} x {line.produit.designation}
|
||||
</span>
|
||||
<strong>{currency(line.montantTTC)}</strong>
|
||||
<strong>{formatCurrency(line.montantTTC)}</strong>
|
||||
</div>
|
||||
))}
|
||||
{sale.notes && <p className="muted">{sale.notes}</p>}
|
||||
<div className="row">
|
||||
<span>HT</span>
|
||||
<span>{currency(sale.montantHT)}</span>
|
||||
<span>{formatCurrency(sale.montantHT)}</span>
|
||||
</div>
|
||||
<div className="row">
|
||||
<span>TVA</span>
|
||||
<span>{currency(sale.montantTVA)}</span>
|
||||
<span>{formatCurrency(sale.montantTVA)}</span>
|
||||
</div>
|
||||
<div className="row total">
|
||||
<span>Total TTC</span>
|
||||
<span>{currency(sale.montantTTC)}</span>
|
||||
<span>{formatCurrency(sale.montantTTC)}</span>
|
||||
</div>
|
||||
<p className="muted">Paiement: {paymentLabels[sale.paiements[0]?.mode || 'ESPECES']}</p>
|
||||
</div>
|
||||
|
||||
27
src/lib/hermes-auth.ts
Normal file
27
src/lib/hermes-auth.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const DEV_HERMES_KEY = 'hermes-demo-key-change-me'
|
||||
|
||||
export function getHermesApiKey() {
|
||||
return process.env.HERMES_API_KEY || (process.env.NODE_ENV !== 'production' ? DEV_HERMES_KEY : '')
|
||||
}
|
||||
|
||||
export function requireHermesAccess(request: NextRequest) {
|
||||
const expectedKey = getHermesApiKey()
|
||||
const authHeader = request.headers.get('authorization') || ''
|
||||
const bearerKey = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : ''
|
||||
const headerKey = request.headers.get('x-hermes-key') || ''
|
||||
const providedKey = bearerKey || headerKey
|
||||
|
||||
if (!expectedKey || providedKey !== expectedKey) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Hermes access denied',
|
||||
hint: 'Send Authorization: Bearer <HERMES_API_KEY> or x-hermes-key.',
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export async function proxy(request: NextRequest) {
|
||||
if (
|
||||
pathname.startsWith('/login') ||
|
||||
pathname.startsWith('/api/auth') ||
|
||||
pathname.startsWith('/api/hermes') ||
|
||||
pathname.startsWith('/_next/static') ||
|
||||
pathname.startsWith('/_next/image') ||
|
||||
pathname === '/favicon.ico'
|
||||
|
||||
Reference in New Issue
Block a user