Files
Coop-NL/src/components/editor/ProductImportModal.tsx
T
Jakub_HostacnyandClaude Opus 5.5 14358b228f feat: import podkladov - jednotkova cena v tvare 'jednotkova cena 8,56 EUR/l', gramaz oddelena z nazvu
Mnozstvo sa odoberie z konca nazvu v letaku (aj 'od 225 g do 250 g',
'3 x 50 g 1 bal.'); co je pred mnozstvom zo stlpca Mnozstvo, ostava v nazve.
Overene na 432 riadkoch podkladov.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:26:25 +02:00

507 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import React, { useState } from 'react';
import Papa from 'papaparse';
import { XMLParser } from 'fast-xml-parser';
import { BadgeTemplate, ProductItem } from '@/types/newsletter';
import { findBadgeByCode, badgeCode } from '@/lib/badgeUtils';
import { Upload, Check, AlertCircle, FileText, X } from 'lucide-react';
import { supabase } from '@/lib/supabase';
import { PodkladyProduct, podkladyToProductItem, readPodkladyZip, splitPrice } from '@/lib/podkladyZip';
import { CARD_BG_BY_BADGE_COLOR } from '@/lib/productCard';
import { shrinkImage, uploadFile } from '@/lib/upload';
interface ImportModalProps {
isOpen: boolean;
onClose: () => void;
onImportProducts: (products: ProductItem[], options?: { replace?: boolean }) => void;
availableBadges?: BadgeTemplate[]; // šablóny cenoviek – stĺpec s kódom určí, ktorú použiť
}
export const ProductImportModal: React.FC<ImportModalProps> = ({
isOpen,
onClose,
onImportProducts,
availableBadges = []
}) => {
const [file, setFile] = useState<File | null>(null);
const [fileType, setFileType] = useState<'csv' | 'json' | 'xml'>('csv');
const [parsedRows, setParsedRows] = useState<any[]>([]);
const [columns, setColumns] = useState<string[]>([]);
// Mapovanie stĺpcov
const [mapping, setMapping] = useState<{
title: string;
priceMain: string;
priceSub: string;
unitPrice: string;
discountText: string;
imageUrl: string;
validity: string;
badgeCode: string;
}>({
title: '',
priceMain: '',
priceSub: '',
unitPrice: '',
discountText: '',
imageUrl: '',
validity: '',
badgeCode: ''
});
const [presetName, setPresetName] = useState('');
const [savedPresets, setSavedPresets] = useState<any[]>([]);
// ZIP s podkladmi z Databázy produktov (tabuľka + fotky) – pevný formát, bez mapovania stĺpcov
const [zip, setZip] = useState<{ products: (PodkladyProduct & { thumb: string })[]; info: string } | null>(null);
const [zipProgress, setZipProgress] = useState<string | null>(null);
const [replaceExisting, setReplaceExisting] = useState(false); // vymazať doterajšie produkty sekcie
if (!isOpen) return null;
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const uploaded = e.target.files?.[0];
if (!uploaded) return;
setFile(uploaded);
const ext = uploaded.name.split('.').pop()?.toLowerCase();
const reader = new FileReader();
setZip(null);
if (ext === 'zip') {
setColumns([]);
setParsedRows([]);
readPodkladyZip(uploaded, availableBadges)
.then(({ products, info }) => {
if (!products.length) throw new Error('V tabuľke sa nenašiel žiadny produkt.');
setZip({ products: products.map((p) => ({ ...p, thumb: p.photo ? URL.createObjectURL(p.photo) : '' })), info });
})
.catch((err: Error) => alert(`ZIP sa nepodarilo načítať: ${err.message}`));
return;
}
if (ext === 'csv') {
setFileType('csv');
reader.onload = (event) => {
const text = event.target?.result as string;
Papa.parse(text, {
header: true,
skipEmptyLines: true,
complete: (results) => {
if (results.data.length > 0) {
setParsedRows(results.data);
const headers = Object.keys(results.data[0] as object);
setColumns(headers);
autoGuessMapping(headers);
}
}
});
};
reader.readAsText(uploaded);
} else if (ext === 'json') {
setFileType('json');
reader.onload = (event) => {
try {
const json = JSON.parse(event.target?.result as string);
const arrayData = Array.isArray(json) ? json : json.products || [json];
setParsedRows(arrayData);
if (arrayData.length > 0) {
const headers = Object.keys(arrayData[0]);
setColumns(headers);
autoGuessMapping(headers);
}
} catch (err) {
alert('Chyba pri parsovaní JSON súboru.');
}
};
reader.readAsText(uploaded);
} else if (ext === 'xml') {
setFileType('xml');
reader.onload = (event) => {
try {
const parser = new XMLParser({ ignoreAttributes: false });
const jsonObj = parser.parse(event.target?.result as string);
// Hľadáme pole produktov
const possibleArray =
jsonObj.products?.product || jsonObj.items?.item || Object.values(jsonObj)[0];
const arrayData = Array.isArray(possibleArray) ? possibleArray : [possibleArray];
setParsedRows(arrayData);
if (arrayData.length > 0) {
const headers = Object.keys(arrayData[0]);
setColumns(headers);
autoGuessMapping(headers);
}
} catch (err) {
alert('Chyba pri parsovaní XML súboru.');
}
};
reader.readAsText(uploaded);
}
};
const autoGuessMapping = (headers: string[]) => {
const newMapping = { ...mapping };
headers.forEach((h) => {
const lower = h.toLowerCase();
if (lower.includes('nazov') || lower.includes('title') || lower.includes('name')) newMapping.title = h;
if (lower.includes('hlavna') || lower.includes('euro') || lower.includes('price_main')) newMapping.priceMain = h;
if (lower.includes('cent') || lower.includes('sub') || lower.includes('price_sub')) newMapping.priceSub = h;
if (lower.includes('merna') || lower.includes('unit') || lower.includes('kg')) newMapping.unitPrice = h;
if (lower.includes('zlava') || lower.includes('discount')) newMapping.discountText = h;
if (lower.includes('obrazok') || lower.includes('image') || lower.includes('foto')) newMapping.imageUrl = h;
if (lower.includes('platnost') || lower.includes('valid')) newMapping.validity = h;
if (lower.includes('cenovk') || lower.includes('badge') || lower.includes('sablon') || lower.includes('template')) newMapping.badgeCode = h;
});
setMapping(newMapping);
};
// Import zo ZIP-u: fotky sa zmenšia (max 1000 px) a nahrajú, potom sa produkty pridajú do sekcie
const applyZipImport = async () => {
if (!zip) return;
const items: ProductItem[] = [];
try {
for (let i = 0; i < zip.products.length; i++) {
const p = zip.products[i];
setZipProgress(`Nahrávam fotky ${i + 1}/${zip.products.length}…`);
let imageUrl = '';
if (p.photo) {
const small = await shrinkImage(new File([p.photo], p.photoName || `${p.ean}.png`, { type: p.photo.type || 'image/png' }));
imageUrl = await uploadFile(small, small.name);
}
items.push(podkladyToProductItem(p, imageUrl, i));
}
} catch (err) {
alert(`Import sa nepodaril: ${(err as Error).message}`);
setZipProgress(null);
return;
}
setZipProgress(null);
zip.products.forEach((p) => p.thumb && URL.revokeObjectURL(p.thumb));
setZip(null);
setFile(null);
onImportProducts(items, { replace: replaceExisting });
onClose();
};
const applyImport = () => {
if (zip) {
applyZipImport();
return;
}
if (!mapping.title) {
alert('Prosím vyberte stĺpec pre Názov produktu.');
return;
}
const importedProducts: ProductItem[] = parsedRows.map((row, idx) => {
let pMain = String(row[mapping.priceMain] || '0').trim();
let pSub = String(row[mapping.priceSub] || '00').trim();
// Ak je cena v jednom stĺpci napr. "1.99" alebo "1,99"
if (!mapping.priceSub && mapping.priceMain) {
const fullPrice = String(row[mapping.priceMain] || '0').replace(',', '.');
const parts = fullPrice.split('.');
pMain = parts[0] || '0';
pSub = parts[1] || '00';
if (pSub.length === 1) pSub += '0';
}
// Šablóna cenovky podľa kódu (alebo názvu) zo súboru
const badge = mapping.badgeCode ? findBadgeByCode(availableBadges, row[mapping.badgeCode]) : undefined;
return {
...(badge ? { badgeCustomTemplate: badge, badgeId: badge.id, badgeWidth: badge.width ?? 110 } : {}),
id: `imported-${Date.now()}-${idx}`,
title: String(row[mapping.title] || 'Nový produkt'),
imageUrl: row[mapping.imageUrl] || 'https://dqexxfspkylqttrtxsww.supabase.co/storage/v1/object/public/newsletter-assets/produkt_1.png',
priceMain: pMain,
priceSub: pSub,
unitPrice: row[mapping.unitPrice] || '',
discountText: row[mapping.discountText] ? `${row[mapping.discountText]}%` : '',
badgeText: 'zľava',
cardBgColor: '#feeaea',
stores: { potraviny: true, supermarket: true, tempo: true },
validityOverride: row[mapping.validity] || undefined
};
});
// Kódy cenoviek, ku ktorým sa nenašla šablóna
if (mapping.badgeCode) {
const unknown = [
...new Set(
parsedRows
.map((row) => String(row[mapping.badgeCode] || '').trim())
.filter((v) => v && !findBadgeByCode(availableBadges, v))
)
];
if (unknown.length) {
alert(`Pre tieto kódy cenoviek sa nenašla šablóna (produkty dostanú predvolenú cenovku):\n${unknown.join(', ')}`);
}
}
onImportProducts(importedProducts);
onClose();
};
return (
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-xs flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-2xl max-w-3xl w-full max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
<div className="flex items-center gap-2">
<Upload className="w-5 h-5 text-red-600" />
<h2 className="text-lg font-bold text-gray-900">Hromadný import produktov</h2>
</div>
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded-lg">
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-6 overflow-y-auto space-y-6">
{/* File Upload Box */}
<div className="border-2 border-dashed border-gray-300 hover:border-red-500 rounded-xl p-6 text-center cursor-pointer transition-colors bg-gray-50/50">
<input
type="file"
accept=".csv,.json,.xml,.zip"
onChange={handleFileUpload}
className="hidden"
id="file-upload-input"
/>
<label htmlFor="file-upload-input" className="cursor-pointer">
<FileText className="w-10 h-10 text-gray-400 mx-auto mb-2" />
<p className="font-semibold text-gray-700">
{file ? file.name : 'Kliknite sem a vyberte ZIP s podkladmi, CSV, JSON alebo XML'}
</p>
<p className="text-xs text-gray-400 mt-1">
Podporované formáty: .zip (podklady z Databázy produktov – tabuľka aj fotky), .csv, .json, .xml
</p>
</label>
</div>
{/* ZIP s podkladmi: náhľad produktov */}
{zip && (
<div className="space-y-2">
<div className="flex items-baseline justify-between gap-3">
<h3 className="text-sm font-bold text-gray-800 uppercase tracking-wider">Produkty v ZIP-e ({zip.products.length})</h3>
{zip.info && <span className="text-[11px] text-gray-500 truncate">{zip.info}</span>}
</div>
<div className="border border-gray-200 rounded-xl divide-y divide-gray-100 overflow-hidden">
{zip.products.map((p) => {
const price = p.price != null ? splitPrice(p.price) : null;
const stores = [p.stores.potraviny && 'P', p.stores.supermarket && 'S', p.stores.tempo && 'T'].filter(Boolean).join(' ');
return (
<div key={p.ean} className="flex items-center gap-3 px-3 py-2 bg-white">
<span
className="w-12 h-12 rounded-lg bg-gray-50 border border-gray-200 overflow-hidden flex items-center justify-center shrink-0"
style={p.color ? { backgroundColor: CARD_BG_BY_BADGE_COLOR[p.color] } : undefined}
title={p.color ? 'Pozadie karty podľa farby cenovky' : undefined}
>
{p.thumb ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={p.thumb} alt="" className="w-full h-full object-contain" />
) : (
<span className="text-[9px] text-red-600 font-bold text-center leading-tight">bez fotky</span>
)}
</span>
<div className="flex-1 min-w-0">
<p className="text-xs text-gray-900 truncate">
<span className="font-bold">{p.title}</span>
{p.quantity && <span className="ml-1 rounded bg-gray-100 px-1 text-gray-600">{p.quantity}</span>}
</p>
<p className="text-[11px] text-gray-500 truncate">
EAN {p.ean}
{p.unitPrice && ` · ${p.unitPrice}`}
{p.deposit && ` · záloha ${p.deposit} €`}
{p.appPrice != null && ` · v appke ${p.appPrice.toFixed(2).replace('.', ',')} €`}
{stores && ` · ${stores}`}
</p>
{p.extras.length > 0 && (
<p className="text-[11px] text-gray-500 truncate">
štítok na fotke: <span className="font-bold text-red-700">{p.extras.join(' · ')}</span>
</p>
)}
<p className="text-[11px] truncate">
{p.badge && p.badgeMissing.length ? (
<span className="text-amber-700">
cenovka: {p.badge.name} – chýba {p.badgeMissing.join(' a ')} pre „{p.badgeLabel}“
</span>
) : p.badge ? (
<span className="text-emerald-700">cenovka: {p.badge.name}</span>
) : (
<span className="text-amber-700">
{p.badgeLabel ? `cenovka „${p.badgeLabel}“ nie je medzi šablónami – použije sa predvolená` : 'bez cenovky – použije sa predvolená'}
</span>
)}
</p>
</div>
<div className="text-right shrink-0">
<p className="text-sm font-black text-red-600">{price ? `${price.main},${price.sub} €` : '—'}</p>
{p.discount && <p className="text-[11px] font-bold text-gray-600">-{p.discount}</p>}
</div>
</div>
);
})}
</div>
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-xs font-semibold text-gray-700 cursor-pointer">
<input type="checkbox" checked={replaceExisting} onChange={(e) => setReplaceExisting(e.target.checked)} className="w-3.5 h-3.5 accent-red-600" />
Vymazať doterajšie produkty v sekcii (nahradiť novými zo ZIP-u)
</label>
<p className="text-[11px] text-gray-500">
Fotky sa pri importe zmenšia na najviac 1000 px, pozadie karty sa nastaví podľa farby cenovky. Rozloženie karty (veľkosť a poloha
cenovky, štítky predajní) sa prevezme z prvého produktu v sekcii.
</p>
</div>
)}
{/* Mapping Table */}
{columns.length > 0 && (
<div className="space-y-4">
<h3 className="text-sm font-bold text-gray-800 uppercase tracking-wider">
Vizuálny Data Mapper (Párovanie stĺpcov)
</h3>
<div className="grid grid-cols-2 gap-4 bg-gray-50 p-4 rounded-xl border border-gray-200">
<div>
<label className="block text-xs font-semibold text-gray-600 mb-1">Názov produktu *</label>
<select
value={mapping.title}
onChange={(e) => setMapping({ ...mapping, title: e.target.value })}
className="w-full text-sm border border-gray-300 rounded-lg p-2 bg-white"
>
<option value="">-- Vyberte stĺpec --</option>
{columns.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-gray-600 mb-1">Hlavná cena (Eurá)</label>
<select
value={mapping.priceMain}
onChange={(e) => setMapping({ ...mapping, priceMain: e.target.value })}
className="w-full text-sm border border-gray-300 rounded-lg p-2 bg-white"
>
<option value="">-- Vyberte stĺpec --</option>
{columns.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-gray-600 mb-1">Centy (podružná cena)</label>
<select
value={mapping.priceSub}
onChange={(e) => setMapping({ ...mapping, priceSub: e.target.value })}
className="w-full text-sm border border-gray-300 rounded-lg p-2 bg-white"
>
<option value="">-- Vyberte stĺpec (voliteľné) --</option>
{columns.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-gray-600 mb-1">Merná cena (napr. 1kg = 4,91 €)</label>
<select
value={mapping.unitPrice}
onChange={(e) => setMapping({ ...mapping, unitPrice: e.target.value })}
className="w-full text-sm border border-gray-300 rounded-lg p-2 bg-white"
>
<option value="">-- Vyberte stĺpec --</option>
{columns.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-gray-600 mb-1">Zľava %</label>
<select
value={mapping.discountText}
onChange={(e) => setMapping({ ...mapping, discountText: e.target.value })}
className="w-full text-sm border border-gray-300 rounded-lg p-2 bg-white"
>
<option value="">-- Vyberte stĺpec --</option>
{columns.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div className="col-span-2">
<label className="block text-xs font-semibold text-gray-600 mb-1">Cenovka (kód šablóny)</label>
<select
value={mapping.badgeCode}
onChange={(e) => setMapping({ ...mapping, badgeCode: e.target.value })}
className="w-full text-sm border border-gray-300 rounded-lg p-2 bg-white"
>
<option value="">-- Bez cenovky zo súboru (predvolená) --</option>
{columns.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
<p className="text-[11px] text-gray-500 mt-1">
Kódy šablón:{' '}
{availableBadges.length
? availableBadges.map(badgeCode).join(', ')
: 'zatiaľ žiadne šablóny'}
{' '}(kód vzniká z názvu šablóny; v súbore môže byť kód aj presný názov)
</p>
</div>
<div>
<label className="block text-xs font-semibold text-gray-600 mb-1">URL obrázka</label>
<select
value={mapping.imageUrl}
onChange={(e) => setMapping({ ...mapping, imageUrl: e.target.value })}
className="w-full text-sm border border-gray-300 rounded-lg p-2 bg-white"
>
<option value="">-- Vyberte stĺpec --</option>
{columns.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</div>
</div>
{/* Živý náhľad prvých 2 riadkov */}
{parsedRows.length > 0 && (
<div className="border border-gray-200 rounded-xl overflow-hidden">
<div className="bg-gray-100 px-4 py-2 text-xs font-bold text-gray-700">
Živý náhľad prvých položiek ({parsedRows.length} nájdených záznamov)
</div>
<div className="p-3 text-xs space-y-2 bg-white">
{parsedRows.slice(0, 2).map((row, idx) => (
<div key={idx} className="flex items-center justify-between border-b border-gray-100 pb-2">
<span className="font-semibold text-gray-800">
{row[mapping.title] || 'Bez názvu'}
{mapping.badgeCode && (
<span className="ml-2 text-[10px] font-medium text-gray-500">
cenovka: {findBadgeByCode(availableBadges, row[mapping.badgeCode])?.name || `? (${row[mapping.badgeCode] || 'prázdne'})`}
</span>
)}
</span>
<span className="text-red-600 font-bold">
{row[mapping.priceMain] || '0'},{row[mapping.priceSub] || '00'} €
</span>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-gray-100 bg-gray-50 flex items-center justify-between">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-semibold text-gray-600 hover:text-gray-900"
>
Zrušiť
</button>
<button
onClick={applyImport}
disabled={(parsedRows.length === 0 && !zip) || !!zipProgress}
className="px-5 py-2 text-sm font-bold bg-red-600 text-white rounded-xl hover:bg-red-700 disabled:opacity-50 transition-colors flex items-center gap-2"
>
<Check className="w-4 h-4" />
{zipProgress || (zip ? `Importovať ${zip.products.length} produktov` : 'Importovať do kampane')}
</button>
</div>
</div>
</div>
);
};