diff --git a/mobile/package.json b/mobile/package.json index c2d6001..baf1418 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@react-native-async-storage/async-storage": "^2.2.0", + "@react-native-community/datetimepicker": "^8.6.0", "@react-navigation/bottom-tabs": "^7.10.0", "@react-navigation/native": "^7.1.0", "@react-navigation/native-stack": "^7.10.0", diff --git a/mobile/src/contexts/.gitkeep b/mobile/src/contexts/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/mobile/src/navigation/Navigation.tsx b/mobile/src/navigation/Navigation.tsx index a4dbe45..b00a5f0 100644 --- a/mobile/src/navigation/Navigation.tsx +++ b/mobile/src/navigation/Navigation.tsx @@ -18,8 +18,8 @@ import WalkDetailScreen from '@screens/WalkDetailScreen'; import HealthScreen from '@screens/HealthScreen'; import VisitsScreen from '@screens/VisitsScreen'; import MedicationsScreen from '@screens/MedicationsScreen'; -import VaccinationsScreen from '@screens/VaccinationsScreen'; -import WeightManagementScreen from '@screens/WeightManagementScreen'; +import VaccinationsScreen from '../screens/VaccinationsScreen'; +import WeightManagementScreen from '../screens/WeightManagementScreen'; const Stack = createNativeStackNavigator(); const Tab = createBottomTabNavigator(); diff --git a/mobile/src/screens/MedicationsScreen.tsx b/mobile/src/screens/MedicationsScreen.tsx index d3713a7..e88d657 100644 --- a/mobile/src/screens/MedicationsScreen.tsx +++ b/mobile/src/screens/MedicationsScreen.tsx @@ -1,46 +1,163 @@ -import React, { useState } from 'react'; -import { View, ScrollView, StyleSheet } from 'react-native'; -import { Text, Card, FAB, Chip, Divider } from 'react-native-paper'; +import React, { useState, useEffect, useRef } from 'react'; +import { View, ScrollView, StyleSheet, TouchableOpacity } from 'react-native'; +import { Text, Card, FAB, Chip, Divider, ActivityIndicator, Portal, Modal, TextInput, Button } from 'react-native-paper'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { COLORS, SPACING } from '../styles/theme'; - -interface Pet { - id: string; - name: string; - breed: string; - age: number; -} +import apiClient from '../services/api'; +import { Pet } from '../types'; +import DateTimePicker from '@react-native-community/datetimepicker'; interface Medication { - id: string; - petId: string; - name: string; - dosage: string; - frequency: string; - startDate: string; - endDate?: string; - purpose: string; - prescribedBy: string; + id: number; + pet_id: number; + med_name: string; + medication_date: string; + expire_date?: string; + costs?: string; notes?: string; - isActive: boolean; } export default function MedicationsScreen() { - // Mock data - replace with actual data from context/API - const [pets] = useState([]); + const [pets, setPets] = useState([]); + const [medications, setMedications] = useState([]); + const [selectedPetId, setSelectedPetId] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [modalVisible, setModalVisible] = useState(false); + const [saving, setSaving] = useState(false); + const [showMedicationDatePicker, setShowMedicationDatePicker] = useState(false); + const [showExpireDatePicker, setShowExpireDatePicker] = useState(false); + + const scrollViewRef = useRef(null); + + // Form state + const [medName, setMedName] = useState(''); + const [medicationDate, setMedicationDate] = useState(new Date().toISOString().split('T')[0]); + const [expireDate, setExpireDate] = useState(''); + const [costs, setCosts] = useState(''); + const [notes, setNotes] = useState(''); + + // Fetch pets and medications from the API + useEffect(() => { + const fetchData = async () => { + try { + setLoading(true); + setError(null); + + const [petsResponse, medicationsResponse] = await Promise.all([ + apiClient.get('/api/pets'), + apiClient.get('/api/medications') + ]); + + if (petsResponse.data.success && petsResponse.data.data) { + const fetchedPets = petsResponse.data.data; + setPets(fetchedPets); + + if (fetchedPets.length > 0) { + setSelectedPetId(fetchedPets[0].id); + } + } - const [medications] = useState([]); + if (medicationsResponse.data.success && medicationsResponse.data.data) { + const medicationsData = medicationsResponse.data.data; + + if (Array.isArray(medicationsData) && medicationsData.length > 0 && medicationsData[0].medications) { + const flattenedMedications: Medication[] = []; + medicationsData.forEach((petMedGroup: any) => { + const petId = petMedGroup.pet_id; + if (petMedGroup.medications && Array.isArray(petMedGroup.medications)) { + petMedGroup.medications.forEach((med: any) => { + flattenedMedications.push({ + ...med, + pet_id: petId + }); + }); + } + }); + setMedications(flattenedMedications); + } else { + setMedications(medicationsData); + } + } + } catch (err: any) { + setError('Tietojen lataus epäonnistui. Yritä uudelleen.'); + } finally { + setLoading(false); + } + }; - const [selectedPetId, setSelectedPetId] = useState(pets[0]?.id || ''); + fetchData(); + }, []); const selectedPetMedications = medications - .filter(med => med.petId === selectedPetId) - .sort((a, b) => { - if (a.isActive === b.isActive) { - return new Date(b.startDate).getTime() - new Date(a.startDate).getTime(); + .filter(med => med.pet_id === selectedPetId) + .sort((a, b) => new Date(b.medication_date).getTime() - new Date(a.medication_date).getTime()); + + const handleOpenModal = () => { + setMedName(''); + setMedicationDate(new Date().toISOString().split('T')[0]); + setExpireDate(''); + setCosts(''); + setNotes(''); + setModalVisible(true); + }; + + const handleCloseModal = () => { + setModalVisible(false); + }; + + const handleSaveMedication = async () => { + if (!selectedPetId || !medName) { + alert('Täytä kaikki pakolliset kentät'); + return; + } + + try { + setSaving(true); + + const medicationData = { + pet_id: selectedPetId, + med_name: medName, + medication_date: medicationDate, + expire_date: expireDate || undefined, + costs: costs ? parseFloat(costs) : undefined, + notes: notes || undefined + }; + + const response = await apiClient.post('/api/medications', medicationData); + + if (response.data.success) { + const medicationsResponse = await apiClient.get('/api/medications'); + if (medicationsResponse.data.success && medicationsResponse.data.data) { + const medicationsData = medicationsResponse.data.data; + + if (Array.isArray(medicationsData) && medicationsData.length > 0 && medicationsData[0].medications) { + const flattenedMedications: Medication[] = []; + medicationsData.forEach((petMedGroup: any) => { + const petId = petMedGroup.pet_id; + if (petMedGroup.medications && Array.isArray(petMedGroup.medications)) { + petMedGroup.medications.forEach((med: any) => { + flattenedMedications.push({ + ...med, + pet_id: petId + }); + }); + } + }); + setMedications(flattenedMedications); + } else { + setMedications(medicationsData); + } + } + + handleCloseModal(); } - return a.isActive ? -1 : 1; - }); + } catch (err: any) { + alert('Lääkityksen tallentaminen epäonnistui. Yritä uudelleen.'); + } finally { + setSaving(false); + } + }; const formatDate = (dateString: string) => { const date = new Date(dateString); @@ -51,79 +168,64 @@ export default function MedicationsScreen() { }); }; - const renderMedicationCard = (medication: Medication) => ( - - - - - {medication.name} - - - {medication.isActive ? 'Aktiivinen' : 'Päättynyt'} - - - - - - - {medication.dosage} - {medication.frequency} - - - - - - - - - {medication.purpose} - - - - - - - {medication.prescribedBy} - - - - - - - Aloitettu: {formatDate(medication.startDate)} - - + const renderMedicationCard = (medication: Medication) => { + const isExpired = medication.expire_date ? new Date(medication.expire_date) < new Date() : false; + + return ( + + + + + {medication.med_name} + + {medication.costs && ( + + {medication.costs} € + + )} + - {medication.endDate && ( - - - - Päättynyt: {formatDate(medication.endDate)} + + + + Aloitettu: {formatDate(medication.medication_date)} - )} - {medication.notes && ( - <> - - - - - {medication.notes} + {medication.expire_date && ( + + + + Vanhenee: {formatDate(medication.expire_date)} - - )} - - - ); + )} + + {medication.notes && ( + <> + + + + + {medication.notes} + + + + )} + + + ); + }; const renderEmptyState = () => ( @@ -137,6 +239,35 @@ export default function MedicationsScreen() { ); + if (loading) { + return ( + + + + + Ladataan lemmikkejä... + + + + ); + } + + if (error) { + return ( + + + + + Virhe + + + {error} + + + + ); + } + if (pets.length === 0) { return ( @@ -198,9 +329,155 @@ export default function MedicationsScreen() { console.log('Lisää lääkitys')} + onPress={handleOpenModal} label="Lisää lääkitys" /> + + + + + + Lisää lääkitys + + + + + setShowMedicationDatePicker(true)}> + } + placeholder="PP-KK-VVVV" + placeholderTextColor="rgba(0, 0, 0, 0.3)" + textColor={COLORS.onSurface} + theme={{ colors: { onSurfaceVariant: 'rgba(0, 0, 0, 0.4)' } }} + /> + + + {showMedicationDatePicker && ( + { + setShowMedicationDatePicker(false); + if (selectedDate) { + setMedicationDate(selectedDate.toISOString().split('T')[0]); + } + }} + /> + )} + + setShowExpireDatePicker(true)}> + } + placeholder="PP-KK-VVVV (valinnainen)" + placeholderTextColor="rgba(0, 0, 0, 0.3)" + textColor={COLORS.onSurface} + theme={{ colors: { onSurfaceVariant: 'rgba(0, 0, 0, 0.4)' } }} + /> + + + {showExpireDatePicker && ( + { + setShowExpireDatePicker(false); + if (selectedDate) { + setExpireDate(selectedDate.toISOString().split('T')[0]); + } + }} + /> + )} + + { + setTimeout(() => { + scrollViewRef.current?.scrollToEnd({ animated: true }); + }, 200); + }} + /> + + { + setTimeout(() => { + scrollViewRef.current?.scrollToEnd({ animated: true }); + }, 200); + }} + /> + + + + + + + + ); } @@ -210,15 +487,6 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: COLORS.background, }, - header: { - paddingHorizontal: SPACING.lg, - paddingTop: SPACING.lg, - paddingBottom: SPACING.md, - }, - title: { - fontWeight: 'bold', - color: COLORS.onBackground, - }, tabsContainer: { maxHeight: 70, backgroundColor: COLORS.surface, @@ -275,21 +543,6 @@ const styles = StyleSheet.create({ color: COLORS.primary, flex: 1, }, - statusChip: { - marginLeft: SPACING.sm, - }, - activeChip: { - backgroundColor: '#E8F5E9', - }, - inactiveChip: { - backgroundColor: COLORS.surfaceVariant, - }, - activeChipText: { - color: '#2E7D32', - }, - inactiveChipText: { - color: COLORS.onSurfaceVariant, - }, dosageContainer: { flexDirection: 'row', alignItems: 'center', @@ -303,16 +556,6 @@ const styles = StyleSheet.create({ divider: { marginVertical: SPACING.sm, }, - medicationDetail: { - flexDirection: 'row', - alignItems: 'center', - gap: SPACING.sm, - marginBottom: SPACING.xs, - }, - detailText: { - flex: 1, - color: COLORS.onSurface, - }, notesContainer: { flexDirection: 'row', gap: SPACING.sm, @@ -344,4 +587,31 @@ const styles = StyleSheet.create({ bottom: SPACING.md, backgroundColor: COLORS.primary, }, + modalContainer: { + backgroundColor: COLORS.surface, + margin: SPACING.lg, + padding: SPACING.lg, + borderRadius: 12, + maxHeight: '90%', + }, + scrollContentContainer: { + paddingBottom: 0, + }, + modalTitle: { + marginBottom: SPACING.lg, + fontWeight: 'bold', + color: COLORS.onSurface, + }, + input: { + marginBottom: SPACING.md, + }, + modalButtons: { + flexDirection: 'row', + justifyContent: 'space-between', + marginTop: SPACING.lg, + gap: SPACING.md, + }, + modalButton: { + flex: 1, + }, }); diff --git a/mobile/src/screens/VaccinationsScreen.tsx b/mobile/src/screens/VaccinationsScreen.tsx index b6c3863..b6ce6d5 100644 --- a/mobile/src/screens/VaccinationsScreen.tsx +++ b/mobile/src/screens/VaccinationsScreen.tsx @@ -1,40 +1,163 @@ -import React, { useState } from 'react'; -import { View, ScrollView, StyleSheet } from 'react-native'; -import { Text, Card, FAB, Chip, Divider } from 'react-native-paper'; +import React, { useState, useEffect, useRef } from 'react'; +import { View, ScrollView, StyleSheet, TouchableOpacity } from 'react-native'; +import { Text, Card, FAB, Chip, Divider, ActivityIndicator, Portal, Modal, TextInput, Button } from 'react-native-paper'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { COLORS, SPACING } from '../styles/theme'; - -interface Pet { - id: string; - name: string; - breed: string; - age: number; -} +import apiClient from '../services/api'; +import { Pet } from '../types'; +import DateTimePicker from '@react-native-community/datetimepicker'; interface Vaccination { - id: string; - petId: string; - name: string; - date: string; - nextDueDate?: string; - batchNumber?: string; - veterinarian: string; - clinic: string; + id: number; + pet_id: number; + vac_name: string; + vaccination_date: string; + expire_date?: string; + costs?: string; notes?: string; - isUpToDate: boolean; } export default function VaccinationsScreen() { - // Mock data - replace with actual data from context/API - const [pets] = useState([]); + const [pets, setPets] = useState([]); + const [vaccinations, setVaccinations] = useState([]); + const [selectedPetId, setSelectedPetId] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [modalVisible, setModalVisible] = useState(false); + const [saving, setSaving] = useState(false); + const [showVaccinationDatePicker, setShowVaccinationDatePicker] = useState(false); + const [showExpireDatePicker, setShowExpireDatePicker] = useState(false); + + const scrollViewRef = useRef(null); - const [vaccinations] = useState([]); + // Form state + const [vacName, setVacName] = useState(''); + const [vaccinationDate, setVaccinationDate] = useState(new Date().toISOString().split('T')[0]); + const [expireDate, setExpireDate] = useState(''); + const [costs, setCosts] = useState(''); + const [notes, setNotes] = useState(''); - const [selectedPetId, setSelectedPetId] = useState(pets[0]?.id || ''); + // Fetch pets and vaccinations from the API + useEffect(() => { + const fetchData = async () => { + try { + setLoading(true); + setError(null); + + const [petsResponse, vaccinationsResponse] = await Promise.all([ + apiClient.get('/api/pets'), + apiClient.get('/api/vaccinations') + ]); + + if (petsResponse.data.success && petsResponse.data.data) { + const fetchedPets = petsResponse.data.data; + setPets(fetchedPets); + + if (fetchedPets.length > 0) { + setSelectedPetId(fetchedPets[0].id); + } + } + + if (vaccinationsResponse.data.success && vaccinationsResponse.data.data) { + const vaccinationsData = vaccinationsResponse.data.data; + + if (Array.isArray(vaccinationsData) && vaccinationsData.length > 0 && vaccinationsData[0].vaccinations) { + const flattenedVaccinations: Vaccination[] = []; + vaccinationsData.forEach((petVacGroup: any) => { + const petId = petVacGroup.pet_id; + if (petVacGroup.vaccinations && Array.isArray(petVacGroup.vaccinations)) { + petVacGroup.vaccinations.forEach((vac: any) => { + flattenedVaccinations.push({ + ...vac, + pet_id: petId + }); + }); + } + }); + setVaccinations(flattenedVaccinations); + } else { + setVaccinations(vaccinationsData); + } + } + } catch (err: any) { + setError('Tietojen lataus epäonnistui. Yritä uudelleen.'); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, []); const selectedPetVaccinations = vaccinations - .filter(vac => vac.petId === selectedPetId) - .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + .filter(vac => vac.pet_id === selectedPetId) + .sort((a, b) => new Date(b.vaccination_date).getTime() - new Date(a.vaccination_date).getTime()); + + const handleOpenModal = () => { + setVacName(''); + setVaccinationDate(new Date().toISOString().split('T')[0]); + setExpireDate(''); + setCosts(''); + setNotes(''); + setModalVisible(true); + }; + + const handleCloseModal = () => { + setModalVisible(false); + }; + + const handleSaveVaccination = async () => { + if (!selectedPetId || !vacName) { + alert('Täytä kaikki pakolliset kentät'); + return; + } + + try { + setSaving(true); + + const vaccinationData = { + pet_id: selectedPetId, + vac_name: vacName, + vaccination_date: vaccinationDate, + expire_date: expireDate || undefined, + costs: costs ? parseFloat(costs) : undefined, + notes: notes || undefined + }; + + const response = await apiClient.post('/api/vaccinations', vaccinationData); + + if (response.data.success) { + const vaccinationsResponse = await apiClient.get('/api/vaccinations'); + if (vaccinationsResponse.data.success && vaccinationsResponse.data.data) { + const vaccinationsData = vaccinationsResponse.data.data; + + if (Array.isArray(vaccinationsData) && vaccinationsData.length > 0 && vaccinationsData[0].vaccinations) { + const flattenedVaccinations: Vaccination[] = []; + vaccinationsData.forEach((petVacGroup: any) => { + const petId = petVacGroup.pet_id; + if (petVacGroup.vaccinations && Array.isArray(petVacGroup.vaccinations)) { + petVacGroup.vaccinations.forEach((vac: any) => { + flattenedVaccinations.push({ + ...vac, + pet_id: petId + }); + }); + } + }); + setVaccinations(flattenedVaccinations); + } else { + setVaccinations(vaccinationsData); + } + } + + handleCloseModal(); + } + } catch (err: any) { + alert('Rokotuksen tallentaminen epäonnistui. Yritä uudelleen.'); + } finally { + setSaving(false); + } + }; const formatDate = (dateString: string) => { const date = new Date(dateString); @@ -45,80 +168,49 @@ export default function VaccinationsScreen() { }); }; - const isOverdue = (nextDueDate?: string) => { - if (!nextDueDate) return false; - return new Date(nextDueDate) < new Date(); - }; - const renderVaccinationCard = (vaccination: Vaccination) => { - const overdue = isOverdue(vaccination.nextDueDate); + const isExpired = vaccination.expire_date ? new Date(vaccination.expire_date) < new Date() : false; return ( - {vaccination.name} + {vaccination.vac_name} - - {overdue ? 'Myöhässä' : vaccination.isUpToDate ? 'Voimassa' : 'Odottaa'} - + {vaccination.costs && ( + + {vaccination.costs} € + + )} - Annettu: {formatDate(vaccination.date)} + Rokotettu: {formatDate(vaccination.vaccination_date)} - {vaccination.nextDueDate && ( + {vaccination.expire_date && ( - Voimassa: {formatDate(vaccination.nextDueDate)} + {isExpired ? 'Vanhentunut' : 'Uusittava'}: {formatDate(vaccination.expire_date)} )} - - - - - - {vaccination.clinic} - - - - - - - {vaccination.veterinarian} - - - {vaccination.notes && ( <> @@ -147,6 +239,35 @@ export default function VaccinationsScreen() { ); + if (loading) { + return ( + + + + + Ladataan lemmikkejä... + + + + ); + } + + if (error) { + return ( + + + + + Virhe + + + {error} + + + + ); + } + if (pets.length === 0) { return ( @@ -208,9 +329,155 @@ export default function VaccinationsScreen() { console.log('Lisää rokotus')} + onPress={handleOpenModal} label="Lisää rokotus" /> + + + + + + Lisää rokotus + + + + + setShowVaccinationDatePicker(true)}> + } + placeholder="PP-KK-VVVV" + placeholderTextColor="rgba(0, 0, 0, 0.3)" + textColor={COLORS.onSurface} + theme={{ colors: { onSurfaceVariant: 'rgba(0, 0, 0, 0.4)' } }} + /> + + + {showVaccinationDatePicker && ( + { + setShowVaccinationDatePicker(false); + if (selectedDate) { + setVaccinationDate(selectedDate.toISOString().split('T')[0]); + } + }} + /> + )} + + setShowExpireDatePicker(true)}> + } + placeholder="PP-KK-VVVV (valinnainen)" + placeholderTextColor="rgba(0, 0, 0, 0.3)" + textColor={COLORS.onSurface} + theme={{ colors: { onSurfaceVariant: 'rgba(0, 0, 0, 0.4)' } }} + /> + + + {showExpireDatePicker && ( + { + setShowExpireDatePicker(false); + if (selectedDate) { + setExpireDate(selectedDate.toISOString().split('T')[0]); + } + }} + /> + )} + + { + setTimeout(() => { + scrollViewRef.current?.scrollToEnd({ animated: true }); + }, 200); + }} + /> + + { + setTimeout(() => { + scrollViewRef.current?.scrollToEnd({ animated: true }); + }, 200); + }} + /> + + + + + + + + ); } @@ -220,15 +487,6 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: COLORS.background, }, - header: { - paddingHorizontal: SPACING.lg, - paddingTop: SPACING.lg, - paddingBottom: SPACING.md, - }, - title: { - fontWeight: 'bold', - color: COLORS.onBackground, - }, tabsContainer: { maxHeight: 70, backgroundColor: COLORS.surface, @@ -285,56 +543,19 @@ const styles = StyleSheet.create({ color: COLORS.primary, flex: 1, }, - statusChip: { - marginLeft: SPACING.sm, - }, - upToDateChip: { - backgroundColor: '#E8F5E9', - }, - pendingChip: { - backgroundColor: '#FFF8E1', - }, - overdueChip: { - backgroundColor: '#FFEBEE', - }, - upToDateChipText: { - color: '#2E7D32', - }, - pendingChipText: { - color: '#F57C00', - }, - overdueChipText: { - color: '#D32F2F', - }, dateContainer: { flexDirection: 'row', alignItems: 'center', gap: SPACING.xs, + marginBottom: SPACING.sm, }, dateText: { fontWeight: '600', color: COLORS.onSurface, }, - nextDueText: { - color: COLORS.onSurfaceVariant, - }, - overdueText: { - color: '#D32F2F', - fontWeight: '600', - }, divider: { marginVertical: SPACING.sm, }, - vaccinationDetail: { - flexDirection: 'row', - alignItems: 'center', - gap: SPACING.sm, - marginBottom: SPACING.xs, - }, - detailText: { - flex: 1, - color: COLORS.onSurface, - }, notesContainer: { flexDirection: 'row', gap: SPACING.sm, @@ -366,4 +587,31 @@ const styles = StyleSheet.create({ bottom: SPACING.md, backgroundColor: COLORS.primary, }, + modalContainer: { + backgroundColor: COLORS.surface, + margin: SPACING.lg, + padding: SPACING.lg, + borderRadius: 12, + maxHeight: '90%', + }, + scrollContentContainer: { + paddingBottom: 0, + }, + modalTitle: { + marginBottom: SPACING.lg, + fontWeight: 'bold', + color: COLORS.onSurface, + }, + input: { + marginBottom: SPACING.md, + }, + modalButtons: { + flexDirection: 'row', + justifyContent: 'space-between', + marginTop: SPACING.lg, + gap: SPACING.md, + }, + modalButton: { + flex: 1, + }, }); diff --git a/mobile/src/screens/VisitsScreen.tsx b/mobile/src/screens/VisitsScreen.tsx index ffc7547..145a1d5 100644 --- a/mobile/src/screens/VisitsScreen.tsx +++ b/mobile/src/screens/VisitsScreen.tsx @@ -1,38 +1,204 @@ -import React, { useState } from 'react'; -import { View, ScrollView, StyleSheet } from 'react-native'; -import { Text, Card, FAB, Chip, Divider } from 'react-native-paper'; +import React, { useState, useEffect, useRef } from 'react'; +import { View, ScrollView, StyleSheet, TouchableOpacity, Keyboard } from 'react-native'; +import { Text, Card, FAB, Chip, Divider, ActivityIndicator, Portal, Modal, TextInput, Button } from 'react-native-paper'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { COLORS, SPACING } from '../styles/theme'; - -interface Pet { - id: string; - name: string; - breed: string; - age: number; -} +import apiClient from '../services/api'; +import { Pet } from '../types'; +import DateTimePicker from '@react-native-community/datetimepicker'; interface Visit { - id: string; - petId: string; - date: string; - clinic: string; - veterinarian: string; - reason: string; + id: number; + pet_id: number; + visit_date: string; + location: string; + vet_name: string; + type_id: string; notes?: string; - cost?: number; + costs?: string; +} + +interface VisitType { + id: number; + name: string; } export default function VisitsScreen() { - // Mock data - replace with actual data from context/API - const [pets] = useState([]); + const [pets, setPets] = useState([]); + const [visits, setVisits] = useState([]); + const [visitTypes, setVisitTypes] = useState([]); + const [selectedPetId, setSelectedPetId] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [modalVisible, setModalVisible] = useState(false); + const [saving, setSaving] = useState(false); + const [showDatePicker, setShowDatePicker] = useState(false); + const [keyboardHeight, setKeyboardHeight] = useState(0); + + const scrollViewRef = useRef(null); + const costsInputRef = useRef(null); + const notesInputRef = useRef(null); + + // Handle keyboard events + useEffect(() => { + const keyboardDidShowListener = Keyboard.addListener( + 'keyboardDidShow', + (e) => { + setKeyboardHeight(e.endCoordinates.height); + } + ); + const keyboardDidHideListener = Keyboard.addListener( + 'keyboardDidHide', + () => { + setKeyboardHeight(0); + } + ); - const [visits] = useState([]); + return () => { + keyboardDidShowListener.remove(); + keyboardDidHideListener.remove(); + }; + }, []); + + // Form state + const [visitDate, setVisitDate] = useState(new Date().toISOString().split('T')[0]); + const [vetName, setVetName] = useState(''); + const [location, setLocation] = useState(''); + const [selectedTypeId, setSelectedTypeId] = useState(null); + const [notes, setNotes] = useState(''); + const [costs, setCosts] = useState(''); - const [selectedPetId, setSelectedPetId] = useState(pets[0]?.id || ''); + // Fetch pets and visits from the API + useEffect(() => { + const fetchData = async () => { + try { + setLoading(true); + setError(null); + + // Fetch pets and visits in parallel + const [petsResponse, visitsResponse] = await Promise.all([ + apiClient.get('/api/pets'), + apiClient.get('/api/vet-visits') + ]); + + if (petsResponse.data.success && petsResponse.data.data) { + const fetchedPets = petsResponse.data.data; + setPets(fetchedPets); + + // Set the first pet as selected by default + if (fetchedPets.length > 0) { + setSelectedPetId(fetchedPets[0].id); + } + } + + if (visitsResponse.data.success && visitsResponse.data.data) { + // Flatten the nested structure: each pet has a vet_visits array + const flattenedVisits: Visit[] = []; + visitsResponse.data.data.forEach((petVisitGroup: any) => { + const petId = petVisitGroup.pet_id; + if (petVisitGroup.vet_visits && Array.isArray(petVisitGroup.vet_visits)) { + petVisitGroup.vet_visits.forEach((visit: any) => { + flattenedVisits.push({ + ...visit, + pet_id: petId + }); + }); + } + }); + setVisits(flattenedVisits); + } + } catch (err: any) { + console.error('Failed to fetch data:', err); + setError('Tietojen lataus epäonnistui. Yritä uudelleen.'); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, []); const selectedPetVisits = visits - .filter(visit => visit.petId === selectedPetId) - .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + .filter(visit => visit.pet_id === selectedPetId) + .sort((a, b) => new Date(b.visit_date).getTime() - new Date(a.visit_date).getTime()); + + const handleOpenModal = async () => { + // Reset form + setVisitDate(new Date().toISOString().split('T')[0]); + setVetName(''); + setLocation(''); + setSelectedTypeId(null); + setNotes(''); + setCosts(''); + setModalVisible(true); + + // Fetch visit types if not already loaded + if (visitTypes.length === 0) { + try { + const typesResponse = await apiClient.get('/api/vet-visits/types'); + if (typesResponse.data.success && typesResponse.data.data) { + console.log('Visit types response:', typesResponse.data.data); + setVisitTypes(typesResponse.data.data); + } + } catch (err: any) { + console.error('Failed to fetch visit types:', err); + } + } + }; + + const handleCloseModal = () => { + setModalVisible(false); + }; + + const handleSaveVisit = async () => { + if (!selectedPetId || !vetName || !location || !selectedTypeId) { + alert('Täytä kaikki pakolliset kentät'); + return; + } + + try { + setSaving(true); + + const visitData = { + pet_id: selectedPetId, + visit_date: visitDate, + vet_name: vetName, + location: location, + type_id: selectedTypeId, + notes: notes || undefined, + costs: costs ? parseFloat(costs) : undefined + }; + + const response = await apiClient.post('/api/vet-visits', visitData); + + if (response.data.success) { + // Refresh visits + const visitsResponse = await apiClient.get('/api/vet-visits'); + if (visitsResponse.data.success && visitsResponse.data.data) { + const flattenedVisits: Visit[] = []; + visitsResponse.data.data.forEach((petVisitGroup: any) => { + const petId = petVisitGroup.pet_id; + if (petVisitGroup.vet_visits && Array.isArray(petVisitGroup.vet_visits)) { + petVisitGroup.vet_visits.forEach((visit: any) => { + flattenedVisits.push({ + ...visit, + pet_id: petId + }); + }); + } + }); + setVisits(flattenedVisits); + } + + handleCloseModal(); + } + } catch (err: any) { + console.error('Failed to save visit:', err); + alert('Käynnin tallentaminen epäonnistui. Yritä uudelleen.'); + } finally { + setSaving(false); + } + }; const formatDate = (dateString: string) => { const date = new Date(dateString); @@ -50,12 +216,12 @@ export default function VisitsScreen() { - {formatDate(visit.date)} + {formatDate(visit.visit_date)} - {visit.cost && ( - - {visit.cost} € + {visit.costs && ( + + {visit.costs} € )} @@ -65,21 +231,21 @@ export default function VisitsScreen() { - {visit.clinic} + {visit.location} - {visit.veterinarian} + {visit.vet_name} - {visit.reason} + {visit.type_id} @@ -110,6 +276,35 @@ export default function VisitsScreen() { ); + if (loading) { + return ( + + + + + Ladataan lemmikkejä... + + + + ); + } + + if (error) { + return ( + + + + + Virhe + + + {error} + + + + ); + } + if (pets.length === 0) { return ( @@ -171,9 +366,177 @@ export default function VisitsScreen() { console.log('Lisää käynti')} + onPress={handleOpenModal} label="Lisää käynti" /> + + + + + + Lisää käynti + + + setShowDatePicker(true)}> + } + placeholder="PP-KK-VVVV" + placeholderTextColor="rgba(0, 0, 0, 0.3)" + textColor={COLORS.onSurface} + theme={{ colors: { onSurfaceVariant: 'rgba(0, 0, 0, 0.4)' } }} + /> + + + {showDatePicker && ( + { + setShowDatePicker(false); + if (selectedDate) { + setVisitDate(selectedDate.toISOString().split('T')[0]); + } + }} + /> + )} + + + + + + + + Käynnin tyyppi * + + + {visitTypes.length > 0 ? ( + visitTypes.map((type) => ( + + )) + ) : ( + + )} + + + { + setTimeout(() => { + scrollViewRef.current?.scrollToEnd({ animated: true }); + }, 200); + }} + /> + + { + setTimeout(() => { + scrollViewRef.current?.scrollToEnd({ animated: true }); + }, 200); + }} + /> + + + + + + + + ); } @@ -296,4 +659,34 @@ const styles = StyleSheet.create({ bottom: SPACING.md, backgroundColor: COLORS.primary, }, + modalContainer: { + backgroundColor: COLORS.surface, + margin: SPACING.lg, + padding: SPACING.lg, + borderRadius: 12, + maxHeight: '90%', + }, + keyboardAvoid: { + width: '100%', + }, + scrollContentContainer: { + paddingBottom: 0, + }, + modalTitle: { + marginBottom: SPACING.lg, + fontWeight: 'bold', + color: COLORS.onSurface, + }, + input: { + marginBottom: SPACING.md, + }, + modalButtons: { + flexDirection: 'row', + justifyContent: 'space-between', + marginTop: SPACING.lg, + gap: SPACING.md, + }, + modalButton: { + flex: 1, + }, }); diff --git a/mobile/src/screens/WeightManagementScreen.tsx b/mobile/src/screens/WeightManagementScreen.tsx index c912bec..6b46af9 100644 --- a/mobile/src/screens/WeightManagementScreen.tsx +++ b/mobile/src/screens/WeightManagementScreen.tsx @@ -1,35 +1,97 @@ -import React, { useState } from 'react'; -import { View, ScrollView, StyleSheet, Dimensions } from 'react-native'; -import { Text, Card, FAB, Chip, Divider } from 'react-native-paper'; +import React, { useState, useEffect, useRef } from 'react'; +import { View, ScrollView, StyleSheet, Dimensions, TouchableOpacity } from 'react-native'; +import { Text, Card, FAB, Chip, Divider, ActivityIndicator, Portal, Modal, Button, TextInput } from 'react-native-paper'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import Svg, { Line, Circle } from 'react-native-svg'; +import DateTimePicker from '@react-native-community/datetimepicker'; import { COLORS, SPACING } from '../styles/theme'; - -interface Pet { - id: string; - name: string; - breed: string; - age: number; -} +import apiClient from '../services/api'; +import { Pet } from '../types'; interface WeightRecord { - id: string; - petId: string; + id: number; + petId: number; date: string; weight: number; - unit: 'kg' | 'g'; + created_at: string; notes?: string; measuredBy?: string; } export default function WeightManagementScreen() { - // Mock data - replace with actual data from context/API - const [pets] = useState([]); + const [pets, setPets] = useState([]); + const [weightRecords, setWeightRecords] = useState([]); + const [selectedPetId, setSelectedPetId] = useState(null); + const [selectedYear, setSelectedYear] = useState(new Date().getFullYear()); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [modalVisible, setModalVisible] = useState(false); + const [saving, setSaving] = useState(false); + const [showDatePicker, setShowDatePicker] = useState(false); + + const scrollViewRef = useRef(null); - const [weightRecords] = useState([]); + // Form state + const [weight, setWeight] = useState(''); + const [date, setDate] = useState(new Date().toISOString().split('T')[0]); - const [selectedPetId, setSelectedPetId] = useState(pets[0]?.id || ''); - const [selectedYear, setSelectedYear] = useState(new Date().getFullYear()); + // Fetch pets and weights from the API + useEffect(() => { + const fetchData = async () => { + try { + setLoading(true); + setError(null); + + // Fetch both pets and weights in parallel + const [petsResponse, weightsResponse] = await Promise.all([ + apiClient.get('/api/pets'), + apiClient.get('/api/weights') + ]); + + if (petsResponse.data.success && petsResponse.data.data) { + const fetchedPets = petsResponse.data.data; + setPets(fetchedPets); + + // Set the first pet as selected by default + if (fetchedPets.length > 0) { + setSelectedPetId(fetchedPets[0].id); + } + } + + if (weightsResponse.data.success && weightsResponse.data.data) { + const weightsData = weightsResponse.data.data; + + // If nested structure (array of pet weight groups) + if (Array.isArray(weightsData) && weightsData.length > 0 && weightsData[0].weights) { + const flattenedWeights: WeightRecord[] = []; + weightsData.forEach((petWeightGroup: any) => { + const petId = petWeightGroup.pet_id; + if (petWeightGroup.weights && Array.isArray(petWeightGroup.weights)) { + petWeightGroup.weights.forEach((weight: any) => { + flattenedWeights.push({ + ...weight, + petId: petId, + weight: parseFloat(weight.weight) // Convert string to number + }); + }); + } + }); + setWeightRecords(flattenedWeights); + } else { + // If flat structure + setWeightRecords(weightsData); + } + } + } catch (err: any) { + console.error('Failed to fetch data:', err); + setError('Tietojen lataus epäonnistui. Yritä uudelleen.'); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, []); const selectedPetWeights = weightRecords .filter(record => record.petId === selectedPetId) .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); @@ -44,6 +106,67 @@ export default function WeightManagementScreen() { record => new Date(record.date).getFullYear() === selectedYear ); + const handleOpenModal = () => { + setWeight(''); + setDate(new Date().toISOString().split('T')[0]); + setModalVisible(true); + }; + + const handleCloseModal = () => { + setModalVisible(false); + }; + + const handleSaveWeight = async () => { + if (!selectedPetId || !weight) { + alert('Täytä kaikki pakolliset kentät'); + return; + } + + try { + setSaving(true); + + const weightData = { + pet_id: selectedPetId, + weight: parseFloat(weight), + date: date + }; + + const response = await apiClient.post('/api/weights', weightData); + + if (response.data.success) { + const weightsResponse = await apiClient.get('/api/weights'); + if (weightsResponse.data.success && weightsResponse.data.data) { + const weightsData = weightsResponse.data.data; + + if (Array.isArray(weightsData) && weightsData.length > 0 && weightsData[0].weights) { + const flattenedWeights: WeightRecord[] = []; + weightsData.forEach((petWeightGroup: any) => { + const petId = petWeightGroup.pet_id; + if (petWeightGroup.weights && Array.isArray(petWeightGroup.weights)) { + petWeightGroup.weights.forEach((weight: any) => { + flattenedWeights.push({ + ...weight, + petId: petId, + weight: parseFloat(weight.weight) + }); + }); + } + }); + setWeightRecords(flattenedWeights); + } else { + setWeightRecords(weightsData); + } + } + + handleCloseModal(); + } + } catch (err: any) { + alert('Painon tallentaminen epäonnistui. Yritä uudelleen.'); + } finally { + setSaving(false); + } + }; + const formatDate = (dateString: string) => { const date = new Date(dateString); return date.toLocaleDateString('fi-FI', { @@ -64,6 +187,13 @@ export default function WeightManagementScreen() { return { change: change.toFixed(1), percentChange, isIncrease: change > 0 }; }; + const formatWeight = (weight: number | undefined): string => { + if (weight === undefined || weight === null) { + return '0'; + } + return weight % 1 === 0 ? weight.toFixed(0) : weight.toFixed(2).replace(/\.?0+$/, ''); + }; + const renderWeightCard = (record: WeightRecord, index: number) => { const weightChange = calculateWeightChange(index); @@ -73,10 +203,7 @@ export default function WeightManagementScreen() { - {record.weight} - - - {record.unit} + {formatWeight(record.weight)} kg {weightChange && ( @@ -143,6 +270,35 @@ export default function WeightManagementScreen() { ); + if (loading) { + return ( + + + + + Ladataan lemmikkejä... + + + + ); + } + + if (error) { + return ( + + + + + Virhe + + + {error} + + + + ); + } + if (pets.length === 0) { return ( @@ -199,7 +355,6 @@ export default function WeightManagementScreen() { // Add 10% padding to top and bottom for better visibility const paddedMax = maxWeight + (weightRange * 0.1); const paddedMin = minWeight - (weightRange * 0.1); - const paddedRange = paddedMax - paddedMin; const svgHeight = 220; // Make graph slightly wider for better readability const screenWidth = Dimensions.get('window').width; @@ -474,9 +629,95 @@ export default function WeightManagementScreen() { console.log('Lisää painomittaus')} + onPress={handleOpenModal} label="Lisää mittaus" /> + + + + + + Lisää painomittaus + + + { + setTimeout(() => { + scrollViewRef.current?.scrollToEnd({ animated: true }); + }, 200); + }} + /> + + setShowDatePicker(true)}> + } + placeholder="PP-KK-VVVV" + placeholderTextColor="rgba(0, 0, 0, 0.3)" + textColor={COLORS.onSurface} + theme={{ colors: { onSurfaceVariant: 'rgba(0, 0, 0, 0.4)' } }} + /> + + + {showDatePicker && ( + { + setShowDatePicker(false); + if (selectedDate) { + setDate(selectedDate.toISOString().split('T')[0]); + } + }} + /> + )} + + + + + + + + ); } @@ -746,4 +987,31 @@ const styles = StyleSheet.create({ graphScrollView: { width: '100%', }, + modalContainer: { + backgroundColor: COLORS.surface, + margin: SPACING.lg, + padding: SPACING.lg, + borderRadius: 12, + maxHeight: '90%', + }, + scrollContentContainer: { + paddingBottom: 0, + }, + modalTitle: { + marginBottom: SPACING.lg, + fontWeight: 'bold', + color: COLORS.onSurface, + }, + input: { + marginBottom: SPACING.md, + }, + modalButtons: { + flexDirection: 'row', + justifyContent: 'space-between', + marginTop: SPACING.lg, + gap: SPACING.md, + }, + modalButton: { + flex: 1, + }, }); diff --git a/mobile/src/types/index.ts b/mobile/src/types/index.ts index 7573a81..56c3539 100644 --- a/mobile/src/types/index.ts +++ b/mobile/src/types/index.ts @@ -1,6 +1,6 @@ // Type definitions for the application export interface Pet { - id: string; + id: number; name: string; breed: string; age: number; diff --git a/package-lock.json b/package-lock.json index 0a810e7..7742d39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "version": "1.0.0", "dependencies": { "@react-native-async-storage/async-storage": "^2.2.0", + "@react-native-community/datetimepicker": "^8.6.0", "@react-navigation/bottom-tabs": "^7.10.0", "@react-navigation/native": "^7.1.0", "@react-navigation/native-stack": "^7.10.0", @@ -4517,6 +4518,28 @@ "react-native": "^0.0.0-0 || >=0.65 <1.0" } }, + "node_modules/@react-native-community/datetimepicker": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@react-native-community/datetimepicker/-/datetimepicker-8.6.0.tgz", + "integrity": "sha512-yxPSqNfxgpGaqHQIpatqe6ykeBdU/1pdsk/G3x01mY2bpTflLpmVTLqFSJYd3MiZzxNZcMs/j1dQakUczSjcYA==", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "expo": ">=52.0.0", + "react": "*", + "react-native": "*", + "react-native-windows": "*" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "react-native-windows": { + "optional": true + } + } + }, "node_modules/@react-native/assets-registry": { "version": "0.81.5", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",