376 lines
13 KiB
TypeScript
376 lines
13 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/datetimepicker';
|
|
import {
|
|
KeyboardAvoidingView,
|
|
Modal,
|
|
Platform,
|
|
Pressable,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Text,
|
|
TextInput,
|
|
View,
|
|
} from 'react-native';
|
|
|
|
import { CalendarEvent, createEvent, Project } from '@/src/lib/api';
|
|
import { useAuth } from '@/src/providers/auth-provider';
|
|
|
|
const PRIMARY = '#69c350';
|
|
|
|
type EventCreateModalProps = {
|
|
visible: boolean;
|
|
projects?: Project[];
|
|
initialProjectId?: number | null;
|
|
initialDate?: Date | null;
|
|
onClose: () => void;
|
|
onCreated: (event: CalendarEvent) => void | Promise<void>;
|
|
};
|
|
|
|
function pad(value: number): string {
|
|
return String(value).padStart(2, '0');
|
|
}
|
|
|
|
function formatDate(date: Date): string {
|
|
return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${date.getFullYear()}`;
|
|
}
|
|
|
|
function formatTime(date: Date): string {
|
|
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
}
|
|
|
|
function createDefaults(initialDate?: Date | null) {
|
|
const start = initialDate ? new Date(initialDate) : new Date();
|
|
if (!initialDate) {
|
|
start.setMinutes(Math.ceil(start.getMinutes() / 15) * 15, 0, 0);
|
|
} else {
|
|
start.setHours(9, 0, 0, 0);
|
|
}
|
|
const end = new Date(start.getTime() + 60 * 60 * 1000);
|
|
return { start, end };
|
|
}
|
|
|
|
function relationId(value: unknown): number | null {
|
|
if (!value) return null;
|
|
if (typeof value === 'object') {
|
|
const id = (value as { id?: number | string }).id;
|
|
return id ? Number(id) : null;
|
|
}
|
|
const id = Number(value);
|
|
return Number.isFinite(id) ? id : null;
|
|
}
|
|
|
|
export function EventCreateModal({
|
|
visible,
|
|
projects = [],
|
|
initialProjectId = null,
|
|
initialDate = null,
|
|
onClose,
|
|
onCreated,
|
|
}: EventCreateModalProps) {
|
|
const { token } = useAuth();
|
|
const [name, setName] = useState('');
|
|
const [notes, setNotes] = useState('');
|
|
const [link, setLink] = useState('');
|
|
const [start, setStart] = useState(() => createDefaults(initialDate).start);
|
|
const [end, setEnd] = useState(() => createDefaults(initialDate).end);
|
|
const [activePicker, setActivePicker] = useState<'startDate' | 'startTime' | 'endDate' | 'endTime' | null>(null);
|
|
const [selectedProjectId, setSelectedProjectId] = useState<number | null>(initialProjectId);
|
|
const [projectSearch, setProjectSearch] = useState('');
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!visible) return;
|
|
const defaults = createDefaults(initialDate);
|
|
setName('');
|
|
setNotes('');
|
|
setLink('');
|
|
setStart(defaults.start);
|
|
setEnd(defaults.end);
|
|
setActivePicker(null);
|
|
setSelectedProjectId(initialProjectId);
|
|
setProjectSearch('');
|
|
setError(null);
|
|
}, [initialDate, initialProjectId, visible]);
|
|
|
|
const selectedProject = useMemo(
|
|
() => projects.find((project) => Number(project.id) === selectedProjectId) || null,
|
|
[projects, selectedProjectId]
|
|
);
|
|
|
|
const projectOptions = useMemo(() => {
|
|
const terms = projectSearch.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
return projects
|
|
.filter((project) => {
|
|
if (terms.length === 0) return true;
|
|
const haystack = `${project.name || ''} ${project.projectNumber || ''}`.toLowerCase();
|
|
return terms.every((term) => haystack.includes(term));
|
|
})
|
|
.slice(0, 8);
|
|
}, [projectSearch, projects]);
|
|
|
|
function updatePicker(event: DateTimePickerEvent, selected?: Date) {
|
|
if (Platform.OS === 'android') setActivePicker(null);
|
|
if (event.type === 'dismissed' || !selected || !activePicker) return;
|
|
|
|
const isStart = activePicker.startsWith('start');
|
|
const isDate = activePicker.endsWith('Date');
|
|
const current = new Date(isStart ? start : end);
|
|
if (isDate) {
|
|
current.setFullYear(selected.getFullYear(), selected.getMonth(), selected.getDate());
|
|
} else {
|
|
current.setHours(selected.getHours(), selected.getMinutes(), 0, 0);
|
|
}
|
|
|
|
if (isStart) {
|
|
setStart(current);
|
|
if (end <= current) setEnd(new Date(current.getTime() + 60 * 60 * 1000));
|
|
} else {
|
|
setEnd(current);
|
|
}
|
|
}
|
|
|
|
async function save() {
|
|
if (!token || saving) return;
|
|
const title = name.trim();
|
|
if (!title) {
|
|
setError('Bitte einen Namen für den Termin eingeben.');
|
|
return;
|
|
}
|
|
|
|
if (end <= start) {
|
|
setError('Das Ende muss nach dem Beginn liegen.');
|
|
return;
|
|
}
|
|
|
|
setSaving(true);
|
|
setError(null);
|
|
try {
|
|
const event = await createEvent(token, {
|
|
name: title,
|
|
startDate: start.toISOString(),
|
|
endDate: end.toISOString(),
|
|
project: selectedProjectId,
|
|
customer: relationId(selectedProject?.customer),
|
|
notes: notes.trim() || null,
|
|
link: link.trim() || null,
|
|
state: 'Final',
|
|
repeatInterval: 'Keine Wiederholung',
|
|
});
|
|
await onCreated(event);
|
|
onClose();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Der Termin konnte nicht gespeichert werden.');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
|
<View style={styles.overlay}>
|
|
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.keyboardWrap}>
|
|
<View style={styles.card}>
|
|
<Text style={styles.title}>Neuer Termin</Text>
|
|
<ScrollView style={styles.scroll} contentContainerStyle={styles.form} keyboardShouldPersistTaps="handled">
|
|
<TextInput
|
|
autoFocus
|
|
placeholder="Name *"
|
|
placeholderTextColor="#9ca3af"
|
|
style={styles.input}
|
|
value={name}
|
|
onChangeText={setName}
|
|
/>
|
|
|
|
<Text style={styles.label}>Beginn</Text>
|
|
<View style={styles.row}>
|
|
<Pressable style={[styles.pickerButton, styles.dateInput]} onPress={() => setActivePicker('startDate')}>
|
|
<Text style={styles.pickerButtonText}>{formatDate(start)}</Text>
|
|
</Pressable>
|
|
<Pressable style={[styles.pickerButton, styles.timeInput]} onPress={() => setActivePicker('startTime')}>
|
|
<Text style={styles.pickerButtonText}>{formatTime(start)}</Text>
|
|
</Pressable>
|
|
</View>
|
|
|
|
<Text style={styles.label}>Ende</Text>
|
|
<View style={styles.row}>
|
|
<Pressable style={[styles.pickerButton, styles.dateInput]} onPress={() => setActivePicker('endDate')}>
|
|
<Text style={styles.pickerButtonText}>{formatDate(end)}</Text>
|
|
</Pressable>
|
|
<Pressable style={[styles.pickerButton, styles.timeInput]} onPress={() => setActivePicker('endTime')}>
|
|
<Text style={styles.pickerButtonText}>{formatTime(end)}</Text>
|
|
</Pressable>
|
|
</View>
|
|
|
|
{activePicker ? (
|
|
<View style={styles.pickerWrap}>
|
|
<DateTimePicker
|
|
value={activePicker.startsWith('start') ? start : end}
|
|
mode={activePicker.endsWith('Date') ? 'date' : 'time'}
|
|
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
|
|
locale="de-DE"
|
|
minuteInterval={5}
|
|
onChange={updatePicker}
|
|
/>
|
|
{Platform.OS === 'ios' ? (
|
|
<Pressable style={styles.pickerDoneButton} onPress={() => setActivePicker(null)}>
|
|
<Text style={styles.pickerDoneText}>Übernehmen</Text>
|
|
</Pressable>
|
|
) : null}
|
|
</View>
|
|
) : null}
|
|
|
|
<Text style={styles.label}>Projekt</Text>
|
|
{selectedProject ? (
|
|
<View style={styles.selectedProject}>
|
|
<View style={styles.selectedProjectMain}>
|
|
<Text style={styles.selectedProjectName}>{selectedProject.name}</Text>
|
|
{selectedProject.projectNumber ? (
|
|
<Text style={styles.selectedProjectNumber}>Nr. {selectedProject.projectNumber}</Text>
|
|
) : null}
|
|
</View>
|
|
<Pressable onPress={() => setSelectedProjectId(null)}>
|
|
<Text style={styles.removeProject}>Ändern</Text>
|
|
</Pressable>
|
|
</View>
|
|
) : (
|
|
<>
|
|
<TextInput
|
|
placeholder="Projekt suchen (optional)"
|
|
placeholderTextColor="#9ca3af"
|
|
style={styles.input}
|
|
value={projectSearch}
|
|
onChangeText={setProjectSearch}
|
|
/>
|
|
{projectOptions.map((project) => (
|
|
<Pressable
|
|
key={String(project.id)}
|
|
style={styles.projectOption}
|
|
onPress={() => setSelectedProjectId(Number(project.id))}>
|
|
<Text style={styles.projectOptionName}>{project.name}</Text>
|
|
<Text style={styles.projectOptionNumber}>{project.projectNumber || `#${project.id}`}</Text>
|
|
</Pressable>
|
|
))}
|
|
</>
|
|
)}
|
|
|
|
<TextInput
|
|
placeholder="Link (optional)"
|
|
placeholderTextColor="#9ca3af"
|
|
style={styles.input}
|
|
value={link}
|
|
onChangeText={setLink}
|
|
autoCapitalize="none"
|
|
keyboardType="url"
|
|
/>
|
|
<TextInput
|
|
placeholder="Notizen (optional)"
|
|
placeholderTextColor="#9ca3af"
|
|
style={[styles.input, styles.multiline]}
|
|
value={notes}
|
|
onChangeText={setNotes}
|
|
multiline
|
|
/>
|
|
{error ? <Text style={styles.error}>{error}</Text> : null}
|
|
</ScrollView>
|
|
|
|
<View style={styles.actions}>
|
|
<Pressable style={styles.secondaryButton} onPress={onClose} disabled={saving}>
|
|
<Text style={styles.secondaryButtonText}>Abbrechen</Text>
|
|
</Pressable>
|
|
<Pressable style={[styles.primaryButton, saving ? styles.disabled : null]} onPress={save} disabled={saving}>
|
|
<Text style={styles.primaryButtonText}>{saving ? 'Speichere...' : 'Anlegen'}</Text>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
</KeyboardAvoidingView>
|
|
</View>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
overlay: { flex: 1, backgroundColor: 'rgba(17, 24, 39, 0.45)', justifyContent: 'center', padding: 20 },
|
|
keyboardWrap: { width: '100%' },
|
|
card: { maxHeight: '92%', backgroundColor: '#ffffff', borderRadius: 14, padding: 16, gap: 12 },
|
|
title: { color: '#111827', fontSize: 18, fontWeight: '700' },
|
|
scroll: { flexShrink: 1 },
|
|
form: { gap: 10, paddingBottom: 4 },
|
|
label: { color: '#374151', fontSize: 13, fontWeight: '700' },
|
|
input: {
|
|
borderWidth: 1,
|
|
borderColor: '#d1d5db',
|
|
borderRadius: 10,
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 10,
|
|
fontSize: 15,
|
|
color: '#111827',
|
|
backgroundColor: '#ffffff',
|
|
},
|
|
row: { flexDirection: 'row', gap: 8 },
|
|
pickerButton: {
|
|
minHeight: 44,
|
|
borderWidth: 1,
|
|
borderColor: '#d1d5db',
|
|
borderRadius: 10,
|
|
paddingHorizontal: 12,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backgroundColor: '#ffffff',
|
|
},
|
|
pickerButtonText: { color: '#111827', fontSize: 15, fontWeight: '600' },
|
|
dateInput: { flex: 1 },
|
|
timeInput: { width: 92 },
|
|
pickerWrap: { borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 10, padding: 8 },
|
|
pickerDoneButton: { alignSelf: 'flex-end', paddingHorizontal: 10, paddingVertical: 7 },
|
|
pickerDoneText: { color: '#3d7a30', fontSize: 14, fontWeight: '700' },
|
|
multiline: { minHeight: 90, textAlignVertical: 'top' },
|
|
selectedProject: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
borderWidth: 1,
|
|
borderColor: '#b9e3ae',
|
|
backgroundColor: '#eff9ea',
|
|
borderRadius: 10,
|
|
padding: 10,
|
|
gap: 8,
|
|
},
|
|
selectedProjectMain: { flex: 1 },
|
|
selectedProjectName: { color: '#111827', fontSize: 14, fontWeight: '700' },
|
|
selectedProjectNumber: { color: '#6b7280', fontSize: 12 },
|
|
removeProject: { color: '#3d7a30', fontSize: 13, fontWeight: '700' },
|
|
projectOption: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: '#e5e7eb',
|
|
paddingVertical: 8,
|
|
gap: 8,
|
|
},
|
|
projectOptionName: { flex: 1, color: '#111827', fontSize: 14, fontWeight: '600' },
|
|
projectOptionNumber: { color: '#6b7280', fontSize: 12 },
|
|
error: { color: '#dc2626', fontSize: 13 },
|
|
actions: { flexDirection: 'row', justifyContent: 'flex-end', gap: 8 },
|
|
secondaryButton: {
|
|
minHeight: 40,
|
|
borderWidth: 1,
|
|
borderColor: '#d1d5db',
|
|
borderRadius: 10,
|
|
paddingHorizontal: 14,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
secondaryButtonText: { color: '#374151', fontWeight: '600' },
|
|
primaryButton: {
|
|
minHeight: 40,
|
|
borderRadius: 10,
|
|
paddingHorizontal: 14,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backgroundColor: PRIMARY,
|
|
},
|
|
primaryButtonText: { color: '#ffffff', fontWeight: '700' },
|
|
disabled: { opacity: 0.6 },
|
|
});
|