- Fix FilamentForm to preserve prices when updating quantities - Add isInitialLoad flag to prevent price override on form load - Only update prices when color is actively changed, not on initial render - Add bulk filament price editor to dashboard - Filter by material and finish - Set refill and/or spool prices for multiple filaments - Preview changes before applying - Update all filtered filaments at once - Add bulk color price editor to colors page - Edit prices for all colors in one interface - Global price application with search filtering - Individual price editing with change tracking - Add auto-kill script for dev server - Kill existing processes on ports 3000/3001 before starting - Prevent "port already in use" errors - Clean start every time npm run dev is executed
289 lines
11 KiB
TypeScript
289 lines
11 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { colorService } from '@/src/services/api';
|
|
|
|
interface Color {
|
|
id: string;
|
|
name: string;
|
|
hex: string;
|
|
cena_refill?: number;
|
|
cena_spulna?: number;
|
|
}
|
|
|
|
interface BulkPriceEditorProps {
|
|
colors: Color[];
|
|
onUpdate: () => void;
|
|
}
|
|
|
|
export function BulkPriceEditor({ colors, onUpdate }: BulkPriceEditorProps) {
|
|
const [showModal, setShowModal] = useState(false);
|
|
const [loading, setLoading] = useState(false);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [priceChanges, setPriceChanges] = useState<Record<string, { cena_refill?: number; cena_spulna?: number }>>({});
|
|
const [globalRefillPrice, setGlobalRefillPrice] = useState<string>('');
|
|
const [globalSpulnaPrice, setGlobalSpulnaPrice] = useState<string>('');
|
|
|
|
// Filter colors based on search
|
|
const filteredColors = colors.filter(color =>
|
|
color.name.toLowerCase().includes(searchTerm.toLowerCase())
|
|
);
|
|
|
|
// Apply global price to all filtered colors
|
|
const applyGlobalRefillPrice = () => {
|
|
if (!globalRefillPrice) return;
|
|
const price = parseInt(globalRefillPrice);
|
|
if (isNaN(price) || price < 0) return;
|
|
|
|
const updates: Record<string, { cena_refill?: number; cena_spulna?: number }> = { ...priceChanges };
|
|
filteredColors.forEach(color => {
|
|
updates[color.id] = {
|
|
...updates[color.id],
|
|
cena_refill: price
|
|
};
|
|
});
|
|
setPriceChanges(updates);
|
|
};
|
|
|
|
const applyGlobalSpulnaPrice = () => {
|
|
if (!globalSpulnaPrice) return;
|
|
const price = parseInt(globalSpulnaPrice);
|
|
if (isNaN(price) || price < 0) return;
|
|
|
|
const updates: Record<string, { cena_refill?: number; cena_spulna?: number }> = { ...priceChanges };
|
|
filteredColors.forEach(color => {
|
|
updates[color.id] = {
|
|
...updates[color.id],
|
|
cena_spulna: price
|
|
};
|
|
});
|
|
setPriceChanges(updates);
|
|
};
|
|
|
|
// Update individual color price
|
|
const updatePrice = (colorId: string, field: 'cena_refill' | 'cena_spulna', value: string) => {
|
|
const price = parseInt(value);
|
|
if (value === '' || (price >= 0 && !isNaN(price))) {
|
|
setPriceChanges(prev => ({
|
|
...prev,
|
|
[colorId]: {
|
|
...prev[colorId],
|
|
[field]: value === '' ? undefined : price
|
|
}
|
|
}));
|
|
}
|
|
};
|
|
|
|
// Get effective price (with changes or original)
|
|
const getEffectivePrice = (color: Color, field: 'cena_refill' | 'cena_spulna'): number => {
|
|
return priceChanges[color.id]?.[field] ?? color[field] ?? (field === 'cena_refill' ? 3499 : 3999);
|
|
};
|
|
|
|
// Save all changes
|
|
const handleSave = async () => {
|
|
const colorUpdates = Object.entries(priceChanges).filter(([_, changes]) =>
|
|
changes.cena_refill !== undefined || changes.cena_spulna !== undefined
|
|
);
|
|
|
|
if (colorUpdates.length === 0) {
|
|
alert('Nema promena za čuvanje');
|
|
return;
|
|
}
|
|
|
|
if (!confirm(`Želite da sačuvate promene za ${colorUpdates.length} boja?`)) {
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
try {
|
|
// Update each color individually
|
|
await Promise.all(
|
|
colorUpdates.map(([colorId, changes]) => {
|
|
const color = colors.find(c => c.id === colorId);
|
|
if (!color) return Promise.resolve();
|
|
|
|
return colorService.update(colorId, {
|
|
name: color.name,
|
|
hex: color.hex,
|
|
cena_refill: changes.cena_refill ?? color.cena_refill,
|
|
cena_spulna: changes.cena_spulna ?? color.cena_spulna
|
|
});
|
|
})
|
|
);
|
|
|
|
alert(`Uspešno ažurirano ${colorUpdates.length} boja!`);
|
|
setPriceChanges({});
|
|
setGlobalRefillPrice('');
|
|
setGlobalSpulnaPrice('');
|
|
setSearchTerm('');
|
|
onUpdate();
|
|
setShowModal(false);
|
|
} catch (error) {
|
|
console.error('Error updating prices:', error);
|
|
alert('Greška pri ažuriranju cena');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
onClick={() => setShowModal(true)}
|
|
className="px-4 py-2 bg-indigo-500 text-white rounded hover:bg-indigo-600"
|
|
>
|
|
Masovno editovanje cena
|
|
</button>
|
|
|
|
{showModal && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
|
Masovno editovanje cena
|
|
</h2>
|
|
<button
|
|
onClick={() => setShowModal(false)}
|
|
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
|
|
{/* Global price controls */}
|
|
<div className="mb-4 p-4 bg-gray-100 dark:bg-gray-700 rounded">
|
|
<h3 className="font-semibold mb-2 text-gray-900 dark:text-white">Primeni na sve prikazane boje:</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="number"
|
|
value={globalRefillPrice}
|
|
onChange={(e) => setGlobalRefillPrice(e.target.value)}
|
|
placeholder="Refill cena"
|
|
className="flex-1 px-3 py-2 border rounded dark:bg-gray-600 dark:border-gray-500 dark:text-white"
|
|
/>
|
|
<button
|
|
onClick={applyGlobalRefillPrice}
|
|
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
|
>
|
|
Primeni refill
|
|
</button>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="number"
|
|
value={globalSpulnaPrice}
|
|
onChange={(e) => setGlobalSpulnaPrice(e.target.value)}
|
|
placeholder="Spulna cena"
|
|
className="flex-1 px-3 py-2 border rounded dark:bg-gray-600 dark:border-gray-500 dark:text-white"
|
|
/>
|
|
<button
|
|
onClick={applyGlobalSpulnaPrice}
|
|
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
|
>
|
|
Primeni spulna
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Search */}
|
|
<div className="mb-4">
|
|
<input
|
|
type="text"
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
placeholder="Pretraži boje..."
|
|
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
|
/>
|
|
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
|
Prikazano: {filteredColors.length} od {colors.length} boja
|
|
{Object.keys(priceChanges).length > 0 && ` · ${Object.keys(priceChanges).length} promena`}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Color list */}
|
|
<div className="flex-1 overflow-y-auto mb-4">
|
|
<table className="w-full">
|
|
<thead className="bg-gray-100 dark:bg-gray-700 sticky top-0">
|
|
<tr>
|
|
<th className="px-4 py-2 text-left text-gray-900 dark:text-white">Boja</th>
|
|
<th className="px-4 py-2 text-left text-gray-900 dark:text-white">Refill cena</th>
|
|
<th className="px-4 py-2 text-left text-gray-900 dark:text-white">Spulna cena</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredColors.map(color => {
|
|
const hasChanges = priceChanges[color.id] !== undefined;
|
|
return (
|
|
<tr
|
|
key={color.id}
|
|
className={`border-b dark:border-gray-700 ${hasChanges ? 'bg-yellow-50 dark:bg-yellow-900/20' : ''}`}
|
|
>
|
|
<td className="px-4 py-2">
|
|
<div className="flex items-center gap-2">
|
|
<div
|
|
className="w-6 h-6 rounded border border-gray-300"
|
|
style={{ backgroundColor: color.hex }}
|
|
/>
|
|
<span className="text-gray-900 dark:text-white">{color.name}</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-2">
|
|
<input
|
|
type="number"
|
|
value={getEffectivePrice(color, 'cena_refill')}
|
|
onChange={(e) => updatePrice(color.id, 'cena_refill', e.target.value)}
|
|
className="w-full px-2 py-1 border rounded dark:bg-gray-600 dark:border-gray-500 dark:text-white"
|
|
/>
|
|
</td>
|
|
<td className="px-4 py-2">
|
|
<input
|
|
type="number"
|
|
value={getEffectivePrice(color, 'cena_spulna')}
|
|
onChange={(e) => updatePrice(color.id, 'cena_spulna', e.target.value)}
|
|
className="w-full px-2 py-1 border rounded dark:bg-gray-600 dark:border-gray-500 dark:text-white"
|
|
/>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex justify-between gap-4">
|
|
<button
|
|
onClick={() => {
|
|
setPriceChanges({});
|
|
setGlobalRefillPrice('');
|
|
setGlobalSpulnaPrice('');
|
|
}}
|
|
disabled={loading || Object.keys(priceChanges).length === 0}
|
|
className="px-4 py-2 bg-gray-300 text-gray-700 rounded hover:bg-gray-400 disabled:opacity-50"
|
|
>
|
|
Poništi promene
|
|
</button>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => setShowModal(false)}
|
|
disabled={loading}
|
|
className="px-4 py-2 bg-gray-500 text-white rounded hover:bg-gray-600 disabled:opacity-50"
|
|
>
|
|
Zatvori
|
|
</button>
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={loading || Object.keys(priceChanges).length === 0}
|
|
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 disabled:opacity-50"
|
|
>
|
|
{loading ? 'Čuvanje...' : `Sačuvaj promene (${Object.keys(priceChanges).length})`}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|