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

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] },
});