Files
Craze-Data-check/src/components/EditPanel.tsx
T

497 lines
24 KiB
TypeScript
Raw Normal View History

import React, { useState, useRef, useCallback } from 'react';
import { ExcelRow } from '../types';
import { useColumns } from '../contexts/ColumnsContext';
import { X, Sparkles, Save, Loader2, Languages, Package, CheckCircle2, Mic, MicOff } from 'lucide-react';
import { generateGemini } from '../services/gemini';
import { cn } from '../lib/utils';
import { useSpeechRecognition } from '../lib/useSpeechRecognition';
import { ConfirmModal } from './ConfirmModal';
interface EditPanelProps {
row: ExcelRow;
rowIndex: number;
onSave: (rowIndex: number, updatedRow: ExcelRow) => void;
onClose: () => void;
onCaptureState: (message: string) => void;
}
interface DimensionInputProps {
label: string;
field: string;
value: string;
isModified: boolean;
onChange: (val: string) => void;
placeholder?: string;
}
const DimensionInput = ({ label, value, isModified, onChange, placeholder }: DimensionInputProps) => (
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-medium text-slate-500 uppercase tracking-wider">{label}</label>
<input
type="text"
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
className={cn(
"w-full bg-slate-900 border rounded p-2 text-sm text-white focus:outline-none focus:ring-1 transition-colors",
isModified ? "border-blue-500 focus:ring-blue-500" : "border-slate-700 focus:border-slate-500"
)}
/>
</div>
);
interface FieldEditorProps {
title: string;
field: string;
value: string;
isModified: boolean;
canTranslate: boolean;
isGenerated: boolean;
isLoading: boolean;
onGenerate: () => void;
onChange: (val: string) => void;
onKeyDown: (e: React.KeyboardEvent) => void;
isListening?: boolean;
onToggleVoice?: () => void;
voiceSupported?: boolean;
}
const FieldEditor = ({
title,
field,
value,
isModified,
canTranslate,
isGenerated,
isLoading,
onGenerate,
onChange,
onKeyDown,
isListening,
onToggleVoice,
voiceSupported
}: FieldEditorProps) => (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-slate-300">{title}</label>
<div className="flex items-center gap-2">
{canTranslate && (
<div className="flex items-center gap-1 text-[10px] text-slate-500 italic">
<Languages className="w-3 h-3" />
Can translate from existing
</div>
)}
{voiceSupported && (
<button
onClick={onToggleVoice}
className={cn(
"flex items-center gap-1.5 text-xs font-medium px-2 py-1 rounded transition-colors",
isListening
? "bg-red-600/20 text-red-400 animate-pulse"
: "bg-slate-700/50 text-slate-400 hover:bg-slate-600 hover:text-white"
)}
title={isListening ? "Stop recording" : "Voice input"}
>
{isListening ? <MicOff className="w-3 h-3" /> : <Mic className="w-3 h-3" />}
{isListening ? 'Stop' : 'Voice'}
</button>
)}
<button
onClick={onGenerate}
disabled={isLoading}
className="flex items-center gap-1.5 text-xs font-medium bg-blue-600/20 text-blue-400 hover:bg-blue-600 hover:text-white px-2 py-1 rounded transition-colors disabled:opacity-50"
>
{isLoading ? <Loader2 className="w-3 h-3 animate-spin" /> : <Sparkles className="w-3 h-3" />}
{canTranslate ? 'Translate with Gemini' : 'Generate with Gemini'}
</button>
</div>
{isGenerated && (
<div className="flex items-center gap-1.5 text-[10px] text-emerald-400 bg-emerald-400/10 px-2 py-0.5 rounded w-fit animate-in fade-in slide-in-from-top-1 duration-300">
<CheckCircle2 className="w-3 h-3" />
AI Generated - You can still edit manually
</div>
)}
</div>
<textarea
id={`field-${field}`}
value={value}
onChange={e => onChange(e.target.value)}
onKeyDown={onKeyDown}
className={cn(
"w-full h-32 bg-slate-900 border rounded-md p-3 text-sm text-white focus:outline-none focus:ring-1 transition-colors resize-none",
isModified ? "border-blue-500 focus:ring-blue-500" : "border-slate-700 focus:border-slate-500 focus:ring-slate-500"
)}
placeholder={`Enter ${title}...`}
/>
</div>
);
export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: EditPanelProps) {
const COLUMNS = useColumns();
const [formData, setFormData] = useState({
longDe: row[COLUMNS.LONG_DE] || '',
longEn: row[COLUMNS.LONG_EN] || '',
shortDe: row[COLUMNS.SHORT_DE] || '',
shortEn: row[COLUMNS.SHORT_EN] || '',
detailsDe: row[COLUMNS.DETAILS_DE] || '',
detailsEn: row[COLUMNS.DETAILS_EN] || '',
innerW: row[COLUMNS.INNER_W] !== undefined && row[COLUMNS.INNER_W] !== null ? String(row[COLUMNS.INNER_W]) : '',
innerL: row[COLUMNS.INNER_L] !== undefined && row[COLUMNS.INNER_L] !== null ? String(row[COLUMNS.INNER_L]) : '',
innerH: row[COLUMNS.INNER_H] !== undefined && row[COLUMNS.INNER_H] !== null ? String(row[COLUMNS.INNER_H]) : '',
outerW: row[COLUMNS.OUTER_W] !== undefined && row[COLUMNS.OUTER_W] !== null ? String(row[COLUMNS.OUTER_W]) : '',
outerL: row[COLUMNS.OUTER_L] !== undefined && row[COLUMNS.OUTER_L] !== null ? String(row[COLUMNS.OUTER_L]) : '',
outerH: row[COLUMNS.OUTER_H] !== undefined && row[COLUMNS.OUTER_H] !== null ? String(row[COLUMNS.OUTER_H]) : '',
unitsOuter: row[COLUMNS.UNITS_OUTER] !== undefined && row[COLUMNS.UNITS_OUTER] !== null ? String(row[COLUMNS.UNITS_OUTER]) : '',
moq: row[COLUMNS.MOQ] !== undefined && row[COLUMNS.MOQ] !== null ? String(row[COLUMNS.MOQ]) : '',
});
const [loadingField, setLoadingField] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
const [pendingGeminiField, setPendingGeminiField] = useState<keyof typeof formData | null>(null);
const [generatedFields, setGeneratedFields] = useState<Set<string>>(new Set());
const handleSpeechResult = useCallback((transcript: string) => {
setFormData(prev => ({ ...prev, longDe: prev.longDe + ' ' + transcript }));
}, []);
const { isListening: isListeningDe, start: startListeningDe, stop: stopListeningDe, isSupported: speechSupported } = useSpeechRecognition({ onResult: handleSpeechResult });
const handleSpeechResultEn = useCallback((transcript: string) => {
setFormData(prev => ({ ...prev, longEn: prev.longEn + ' ' + transcript }));
}, []);
const { isListening: isListeningEn, start: startListeningEn, stop: stopListeningEn } = useSpeechRecognition({ onResult: handleSpeechResultEn, lang: 'en-US' });
const isModified = (field: keyof typeof formData) => {
const colMap: Record<string, number> = {
longDe: COLUMNS.LONG_DE,
longEn: COLUMNS.LONG_EN,
shortDe: COLUMNS.SHORT_DE,
shortEn: COLUMNS.SHORT_EN,
innerW: COLUMNS.INNER_W,
innerL: COLUMNS.INNER_L,
innerH: COLUMNS.INNER_H,
outerW: COLUMNS.OUTER_W,
outerL: COLUMNS.OUTER_L,
outerH: COLUMNS.OUTER_H,
unitsOuter: COLUMNS.UNITS_OUTER,
moq: COLUMNS.MOQ,
detailsDe: COLUMNS.DETAILS_DE,
detailsEn: COLUMNS.DETAILS_EN,
};
const colIndex = (colMap as Record<string, number>)[field as string];
if (colIndex === undefined) return false;
return formData[field] !== (row[colIndex] !== undefined && row[colIndex] !== null ? String(row[colIndex]) : '');
};
const handleGenerate = async (field: keyof typeof formData) => {
setPendingGeminiField(field);
};
const executeGenerate = async () => {
if (!pendingGeminiField) return;
const field = pendingGeminiField;
setPendingGeminiField(null);
setLoadingField(field);
setError(null);
try {
let prompt = '';
const baseContext = `Article Name: ${row[COLUMNS.ARTICLE_NAME]}\nArticle Details (EN): ${formData.detailsEn || 'N/A'}\nArticle Details (DE): ${formData.detailsDe || 'N/A'}`;
const systemPrompt = "You are a professional copywriter and expert translator for CRAZE GmbH, a German toy company. CRITICAL: Output ONLY the translated or generated content. Do not include any introductions, conclusions, or conversational text. Translate EVERYTHING, including phrases in ALL CAPS (maintain the all-caps casing for those phrases in the translation). Your response must be complete, with all sentences finished and logically concluded. Never end mid-sentence.";
if (field === 'longDe') {
if (formData.longEn) {
prompt = `Translate the EVERYTHING below from English into professional German.
CRITICAL INSTRUCTIONS:
1. Do NOT summarize.
2. Maintain the EXACT same length and detailed information.
3. Keep all technical specs intact.
4. Translate every single paragraph into natural, commercial German for toy buyers.
5. Translate ALL text, including titles or phrases in ALL CAPS, and keep them in ALL CAPS in the translation.
6. ENSURE the translation is complete and does not cut off. The final sentence must be fully finished.
English Text to Translate:
${formData.longEn}`;
} else {
prompt = `Based on the following product details, generate a long commercial description in German. It should be fluent, oriented towards B2B toy buyers, 3-5 paragraphs long. Include features, benefits, and material if available.
IMPORTANT: Ensure the description is complete and ends with a finished sentence.\n\n${baseContext}`;
}
} else if (field === 'longEn') {
if (formData.longDe) {
prompt = `Translate the EVERYTHING below from German into professional English.
CRITICAL INSTRUCTIONS:
1. Do NOT summarize.
2. Maintain the EXACT same length and detailed information.
3. Keep all technical specs intact.
4. Translate every single paragraph into natural, commercial English for toy buyers.
5. Translate ALL text, including titles or phrases in ALL CAPS, and keep them in ALL CAPS in the translation.
6. ENSURE the translation is complete and does not cut off. The final sentence must be fully finished.
German Text to Translate:
${formData.longDe}`;
} else {
prompt = `Based on the following product details, generate a long commercial description in English. It should be fluent, oriented towards B2B toy buyers, 3-5 paragraphs long.
IMPORTANT: Ensure the description is complete and ends with a finished sentence.\n\n${baseContext}`;
}
} else if (field === 'shortDe') {
if (formData.shortEn) {
prompt = `Translate exactly this short English product description into professional German for the toy market.
IMPORTANT:
1. Translate everything, including any ALL CAPS phrases, maintaining the all-caps formatting.
2. ENSURE the translation is complete and does not cut off mid-sentence.\n\n${formData.shortEn}`;
} else if (formData.longDe) {
prompt = `Create a concise summary of the following German product description.
CRITICAL: The summary MUST be between 250 and 450 characters long.
Preserve the most important commercial highlights and features in natural, professional German for B2B toy buyers.
Do not use bullet points — write flowing prose.
IMPORTANT: Ensure the summary is a complete thought and ends with a finished sentence.\n\nOriginal description:\n${formData.longDe}`;
} else {
prompt = `Based on the following product details, generate a short commercial description in German.
CRITICAL: Length MUST be between 250 and 450 characters.
Ensure the text is complete and ends with a finished sentence.\n\n${baseContext}`;
}
} else if (field === 'shortEn') {
if (formData.shortDe) {
prompt = `Translate exactly this short German product description into professional English for the toy market.
IMPORTANT:
1. Translate everything, including any ALL CAPS phrases, maintaining the all-caps formatting.
2. ENSURE the translation is complete and does not cut off mid-sentence.\n\n${formData.shortDe}`;
} else if (formData.longEn) {
prompt = `Create a concise summary of the following English product description.
CRITICAL: The summary MUST be between 250 and 450 characters long.
Preserve the most important commercial highlights and features in natural, professional English for B2B toy buyers.
Do not use bullet points — write flowing prose.
IMPORTANT: Ensure the summary is a complete thought and ends with a finished sentence.\n\nOriginal description:\n${formData.longEn}`;
} else {
prompt = `Based on the following product details, generate a short commercial description in English.
CRITICAL: Length MUST be between 250 and 450 characters.
Ensure the text is complete and ends with a finished sentence.\n\n${baseContext}`;
}
}
const generatedText = await generateGemini(prompt, systemPrompt);
setFormData(prev => ({ ...prev, [field]: generatedText.trim() }));
setGeneratedFields(prev => new Set(prev).add(field));
// Focus the textarea so user can edit immediately after AI generation
setTimeout(() => {
const textarea = document.getElementById(`field-${field}`);
if (textarea) textarea.focus();
}, 100);
} catch (err: any) {
setError(err.message || 'An error occurred during generation.');
} finally {
setLoadingField(null);
}
};
const handleSave = () => {
setIsConfirmOpen(false);
const hasModifications = Object.keys(formData).some(k => isModified(k as keyof typeof formData));
if (hasModifications) {
onCaptureState(`Updated product ${row[COLUMNS.ARTICLE_NO]}`);
}
const newRow = [...row];
newRow[COLUMNS.LONG_DE] = formData.longDe;
newRow[COLUMNS.LONG_EN] = formData.longEn;
newRow[COLUMNS.SHORT_DE] = formData.shortDe;
newRow[COLUMNS.SHORT_EN] = formData.shortEn;
newRow[COLUMNS.INNER_W] = formData.innerW;
newRow[COLUMNS.INNER_L] = formData.innerL;
newRow[COLUMNS.INNER_H] = formData.innerH;
newRow[COLUMNS.OUTER_W] = formData.outerW;
newRow[COLUMNS.OUTER_L] = formData.outerL;
newRow[COLUMNS.OUTER_H] = formData.outerH;
newRow[COLUMNS.UNITS_OUTER] = formData.unitsOuter;
newRow[COLUMNS.MOQ] = formData.moq;
newRow[COLUMNS.DETAILS_DE] = formData.detailsDe;
newRow[COLUMNS.DETAILS_EN] = formData.detailsEn;
onSave(rowIndex, newRow);
};
const handleInputKeyDown = (e: React.KeyboardEvent) => {
e.stopPropagation();
};
return (
<>
<div className="fixed inset-0 bg-slate-950/50 backdrop-blur-sm z-40" onClick={onClose} />
<div className="fixed right-0 top-0 bottom-0 w-[600px] bg-slate-800 border-l border-slate-700 shadow-2xl z-50 flex flex-col animate-in slide-in-from-right duration-200">
<div className="flex items-center justify-between p-6 border-b border-slate-700 bg-slate-800/50">
<div>
<h2 className="text-xl font-bold text-white">Edit Product</h2>
<p className="text-sm text-slate-400 mt-1">{row[COLUMNS.ARTICLE_NO]} - {row[COLUMNS.ARTICLE_NAME]}</p>
</div>
<button onClick={onClose} className="p-2 text-slate-400 hover:text-white hover:bg-slate-700 rounded-full transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-auto p-6 space-y-6">
{error && (
<div className="bg-red-500/10 border border-red-500/50 text-red-400 p-4 rounded-md text-sm">
{error}
</div>
)}
<div className="grid grid-cols-2 gap-4 bg-slate-900/50 p-4 rounded-lg border border-slate-700/50">
<div>
<span className="block text-xs text-slate-500 mb-1">Line</span>
<span className="text-sm text-slate-300 font-medium">{row[COLUMNS.LINE] || '-'}</span>
</div>
<div>
<span className="block text-xs text-slate-500 mb-1">License</span>
<span className="text-sm text-slate-300 font-medium">{row[COLUMNS.LICENSE] || '-'}</span>
</div>
<div className="col-span-2">
<span className="block text-xs text-slate-500 mb-1">Details (DE)</span>
<textarea
value={formData.detailsDe}
onChange={e => setFormData(prev => ({ ...prev, detailsDe: e.target.value }))}
className={cn(
"w-full h-20 bg-slate-900 border rounded p-2 text-sm text-white focus:outline-none focus:ring-1 transition-colors resize-none",
isModified('detailsDe') ? "border-blue-500 focus:ring-blue-500" : "border-slate-700/50"
)}
placeholder="Enter details in German..."
/>
</div>
<div className="col-span-2">
<span className="block text-xs text-slate-500 mb-1">Details (EN)</span>
<textarea
value={formData.detailsEn}
onChange={e => setFormData(prev => ({ ...prev, detailsEn: e.target.value }))}
className={cn(
"w-full h-20 bg-slate-900 border rounded p-2 text-sm text-white focus:outline-none focus:ring-1 transition-colors resize-none",
isModified('detailsEn') ? "border-blue-500 focus:ring-blue-500" : "border-slate-700/50"
)}
placeholder="Enter details in English..."
/>
</div>
</div>
<div className="space-y-4 bg-slate-900/30 p-4 rounded-lg border border-slate-700/50">
<h3 className="text-xs font-semibold text-slate-400 uppercase tracking-widest flex items-center gap-2">
<Package className="w-3 h-3" /> Dimensions & Packaging
</h3>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-3 text-[10px] text-slate-500 font-medium">INNER BOX (L × W × H) cm</div>
<DimensionInput label="Length" value={formData.innerL} field="innerL" isModified={isModified('innerL')} onChange={(val) => setFormData(p => ({...p, innerL: val}))} placeholder="L" />
<DimensionInput label="Width" value={formData.innerW} field="innerW" isModified={isModified('innerW')} onChange={(val) => setFormData(p => ({...p, innerW: val}))} placeholder="W" />
<DimensionInput label="Height" value={formData.innerH} field="innerH" isModified={isModified('innerH')} onChange={(val) => setFormData(p => ({...p, innerH: val}))} placeholder="H" />
</div>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-3 text-[10px] text-slate-500 font-medium">OUTER BOX (L × W × H) cm</div>
<DimensionInput label="Length" value={formData.outerL} field="outerL" isModified={isModified('outerL')} onChange={(val) => setFormData(p => ({...p, outerL: val}))} placeholder="L" />
<DimensionInput label="Width" value={formData.outerW} field="outerW" isModified={isModified('outerW')} onChange={(val) => setFormData(p => ({...p, outerW: val}))} placeholder="W" />
<DimensionInput label="Height" value={formData.outerH} field="outerH" isModified={isModified('outerH')} onChange={(val) => setFormData(p => ({...p, outerH: val}))} placeholder="H" />
</div>
<div className="grid grid-cols-2 gap-3">
<DimensionInput label="Units per Outer" value={formData.unitsOuter} field="unitsOuter" isModified={isModified('unitsOuter')} onChange={(val) => setFormData(p => ({...p, unitsOuter: val}))} />
<DimensionInput label="MOQ" value={formData.moq} field="moq" isModified={isModified('moq')} onChange={(val) => setFormData(p => ({...p, moq: val}))} />
</div>
</div>
<FieldEditor
title="Long Description (DE)"
field="longDe"
value={formData.longDe}
isModified={isModified('longDe')}
canTranslate={!!formData.longEn}
isGenerated={generatedFields.has('longDe')}
isLoading={loadingField === 'longDe'}
onGenerate={() => handleGenerate('longDe')}
onChange={(val) => setFormData(p => ({...p, longDe: val}))}
onKeyDown={handleInputKeyDown}
isListening={isListeningDe}
onToggleVoice={isListeningDe ? stopListeningDe : startListeningDe}
voiceSupported={speechSupported}
/>
<FieldEditor
title="Long Description (EN)"
field="longEn"
value={formData.longEn}
isModified={isModified('longEn')}
canTranslate={!!formData.longDe}
isGenerated={generatedFields.has('longEn')}
isLoading={loadingField === 'longEn'}
onGenerate={() => handleGenerate('longEn')}
onChange={(val) => setFormData(p => ({...p, longEn: val}))}
onKeyDown={handleInputKeyDown}
isListening={isListeningEn}
onToggleVoice={isListeningEn ? stopListeningEn : startListeningEn}
voiceSupported={speechSupported}
/>
<FieldEditor
title="Short Description (DE)"
field="shortDe"
value={formData.shortDe}
isModified={isModified('shortDe')}
canTranslate={!!formData.shortEn}
isGenerated={generatedFields.has('shortDe')}
isLoading={loadingField === 'shortDe'}
onGenerate={() => handleGenerate('shortDe')}
onChange={(val) => setFormData(p => ({...p, shortDe: val}))}
onKeyDown={handleInputKeyDown}
/>
<FieldEditor
title="Short Description (EN)"
field="shortEn"
value={formData.shortEn}
isModified={isModified('shortEn')}
canTranslate={!!formData.shortDe}
isGenerated={generatedFields.has('shortEn')}
isLoading={loadingField === 'shortEn'}
onGenerate={() => handleGenerate('shortEn')}
onChange={(val) => setFormData(p => ({...p, shortEn: val}))}
onKeyDown={handleInputKeyDown}
/>
</div>
<div className="p-6 border-t border-slate-700 bg-slate-800/50 flex justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-slate-300 hover:text-white hover:bg-slate-700 rounded-md transition-colors"
>
Cancel
</button>
<button
onClick={() => setIsConfirmOpen(true)}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md shadow-lg shadow-blue-900/20 transition-colors"
>
<Save className="w-4 h-4" />
Queue Changes
</button>
</div>
<ConfirmModal
isOpen={isConfirmOpen}
onConfirm={handleSave}
onCancel={() => setIsConfirmOpen(false)}
title="Confirm Changes"
message={`Are you sure you want to save the modifications for product ${row[COLUMNS.ARTICLE_NO]}?`}
type="info"
confirmText="Save changes"
/>
<ConfirmModal
isOpen={!!pendingGeminiField}
onConfirm={executeGenerate}
onCancel={() => setPendingGeminiField(null)}
title="Confirm AI Action"
message={`Are you sure you want to use Gemini to ${
((pendingGeminiField === 'shortEn' && formData.shortDe) ||
(pendingGeminiField === 'shortDe' && formData.shortEn) ||
(pendingGeminiField === 'longEn' && formData.longDe) ||
(pendingGeminiField === 'longDe' && formData.longEn))
? 'translate' : 'generate'} the ${pendingGeminiField} field?`}
type="info"
confirmText="Start Generation"
/>
</div>
</>
);
}