6d2711e36e
Default to beenvoice.soconnor.dev with server settings hidden behind Advanced; add Entities tab with clients/businesses, invoice creation, UI fixes for dashboard layout, date fields, FAB position, and card-matched button radius. Co-authored-by: Cursor <cursoragent@cursor.com>
219 lines
6.8 KiB
TypeScript
219 lines
6.8 KiB
TypeScript
import { router, Stack, useLocalSearchParams } from "expo-router";
|
|
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
|
|
|
|
import { AppBackground } from "@/components/AppBackground";
|
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
|
import { StatusBadge } from "@/components/StatusBadge";
|
|
import { Button } from "@/components/ui/Button";
|
|
import { Card } from "@/components/ui/Card";
|
|
import { fonts, spacing } from "@/constants/theme";
|
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
|
import { formatCurrency, formatDate } from "@/lib/format";
|
|
import { getInvoiceStatus } from "@/lib/invoice-status";
|
|
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
|
import type { ThemeColors } from "@/lib/theme-palette";
|
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
|
import { api } from "@/lib/trpc";
|
|
|
|
export default function ClientDetailScreen() {
|
|
const { colors } = useAppTheme();
|
|
const styles = useThemedStyles(createClientDetailStyles);
|
|
const { id } = useLocalSearchParams<{ id: string }>();
|
|
const scrollPadding = useTabBarScrollPadding();
|
|
|
|
const clientQuery = api.clients.getById.useQuery(
|
|
{ id: id ?? "" },
|
|
{ enabled: Boolean(id) },
|
|
);
|
|
|
|
if (!id) {
|
|
return <LoadingScreen message="Invalid client" />;
|
|
}
|
|
|
|
if (clientQuery.isLoading) {
|
|
return <LoadingScreen message="Loading client…" />;
|
|
}
|
|
|
|
const client = clientQuery.data;
|
|
if (!client) {
|
|
return <LoadingScreen message="Client not found" />;
|
|
}
|
|
|
|
const invoices = client.invoices ?? [];
|
|
const totalInvoiced = invoices.reduce((sum, invoice) => sum + invoice.totalAmount, 0);
|
|
const currency = client.currency ?? "USD";
|
|
|
|
return (
|
|
<AppBackground>
|
|
<Stack.Screen options={{ headerBackTitle: "Entities" }} />
|
|
<ScrollView
|
|
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
|
contentInsetAdjustmentBehavior="automatic"
|
|
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
|
>
|
|
<View style={styles.hero}>
|
|
<Text style={styles.name}>{client.name}</Text>
|
|
{client.email ? <Text style={styles.meta}>{client.email}</Text> : null}
|
|
{client.phone ? <Text style={styles.meta}>{client.phone}</Text> : null}
|
|
</View>
|
|
|
|
<Card title="Summary">
|
|
<DetailRow label="Total invoiced" value={formatCurrency(totalInvoiced, currency)} />
|
|
<DetailRow label="Invoices" value={String(invoices.length)} />
|
|
{client.defaultHourlyRate != null ? (
|
|
<DetailRow
|
|
label="Default rate"
|
|
value={`${formatCurrency(client.defaultHourlyRate, currency)}/hr`}
|
|
/>
|
|
) : null}
|
|
</Card>
|
|
|
|
{(client.addressLine1 || client.city || client.state) && (
|
|
<Card title="Address">
|
|
{client.addressLine1 ? <Text style={styles.body}>{client.addressLine1}</Text> : null}
|
|
{client.addressLine2 ? <Text style={styles.body}>{client.addressLine2}</Text> : null}
|
|
{(client.city || client.state || client.postalCode) && (
|
|
<Text style={styles.body}>
|
|
{[client.city, client.state, client.postalCode].filter(Boolean).join(", ")}
|
|
</Text>
|
|
)}
|
|
{client.country ? <Text style={styles.body}>{client.country}</Text> : null}
|
|
</Card>
|
|
)}
|
|
|
|
<Card title="Invoices">
|
|
{invoices.length === 0 ? (
|
|
<Text style={styles.muted}>No invoices for this client yet.</Text>
|
|
) : (
|
|
invoices.map((invoice) => {
|
|
const status = getInvoiceStatus(invoice);
|
|
return (
|
|
<Pressable
|
|
key={invoice.id}
|
|
style={styles.invoiceRow}
|
|
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
|
|
>
|
|
<View style={styles.invoiceMeta}>
|
|
<Text style={styles.invoiceTitle}>
|
|
{invoice.invoicePrefix}
|
|
{invoice.invoiceNumber}
|
|
</Text>
|
|
<Text style={styles.muted}>Due {formatDate(invoice.dueDate)}</Text>
|
|
</View>
|
|
<View style={styles.invoiceRight}>
|
|
<Text style={styles.invoiceAmount}>
|
|
{formatCurrency(invoice.totalAmount, invoice.currency)}
|
|
</Text>
|
|
<StatusBadge status={status} />
|
|
</View>
|
|
</Pressable>
|
|
);
|
|
})
|
|
)}
|
|
</Card>
|
|
|
|
<View style={styles.actions}>
|
|
<Button
|
|
title="Edit client"
|
|
onPress={() => router.push(`/(app)/entities/clients/edit/${client.id}`)}
|
|
/>
|
|
<Button
|
|
title="New invoice"
|
|
variant="secondary"
|
|
onPress={() => router.push("/(app)/invoices/new")}
|
|
/>
|
|
</View>
|
|
</ScrollView>
|
|
</AppBackground>
|
|
);
|
|
}
|
|
|
|
function DetailRow({ label, value }: { label: string; value: string }) {
|
|
const { colors } = useAppTheme();
|
|
return (
|
|
<View style={detailStyles.row}>
|
|
<Text style={[detailStyles.label, { color: colors.mutedForeground }]}>{label}</Text>
|
|
<Text style={[detailStyles.value, { color: colors.foreground }]}>{value}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const detailStyles = StyleSheet.create({
|
|
row: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
paddingVertical: 4,
|
|
},
|
|
label: {
|
|
fontFamily: fonts.body,
|
|
fontSize: 14,
|
|
},
|
|
value: {
|
|
fontFamily: fonts.bodySemiBold,
|
|
fontSize: 14,
|
|
},
|
|
});
|
|
|
|
const createClientDetailStyles = (colors: ThemeColors, _isDark: boolean) =>
|
|
StyleSheet.create({
|
|
container: {
|
|
padding: spacing.md,
|
|
gap: spacing.md,
|
|
},
|
|
hero: {
|
|
gap: 4,
|
|
},
|
|
name: {
|
|
fontSize: 24,
|
|
lineHeight: 28,
|
|
fontFamily: fonts.heading,
|
|
color: colors.foreground,
|
|
},
|
|
meta: {
|
|
fontSize: 14,
|
|
fontFamily: fonts.body,
|
|
color: colors.mutedForeground,
|
|
},
|
|
body: {
|
|
fontFamily: fonts.body,
|
|
fontSize: 14,
|
|
color: colors.foreground,
|
|
lineHeight: 20,
|
|
},
|
|
muted: {
|
|
fontFamily: fonts.body,
|
|
fontSize: 14,
|
|
color: colors.mutedForeground,
|
|
},
|
|
invoiceRow: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
gap: spacing.md,
|
|
paddingVertical: spacing.sm,
|
|
borderTopWidth: 1,
|
|
borderTopColor: colors.border,
|
|
},
|
|
invoiceMeta: {
|
|
flex: 1,
|
|
gap: 2,
|
|
},
|
|
invoiceTitle: {
|
|
fontFamily: fonts.bodySemiBold,
|
|
color: colors.foreground,
|
|
fontSize: 15,
|
|
},
|
|
invoiceRight: {
|
|
alignItems: "flex-end",
|
|
gap: spacing.sm,
|
|
},
|
|
invoiceAmount: {
|
|
fontFamily: fonts.bodySemiBold,
|
|
color: colors.foreground,
|
|
fontSize: 15,
|
|
},
|
|
actions: {
|
|
gap: spacing.sm,
|
|
},
|
|
});
|