diff --git a/backend/prisma/migrations/20260416200000_add_receipt_alias/migration.sql b/backend/prisma/migrations/20260416200000_add_receipt_alias/migration.sql new file mode 100644 index 00000000..dd51723e --- /dev/null +++ b/backend/prisma/migrations/20260416200000_add_receipt_alias/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE `ReceiptAlias` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `receiptName` VARCHAR(191) NOT NULL, + `productId` INTEGER NOT NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + UNIQUE INDEX `ReceiptAlias_receiptName_key`(`receiptName`), + INDEX `ReceiptAlias_productId_idx`(`productId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `ReceiptAlias` ADD CONSTRAINT `ReceiptAlias_productId_fkey` FOREIGN KEY (`productId`) REFERENCES `Product`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index b22068e1..5604a034 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -21,6 +21,7 @@ model Product { inventoryItems InventoryItem[] recipeIngredients RecipeIngredient[] pantryItems PantryItem[] + receiptAliases ReceiptAlias[] } model InventoryItem { @@ -95,6 +96,14 @@ model PantryItem { updatedAt DateTime @updatedAt } +model ReceiptAlias { + id Int @id @default(autoincrement()) + receiptName String @unique // normaliserat kvittonamn (lowercase, trim) + productId Int + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) +} + model MealPlanEntry { id Int @id @default(autoincrement()) date DateTime @db.Date diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index a8686bf5..4473b628 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -8,6 +8,7 @@ import { QuickImportModule } from './quick-import/quick-import.module'; import { PantryModule } from './pantry/pantry.module'; import { MealPlanModule } from './meal-plan/meal-plan.module'; import { ReceiptImportModule } from './receipt-import/receipt-import.module'; +import { ReceiptAliasModule } from './receipt-alias/receipt-alias.module'; @Module({ @@ -21,6 +22,7 @@ import { ReceiptImportModule } from './receipt-import/receipt-import.module'; PantryModule, MealPlanModule, ReceiptImportModule, + ReceiptAliasModule, ], }) export class AppModule {} \ No newline at end of file diff --git a/backend/src/receipt-alias/dto/create-receipt-alias.dto.ts b/backend/src/receipt-alias/dto/create-receipt-alias.dto.ts new file mode 100644 index 00000000..92381c65 --- /dev/null +++ b/backend/src/receipt-alias/dto/create-receipt-alias.dto.ts @@ -0,0 +1,10 @@ +import { IsInt, IsString, MinLength } from 'class-validator'; + +export class CreateReceiptAliasDto { + @IsString() + @MinLength(1) + receiptName!: string; + + @IsInt() + productId!: number; +} diff --git a/backend/src/receipt-alias/receipt-alias.controller.ts b/backend/src/receipt-alias/receipt-alias.controller.ts new file mode 100644 index 00000000..3f710f9c --- /dev/null +++ b/backend/src/receipt-alias/receipt-alias.controller.ts @@ -0,0 +1,23 @@ +import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post } from '@nestjs/common'; +import { ReceiptAliasService } from './receipt-alias.service'; +import { CreateReceiptAliasDto } from './dto/create-receipt-alias.dto'; + +@Controller('receipt-aliases') +export class ReceiptAliasController { + constructor(private readonly receiptAliasService: ReceiptAliasService) {} + + @Get() + findAll() { + return this.receiptAliasService.findAll(); + } + + @Post() + upsert(@Body() dto: CreateReceiptAliasDto) { + return this.receiptAliasService.upsert(dto); + } + + @Delete(':id') + remove(@Param('id', ParseIntPipe) id: number) { + return this.receiptAliasService.remove(id); + } +} diff --git a/backend/src/receipt-alias/receipt-alias.module.ts b/backend/src/receipt-alias/receipt-alias.module.ts new file mode 100644 index 00000000..d7989cc5 --- /dev/null +++ b/backend/src/receipt-alias/receipt-alias.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { ReceiptAliasController } from './receipt-alias.controller'; +import { ReceiptAliasService } from './receipt-alias.service'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [ReceiptAliasController], + providers: [ReceiptAliasService], + exports: [ReceiptAliasService], +}) +export class ReceiptAliasModule {} diff --git a/backend/src/receipt-alias/receipt-alias.service.ts b/backend/src/receipt-alias/receipt-alias.service.ts new file mode 100644 index 00000000..961d33e4 --- /dev/null +++ b/backend/src/receipt-alias/receipt-alias.service.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateReceiptAliasDto } from './dto/create-receipt-alias.dto'; + +@Injectable() +export class ReceiptAliasService { + constructor(private readonly prisma: PrismaService) {} + + findAll() { + return this.prisma.receiptAlias.findMany({ + include: { product: { select: { id: true, name: true, canonicalName: true } } }, + orderBy: { receiptName: 'asc' }, + }); + } + + async upsert(dto: CreateReceiptAliasDto) { + const normalized = dto.receiptName.toLowerCase().trim(); + return this.prisma.receiptAlias.upsert({ + where: { receiptName: normalized }, + create: { receiptName: normalized, productId: dto.productId }, + update: { productId: dto.productId }, + }); + } + + remove(id: number) { + return this.prisma.receiptAlias.delete({ where: { id } }); + } +} diff --git a/backend/src/receipt-import/dto/parsed-receipt-item.dto.ts b/backend/src/receipt-import/dto/parsed-receipt-item.dto.ts index 02e51d71..08a6e854 100644 --- a/backend/src/receipt-import/dto/parsed-receipt-item.dto.ts +++ b/backend/src/receipt-import/dto/parsed-receipt-item.dto.ts @@ -3,6 +3,10 @@ export interface ParsedReceiptItem { quantity: number; unit: string; price?: number | null; + // alias-match: säker, användaren slipper bekräfta matchedProductId?: number; matchedProductName?: string; + // ordbaserad match: förslag, kräver bekräftelse + suggestedProductId?: number; + suggestedProductName?: string; } diff --git a/backend/src/receipt-import/receipt-import.service.ts b/backend/src/receipt-import/receipt-import.service.ts index a664b814..c285aa95 100644 --- a/backend/src/receipt-import/receipt-import.service.ts +++ b/backend/src/receipt-import/receipt-import.service.ts @@ -131,8 +131,16 @@ export class ReceiptImportService { ): Promise { if (!response.ok) { const err = await response.text(); - this.logger.error(`Mistral API svarade ${response.status}: ${err}`); - throw new ServiceUnavailableException('Mistral API returnerade ett fel — kontrollera API-nyckeln'); + this.logger.error(`Mistral API svarade ${response.status} (${source}): ${err}`); + const hint = + response.status === 401 + ? 'Ogiltig API-nyckel (401)' + : response.status === 429 + ? 'För många förfrågningar — försök igen om en stund (429)' + : `HTTP ${response.status}`; + throw new ServiceUnavailableException( + `Mistral API returnerade ett fel: ${hint}`, + ); } const data = (await response.json()) as { @@ -156,35 +164,61 @@ export class ReceiptImportService { private async matchProducts( items: ParsedReceiptItem[], ): Promise { - const products = await this.prisma.product.findMany({ - select: { id: true, name: true, canonicalName: true }, - }); + // Hämta alias och produkter parallellt + const [aliases, products] = await Promise.all([ + this.prisma.receiptAlias.findMany({ + select: { receiptName: true, productId: true, product: { select: { id: true, name: true, canonicalName: true } } }, + }), + this.prisma.product.findMany({ + where: { isActive: true }, + select: { id: true, name: true, canonicalName: true }, + }), + ]); return items.map((item) => { const raw = (item.rawName ?? '').toLowerCase().trim(); if (!raw) return item; - // Exakt matchning först - let match = products.find((p) => { - const n = (p.canonicalName ?? p.name).toLowerCase(); - return n === raw || p.name.toLowerCase() === raw; - }); - - // Delvis matchning - if (!match) { - match = products.find((p) => { - const n = (p.canonicalName ?? p.name).toLowerCase(); - return n.includes(raw) || raw.includes(n); - }); + // 1. Alias-match (säker, användaren behöver inte bekräfta) + const alias = aliases.find((a) => a.receiptName === raw); + if (alias) { + return { + ...item, + matchedProductId: alias.product.id, + matchedProductName: alias.product.canonicalName ?? alias.product.name, + }; } + // 2. Ordbaserad matchning (förslag, kräver bekräftelse) + const suggestion = this.findWordMatch(raw, products); return { ...item, - matchedProductId: match?.id, - matchedProductName: match - ? (match.canonicalName ?? match.name) + suggestedProductId: suggestion?.id, + suggestedProductName: suggestion + ? (suggestion.canonicalName ?? suggestion.name) : undefined, }; }); } + + private findWordMatch( + raw: string, + products: { id: number; name: string; canonicalName: string | null }[], + ): { id: number; name: string; canonicalName: string | null } | undefined { + // Dela upp kvittonamnet i ord (min 3 tecken) + const rawWords = raw.split(/[\s\-_]+/).filter((w) => w.length >= 3); + if (rawWords.length === 0) return undefined; + + // Fortsätt med att hitta produkter där ett produktnamn-ord finns i kvittonamnet + // Exempel: produktord "ost" finns i kvittoord "prästost", "herrgårdsost", "brieost" + return products.find((p) => { + const productWords = (p.canonicalName ?? p.name) + .toLowerCase() + .split(/[\s\-_]+/) + .filter((w) => w.length >= 3); + return productWords.some((pw) => + rawWords.some((rw) => rw.includes(pw) || pw.includes(rw)), + ); + }); + } } diff --git a/frontend/app/api/receipt-alias-proxy/route.ts b/frontend/app/api/receipt-alias-proxy/route.ts new file mode 100644 index 00000000..06664bdd --- /dev/null +++ b/frontend/app/api/receipt-alias-proxy/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const API_BASE = + process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080'; + +export async function GET() { + const res = await fetch(`${API_BASE}/api/receipt-aliases`, { cache: 'no-store' }); + const text = await res.text(); + return new NextResponse(text, { + status: res.status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +export async function POST(request: NextRequest) { + const body = await request.json(); + const res = await fetch(`${API_BASE}/api/receipt-aliases`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const text = await res.text(); + return new NextResponse(text, { + status: res.status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +export async function DELETE(request: NextRequest) { + const id = request.nextUrl.searchParams.get('id'); + const res = await fetch(`${API_BASE}/api/receipt-aliases/${id}`, { + method: 'DELETE', + }); + return new NextResponse(null, { status: res.status }); +} diff --git a/frontend/app/kvitto/ReceiptImportClient.tsx b/frontend/app/kvitto/ReceiptImportClient.tsx index b447ceb2..cf25785d 100644 --- a/frontend/app/kvitto/ReceiptImportClient.tsx +++ b/frontend/app/kvitto/ReceiptImportClient.tsx @@ -1,284 +1,302 @@ -'use client'; - -import { useRef, useState } from 'react'; - -type ParsedItem = { - rawName: string; - quantity: number; - unit: string; - price?: number | null; - matchedProductId?: number; - matchedProductName?: string; -}; - -type RowState = ParsedItem & { checked: boolean; editQty: string; editUnit: string }; - -const UNITS = ['st', 'kg', 'g', 'l', 'dl', 'cl', 'ml', 'förp', 'pak', 'burk', 'flaska']; - -export default function ReceiptImportClient() { - const fileRef = useRef(null); - const [preview, setPreview] = useState(null); - const [parsing, setParsing] = useState(false); - const [saving, setSaving] = useState(false); - const [rows, setRows] = useState([]); - const [error, setError] = useState(null); - const [savedCount, setSavedCount] = useState(null); - const [selectedFile, setSelectedFile] = useState(null); - - const handleFileChange = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - setSelectedFile(file); - if (file.type === 'application/pdf') { - setPreview('pdf'); - } else { - setPreview(URL.createObjectURL(file)); - } - setRows([]); - setError(null); - setSavedCount(null); - }; - - const handleParse = async () => { - if (!selectedFile) return; - setParsing(true); - setError(null); - try { - const fd = new FormData(); - fd.append('file', selectedFile); - const res = await fetch('/api/receipt-import-proxy', { method: 'POST', body: fd }); - if (!res.ok) { - const e = await res.json().catch(() => ({ message: 'Okänt fel' })); - throw new Error(e.message ?? 'Servern svarade med fel'); - } - const items: ParsedItem[] = await res.json(); - setRows( - items.map((item) => ({ - ...item, - checked: !!item.matchedProductId, - editQty: String(item.quantity), - editUnit: item.unit, - })), - ); - } catch (err) { - setError(err instanceof Error ? err.message : 'Kunde inte tolka kvittot'); - } finally { - setParsing(false); - } - }; - - const updateRow = (i: number, patch: Partial) => { - setRows((prev) => prev.map((r, idx) => (idx === i ? { ...r, ...patch } : r))); - }; - - const handleSave = async () => { - const toSave = rows.filter((r) => r.checked && r.matchedProductId); - if (toSave.length === 0) return; - setSaving(true); - setError(null); - try { - await Promise.all( - toSave.map((r) => - fetch('/api/inventory', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - productId: r.matchedProductId, - quantity: parseFloat(r.editQty) || r.quantity, - unit: r.editUnit, - }), - }), - ), - ); - setSavedCount(toSave.length); - setRows([]); - setPreview(null); - setSelectedFile(null); - if (fileRef.current) fileRef.current.value = ''; - } catch { - setError('Något gick fel vid sparning. Försök igen.'); - } finally { - setSaving(false); - } - }; - - const checkedCount = rows.filter((r) => r.checked && r.matchedProductId).length; - const unmatchedCount = rows.filter((r) => !r.matchedProductId).length; - - return ( -
- {/* Fil-input */} -
fileRef.current?.click()} - > - - {preview === 'pdf' ? ( -
-
📄
-
{selectedFile?.name}
-
PDF-kvitto valt
-
- ) : preview ? ( - // eslint-disable-next-line @next/next/no-img-element - Kvittoförhandsgranskning - ) : ( -
-
📷
-
Fotografera eller välj kvitto
-
- Klicka för att välja bild (JPEG, PNG, WebP) eller PDF -
-
- )} -
- - {preview && rows.length === 0 && ( - - )} - - {error && ( -

- {error} -

- )} - - {savedCount !== null && ( -

- ✓ {savedCount} {savedCount === 1 ? 'vara lades till' : 'varor lades till'} i inventariet. -

- )} - - {/* Parsade rader */} - {rows.length > 0 && ( -
-
-

- Identifierade varor ({rows.length}) -

- {unmatchedCount > 0 && ( - - {unmatchedCount} {unmatchedCount === 1 ? 'vara' : 'varor'} saknas i produktdatabasen - - )} -
- -
- {rows.map((row, i) => { - const matched = !!row.matchedProductId; - return ( -
- updateRow(i, { checked: e.target.checked })} - style={{ width: '18px', height: '18px', cursor: matched ? 'pointer' : 'not-allowed' }} - title={!matched ? 'Produkten finns inte i databasen — lägg till den i admin först' : ''} - /> -
-
- {row.matchedProductName ?? row.rawName} -
- {row.matchedProductName && row.matchedProductName.toLowerCase() !== row.rawName.toLowerCase() && ( -
- Kvitto: {row.rawName} -
- )} - {!matched && ( -
- Ingen matchning — {row.rawName} -
- )} -
- updateRow(i, { editQty: e.target.value })} - style={{ padding: '0.3rem 0.5rem', border: '1px solid #ced4da', borderRadius: '4px', width: '100%', fontSize: '0.9rem' }} - /> - -
- ); - })} -
- -
- - -
-
- )} -
- ); -} - -function primaryBtn(disabled: boolean): React.CSSProperties { - return { - padding: '0.6rem 1.25rem', - background: disabled ? '#aaa' : '#0070f3', - color: '#fff', - border: 'none', - borderRadius: '6px', - cursor: disabled ? 'not-allowed' : 'pointer', - fontWeight: 600, - fontSize: '0.95rem', - }; +'use client'; + +import { useRef, useState, useEffect } from 'react'; + +type ParsedItem = { + rawName: string; + quantity: number; + unit: string; + price?: number | null; + matchedProductId?: number; + matchedProductName?: string; + suggestedProductId?: number; + suggestedProductName?: string; +}; + +type Product = { id: number; name: string; canonicalName: string | null }; + +type RowState = { + rawName: string; + quantity: number; + unit: string; + price?: number | null; + selectedProductId: number | ''; + selectedProductName: string; + checked: boolean; + saveAlias: boolean; + editQty: string; + editUnit: string; + matchSource: 'alias' | 'suggestion' | 'manual' | 'none'; +}; + +const UNITS = ['st', 'kg', 'g', 'l', 'dl', 'cl', 'ml', 'förp', 'pak', 'burk', 'flaska']; + +export default function ReceiptImportClient() { + const fileRef = useRef(null); + const [preview, setPreview] = useState(null); + const [parsing, setParsing] = useState(false); + const [saving, setSaving] = useState(false); + const [rows, setRows] = useState([]); + const [allProducts, setAllProducts] = useState([]); + const [error, setError] = useState(null); + const [savedCount, setSavedCount] = useState(null); + const [selectedFile, setSelectedFile] = useState(null); + + useEffect(() => { + fetch('/api/products') + .then((r) => r.json()) + .then((data) => { if (Array.isArray(data)) setAllProducts(data); }) + .catch(() => {}); + }, []); + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setSelectedFile(file); + setPreview(file.type === 'application/pdf' ? 'pdf' : URL.createObjectURL(file)); + setRows([]); + setError(null); + setSavedCount(null); + }; + + const handleParse = async () => { + if (!selectedFile) return; + setParsing(true); + setError(null); + try { + const fd = new FormData(); + fd.append('file', selectedFile); + const res = await fetch('/api/receipt-import-proxy', { method: 'POST', body: fd }); + if (!res.ok) { + const e = await res.json().catch(() => ({ message: 'Okänt fel' })); + throw new Error(e.message ?? 'Servern svarade med fel'); + } + const items: ParsedItem[] = await res.json(); + setRows( + items.map((item): RowState => { + if (item.matchedProductId) { + return { + rawName: item.rawName, + quantity: item.quantity, + unit: item.unit, + price: item.price, + selectedProductId: item.matchedProductId, + selectedProductName: item.matchedProductName ?? '', + checked: true, + saveAlias: false, + editQty: String(item.quantity), + editUnit: item.unit, + matchSource: 'alias', + }; + } + if (item.suggestedProductId) { + return { + rawName: item.rawName, + quantity: item.quantity, + unit: item.unit, + price: item.price, + selectedProductId: item.suggestedProductId, + selectedProductName: item.suggestedProductName ?? '', + checked: false, + saveAlias: false, + editQty: String(item.quantity), + editUnit: item.unit, + matchSource: 'suggestion', + }; + } + return { + rawName: item.rawName, + quantity: item.quantity, + unit: item.unit, + price: item.price, + selectedProductId: '', + selectedProductName: '', + checked: false, + saveAlias: false, + editQty: String(item.quantity), + editUnit: item.unit, + matchSource: 'none', + }; + }), + ); + } catch (err) { + setError(err instanceof Error ? err.message : 'Kunde inte tolka kvittot'); + } finally { + setParsing(false); + } + }; + + const updateRow = (i: number, patch: Partial) => { + setRows((prev) => prev.map((r, idx) => (idx === i ? { ...r, ...patch } : r))); + }; + + const handleProductSelect = (i: number, productId: string) => { + if (!productId) { + updateRow(i, { selectedProductId: '', selectedProductName: '', checked: false, matchSource: 'none' }); + return; + } + const prod = allProducts.find((p) => p.id === Number(productId)); + if (prod) { + updateRow(i, { + selectedProductId: prod.id, + selectedProductName: prod.canonicalName ?? prod.name, + checked: true, + matchSource: 'manual', + saveAlias: true, + }); + } + }; + + const handleSave = async () => { + const toSave = rows.filter((r) => r.checked && r.selectedProductId !== ''); + if (toSave.length === 0) return; + setSaving(true); + setError(null); + try { + await Promise.all([ + ...toSave.map((r) => + fetch('/api/inventory', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + productId: r.selectedProductId, + quantity: parseFloat(r.editQty) || r.quantity, + unit: r.editUnit, + brand: r.rawName, + }), + }), + ), + ...toSave + .filter((r) => r.saveAlias && r.selectedProductId !== '') + .map((r) => + fetch('/api/receipt-alias-proxy', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + receiptName: r.rawName, + productId: r.selectedProductId, + }), + }), + ), + ]); + setSavedCount(toSave.length); + setRows([]); + setPreview(null); + setSelectedFile(null); + if (fileRef.current) fileRef.current.value = ''; + } catch { + setError('Något gick fel vid sparning. Försök igen.'); + } finally { + setSaving(false); + } + }; + + const checkedCount = rows.filter((r) => r.checked && r.selectedProductId !== '').length; + + const sourceLabel = (src: RowState['matchSource']) => { + if (src === 'alias') return { text: 'Känd vara', color: '#27ae60' }; + if (src === 'suggestion') return { text: 'Förslag', color: '#e67e22' }; + if (src === 'manual') return { text: 'Manuellt vald', color: '#0070f3' }; + return null; + }; + + return ( +
+
fileRef.current?.click()} + > + + {preview === 'pdf' ? ( +
+
📄
+
{selectedFile?.name}
+
PDF-kvitto valt
+
+ ) : preview ? ( + // eslint-disable-next-line @next/next/no-img-element + Kvittoförhandsgranskning + ) : ( +
+
📷
+
Fotografera eller välj kvitto
+
Klicka för att välja bild (JPEG, PNG, WebP) eller PDF
+
+ )} +
+ + {preview && rows.length === 0 && ( + + )} + + {error && ( +

{error}

+ )} + + {savedCount !== null && ( +

+ ✓ {savedCount} {savedCount === 1 ? 'vara lades till' : 'varor lades till'} i inventariet. +

+ )} + + {rows.length > 0 && ( +
+
+

Identifierade varor ({rows.length})

+ Grön = känd koppling · Orange = förslag · Välj manuellt om inget förslag +
+ +
+ {rows.map((row, i) => { + const label = sourceLabel(row.matchSource); + return ( +
+
+ updateRow(i, { checked: e.target.checked })} style={{ width: '18px', height: '18px', cursor: row.selectedProductId !== '' ? 'pointer' : 'not-allowed' }} /> + {row.rawName} + {label && ( + {label.text} + )} +
+
+ + updateRow(i, { editQty: e.target.value })} style={{ padding: '0.35rem 0.5rem', border: '1px solid #ced4da', borderRadius: '6px', fontSize: '0.9rem' }} /> + +
+ {row.selectedProductId !== '' && row.matchSource !== 'alias' && ( + + )} +
+ ); + })} +
+ +
+ + +
+
+ )} +
+ ); +} + +function primaryBtn(disabled: boolean): React.CSSProperties { + return { padding: '0.6rem 1.25rem', background: disabled ? '#aaa' : '#0070f3', color: '#fff', border: 'none', borderRadius: '6px', cursor: disabled ? 'not-allowed' : 'pointer', fontWeight: 600, fontSize: '0.95rem' }; }