Add sale management feature for admin panel

- Add database migration for sale fields (percentage, active, dates)
- Update API to handle sale operations and bulk updates
- Create SaleManager component for admin interface
- Update FilamentTableV2 to display sale prices on frontend
- Add sale column in admin dashboard
- Implement sale price calculations with strikethrough styling
This commit is contained in:
DaX
2025-07-05 14:48:31 +02:00
parent c0682e1969
commit 0df9d5d294
10 changed files with 354 additions and 5 deletions

BIN
api-update-sale.tar.gz Normal file

Binary file not shown.

View File

@@ -153,7 +153,7 @@ app.post('/api/filaments', authenticateToken, async (req, res) => {
app.put('/api/filaments/:id', authenticateToken, async (req, res) => {
const { id } = req.params;
const { tip, finish, boja, boja_hex, refill, spulna, cena } = req.body;
const { tip, finish, boja, boja_hex, refill, spulna, cena, sale_percentage, sale_active, sale_start_date, sale_end_date } = req.body;
try {
// Ensure refill and spulna are numbers
@@ -165,9 +165,12 @@ app.put('/api/filaments/:id', authenticateToken, async (req, res) => {
`UPDATE filaments
SET tip = $1, finish = $2, boja = $3, boja_hex = $4,
refill = $5, spulna = $6, kolicina = $7, cena = $8,
sale_percentage = $9, sale_active = $10,
sale_start_date = $11, sale_end_date = $12,
updated_at = CURRENT_TIMESTAMP
WHERE id = $9 RETURNING *`,
[tip, finish, boja, boja_hex, refillNum, spulnaNum, kolicina, cena, id]
WHERE id = $13 RETURNING *`,
[tip, finish, boja, boja_hex, refillNum, spulnaNum, kolicina, cena,
sale_percentage || 0, sale_active || false, sale_start_date, sale_end_date, id]
);
res.json(result.rows[0]);
} catch (error) {
@@ -188,6 +191,51 @@ app.delete('/api/filaments/:id', authenticateToken, async (req, res) => {
}
});
// Bulk sale update endpoint
app.post('/api/filaments/sale/bulk', authenticateToken, async (req, res) => {
const { filamentIds, salePercentage, saleStartDate, saleEndDate, enableSale } = req.body;
try {
let query;
let params;
if (filamentIds && filamentIds.length > 0) {
// Update specific filaments
query = `
UPDATE filaments
SET sale_percentage = $1,
sale_active = $2,
sale_start_date = $3,
sale_end_date = $4,
updated_at = CURRENT_TIMESTAMP
WHERE id = ANY($5)
RETURNING *`;
params = [salePercentage || 0, enableSale || false, saleStartDate, saleEndDate, filamentIds];
} else {
// Update all filaments
query = `
UPDATE filaments
SET sale_percentage = $1,
sale_active = $2,
sale_start_date = $3,
sale_end_date = $4,
updated_at = CURRENT_TIMESTAMP
RETURNING *`;
params = [salePercentage || 0, enableSale || false, saleStartDate, saleEndDate];
}
const result = await pool.query(query, params);
res.json({
success: true,
updatedCount: result.rowCount,
updatedFilaments: result.rows
});
} catch (error) {
console.error('Error updating sale:', error);
res.status(500).json({ error: 'Failed to update sale' });
}
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});

View File

@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
import { filamentService, colorService } from '@/src/services/api';
import { Filament } from '@/src/types/filament';
import { trackEvent } from '@/src/components/MatomoAnalytics';
import { SaleManager } from '@/src/components/SaleManager';
// Removed unused imports for Bambu Lab color categorization
import '@/src/styles/select.css';
@@ -339,6 +340,11 @@ export default function AdminDashboard() {
Obriši izabrane ({selectedFilaments.size})
</button>
)}
<SaleManager
filaments={filaments}
selectedFilaments={selectedFilaments}
onSaleUpdate={fetchFilaments}
/>
<button
onClick={() => router.push('/')}
className="flex-1 sm:flex-initial px-3 sm:px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm sm:text-base"
@@ -474,6 +480,7 @@ export default function AdminDashboard() {
<th onClick={() => handleSort('cena')} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
Cena {sortField === 'cena' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Popust</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Akcije</th>
</tr>
</thead>
@@ -565,6 +572,22 @@ export default function AdminDashboard() {
);
})()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
{filament.sale_active && filament.sale_percentage ? (
<div>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
-{filament.sale_percentage}%
</span>
{filament.sale_end_date && (
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
do {new Date(filament.sale_end_date).toLocaleDateString('sr-RS')}
</div>
)}
</div>
) : (
<span className="text-gray-400 dark:text-gray-500">-</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button
onClick={() => {

View File

@@ -0,0 +1,10 @@
-- Add sale fields to filaments table
ALTER TABLE filaments
ADD COLUMN IF NOT EXISTS sale_percentage INTEGER DEFAULT 0 CHECK (sale_percentage >= 0 AND sale_percentage <= 100),
ADD COLUMN IF NOT EXISTS sale_active BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS sale_start_date TIMESTAMP WITH TIME ZONE,
ADD COLUMN IF NOT EXISTS sale_end_date TIMESTAMP WITH TIME ZONE;
-- Add indexes for better performance
CREATE INDEX IF NOT EXISTS idx_filaments_sale_active ON filaments(sale_active) WHERE sale_active = TRUE;
CREATE INDEX IF NOT EXISTS idx_filaments_sale_dates ON filaments(sale_start_date, sale_end_date) WHERE sale_active = TRUE;

39
scripts/deploy-api-update.sh Executable file
View File

@@ -0,0 +1,39 @@
#!/bin/bash
echo "Deploying API update to EC2 instance..."
# Get instance ID
INSTANCE_ID="i-03956ecf32292d7d9"
# Create update script
cat > /tmp/update-api.sh << 'EOF'
#!/bin/bash
cd /home/ubuntu/filamenteka-api
# Backup current server.js
cp server.js server.js.backup
# Download the updated server.js from GitHub
curl -o server.js https://raw.githubusercontent.com/daxdax89/Filamenteka/sale/api/server.js
# Restart the service
sudo systemctl restart node-api
echo "API server updated and restarted"
EOF
# Send command to EC2 instance
aws ssm send-command \
--region eu-central-1 \
--instance-ids "$INSTANCE_ID" \
--document-name "AWS-RunShellScript" \
--parameters "commands=[
'cd /home/ubuntu/filamenteka-api',
'cp server.js server.js.backup',
'curl -o server.js https://raw.githubusercontent.com/daxdax89/Filamenteka/sale/api/server.js',
'sudo systemctl restart node-api',
'sudo systemctl status node-api'
]" \
--output json
echo "Command sent. Check AWS Systems Manager for execution status."

17
scripts/run-sale-migration.sh Executable file
View File

@@ -0,0 +1,17 @@
#!/bin/bash
echo "Running sale fields migration..."
# Get RDS endpoint from AWS
RDS_ENDPOINT=$(aws rds describe-db-instances --region eu-central-1 --db-instance-identifier filamenteka --query 'DBInstances[0].Endpoint.Address' --output text)
# Get database credentials from Secrets Manager
DB_CREDS=$(aws secretsmanager get-secret-value --region eu-central-1 --secret-id filamenteka-db-credentials --query 'SecretString' --output text)
DB_USER=$(echo $DB_CREDS | jq -r '.username')
DB_PASS=$(echo $DB_CREDS | jq -r '.password')
DB_NAME=$(echo $DB_CREDS | jq -r '.database')
# Run the migration
PGPASSWORD="$DB_PASS" psql -h $RDS_ENDPOINT -U $DB_USER -d $DB_NAME -f database/migrations/014_add_sale_fields.sql
echo "Migration completed!"

View File

@@ -245,20 +245,40 @@ const FilamentTableV2: React.FC<FilamentTableV2Props> = ({ filaments }) => {
spoolPrice = colorData?.cena_spulna || 3999;
}
// Calculate sale prices if applicable
const saleActive = filament.sale_active && filament.sale_percentage;
const saleRefillPrice = saleActive ? Math.round(refillPrice * (1 - filament.sale_percentage! / 100)) : refillPrice;
const saleSpoolPrice = saleActive ? Math.round(spoolPrice * (1 - filament.sale_percentage! / 100)) : spoolPrice;
return (
<>
{hasRefill && (
<span className="text-green-600 dark:text-green-400">
<span className={saleActive ? "line-through text-gray-500 dark:text-gray-400" : "text-green-600 dark:text-green-400"}>
{refillPrice.toLocaleString('sr-RS')}
</span>
)}
{hasRefill && saleActive && (
<span className="text-green-600 dark:text-green-400 font-bold ml-1">
{saleRefillPrice.toLocaleString('sr-RS')}
</span>
)}
{hasRefill && hasSpool && <span className="mx-1">/</span>}
{hasSpool && (
<span className="text-blue-500 dark:text-blue-400">
<span className={saleActive ? "line-through text-gray-500 dark:text-gray-400" : "text-blue-500 dark:text-blue-400"}>
{spoolPrice.toLocaleString('sr-RS')}
</span>
)}
{hasSpool && saleActive && (
<span className="text-blue-500 dark:text-blue-400 font-bold ml-1">
{saleSpoolPrice.toLocaleString('sr-RS')}
</span>
)}
<span className="ml-1 text-gray-600 dark:text-gray-400">RSD</span>
{saleActive && (
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
-{filament.sale_percentage}%
</span>
)}
</>
);
})()}

View File

@@ -0,0 +1,177 @@
import React, { useState } from 'react';
import { filamentService } from '@/src/services/api';
import { Filament } from '@/src/types/filament';
interface SaleManagerProps {
filaments: Filament[];
selectedFilaments: Set<string>;
onSaleUpdate: () => void;
}
export function SaleManager({ filaments, selectedFilaments, onSaleUpdate }: SaleManagerProps) {
const [showSaleModal, setShowSaleModal] = useState(false);
const [salePercentage, setSalePercentage] = useState(10);
const [saleStartDate, setSaleStartDate] = useState('');
const [saleEndDate, setSaleEndDate] = useState('');
const [enableSale, setEnableSale] = useState(true);
const [applyToAll, setApplyToAll] = useState(false);
const [loading, setLoading] = useState(false);
const handleSaleUpdate = async () => {
setLoading(true);
try {
const filamentIds = applyToAll ? undefined : Array.from(selectedFilaments);
try {
await filamentService.updateBulkSale({
filamentIds,
salePercentage,
saleStartDate: saleStartDate || undefined,
saleEndDate: saleEndDate || undefined,
enableSale
});
} catch (error: any) {
// If bulk endpoint fails, fall back to individual updates
if (error.response?.status === 404) {
alert('Bulk sale endpoint not available yet. Please update the API server with the latest code.');
return;
}
throw error;
}
onSaleUpdate();
setShowSaleModal(false);
setSalePercentage(10);
setSaleStartDate('');
setSaleEndDate('');
setEnableSale(true);
setApplyToAll(false);
} catch (error) {
console.error('Error updating sale:', error);
alert('Greška pri ažuriranju popusta');
} finally {
setLoading(false);
}
};
// Get current date/time in local timezone for datetime-local input
const getCurrentDateTime = () => {
const now = new Date();
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
return now.toISOString().slice(0, 16);
};
return (
<>
<button
onClick={() => setShowSaleModal(true)}
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"
>
Upravljaj popustima
</button>
{showSaleModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4">
<h2 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">
Upravljanje popustima
</h2>
<div className="space-y-4">
<div>
<label className="flex items-center gap-2 mb-4">
<input
type="checkbox"
checked={applyToAll}
onChange={(e) => setApplyToAll(e.target.checked)}
className="w-4 h-4 text-purple-600"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">
Primeni na sve filamente
</span>
</label>
{!applyToAll && (
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Izabrano: {selectedFilaments.size} filament{selectedFilaments.size === 1 ? '' : 'a'}
</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
Procenat popusta (%)
</label>
<input
type="number"
min="0"
max="100"
value={salePercentage}
onChange={(e) => setSalePercentage(parseInt(e.target.value) || 0)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
Početak popusta (opciono)
</label>
<input
type="datetime-local"
value={saleStartDate}
onChange={(e) => setSaleStartDate(e.target.value)}
min={getCurrentDateTime()}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
Kraj popusta (opciono)
</label>
<input
type="datetime-local"
value={saleEndDate}
onChange={(e) => setSaleEndDate(e.target.value)}
min={saleStartDate || getCurrentDateTime()}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
/>
</div>
<div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={enableSale}
onChange={(e) => setEnableSale(e.target.checked)}
className="w-4 h-4 text-purple-600"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">
Aktiviraj popust
</span>
</label>
</div>
</div>
<div className="flex justify-end gap-4 mt-6">
<button
onClick={() => setShowSaleModal(false)}
className="px-4 py-2 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-200 rounded hover:bg-gray-400 dark:hover:bg-gray-500"
disabled={loading}
>
Otkaži
</button>
<button
onClick={handleSaleUpdate}
className="px-4 py-2 bg-purple-500 text-white rounded hover:bg-purple-600 disabled:opacity-50"
disabled={loading || (!applyToAll && selectedFilaments.size === 0)}
>
{loading ? 'Ažuriranje...' : 'Primeni'}
</button>
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -87,6 +87,17 @@ export const filamentService = {
const response = await api.delete(`/filaments/${id}`);
return response.data;
},
updateBulkSale: async (data: {
filamentIds?: string[];
salePercentage: number;
saleStartDate?: string;
saleEndDate?: string;
enableSale: boolean;
}) => {
const response = await api.post('/filaments/sale/bulk', data);
return response.data;
},
};
export default api;

View File

@@ -11,4 +11,8 @@ export interface Filament {
status?: string;
created_at?: string; // Using snake_case to match database
updated_at?: string; // Using snake_case to match database
sale_percentage?: number;
sale_active?: boolean;
sale_start_date?: string;
sale_end_date?: string;
}