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:
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { colorService } from '@/src/services/api';
|
import { colorService } from '@/src/services/api';
|
||||||
import { bambuLabColors, getColorHex } from '@/src/data/bambuLabColorsComplete';
|
import { bambuLabColors, getColorHex } from '@/src/data/bambuLabColorsComplete';
|
||||||
|
import { BulkPriceEditor } from '@/src/components/BulkPriceEditor';
|
||||||
import '@/src/styles/select.css';
|
import '@/src/styles/select.css';
|
||||||
|
|
||||||
interface Color {
|
interface Color {
|
||||||
@@ -221,7 +222,7 @@ export default function ColorsManagement() {
|
|||||||
/>
|
/>
|
||||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Upravljanje bojama</h1>
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Upravljanje bojama</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4 flex-wrap">
|
||||||
{!showAddForm && !editingColor && (
|
{!showAddForm && !editingColor && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddForm(true)}
|
onClick={() => setShowAddForm(true)}
|
||||||
@@ -230,6 +231,7 @@ export default function ColorsManagement() {
|
|||||||
Dodaj novu boju
|
Dodaj novu boju
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<BulkPriceEditor colors={colors} onUpdate={fetchColors} />
|
||||||
{selectedColors.size > 0 && (
|
{selectedColors.size > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={handleBulkDelete}
|
onClick={handleBulkDelete}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { filamentService, colorService } from '@/src/services/api';
|
|||||||
import { Filament } from '@/src/types/filament';
|
import { Filament } from '@/src/types/filament';
|
||||||
import { trackEvent } from '@/src/components/MatomoAnalytics';
|
import { trackEvent } from '@/src/components/MatomoAnalytics';
|
||||||
import { SaleManager } from '@/src/components/SaleManager';
|
import { SaleManager } from '@/src/components/SaleManager';
|
||||||
|
import { BulkFilamentPriceEditor } from '@/src/components/BulkFilamentPriceEditor';
|
||||||
// Removed unused imports for Bambu Lab color categorization
|
// Removed unused imports for Bambu Lab color categorization
|
||||||
import '@/src/styles/select.css';
|
import '@/src/styles/select.css';
|
||||||
|
|
||||||
@@ -396,6 +397,10 @@ export default function AdminDashboard() {
|
|||||||
selectedFilaments={selectedFilaments}
|
selectedFilaments={selectedFilaments}
|
||||||
onSaleUpdate={fetchFilaments}
|
onSaleUpdate={fetchFilaments}
|
||||||
/>
|
/>
|
||||||
|
<BulkFilamentPriceEditor
|
||||||
|
filaments={filaments}
|
||||||
|
onUpdate={fetchFilaments}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/upadaj/colors')}
|
onClick={() => router.push('/upadaj/colors')}
|
||||||
className="flex-1 sm:flex-initial px-3 sm:px-4 py-2 bg-purple-500 text-white rounded hover:bg-purple-600 text-sm sm:text-base"
|
className="flex-1 sm:flex-initial px-3 sm:px-4 py-2 bg-purple-500 text-white rounded hover:bg-purple-600 text-sm sm:text-base"
|
||||||
@@ -713,6 +718,9 @@ function FilamentForm({
|
|||||||
cena_spulna: 0,
|
cena_spulna: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Track if this is the initial load to prevent price override
|
||||||
|
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
||||||
|
|
||||||
// Update form when filament prop changes
|
// Update form when filament prop changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Extract prices from the cena field if it exists (format: "3499/3999" or just "3499")
|
// Extract prices from the cena field if it exists (format: "3499/3999" or just "3499")
|
||||||
@@ -742,10 +750,19 @@ function FilamentForm({
|
|||||||
cena_refill: refillPrice || 3499,
|
cena_refill: refillPrice || 3499,
|
||||||
cena_spulna: spulnaPrice || 3999,
|
cena_spulna: spulnaPrice || 3999,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reset initial load flag when filament changes
|
||||||
|
setIsInitialLoad(true);
|
||||||
}, [filament]);
|
}, [filament]);
|
||||||
|
|
||||||
// Update prices when color selection changes
|
// Update prices when color selection changes (but not on initial load)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Skip price update on initial load to preserve existing filament prices
|
||||||
|
if (isInitialLoad) {
|
||||||
|
setIsInitialLoad(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (formData.boja && availableColors.length > 0) {
|
if (formData.boja && availableColors.length > 0) {
|
||||||
const colorData = availableColors.find(c => c.name === formData.boja);
|
const colorData = availableColors.find(c => c.name === formData.boja);
|
||||||
if (colorData) {
|
if (colorData) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "./scripts/kill-dev.sh && next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
|
|||||||
26
scripts/kill-dev.sh
Executable file
26
scripts/kill-dev.sh
Executable file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Kill any processes running on ports 3000 and 3001
|
||||||
|
echo "🔍 Checking for processes on ports 3000 and 3001..."
|
||||||
|
PIDS=$(lsof -ti:3000,3001 2>/dev/null)
|
||||||
|
if [ -n "$PIDS" ]; then
|
||||||
|
echo "$PIDS" | xargs kill -9 2>/dev/null
|
||||||
|
echo "✅ Killed processes on ports 3000/3001"
|
||||||
|
else
|
||||||
|
echo "ℹ️ No processes found on ports 3000/3001"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Kill any old Next.js dev server processes (but not the current script)
|
||||||
|
echo "🔍 Checking for Next.js dev processes..."
|
||||||
|
OLD_PIDS=$(ps aux | grep -i "next dev" | grep -v grep | grep -v "kill-dev.sh" | awk '{print $2}')
|
||||||
|
if [ -n "$OLD_PIDS" ]; then
|
||||||
|
echo "$OLD_PIDS" | xargs kill -9 2>/dev/null
|
||||||
|
echo "✅ Killed Next.js dev processes"
|
||||||
|
else
|
||||||
|
echo "ℹ️ No Next.js dev processes found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Give it a moment to clean up
|
||||||
|
sleep 0.5
|
||||||
|
|
||||||
|
echo "✨ Ready to start fresh dev server!"
|
||||||
363
src/components/BulkFilamentPriceEditor.tsx
Normal file
363
src/components/BulkFilamentPriceEditor.tsx
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { filamentService, colorService } from '@/src/services/api';
|
||||||
|
import { Filament } from '@/src/types/filament';
|
||||||
|
|
||||||
|
interface BulkFilamentPriceEditorProps {
|
||||||
|
filaments: Array<Filament & { id: string }>;
|
||||||
|
onUpdate: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BulkFilamentPriceEditor({ filaments, onUpdate }: BulkFilamentPriceEditorProps) {
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
const [selectedMaterial, setSelectedMaterial] = useState<string>('');
|
||||||
|
const [selectedFinish, setSelectedFinish] = useState<string>('');
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
|
// Price inputs
|
||||||
|
const [newRefillPrice, setNewRefillPrice] = useState<string>('');
|
||||||
|
const [newSpoolPrice, setNewSpoolPrice] = useState<string>('');
|
||||||
|
|
||||||
|
// Get unique materials and finishes
|
||||||
|
const materials = useMemo(() =>
|
||||||
|
[...new Set(filaments.map(f => f.tip))].sort(),
|
||||||
|
[filaments]
|
||||||
|
);
|
||||||
|
|
||||||
|
const finishes = useMemo(() =>
|
||||||
|
[...new Set(filaments.map(f => f.finish))].sort(),
|
||||||
|
[filaments]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filter filaments based on selections
|
||||||
|
const filteredFilaments = useMemo(() => {
|
||||||
|
return filaments.filter(f => {
|
||||||
|
const matchesMaterial = !selectedMaterial || f.tip === selectedMaterial;
|
||||||
|
const matchesFinish = !selectedFinish || f.finish === selectedFinish;
|
||||||
|
const matchesSearch = !searchTerm ||
|
||||||
|
f.boja.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
f.tip.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
f.finish.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
|
||||||
|
return matchesMaterial && matchesFinish && matchesSearch;
|
||||||
|
});
|
||||||
|
}, [filaments, selectedMaterial, selectedFinish, searchTerm]);
|
||||||
|
|
||||||
|
// Group filaments by color (since multiple filaments can have same color)
|
||||||
|
const colorGroups = useMemo(() => {
|
||||||
|
const groups = new Map<string, Array<Filament & { id: string }>>();
|
||||||
|
filteredFilaments.forEach(f => {
|
||||||
|
const existing = groups.get(f.boja) || [];
|
||||||
|
existing.push(f);
|
||||||
|
groups.set(f.boja, existing);
|
||||||
|
});
|
||||||
|
return Array.from(groups.entries()).sort((a, b) => a[0].localeCompare(b[0]));
|
||||||
|
}, [filteredFilaments]);
|
||||||
|
|
||||||
|
const handleApplyPrices = async () => {
|
||||||
|
const refillPrice = parseInt(newRefillPrice);
|
||||||
|
const spoolPrice = parseInt(newSpoolPrice);
|
||||||
|
|
||||||
|
if (isNaN(refillPrice) && isNaN(spoolPrice)) {
|
||||||
|
alert('Molimo unesite bar jednu cenu (refill ili spulna)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((refillPrice && refillPrice < 0) || (spoolPrice && spoolPrice < 0)) {
|
||||||
|
alert('Cena ne može biti negativna');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredFilaments.length === 0) {
|
||||||
|
alert('Nema filamenata koji odgovaraju filterima');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmMsg = `Želite da promenite cene za ${filteredFilaments.length} filament(a)?
|
||||||
|
${selectedMaterial ? `\nMaterijal: ${selectedMaterial}` : ''}
|
||||||
|
${selectedFinish ? `\nFiniš: ${selectedFinish}` : ''}
|
||||||
|
${!isNaN(refillPrice) ? `\nRefill cena: ${refillPrice}` : ''}
|
||||||
|
${!isNaN(spoolPrice) ? `\nSpulna cena: ${spoolPrice}` : ''}`;
|
||||||
|
|
||||||
|
if (!confirm(confirmMsg)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// Update each filament
|
||||||
|
await Promise.all(
|
||||||
|
filteredFilaments.map(async (filament) => {
|
||||||
|
// Parse current prices
|
||||||
|
const prices = filament.cena.split('/');
|
||||||
|
const currentRefillPrice = parseInt(prices[0]) || 3499;
|
||||||
|
const currentSpoolPrice = prices.length > 1 ? parseInt(prices[1]) || 3999 : 3999;
|
||||||
|
|
||||||
|
// Use new prices if provided, otherwise keep current
|
||||||
|
const finalRefillPrice = !isNaN(refillPrice) ? refillPrice : currentRefillPrice;
|
||||||
|
const finalSpoolPrice = !isNaN(spoolPrice) ? spoolPrice : currentSpoolPrice;
|
||||||
|
|
||||||
|
// Build price string based on quantities
|
||||||
|
let priceString = '';
|
||||||
|
if (filament.refill > 0 && filament.spulna > 0) {
|
||||||
|
priceString = `${finalRefillPrice}/${finalSpoolPrice}`;
|
||||||
|
} else if (filament.refill > 0) {
|
||||||
|
priceString = String(finalRefillPrice);
|
||||||
|
} else if (filament.spulna > 0) {
|
||||||
|
priceString = String(finalSpoolPrice);
|
||||||
|
} else {
|
||||||
|
priceString = `${finalRefillPrice}/${finalSpoolPrice}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filamentService.update(filament.id, {
|
||||||
|
tip: filament.tip,
|
||||||
|
finish: filament.finish,
|
||||||
|
boja: filament.boja,
|
||||||
|
boja_hex: filament.boja_hex,
|
||||||
|
refill: filament.refill,
|
||||||
|
spulna: filament.spulna,
|
||||||
|
cena: priceString
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
alert(`Uspešno ažurirano ${filteredFilaments.length} filament(a)!`);
|
||||||
|
setNewRefillPrice('');
|
||||||
|
setNewSpoolPrice('');
|
||||||
|
onUpdate();
|
||||||
|
setShowModal(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating prices:', error);
|
||||||
|
alert('Greška pri ažuriranju cena');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
setSelectedMaterial('');
|
||||||
|
setSelectedFinish('');
|
||||||
|
setSearchTerm('');
|
||||||
|
setNewRefillPrice('');
|
||||||
|
setNewSpoolPrice('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowModal(true)}
|
||||||
|
className="flex-1 sm:flex-initial px-3 sm:px-4 py-2 bg-indigo-500 text-white rounded hover:bg-indigo-600 text-sm sm:text-base"
|
||||||
|
>
|
||||||
|
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-5xl 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 text-2xl"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="mb-4 p-4 bg-gray-100 dark:bg-gray-700 rounded space-y-3">
|
||||||
|
<h3 className="font-semibold text-gray-900 dark:text-white mb-2">Filteri:</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Materijal
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedMaterial}
|
||||||
|
onChange={(e) => setSelectedMaterial(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border rounded dark:bg-gray-600 dark:border-gray-500 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="">Svi materijali</option>
|
||||||
|
{materials.map(m => (
|
||||||
|
<option key={m} value={m}>{m}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Finiš
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedFinish}
|
||||||
|
onChange={(e) => setSelectedFinish(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border rounded dark:bg-gray-600 dark:border-gray-500 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="">Svi finiševi</option>
|
||||||
|
{finishes.map(f => (
|
||||||
|
<option key={f} value={f}>{f}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Pretraga
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
placeholder="Pretraži boju, tip, finiš..."
|
||||||
|
className="w-full px-3 py-2 border rounded dark:bg-gray-600 dark:border-gray-500 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Prikazano: {filteredFilaments.length} filament(a)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price inputs */}
|
||||||
|
<div className="mb-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded">
|
||||||
|
<h3 className="font-semibold text-gray-900 dark:text-white mb-3">
|
||||||
|
Nove cene za filtrirane filamente:
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Refill cena
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={newRefillPrice}
|
||||||
|
onChange={(e) => setNewRefillPrice(e.target.value)}
|
||||||
|
placeholder="Npr. 3499"
|
||||||
|
className="w-full px-3 py-2 border rounded dark:bg-gray-600 dark:border-gray-500 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Spulna cena
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={newSpoolPrice}
|
||||||
|
onChange={(e) => setNewSpoolPrice(e.target.value)}
|
||||||
|
placeholder="Npr. 3999"
|
||||||
|
className="w-full px-3 py-2 border rounded dark:bg-gray-600 dark:border-gray-500 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mt-2">
|
||||||
|
Napomena: Možete promeniti samo refill, samo spulna, ili obe cene. Prazna polja će zadržati postojeće cene.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
<div className="flex-1 overflow-y-auto mb-4">
|
||||||
|
<h3 className="font-semibold text-gray-900 dark:text-white mb-2">
|
||||||
|
Pregled filamenata ({colorGroups.length} boja):
|
||||||
|
</h3>
|
||||||
|
{colorGroups.length === 0 ? (
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 text-center py-8">
|
||||||
|
Nema filamenata koji odgovaraju filterima
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-100 dark:bg-gray-700 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left text-gray-900 dark:text-white">Boja</th>
|
||||||
|
<th className="px-3 py-2 text-left text-gray-900 dark:text-white">Tip</th>
|
||||||
|
<th className="px-3 py-2 text-left text-gray-900 dark:text-white">Finiš</th>
|
||||||
|
<th className="px-3 py-2 text-left text-gray-900 dark:text-white">Refill</th>
|
||||||
|
<th className="px-3 py-2 text-left text-gray-900 dark:text-white">Spulna</th>
|
||||||
|
<th className="px-3 py-2 text-left text-gray-900 dark:text-white">Trenutna cena</th>
|
||||||
|
<th className="px-3 py-2 text-left text-gray-900 dark:text-white">Nova cena</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{colorGroups.map(([color, filamentGroup]) =>
|
||||||
|
filamentGroup.map((f, idx) => {
|
||||||
|
const prices = f.cena.split('/');
|
||||||
|
const currentRefillPrice = parseInt(prices[0]) || 3499;
|
||||||
|
const currentSpoolPrice = prices.length > 1 ? parseInt(prices[1]) || 3999 : 3999;
|
||||||
|
|
||||||
|
const refillPrice = parseInt(newRefillPrice);
|
||||||
|
const spoolPrice = parseInt(newSpoolPrice);
|
||||||
|
|
||||||
|
const finalRefillPrice = !isNaN(refillPrice) ? refillPrice : currentRefillPrice;
|
||||||
|
const finalSpoolPrice = !isNaN(spoolPrice) ? spoolPrice : currentSpoolPrice;
|
||||||
|
|
||||||
|
let newPriceString = '';
|
||||||
|
if (f.refill > 0 && f.spulna > 0) {
|
||||||
|
newPriceString = `${finalRefillPrice}/${finalSpoolPrice}`;
|
||||||
|
} else if (f.refill > 0) {
|
||||||
|
newPriceString = String(finalRefillPrice);
|
||||||
|
} else if (f.spulna > 0) {
|
||||||
|
newPriceString = String(finalSpoolPrice);
|
||||||
|
}
|
||||||
|
|
||||||
|
const priceChanged = newPriceString !== f.cena;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={`${f.id}-${idx}`}
|
||||||
|
className={`border-b dark:border-gray-700 ${priceChanged ? 'bg-yellow-50 dark:bg-yellow-900/20' : ''}`}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2 text-gray-900 dark:text-white">{f.boja}</td>
|
||||||
|
<td className="px-3 py-2 text-gray-700 dark:text-gray-300">{f.tip}</td>
|
||||||
|
<td className="px-3 py-2 text-gray-700 dark:text-gray-300">{f.finish}</td>
|
||||||
|
<td className="px-3 py-2 text-gray-700 dark:text-gray-300">{f.refill}</td>
|
||||||
|
<td className="px-3 py-2 text-gray-700 dark:text-gray-300">{f.spulna}</td>
|
||||||
|
<td className="px-3 py-2 text-gray-700 dark:text-gray-300">{f.cena}</td>
|
||||||
|
<td className={`px-3 py-2 font-semibold ${priceChanged ? 'text-green-600 dark:text-green-400' : 'text-gray-700 dark:text-gray-300'}`}>
|
||||||
|
{newPriceString || f.cena}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-between gap-4 pt-4 border-t dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
onClick={resetFilters}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-4 py-2 bg-gray-300 text-gray-700 rounded hover:bg-gray-400 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Resetuj filtere
|
||||||
|
</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={handleApplyPrices}
|
||||||
|
disabled={loading || filteredFilaments.length === 0}
|
||||||
|
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Čuvanje...' : `Primeni cene (${filteredFilaments.length})`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
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