Add bulk price editing features and fix quantity update price preservation
- 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
This commit is contained in:
288
src/components/BulkPriceEditor.tsx
Normal file
288
src/components/BulkPriceEditor.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user