284 lines
10 KiB
TypeScript
284 lines
10 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import { Alert, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import * as DocumentPicker from 'expo-document-picker';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { api, DocCategory, Document, LocalFile } from '@/api';
|
|
import { useT } from '@/i18n';
|
|
import { AppBar } from '@/components/AppBar';
|
|
import { Button, Field, Loading, TextField } from '@/components/ui';
|
|
import { DateField } from '@/components/DateField';
|
|
import { FileGlyph } from '@/components/domain';
|
|
import { clearCachedFile } from '@/lib/documentFile';
|
|
import { pickImageFromLibrary } from '@/lib/pickImage';
|
|
import { isCameraBlocked, openPermissionSettings } from '@/lib/permissions';
|
|
import { scanDocumentToPdf } from '@/scanner/scanToPdf';
|
|
import { formatBytes } from '@/lib/format';
|
|
import { categoryColor, colors, fontSize, radii, spacing } from '@/theme';
|
|
|
|
export default function EditDocumentScreen() {
|
|
const { id, propertyId } = useLocalSearchParams<{ id: string; propertyId?: string }>();
|
|
const docId = Number(id);
|
|
const pid = propertyId ? Number(propertyId) : undefined;
|
|
const router = useRouter();
|
|
const qc = useQueryClient();
|
|
const t = useT();
|
|
|
|
// From the property's cached list: the backend has no per-file GET.
|
|
const listQuery = useQuery({
|
|
queryKey: ['documents', pid],
|
|
queryFn: () => api.documents.listByProperty(pid as number),
|
|
enabled: pid != null,
|
|
});
|
|
const doc: Document | undefined = listQuery.data?.find((d: Document) => d.id === docId);
|
|
|
|
const catsQuery = useQuery({ queryKey: ['categories'], queryFn: () => api.reference.categories() });
|
|
const categories: DocCategory[] = catsQuery.data ?? [];
|
|
|
|
const [expiry, setExpiry] = useState<string | null>(null);
|
|
const [note, setNote] = useState('');
|
|
const [title, setTitle] = useState('');
|
|
const [category, setCategory] = useState<string>('');
|
|
const [loaded, setLoaded] = useState(false);
|
|
const [replacing, setReplacing] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!doc || loaded) return;
|
|
setExpiry(doc.expiryDate ?? null);
|
|
setNote(doc.note ?? '');
|
|
setTitle(doc.name ?? '');
|
|
setCategory(doc.category);
|
|
setLoaded(true);
|
|
}, [doc, loaded]);
|
|
|
|
const save = useMutation({
|
|
mutationFn: () =>
|
|
api.documents.update({
|
|
id: docId,
|
|
title: title.trim() || null,
|
|
expiryDate: expiry,
|
|
note: note.trim() || null,
|
|
// Re-filing costs an extra backend lookup, so only send it if changed.
|
|
...(doc && category !== doc.category ? { category } : {}),
|
|
}),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['documents'] });
|
|
qc.invalidateQueries({ queryKey: ['all-documents'] });
|
|
router.back();
|
|
},
|
|
onError: (e: any) => Alert.alert(t('editDocument.failed'), e?.message ?? t('common.tryAgain')),
|
|
});
|
|
|
|
// A separate endpoint from the metadata update, so it applies immediately.
|
|
async function replaceWith(file: LocalFile | null) {
|
|
if (!file || !doc) return;
|
|
setReplacing(true);
|
|
try {
|
|
await api.documents.replaceFile(doc.id, file);
|
|
await clearCachedFile(doc); // otherwise the old copy stays on screen
|
|
qc.invalidateQueries({ queryKey: ['documents'] });
|
|
qc.invalidateQueries({ queryKey: ['all-documents'] });
|
|
qc.removeQueries({ queryKey: ['document-file', doc.id] });
|
|
Alert.alert(t('editDocument.replaced'));
|
|
} catch (e: any) {
|
|
Alert.alert(t('editDocument.replaceFailed'), e?.message ?? t('common.tryAgain'));
|
|
} finally {
|
|
setReplacing(false);
|
|
}
|
|
}
|
|
|
|
async function replaceByScan() {
|
|
try {
|
|
const result = await scanDocumentToPdf(doc?.name || t('upload.scannedName'));
|
|
await replaceWith(result?.file ?? null);
|
|
} catch (e: any) {
|
|
if (await isCameraBlocked()) openPermissionSettings('permissions.cameraBody');
|
|
else Alert.alert(t('upload.scannerUnavailable'), e?.message ?? t('upload.scannerUnavailableBody'));
|
|
}
|
|
}
|
|
|
|
async function replaceByFile() {
|
|
const res = await DocumentPicker.getDocumentAsync({
|
|
type: ['application/pdf', 'image/*'],
|
|
copyToCacheDirectory: true,
|
|
});
|
|
if (res.canceled || !res.assets?.length) return;
|
|
const a = res.assets[0];
|
|
await replaceWith({
|
|
uri: a.uri,
|
|
name: a.name,
|
|
mimeType: a.mimeType ?? 'application/octet-stream',
|
|
sizeBytes: a.size ?? 0,
|
|
});
|
|
}
|
|
|
|
async function replaceByPhoto() {
|
|
await replaceWith(await pickImageFromLibrary({ quality: 0.9, fallbackName: 'photo' }));
|
|
}
|
|
|
|
if (!doc) {
|
|
return (
|
|
<View style={{ flex: 1, backgroundColor: colors.bgApp }}>
|
|
<AppBar title={t('editDocument.title')} back />
|
|
<Loading />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<KeyboardAvoidingView
|
|
style={{ flex: 1, backgroundColor: colors.bgApp }}
|
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
|
>
|
|
<AppBar title={t('editDocument.title')} subtitle={doc.name} back />
|
|
<ScrollView contentContainerStyle={styles.body} keyboardShouldPersistTaps="handled">
|
|
{/* Current file + replacement */}
|
|
<Field label={t('editDocument.file')}>
|
|
<View style={styles.fileCard}>
|
|
<FileGlyph mimeType={doc.mimeType} size={38} />
|
|
<View style={{ flex: 1, marginLeft: spacing[3] }}>
|
|
<Text style={styles.fileName} numberOfLines={1}>
|
|
{doc.name}
|
|
</Text>
|
|
<Text style={styles.fileMeta}>{formatBytes(doc.sizeBytes)}</Text>
|
|
</View>
|
|
</View>
|
|
<View style={styles.replaceRow}>
|
|
<ReplaceButton icon="scan-outline" label={t('upload.scan')} onPress={replaceByScan} busy={replacing} />
|
|
<ReplaceButton icon="document-outline" label={t('upload.chooseFile')} onPress={replaceByFile} busy={replacing} />
|
|
<ReplaceButton icon="image-outline" label={t('upload.gallery')} onPress={replaceByPhoto} busy={replacing} />
|
|
</View>
|
|
<Text style={styles.replaceHint}>{t('editDocument.replaceHint')}</Text>
|
|
</Field>
|
|
|
|
<Field label={t('editDocument.name')}>
|
|
<TextField
|
|
value={title}
|
|
onChangeText={setTitle}
|
|
placeholder={t('editDocument.namePlaceholder')}
|
|
autoCapitalize="sentences"
|
|
/>
|
|
</Field>
|
|
|
|
<Field label={t('upload.expiry')} optional>
|
|
<DateField
|
|
value={expiry}
|
|
onChange={setExpiry}
|
|
placeholder={t('editDocument.noExpiry')}
|
|
/>
|
|
</Field>
|
|
|
|
<Field label={t('upload.category')}>
|
|
<View style={styles.catWrap}>
|
|
{categories.map((c) => {
|
|
const active = c.slug === category;
|
|
return (
|
|
<Pressable
|
|
key={c.slug}
|
|
onPress={() => setCategory(c.slug)}
|
|
style={[
|
|
styles.catChip,
|
|
active && { backgroundColor: categoryColor(c.slug) + '18', borderColor: categoryColor(c.slug) },
|
|
]}
|
|
>
|
|
<View style={[styles.catDot, { backgroundColor: categoryColor(c.slug) }]} />
|
|
<Text style={[styles.catText, active && { color: categoryColor(c.slug) }]}>{c.name}</Text>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</View>
|
|
</Field>
|
|
|
|
<Field label={t('editDocument.note')} optional>
|
|
<TextField
|
|
value={note}
|
|
onChangeText={setNote}
|
|
placeholder={t('editDocument.notePlaceholder')}
|
|
multiline
|
|
numberOfLines={4}
|
|
style={{ minHeight: 90, textAlignVertical: 'top' }}
|
|
/>
|
|
</Field>
|
|
|
|
<Text style={styles.hint}>{t('editDocument.hint')}</Text>
|
|
|
|
<Button
|
|
title={t('editDocument.save')}
|
|
icon="checkmark"
|
|
size="lg"
|
|
block
|
|
loading={save.isPending}
|
|
onPress={() => save.mutate()}
|
|
style={{ marginTop: spacing[2] }}
|
|
/>
|
|
</ScrollView>
|
|
</KeyboardAvoidingView>
|
|
);
|
|
}
|
|
|
|
function ReplaceButton({
|
|
icon,
|
|
label,
|
|
onPress,
|
|
busy,
|
|
}: {
|
|
icon: keyof typeof Ionicons.glyphMap;
|
|
label: string;
|
|
onPress: () => void;
|
|
busy?: boolean;
|
|
}) {
|
|
return (
|
|
<Pressable
|
|
onPress={busy ? undefined : onPress}
|
|
style={({ pressed }) => [styles.replaceBtn, (pressed || busy) && { opacity: 0.6 }]}
|
|
>
|
|
<Ionicons name={icon} size={20} color={colors.teal600} />
|
|
<Text style={styles.replaceLabel} numberOfLines={1}>
|
|
{label}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
body: { padding: spacing[4], paddingBottom: spacing[9] },
|
|
catWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
|
|
catChip: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 7,
|
|
paddingHorizontal: 12,
|
|
height: 36,
|
|
borderRadius: radii.pill,
|
|
borderWidth: 1,
|
|
borderColor: colors.border,
|
|
backgroundColor: colors.surface,
|
|
},
|
|
catDot: { width: 8, height: 8, borderRadius: 4 },
|
|
catText: { fontSize: fontSize.sm, fontWeight: '600', color: colors.slate600 },
|
|
hint: { fontSize: fontSize.xs, color: colors.textMuted, marginBottom: spacing[3] },
|
|
fileCard: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
backgroundColor: colors.surfaceSunken,
|
|
borderRadius: radii.md,
|
|
padding: spacing[3],
|
|
},
|
|
fileName: { fontSize: fontSize.sm, fontWeight: '600', color: colors.ink },
|
|
fileMeta: { fontSize: fontSize.xs, color: colors.textMuted, marginTop: 2 },
|
|
replaceRow: { flexDirection: 'row', gap: spacing[2], marginTop: spacing[3] },
|
|
replaceBtn: {
|
|
flex: 1,
|
|
alignItems: 'center',
|
|
gap: 6,
|
|
paddingVertical: spacing[3],
|
|
borderRadius: radii.md,
|
|
borderWidth: 1,
|
|
borderColor: colors.border,
|
|
backgroundColor: colors.surface,
|
|
},
|
|
replaceLabel: { fontSize: fontSize.xs, fontWeight: '600', color: colors.ink },
|
|
replaceHint: { fontSize: fontSize.xs, color: colors.textMuted, marginTop: spacing[2] },
|
|
});
|