Files
casadoc-app/app/property/[id].tsx
T
2026-08-26 23:01:07 +03:00

228 lines
9.1 KiB
TypeScript

import React, { useMemo } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useQuery } from '@tanstack/react-query';
import { api, DocCategory, Document } from '@/api';
import { useT } from '@/i18n';
import { AppBar } from '@/components/AppBar';
import { Button, Card, IconButton, Loading, SectionTitle } from '@/components/ui';
import { DocumentRow } from '@/components/domain';
import { PropertyCover } from '@/components/PropertyCover';
import { categoryColor, colors, fontSize, radii, shadow, spacing, tint } from '@/theme';
export default function PropertyDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const propertyId = Number(id);
const router = useRouter();
const t = useT();
const propertyQuery = useQuery({
queryKey: ['property', propertyId],
queryFn: () => api.properties.get(propertyId),
});
const docsQuery = useQuery({
queryKey: ['documents', propertyId],
queryFn: () => api.documents.listByProperty(propertyId),
});
const catsQuery = useQuery({ queryKey: ['categories'], queryFn: () => api.reference.categories() });
const docs: Document[] = docsQuery.data ?? [];
const categories: DocCategory[] = catsQuery.data ?? [];
const catName = (slug: string) => categories.find((c) => c.slug === slug)?.name ?? slug;
const folders = useMemo(() => {
const map = new Map<string, { count: number; expiring: number }>();
for (const d of docs) {
const entry = map.get(d.category) ?? { count: 0, expiring: 0 };
entry.count += 1;
if (d.expiryStatus === 'expiring' || d.expiryStatus === 'expired') entry.expiring += 1;
map.set(d.category, entry);
}
return Array.from(map.entries()).map(([slug, v]) => ({ slug, ...v }));
}, [docs]);
const expiringDocs = docs.filter((d) => d.expiryStatus === 'expiring' || d.expiryStatus === 'expired');
const recent = [...docs]
.sort((a, b) => (b.uploadedAt > a.uploadedAt ? 1 : -1))
.slice(0, 3);
const property = propertyQuery.data;
const photoMissing = !property?.photoUrl;
const addressLine = [property?.address, property?.zip, property?.city].filter(Boolean).join(', ');
return (
<View style={{ flex: 1, backgroundColor: colors.bgApp }}>
<AppBar
title={property?.name ?? t('property.fallbackTitle')}
back
right={
<IconButton
name="create-outline"
onPress={() => router.push(`/property/edit/${propertyId}`)}
/>
}
/>
{propertyQuery.isLoading ? (
<Loading />
) : (
<ScrollView contentContainerStyle={styles.body}>
{/* Tapping an empty cover is the shortest path to adding a photo. */}
<Pressable
onPress={photoMissing ? () => router.push(`/property/edit/${propertyId}`) : undefined}
style={styles.coverWrap}
>
<PropertyCover photoUrl={property?.photoUrl} type={property?.type} height={150} />
</Pressable>
<Text style={styles.name}>{property?.name}</Text>
{addressLine ? (
<View style={styles.addrRow}>
<Ionicons name="location-outline" size={15} color={colors.slate500} />
<Text style={styles.addr}>{addressLine}</Text>
</View>
) : (
// Missing data is an invitation to fill it in, not a dash.
<Pressable
style={styles.addrRow}
onPress={() => router.push(`/property/edit/${propertyId}`)}
>
<Ionicons name="add-circle-outline" size={15} color={colors.teal600} />
<Text style={styles.addrAdd}>{t('property.addAddress')}</Text>
</Pressable>
)}
<Button
title={t('property.addDocument')}
icon="cloud-upload-outline"
block
onPress={() => router.push(`/upload?propertyId=${propertyId}`)}
style={{ marginTop: spacing[5] }}
/>
{expiringDocs.length > 0 ? (
<Card
style={styles.warnBanner}
// One document opens directly; several open the filtered list.
onPress={() =>
expiringDocs.length === 1
? router.push(`/document/${expiringDocs[0].id}?propertyId=${propertyId}`)
: router.push(`/documents/${propertyId}?status=attention`)
}
>
<Ionicons name="time-outline" size={20} color={colors.warningText} />
<View style={{ flex: 1 }}>
<Text style={styles.warnTitle}>
{t('property.needAttention', { count: expiringDocs.length })}
</Text>
<Text style={styles.warnText} numberOfLines={2}>
{expiringDocs.map((d) => d.name).join(' · ')}
</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={colors.warningText} />
</Card>
) : null}
<SectionTitle title={t('property.categories')} action={<Text style={styles.link}>{t('property.folders', { count: folders.length })}</Text>} />
{folders.length === 0 ? (
<Card>
<Text style={styles.emptyFolders}>{t('property.noDocuments')}</Text>
</Card>
) : (
<View style={styles.folderGrid}>
{folders.map((f) => (
<Pressable
key={f.slug}
style={styles.folder}
onPress={() => router.push(`/documents/${propertyId}?category=${f.slug}`)}
>
{/* Accent bar carries the category colour, as in the deck. */}
<View style={[styles.folderAccent, { backgroundColor: categoryColor(f.slug) }]} />
<View style={[styles.folderIcon, { backgroundColor: tint(categoryColor(f.slug)) }]}>
<Ionicons name="folder" size={20} color={categoryColor(f.slug)} />
</View>
<Text style={styles.folderName}>{catName(f.slug)}</Text>
<Text style={[styles.folderCount, f.expiring > 0 && { color: colors.warningText, fontWeight: '600' }]}>
{t('properties.docCount', { count: f.count })}
{f.expiring > 0 ? ` · ${t('properties.expiringCount', { count: f.expiring })}` : ''}
</Text>
</Pressable>
))}
</View>
)}
<SectionTitle
title={t('property.recent')}
action={
docs.length > 0 ? (
<Pressable onPress={() => router.push(`/documents/${propertyId}`)}>
<Text style={styles.link}>{t('common.seeAll')}</Text>
</Pressable>
) : undefined
}
/>
{recent.length === 0 ? (
<Card>
<Text style={styles.emptyFolders}>{t('property.noRecent')}</Text>
</Card>
) : (
<View style={{ gap: spacing[3] }}>
{recent.map((d: Document) => (
<DocumentRow
key={d.id}
document={d}
onPress={() => router.push(`/document/${d.id}?propertyId=${propertyId}`)}
/>
))}
</View>
)}
</ScrollView>
)}
</View>
);
}
const styles = StyleSheet.create({
body: { padding: spacing[4], paddingBottom: spacing[9] },
coverWrap: { borderRadius: radii.lg, overflow: 'hidden', marginBottom: spacing[4] },
name: { fontSize: fontSize['2xl'], fontWeight: '700', color: colors.ink },
addrRow: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 4 },
addr: { fontSize: fontSize.base, color: colors.slate600, flex: 1 },
addrAdd: { fontSize: fontSize.base, color: colors.teal700, fontWeight: '600' },
warnBanner: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing[3],
backgroundColor: colors.warningBg,
borderColor: '#f0dcae',
marginTop: spacing[5],
},
warnTitle: { fontSize: fontSize.md, fontWeight: '700', color: colors.warningText },
warnText: { fontSize: fontSize.sm, color: colors.warningText, opacity: 0.85, marginTop: 2 },
link: { color: colors.teal700, fontWeight: '600', fontSize: fontSize.sm },
folderGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing[3] },
folder: {
width: '47.8%',
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
borderRadius: radii.md,
padding: spacing[4],
overflow: 'hidden',
...shadow.xs,
},
folderAccent: { position: 'absolute', left: 0, top: 0, bottom: 0, width: 4 },
folderIcon: {
width: 40,
height: 40,
borderRadius: 11,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 10,
},
folderName: { fontSize: fontSize.base, fontWeight: '600', color: colors.ink },
folderCount: { fontSize: fontSize.xs, color: colors.textMuted, marginTop: 3 },
emptyFolders: { fontSize: fontSize.sm, color: colors.textMuted, textAlign: 'center', paddingVertical: spacing[3] },
});