228 lines
9.1 KiB
TypeScript
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] },
|
|
});
|