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:
BIN
api-update-sale.tar.gz
Normal file
BIN
api-update-sale.tar.gz
Normal file
Binary file not shown.
@@ -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}`);
|
||||
});
|
||||
@@ -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={() => {
|
||||
|
||||
10
database/migrations/014_add_sale_fields.sql
Normal file
10
database/migrations/014_add_sale_fields.sql
Normal 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
39
scripts/deploy-api-update.sh
Executable 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
17
scripts/run-sale-migration.sh
Executable 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!"
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
177
src/components/SaleManager.tsx
Normal file
177
src/components/SaleManager.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user