Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4db43f833b | |||
| 4e4268d678 | |||
| 1934d0b655 | |||
| 119123e006 | |||
| 5825640105 | |||
| f27b62a403 | |||
| 9303aab78e | |||
| 6dd6dc301e |
@@ -1,9 +1,41 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { Header } from '@/components/layout/Header'
|
||||
import { UsersManager } from '@/components/settings/UsersManager'
|
||||
import { ConnectionStatus } from '@/components/settings/ConnectionStatus'
|
||||
|
||||
type Tab = 'connexions' | 'utilisateurs'
|
||||
|
||||
export default function ParametresPage() {
|
||||
const { data: session } = useSession()
|
||||
const isAdmin = session?.user?.role === 'admin'
|
||||
const [tab, setTab] = useState<Tab>('connexions')
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="⚙️ Paramètres" />
|
||||
<div className="p-6"><p className="text-gray-400 text-sm">Module Paramètres — à implémenter (Plan 4)</p></div>
|
||||
<Header title="Paramètres" />
|
||||
<div className="p-6 space-y-6">
|
||||
{isAdmin && (
|
||||
<div className="flex gap-2">
|
||||
{(['connexions', 'utilisateurs'] as Tab[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
tab === t
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'bg-slate-700 text-gray-300 hover:bg-slate-600'
|
||||
}`}
|
||||
>
|
||||
{t === 'connexions' ? 'Connexions' : 'Utilisateurs'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(!isAdmin || tab === 'connexions') && <ConnectionStatus />}
|
||||
{isAdmin && tab === 'utilisateurs' && <UsersManager />}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,56 @@
|
||||
'use client'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { Header } from '@/components/layout/Header'
|
||||
import { ProductFiltersBar } from '@/components/products/ProductFiltersBar'
|
||||
import { ProductTable } from '@/components/products/ProductTable'
|
||||
import type { CatalogProduct, ProductFilters } from '@/types/products'
|
||||
|
||||
export default function ProduitsPage() {
|
||||
const [allProducts, setAllProducts] = useState<CatalogProduct[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [filters, setFilters] = useState<ProductFilters>({ status: 'all', search: '' })
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const params = filters.status !== 'all' ? `?status=${filters.status}` : ''
|
||||
fetch(`/api/products/list${params}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.error) throw new Error(data.error)
|
||||
setAllProducts(data.products)
|
||||
})
|
||||
.catch((e: Error) => setError(e.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [filters.status])
|
||||
|
||||
const displayed = useMemo(() => {
|
||||
if (!filters.search.trim()) return allProducts
|
||||
const q = filters.search.toLowerCase()
|
||||
return allProducts.filter(
|
||||
(p) => p.title.toLowerCase().includes(q) || p.ref.toLowerCase().includes(q),
|
||||
)
|
||||
}, [allProducts, filters.search])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="📦 Produits" />
|
||||
<div className="p-6"><p className="text-gray-400 text-sm">Module Produits — à implémenter (Plan 4)</p></div>
|
||||
<Header title="Produits" />
|
||||
<div className="p-6">
|
||||
<div className="bg-slate-800 rounded-xl p-6">
|
||||
<ProductFiltersBar
|
||||
filters={filters}
|
||||
total={displayed.length}
|
||||
onChange={(f) => setFilters((prev) => ({ ...prev, ...f }))}
|
||||
/>
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-900/30 border border-red-700 rounded-lg text-red-300 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<ProductTable products={displayed} loading={loading} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
|
||||
async function testShopify(): Promise<{ ok: boolean; message: string }> {
|
||||
const domain = process.env.SHOPIFY_STORE_DOMAIN
|
||||
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
|
||||
if (!domain || !token) return { ok: false, message: 'Variables manquantes' }
|
||||
try {
|
||||
const res = await fetch(`https://${domain}/admin/api/2024-01/shop.json`, {
|
||||
headers: { 'X-Shopify-Access-Token': token },
|
||||
})
|
||||
if (!res.ok) return { ok: false, message: `HTTP ${res.status}` }
|
||||
const data = await res.json()
|
||||
return { ok: true, message: `Boutique : ${data.shop?.name ?? domain}` }
|
||||
} catch (e) {
|
||||
return { ok: false, message: e instanceof Error ? e.message : 'Erreur réseau' }
|
||||
}
|
||||
}
|
||||
|
||||
async function testSheets(): Promise<{ ok: boolean; message: string }> {
|
||||
const apiKey = process.env.GOOGLE_API_KEY
|
||||
const sheetId = process.env.GOOGLE_SHEETS_ID
|
||||
if (!apiKey || !sheetId) return { ok: false, message: 'Variables manquantes' }
|
||||
try {
|
||||
const url = `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}?key=${apiKey}&fields=properties.title`
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) return { ok: false, message: `HTTP ${res.status}` }
|
||||
const data = await res.json()
|
||||
return { ok: true, message: `Feuille : ${data.properties?.title ?? sheetId}` }
|
||||
} catch (e) {
|
||||
return { ok: false, message: e instanceof Error ? e.message : 'Erreur réseau' }
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export async function GET(_req: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session || session.user.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const [shopify, sheets] = await Promise.all([testShopify(), testSheets()])
|
||||
return NextResponse.json({ shopify, sheets })
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
async function requireAdmin() {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session || session.user.role !== 'admin') return null
|
||||
return session
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
const session = await requireAdmin()
|
||||
if (!session) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const body = await req.json().catch(() => ({}))
|
||||
const { name, role, password } = body as Record<string, string>
|
||||
|
||||
const data: Record<string, string> = {}
|
||||
if (name) data.name = name
|
||||
if (role) data.role = role
|
||||
if (password) data.password = await bcrypt.hash(password, 12)
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
return NextResponse.json({ error: 'Aucun champ à mettre à jour' }, { status: 400 })
|
||||
}
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id: params.id },
|
||||
data,
|
||||
select: { id: true, email: true, name: true, role: true, createdAt: true },
|
||||
})
|
||||
return NextResponse.json(user)
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
const session = await requireAdmin()
|
||||
if (!session) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
if (params.id === session.user.id) {
|
||||
return NextResponse.json({ error: 'Impossible de se supprimer soi-même' }, { status: 400 })
|
||||
}
|
||||
|
||||
await prisma.user.delete({ where: { id: params.id } })
|
||||
return NextResponse.json({ ok: true })
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { GET, POST } from './route'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
jest.mock('next-auth')
|
||||
jest.mock('@/lib/prisma', () => ({ prisma: { user: { findMany: jest.fn(), create: jest.fn() } } }))
|
||||
jest.mock('bcryptjs')
|
||||
|
||||
const mockSession = getServerSession as jest.MockedFunction<typeof getServerSession>
|
||||
const mockFindMany = prisma.user.findMany as jest.MockedFunction<typeof prisma.user.findMany>
|
||||
const mockCreate = prisma.user.create as jest.MockedFunction<typeof prisma.user.create>
|
||||
const mockHash = bcrypt.hash as jest.MockedFunction<typeof bcrypt.hash>
|
||||
|
||||
const adminSession = { user: { id: 'admin-id', role: 'admin' } }
|
||||
const gestSession = { user: { id: 'gest-id', role: 'gestionnaire' } }
|
||||
|
||||
describe('GET /api/admin/users', () => {
|
||||
it('retourne 403 si non admin', async () => {
|
||||
mockSession.mockResolvedValue(gestSession as never)
|
||||
const res = await GET(new Request('http://localhost') as never)
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('retourne la liste des utilisateurs', async () => {
|
||||
mockSession.mockResolvedValue(adminSession as never)
|
||||
mockFindMany.mockResolvedValue([
|
||||
{ id: '1', email: 'a@b.com', name: 'Alice', role: 'admin', createdAt: new Date() } as never,
|
||||
])
|
||||
const res = await GET(new Request('http://localhost') as never)
|
||||
const body = await res.json()
|
||||
expect(body).toHaveLength(1)
|
||||
expect(body[0].email).toBe('a@b.com')
|
||||
expect(body[0].password).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/admin/users', () => {
|
||||
it('retourne 403 si non admin', async () => {
|
||||
mockSession.mockResolvedValue(gestSession as never)
|
||||
const res = await POST(new Request('http://localhost', { method: 'POST', body: '{}' }) as never)
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('crée un utilisateur avec mot de passe hashé', async () => {
|
||||
mockSession.mockResolvedValue(adminSession as never)
|
||||
mockHash.mockResolvedValue('hashed' as never)
|
||||
mockCreate.mockResolvedValue({ id: 'new', email: 'b@c.com', name: 'Bob', role: 'gestionnaire', createdAt: new Date() } as never)
|
||||
const body = JSON.stringify({ email: 'b@c.com', name: 'Bob', password: 'pass123', role: 'gestionnaire' })
|
||||
const res = await POST(new Request('http://localhost', { method: 'POST', body, headers: { 'Content-Type': 'application/json' } }) as never)
|
||||
expect(res.status).toBe(201)
|
||||
expect(mockHash).toHaveBeenCalledWith('pass123', 12)
|
||||
})
|
||||
|
||||
it('retourne 400 si champs manquants', async () => {
|
||||
mockSession.mockResolvedValue(adminSession as never)
|
||||
const body = JSON.stringify({ email: 'b@c.com' })
|
||||
const res = await POST(new Request('http://localhost', { method: 'POST', body, headers: { 'Content-Type': 'application/json' } }) as never)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
async function requireAdmin() {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session || session.user.role !== 'admin') return null
|
||||
return session
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export async function GET(_req: NextRequest) {
|
||||
const session = await requireAdmin()
|
||||
if (!session) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: { id: true, email: true, name: true, role: true, createdAt: true },
|
||||
})
|
||||
return NextResponse.json(users)
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await requireAdmin()
|
||||
if (!session) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const body = await req.json().catch(() => ({}))
|
||||
const { email, name, password, role } = body as Record<string, string>
|
||||
|
||||
if (!email || !name || !password || !role) {
|
||||
return NextResponse.json({ error: 'email, name, password et role requis' }, { status: 400 })
|
||||
}
|
||||
|
||||
const hashed = await bcrypt.hash(password, 12)
|
||||
const user = await prisma.user.create({
|
||||
data: { email, name, password: hashed, role },
|
||||
select: { id: true, email: true, name: true, role: true, createdAt: true },
|
||||
})
|
||||
return NextResponse.json(user, { status: 201 })
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { GET } from './route'
|
||||
import * as shopifySync from '@/lib/shopifySync'
|
||||
import type { ShopifyProductFull } from '@/types/sync'
|
||||
|
||||
jest.mock('@/lib/shopifySync')
|
||||
const mockFetch = shopifySync.fetchAllShopifyProducts as jest.MockedFunction<typeof shopifySync.fetchAllShopifyProducts>
|
||||
|
||||
const PRODUCTS: ShopifyProductFull[] = [
|
||||
{ shopifyId: '1', ref: 'REF-001', handle: 'ref-001', title: 'Brique rouge', description: '', price: '12.50', stock: 100, status: 'active' },
|
||||
{ shopifyId: '2', ref: 'REF-002', handle: 'ref-002', title: 'Parpaing', description: '', price: '5.00', stock: 0, status: 'draft' },
|
||||
{ shopifyId: '3', ref: 'REF-003', handle: 'ref-003', title: 'Ardoise', description: '', price: '80.00', stock: 20, status: 'active' },
|
||||
]
|
||||
|
||||
describe('GET /api/products/list', () => {
|
||||
beforeEach(() => {
|
||||
process.env.SHOPIFY_STORE_DOMAIN = 'test.myshopify.com'
|
||||
process.env.SHOPIFY_ADMIN_API_TOKEN = 'token'
|
||||
mockFetch.mockResolvedValue(PRODUCTS)
|
||||
})
|
||||
afterEach(() => jest.clearAllMocks())
|
||||
|
||||
it('retourne tous les produits sans filtre', async () => {
|
||||
const req = new Request('http://localhost/api/products/list')
|
||||
const res = await GET(req as never)
|
||||
const body = await res.json()
|
||||
expect(body.total).toBe(3)
|
||||
expect(body.products).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('filtre par status=active', async () => {
|
||||
const req = new Request('http://localhost/api/products/list?status=active')
|
||||
const res = await GET(req as never)
|
||||
const body = await res.json()
|
||||
expect(body.total).toBe(2)
|
||||
body.products.forEach((p: { status: string }) => expect(p.status).toBe('active'))
|
||||
})
|
||||
|
||||
it('filtre par status=draft', async () => {
|
||||
const req = new Request('http://localhost/api/products/list?status=draft')
|
||||
const res = await GET(req as never)
|
||||
const body = await res.json()
|
||||
expect(body.total).toBe(1)
|
||||
expect(body.products[0].ref).toBe('REF-002')
|
||||
})
|
||||
|
||||
it('retourne shopifyUrl pour chaque produit', async () => {
|
||||
const req = new Request('http://localhost/api/products/list')
|
||||
const res = await GET(req as never)
|
||||
const body = await res.json()
|
||||
expect(body.products[0].shopifyUrl).toContain('test.myshopify.com/admin/products/1')
|
||||
})
|
||||
|
||||
it('retourne 500 si Shopify inaccessible', async () => {
|
||||
mockFetch.mockRejectedValue(new Error('network error'))
|
||||
const req = new Request('http://localhost/api/products/list')
|
||||
const res = await GET(req as never)
|
||||
expect(res.status).toBe(500)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { fetchAllShopifyProducts } from '@/lib/shopifySync'
|
||||
import type { CatalogProduct, ProductListResponse } from '@/types/products'
|
||||
|
||||
export async function GET(req: NextRequest): Promise<NextResponse<ProductListResponse | { error: string }>> {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const statusFilter = searchParams.get('status') ?? 'all'
|
||||
|
||||
const domain = process.env.SHOPIFY_STORE_DOMAIN ?? ''
|
||||
const shopifyProducts = await fetchAllShopifyProducts()
|
||||
|
||||
let products: CatalogProduct[] = shopifyProducts.map((p) => ({
|
||||
shopifyId: p.shopifyId,
|
||||
ref: p.ref,
|
||||
handle: p.handle,
|
||||
title: p.title,
|
||||
price: p.price,
|
||||
stock: p.stock,
|
||||
status: p.status as CatalogProduct['status'],
|
||||
shopifyUrl: `https://${domain}/admin/products/${p.shopifyId}`,
|
||||
}))
|
||||
|
||||
if (statusFilter !== 'all') {
|
||||
products = products.filter((p) => p.status === statusFilter)
|
||||
}
|
||||
|
||||
return NextResponse.json({ products, total: products.length })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
import type { ProductFilters } from '@/types/products'
|
||||
|
||||
interface Props {
|
||||
filters: ProductFilters
|
||||
total: number
|
||||
onChange: (f: Partial<ProductFilters>) => void
|
||||
}
|
||||
|
||||
export function ProductFiltersBar({ filters, total, onChange }: Props) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3 items-center mb-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher ref ou titre..."
|
||||
value={filters.search}
|
||||
onChange={(e) => onChange({ search: e.target.value })}
|
||||
className="flex-1 min-w-48 bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
{(['all', 'active', 'draft'] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => onChange({ status: s })}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
filters.status === s
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'bg-slate-700 text-gray-300 hover:bg-slate-600'
|
||||
}`}
|
||||
>
|
||||
{s === 'all' ? 'Tous' : s === 'active' ? 'Actifs' : 'Inactifs'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm text-gray-400 ml-auto">{total} produit{total > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
'use client'
|
||||
import type { CatalogProduct } from '@/types/products'
|
||||
|
||||
interface Props {
|
||||
products: CatalogProduct[]
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export function ProductTable({ products, loading }: Props) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20 text-gray-400">
|
||||
Chargement des produits…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (products.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-20 text-gray-400 text-sm">
|
||||
Aucun produit trouvé.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const displayed = products.slice(0, PAGE_SIZE)
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-400 border-b border-slate-700">
|
||||
<th className="pb-3 pr-4 font-medium">Référence</th>
|
||||
<th className="pb-3 pr-4 font-medium">Titre</th>
|
||||
<th className="pb-3 pr-4 font-medium">Prix</th>
|
||||
<th className="pb-3 pr-4 font-medium">Stock</th>
|
||||
<th className="pb-3 pr-4 font-medium">Statut</th>
|
||||
<th className="pb-3 font-medium">Lien</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{displayed.map((p) => (
|
||||
<tr key={p.shopifyId} className="border-b border-slate-700/50 hover:bg-slate-700/30 transition-colors">
|
||||
<td className="py-3 pr-4 font-mono text-indigo-300">{p.ref}</td>
|
||||
<td className="py-3 pr-4 text-white">{p.title}</td>
|
||||
<td className="py-3 pr-4 text-gray-300">{p.price} €</td>
|
||||
<td className="py-3 pr-4 text-gray-300">{p.stock}</td>
|
||||
<td className="py-3 pr-4">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
|
||||
p.status === 'active'
|
||||
? 'bg-green-900/50 text-green-300'
|
||||
: 'bg-gray-700 text-gray-400'
|
||||
}`}>
|
||||
{p.status === 'active' ? 'Actif' : 'Inactif'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<a
|
||||
href={p.shopifyUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-indigo-400 hover:text-indigo-300 text-xs underline"
|
||||
>
|
||||
Shopify ↗
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{products.length > PAGE_SIZE && (
|
||||
<p className="text-center text-gray-400 text-xs mt-4">
|
||||
Affichage des {PAGE_SIZE} premiers résultats sur {products.length}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
|
||||
interface TestResult {
|
||||
ok: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
interface Results {
|
||||
shopify: TestResult
|
||||
sheets: TestResult
|
||||
}
|
||||
|
||||
export function ConnectionStatus() {
|
||||
const [results, setResults] = useState<Results | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const runTest = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch('/api/admin/connections/test')
|
||||
if (!res.ok) throw new Error('Accès refusé')
|
||||
setResults(await res.json())
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Erreur')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-slate-800 rounded-xl p-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Test des connexions</h2>
|
||||
<button
|
||||
onClick={runTest}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors mb-4"
|
||||
>
|
||||
{loading ? 'Test en cours…' : 'Tester les connexions'}
|
||||
</button>
|
||||
{error && <p className="text-red-400 text-sm mb-4">{error}</p>}
|
||||
{results && (
|
||||
<div className="space-y-3">
|
||||
{(
|
||||
[
|
||||
{ key: 'shopify', label: 'Shopify Admin API' },
|
||||
{ key: 'sheets', label: 'Google Sheets API' },
|
||||
] as const
|
||||
).map(({ key, label }) => (
|
||||
<div key={key} className="flex items-center gap-3 p-3 bg-slate-700 rounded-lg">
|
||||
<span className={`text-lg ${results[key].ok ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{results[key].ok ? '✓' : '✗'}
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">{label}</p>
|
||||
<p className="text-xs text-gray-400">{results[key].message}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
'use client'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
role: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
email: string
|
||||
name: string
|
||||
password: string
|
||||
role: 'admin' | 'gestionnaire'
|
||||
}
|
||||
|
||||
const EMPTY_FORM: FormData = { email: '', name: '', password: '', role: 'gestionnaire' }
|
||||
|
||||
export function UsersManager() {
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [form, setForm] = useState<FormData>(EMPTY_FORM)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
|
||||
const load = () => {
|
||||
setLoading(true)
|
||||
fetch('/api/admin/users')
|
||||
.then((r) => r.json())
|
||||
.then(setUsers)
|
||||
.catch(() => setError('Impossible de charger les utilisateurs'))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(load, [])
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
try {
|
||||
const res = await fetch('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error)
|
||||
setSuccess(`Utilisateur ${data.name} créé.`)
|
||||
setForm(EMPTY_FORM)
|
||||
load()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Erreur')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (!confirm(`Supprimer ${name} ?`)) return
|
||||
try {
|
||||
const res = await fetch(`/api/admin/users/${id}`, { method: 'DELETE' })
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error)
|
||||
load()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Erreur suppression')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-slate-800 rounded-xl p-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Gestion des utilisateurs</h2>
|
||||
|
||||
{/* Liste */}
|
||||
{loading ? (
|
||||
<p className="text-gray-400 text-sm mb-6">Chargement…</p>
|
||||
) : (
|
||||
<div className="mb-6 space-y-2">
|
||||
{users.map((u) => (
|
||||
<div key={u.id} className="flex items-center justify-between p-3 bg-slate-700 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">{u.name}</p>
|
||||
<p className="text-xs text-gray-400">{u.email} — <span className="capitalize">{u.role}</span></p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(u.id, u.name)}
|
||||
className="text-red-400 hover:text-red-300 text-xs px-2 py-1 rounded hover:bg-red-900/30 transition-colors"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{users.length === 0 && <p className="text-gray-400 text-sm">Aucun utilisateur.</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Formulaire création */}
|
||||
<form onSubmit={handleCreate} className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-gray-300">Nouvel utilisateur</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<input
|
||||
required
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
|
||||
className="bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
placeholder="Nom"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
className="bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
placeholder="Mot de passe"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
|
||||
className="bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
<select
|
||||
value={form.role}
|
||||
onChange={(e) => setForm((f) => ({ ...f, role: e.target.value as FormData['role'] }))}
|
||||
className="bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="gestionnaire">Gestionnaire</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||
{success && <p className="text-green-400 text-sm">{success}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
{submitting ? 'Création…' : "Créer l'utilisateur"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// src/types/products.ts
|
||||
|
||||
/** Produit Shopify simplifié pour affichage catalogue */
|
||||
export interface CatalogProduct {
|
||||
shopifyId: string
|
||||
ref: string // SKU de la première variante
|
||||
handle: string
|
||||
title: string
|
||||
price: string // "29.99"
|
||||
stock: number // inventory_quantity variante 1
|
||||
status: 'active' | 'draft' | 'archived'
|
||||
shopifyUrl: string // https://materiaux-destock.myshopify.com/admin/products/<id>
|
||||
}
|
||||
|
||||
/** Filtres appliqués à la liste */
|
||||
export interface ProductFilters {
|
||||
status: 'all' | 'active' | 'draft'
|
||||
search: string // filtre titre ou ref (côté client)
|
||||
}
|
||||
|
||||
/** Réponse de l'API /api/products/list */
|
||||
export interface ProductListResponse {
|
||||
products: CatalogProduct[]
|
||||
total: number
|
||||
}
|
||||
Reference in New Issue
Block a user