Files
casadoc-app/src/components/PropertyForm.tsx
T
2026-08-26 23:01:07 +03:00

431 lines
14 KiB
TypeScript

import React, { useEffect, useState } from 'react';
import {
Alert,
Image,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { useFocusEffect, useRouter } from 'expo-router';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Ionicons } from '@expo/vector-icons';
import { api, LocalFile, Property, PropertyType } from '@/api';
import { useT } from '@/i18n';
import { AppBar } from '@/components/AppBar';
import { Button, Field, Loading, TextField } from '@/components/ui';
import { pickImageFromLibrary, takePhotoWithCamera } from '@/lib/pickImage';
import { PickedLocation, takePendingLocation } from '@/lib/pendingLocation';
import { colors, fontSize, radii, spacing } from '@/theme';
const TYPES: PropertyType[] = ['apartment', 'house', 'villa', 'commercial', 'land', 'building'];
/** Create and edit in one form; `propertyId` switches the mode. */
export function PropertyForm({ propertyId }: { propertyId?: number }) {
const router = useRouter();
const qc = useQueryClient();
const t = useT();
const isEdit = propertyId != null;
const existing = useQuery({
queryKey: ['property', propertyId],
queryFn: () => api.properties.get(propertyId as number),
enabled: isEdit,
});
const [name, setName] = useState('');
const [type, setType] = useState<PropertyType>('apartment');
const [address, setAddress] = useState('');
const [zip, setZip] = useState('');
const [city, setCity] = useState('');
const [description, setDescription] = useState('');
const [photo, setPhoto] = useState<LocalFile | null>(null);
const [coords, setCoords] = useState<{ latitude: number; longitude: number } | null>(null);
const [loaded, setLoaded] = useState(false);
const [deleting, setDeleting] = useState(false);
useEffect(() => {
const p = existing.data;
if (!p || loaded) return;
setName(p.name ?? '');
setType(p.type);
setAddress(p.address ?? '');
setZip(p.zip ?? '');
setCity(p.city ?? '');
setDescription(p.description ?? '');
if (p.latitude != null && p.longitude != null) {
setCoords({ latitude: p.latitude, longitude: p.longitude });
}
setLoaded(true);
}, [existing.data, loaded]);
const save = useMutation({
mutationFn: async (): Promise<Property> => {
const payload = {
name: name.trim(),
type,
address: address.trim() || null,
zip: zip.trim() || null,
city: city.trim() || null,
description: description.trim() || null,
latitude: coords?.latitude ?? null,
longitude: coords?.longitude ?? null,
};
const saved = isEdit
? await api.properties.update({ id: propertyId as number, ...payload })
: await api.properties.create(payload);
// The photo endpoint needs an id, so it goes up after the property.
if (photo) {
try {
return await api.properties.uploadPhoto(saved.id, photo);
} catch (e: any) {
Alert.alert(t('addProperty.photoFailed'), e?.message ?? t('common.tryAgain'));
}
}
return saved;
},
onSuccess: (saved) => {
qc.invalidateQueries({ queryKey: ['properties'] });
qc.invalidateQueries({ queryKey: ['property', saved.id] });
if (isEdit) router.back();
else router.replace(`/property/${saved.id}`);
},
onError: (e: any) => Alert.alert(t('addProperty.saveFailed'), e?.message ?? t('common.tryAgain')),
});
// The map picker is a separate screen; collect its result on the way back.
useFocusEffect(
React.useCallback(() => {
const picked = takePendingLocation();
if (!picked) return;
setCoords({ latitude: picked.latitude, longitude: picked.longitude });
applyGeocodedAddress(picked);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [address, zip, city]),
);
/** Silent when the fields are empty, asks before overwriting typed text. */
function applyGeocodedAddress(picked: PickedLocation) {
const next = {
address: picked.address ?? '',
zip: picked.postalCode ?? '',
city: picked.city ?? '',
};
if (!next.address && !next.zip && !next.city) return;
const apply = () => {
if (next.address) setAddress(next.address);
if (next.zip) setZip(next.zip);
if (next.city) setCity(next.city);
};
const wouldOverwrite =
(next.address && address.trim() && next.address !== address.trim()) ||
(next.zip && zip.trim() && next.zip !== zip.trim()) ||
(next.city && city.trim() && next.city !== city.trim());
if (!wouldOverwrite) {
apply();
return;
}
Alert.alert(
t('location.fillTitle'),
t('location.fillBody', {
address: [next.address, next.zip, next.city].filter(Boolean).join(', '),
}),
[
{ text: t('location.fillKeep'), style: 'cancel' },
{ text: t('location.fillApply'), onPress: apply },
],
);
}
function openMap() {
const q = new URLSearchParams();
if (coords) {
q.set('lat', String(coords.latitude));
q.set('lon', String(coords.longitude));
}
const full = [address.trim(), zip.trim(), city.trim()].filter(Boolean).join(', ');
if (full) q.set('address', full);
router.push(`/property/location?${q.toString()}`);
}
// Standing in front of the place is the common case, so camera first.
function pickPhoto() {
const opts = { allowsEditing: true, aspect: [16, 10] as [number, number], fallbackName: 'cover' };
Alert.alert(t('addProperty.photoSourceTitle'), undefined, [
{
text: t('addProperty.takePhoto'),
onPress: async () => {
const picked = await takePhotoWithCamera(opts);
if (picked) setPhoto(picked);
},
},
{
text: t('addProperty.fromGallery'),
onPress: async () => {
const picked = await pickImageFromLibrary(opts);
if (picked) setPhoto(picked);
},
},
{ text: t('common.cancel'), style: 'cancel' },
]);
}
// Irreversible, so the confirmation names the property and its documents.
function confirmDelete() {
const docs = (qc.getQueryData(['documents', propertyId]) as unknown[] | undefined)?.length ?? 0;
Alert.alert(
t('editProperty.deleteTitle'),
t(docs > 0 ? 'editProperty.deleteBodyWithDocs' : 'editProperty.deleteBody', {
name: name.trim() || t('property.fallbackTitle'),
count: docs,
}),
[
{ text: t('common.cancel'), style: 'cancel' },
{
text: t('common.delete'),
style: 'destructive',
onPress: async () => {
setDeleting(true);
try {
await api.properties.remove(propertyId as number);
qc.invalidateQueries({ queryKey: ['properties'] });
qc.removeQueries({ queryKey: ['property', propertyId] });
qc.removeQueries({ queryKey: ['documents', propertyId] });
qc.invalidateQueries({ queryKey: ['all-documents'] });
// The detail screen behind us is gone too.
router.replace('/(tabs)');
} catch (e: any) {
Alert.alert(t('editProperty.deleteFailed'), e?.message ?? t('common.tryAgain'));
} finally {
setDeleting(false);
}
},
},
],
);
}
function onSave() {
if (!name.trim()) {
return Alert.alert(t('addProperty.nameRequired'), t('addProperty.nameRequiredBody'));
}
save.mutate();
}
const coverUri = photo?.uri ?? existing.data?.photoUrl ?? null;
if (isEdit && existing.isLoading) {
return (
<View style={{ flex: 1, backgroundColor: colors.bgApp }}>
<AppBar title={t('editProperty.title')} back />
<Loading />
</View>
);
}
return (
<KeyboardAvoidingView
style={{ flex: 1, backgroundColor: colors.bgApp }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<AppBar title={isEdit ? t('editProperty.title') : t('addProperty.title')} back />
<ScrollView contentContainerStyle={styles.body} keyboardShouldPersistTaps="handled">
{/* Cover photo */}
<Pressable style={styles.cover} onPress={pickPhoto}>
{coverUri ? (
<>
<Image source={{ uri: coverUri }} style={styles.coverImage} resizeMode="cover" />
<View style={styles.coverEdit}>
<Ionicons name="camera" size={16} color={colors.onDark} />
<Text style={styles.coverEditText}>{t('addProperty.changePhoto')}</Text>
</View>
</>
) : (
<View style={styles.coverEmpty}>
<Ionicons name="camera-outline" size={26} color={colors.teal600} />
<Text style={styles.coverEmptyText}>{t('addProperty.addPhoto')}</Text>
</View>
)}
</Pressable>
<Field label={t('addProperty.name')}>
<TextField
icon="home-outline"
value={name}
onChangeText={setName}
placeholder={t('addProperty.namePlaceholder')}
autoCapitalize="words"
returnKeyType="next"
/>
</Field>
<Field label={t('addProperty.type')}>
<View style={styles.typeWrap}>
{TYPES.map((value) => {
const active = value === type;
return (
<Pressable
key={value}
onPress={() => setType(value)}
style={[styles.typeChip, active && styles.typeChipActive]}
>
<Text style={[styles.typeText, active && { color: colors.teal700 }]}>
{t(`propertyTypes.${value}`)}
</Text>
</Pressable>
);
})}
</View>
</Field>
<Field label={t('addProperty.address')}>
<TextField
icon="location-outline"
value={address}
onChangeText={setAddress}
placeholder={t('addProperty.addressPlaceholder')}
autoCapitalize="words"
textContentType="streetAddressLine1"
/>
</Field>
<View style={{ flexDirection: 'row', gap: spacing[3] }}>
<Field label={t('addProperty.zip')} style={{ flex: 1 }}>
<TextField
value={zip}
onChangeText={setZip}
placeholder="20121"
keyboardType="number-pad"
maxLength={10}
textContentType="postalCode"
/>
</Field>
<Field label={t('addProperty.city')} style={{ flex: 2 }}>
<TextField
value={city}
onChangeText={setCity}
placeholder="Milano"
autoCapitalize="words"
textContentType="addressCity"
/>
</Field>
</View>
<Field label={t('location.field')} optional>
<Pressable style={styles.locationRow} onPress={openMap}>
<Ionicons name={coords ? 'pin' : 'map-outline'} size={18} color={colors.teal600} />
<View style={{ flex: 1 }}>
<Text style={[styles.locationValue, !coords && styles.locationEmpty]}>
{coords
? `${coords.latitude.toFixed(5)}, ${coords.longitude.toFixed(5)}`
: t('location.notSet')}
</Text>
</View>
<Text style={styles.locationAction}>
{coords ? t('location.change') : t('location.set')}
</Text>
</Pressable>
</Field>
<Field label={t('addProperty.description')} optional>
<TextField
value={description}
onChangeText={setDescription}
placeholder={t('addProperty.descriptionPlaceholder')}
multiline
numberOfLines={4}
style={{ minHeight: 90, textAlignVertical: 'top' }}
/>
</Field>
<Button
title={isEdit ? t('editProperty.save') : t('addProperty.save')}
icon="checkmark"
size="lg"
block
loading={save.isPending}
onPress={onSave}
style={{ marginTop: spacing[2] }}
/>
{isEdit ? (
<Button
title={t('editProperty.delete')}
variant="danger"
icon="trash-outline"
block
loading={deleting}
onPress={confirmDelete}
style={{ marginTop: spacing[3] }}
/>
) : null}
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
body: { padding: spacing[4], paddingBottom: spacing[9] },
cover: {
height: 150,
borderRadius: radii.lg,
overflow: 'hidden',
marginBottom: spacing[5],
backgroundColor: colors.surface,
borderWidth: 1.5,
borderColor: colors.border,
borderStyle: 'dashed',
},
coverImage: { width: '100%', height: '100%' },
coverEmpty: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 8 },
coverEmptyText: { fontSize: fontSize.sm, fontWeight: '600', color: colors.teal700 },
coverEdit: {
position: 'absolute',
right: spacing[3],
bottom: spacing[3],
flexDirection: 'row',
alignItems: 'center',
gap: 6,
backgroundColor: 'rgba(18,27,36,0.72)',
paddingHorizontal: 12,
height: 30,
borderRadius: radii.pill,
},
coverEditText: { color: colors.onDark, fontSize: fontSize.xs, fontWeight: '600' },
locationRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
borderRadius: radii.md,
paddingHorizontal: spacing[4],
minHeight: 50,
},
locationValue: { fontSize: fontSize.md, color: colors.ink, fontWeight: '600' },
locationEmpty: { color: colors.textMuted2, fontWeight: '400' },
locationAction: { fontSize: fontSize.sm, fontWeight: '600', color: colors.teal700 },
typeWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
typeChip: {
paddingHorizontal: 14,
height: 36,
borderRadius: radii.pill,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.surface,
alignItems: 'center',
justifyContent: 'center',
},
typeChipActive: { backgroundColor: colors.teal50, borderColor: colors.teal300 },
typeText: { fontSize: fontSize.sm, fontWeight: '600', color: colors.slate600 },
});