Add sales tracking system with customers, analytics, and inventory management
All checks were successful
Deploy / deploy (push) Successful in 2m26s
All checks were successful
Deploy / deploy (push) Successful in 2m26s
- Add customers table (021) and sales/sale_items tables (022) migrations - Add customer CRUD, sale CRUD (transactional with auto inventory decrement/restore), and analytics API endpoints (overview, top sellers, revenue chart, inventory alerts) - Add sales page with NewSaleModal (customer autocomplete, multi-item form, color-based pricing, stock validation) and SaleDetailModal - Add customers page with search, inline editing, and purchase history - Add analytics dashboard with recharts (revenue line chart, top sellers bar, refill vs spulna pie chart, inventory alerts table with stockout estimates) - Add customerService, saleService, analyticsService to frontend API layer - Update sidebar navigation on all admin pages
This commit is contained in:
535
app/upadaj/customers/page.tsx
Normal file
535
app/upadaj/customers/page.tsx
Normal file
@@ -0,0 +1,535 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { customerService } from '@/src/services/api';
|
||||
import { Customer, Sale } from '@/src/types/sales';
|
||||
|
||||
interface CustomerWithSales extends Customer {
|
||||
sales?: Sale[];
|
||||
total_purchases?: number;
|
||||
}
|
||||
|
||||
export default function CustomersManagement() {
|
||||
const router = useRouter();
|
||||
const [customers, setCustomers] = useState<CustomerWithSales[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [expandedCustomerId, setExpandedCustomerId] = useState<string | null>(null);
|
||||
const [expandedSales, setExpandedSales] = useState<Sale[]>([]);
|
||||
const [loadingSales, setLoadingSales] = useState(false);
|
||||
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
|
||||
const [editForm, setEditForm] = useState<Partial<Customer>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// Initialize dark mode
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
const saved = localStorage.getItem('darkMode');
|
||||
if (saved !== null) {
|
||||
setDarkMode(JSON.parse(saved));
|
||||
} else {
|
||||
setDarkMode(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
localStorage.setItem('darkMode', JSON.stringify(darkMode));
|
||||
if (darkMode) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}, [darkMode, mounted]);
|
||||
|
||||
// Check authentication
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
const token = localStorage.getItem('authToken');
|
||||
const expiry = localStorage.getItem('tokenExpiry');
|
||||
if (!token || !expiry || Date.now() > parseInt(expiry)) {
|
||||
window.location.href = '/upadaj';
|
||||
}
|
||||
}, [mounted]);
|
||||
|
||||
// Fetch customers
|
||||
const fetchCustomers = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await customerService.getAll();
|
||||
setCustomers(data);
|
||||
} catch (err) {
|
||||
setError('Greska pri ucitavanju kupaca');
|
||||
console.error('Fetch error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCustomers();
|
||||
}, [fetchCustomers]);
|
||||
|
||||
// Search customers
|
||||
const filteredCustomers = customers.filter((customer) => {
|
||||
if (!searchTerm) return true;
|
||||
const term = searchTerm.toLowerCase();
|
||||
return (
|
||||
customer.name.toLowerCase().includes(term) ||
|
||||
(customer.phone && customer.phone.toLowerCase().includes(term)) ||
|
||||
(customer.city && customer.city.toLowerCase().includes(term))
|
||||
);
|
||||
});
|
||||
|
||||
// Toggle expanded row to show purchase history
|
||||
const handleToggleExpand = async (customerId: string) => {
|
||||
if (expandedCustomerId === customerId) {
|
||||
setExpandedCustomerId(null);
|
||||
setExpandedSales([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setExpandedCustomerId(customerId);
|
||||
setLoadingSales(true);
|
||||
try {
|
||||
const data = await customerService.getById(customerId);
|
||||
setExpandedSales(data.sales || []);
|
||||
} catch (err) {
|
||||
console.error('Error fetching customer sales:', err);
|
||||
setExpandedSales([]);
|
||||
} finally {
|
||||
setLoadingSales(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Edit customer
|
||||
const handleStartEdit = (customer: Customer) => {
|
||||
setEditingCustomer(customer);
|
||||
setEditForm({
|
||||
name: customer.name,
|
||||
phone: customer.phone || '',
|
||||
city: customer.city || '',
|
||||
notes: customer.notes || '',
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingCustomer(null);
|
||||
setEditForm({});
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!editingCustomer) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await customerService.update(editingCustomer.id, editForm);
|
||||
setEditingCustomer(null);
|
||||
setEditForm({});
|
||||
await fetchCustomers();
|
||||
} catch (err) {
|
||||
setError('Greska pri cuvanju izmena');
|
||||
console.error('Save error:', err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('authToken');
|
||||
localStorage.removeItem('tokenExpiry');
|
||||
router.push('/upadaj');
|
||||
};
|
||||
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleDateString('sr-Latn-RS', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('sr-Latn-RS', {
|
||||
style: 'decimal',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount) + ' RSD';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||
<div className="text-gray-600 dark:text-gray-400">Ucitavanje...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors">
|
||||
<div className="flex">
|
||||
{/* Sidebar */}
|
||||
<div className="w-64 bg-white dark:bg-gray-800 shadow-lg h-screen sticky top-0">
|
||||
<div className="p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-6">Admin Panel</h2>
|
||||
<nav className="space-y-2">
|
||||
<a
|
||||
href="/dashboard"
|
||||
className="block px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
Filamenti
|
||||
</a>
|
||||
<a
|
||||
href="/upadaj/colors"
|
||||
className="block px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
Boje
|
||||
</a>
|
||||
<a
|
||||
href="/upadaj/requests"
|
||||
className="block px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
Zahtevi za boje
|
||||
</a>
|
||||
<a
|
||||
href="/upadaj/sales"
|
||||
className="block px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
Prodaja
|
||||
</a>
|
||||
<a
|
||||
href="/upadaj/customers"
|
||||
className="block px-4 py-2 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded"
|
||||
>
|
||||
Kupci
|
||||
</a>
|
||||
<a
|
||||
href="/upadaj/analytics"
|
||||
className="block px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
Analitika
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1">
|
||||
<header className="bg-white dark:bg-gray-800 shadow transition-colors">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="Filamenteka"
|
||||
className="h-20 sm:h-32 w-auto drop-shadow-lg"
|
||||
/>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Upravljanje kupcima
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
<button
|
||||
onClick={() => router.push('/')}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||
>
|
||||
Nazad na sajt
|
||||
</button>
|
||||
{mounted && (
|
||||
<button
|
||||
onClick={() => setDarkMode(!darkMode)}
|
||||
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
||||
title={darkMode ? 'Svetla tema' : 'Tamna tema'}
|
||||
>
|
||||
{darkMode ? '\u2600' : '\u263D'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
|
||||
>
|
||||
Odjava
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 rounded">
|
||||
{error}
|
||||
<button
|
||||
onClick={() => setError('')}
|
||||
className="ml-4 text-red-600 dark:text-red-300 underline text-sm"
|
||||
>
|
||||
Zatvori
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search bar */}
|
||||
<div className="mb-6">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Pretrazi kupce..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full max-w-md px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Customer count */}
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
Ukupno kupaca: {filteredCustomers.length}
|
||||
</p>
|
||||
|
||||
{/* Customer table */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Ime
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Telefon
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Grad
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Beleske
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Datum registracije
|
||||
</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>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{filteredCustomers.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={6}
|
||||
className="px-6 py-8 text-center text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{searchTerm ? 'Nema rezultata pretrage' : 'Nema registrovanih kupaca'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredCustomers.map((customer) => (
|
||||
<>
|
||||
<tr
|
||||
key={customer.id}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
{editingCustomer?.id === customer.id ? (
|
||||
<>
|
||||
<td className="px-6 py-4">
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.name || ''}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, name: e.target.value })
|
||||
}
|
||||
className="w-full px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.phone || ''}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, phone: e.target.value })
|
||||
}
|
||||
className="w-full px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.city || ''}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, city: e.target.value })
|
||||
}
|
||||
className="w-full px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<textarea
|
||||
value={editForm.notes || ''}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, notes: e.target.value })
|
||||
}
|
||||
rows={2}
|
||||
placeholder="npr. stampa figurice, obicno crna..."
|
||||
className="w-full px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
{formatDate(customer.created_at)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSaveEdit}
|
||||
disabled={saving}
|
||||
className="px-3 py-1 bg-green-500 text-white text-sm rounded hover:bg-green-600 disabled:opacity-50"
|
||||
>
|
||||
{saving ? '...' : 'Sacuvaj'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelEdit}
|
||||
className="px-3 py-1 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-200 text-sm rounded hover:bg-gray-400 dark:hover:bg-gray-500"
|
||||
>
|
||||
Otkazi
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{customer.name}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
{customer.phone || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
{customer.city || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400 max-w-xs whitespace-pre-wrap">
|
||||
{customer.notes || <span className="text-gray-400 dark:text-gray-600 italic">Nema beleski</span>}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
{formatDate(customer.created_at)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleStartEdit(customer)}
|
||||
className="px-3 py-1 bg-blue-500 text-white text-sm rounded hover:bg-blue-600"
|
||||
>
|
||||
Izmeni
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleToggleExpand(customer.id)}
|
||||
className={`px-3 py-1 text-sm rounded ${
|
||||
expandedCustomerId === customer.id
|
||||
? 'bg-indigo-600 text-white hover:bg-indigo-700'
|
||||
: 'bg-indigo-100 dark:bg-indigo-900 text-indigo-700 dark:text-indigo-300 hover:bg-indigo-200 dark:hover:bg-indigo-800'
|
||||
}`}
|
||||
>
|
||||
Istorija kupovina
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
|
||||
{/* Expanded purchase history */}
|
||||
{expandedCustomerId === customer.id && (
|
||||
<tr key={`${customer.id}-sales`}>
|
||||
<td
|
||||
colSpan={6}
|
||||
className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50"
|
||||
>
|
||||
<div className="ml-4">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
||||
Istorija kupovina - {customer.name}
|
||||
</h4>
|
||||
{loadingSales ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Ucitavanje...
|
||||
</p>
|
||||
) : expandedSales.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Nema evidentiranih kupovina
|
||||
</p>
|
||||
) : (
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700 rounded overflow-hidden">
|
||||
<thead className="bg-gray-100 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
|
||||
ID
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
|
||||
Datum
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
|
||||
Stavke
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
|
||||
Iznos
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
|
||||
Napomena
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{expandedSales.map((sale) => (
|
||||
<tr key={sale.id}>
|
||||
<td className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 font-mono">
|
||||
{sale.id.substring(0, 8)}...
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{formatDate(sale.created_at)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{sale.item_count ?? '-'}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-900 dark:text-white font-medium">
|
||||
{formatCurrency(sale.total_amount)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
{sale.notes || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="bg-gray-50 dark:bg-gray-700/50">
|
||||
<tr>
|
||||
<td
|
||||
colSpan={3}
|
||||
className="px-4 py-2 text-sm font-semibold text-gray-700 dark:text-gray-300 text-right"
|
||||
>
|
||||
Ukupno ({expandedSales.length} kupovina):
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm font-bold text-gray-900 dark:text-white">
|
||||
{formatCurrency(
|
||||
expandedSales.reduce(
|
||||
(sum, sale) => sum + sale.total_amount,
|
||||
0
|
||||
)
|
||||
)}
|
||||
</td>
|
||||
<td />
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user