Add 'apps/mobile/' from commit '5fa30f365f21531094cd4d2045042bb4f1370ac3'
git-subtree-dir: apps/mobile git-subtree-mainline:86f8987dffgit-subtree-split:5fa30f365f
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { fonts, radii, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import type { InvoiceStatus } from "@/lib/invoice-status";
|
||||
|
||||
type ActionItem = {
|
||||
key: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
onPress: () => void;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
type InvoiceDetailActionsProps = {
|
||||
status: InvoiceStatus;
|
||||
clientEmail: string;
|
||||
onPaymentReminder?: () => void;
|
||||
paymentReminderLoading?: boolean;
|
||||
onUpdateStatus: () => void;
|
||||
updateStatusLoading?: boolean;
|
||||
onTrackTime: () => void;
|
||||
};
|
||||
|
||||
export function InvoiceDetailActions({
|
||||
status,
|
||||
clientEmail,
|
||||
onPaymentReminder,
|
||||
paymentReminderLoading,
|
||||
onUpdateStatus,
|
||||
updateStatusLoading,
|
||||
onTrackTime,
|
||||
}: InvoiceDetailActionsProps) {
|
||||
const { colors } = useAppTheme();
|
||||
|
||||
const rows: ActionItem[] = [];
|
||||
|
||||
if ((status === "sent" || status === "overdue") && onPaymentReminder) {
|
||||
rows.push({
|
||||
key: "reminder",
|
||||
title: "Send payment reminder",
|
||||
subtitle: clientEmail ? `Nudge ${clientEmail}` : "Add a client email first",
|
||||
icon: "notifications-outline",
|
||||
onPress: onPaymentReminder,
|
||||
loading: paymentReminderLoading,
|
||||
});
|
||||
}
|
||||
|
||||
rows.push(
|
||||
{
|
||||
key: "status",
|
||||
title: "Update status",
|
||||
subtitle: "Draft, sent, or paid",
|
||||
icon: "swap-horizontal-outline",
|
||||
onPress: onUpdateStatus,
|
||||
loading: updateStatusLoading,
|
||||
},
|
||||
{
|
||||
key: "timer",
|
||||
title: "Track time",
|
||||
subtitle: "Clock hours to this invoice",
|
||||
icon: "timer-outline",
|
||||
onPress: onTrackTime,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.card,
|
||||
{
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.cardGlass,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.list}>
|
||||
{rows.map((row, index) => (
|
||||
<View key={row.key}>
|
||||
{index > 0 ? (
|
||||
<View style={[styles.divider, { backgroundColor: colors.border }]} />
|
||||
) : null}
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={row.loading}
|
||||
onPress={row.onPress}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
>
|
||||
<View style={[styles.iconWrap, { backgroundColor: colors.muted }]}>
|
||||
<Ionicons name={row.icon} size={20} color={colors.foreground} />
|
||||
</View>
|
||||
<View style={styles.copy}>
|
||||
<Text style={[styles.title, { color: colors.foreground }]}>{row.title}</Text>
|
||||
{row.subtitle ? (
|
||||
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
|
||||
{row.subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={18} color={colors.mutedForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
borderWidth: 1,
|
||||
borderRadius: radii.lg,
|
||||
padding: spacing.md,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
list: {
|
||||
gap: 0,
|
||||
},
|
||||
divider: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
marginVertical: spacing.xs,
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
rowPressed: {
|
||||
opacity: 0.75,
|
||||
},
|
||||
iconWrap: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: radii.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
copy: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
minWidth: 0,
|
||||
},
|
||||
title: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 15,
|
||||
},
|
||||
subtitle: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { fonts, radii, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
type SecondaryAction = {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
onPress: () => void;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type InvoiceEditorFooterProps = {
|
||||
primaryTitle: string;
|
||||
onPrimary: () => void;
|
||||
primaryLoading?: boolean;
|
||||
primaryDisabled?: boolean;
|
||||
secondary?: SecondaryAction;
|
||||
};
|
||||
|
||||
export function InvoiceEditorFooter({
|
||||
primaryTitle,
|
||||
onPrimary,
|
||||
primaryLoading,
|
||||
primaryDisabled,
|
||||
secondary,
|
||||
}: InvoiceEditorFooterProps) {
|
||||
const { colors } = useAppTheme();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.card,
|
||||
{
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.cardGlass,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Button
|
||||
title={primaryTitle}
|
||||
loading={primaryLoading}
|
||||
disabled={primaryDisabled}
|
||||
onPress={onPrimary}
|
||||
/>
|
||||
|
||||
{secondary ? (
|
||||
<>
|
||||
<View style={[styles.divider, { backgroundColor: colors.border }]} />
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={secondary.disabled || secondary.loading}
|
||||
onPress={secondary.onPress}
|
||||
style={({ pressed }) => [
|
||||
styles.secondaryRow,
|
||||
(pressed || secondary.loading) && styles.secondaryPressed,
|
||||
(secondary.disabled || secondary.loading) && styles.secondaryDisabled,
|
||||
]}
|
||||
>
|
||||
<View style={[styles.iconWrap, { backgroundColor: colors.muted }]}>
|
||||
<Ionicons name={secondary.icon} size={20} color={colors.foreground} />
|
||||
</View>
|
||||
<View style={styles.secondaryCopy}>
|
||||
<Text style={[styles.secondaryTitle, { color: colors.foreground }]}>
|
||||
{secondary.title}
|
||||
</Text>
|
||||
{secondary.subtitle ? (
|
||||
<Text style={[styles.secondarySubtitle, { color: colors.mutedForeground }]}>
|
||||
{secondary.subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={18} color={colors.mutedForeground} />
|
||||
</Pressable>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
borderWidth: 1,
|
||||
borderRadius: radii.lg,
|
||||
padding: spacing.md,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
divider: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
marginVertical: spacing.xs,
|
||||
},
|
||||
secondaryRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
secondaryPressed: {
|
||||
opacity: 0.75,
|
||||
},
|
||||
secondaryDisabled: {
|
||||
opacity: 0.45,
|
||||
},
|
||||
iconWrap: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: radii.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
secondaryCopy: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
minWidth: 0,
|
||||
},
|
||||
secondaryTitle: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 15,
|
||||
},
|
||||
secondarySubtitle: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { ScrollView, StyleSheet, View } from "react-native";
|
||||
|
||||
import { FilterChip } from "@/components/FilterChip";
|
||||
import { spacing } from "@/constants/theme";
|
||||
|
||||
export type InvoiceEditorSection = "setup" | "lines" | "preview";
|
||||
export type InvoiceViewSection = "details" | "preview";
|
||||
|
||||
type InvoiceEditorSectionTabsProps =
|
||||
| {
|
||||
mode?: "edit";
|
||||
value: InvoiceEditorSection;
|
||||
onChange: (value: InvoiceEditorSection) => void;
|
||||
}
|
||||
| {
|
||||
mode: "view";
|
||||
value: InvoiceViewSection;
|
||||
onChange: (value: InvoiceViewSection) => void;
|
||||
};
|
||||
|
||||
export function InvoiceEditorSectionTabs(props: InvoiceEditorSectionTabsProps) {
|
||||
const tabs =
|
||||
props.mode === "view"
|
||||
? [
|
||||
{ id: "details" as const, label: "Details" },
|
||||
{ id: "preview" as const, label: "PDF" },
|
||||
]
|
||||
: [
|
||||
{ id: "setup" as const, label: "Setup" },
|
||||
{ id: "lines" as const, label: "Line items" },
|
||||
{ id: "preview" as const, label: "PDF preview" },
|
||||
];
|
||||
|
||||
return (
|
||||
<View>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.row}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<FilterChip
|
||||
key={tab.id}
|
||||
label={tab.label}
|
||||
active={props.value === tab.id}
|
||||
onPress={() => props.onChange(tab.id as never)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { WebView } from "react-native-webview";
|
||||
|
||||
import { fonts, radii, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import {
|
||||
canPreviewPdfInput,
|
||||
type InvoicePdfPreviewInput,
|
||||
} from "@/lib/invoice-pdf-input";
|
||||
import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
type InvoicePdfPreviewProps = {
|
||||
input: InvoicePdfPreviewInput | null;
|
||||
height?: number;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
};
|
||||
|
||||
function buildPdfHtml(contentType: string, base64: string) {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=3.0" />
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; background: #525659; }
|
||||
embed { width: 100%; height: 100%; border: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<embed src="data:${contentType};base64,${base64}" type="application/pdf" />
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export function InvoicePdfPreview({
|
||||
input,
|
||||
height = 560,
|
||||
style,
|
||||
}: InvoicePdfPreviewProps) {
|
||||
const { colors } = useAppTheme();
|
||||
const styles = useThemedStyles(createPreviewStyles);
|
||||
const enabled = canPreviewPdfInput(input);
|
||||
|
||||
const { data, isLoading, isFetching, error, refetch } =
|
||||
api.invoices.previewPdf.useQuery(input!, {
|
||||
enabled,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 5_000,
|
||||
});
|
||||
|
||||
const html = useMemo(() => {
|
||||
if (!data?.base64) return null;
|
||||
return buildPdfHtml(data.contentType, data.base64);
|
||||
}, [data]);
|
||||
|
||||
if (!enabled) {
|
||||
return (
|
||||
<View style={[styles.frame, { height }, style]}>
|
||||
<Text style={styles.placeholder}>
|
||||
Select a client and add a description to every line item to preview the
|
||||
PDF.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading && !html) {
|
||||
return (
|
||||
<View style={[styles.frame, styles.centered, { height }, style]}>
|
||||
<ActivityIndicator color={colors.primary} />
|
||||
<Text style={styles.loadingText}>Generating preview…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<View style={[styles.frame, styles.centered, { height }, style]}>
|
||||
<Text style={styles.errorText}>{error.message}</Text>
|
||||
<Pressable accessibilityRole="button" onPress={() => void refetch()}>
|
||||
<Text style={[styles.retry, { color: colors.primary }]}>Try again</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!html) {
|
||||
return (
|
||||
<View style={[styles.frame, styles.centered, { height }, style]}>
|
||||
<Text style={styles.placeholder}>PDF preview will appear here.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.wrapper, style]}>
|
||||
{isFetching ? (
|
||||
<View style={styles.refreshing}>
|
||||
<ActivityIndicator size="small" color={colors.primary} />
|
||||
</View>
|
||||
) : null}
|
||||
<View style={[styles.frame, { height }]}>
|
||||
<WebView
|
||||
originWhitelist={["*"]}
|
||||
source={{ html }}
|
||||
style={styles.webview}
|
||||
scrollEnabled
|
||||
showsVerticalScrollIndicator
|
||||
showsHorizontalScrollIndicator={false}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const createPreviewStyles = (colors: ThemeColors) =>
|
||||
StyleSheet.create({
|
||||
wrapper: {
|
||||
gap: spacing.xs,
|
||||
},
|
||||
frame: {
|
||||
overflow: "hidden",
|
||||
borderRadius: radii.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.muted,
|
||||
},
|
||||
webview: {
|
||||
flex: 1,
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
centered: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: spacing.lg,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
placeholder: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
color: colors.mutedForeground,
|
||||
textAlign: "center",
|
||||
padding: spacing.lg,
|
||||
},
|
||||
loadingText: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 13,
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
errorText: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
color: colors.destructive,
|
||||
textAlign: "center",
|
||||
},
|
||||
retry: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 14,
|
||||
},
|
||||
refreshing: {
|
||||
position: "absolute",
|
||||
top: spacing.sm,
|
||||
right: spacing.sm,
|
||||
zIndex: 2,
|
||||
borderRadius: radii.pill,
|
||||
backgroundColor: colors.card,
|
||||
padding: spacing.xs,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { DateTimeField } from "@/components/ui/DateTimeField";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { SelectField } from "@/components/ui/SelectField";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { defaultDueDate } from "@/lib/invoice-number";
|
||||
|
||||
type SelectOption = { label: string; value: string };
|
||||
|
||||
type InvoiceSetupFormProps = {
|
||||
businessId: string;
|
||||
onBusinessIdChange: (value: string) => void;
|
||||
businessOptions: SelectOption[];
|
||||
businessError?: string;
|
||||
businessReadOnly?: boolean;
|
||||
clientId: string;
|
||||
onClientIdChange: (value: string) => void;
|
||||
clientOptions: SelectOption[];
|
||||
clientError?: string;
|
||||
clientReadOnly?: boolean;
|
||||
invoiceNumber: string;
|
||||
onInvoiceNumberChange?: (value: string) => void;
|
||||
invoiceNumberReadOnly?: boolean;
|
||||
issueDate: Date;
|
||||
onIssueDateChange?: (date: Date) => void;
|
||||
issueDateReadOnly?: boolean;
|
||||
dueDate: Date;
|
||||
onDueDateChange: (date: Date) => void;
|
||||
taxRate: string;
|
||||
onTaxRateChange?: (value: string) => void;
|
||||
taxRateReadOnly?: boolean;
|
||||
notes: string;
|
||||
onNotesChange: (value: string) => void;
|
||||
sendReminderAt?: Date | null;
|
||||
onSendReminderAtChange?: (date: Date | null) => void;
|
||||
showSendReminder?: boolean;
|
||||
};
|
||||
|
||||
export function InvoiceSetupForm({
|
||||
businessId,
|
||||
onBusinessIdChange,
|
||||
businessOptions,
|
||||
businessError,
|
||||
businessReadOnly = false,
|
||||
clientId,
|
||||
onClientIdChange,
|
||||
clientOptions,
|
||||
clientError,
|
||||
clientReadOnly = false,
|
||||
invoiceNumber,
|
||||
onInvoiceNumberChange,
|
||||
invoiceNumberReadOnly = false,
|
||||
issueDate,
|
||||
onIssueDateChange,
|
||||
issueDateReadOnly = false,
|
||||
dueDate,
|
||||
onDueDateChange,
|
||||
taxRate,
|
||||
onTaxRateChange,
|
||||
taxRateReadOnly = false,
|
||||
notes,
|
||||
onNotesChange,
|
||||
sendReminderAt,
|
||||
onSendReminderAtChange,
|
||||
showSendReminder = false,
|
||||
}: InvoiceSetupFormProps) {
|
||||
const { colors } = useAppTheme();
|
||||
|
||||
return (
|
||||
<View style={styles.form}>
|
||||
{businessOptions.length === 0 ? (
|
||||
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
|
||||
Add a business in Entities before invoicing.
|
||||
</Text>
|
||||
) : (
|
||||
<SelectField
|
||||
label="Business"
|
||||
placeholder="Select business…"
|
||||
value={businessId}
|
||||
options={businessOptions}
|
||||
required
|
||||
error={businessError}
|
||||
disabled={businessReadOnly}
|
||||
onValueChange={onBusinessIdChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{clientOptions.length === 0 ? (
|
||||
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
|
||||
Add a client in Entities before invoicing.
|
||||
</Text>
|
||||
) : (
|
||||
<SelectField
|
||||
label="Client"
|
||||
placeholder="Select client…"
|
||||
value={clientId}
|
||||
options={clientOptions}
|
||||
required
|
||||
error={clientError}
|
||||
disabled={clientReadOnly}
|
||||
onValueChange={onClientIdChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{invoiceNumberReadOnly ? (
|
||||
<View style={styles.readOnlyField}>
|
||||
<Text style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}>
|
||||
Invoice number
|
||||
</Text>
|
||||
<Text style={[styles.readOnlyValue, { color: colors.foreground }]}>
|
||||
{invoiceNumber}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Input
|
||||
label="Invoice number"
|
||||
value={invoiceNumber}
|
||||
onChangeText={onInvoiceNumberChange}
|
||||
autoCapitalize="characters"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
|
||||
{issueDateReadOnly ? (
|
||||
<View style={styles.readOnlyField}>
|
||||
<Text style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}>
|
||||
Issue date
|
||||
</Text>
|
||||
<Text style={[styles.readOnlyValue, { color: colors.foreground }]}>
|
||||
{issueDate.toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<DateTimeField
|
||||
label="Issue date"
|
||||
mode="date"
|
||||
value={issueDate}
|
||||
onChange={(date) => {
|
||||
onIssueDateChange?.(date);
|
||||
if (dueDate < date) onDueDateChange(defaultDueDate(date));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DateTimeField label="Due date" mode="date" value={dueDate} onChange={onDueDateChange} />
|
||||
|
||||
{taxRateReadOnly ? (
|
||||
<View style={styles.readOnlyField}>
|
||||
<Text style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}>
|
||||
Tax rate
|
||||
</Text>
|
||||
<Text style={[styles.readOnlyValue, { color: colors.foreground }]}>
|
||||
{taxRate}%
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Input
|
||||
label="Tax rate (%)"
|
||||
value={taxRate}
|
||||
onChangeText={onTaxRateChange}
|
||||
keyboardType="decimal-pad"
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSendReminder && onSendReminderAtChange ? (
|
||||
<>
|
||||
<DateTimeField
|
||||
label="Remind me to send"
|
||||
mode="date"
|
||||
value={sendReminderAt ?? dueDate}
|
||||
minimumDate={new Date()}
|
||||
maximumDate={new Date(2100, 0, 1)}
|
||||
onChange={onSendReminderAtChange}
|
||||
/>
|
||||
{sendReminderAt ? (
|
||||
<Pressable onPress={() => onSendReminderAtChange(null)}>
|
||||
<Text style={[styles.clearReminder, { color: colors.primary }]}>
|
||||
Clear send reminder
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Input
|
||||
label="Notes"
|
||||
value={notes}
|
||||
onChangeText={onNotesChange}
|
||||
placeholder="Optional notes for the client"
|
||||
multiline
|
||||
style={styles.notesInput}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
form: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
hint: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
readOnlyField: {
|
||||
gap: 4,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
readOnlyLabel: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 13,
|
||||
},
|
||||
readOnlyValue: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 15,
|
||||
},
|
||||
notesInput: {
|
||||
minHeight: 72,
|
||||
textAlignVertical: "top",
|
||||
},
|
||||
clearReminder: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 13,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
type InvoiceTotalsProps = {
|
||||
subtotal: string;
|
||||
taxLabel?: string;
|
||||
taxAmount?: string;
|
||||
total: string;
|
||||
};
|
||||
|
||||
export function InvoiceTotals({
|
||||
subtotal,
|
||||
taxLabel,
|
||||
taxAmount,
|
||||
total,
|
||||
}: InvoiceTotalsProps) {
|
||||
const { colors } = useAppTheme();
|
||||
|
||||
return (
|
||||
<View style={[styles.totals, { borderTopColor: colors.border }]}>
|
||||
<TotalRow label="Subtotal" value={subtotal} />
|
||||
{taxLabel && taxAmount ? <TotalRow label={taxLabel} value={taxAmount} /> : null}
|
||||
<TotalRow label="Total" value={total} bold />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function TotalRow({
|
||||
label,
|
||||
value,
|
||||
bold,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
bold?: boolean;
|
||||
}) {
|
||||
const { colors } = useAppTheme();
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Text
|
||||
style={[
|
||||
styles.label,
|
||||
{ color: colors.mutedForeground },
|
||||
bold && styles.bold,
|
||||
bold && { color: colors.foreground },
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.value,
|
||||
{ color: colors.foreground },
|
||||
bold && styles.bold,
|
||||
]}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
totals: {
|
||||
marginTop: spacing.md,
|
||||
paddingTop: spacing.md,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
},
|
||||
label: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
},
|
||||
value: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 14,
|
||||
},
|
||||
bold: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 15,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { ScrollView, StyleSheet, View } from "react-native";
|
||||
|
||||
import { FilterChip } from "@/components/FilterChip";
|
||||
import { spacing } from "@/constants/theme";
|
||||
import type { InvoiceStatus } from "@/lib/invoice-status";
|
||||
|
||||
export type InvoiceViewSection = "details" | "preview";
|
||||
|
||||
type InvoiceViewChipsProps = {
|
||||
section: InvoiceViewSection;
|
||||
onSectionChange: (section: InvoiceViewSection) => void;
|
||||
status: InvoiceStatus;
|
||||
onEdit: () => void;
|
||||
onSend: () => void;
|
||||
};
|
||||
|
||||
export function InvoiceViewChips({
|
||||
section,
|
||||
onSectionChange,
|
||||
status,
|
||||
onEdit,
|
||||
onSend,
|
||||
}: InvoiceViewChipsProps) {
|
||||
const sendLabel = status === "draft" ? "Send" : "Resend";
|
||||
|
||||
return (
|
||||
<View>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.row}
|
||||
>
|
||||
<FilterChip
|
||||
label="Details"
|
||||
active={section === "details"}
|
||||
onPress={() => onSectionChange("details")}
|
||||
/>
|
||||
<FilterChip label="Edit" onPress={onEdit} />
|
||||
{status !== "paid" ? (
|
||||
<FilterChip label={sendLabel} onPress={onSend} />
|
||||
) : null}
|
||||
<FilterChip
|
||||
label="View PDF"
|
||||
active={section === "preview"}
|
||||
onPress={() => onSectionChange("preview")}
|
||||
/>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,310 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
|
||||
|
||||
import { CompactDateField } from "@/components/ui/CompactDateField";
|
||||
import { CompactStepperInput } from "@/components/ui/CompactStepperInput";
|
||||
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||
import { fonts, radii, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { formatCurrency, formatShortDate } from "@/lib/format";
|
||||
|
||||
export type EditableLineItem = {
|
||||
id?: string;
|
||||
date: Date;
|
||||
description: string;
|
||||
hours: string;
|
||||
rate: string;
|
||||
};
|
||||
|
||||
type LineItemEditorProps = {
|
||||
item: EditableLineItem;
|
||||
index: number;
|
||||
currency: string;
|
||||
onChange: (patch: Partial<EditableLineItem>) => void;
|
||||
onRemove: () => void;
|
||||
onDuplicate?: () => void;
|
||||
readOnly?: boolean;
|
||||
isLast?: boolean;
|
||||
};
|
||||
|
||||
function FieldLabel({ children }: { children: string }) {
|
||||
const { colors } = useAppTheme();
|
||||
return (
|
||||
<Text style={[styles.fieldLabel, { color: colors.mutedForeground }]}>{children}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export function LineItemEditor({
|
||||
item,
|
||||
index,
|
||||
currency,
|
||||
onChange,
|
||||
onRemove,
|
||||
onDuplicate,
|
||||
readOnly = false,
|
||||
isLast = false,
|
||||
}: LineItemEditorProps) {
|
||||
const { colors } = useAppTheme();
|
||||
const hours = Number(item.hours) || 0;
|
||||
const rate = Number(item.rate) || 0;
|
||||
const amount = hours * rate;
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.readBlock,
|
||||
!isLast && { borderBottomColor: colors.border, borderBottomWidth: StyleSheet.hairlineWidth },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.readIndex, { color: colors.mutedForeground }]}>
|
||||
Line {index + 1}
|
||||
</Text>
|
||||
<Text style={[styles.readTitle, { color: colors.foreground }]} numberOfLines={3}>
|
||||
{item.description.trim() || "Untitled line"}
|
||||
</Text>
|
||||
<Text style={[styles.readSub, { color: colors.mutedForeground }]}>
|
||||
{formatShortDate(item.date)} · {hours}h × {formatCurrency(rate, currency)}
|
||||
</Text>
|
||||
<Text style={[styles.readAmount, { color: colors.foreground }]}>
|
||||
{formatCurrency(amount, currency)}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const content = (
|
||||
<View
|
||||
style={[
|
||||
styles.editBlock,
|
||||
!isLast && { borderBottomColor: colors.border, borderBottomWidth: StyleSheet.hairlineWidth },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.lineLabel, { color: colors.mutedForeground }]}>Line {index + 1}</Text>
|
||||
|
||||
<TextInput
|
||||
value={item.description}
|
||||
onChangeText={(description) => onChange({ description })}
|
||||
placeholder="What was done?"
|
||||
placeholderTextColor={colors.mutedForeground}
|
||||
style={[
|
||||
styles.descriptionInput,
|
||||
{
|
||||
color: colors.foreground,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.cardGlass,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<View style={styles.fieldsRow}>
|
||||
<View style={styles.fieldCol}>
|
||||
<FieldLabel>Date</FieldLabel>
|
||||
<CompactDateField
|
||||
value={item.date}
|
||||
onChange={(date) => onChange({ date })}
|
||||
style={styles.fieldControl}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.fieldCol}>
|
||||
<FieldLabel>Hours</FieldLabel>
|
||||
<CompactStepperInput
|
||||
value={item.hours}
|
||||
onChangeText={(hours) => onChange({ hours })}
|
||||
step={0.25}
|
||||
style={styles.fieldControl}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.fieldCol}>
|
||||
<FieldLabel>Rate</FieldLabel>
|
||||
<View
|
||||
style={[
|
||||
styles.rateField,
|
||||
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.ratePrefix, { color: colors.mutedForeground }]}>$</Text>
|
||||
<TextInput
|
||||
value={item.rate}
|
||||
onChangeText={(rate) => onChange({ rate })}
|
||||
keyboardType="decimal-pad"
|
||||
placeholder="0"
|
||||
placeholderTextColor={colors.mutedForeground}
|
||||
style={[styles.rateInput, { color: colors.foreground }]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.footerRow}>
|
||||
<View style={styles.amountGroup}>
|
||||
<Text style={[styles.amountLabel, { color: colors.mutedForeground }]}>Amount</Text>
|
||||
<Text style={[styles.amountValue, { color: colors.foreground }]}>
|
||||
{formatCurrency(amount, currency)}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Remove line item"
|
||||
onPress={onRemove}
|
||||
hitSlop={8}
|
||||
style={({ pressed }) => [
|
||||
styles.remove,
|
||||
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
|
||||
pressed && styles.removePressed,
|
||||
]}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={18} color={colors.destructive} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
const swipeActions = [
|
||||
...(onDuplicate
|
||||
? [
|
||||
{
|
||||
key: "duplicate",
|
||||
label: "Copy",
|
||||
icon: "copy-outline" as const,
|
||||
color: "#fff",
|
||||
backgroundColor: colors.primary,
|
||||
onPress: onDuplicate,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: "delete",
|
||||
label: "Delete",
|
||||
icon: "trash-outline" as const,
|
||||
color: "#fff",
|
||||
backgroundColor: colors.destructive,
|
||||
onPress: onRemove,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<SwipeableRow actions={swipeActions} backgroundColor={colors.card}>
|
||||
{content}
|
||||
</SwipeableRow>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
readBlock: {
|
||||
paddingVertical: spacing.md,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
readIndex: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 11,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.4,
|
||||
},
|
||||
readTitle: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 15,
|
||||
lineHeight: 20,
|
||||
},
|
||||
readSub: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 13,
|
||||
},
|
||||
readAmount: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 15,
|
||||
marginTop: 2,
|
||||
},
|
||||
editBlock: {
|
||||
paddingVertical: spacing.md,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
lineLabel: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 11,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.4,
|
||||
},
|
||||
descriptionInput: {
|
||||
width: "100%",
|
||||
minHeight: 40,
|
||||
borderWidth: 1,
|
||||
borderRadius: radii.md,
|
||||
paddingHorizontal: spacing.sm,
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 15,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
fieldsRow: {
|
||||
flexDirection: "row",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
fieldCol: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
minWidth: 0,
|
||||
},
|
||||
fieldLabel: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 11,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
fieldControl: {
|
||||
width: "100%",
|
||||
},
|
||||
rateField: {
|
||||
minHeight: 36,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
borderWidth: 1,
|
||||
borderRadius: radii.md,
|
||||
paddingHorizontal: spacing.xs,
|
||||
},
|
||||
ratePrefix: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 13,
|
||||
},
|
||||
rateInput: {
|
||||
flex: 1,
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 13,
|
||||
paddingVertical: 4,
|
||||
textAlign: "right",
|
||||
minWidth: 0,
|
||||
},
|
||||
footerRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: spacing.sm,
|
||||
marginTop: 2,
|
||||
},
|
||||
amountGroup: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "baseline",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
amountLabel: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 12,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
amountValue: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 17,
|
||||
},
|
||||
remove: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: radii.md,
|
||||
borderWidth: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
removePressed: {
|
||||
opacity: 0.65,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user