Backend: - New models: Contact, Expense, ExpensePayment (3 new tables) - Expense status (paid/partial/due) is always computed from paid_amount vs total_amount — never stored - paid_amount recomputed from sum of all payments on each payment write (no client trust) - New router /api/contacts/: list, create, update, soft-delete (is_active=false) - New router /api/expenses/: list (filter by status/category/contact), create, update, delete, record payment, summary (total_due, total_paid, by_category) - Migrations: CREATE TABLE IF NOT EXISTS for contacts, expenses, expense_payments Frontend: - ContactsPage (/contacts): searchable table, type badge (Προμηθευτής/Προσωπικό/Κοινή Χρεία/Άλλο), create/edit modal, soft-delete - ExpensesPage (/expenses): filter bar (status + category), expandable rows with payment history, summary header showing total outstanding, inline Payment modal (defaults to full due amount), create/edit expense modal - Sidebar: Receipt icon for Έξοδα, BookUser icon for Επαφές Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
219 lines
11 KiB
JavaScript
219 lines
11 KiB
JavaScript
import { useState } from 'react'
|
||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||
import toast from 'react-hot-toast'
|
||
import client from '../api/client'
|
||
|
||
const TYPE_LABELS = {
|
||
supplier: 'Προμηθευτής',
|
||
staff: 'Προσωπικό',
|
||
utility: 'Κοινή Χρεία',
|
||
other: 'Άλλο',
|
||
}
|
||
const TYPE_COLORS = {
|
||
supplier: { bg: '#eff6ff', color: '#1d4ed8' },
|
||
staff: { bg: '#f0fdf4', color: '#15803d' },
|
||
utility: { bg: '#fefce8', color: '#a16207' },
|
||
other: { bg: '#f3f4f6', color: '#374151' },
|
||
}
|
||
|
||
const EMPTY_FORM = { name: '', type: 'supplier', phone: '', email: '', address: '', notes: '' }
|
||
|
||
function ContactModal({ initial, onClose, onSave, isPending }) {
|
||
const [form, setForm] = useState(initial ?? EMPTY_FORM)
|
||
const f = (k, v) => setForm(p => ({ ...p, [k]: v }))
|
||
const isNew = !initial?.id
|
||
const canSave = form.name.trim()
|
||
|
||
const inputStyle = {
|
||
width: '100%', padding: '8px 11px', border: '1px solid #e5e7eb',
|
||
borderRadius: 8, fontSize: 13.5, outline: 'none', fontFamily: 'inherit',
|
||
background: 'white',
|
||
}
|
||
|
||
return (
|
||
<div style={{
|
||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
zIndex: 50, padding: 20,
|
||
}}>
|
||
<div style={{
|
||
background: 'white', borderRadius: 16, width: '100%', maxWidth: 520,
|
||
boxShadow: '0 20px 60px rgba(0,0,0,0.18)', display: 'flex', flexDirection: 'column',
|
||
}}>
|
||
<div style={{ padding: '18px 22px 14px', borderBottom: '1px solid #f0f0ef', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<h2 style={{ margin: 0, fontSize: 16, fontWeight: 700 }}>{isNew ? 'Νέα Επαφή' : 'Επεξεργασία Επαφής'}</h2>
|
||
<button onClick={onClose} style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', color: '#9ca3af' }}>✕</button>
|
||
</div>
|
||
|
||
<div style={{ padding: '18px 22px', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||
<div>
|
||
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Όνομα *</label>
|
||
<input style={inputStyle} value={form.name} onChange={e => f('name', e.target.value)} autoFocus placeholder="π.χ. Νίκος Γαλακτοπωλείο" />
|
||
</div>
|
||
<div>
|
||
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Τύπος</label>
|
||
<select style={inputStyle} value={form.type} onChange={e => f('type', e.target.value)}>
|
||
{Object.entries(TYPE_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||
</select>
|
||
</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||
<div>
|
||
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Τηλέφωνο</label>
|
||
<input style={inputStyle} value={form.phone} onChange={e => f('phone', e.target.value)} placeholder="π.χ. 6912345678" />
|
||
</div>
|
||
<div>
|
||
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Email</label>
|
||
<input style={inputStyle} type="email" value={form.email} onChange={e => f('email', e.target.value)} placeholder="info@example.com" />
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Διεύθυνση</label>
|
||
<input style={inputStyle} value={form.address} onChange={e => f('address', e.target.value)} placeholder="Οδός, Πόλη" />
|
||
</div>
|
||
<div>
|
||
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Σημειώσεις</label>
|
||
<textarea style={{ ...inputStyle, resize: 'none' }} rows={3} value={form.notes} onChange={e => f('notes', e.target.value)} placeholder="Εσωτερικές σημειώσεις…" />
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ padding: '14px 22px', borderTop: '1px solid #f0f0ef', display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
|
||
<button onClick={onClose} style={{ padding: '8px 18px', border: '1px solid #e5e7eb', background: 'white', borderRadius: 8, fontSize: 13, cursor: 'pointer' }}>Ακύρωση</button>
|
||
<button
|
||
onClick={() => onSave(form)}
|
||
disabled={!canSave || isPending}
|
||
style={{
|
||
padding: '8px 20px', border: 'none', borderRadius: 8, fontSize: 13, fontWeight: 600,
|
||
cursor: canSave && !isPending ? 'pointer' : 'default',
|
||
background: canSave ? '#111827' : '#e5e7eb',
|
||
color: canSave ? 'white' : '#9ca3af',
|
||
}}
|
||
>
|
||
{isPending ? 'Αποθήκευση…' : isNew ? 'Δημιουργία' : 'Αποθήκευση'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function ContactsPage() {
|
||
const qc = useQueryClient()
|
||
const [modal, setModal] = useState(null) // null | { contact } | { contact: null } for new
|
||
|
||
const { data: contacts = [], isLoading } = useQuery({
|
||
queryKey: ['contacts'],
|
||
queryFn: () => client.get('/api/contacts/').then(r => r.data),
|
||
staleTime: 30_000,
|
||
})
|
||
|
||
const save = useMutation({
|
||
mutationFn: ({ form, id }) => id
|
||
? client.put(`/api/contacts/${id}`, form)
|
||
: client.post('/api/contacts/', form),
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ['contacts'] })
|
||
setModal(null)
|
||
toast.success('Αποθηκεύτηκε')
|
||
},
|
||
onError: () => toast.error('Σφάλμα αποθήκευσης'),
|
||
})
|
||
|
||
const deactivate = useMutation({
|
||
mutationFn: (id) => client.delete(`/api/contacts/${id}`),
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ['contacts'] })
|
||
toast.success('Η επαφή αποενεργοποιήθηκε')
|
||
},
|
||
onError: () => toast.error('Σφάλμα'),
|
||
})
|
||
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||
{/* Header */}
|
||
<div style={{ padding: '18px 28px 14px', borderBottom: '1px solid #f0f0ef', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0, background: 'white' }}>
|
||
<div>
|
||
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 800, color: '#111315' }}>Επαφές / Προμηθευτές</h1>
|
||
<p style={{ margin: '2px 0 0', fontSize: 13, color: '#9ca3af' }}>{contacts.length} ενεργές επαφές</p>
|
||
</div>
|
||
<button
|
||
onClick={() => setModal({ contact: null })}
|
||
style={{ padding: '9px 18px', borderRadius: 9, border: 'none', background: '#111827', color: 'white', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}
|
||
>
|
||
+ Νέα Επαφή
|
||
</button>
|
||
</div>
|
||
|
||
{/* Table */}
|
||
<div style={{ flex: 1, overflowY: 'auto', padding: '20px 28px' }}>
|
||
{isLoading && <div style={{ textAlign: 'center', color: '#9ca3af', padding: '48px 0' }}>Φόρτωση…</div>}
|
||
{!isLoading && contacts.length === 0 && (
|
||
<div style={{ textAlign: 'center', color: '#9ca3af', padding: '48px 0', fontSize: 14 }}>
|
||
Δεν υπάρχουν επαφές ακόμα. Προσθέστε τον πρώτο προμηθευτή.
|
||
</div>
|
||
)}
|
||
{contacts.length > 0 && (
|
||
<div style={{ border: '1px solid #e5e7eb', borderRadius: 12, overflow: 'hidden' }}>
|
||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13.5 }}>
|
||
<thead>
|
||
<tr style={{ background: '#f9fafb', borderBottom: '1px solid #e5e7eb' }}>
|
||
{['Όνομα', 'Τύπος', 'Τηλέφωνο', 'Email', ''].map(h => (
|
||
<th key={h} style={{ padding: '10px 16px', textAlign: 'left', fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em', whiteSpace: 'nowrap' }}>{h}</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{contacts.map((c, i) => {
|
||
const tc = TYPE_COLORS[c.type] || TYPE_COLORS.other
|
||
return (
|
||
<tr key={c.id} style={{ borderBottom: i < contacts.length - 1 ? '1px solid #f3f4f6' : 'none', background: 'white' }}>
|
||
<td style={{ padding: '12px 16px', fontWeight: 600, color: '#111315' }}>
|
||
{c.name}
|
||
{c.notes && <div style={{ fontSize: 11.5, color: '#9ca3af', fontWeight: 400, marginTop: 1 }}>{c.notes.slice(0, 60)}{c.notes.length > 60 ? '…' : ''}</div>}
|
||
</td>
|
||
<td style={{ padding: '12px 16px' }}>
|
||
<span style={{ fontSize: 11.5, fontWeight: 700, padding: '2px 8px', borderRadius: 99, background: tc.bg, color: tc.color }}>
|
||
{TYPE_LABELS[c.type] || c.type}
|
||
</span>
|
||
</td>
|
||
<td style={{ padding: '12px 16px', color: c.phone ? '#374151' : '#d1d5db' }}>{c.phone || '—'}</td>
|
||
<td style={{ padding: '12px 16px', color: c.email ? '#374151' : '#d1d5db' }}>{c.email || '—'}</td>
|
||
<td style={{ padding: '12px 16px', textAlign: 'right' }}>
|
||
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
|
||
<button
|
||
onClick={() => setModal({ contact: c })}
|
||
style={{ padding: '4px 12px', borderRadius: 6, border: '1px solid #e5e7eb', background: 'white', fontSize: 12, fontWeight: 500, cursor: 'pointer', color: '#374151' }}
|
||
>
|
||
Επεξεργασία
|
||
</button>
|
||
<button
|
||
onClick={() => { if (window.confirm(`Αποενεργοποίηση "${c.name}";`)) deactivate.mutate(c.id) }}
|
||
style={{ padding: '4px 10px', borderRadius: 6, border: '1px solid #fecaca', background: 'white', fontSize: 12, cursor: 'pointer', color: '#ef4444' }}
|
||
>
|
||
Απενεργ.
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{modal && (
|
||
<ContactModal
|
||
initial={modal.contact ? { ...modal.contact, phone: modal.contact.phone || '', email: modal.contact.email || '', address: modal.contact.address || '', notes: modal.contact.notes || '' } : null}
|
||
onClose={() => setModal(null)}
|
||
isPending={save.isPending}
|
||
onSave={(form) => save.mutate({
|
||
form: { name: form.name, type: form.type, phone: form.phone || null, email: form.email || null, address: form.address || null, notes: form.notes || null },
|
||
id: modal.contact?.id,
|
||
})}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|