feat: hromadny import produktov zo ZIP-u s podkladmi (xlsx + fotky)

Tabulka z Databazy produktov sa precita bez mapovania stlpcov, fotky
<EAN>/web/*.png sa zmensia na 1000 px a nahraju. Cenovka sa hlada podla
Zlava graficka | Farba cenovky | Povod. Importovane produkty preberu
rozlozenie karty z prveho produktu v sekcii.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub_HostacnyandClaude Opus 5.5 committed 2026-10-21 13:57:16 +02:00
1 parent 48fd82a77d
commit 9b1f7b60cf
3 files changed
+303 -6

No files matched your search

+112 -5
View File
@@ -7,6 +7,8 @@ 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 { shrinkImage, uploadFile } from '@/lib/upload';
interface ImportModalProps {
isOpen: boolean;
@@ -50,6 +52,10 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
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);
if (!isOpen) return null;
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -59,6 +65,19 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
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');
@@ -135,7 +154,39 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
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);
onClose();
};
const applyImport = () => {
if (zip) {
applyZipImport();
return;
}
if (!mapping.title) {
alert('Prosím vyberte stĺpec pre Názov produktu.');
return;
@@ -211,7 +262,7 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
<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"
accept=".csv,.json,.xml,.zip"
onChange={handleFileUpload}
className="hidden"
id="file-upload-input"
@@ -219,12 +270,68 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
<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 alebo pretiahnite CSV, JSON alebo XML'}
{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>
<p className="text-xs text-gray-400 mt-1">Podporované formáty: .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">
{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 font-bold text-gray-900 truncate">{p.title}</p>
<p className="text-[11px] text-gray-500 truncate">
EAN {p.ean}
{p.unitPrice && ` · ${p.unitPrice}`}
{p.deposit && ` · záloha ${p.deposit} €`}
{stores && ` · ${stores}`}
</p>
<p className="text-[11px] truncate">
{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>
<p className="text-[11px] text-gray-500">
Fotky sa pri importe zmenšia na najviac 1000 px. 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">
@@ -363,11 +470,11 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
</button>
<button
onClick={applyImport}
disabled={parsedRows.length === 0}
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" />
Importovať do kampane
{zipProgress || (zip ? `Importovať ${zip.products.length} produktov` : 'Importovať do kampane')}
</button>
</div>
</div>