Yaltopia-Tickets-App/app/declarations/edit.tsx
2026-06-05 13:39:37 +03:00

824 lines
28 KiB
TypeScript

import React, { useState, useEffect, useMemo } from "react";
import {
View,
Pressable,
TextInput,
StyleSheet,
ActivityIndicator,
ScrollView,
Modal,
Dimensions,
} from "react-native";
import { useColorScheme } from "nativewind";
import * as DocumentPicker from "expo-document-picker";
import { Text } from "@/components/ui/text";
import {
ChevronDown,
Upload,
X,
FileText,
Plus,
Link2,
Search,
} from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { useSirouRouter } from "@sirou/react-native";
import { useLocalSearchParams } from "expo-router";
import { AppRoutes } from "@/lib/routes";
import { api, BASE_URL } from "@/lib/api";
import { toast } from "@/lib/toast-store";
import { useAuthStore } from "@/lib/auth-store";
import { PickerModal, SelectOption } from "@/components/PickerModal";
import { FormFlow } from "@/components/FormFlow";
import { getPlaceholderColor } from "@/lib/colors";
const { height: SCREEN_HEIGHT } = Dimensions.get("window");
type DeclarationType = "VAT" | "WITHHOLDING_TAX";
type Period = "MONTHLY" | "QUARTERLY" | "SEMI_ANNUAL" | "ANNUAL" | "ONE_TIME" | "";
type FileEntry = { uri: string; name: string; type: string };
const DECLARATION_TYPES: SelectOption[] = [
{ label: "VAT", value: "VAT" },
{ label: "Withholding Tax", value: "WITHHOLDING_TAX" },
];
const PERIODS: SelectOption[] = [
{ label: "Monthly", value: "MONTHLY" },
{ label: "Quarterly", value: "QUARTERLY" },
{ label: "Semi-Annual", value: "SEMI_ANNUAL" },
{ label: "Annual", value: "ANNUAL" },
{ label: "One-Time", value: "ONE_TIME" },
];
const S = StyleSheet.create({
input: {
paddingHorizontal: 12,
paddingVertical: 12,
fontSize: 12,
fontWeight: "500",
borderRadius: 6,
borderWidth: 1,
textAlignVertical: "center",
},
});
function useInputColors() {
const { colorScheme } = useColorScheme();
const dark = colorScheme === "dark";
return {
bg: dark ? "rgba(30,30,30,0.8)" : "rgba(241,245,249,0.2)",
border: dark ? "rgba(255,255,255,0.08)" : "rgba(203,213,225,0.6)",
text: dark ? "#f1f5f9" : "#0f172a",
placeholder: "rgba(100,116,139,0.45)",
};
}
function Field({
label,
value,
onChangeText,
placeholder,
numeric = false,
flex,
multiline = false,
required = false,
}: {
label: string;
value: string;
onChangeText: (v: string) => void;
placeholder: string;
numeric?: boolean;
flex?: number;
multiline?: boolean;
required?: boolean;
}) {
const c = useInputColors();
return (
<View style={flex != null ? { flex } : undefined}>
<Text className="text-[14px] font-sans-bold mb-1.5 ml-1 text-foreground">
{label}
{required && <Text className="text-red-500"> *</Text>}
</Text>
<TextInput
style={[
S.input,
{ backgroundColor: c.bg, borderColor: c.border, color: c.text },
multiline ? { height: 80, paddingTop: 10 } : {},
]}
placeholder={placeholder}
placeholderTextColor={c.placeholder}
value={value}
onChangeText={onChangeText}
keyboardType={numeric ? "numeric" : "default"}
multiline={multiline}
autoCorrect={false}
autoCapitalize="none"
/>
</View>
);
}
function PickerField({
label,
value,
onPress,
required = false,
}: {
label: string;
value: string;
onPress: () => void;
required?: boolean;
}) {
const c = useInputColors();
return (
<View className="flex-1">
<Text className="text-[14px] font-sans-bold mb-1.5 ml-1 text-foreground">
{label}
{required && <Text className="text-red-500"> *</Text>}
</Text>
<Pressable
onPress={onPress}
className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between"
style={{ backgroundColor: c.bg, borderColor: c.border }}
>
<Text className="text-xs font-sans-bold" style={{ color: c.text }}>
{value}
</Text>
<ChevronDown size={14} color={c.text} strokeWidth={3} />
</Pressable>
</View>
);
}
export default function EditDeclarationScreen() {
const nav = useSirouRouter<AppRoutes>();
const { id } = useLocalSearchParams<{ id: string }>();
const { colorScheme } = useColorScheme();
const isDark = colorScheme === "dark";
const c = useInputColors();
const [initialLoading, setInitialLoading] = useState(true);
const [step, setStep] = useState(0);
const [submitting, setSubmitting] = useState(false);
const [declarationNumber, setDeclarationNumber] = useState("");
const [type, setType] = useState<DeclarationType>("VAT");
const [title, setTitle] = useState("");
const [period, setPeriod] = useState<Period>("");
const [periodStart, setPeriodStart] = useState("");
const [periodEnd, setPeriodEnd] = useState("");
const [tin, setTin] = useState("");
const [taxAccountNumber, setTaxAccountNumber] = useState("");
const [taxCentre, setTaxCentre] = useState("");
const [selectedInvoices, setSelectedInvoices] = useState<any[]>([]);
const [declarationFile, setDeclarationFile] = useState<FileEntry | null>(null);
const [receiptFiles, setReceiptFiles] = useState<FileEntry[]>([]);
const [suggestedFilename, setSuggestedFilename] = useState("");
const [notes, setNotes] = useState("");
const [dueDate, setDueDate] = useState("");
const [showTypePicker, setShowTypePicker] = useState(false);
const [showPeriodPicker, setShowPeriodPicker] = useState(false);
const [showInvoicePicker, setShowInvoicePicker] = useState(false);
const [invoices, setInvoices] = useState<any[]>([]);
const [invoiceSearch, setInvoiceSearch] = useState("");
const [loadingInvoices, setLoadingInvoices] = useState(false);
useEffect(() => {
const load = async () => {
try {
const res = await api.declarations.getById({ params: { id } });
const d = res?.data ?? res;
if (!d) throw new Error("Not found");
setDeclarationNumber(d.declarationNumber || "");
setType(d.type || "VAT");
setTitle(d.title || "");
setPeriod(d.period || "");
setPeriodStart(
d.periodStart
? new Date(d.periodStart).toISOString().split("T")[0]
: "",
);
setPeriodEnd(
d.periodEnd
? new Date(d.periodEnd).toISOString().split("T")[0]
: "",
);
setTin(d.tin || "");
setTaxAccountNumber(d.taxAccountNumber || "");
setTaxCentre(d.taxCentre || "");
setSelectedInvoices(d.invoices || []);
setNotes(d.notes || "");
setDueDate(
d.dueDate
? new Date(d.dueDate).toISOString().split("T")[0]
: "",
);
setSuggestedFilename(d.suggestedFilename || "");
} catch (err: any) {
toast.error("Error", "Failed to load declaration");
nav.back();
} finally {
setInitialLoading(false);
}
};
load();
}, [id]);
const steps = [
{ key: "details", label: "Declaration Detail" },
{ key: "tax", label: "Tax Info" },
{ key: "files", label: "Files" },
{ key: "notes", label: "Notes" },
{ key: "review", label: "Review" },
];
const openInvoicePicker = async () => {
setLoadingInvoices(true);
try {
const response = await api.invoices.getAll({ query: { limit: 50 } });
setInvoices(Array.isArray(response) ? response : response.data || []);
setInvoiceSearch("");
setShowInvoicePicker(true);
} catch {
toast.error("Error", "Failed to fetch invoices");
} finally {
setLoadingInvoices(false);
}
};
const toggleInvoice = (inv: any) => {
setSelectedInvoices((prev) =>
prev.find((i) => i.id === inv.id)
? prev.filter((i) => i.id !== inv.id)
: [...prev, inv],
);
};
const filteredInvoices = useMemo(() => {
if (!invoiceSearch) return invoices;
const q = invoiceSearch.toLowerCase();
return invoices.filter(
(inv) =>
(inv.invoiceNumber || "").toLowerCase().includes(q) ||
(inv.customerName || "").toLowerCase().includes(q),
);
}, [invoices, invoiceSearch]);
const pickFile = async () => {
try {
const result = await DocumentPicker.getDocumentAsync({
type: "application/pdf",
copyToCacheDirectory: true,
});
if (!result.canceled && result.assets?.length > 0) {
const asset = result.assets[0];
setDeclarationFile({
uri: asset.uri,
name: asset.name,
type: asset.mimeType || "application/pdf",
});
}
} catch {
toast.warning("Picker", "Could not open document picker");
}
};
const pickReceipts = async () => {
try {
const result = await DocumentPicker.getDocumentAsync({
type: "*/*",
copyToCacheDirectory: true,
multiple: true,
});
if (!result.canceled && result.assets?.length > 0) {
setReceiptFiles((prev) => [
...prev,
...result.assets.map((a: any) => ({
uri: a.uri,
name: a.name,
type: a.mimeType || "application/octet-stream",
})),
]);
}
} catch {
toast.warning("Picker", "Could not open document picker");
}
};
const handleNext = () => {
if (step === 0) {
if (!declarationNumber.trim()) {
toast.error("Validation", "Declaration number is required");
return;
}
if (!title.trim()) {
toast.error("Validation", "Title is required");
return;
}
if (!periodStart.trim()) {
toast.error("Validation", "Period start is required");
return;
}
if (!periodEnd.trim()) {
toast.error("Validation", "Period end is required");
return;
}
}
setStep(step + 1);
};
const handleSubmit = async () => {
setSubmitting(true);
try {
const payload: Record<string, any> = {
declarationNumber: declarationNumber.trim(),
type,
period,
title: title.trim(),
periodStart: new Date(periodStart).toISOString(),
periodEnd: new Date(periodEnd).toISOString(),
};
if (tin.trim()) payload.tin = tin.trim();
if (taxAccountNumber.trim()) payload.taxAccountNumber = taxAccountNumber.trim();
if (taxCentre.trim()) payload.taxCentre = taxCentre.trim();
if (notes.trim()) payload.notes = notes.trim();
if (dueDate.trim()) payload.dueDate = new Date(dueDate).toISOString();
if (suggestedFilename.trim()) payload.suggestedFilename = suggestedFilename.trim();
if (selectedInvoices.length > 0) {
payload.invoiceIds = selectedInvoices.map((i) => i.id);
}
const hasFiles = declarationFile !== null || receiptFiles.length > 0;
if (hasFiles) {
const { accessToken } = useAuthStore.getState();
const formData = new FormData();
formData.append("data", JSON.stringify(payload));
if (declarationFile) {
formData.append("file", {
uri: declarationFile.uri,
name: declarationFile.name,
type: declarationFile.type,
} as any);
}
receiptFiles.forEach((rf) => {
formData.append("receipts", {
uri: rf.uri,
name: rf.name,
type: rf.type,
} as any);
});
const response = await fetch(`${BASE_URL}declarations/${id}`, {
method: "PUT",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "multipart/form-data",
},
body: formData,
});
if (!response.ok) throw new Error(await response.text());
} else {
await api.declarations.update({ params: { id }, body: payload });
}
toast.success("Success", "Declaration updated");
nav.back();
} catch (error: any) {
toast.error(
"Error",
error?.response?.data?.message ||
error?.data?.message ||
error?.message ||
"Failed to update declaration",
);
} finally {
setSubmitting(false);
}
};
if (initialLoading) {
return (
<ScreenWrapper className="bg-background">
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color="#E46212" />
</View>
</ScreenWrapper>
);
}
return (
<ScreenWrapper className="bg-background">
<FormFlow
steps={steps}
currentStep={step}
onNext={handleNext}
onBack={() => setStep(step - 1)}
onComplete={handleSubmit}
loading={submitting}
completeLabel="Update Declaration"
>
{step === 0 && (
<View className="gap-5">
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
Declaration Detail
</Text>
<View className="gap-4">
<Field
label="Declaration Number"
value={declarationNumber}
onChangeText={setDeclarationNumber}
placeholder="e.g. MOR-2024-001"
required
/>
<View className="flex-row gap-4">
<PickerField
label="Type"
value={type === "WITHHOLDING_TAX" ? "Withholding Tax" : type}
onPress={() => setShowTypePicker(true)}
/>
<PickerField
label="Period"
value={period || "Select"}
onPress={() => setShowPeriodPicker(true)}
/>
</View>
<Field
label="Title"
value={title}
onChangeText={setTitle}
placeholder="e.g. Monthly VAT - Jan 2024"
required
/>
<View className="flex-row gap-4">
<Field
label="Period Start"
value={periodStart}
onChangeText={setPeriodStart}
placeholder="2024-01-01"
required
flex={1}
/>
<Field
label="Period End"
value={periodEnd}
onChangeText={setPeriodEnd}
placeholder="2024-01-31"
required
flex={1}
/>
</View>
<Field
label="Due Date"
value={dueDate}
onChangeText={setDueDate}
placeholder="2024-02-15 (optional)"
/>
</View>
</View>
)}
{step === 1 && (
<View className="gap-5">
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
Tax Info
</Text>
<View className="gap-4">
<View className="flex-row gap-4">
<Field
label="TIN"
value={tin}
onChangeText={setTin}
placeholder="Tax ID (optional)"
flex={1}
/>
<Field
label="Tax Account"
value={taxAccountNumber}
onChangeText={setTaxAccountNumber}
placeholder="Account # (optional)"
flex={1}
/>
</View>
<Field
label="Tax Centre"
value={taxCentre}
onChangeText={setTaxCentre}
placeholder="e.g. Addis Ababa (optional)"
/>
<View>
<Text className="text-[14px] font-sans-bold mb-1.5 ml-1 text-foreground">
Link Invoices
</Text>
<Pressable
onPress={openInvoicePicker}
className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between"
style={{ backgroundColor: c.bg, borderColor: c.border }}
>
{loadingInvoices ? (
<ActivityIndicator color="#E46212" size="small" />
) : (
<>
<Text
className="text-xs font-sans-medium flex-1"
style={{
color:
selectedInvoices.length > 0
? c.text
: c.placeholder,
}}
numberOfLines={1}
>
{selectedInvoices.length > 0
? `${selectedInvoices.length} invoice${selectedInvoices.length > 1 ? "s" : ""} linked`
: "Select invoices to link (optional)"}
</Text>
<Link2 size={14} color="#E46212" strokeWidth={2.5} />
</>
)}
</Pressable>
</View>
</View>
</View>
)}
{step === 2 && (
<View className="gap-5">
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
File Uploads
</Text>
<View className="gap-4">
<View>
<Text className="text-[14px] font-sans-bold mb-1.5 ml-1 text-foreground">
MoR Declaration PDF
</Text>
{declarationFile ? (
<View className="flex-row items-center px-3 py-2.5 rounded-[6px] border border-border bg-card">
<FileText size={16} color="#E46212" strokeWidth={2} />
<Text
className="flex-1 text-xs font-sans-medium ml-2 text-foreground"
numberOfLines={1}
>
{declarationFile.name}
</Text>
<Pressable onPress={() => setDeclarationFile(null)}>
<X size={14} color="#ef4444" strokeWidth={2.5} />
</Pressable>
</View>
) : (
<Pressable
onPress={pickFile}
className="h-14 rounded-[6px] border border-dashed border-border items-center justify-center"
>
<Upload size={18} color="#E46212" strokeWidth={2} />
<Text className="text-primary text-xs font-sans-bold mt-1">
Tap to select PDF
</Text>
</Pressable>
)}
</View>
<View>
<Text className="text-[14px] font-sans-bold mb-1.5 ml-1 text-foreground">
Payment Receipts
</Text>
{receiptFiles.length > 0 && (
<View className="gap-2 mb-3">
{receiptFiles.map((rf, idx) => (
<View
key={idx}
className="flex-row items-center px-3 py-2.5 rounded-[6px] border border-border bg-card"
>
<FileText size={14} color="#94a3b8" strokeWidth={2} />
<Text
className="flex-1 text-xs font-sans-medium ml-2 text-foreground"
numberOfLines={1}
>
{rf.name}
</Text>
<Pressable
onPress={() =>
setReceiptFiles((p) =>
p.filter((_, i) => i !== idx),
)
}
>
<X size={14} color="#ef4444" strokeWidth={2.5} />
</Pressable>
</View>
))}
</View>
)}
<Pressable
onPress={pickReceipts}
className="h-12 rounded-[6px] border border-dashed border-border items-center justify-center flex-row gap-2"
>
<Plus size={16} color="#E46212" strokeWidth={2.5} />
<Text className="text-primary text-xs font-sans-bold">
Add Receipt
</Text>
</Pressable>
</View>
</View>
</View>
)}
{step === 3 && (
<View className="gap-5">
<Field
label="Notes"
value={notes}
onChangeText={setNotes}
placeholder="Any additional notes (optional)"
multiline
/>
</View>
)}
{step === 4 && (
<View className="gap-5">
<Text className="text-[18px] font-sans-bold text-foreground tracking-tight">
Summary
</Text>
<View className="bg-card rounded-[6px] border border-border p-4 gap-3">
<SummaryRow label="Declaration #" value={declarationNumber} />
<SummaryRow
label="Type"
value={type === "WITHHOLDING_TAX" ? "Withholding Tax" : type}
/>
<SummaryRow label="Title" value={title} />
<SummaryRow label="Period" value={period} />
<SummaryRow label="Period Start" value={periodStart} />
<SummaryRow label="Period End" value={periodEnd} />
{dueDate ? <SummaryRow label="Due Date" value={dueDate} /> : null}
{tin ? <SummaryRow label="TIN" value={tin} /> : null}
{taxAccountNumber ? (
<SummaryRow label="Tax Account" value={taxAccountNumber} />
) : null}
{taxCentre ? (
<SummaryRow label="Tax Centre" value={taxCentre} />
) : null}
{selectedInvoices.length > 0 ? (
<SummaryRow
label="Linked Invoices"
value={`${selectedInvoices.length} invoice${selectedInvoices.length > 1 ? "s" : ""}`}
/>
) : null}
{notes && <SummaryRow label="Notes" value={notes} />}
</View>
{declarationFile && (
<View className="bg-card rounded-[6px] p-4 gap-3">
<SummaryRow label="Declaration PDF" value={declarationFile.name} />
</View>
)}
{receiptFiles.length > 0 && (
<View className="bg-card rounded-[6px] p-4 gap-2">
<Text className="text-[14px] text-foreground font-sans-bold">
Receipts ({receiptFiles.length})
</Text>
{receiptFiles.map((rf, i) => (
<Text key={i} className="text-[14px] text-foreground font-sans-bold">
{i + 1}. {rf.name}
</Text>
))}
</View>
)}
</View>
)}
</FormFlow>
<Modal
visible={showInvoicePicker}
transparent
animationType="slide"
onRequestClose={() => setShowInvoicePicker(false)}
>
<Pressable
className="flex-1 bg-black/40"
onPress={() => setShowInvoicePicker(false)}
>
<View className="flex-1 justify-end">
<Pressable
className="bg-card rounded-t-[36px] overflow-hidden border-t-[3px] border-border/20"
style={{ maxHeight: SCREEN_HEIGHT * 0.75 }}
onPress={(e) => e.stopPropagation()}
>
<View className="px-6 pb-4 pt-4 flex-row justify-between items-center border-b border-border/40">
<Text className="text-foreground font-sans-bold text-[18px]">
Link Invoices
</Text>
<Pressable
onPress={() => setShowInvoicePicker(false)}
className="h-8 w-8 bg-secondary/80 rounded-full items-center justify-center border border-border/10"
>
<Text className="text-foreground text-xs font-sans-bold"></Text>
</Pressable>
</View>
<View className="px-5 py-3">
<View className="flex-row items-center bg-card border border-border rounded-xl px-3 h-10">
<Search size={16} color="#94a3b8" strokeWidth={2} />
<TextInput
className="flex-1 ml-2 text-foreground text-sm"
placeholder="Search invoices..."
placeholderTextColor={getPlaceholderColor(isDark)}
value={invoiceSearch}
onChangeText={setInvoiceSearch}
autoCapitalize="none"
/>
</View>
</View>
<ScrollView className="px-5" showsVerticalScrollIndicator={false}>
{filteredInvoices.map((inv: any) => {
const selected = selectedInvoices.find((i) => i.id === inv.id);
return (
<Pressable
key={inv.id}
onPress={() => toggleInvoice(inv)}
className={`flex-row items-center py-3 px-3 rounded-[8px] mb-1 ${
selected ? "bg-primary/10" : ""
}`}
>
<View className="flex-1">
<Text className="text-foreground font-sans-bold text-sm">
{inv.customerName || "Unknown"}
</Text>
<Text className="text-muted-foreground text-[11px] font-sans-medium">
#{inv.invoiceNumber} · {inv.currency}{" "}
{Number(inv.amount?.value || inv.amount || 0).toLocaleString()}
</Text>
</View>
{selected && (
<View className="h-5 w-5 rounded-full bg-primary items-center justify-center">
<Text className="text-white text-[10px] font-sans-bold"></Text>
</View>
)}
</Pressable>
);
})}
{filteredInvoices.length === 0 && (
<Text className="text-muted-foreground text-sm font-sans-medium text-center py-8">
No invoices found
</Text>
)}
</ScrollView>
</Pressable>
</View>
</Pressable>
</Modal>
<PickerModal
visible={showTypePicker}
title="Declaration Type"
onClose={() => setShowTypePicker(false)}
>
{DECLARATION_TYPES.map((opt) => (
<SelectOption
key={opt.value}
label={opt.label}
value={opt.value}
selected={type === opt.value}
onSelect={(v) => {
setType(v as DeclarationType);
setShowTypePicker(false);
}}
/>
))}
</PickerModal>
<PickerModal
visible={showPeriodPicker}
title="Period"
onClose={() => setShowPeriodPicker(false)}
>
{PERIODS.map((opt) => (
<SelectOption
key={opt.value}
label={opt.label}
value={opt.value}
selected={period === opt.value}
onSelect={(v) => {
setPeriod(v as Period);
setShowPeriodPicker(false);
}}
/>
))}
</PickerModal>
</ScreenWrapper>
);
}
function SummaryRow({ label, value }: { label: string; value: string }) {
return (
<View className="flex-row justify-between">
<Text className="text-[14px] text-foreground font-sans-bold">{label}</Text>
<Text className="text-[14px] text-foreground font-sans-bold text-right flex-1 ml-4">
{value}
</Text>
</View>
);
}