Files
FEDEO/mobile/components/event-create-modal.tsx

378 lines
13 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
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 formatDateInput(date: Date): string {
return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${date.getFullYear()}`;
}
function formatTimeInput(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 {
startDate: formatDateInput(start),
startTime: formatTimeInput(start),
endDate: formatDateInput(end),
endTime: formatTimeInput(end),
};
}
function parseLocalDate(dateValue: string, timeValue: string): Date | null {
const match = dateValue.trim().match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
const timeMatch = timeValue.trim().match(/^(\d{1,2}):(\d{2})$/);
if (!match || !timeMatch) return null;
const day = Number(match[1]);
const month = Number(match[2]);
const year = Number(match[3]);
const hours = Number(timeMatch[1]);
const minutes = Number(timeMatch[2]);
if (hours > 23 || minutes > 59) return null;
const date = new Date(year, month - 1, day, hours, minutes, 0, 0);
if (
date.getFullYear() !== year ||
date.getMonth() !== month - 1 ||
date.getDate() !== day
) {
return null;
}
return date;
}
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 [startDate, setStartDate] = useState('');
const [startTime, setStartTime] = useState('');
const [endDate, setEndDate] = useState('');
const [endTime, setEndTime] = useState('');
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('');
setStartDate(defaults.startDate);
setStartTime(defaults.startTime);
setEndDate(defaults.endDate);
setEndTime(defaults.endTime);
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]);
async function save() {
if (!token || saving) return;
const title = name.trim();
if (!title) {
setError('Bitte einen Namen für den Termin eingeben.');
return;
}
const start = parseLocalDate(startDate, startTime);
const end = parseLocalDate(endDate, endTime);
if (!start || !end) {
setError('Bitte Datum und Uhrzeit im angegebenen Format 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}>
<TextInput
placeholder="TT.MM.JJJJ"
placeholderTextColor="#9ca3af"
style={[styles.input, styles.dateInput]}
value={startDate}
onChangeText={setStartDate}
keyboardType="numbers-and-punctuation"
/>
<TextInput
placeholder="HH:MM"
placeholderTextColor="#9ca3af"
style={[styles.input, styles.timeInput]}
value={startTime}
onChangeText={setStartTime}
keyboardType="numbers-and-punctuation"
/>
</View>
<Text style={styles.label}>Ende</Text>
<View style={styles.row}>
<TextInput
placeholder="TT.MM.JJJJ"
placeholderTextColor="#9ca3af"
style={[styles.input, styles.dateInput]}
value={endDate}
onChangeText={setEndDate}
keyboardType="numbers-and-punctuation"
/>
<TextInput
placeholder="HH:MM"
placeholderTextColor="#9ca3af"
style={[styles.input, styles.timeInput]}
value={endTime}
onChangeText={setEndTime}
keyboardType="numbers-and-punctuation"
/>
</View>
<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 },
dateInput: { flex: 1 },
timeInput: { width: 92 },
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 },
});