431 lines
14 KiB
TypeScript
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 },
|
|
});
|