268 lines
13 KiB
TypeScript
268 lines
13 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, Alert, Platform } from 'react-native';
|
|
import Animated, { FadeInUp, FadeInDown, SlideInDown, SlideOutDown } from 'react-native-reanimated';
|
|
import { useBarbearia } from '../../stores/BarbeariaContext';
|
|
import { useLanguage } from '../../stores/LanguageContext';
|
|
import { COLORS, SPACING, TYPOGRAPHY, BORDER_RADIUS, SHADOWS } from '../../constants/theme';
|
|
import { Button } from '../../components/ui/Button';
|
|
import { format, startOfToday, addDays } from 'date-fns';
|
|
import { ptBR, es } from 'date-fns/locale';
|
|
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Lock, CheckCircle2, X } from 'lucide-react-native';
|
|
|
|
const ALL_TIMES = [
|
|
'09:00', '09:30', '10:00', '10:30', '11:00', '11:30',
|
|
'13:30', '14:00', '14:30', '15:00', '15:30', '16:00', '16:30', '17:00', '17:30', '18:00'
|
|
];
|
|
|
|
interface SelectedSlot {
|
|
barberId: string;
|
|
time: string;
|
|
}
|
|
|
|
export default function AdminAgenda() {
|
|
const { barbearia, updateBlockedSlots, activeBarberId } = useBarbearia();
|
|
const { t, language } = useLanguage();
|
|
const [selectedDate, setSelectedDate] = useState(startOfToday());
|
|
const [selectedSlots, setSelectedSlots] = useState<SelectedSlot[]>([]);
|
|
|
|
const isOwner = !activeBarberId;
|
|
|
|
const appointments = barbearia?.appointments || [];
|
|
const barbers = barbearia?.barbers || [];
|
|
const blockedSlots = barbearia?.blockedSlots || [];
|
|
const themeColors = barbearia?.colors || COLORS;
|
|
const primaryColor = themeColors.primary;
|
|
|
|
const dateFormatted = format(selectedDate, 'dd/MM/yyyy');
|
|
const dateLocale = language === 'pt' ? ptBR : es;
|
|
|
|
const changeDate = (amount: number) => {
|
|
setSelectedDate(prev => addDays(prev, amount));
|
|
setSelectedSlots([]); // Clear selection when changing days
|
|
};
|
|
|
|
const handleSlotPress = (barberId: string, time: string, hasAppointment: boolean) => {
|
|
if (!isOwner && barberId !== activeBarberId) {
|
|
if (Platform.OS === 'web') window.alert('Você só pode alterar a sua própria agenda.');
|
|
else Alert.alert('Acesso Negado', 'Você só pode alterar a sua própria agenda.');
|
|
return;
|
|
}
|
|
|
|
if (hasAppointment) {
|
|
if (Platform.OS === 'web') window.alert('Horário ocupado por agendamento.');
|
|
else Alert.alert('Aviso', 'Este horário já possui um agendamento.');
|
|
return;
|
|
}
|
|
|
|
setSelectedSlots(prev => {
|
|
const exists = prev.find(s => s.barberId === barberId && s.time === time);
|
|
if (exists) {
|
|
return prev.filter(s => !(s.barberId === barberId && s.time === time));
|
|
} else {
|
|
return [...prev, { barberId, time }];
|
|
}
|
|
});
|
|
};
|
|
|
|
const applyAction = async (action: 'block' | 'unblock') => {
|
|
if (selectedSlots.length === 0) return;
|
|
|
|
const slotsToUpdate = selectedSlots.map(s => ({
|
|
barberId: s.barberId,
|
|
time: s.time,
|
|
date: dateFormatted
|
|
}));
|
|
|
|
await updateBlockedSlots(slotsToUpdate, action);
|
|
setSelectedSlots([]); // Limpa a seleção após aplicar
|
|
};
|
|
|
|
const isSlotSelected = (barberId: string, time: string) => {
|
|
return selectedSlots.some(s => s.barberId === barberId && s.time === time);
|
|
};
|
|
|
|
return (
|
|
<View style={[styles.container, { backgroundColor: themeColors.background }]}>
|
|
<Animated.View entering={FadeInUp.duration(600)} style={[styles.header, { backgroundColor: themeColors.surface }]}>
|
|
<Text style={[styles.title, { color: themeColors.text }]}>{t('admin.agenda.title')}</Text>
|
|
<View style={[styles.dateSelector, { backgroundColor: `${primaryColor}10` }]}>
|
|
<TouchableOpacity onPress={() => changeDate(-1)} style={styles.navBtn}>
|
|
<ChevronLeft color={primaryColor} size={24} />
|
|
</TouchableOpacity>
|
|
<View style={styles.dateInfo}>
|
|
<CalendarIcon size={18} color={primaryColor} />
|
|
<Text style={[styles.dateText, { color: themeColors.text }]}>
|
|
{format(selectedDate, "EEEE, dd 'de' MMMM", { locale: dateLocale })}
|
|
</Text>
|
|
</View>
|
|
<TouchableOpacity onPress={() => changeDate(1)} style={styles.navBtn}>
|
|
<ChevronRight color={primaryColor} size={24} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
</Animated.View>
|
|
|
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
<View style={styles.tableContainer}>
|
|
<View style={styles.tableHeader}>
|
|
<View style={[styles.cell, styles.timeColumn, { backgroundColor: themeColors.surfaceLight }]}>
|
|
<Text style={[styles.headerText, { color: primaryColor }]}>{t('admin.agenda.time')}</Text>
|
|
</View>
|
|
{barbers.map(barber => (
|
|
<View key={barber.id} style={[styles.cell, styles.barberColumn, { backgroundColor: themeColors.surface }]}>
|
|
<Text style={[styles.headerText, { color: primaryColor }]} numberOfLines={1}>{barber.nome}</Text>
|
|
</View>
|
|
))}
|
|
</View>
|
|
|
|
<ScrollView style={styles.tableBody} contentContainerStyle={{ paddingBottom: 280 }} showsVerticalScrollIndicator={false}>
|
|
{ALL_TIMES.map(time => (
|
|
<View key={time} style={styles.tableRow}>
|
|
<View style={[styles.cell, styles.timeColumn, { backgroundColor: themeColors.surfaceLight }]}>
|
|
<Text style={[styles.timeText, { color: themeColors.text }]}>{time}</Text>
|
|
</View>
|
|
|
|
{barbers.map(barber => {
|
|
const appointment = appointments.find(a =>
|
|
a.date === dateFormatted &&
|
|
a.time === time &&
|
|
a.barberId === barber.id &&
|
|
a.status !== 'rejected'
|
|
);
|
|
|
|
const isBlocked = blockedSlots.some(s => s.barberId === barber.id && s.date === dateFormatted && s.time === time);
|
|
const isSelected = isSlotSelected(barber.id, time);
|
|
|
|
return (
|
|
<TouchableOpacity
|
|
key={`${barber.id}-${time}`}
|
|
onPress={() => handleSlotPress(barber.id, time, !!appointment)}
|
|
activeOpacity={0.7}
|
|
style={[
|
|
styles.cell,
|
|
styles.barberColumn,
|
|
{ borderColor: themeColors.divider },
|
|
appointment
|
|
? (appointment.status === 'accepted' ? styles.busyCell : styles.pendingCell)
|
|
: (isBlocked ? styles.blockedCell : styles.freeCell),
|
|
isSelected && { borderColor: primaryColor, borderWidth: 2, backgroundColor: `${primaryColor}20` }
|
|
]}
|
|
>
|
|
{isSelected && !appointment && (
|
|
<View style={[styles.selectedOverlay, { backgroundColor: `${primaryColor}15` }]}>
|
|
<CheckCircle2 size={24} color={primaryColor} />
|
|
</View>
|
|
)}
|
|
|
|
{appointment ? (
|
|
<Text style={[styles.appointmentText, { color: themeColors.text }]} numberOfLines={1}>
|
|
{appointment.clientName}
|
|
</Text>
|
|
) : isBlocked ? (
|
|
<View style={{flexDirection: 'row', alignItems: 'center', gap: 4}}>
|
|
<Lock size={12} color={themeColors.textMuted} />
|
|
<Text style={[styles.freeText, { color: themeColors.textMuted }]}>Bloqueado</Text>
|
|
</View>
|
|
) : (
|
|
<Text style={[styles.freeText, { color: themeColors.textMuted }]}>{t('admin.agenda.free')}</Text>
|
|
)}
|
|
</TouchableOpacity>
|
|
);
|
|
})}
|
|
</View>
|
|
))}
|
|
</ScrollView>
|
|
</View>
|
|
</ScrollView>
|
|
|
|
{/* Action Bar (Multi-Select) */}
|
|
{selectedSlots.length > 0 && (
|
|
<Animated.View entering={SlideInDown} exiting={SlideOutDown} style={[styles.actionBar, { backgroundColor: themeColors.surface, borderTopColor: primaryColor }]}>
|
|
<View style={styles.actionBarInfo}>
|
|
<Text style={[styles.actionBarText, { color: themeColors.text }]}>
|
|
{`${selectedSlots.length} horário${selectedSlots.length > 1 ? 's' : ''} selecionado${selectedSlots.length > 1 ? 's' : ''}`}
|
|
</Text>
|
|
<TouchableOpacity onPress={() => setSelectedSlots([])} style={styles.clearBtn}>
|
|
<X size={20} color={themeColors.textMuted} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
<View style={styles.actionButtons}>
|
|
<Button
|
|
title="Liberar"
|
|
variant="outline"
|
|
onPress={() => applyAction('unblock')}
|
|
style={{ flex: 1, borderColor: primaryColor }}
|
|
textStyle={{ color: primaryColor }}
|
|
/>
|
|
<Button
|
|
title="Bloquear"
|
|
onPress={() => applyAction('block')}
|
|
style={{ flex: 1, backgroundColor: primaryColor }}
|
|
textStyle={{ color: themeColors.background }}
|
|
/>
|
|
</View>
|
|
</Animated.View>
|
|
)}
|
|
|
|
{/* Legenda (Oculta se houver seleção para dar espaço à Action Bar) */}
|
|
{selectedSlots.length === 0 && (
|
|
<Animated.View entering={FadeInDown} style={[styles.legend, { backgroundColor: themeColors.surface }]}>
|
|
<View style={styles.legendItem}>
|
|
<View style={[styles.dot, { backgroundColor: COLORS.success }]} />
|
|
<Text style={[styles.legendText, { color: themeColors.textMuted }]}>{t('admin.agenda.confirmed')}</Text>
|
|
</View>
|
|
<View style={styles.legendItem}>
|
|
<View style={[styles.dot, { backgroundColor: '#EAB308' }]} />
|
|
<Text style={[styles.legendText, { color: themeColors.textMuted }]}>{t('admin.dashboard.pending_badge') || 'Pendente'}</Text>
|
|
</View>
|
|
<View style={styles.legendItem}>
|
|
<View style={[styles.dot, { backgroundColor: COLORS.secondary }]} />
|
|
<Text style={[styles.legendText, { color: themeColors.textMuted }]}>Bloqueado</Text>
|
|
</View>
|
|
</Animated.View>
|
|
)}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: { flex: 1, backgroundColor: COLORS.background },
|
|
header: {
|
|
padding: SPACING.xl,
|
|
paddingTop: 60,
|
|
backgroundColor: COLORS.surface,
|
|
borderBottomLeftRadius: BORDER_RADIUS.xl,
|
|
borderBottomRightRadius: BORDER_RADIUS.xl,
|
|
...(SHADOWS.medium as any),
|
|
zIndex: 10,
|
|
},
|
|
title: { ...TYPOGRAPHY.h2, color: COLORS.text, marginBottom: SPACING.lg },
|
|
dateSelector: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', backgroundColor: `${COLORS.primary}10`, padding: SPACING.md, borderRadius: BORDER_RADIUS.md },
|
|
dateInfo: { flexDirection: 'row', alignItems: 'center', gap: 10 },
|
|
dateText: { color: COLORS.text, ...TYPOGRAPHY.bodySmall, fontWeight: '700', textTransform: 'capitalize' },
|
|
navBtn: { padding: 5 },
|
|
tableContainer: { padding: SPACING.lg },
|
|
tableHeader: { flexDirection: 'row', marginBottom: SPACING.xs },
|
|
tableRow: { flexDirection: 'row', marginBottom: SPACING.xs },
|
|
cell: { padding: 12, justifyContent: 'center', alignItems: 'center', borderRadius: BORDER_RADIUS.sm, marginRight: SPACING.xs },
|
|
timeColumn: { width: 70, backgroundColor: COLORS.surfaceLight },
|
|
barberColumn: { width: 140, backgroundColor: COLORS.surface, position: 'relative', overflow: 'hidden' },
|
|
headerText: { color: COLORS.primary, ...TYPOGRAPHY.caption, fontWeight: '800' },
|
|
timeText: { color: COLORS.text, ...TYPOGRAPHY.bodySmall, fontWeight: '700' },
|
|
freeCell: { backgroundColor: 'rgba(255,255,255,0.02)', borderStyle: 'dashed', borderWidth: 1, borderColor: COLORS.divider },
|
|
blockedCell: { backgroundColor: 'rgba(255, 255, 255, 0.05)', borderWidth: 1, borderColor: COLORS.divider, borderStyle: 'dashed' },
|
|
busyCell: { backgroundColor: `${COLORS.success}20`, borderWidth: 1, borderColor: `${COLORS.success}40` },
|
|
pendingCell: { backgroundColor: 'rgba(234, 179, 8, 0.2)', borderWidth: 1, borderColor: 'rgba(234, 179, 8, 0.4)' },
|
|
appointmentText: { color: COLORS.text, ...TYPOGRAPHY.caption, fontWeight: 'bold' },
|
|
freeText: { color: COLORS.textMuted, fontSize: 10 },
|
|
selectedOverlay: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, justifyContent: 'center', alignItems: 'center', zIndex: 5 },
|
|
tableBody: { height: 500 },
|
|
actionBar: { position: 'absolute', bottom: 0, left: 0, right: 0, padding: SPACING.xl, paddingBottom: SPACING.xxl, borderTopWidth: 4, borderTopLeftRadius: BORDER_RADIUS.xl, borderTopRightRadius: BORDER_RADIUS.xl, ...(SHADOWS.large as any) },
|
|
actionBarInfo: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: SPACING.lg },
|
|
actionBarText: { ...TYPOGRAPHY.h4 },
|
|
clearBtn: { padding: SPACING.sm, backgroundColor: 'rgba(255,255,255,0.05)', borderRadius: BORDER_RADIUS.full },
|
|
actionButtons: { flexDirection: 'row', gap: SPACING.md },
|
|
legend: { flexDirection: 'row', padding: SPACING.xl, gap: 20, justifyContent: 'center', backgroundColor: COLORS.surface, borderTopLeftRadius: BORDER_RADIUS.xl, borderTopRightRadius: BORDER_RADIUS.xl },
|
|
legendItem: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
|
dot: { width: 12, height: 12, borderRadius: 6 },
|
|
legendText: { color: COLORS.textMuted, ...TYPOGRAPHY.caption, fontWeight: '600' }
|
|
}); |