@@ -0,0 +1,342 @@
import { useState } from 'react'
import { useQuery , useMutation , useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import client from '../api/client'
// ── Helpers ───────────────────────────────────────────────────────────────────
function fmt ( n ) {
if ( n == null ) return '—'
return n . toLocaleString ( 'el-GR' , { minimumFractionDigits : 2 , maximumFractionDigits : 2 } ) + ' €'
}
function fmtDateTime ( iso ) {
if ( ! iso ) return '—'
return new Date ( iso ) . toLocaleString ( 'el-GR' , { day : '2-digit' , month : '2-digit' , hour : '2-digit' , minute : '2-digit' } )
}
function daysSince ( iso ) {
if ( ! iso ) return null
const days = Math . floor ( ( Date . now ( ) - new Date ( iso ) . getTime ( ) ) / 86400000 )
if ( days === 0 ) return 'Σήμερα'
if ( days === 1 ) return '1 μέρα'
return ` ${ days } μέρες `
}
const STATUS _CONFIG = {
open : { label : 'Ανοιχτή' , bg : '#fee2e2' , color : '#dc2626' } ,
closed : { label : 'Κλειστή' , bg : '#dcfce7' , color : '#16a34a' } ,
forgiven : { label : 'Χαρίστηκε' , bg : '#f3f4f6' , color : '#6b7280' } ,
}
// ── Pay modal ─────────────────────────────────────────────────────────────────
function PayModal ( { tab , onClose , onPay , isPending } ) {
const [ amount , setAmount ] = useState ( String ( Math . round ( tab . balance * 100 ) / 100 ) )
const [ method , setMethod ] = useState ( 'cash' )
const [ notes , setNotes ] = useState ( '' )
const parsed = parseFloat ( amount )
const canPay = parsed > 0 && parsed <= tab . balance + 0.005
return (
< div style = { { position : 'fixed' , inset : 0 , background : 'rgba(0,0,0,0.45)' , display : 'flex' , alignItems : 'center' , justifyContent : 'center' , zIndex : 60 , padding : 20 } } >
< div style = { { background : 'white' , borderRadius : 16 , width : '100%' , maxWidth : 400 , boxShadow : '0 20px 60px rgba(0,0,0,0.18)' } } >
< div style = { { padding : '18px 22px 14px' , borderBottom : '1px solid #f0f0ef' } } >
< div style = { { fontSize : 16 , fontWeight : 700 } } > Πληρωμή Καρτέλας < / div >
< div style = { { fontSize : 13 , color : '#6b7280' , marginTop : 2 } } > { tab . customer _name } < / div >
< / div >
< div style = { { padding : '18px 22px' , display : 'flex' , flexDirection : 'column' , gap : 14 } } >
< div style = { { background : '#f9fafb' , borderRadius : 10 , padding : '12px 14px' , display : 'flex' , justifyContent : 'space-between' } } >
< span style = { { fontSize : 13 , color : '#6b7280' } } > Υπόλοιπο καρτέλας < / span >
< span style = { { fontSize : 15 , fontWeight : 700 , color : '#dc2626' } } > { fmt ( tab . balance ) } < / span >
< / div >
< div >
< label style = { { fontSize : 12 , fontWeight : 600 , color : '#374151' , display : 'block' , marginBottom : 5 } } > Ποσό πληρωμής ( € ) < / label >
< input type = "number" step = "0.01" min = "0.01"
value = { amount } onChange = { e => setAmount ( e . target . value ) } autoFocus
style = { { width : '100%' , padding : '9px 12px' , border : '1.5px solid #e5e7eb' , borderRadius : 8 , fontSize : 16 , fontWeight : 600 , outline : 'none' , boxSizing : 'border-box' } } / >
< / div >
< div >
< label style = { { fontSize : 12 , fontWeight : 600 , color : '#374151' , display : 'block' , marginBottom : 5 } } > Τρόπος πληρωμής < / label >
< div style = { { display : 'flex' , gap : 6 } } >
{ [ [ 'cash' , 'Μετρητά' ] , [ 'card' , 'Κάρτα' ] , [ 'other' , 'Άλλο' ] ] . map ( ( [ v , l ] ) => (
< button key = { v } onClick = { ( ) => setMethod ( v ) }
style = { {
flex : 1 , padding : '7px' , borderRadius : 7 , fontSize : 12.5 , fontWeight : 600 , cursor : 'pointer' ,
border : ` 1.5px solid ${ method === v ? '#111827' : '#e5e7eb' } ` ,
background : method === v ? '#111827' : 'white' ,
color : method === v ? 'white' : '#6b7280' ,
} } > { l } < / button >
) ) }
< / div >
< / div >
< div >
< label style = { { fontSize : 12 , fontWeight : 600 , color : '#374151' , display : 'block' , marginBottom : 5 } } > Σημείωση < / label >
< input value = { notes } onChange = { e => setNotes ( e . target . value ) } placeholder = "προαιρετικό"
style = { { width : '100%' , padding : '8px 11px' , border : '1px solid #e5e7eb' , borderRadius : 8 , fontSize : 13 , outline : 'none' , boxSizing : 'border-box' } } / >
< / div >
< / div >
< div style = { { padding : '14px 22px' , borderTop : '1px solid #f0f0ef' , display : 'flex' , gap : 10 , justifyContent : 'flex-end' } } >
< button onClick = { onClose } style = { { padding : '8px 18px' , border : '1px solid #e5e7eb' , background : 'white' , borderRadius : 8 , fontSize : 13 , cursor : 'pointer' } } > Ακύρωση < / button >
< button onClick = { ( ) => onPay ( parsed , method , notes || null ) } disabled = { ! canPay || isPending }
style = { { padding : '8px 20px' , border : 'none' , borderRadius : 8 , fontSize : 13 , fontWeight : 600 , cursor : canPay ? 'pointer' : 'default' , background : canPay ? '#16a34a' : '#e5e7eb' , color : canPay ? 'white' : '#9ca3af' } } >
{ isPending ? 'Αποθήκευση…' : 'Καταγραφή Πληρωμής' }
< / button >
< / div >
< / div >
< / div >
)
}
// ── Forgive confirm ───────────────────────────────────────────────────────────
function ForgiveModal ( { tab , onClose , onForgive , isPending } ) {
const [ reason , setReason ] = useState ( '' )
return (
< div style = { { position : 'fixed' , inset : 0 , background : 'rgba(0,0,0,0.45)' , display : 'flex' , alignItems : 'center' , justifyContent : 'center' , zIndex : 60 , padding : 20 } } >
< div style = { { background : 'white' , borderRadius : 16 , width : '100%' , maxWidth : 400 , boxShadow : '0 20px 60px rgba(0,0,0,0.18)' } } >
< div style = { { padding : '18px 22px 14px' , borderBottom : '1px solid #f0f0ef' } } >
< div style = { { fontSize : 16 , fontWeight : 700 , color : '#dc2626' } } > Χάρισμα Καρτέλας < / div >
< div style = { { fontSize : 13 , color : '#6b7280' , marginTop : 2 } } > { tab . customer _name } — υπόλοιπο { fmt ( tab . balance ) } < / div >
< / div >
< div style = { { padding : '18px 22px' , display : 'flex' , flexDirection : 'column' , gap : 12 } } >
< div style = { { padding : '12px 14px' , background : '#fef2f2' , borderRadius : 10 , border : '1px solid #fecaca' , fontSize : 13 , color : '#7f1d1d' , lineHeight : 1.5 } } >
Το υπόλοιπο των { fmt ( tab . balance ) } θα διαγραφεί . Η ενέργεια δεν αναιρείται .
< / div >
< div >
< label style = { { fontSize : 12 , fontWeight : 600 , color : '#374151' , display : 'block' , marginBottom : 5 } } > Αιτιολογία < / label >
< input value = { reason } onChange = { e => setReason ( e . target . value ) } placeholder = "π.χ. Καλή θέληση"
style = { { width : '100%' , padding : '8px 11px' , border : '1px solid #e5e7eb' , borderRadius : 8 , fontSize : 13 , outline : 'none' , boxSizing : 'border-box' } } / >
< / div >
< / div >
< div style = { { padding : '14px 22px' , borderTop : '1px solid #f0f0ef' , display : 'flex' , gap : 10 , justifyContent : 'flex-end' } } >
< button onClick = { onClose } style = { { padding : '8px 18px' , border : '1px solid #e5e7eb' , background : 'white' , borderRadius : 8 , fontSize : 13 , cursor : 'pointer' } } > Ακύρωση < / button >
< button onClick = { ( ) => onForgive ( reason || null ) } disabled = { isPending }
style = { { padding : '8px 20px' , border : 'none' , borderRadius : 8 , fontSize : 13 , fontWeight : 600 , cursor : 'pointer' , background : '#dc2626' , color : 'white' } } >
{ isPending ? 'Αποθήκευση…' : 'Χάρισμα Υπολοίπου' }
< / button >
< / div >
< / div >
< / div >
)
}
// ── Tab detail card ───────────────────────────────────────────────────────────
function TabDetail ( { tab , onPay , onClose , onForgive } ) {
const sc = STATUS _CONFIG [ tab . status ] || STATUS _CONFIG . open
const canClose = tab . status === 'open' && tab . balance <= 0.005 && tab . entries . length > 0
return (
< div style = { { border : '1px solid #e5e7eb' , borderRadius : 12 , overflow : 'hidden' , background : 'white' } } >
{ /* Header */ }
< div style = { { padding : '14px 18px' , background : '#f9fafb' , borderBottom : '1px solid #e5e7eb' , display : 'flex' , alignItems : 'center' , justifyContent : 'space-between' , flexWrap : 'wrap' , gap : 8 } } >
< div >
< div style = { { fontSize : 15 , fontWeight : 700 , color : '#111315' } } > { tab . customer _name } < / div >
< div style = { { fontSize : 12 , color : '#9ca3af' , marginTop : 1 } } >
Άνοιγμα : { fmtDateTime ( tab . opened _at ) } · { daysSince ( tab . opened _at ) } ανοιχτή
< / div >
< / div >
< div style = { { display : 'flex' , alignItems : 'center' , gap : 8 } } >
< span style = { { fontSize : 22 , fontWeight : 800 , color : tab . balance > 0 ? '#dc2626' : '#16a34a' } } >
{ fmt ( tab . balance ) }
< / span >
< span style = { { fontSize : 11.5 , fontWeight : 700 , padding : '2px 8px' , borderRadius : 99 , background : sc . bg , color : sc . color } } > { sc . label } < / span >
< / div >
< / div >
{ /* Stats */ }
< div style = { { display : 'grid' , gridTemplateColumns : 'repeat(3,1fr)' , gap : 1 , background : '#f0f0ef' } } >
{ [
{ label : 'Συνολικές χρεώσεις' , value : fmt ( tab . total _charged ) } ,
{ label : 'Πληρωμένο' , value : fmt ( tab . total _paid ) , color : '#16a34a' } ,
{ label : 'Υπόλοιπο' , value : fmt ( tab . balance ) , color : tab . balance > 0 ? '#dc2626' : '#16a34a' } ,
] . map ( s => (
< div key = { s . label } style = { { background : 'white' , padding : '10px 14px' , textAlign : 'center' } } >
< div style = { { fontSize : 10 , color : '#9ca3af' , fontWeight : 700 , textTransform : 'uppercase' , letterSpacing : '0.04em' } } > { s . label } < / div >
< div style = { { fontSize : 15 , fontWeight : 800 , color : s . color || '#111315' , marginTop : 2 } } > { s . value } < / div >
< / div >
) ) }
< / div >
{ /* Entries */ }
{ tab . entries . length > 0 && (
< div style = { { padding : '12px 18px' , borderBottom : '1px solid #f0f0ef' } } >
< div style = { { fontSize : 11 , fontWeight : 700 , color : '#9ca3af' , textTransform : 'uppercase' , letterSpacing : '0.05em' , marginBottom : 8 } } > Χρεώσεις < / div >
< div style = { { display : 'flex' , flexDirection : 'column' , gap : 4 } } >
{ tab . entries . map ( e => (
< div key = { e . id } style = { { display : 'flex' , justifyContent : 'space-between' , alignItems : 'center' , fontSize : 13 } } >
< span style = { { color : '#374151' } } > { e . description || ` Χρέωση # ${ e . id } ` } < / span >
< span style = { { fontWeight : 600 , color : '#dc2626' , whiteSpace : 'nowrap' , marginLeft : 12 } } > { fmt ( e . amount ) } < / span >
< / div >
) ) }
< / div >
< / div >
) }
{ /* Payments */ }
{ tab . payments . length > 0 && (
< div style = { { padding : '12px 18px' , borderBottom : '1px solid #f0f0ef' } } >
< div style = { { fontSize : 11 , fontWeight : 700 , color : '#9ca3af' , textTransform : 'uppercase' , letterSpacing : '0.05em' , marginBottom : 8 } } > Πληρωμές < / div >
< div style = { { display : 'flex' , flexDirection : 'column' , gap : 4 } } >
{ tab . payments . map ( p => (
< div key = { p . id } style = { { display : 'flex' , justifyContent : 'space-between' , alignItems : 'center' , fontSize : 13 } } >
< span style = { { color : '#374151' } } >
{ p . received _by _name } · { fmtDateTime ( p . created _at ) }
{ p . payment _method && < span style = { { color : '#9ca3af' , marginLeft : 6 } } > ( { p . payment _method } ) < / span > }
{ p . notes && < span style = { { color : '#9ca3af' , marginLeft : 6 } } > — { p . notes } < / span > }
< / span >
< span style = { { fontWeight : 600 , color : '#16a34a' , whiteSpace : 'nowrap' , marginLeft : 12 } } > { fmt ( p . amount ) } < / span >
< / div >
) ) }
< / div >
< / div >
) }
{ /* Actions */ }
{ tab . status === 'open' && (
< div style = { { padding : '12px 18px' , display : 'flex' , gap : 8 , flexWrap : 'wrap' } } >
{ tab . balance > 0.005 && (
< button onClick = { ( ) => onPay ( tab ) }
style = { { padding : '7px 16px' , borderRadius : 8 , border : 'none' , background : '#16a34a' , color : 'white' , fontSize : 13 , fontWeight : 600 , cursor : 'pointer' } } >
Πληρωμή
< / button >
) }
{ canClose && (
< button onClick = { ( ) => onClose ( tab ) }
style = { { padding : '7px 16px' , borderRadius : 8 , border : '1px solid #d1d5db' , background : 'white' , color : '#374151' , fontSize : 13 , fontWeight : 600 , cursor : 'pointer' } } >
Κλείσιμο Καρτέλας
< / button >
) }
< button onClick = { ( ) => onForgive ( tab ) }
style = { { padding : '7px 16px' , borderRadius : 8 , border : '1px solid #fecaca' , background : 'white' , color : '#dc2626' , fontSize : 13 , fontWeight : 500 , cursor : 'pointer' , marginLeft : 'auto' } } >
Χάρισμα Υπολοίπου
< / button >
< / div >
) }
< / div >
)
}
// ── Main page ─────────────────────────────────────────────────────────────────
export default function TabsPage ( ) {
const qc = useQueryClient ( )
const [ showAll , setShowAll ] = useState ( false )
const [ modal , setModal ] = useState ( null ) // { type: 'pay'|'forgive', tab }
const { data : tabs = [ ] , isLoading } = useQuery ( {
queryKey : [ 'tabs' , showAll ] ,
queryFn : ( ) => client . get ( '/api/tabs/' , { params : showAll ? { tab _status : 'all' } : { } } ) . then ( r => r . data ) ,
staleTime : 15 _000 ,
} )
// Refetch tabs with all statuses when toggled
const { data : allTabs = [ ] } = useQuery ( {
queryKey : [ 'tabs-all' ] ,
queryFn : ( ) => client . get ( '/api/tabs/' , { params : { tab _status : 'closed' } } ) . then ( r => r . data ) ,
staleTime : 30 _000 ,
enabled : showAll ,
} )
const payTab = useMutation ( {
mutationFn : ( { id , amount , payment _method , notes } ) =>
client . post ( ` /api/tabs/ ${ id } /pay ` , { amount , payment _method , notes } ) ,
onSuccess : ( ) => {
qc . invalidateQueries ( { queryKey : [ 'tabs' ] } )
qc . invalidateQueries ( { queryKey : [ 'tabs-all' ] } )
setModal ( null )
toast . success ( 'Πληρωμή καταγράφηκε' )
} ,
onError : ( e ) => toast . error ( e ? . response ? . data ? . detail || 'Σφάλμα' ) ,
} )
const closeTab = useMutation ( {
mutationFn : ( id ) => client . post ( ` /api/tabs/ ${ id } /close ` , { } ) ,
onSuccess : ( ) => {
qc . invalidateQueries ( { queryKey : [ 'tabs' ] } )
toast . success ( 'Καρτέλα έκλεισε' )
} ,
onError : ( e ) => toast . error ( e ? . response ? . data ? . detail || 'Σφάλμα' ) ,
} )
const forgiveTab = useMutation ( {
mutationFn : ( { id , reason } ) => client . post ( ` /api/tabs/ ${ id } /forgive ` , { reason } ) ,
onSuccess : ( ) => {
qc . invalidateQueries ( { queryKey : [ 'tabs' ] } )
setModal ( null )
toast . success ( 'Το υπόλοιπο χαρίστηκε' )
} ,
onError : ( ) => toast . error ( 'Σφάλμα' ) ,
} )
const openTabs = tabs . filter ( t => t . status === 'open' )
const totalOutstanding = openTabs . reduce ( ( s , t ) => s + t . balance , 0 )
return (
< div style = { { display : 'flex' , flexDirection : 'column' , height : '100%' , overflow : 'hidden' } } >
{ /* Header */ }
< div style = { { padding : '18px 28px 14px' , borderBottom : '1px solid #f0f0ef' , flexShrink : 0 , background : 'white' , display : 'flex' , alignItems : 'center' , justifyContent : 'space-between' } } >
< div >
< h1 style = { { margin : 0 , fontSize : 20 , fontWeight : 800 , color : '#111315' } } > Καρτέλες < / h1 >
< p style = { { margin : '2px 0 0' , fontSize : 13 , color : '#9ca3af' } } >
{ openTabs . length } ανοιχτές καρτέλες · Σύνολο οφειλόμενων : { ' ' }
< strong style = { { color : totalOutstanding > 0 ? '#dc2626' : '#16a34a' } } > { fmt ( totalOutstanding ) } < / strong >
< / p >
< / div >
< button
onClick = { ( ) => setShowAll ( s => ! s ) }
style = { { padding : '7px 14px' , borderRadius : 8 , border : '1px solid #e5e7eb' , background : 'white' , fontSize : 12.5 , cursor : 'pointer' , color : '#6b7280' } }
>
{ showAll ? 'Μόνο ανοιχτές' : 'Εμφάνιση κλειστών' }
< / button >
< / div >
{ /* Tab list */ }
< div style = { { flex : 1 , overflowY : 'auto' , padding : '16px 28px' , display : 'flex' , flexDirection : 'column' , gap : 12 } } >
{ isLoading && < div style = { { color : '#9ca3af' , fontSize : 13 , textAlign : 'center' , padding : '48px 0' } } > Φόρτωση … < / div > }
{ ! isLoading && openTabs . length === 0 && (
< div style = { { textAlign : 'center' , color : '#9ca3af' , fontSize : 14 , padding : '48px 0' } } >
Δεν υπάρχουν ανοιχτές καρτέλες .
< / div >
) }
{ openTabs . map ( tab => (
< TabDetail
key = { tab . id }
tab = { tab }
onPay = { t => setModal ( { type : 'pay' , tab : t } ) }
onClose = { t => closeTab . mutate ( t . id ) }
onForgive = { t => setModal ( { type : 'forgive' , tab : t } ) }
/ >
) ) }
{ showAll && allTabs . length > 0 && (
< >
< div style = { { fontSize : 11 , fontWeight : 700 , color : '#9ca3af' , textTransform : 'uppercase' , letterSpacing : '0.05em' , marginTop : 8 } } > Κλειστές / Χαρισμένες < / div >
{ allTabs . map ( tab => (
< TabDetail key = { tab . id } tab = { tab } onPay = { ( ) => { } } onClose = { ( ) => { } } onForgive = { ( ) => { } } / >
) ) }
< / >
) }
< / div >
{ modal ? . type === 'pay' && (
< PayModal
tab = { modal . tab }
onClose = { ( ) => setModal ( null ) }
isPending = { payTab . isPending }
onPay = { ( amount , payment _method , notes ) => payTab . mutate ( { id : modal . tab . id , amount , payment _method , notes } ) }
/ >
) }
{ modal ? . type === 'forgive' && (
< ForgiveModal
tab = { modal . tab }
onClose = { ( ) => setModal ( null ) }
isPending = { forgiveTab . isPending }
onForgive = { ( reason ) => forgiveTab . mutate ( { id : modal . tab . id , reason } ) }
/ >
) }
< / div >
)
}