Polish mobile app for App Store review and expand CRUD.
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>
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
import { fonts } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
export default function EntitiesLayout() {
|
||||
const { colors } = useAppTheme();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
contentStyle: { backgroundColor: "transparent" },
|
||||
headerStyle: { backgroundColor: colors.cardGlass },
|
||||
headerTitleStyle: {
|
||||
fontFamily: fonts.heading,
|
||||
fontSize: 18,
|
||||
color: colors.foreground,
|
||||
},
|
||||
headerShadowVisible: false,
|
||||
headerTintColor: colors.foreground,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: "Entities",
|
||||
headerShown: false,
|
||||
statusBarTranslucent: true,
|
||||
contentStyle: { flex: 1, backgroundColor: "transparent" },
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="clients/new"
|
||||
options={{
|
||||
title: "New client",
|
||||
headerBackTitle: "Entities",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="clients/[id]"
|
||||
options={{
|
||||
title: "Client",
|
||||
headerBackTitle: "Entities",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="clients/edit/[id]"
|
||||
options={{
|
||||
title: "Edit client",
|
||||
headerBackTitle: "Client",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="businesses/new"
|
||||
options={{
|
||||
title: "New business",
|
||||
headerBackTitle: "Entities",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="businesses/[id]"
|
||||
options={{
|
||||
title: "Business",
|
||||
headerBackTitle: "Entities",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="businesses/edit/[id]"
|
||||
options={{
|
||||
title: "Edit business",
|
||||
headerBackTitle: "Business",
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { router, Stack, useLocalSearchParams } from "expo-router";
|
||||
import { Alert, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
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 BusinessDetailScreen() {
|
||||
const { colors } = useAppTheme();
|
||||
const styles = useThemedStyles(createBusinessDetailStyles);
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const scrollPadding = useTabBarScrollPadding();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const businessQuery = api.businesses.getById.useQuery(
|
||||
{ id: id ?? "" },
|
||||
{ enabled: Boolean(id) },
|
||||
);
|
||||
|
||||
const setDefault = api.businesses.setDefault.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.businesses.getAll.invalidate();
|
||||
if (id) void utils.businesses.getById.invalidate({ id });
|
||||
Alert.alert("Default updated", "This business is now your default.");
|
||||
},
|
||||
onError: (err) => Alert.alert("Could not set default", err.message),
|
||||
});
|
||||
|
||||
if (!id) {
|
||||
return <LoadingScreen message="Invalid business" />;
|
||||
}
|
||||
|
||||
if (businessQuery.isLoading) {
|
||||
return <LoadingScreen message="Loading business…" />;
|
||||
}
|
||||
|
||||
const business = businessQuery.data;
|
||||
if (!business) {
|
||||
return <LoadingScreen message="Business not found" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<Stack.Screen options={{ headerBackTitle: "Entities" }} />
|
||||
<ScrollView
|
||||
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
||||
>
|
||||
<View style={styles.hero}>
|
||||
<View style={styles.nameRow}>
|
||||
<Text style={styles.name}>{business.name}</Text>
|
||||
{business.isDefault ? <Text style={styles.badge}>Default</Text> : null}
|
||||
</View>
|
||||
{business.nickname ? <Text style={styles.meta}>{business.nickname}</Text> : null}
|
||||
{business.email ? <Text style={styles.meta}>{business.email}</Text> : null}
|
||||
{business.phone ? <Text style={styles.meta}>{business.phone}</Text> : null}
|
||||
{business.website ? <Text style={styles.meta}>{business.website}</Text> : null}
|
||||
</View>
|
||||
|
||||
<Card title="Details">
|
||||
{business.taxId ? (
|
||||
<DetailRow label="Tax ID" value={business.taxId} />
|
||||
) : null}
|
||||
<DetailRow
|
||||
label="Email sending"
|
||||
value={business.resendDomain ? "Configured" : "Not configured"}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{(business.addressLine1 || business.city || business.state) && (
|
||||
<Card title="Address">
|
||||
{business.addressLine1 ? (
|
||||
<Text style={styles.body}>{business.addressLine1}</Text>
|
||||
) : null}
|
||||
{business.addressLine2 ? (
|
||||
<Text style={styles.body}>{business.addressLine2}</Text>
|
||||
) : null}
|
||||
{(business.city || business.state || business.postalCode) && (
|
||||
<Text style={styles.body}>
|
||||
{[business.city, business.state, business.postalCode].filter(Boolean).join(", ")}
|
||||
</Text>
|
||||
)}
|
||||
{business.country ? <Text style={styles.body}>{business.country}</Text> : null}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
title="Edit business"
|
||||
onPress={() => router.push(`/(app)/entities/businesses/edit/${business.id}`)}
|
||||
/>
|
||||
{!business.isDefault ? (
|
||||
<Button
|
||||
title="Set as default"
|
||||
variant="secondary"
|
||||
loading={setDefault.isPending}
|
||||
onPress={() => setDefault.mutate({ id: business.id })}
|
||||
/>
|
||||
) : null}
|
||||
</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 createBusinessDetailStyles = (colors: ThemeColors, isDark: boolean) =>
|
||||
StyleSheet.create({
|
||||
container: {
|
||||
padding: spacing.md,
|
||||
gap: spacing.md,
|
||||
},
|
||||
hero: {
|
||||
gap: 4,
|
||||
},
|
||||
nameRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
name: {
|
||||
fontSize: 24,
|
||||
lineHeight: 28,
|
||||
fontFamily: fonts.heading,
|
||||
color: colors.foreground,
|
||||
},
|
||||
badge: {
|
||||
fontSize: 11,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.primary,
|
||||
backgroundColor: isDark ? "rgba(74, 222, 128, 0.15)" : colors.muted,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 999,
|
||||
overflow: "hidden",
|
||||
},
|
||||
meta: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
body: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
color: colors.foreground,
|
||||
lineHeight: 20,
|
||||
},
|
||||
actions: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { router, Stack, useLocalSearchParams } from "expo-router";
|
||||
import { Alert } from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { BusinessForm } from "@/components/businesses/BusinessForm";
|
||||
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||
|
||||
export default function EditBusinessScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const scrollPadding = useTabBarScrollPadding();
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<Stack.Screen options={{ headerBackTitle: "Business" }} />
|
||||
<BusinessForm
|
||||
mode="edit"
|
||||
businessId={id}
|
||||
scrollPadding={scrollPadding}
|
||||
onSaved={() => {
|
||||
Alert.alert("Saved", "Business updated", [
|
||||
{ text: "OK", onPress: () => router.back() },
|
||||
]);
|
||||
}}
|
||||
onDeleted={() => {
|
||||
Alert.alert("Deleted", "Business removed", [
|
||||
{ text: "OK", onPress: () => router.replace("/(app)/entities") },
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { router, Stack } from "expo-router";
|
||||
import { Alert } from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { BusinessForm } from "@/components/businesses/BusinessForm";
|
||||
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||
|
||||
export default function NewBusinessScreen() {
|
||||
const scrollPadding = useTabBarScrollPadding();
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<Stack.Screen options={{ headerBackTitle: "Entities" }} />
|
||||
<BusinessForm
|
||||
mode="create"
|
||||
scrollPadding={scrollPadding}
|
||||
onSaved={() => {
|
||||
Alert.alert("Business created", "Your business has been saved.", [
|
||||
{ text: "OK", onPress: () => router.back() },
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { router, Stack, useLocalSearchParams } from "expo-router";
|
||||
import { Alert } from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { ClientForm } from "@/components/clients/ClientForm";
|
||||
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||
|
||||
export default function EditClientScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const scrollPadding = useTabBarScrollPadding();
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<Stack.Screen options={{ headerBackTitle: "Client" }} />
|
||||
<ClientForm
|
||||
mode="edit"
|
||||
clientId={id}
|
||||
scrollPadding={scrollPadding}
|
||||
onSaved={() => {
|
||||
Alert.alert("Saved", "Client updated", [
|
||||
{ text: "OK", onPress: () => router.back() },
|
||||
]);
|
||||
}}
|
||||
onDeleted={() => {
|
||||
Alert.alert("Deleted", "Client removed", [
|
||||
{ text: "OK", onPress: () => router.replace("/(app)/entities") },
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { router, Stack } from "expo-router";
|
||||
import { Alert } from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { ClientForm } from "@/components/clients/ClientForm";
|
||||
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||
|
||||
export default function NewClientScreen() {
|
||||
const scrollPadding = useTabBarScrollPadding();
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<Stack.Screen options={{ headerBackTitle: "Entities" }} />
|
||||
<ClientForm
|
||||
mode="create"
|
||||
scrollPadding={scrollPadding}
|
||||
onSaved={() => {
|
||||
Alert.alert("Client created", "Your client has been saved.", [
|
||||
{ text: "OK", onPress: () => router.back() },
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { router } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { FilterChip } from "@/components/FilterChip";
|
||||
import { FloatingActionButton } from "@/components/FloatingActionButton";
|
||||
import { GlassSurface } from "@/components/GlassSurface";
|
||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { TabPage } from "@/components/TabPage";
|
||||
import { TabScrollView } from "@/components/TabScrollView";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
type EntityTab = "clients" | "businesses";
|
||||
|
||||
const tabs: Array<{ label: string; value: EntityTab }> = [
|
||||
{ label: "Clients", value: "clients" },
|
||||
{ label: "Businesses", value: "businesses" },
|
||||
];
|
||||
|
||||
export default function EntitiesScreen() {
|
||||
const { colors } = useAppTheme();
|
||||
const styles = useThemedStyles(createEntitiesStyles);
|
||||
const [tab, setTab] = useState<EntityTab>("clients");
|
||||
|
||||
const clientsQuery = api.clients.getAll.useQuery();
|
||||
const businessesQuery = api.businesses.getAll.useQuery();
|
||||
|
||||
const activeQuery = tab === "clients" ? clientsQuery : businessesQuery;
|
||||
const isLoading =
|
||||
clientsQuery.isLoading || (tab === "businesses" && businessesQuery.isLoading);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingScreen message="Loading…" />;
|
||||
}
|
||||
|
||||
if (activeQuery.error) {
|
||||
return (
|
||||
<AppBackground>
|
||||
<TabPage>
|
||||
<View style={styles.errorBox}>
|
||||
<Text style={styles.errorTitle}>Could not load {tab}</Text>
|
||||
<Text style={styles.errorText}>{activeQuery.error.message}</Text>
|
||||
</View>
|
||||
</TabPage>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const clients = clientsQuery.data ?? [];
|
||||
const businesses = businessesQuery.data ?? [];
|
||||
|
||||
function refresh() {
|
||||
if (tab === "clients") void clientsQuery.refetch();
|
||||
else void businessesQuery.refetch();
|
||||
}
|
||||
|
||||
return (
|
||||
<AppBackground>
|
||||
<TabPage>
|
||||
<TabScrollView
|
||||
header={
|
||||
<PageHeader
|
||||
title="Entities"
|
||||
subtitle="Clients you bill and businesses you send from"
|
||||
/>
|
||||
}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={activeQuery.isRefetching}
|
||||
onRefresh={refresh}
|
||||
tintColor={colors.primary}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.tabScroll}
|
||||
contentContainerStyle={styles.tabs}
|
||||
>
|
||||
{tabs.map((item) => (
|
||||
<FilterChip
|
||||
key={item.value}
|
||||
label={item.label}
|
||||
active={tab === item.value}
|
||||
onPress={() => setTab(item.value)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
{tab === "clients" ? (
|
||||
clients.length === 0 ? (
|
||||
<View style={styles.empty}>
|
||||
<Text style={styles.emptyTitle}>No clients yet</Text>
|
||||
<Text style={styles.emptyText}>
|
||||
Add a client to start creating invoices.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
clients.map((client) => (
|
||||
<Pressable
|
||||
key={client.id}
|
||||
onPress={() => router.push(`/(app)/entities/clients/${client.id}`)}
|
||||
>
|
||||
<GlassSurface style={styles.card}>
|
||||
<View style={styles.cardInner}>
|
||||
<Text style={styles.name}>{client.name}</Text>
|
||||
{client.email ? (
|
||||
<Text style={styles.meta}>{client.email}</Text>
|
||||
) : null}
|
||||
{client.defaultHourlyRate != null ? (
|
||||
<Text style={styles.meta}>
|
||||
{formatCurrency(client.defaultHourlyRate, client.currency ?? "USD")}
|
||||
/hr
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</GlassSurface>
|
||||
</Pressable>
|
||||
))
|
||||
)
|
||||
) : businesses.length === 0 ? (
|
||||
<View style={styles.empty}>
|
||||
<Text style={styles.emptyTitle}>No businesses yet</Text>
|
||||
<Text style={styles.emptyText}>
|
||||
Add your business profile for invoices and email sending.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
businesses.map((business) => (
|
||||
<Pressable
|
||||
key={business.id}
|
||||
onPress={() => router.push(`/(app)/entities/businesses/${business.id}`)}
|
||||
>
|
||||
<GlassSurface style={styles.card}>
|
||||
<View style={styles.cardInner}>
|
||||
<View style={styles.nameRow}>
|
||||
<Text style={styles.name}>{business.name}</Text>
|
||||
{business.isDefault ? (
|
||||
<Text style={styles.badge}>Default</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{business.nickname ? (
|
||||
<Text style={styles.meta}>{business.nickname}</Text>
|
||||
) : null}
|
||||
{business.email ? <Text style={styles.meta}>{business.email}</Text> : null}
|
||||
</View>
|
||||
</GlassSurface>
|
||||
</Pressable>
|
||||
))
|
||||
)}
|
||||
</TabScrollView>
|
||||
<FloatingActionButton
|
||||
accessibilityLabel={tab === "clients" ? "Add client" : "Add business"}
|
||||
onPress={() =>
|
||||
router.push(
|
||||
tab === "clients"
|
||||
? "/(app)/entities/clients/new"
|
||||
: "/(app)/entities/businesses/new",
|
||||
)
|
||||
}
|
||||
/>
|
||||
</TabPage>
|
||||
</AppBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const createEntitiesStyles = (colors: ThemeColors, isDark: boolean) =>
|
||||
StyleSheet.create({
|
||||
tabScroll: {
|
||||
flexGrow: 0,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
tabs: {
|
||||
gap: spacing.sm,
|
||||
paddingRight: spacing.md,
|
||||
},
|
||||
card: {},
|
||||
cardInner: {
|
||||
padding: spacing.md,
|
||||
gap: 4,
|
||||
},
|
||||
nameRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
name: {
|
||||
fontSize: 16,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
},
|
||||
badge: {
|
||||
fontSize: 11,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.primary,
|
||||
backgroundColor: isDark ? "rgba(74, 222, 128, 0.15)" : colors.muted,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 999,
|
||||
overflow: "hidden",
|
||||
},
|
||||
meta: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
empty: {
|
||||
padding: spacing.lg,
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 18,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
},
|
||||
emptyText: {
|
||||
textAlign: "center",
|
||||
color: colors.mutedForeground,
|
||||
fontFamily: fonts.body,
|
||||
lineHeight: 20,
|
||||
},
|
||||
errorBox: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
padding: spacing.lg,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
errorTitle: {
|
||||
fontSize: 18,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
},
|
||||
errorText: {
|
||||
color: colors.mutedForeground,
|
||||
fontFamily: fonts.body,
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user