Polish mobile and web experience
This commit is contained in:
@@ -2,7 +2,6 @@ import { router } from "expo-router";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
RefreshControl,
|
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
@@ -15,6 +14,7 @@ import { FloatingActionButton } from "@/components/FloatingActionButton";
|
|||||||
import { GlassSurface } from "@/components/GlassSurface";
|
import { GlassSurface } from "@/components/GlassSurface";
|
||||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
import { PageHeader } from "@/components/PageHeader";
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { PullToRefresh } from "@/components/PullToRefresh";
|
||||||
import { SwipeableRow } from "@/components/SwipeableRow";
|
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||||
import { TabPage } from "@/components/TabPage";
|
import { TabPage } from "@/components/TabPage";
|
||||||
import { TabScrollView } from "@/components/TabScrollView";
|
import { TabScrollView } from "@/components/TabScrollView";
|
||||||
@@ -71,8 +71,7 @@ export default function EntitiesScreen() {
|
|||||||
const businesses = businessesQuery.data ?? [];
|
const businesses = businessesQuery.data ?? [];
|
||||||
|
|
||||||
function refresh() {
|
function refresh() {
|
||||||
if (tab === "clients") void clientsQuery.refetch();
|
return tab === "clients" ? clientsQuery.refetch() : businessesQuery.refetch();
|
||||||
else void businessesQuery.refetch();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmDelete(id: string, name: string) {
|
function confirmDelete(id: string, name: string) {
|
||||||
@@ -100,8 +99,7 @@ export default function EntitiesScreen() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<PullToRefresh
|
||||||
refreshing={activeQuery.isRefetching}
|
|
||||||
onRefresh={refresh}
|
onRefresh={refresh}
|
||||||
tintColor={colors.primary}
|
tintColor={colors.primary}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { Pressable, RefreshControl, StyleSheet, Text, View } from "react-native";
|
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
import { AppBackground } from "@/components/AppBackground";
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
import { GlassSurface } from "@/components/GlassSurface";
|
import { GlassSurface } from "@/components/GlassSurface";
|
||||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
import { PageHeader } from "@/components/PageHeader";
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { PullToRefresh } from "@/components/PullToRefresh";
|
||||||
import { Screen } from "@/components/Screen";
|
import { Screen } from "@/components/Screen";
|
||||||
import { StatCard } from "@/components/StatCard";
|
import { StatCard } from "@/components/StatCard";
|
||||||
import { StatusBadge } from "@/components/StatusBadge";
|
import { StatusBadge } from "@/components/StatusBadge";
|
||||||
@@ -152,12 +153,8 @@ export default function DashboardScreen() {
|
|||||||
<TabScrollView
|
<TabScrollView
|
||||||
header={<PageHeader title={`Hello, ${firstName}`} subtitle="What needs attention now" />}
|
header={<PageHeader title={`Hello, ${firstName}`} subtitle="What needs attention now" />}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<PullToRefresh
|
||||||
refreshing={statsQuery.isRefetching || runningQuery.isRefetching}
|
onRefresh={() => Promise.all([statsQuery.refetch(), runningQuery.refetch()])}
|
||||||
onRefresh={() => {
|
|
||||||
void statsQuery.refetch();
|
|
||||||
void runningQuery.refetch();
|
|
||||||
}}
|
|
||||||
tintColor={colors.primary}
|
tintColor={colors.primary}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -221,7 +218,10 @@ export default function DashboardScreen() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{running ? (
|
{running ? (
|
||||||
<Pressable onPress={() => router.push("/(app)/timer")}>
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => router.push("/(app)/timer")}
|
||||||
|
>
|
||||||
<GlassSurface style={styles.runningGlass}>
|
<GlassSurface style={styles.runningGlass}>
|
||||||
<View style={styles.runningRow}>
|
<View style={styles.runningRow}>
|
||||||
<View style={styles.runningDot} />
|
<View style={styles.runningDot} />
|
||||||
@@ -299,6 +299,11 @@ export default function DashboardScreen() {
|
|||||||
{stats.currentDraft ? (
|
{stats.currentDraft ? (
|
||||||
<GlassSurface style={styles.draftGlass}>
|
<GlassSurface style={styles.draftGlass}>
|
||||||
<Pressable
|
<Pressable
|
||||||
|
accessible
|
||||||
|
accessibilityLabel={`Current draft, ${
|
||||||
|
stats.currentDraft.client?.name ?? "Client"
|
||||||
|
}, ${formatCurrency(stats.currentDraft.totalAmount)}, ${stats.currentDraft.totalHours.toFixed(1)} hours logged`}
|
||||||
|
accessibilityRole="button"
|
||||||
style={styles.draftBanner}
|
style={styles.draftBanner}
|
||||||
onPress={() => router.push(`/(app)/invoices/${stats.currentDraft!.id}`)}
|
onPress={() => router.push(`/(app)/invoices/${stats.currentDraft!.id}`)}
|
||||||
>
|
>
|
||||||
@@ -325,7 +330,12 @@ export default function DashboardScreen() {
|
|||||||
<View style={styles.statCell}>
|
<View style={styles.statCell}>
|
||||||
<StatCard label="Overdue" value={String(stats.overdueCount)} />
|
<StatCard label="Overdue" value={String(stats.overdueCount)} />
|
||||||
</View>
|
</View>
|
||||||
<Pressable style={styles.statCell} onPress={() => router.push("/(app)/entities")}>
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Clients, ${stats.totalClients}`}
|
||||||
|
style={styles.statCell}
|
||||||
|
onPress={() => router.push("/(app)/entities")}
|
||||||
|
>
|
||||||
<StatCard label="Clients" value={String(stats.totalClients)} />
|
<StatCard label="Clients" value={String(stats.totalClients)} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
@@ -354,6 +364,14 @@ export default function DashboardScreen() {
|
|||||||
const status = getInvoiceStatus(invoice);
|
const status = getInvoiceStatus(invoice);
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
|
accessible
|
||||||
|
accessibilityLabel={`${invoice.invoicePrefix}${invoice.invoiceNumber}, ${
|
||||||
|
invoice.client?.name ?? "Client"
|
||||||
|
}, ${formatDate(invoice.issueDate)}, ${formatCurrency(
|
||||||
|
invoice.totalAmount,
|
||||||
|
invoice.currency,
|
||||||
|
)}, ${status}`}
|
||||||
|
accessibilityRole="button"
|
||||||
key={invoice.id}
|
key={invoice.id}
|
||||||
style={({ pressed }) => [styles.recentRow, pressed && styles.pressed]}
|
style={({ pressed }) => [styles.recentRow, pressed && styles.pressed]}
|
||||||
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
|
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { router } from "expo-router";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
RefreshControl,
|
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
@@ -15,6 +14,7 @@ import { FloatingActionButton } from "@/components/FloatingActionButton";
|
|||||||
import { GlassSurface } from "@/components/GlassSurface";
|
import { GlassSurface } from "@/components/GlassSurface";
|
||||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
import { PageHeader } from "@/components/PageHeader";
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { PullToRefresh } from "@/components/PullToRefresh";
|
||||||
import { SwipeableRow } from "@/components/SwipeableRow";
|
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||||
import { StatusBadge } from "@/components/StatusBadge";
|
import { StatusBadge } from "@/components/StatusBadge";
|
||||||
import { TabPage } from "@/components/TabPage";
|
import { TabPage } from "@/components/TabPage";
|
||||||
@@ -121,8 +121,7 @@ export default function InvoicesScreen() {
|
|||||||
<PageHeader title="Invoices" subtitle="Review status, amounts, and due dates" />
|
<PageHeader title="Invoices" subtitle="Review status, amounts, and due dates" />
|
||||||
}
|
}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<PullToRefresh
|
||||||
refreshing={invoicesQuery.isRefetching}
|
|
||||||
onRefresh={() => invoicesQuery.refetch()}
|
onRefresh={() => invoicesQuery.refetch()}
|
||||||
tintColor={colors.primary}
|
tintColor={colors.primary}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { formatCurrency } from "@/lib/format";
|
|||||||
import {
|
import {
|
||||||
isRequiredString,
|
isRequiredString,
|
||||||
isValidTaxRate,
|
isValidTaxRate,
|
||||||
|
useFieldVisibility,
|
||||||
validateLineItems,
|
validateLineItems,
|
||||||
} from "@/lib/form-validation";
|
} from "@/lib/form-validation";
|
||||||
import { resolveInvoiceBusinessId } from "@/lib/invoice-business";
|
import { resolveInvoiceBusinessId } from "@/lib/invoice-business";
|
||||||
@@ -70,6 +71,7 @@ export default function NewInvoiceScreen() {
|
|||||||
);
|
);
|
||||||
const [section, setSection] = useState<InvoiceEditorSection>("setup");
|
const [section, setSection] = useState<InvoiceEditorSection>("setup");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const { touch, visible, markSubmitted } = useFieldVisibility();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (businessId || !businessesQuery.data?.length) return;
|
if (businessId || !businessesQuery.data?.length) return;
|
||||||
@@ -184,10 +186,12 @@ export default function NewInvoiceScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateItem(index: number, patch: Partial<EditableLineItem>) {
|
function updateItem(index: number, patch: Partial<EditableLineItem>) {
|
||||||
|
touch("lineItems");
|
||||||
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function addItem() {
|
function addItem() {
|
||||||
|
touch("lineItems");
|
||||||
setItems((prev) => [
|
setItems((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
@@ -200,10 +204,12 @@ export default function NewInvoiceScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function removeItem(index: number) {
|
function removeItem(index: number) {
|
||||||
|
touch("lineItems");
|
||||||
setItems((prev) => prev.filter((_, i) => i !== index));
|
setItems((prev) => prev.filter((_, i) => i !== index));
|
||||||
}
|
}
|
||||||
|
|
||||||
function duplicateItem(index: number) {
|
function duplicateItem(index: number) {
|
||||||
|
touch("lineItems");
|
||||||
setItems((prev) => {
|
setItems((prev) => {
|
||||||
const source = prev[index];
|
const source = prev[index];
|
||||||
if (!source) return prev;
|
if (!source) return prev;
|
||||||
@@ -213,6 +219,7 @@ export default function NewInvoiceScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleCreate() {
|
function handleCreate() {
|
||||||
|
markSubmitted();
|
||||||
if (!canCreate) return;
|
if (!canCreate) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
@@ -296,27 +303,31 @@ export default function NewInvoiceScreen() {
|
|||||||
businessId={businessId}
|
businessId={businessId}
|
||||||
onBusinessIdChange={setBusinessId}
|
onBusinessIdChange={setBusinessId}
|
||||||
businessOptions={businessOptions}
|
businessOptions={businessOptions}
|
||||||
businessError={businessError}
|
businessError={visible("business") ? businessError : undefined}
|
||||||
|
onBusinessBlur={() => touch("business")}
|
||||||
clientId={clientId}
|
clientId={clientId}
|
||||||
onClientIdChange={setClientId}
|
onClientIdChange={setClientId}
|
||||||
clientOptions={clientOptions}
|
clientOptions={clientOptions}
|
||||||
clientError={clientError}
|
clientError={visible("client") ? clientError : undefined}
|
||||||
|
onClientBlur={() => touch("client")}
|
||||||
invoiceNumber={invoiceNumber}
|
invoiceNumber={invoiceNumber}
|
||||||
onInvoiceNumberChange={setInvoiceNumber}
|
onInvoiceNumberChange={setInvoiceNumber}
|
||||||
|
invoiceNumberError={
|
||||||
|
visible("invoiceNumber") ? invoiceNumberError : undefined
|
||||||
|
}
|
||||||
|
onInvoiceNumberBlur={() => touch("invoiceNumber")}
|
||||||
issueDate={issueDate}
|
issueDate={issueDate}
|
||||||
onIssueDateChange={setIssueDate}
|
onIssueDateChange={setIssueDate}
|
||||||
dueDate={dueDate}
|
dueDate={dueDate}
|
||||||
onDueDateChange={setDueDate}
|
onDueDateChange={setDueDate}
|
||||||
taxRate={taxRate}
|
taxRate={taxRate}
|
||||||
onTaxRateChange={setTaxRate}
|
onTaxRateChange={setTaxRate}
|
||||||
|
taxRateError={visible("taxRate") ? taxError : undefined}
|
||||||
|
onTaxRateBlur={() => touch("taxRate")}
|
||||||
notes={notes}
|
notes={notes}
|
||||||
onNotesChange={setNotes}
|
onNotesChange={setNotes}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{taxError ? <Text style={styles.error}>{taxError}</Text> : null}
|
|
||||||
{invoiceNumberError ? (
|
|
||||||
<Text style={styles.error}>{invoiceNumberError}</Text>
|
|
||||||
) : null}
|
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -354,11 +365,13 @@ export default function NewInvoiceScreen() {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{lineItemsError ? <Text style={styles.error}>{lineItemsError}</Text> : null}
|
{visible("lineItems") && lineItemsError ? (
|
||||||
|
<Text selectable style={styles.error}>{lineItemsError}</Text>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
{error ? <Text selectable style={styles.error}>{error}</Text> : null}
|
||||||
|
|
||||||
<InvoiceEditorFooter
|
<InvoiceEditorFooter
|
||||||
primaryTitle={isBlank ? "Create blank invoice" : "Create invoice"}
|
primaryTitle={isBlank ? "Create blank invoice" : "Create invoice"}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
RefreshControl,
|
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
@@ -15,6 +14,7 @@ import { AppBackground } from "@/components/AppBackground";
|
|||||||
import { FilterChip } from "@/components/FilterChip";
|
import { FilterChip } from "@/components/FilterChip";
|
||||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
import { PageHeader } from "@/components/PageHeader";
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { PullToRefresh } from "@/components/PullToRefresh";
|
||||||
import { SwipeableRow } from "@/components/SwipeableRow";
|
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||||
import { TabPage } from "@/components/TabPage";
|
import { TabPage } from "@/components/TabPage";
|
||||||
import { TabScrollView } from "@/components/TabScrollView";
|
import { TabScrollView } from "@/components/TabScrollView";
|
||||||
@@ -110,9 +110,8 @@ export default function ExpensesScreen() {
|
|||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<PullToRefresh
|
||||||
refreshing={expensesQuery.isRefetching}
|
onRefresh={() => expensesQuery.refetch()}
|
||||||
onRefresh={() => void expensesQuery.refetch()}
|
|
||||||
tintColor={colors.primary}
|
tintColor={colors.primary}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { RefreshControl, StyleSheet, Text, View } from "react-native";
|
import { StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
import { AppBackground } from "@/components/AppBackground";
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
import { PageHeader } from "@/components/PageHeader";
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { PullToRefresh } from "@/components/PullToRefresh";
|
||||||
import { SwipeableRow } from "@/components/SwipeableRow";
|
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||||
import { TabPage } from "@/components/TabPage";
|
import { TabPage } from "@/components/TabPage";
|
||||||
import { TabScrollView } from "@/components/TabScrollView";
|
import { TabScrollView } from "@/components/TabScrollView";
|
||||||
@@ -46,9 +47,8 @@ export default function RecurringScreen() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<PullToRefresh
|
||||||
refreshing={query.isRefetching}
|
onRefresh={() => query.refetch()}
|
||||||
onRefresh={() => void query.refetch()}
|
|
||||||
tintColor={colors.primary}
|
tintColor={colors.primary}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { RefreshControl, StyleSheet, Text, View } from "react-native";
|
import { StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
import { AppBackground } from "@/components/AppBackground";
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
import { PageHeader } from "@/components/PageHeader";
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { PullToRefresh } from "@/components/PullToRefresh";
|
||||||
import { StatCard } from "@/components/StatCard";
|
import { StatCard } from "@/components/StatCard";
|
||||||
import { TabPage } from "@/components/TabPage";
|
import { TabPage } from "@/components/TabPage";
|
||||||
import { TabScrollView } from "@/components/TabScrollView";
|
import { TabScrollView } from "@/components/TabScrollView";
|
||||||
@@ -48,13 +49,14 @@ export default function ReportsScreen() {
|
|||||||
<TabScrollView
|
<TabScrollView
|
||||||
header={<PageHeader title="Reports" subtitle="Business performance snapshot" />}
|
header={<PageHeader title="Reports" subtitle="Business performance snapshot" />}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<PullToRefresh
|
||||||
refreshing={statsQuery.isRefetching}
|
onRefresh={() =>
|
||||||
onRefresh={() => {
|
Promise.all([
|
||||||
void statsQuery.refetch();
|
statsQuery.refetch(),
|
||||||
void expensesQuery.refetch();
|
expensesQuery.refetch(),
|
||||||
void summaryQuery.refetch();
|
summaryQuery.refetch(),
|
||||||
}}
|
])
|
||||||
|
}
|
||||||
tintColor={colors.primary}
|
tintColor={colors.primary}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { RefreshControl, StyleSheet, Text, View } from "react-native";
|
import { StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
import { AppBackground } from "@/components/AppBackground";
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
import { PageHeader } from "@/components/PageHeader";
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { PullToRefresh } from "@/components/PullToRefresh";
|
||||||
import { SwipeableRow } from "@/components/SwipeableRow";
|
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||||
import { TabPage } from "@/components/TabPage";
|
import { TabPage } from "@/components/TabPage";
|
||||||
import { TabScrollView } from "@/components/TabScrollView";
|
import { TabScrollView } from "@/components/TabScrollView";
|
||||||
@@ -74,9 +75,8 @@ export default function TimeEntriesScreen() {
|
|||||||
<PageHeader title="Time entries" subtitle={`${completed.length} completed entries`} />
|
<PageHeader title="Time entries" subtitle={`${completed.length} completed entries`} />
|
||||||
}
|
}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<PullToRefresh
|
||||||
refreshing={entriesQuery.isRefetching}
|
onRefresh={() => entriesQuery.refetch()}
|
||||||
onRefresh={() => void entriesQuery.refetch()}
|
|
||||||
tintColor={colors.primary}
|
tintColor={colors.primary}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,11 @@ import { useAppTheme } from "@/contexts/ThemeContext";
|
|||||||
import { resetPassword } from "@/lib/auth-api";
|
import { resetPassword } from "@/lib/auth-api";
|
||||||
import type { ThemeColors } from "@/lib/theme-palette";
|
import type { ThemeColors } from "@/lib/theme-palette";
|
||||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||||
import { isRequiredString, isValidPassword } from "@/lib/form-validation";
|
import {
|
||||||
|
isRequiredString,
|
||||||
|
isValidPassword,
|
||||||
|
useFieldVisibility,
|
||||||
|
} from "@/lib/form-validation";
|
||||||
|
|
||||||
export default function ResetPasswordScreen() {
|
export default function ResetPasswordScreen() {
|
||||||
const styles = useThemedStyles(createResetPasswordStyles);
|
const styles = useThemedStyles(createResetPasswordStyles);
|
||||||
@@ -33,6 +37,7 @@ export default function ResetPasswordScreen() {
|
|||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [serverReady, setServerReady] = useState(true);
|
const [serverReady, setServerReady] = useState(true);
|
||||||
|
const { touch, visible, markSubmitted } = useFieldVisibility();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof tokenParam === "string" && tokenParam.length > 0) {
|
if (typeof tokenParam === "string" && tokenParam.length > 0) {
|
||||||
@@ -56,6 +61,7 @@ export default function ResetPasswordScreen() {
|
|||||||
confirmPassword.length > 0;
|
confirmPassword.length > 0;
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
|
markSubmitted();
|
||||||
if (!canSubmit) return;
|
if (!canSubmit) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
@@ -111,30 +117,33 @@ export default function ResetPasswordScreen() {
|
|||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
value={token}
|
value={token}
|
||||||
onChangeText={setToken}
|
onChangeText={setToken}
|
||||||
|
onBlur={() => touch("token")}
|
||||||
placeholder="Paste token from email"
|
placeholder="Paste token from email"
|
||||||
required
|
required
|
||||||
error={tokenError}
|
error={visible("token") ? tokenError : undefined}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="New password"
|
label="New password"
|
||||||
secureTextEntry
|
secureTextEntry
|
||||||
value={password}
|
value={password}
|
||||||
onChangeText={setPassword}
|
onChangeText={setPassword}
|
||||||
|
onBlur={() => touch("password")}
|
||||||
placeholder="At least 8 characters"
|
placeholder="At least 8 characters"
|
||||||
required
|
required
|
||||||
error={passwordError}
|
error={visible("password") ? passwordError : undefined}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Confirm password"
|
label="Confirm password"
|
||||||
secureTextEntry
|
secureTextEntry
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChangeText={setConfirmPassword}
|
onChangeText={setConfirmPassword}
|
||||||
|
onBlur={() => touch("confirmPassword")}
|
||||||
placeholder="Repeat password"
|
placeholder="Repeat password"
|
||||||
required
|
required
|
||||||
error={confirmError}
|
error={visible("confirmPassword") ? confirmError : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
{error ? <Text selectable style={styles.error}>{error}</Text> : null}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
title="Update password"
|
title="Update password"
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ export function FilterChip({ label, active, onPress }: FilterChipProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
|
accessible
|
||||||
|
accessibilityLabel={label}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
|
accessibilityState={{ selected: Boolean(active) }}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
style={[
|
style={[
|
||||||
styles.chip,
|
styles.chip,
|
||||||
@@ -39,7 +42,7 @@ export function FilterChip({ label, active, onPress }: FilterChipProps) {
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
chip: {
|
chip: {
|
||||||
height: 32,
|
minHeight: 44,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderRadius: radii.pill,
|
borderRadius: radii.pill,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { RefreshControl, type RefreshControlProps } from "react-native";
|
||||||
|
|
||||||
|
type PullToRefreshProps = Omit<RefreshControlProps, "onRefresh" | "refreshing"> & {
|
||||||
|
onRefresh: () => Promise<unknown> | unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the native refresh indicator tied to an actual pull gesture.
|
||||||
|
* Query `isRefetching` also covers background polling and invalidations, which
|
||||||
|
* can repeatedly move an offscreen iOS scroll view when used as `refreshing`.
|
||||||
|
*/
|
||||||
|
export function PullToRefresh({ onRefresh, ...props }: PullToRefreshProps) {
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(async () => {
|
||||||
|
if (refreshing) return;
|
||||||
|
|
||||||
|
setRefreshing(true);
|
||||||
|
try {
|
||||||
|
await onRefresh();
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, [onRefresh, refreshing]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RefreshControl
|
||||||
|
{...props}
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={() => void handleRefresh()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,9 +4,15 @@ import { Pressable, type PressableProps, StyleSheet, Text, View } from "react-na
|
|||||||
import Swipeable, {
|
import Swipeable, {
|
||||||
type SwipeableMethods,
|
type SwipeableMethods,
|
||||||
} from "react-native-gesture-handler/ReanimatedSwipeable";
|
} from "react-native-gesture-handler/ReanimatedSwipeable";
|
||||||
|
import Animated, {
|
||||||
|
interpolate,
|
||||||
|
useAnimatedStyle,
|
||||||
|
type SharedValue,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
|
||||||
import { fonts, radii, spacing } from "@/constants/theme";
|
import { fonts, radii, spacing } from "@/constants/theme";
|
||||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { resolveActionForeground } from "@/lib/action-contrast";
|
||||||
import type { ThemeColors } from "@/lib/theme-palette";
|
import type { ThemeColors } from "@/lib/theme-palette";
|
||||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||||
|
|
||||||
@@ -29,6 +35,38 @@ type SwipeableRowProps = {
|
|||||||
contentStyle?: PressableProps["style"];
|
contentStyle?: PressableProps["style"];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SwipeActionsProps = {
|
||||||
|
actions: SwipeAction[];
|
||||||
|
progress: SharedValue<number>;
|
||||||
|
onActionPress: (action: SwipeAction) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function SwipeActions({ actions, progress, onActionPress }: SwipeActionsProps) {
|
||||||
|
const styles = useThemedStyles(createSwipeableRowStyles);
|
||||||
|
const revealStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: interpolate(progress.value, [0, 0.02, 0.15], [0, 0, 1], "clamp"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[styles.actions, revealStyle]}>
|
||||||
|
{actions.map((action) => {
|
||||||
|
const foreground = resolveActionForeground(action.backgroundColor, action.color);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={action.key}
|
||||||
|
style={[styles.actionButton, { backgroundColor: action.backgroundColor }]}
|
||||||
|
onPress={() => onActionPress(action)}
|
||||||
|
>
|
||||||
|
<Ionicons name={action.icon} size={20} color={foreground} />
|
||||||
|
<Text style={[styles.actionLabel, { color: foreground }]}>{action.label}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SwipeableRow({
|
export function SwipeableRow({
|
||||||
children,
|
children,
|
||||||
actions,
|
actions,
|
||||||
@@ -92,25 +130,20 @@ export function SwipeableRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRightActions() {
|
function handleActionPress(action: SwipeAction) {
|
||||||
|
suppressContentPress();
|
||||||
|
swipeRef.current?.close();
|
||||||
|
rowOpenRef.current = false;
|
||||||
|
action.onPress();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRightActions(progress: SharedValue<number>) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.actions}>
|
<SwipeActions
|
||||||
{actions.map((action) => (
|
actions={actions}
|
||||||
<Pressable
|
progress={progress}
|
||||||
key={action.key}
|
onActionPress={handleActionPress}
|
||||||
style={[styles.actionButton, { backgroundColor: action.backgroundColor }]}
|
/>
|
||||||
onPress={() => {
|
|
||||||
suppressContentPress();
|
|
||||||
swipeRef.current?.close();
|
|
||||||
rowOpenRef.current = false;
|
|
||||||
action.onPress();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name={action.icon} size={20} color={action.color} />
|
|
||||||
<Text style={[styles.actionLabel, { color: action.color }]}>{action.label}</Text>
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +154,7 @@ export function SwipeableRow({
|
|||||||
return (
|
return (
|
||||||
<Swipeable
|
<Swipeable
|
||||||
ref={swipeRef}
|
ref={swipeRef}
|
||||||
|
containerStyle={styles.container}
|
||||||
renderRightActions={renderRightActions}
|
renderRightActions={renderRightActions}
|
||||||
overshootRight={false}
|
overshootRight={false}
|
||||||
onSwipeableOpenStartDrag={suppressContentPress}
|
onSwipeableOpenStartDrag={suppressContentPress}
|
||||||
@@ -141,11 +175,15 @@ export function SwipeableRow({
|
|||||||
|
|
||||||
const createSwipeableRowStyles = (colors: ThemeColors) =>
|
const createSwipeableRowStyles = (colors: ThemeColors) =>
|
||||||
StyleSheet.create({
|
StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
borderRadius: radii.lg,
|
||||||
|
overflow: "hidden",
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
row: {
|
row: {
|
||||||
backgroundColor: colors.background,
|
backgroundColor: colors.background,
|
||||||
borderRadius: radii.lg,
|
borderRadius: radii.lg,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
marginBottom: spacing.xs,
|
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { fonts, spacing } from "@/constants/theme";
|
|||||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
import type { ThemeColors } from "@/lib/theme-palette";
|
import type { ThemeColors } from "@/lib/theme-palette";
|
||||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||||
import { isRequiredString } from "@/lib/form-validation";
|
import { isRequiredString, useFieldVisibility } from "@/lib/form-validation";
|
||||||
import { api } from "@/lib/trpc";
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
type BusinessFormValues = {
|
type BusinessFormValues = {
|
||||||
@@ -78,6 +78,7 @@ export function BusinessForm({
|
|||||||
|
|
||||||
const [values, setValues] = useState<BusinessFormValues>(emptyValues);
|
const [values, setValues] = useState<BusinessFormValues>(emptyValues);
|
||||||
const [fieldError, setFieldError] = useState<string | null>(null);
|
const [fieldError, setFieldError] = useState<string | null>(null);
|
||||||
|
const { touch, visible, markSubmitted } = useFieldVisibility();
|
||||||
|
|
||||||
const switchProps = {
|
const switchProps = {
|
||||||
trackColor: { false: colors.switchTrackOff, true: colors.switchTrackOn },
|
trackColor: { false: colors.switchTrackOff, true: colors.switchTrackOn },
|
||||||
@@ -154,6 +155,7 @@ export function BusinessForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleSave() {
|
function handleSave() {
|
||||||
|
markSubmitted();
|
||||||
if (!canSave) return;
|
if (!canSave) return;
|
||||||
|
|
||||||
const payload = buildPayload();
|
const payload = buildPayload();
|
||||||
@@ -203,8 +205,9 @@ export function BusinessForm({
|
|||||||
label="Name"
|
label="Name"
|
||||||
value={values.name}
|
value={values.name}
|
||||||
onChangeText={(v) => patch("name", v)}
|
onChangeText={(v) => patch("name", v)}
|
||||||
|
onBlur={() => touch("name")}
|
||||||
required
|
required
|
||||||
error={nameError}
|
error={visible("name") ? nameError : undefined}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Nickname"
|
label="Nickname"
|
||||||
@@ -281,7 +284,7 @@ export function BusinessForm({
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{fieldError ? <Text style={styles.error}>{fieldError}</Text> : null}
|
{fieldError ? <Text selectable style={styles.error}>{fieldError}</Text> : null}
|
||||||
|
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ import { Input } from "@/components/ui/Input";
|
|||||||
import { fonts, spacing } from "@/constants/theme";
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
import type { ThemeColors } from "@/lib/theme-palette";
|
import type { ThemeColors } from "@/lib/theme-palette";
|
||||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||||
import { isRequiredString, parseNonNegativeNumber } from "@/lib/form-validation";
|
import {
|
||||||
|
isRequiredString,
|
||||||
|
parseNonNegativeNumber,
|
||||||
|
useFieldVisibility,
|
||||||
|
} from "@/lib/form-validation";
|
||||||
import { api } from "@/lib/trpc";
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
export type ClientFormValues = {
|
export type ClientFormValues = {
|
||||||
@@ -71,6 +75,7 @@ export function ClientForm({
|
|||||||
|
|
||||||
const [values, setValues] = useState<ClientFormValues>(emptyValues);
|
const [values, setValues] = useState<ClientFormValues>(emptyValues);
|
||||||
const [fieldError, setFieldError] = useState<string | null>(null);
|
const [fieldError, setFieldError] = useState<string | null>(null);
|
||||||
|
const { touch, visible, markSubmitted } = useFieldVisibility();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const client = clientQuery.data;
|
const client = clientQuery.data;
|
||||||
@@ -125,6 +130,7 @@ export function ClientForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleSave() {
|
function handleSave() {
|
||||||
|
markSubmitted();
|
||||||
if (!canSave) return;
|
if (!canSave) return;
|
||||||
|
|
||||||
const rate = values.defaultHourlyRate.trim()
|
const rate = values.defaultHourlyRate.trim()
|
||||||
@@ -199,8 +205,9 @@ export function ClientForm({
|
|||||||
label="Name"
|
label="Name"
|
||||||
value={values.name}
|
value={values.name}
|
||||||
onChangeText={(v) => patch("name", v)}
|
onChangeText={(v) => patch("name", v)}
|
||||||
|
onBlur={() => touch("name")}
|
||||||
required
|
required
|
||||||
error={nameError}
|
error={visible("name") ? nameError : undefined}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Email"
|
label="Email"
|
||||||
@@ -247,9 +254,10 @@ export function ClientForm({
|
|||||||
label="Default hourly rate"
|
label="Default hourly rate"
|
||||||
value={values.defaultHourlyRate}
|
value={values.defaultHourlyRate}
|
||||||
onChangeText={(v) => patch("defaultHourlyRate", v)}
|
onChangeText={(v) => patch("defaultHourlyRate", v)}
|
||||||
|
onBlur={() => touch("defaultHourlyRate")}
|
||||||
keyboardType="decimal-pad"
|
keyboardType="decimal-pad"
|
||||||
placeholder="Optional"
|
placeholder="Optional"
|
||||||
error={rateError}
|
error={visible("defaultHourlyRate") ? rateError : undefined}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Currency"
|
label="Currency"
|
||||||
@@ -260,7 +268,7 @@ export function ClientForm({
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{fieldError ? <Text style={styles.error}>{fieldError}</Text> : null}
|
{fieldError ? <Text selectable style={styles.error}>{fieldError}</Text> : null}
|
||||||
|
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -14,14 +14,18 @@ type InvoiceSetupFormProps = {
|
|||||||
onBusinessIdChange: (value: string) => void;
|
onBusinessIdChange: (value: string) => void;
|
||||||
businessOptions: SelectOption[];
|
businessOptions: SelectOption[];
|
||||||
businessError?: string;
|
businessError?: string;
|
||||||
|
onBusinessBlur?: () => void;
|
||||||
businessReadOnly?: boolean;
|
businessReadOnly?: boolean;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
onClientIdChange: (value: string) => void;
|
onClientIdChange: (value: string) => void;
|
||||||
clientOptions: SelectOption[];
|
clientOptions: SelectOption[];
|
||||||
clientError?: string;
|
clientError?: string;
|
||||||
|
onClientBlur?: () => void;
|
||||||
clientReadOnly?: boolean;
|
clientReadOnly?: boolean;
|
||||||
invoiceNumber: string;
|
invoiceNumber: string;
|
||||||
onInvoiceNumberChange?: (value: string) => void;
|
onInvoiceNumberChange?: (value: string) => void;
|
||||||
|
invoiceNumberError?: string;
|
||||||
|
onInvoiceNumberBlur?: () => void;
|
||||||
invoiceNumberReadOnly?: boolean;
|
invoiceNumberReadOnly?: boolean;
|
||||||
issueDate: Date;
|
issueDate: Date;
|
||||||
onIssueDateChange?: (date: Date) => void;
|
onIssueDateChange?: (date: Date) => void;
|
||||||
@@ -30,6 +34,8 @@ type InvoiceSetupFormProps = {
|
|||||||
onDueDateChange: (date: Date) => void;
|
onDueDateChange: (date: Date) => void;
|
||||||
taxRate: string;
|
taxRate: string;
|
||||||
onTaxRateChange?: (value: string) => void;
|
onTaxRateChange?: (value: string) => void;
|
||||||
|
taxRateError?: string;
|
||||||
|
onTaxRateBlur?: () => void;
|
||||||
taxRateReadOnly?: boolean;
|
taxRateReadOnly?: boolean;
|
||||||
notes: string;
|
notes: string;
|
||||||
onNotesChange: (value: string) => void;
|
onNotesChange: (value: string) => void;
|
||||||
@@ -43,14 +49,18 @@ export function InvoiceSetupForm({
|
|||||||
onBusinessIdChange,
|
onBusinessIdChange,
|
||||||
businessOptions,
|
businessOptions,
|
||||||
businessError,
|
businessError,
|
||||||
|
onBusinessBlur,
|
||||||
businessReadOnly = false,
|
businessReadOnly = false,
|
||||||
clientId,
|
clientId,
|
||||||
onClientIdChange,
|
onClientIdChange,
|
||||||
clientOptions,
|
clientOptions,
|
||||||
clientError,
|
clientError,
|
||||||
|
onClientBlur,
|
||||||
clientReadOnly = false,
|
clientReadOnly = false,
|
||||||
invoiceNumber,
|
invoiceNumber,
|
||||||
onInvoiceNumberChange,
|
onInvoiceNumberChange,
|
||||||
|
invoiceNumberError,
|
||||||
|
onInvoiceNumberBlur,
|
||||||
invoiceNumberReadOnly = false,
|
invoiceNumberReadOnly = false,
|
||||||
issueDate,
|
issueDate,
|
||||||
onIssueDateChange,
|
onIssueDateChange,
|
||||||
@@ -59,6 +69,8 @@ export function InvoiceSetupForm({
|
|||||||
onDueDateChange,
|
onDueDateChange,
|
||||||
taxRate,
|
taxRate,
|
||||||
onTaxRateChange,
|
onTaxRateChange,
|
||||||
|
taxRateError,
|
||||||
|
onTaxRateBlur,
|
||||||
taxRateReadOnly = false,
|
taxRateReadOnly = false,
|
||||||
notes,
|
notes,
|
||||||
onNotesChange,
|
onNotesChange,
|
||||||
@@ -84,6 +96,7 @@ export function InvoiceSetupForm({
|
|||||||
error={businessError}
|
error={businessError}
|
||||||
disabled={businessReadOnly}
|
disabled={businessReadOnly}
|
||||||
onValueChange={onBusinessIdChange}
|
onValueChange={onBusinessIdChange}
|
||||||
|
onBlur={onBusinessBlur}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -101,6 +114,7 @@ export function InvoiceSetupForm({
|
|||||||
error={clientError}
|
error={clientError}
|
||||||
disabled={clientReadOnly}
|
disabled={clientReadOnly}
|
||||||
onValueChange={onClientIdChange}
|
onValueChange={onClientIdChange}
|
||||||
|
onBlur={onClientBlur}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -118,8 +132,10 @@ export function InvoiceSetupForm({
|
|||||||
label="Invoice number"
|
label="Invoice number"
|
||||||
value={invoiceNumber}
|
value={invoiceNumber}
|
||||||
onChangeText={onInvoiceNumberChange}
|
onChangeText={onInvoiceNumberChange}
|
||||||
|
onBlur={onInvoiceNumberBlur}
|
||||||
autoCapitalize="characters"
|
autoCapitalize="characters"
|
||||||
required
|
required
|
||||||
|
error={invoiceNumberError}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -160,7 +176,9 @@ export function InvoiceSetupForm({
|
|||||||
label="Tax rate (%)"
|
label="Tax rate (%)"
|
||||||
value={taxRate}
|
value={taxRate}
|
||||||
onChangeText={onTaxRateChange}
|
onChangeText={onTaxRateChange}
|
||||||
|
onBlur={onTaxRateBlur}
|
||||||
keyboardType="decimal-pad"
|
keyboardType="decimal-pad"
|
||||||
|
error={taxRateError}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useState, type ReactNode } from "react";
|
|||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Pressable,
|
Pressable,
|
||||||
RefreshControl,
|
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
@@ -13,6 +12,7 @@ import { router } from "expo-router";
|
|||||||
import { FilterChip } from "@/components/FilterChip";
|
import { FilterChip } from "@/components/FilterChip";
|
||||||
import { GlassSurface } from "@/components/GlassSurface";
|
import { GlassSurface } from "@/components/GlassSurface";
|
||||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
|
import { PullToRefresh } from "@/components/PullToRefresh";
|
||||||
import { SwipeableRow } from "@/components/SwipeableRow";
|
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||||
import { TabScrollView } from "@/components/TabScrollView";
|
import { TabScrollView } from "@/components/TabScrollView";
|
||||||
import { TimeEntryEditSheet } from "@/components/time-clock/TimeEntryEditSheet";
|
import { TimeEntryEditSheet } from "@/components/time-clock/TimeEntryEditSheet";
|
||||||
@@ -20,16 +20,14 @@ import { Button } from "@/components/ui/Button";
|
|||||||
import { Card } from "@/components/ui/Card";
|
import { Card } from "@/components/ui/Card";
|
||||||
import { DateTimeField } from "@/components/ui/DateTimeField";
|
import { DateTimeField } from "@/components/ui/DateTimeField";
|
||||||
import { Input } from "@/components/ui/Input";
|
import { Input } from "@/components/ui/Input";
|
||||||
|
import { SelectField } from "@/components/ui/SelectField";
|
||||||
import { fonts, spacing } from "@/constants/theme";
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
import { useAccounts } from "@/contexts/AccountsContext";
|
import { useAccounts } from "@/contexts/AccountsContext";
|
||||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
import { formatCurrency, formatDateTime } from "@/lib/format";
|
import { formatCurrency, formatDateTime } from "@/lib/format";
|
||||||
import { parseNonNegativeNumber } from "@/lib/form-validation";
|
import { parseNonNegativeNumber } from "@/lib/form-validation";
|
||||||
import type { ThemeColors } from "@/lib/theme-palette";
|
import type { ThemeColors } from "@/lib/theme-palette";
|
||||||
import {
|
import { setLastTimeClockClientId } from "@/lib/time-clock-prefs";
|
||||||
getLastTimeClockClientId,
|
|
||||||
setLastTimeClockClientId,
|
|
||||||
} from "@/lib/time-clock-prefs";
|
|
||||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||||
import {
|
import {
|
||||||
endTimeClockLiveActivity,
|
endTimeClockLiveActivity,
|
||||||
@@ -99,12 +97,8 @@ export function TimeClockPanel({
|
|||||||
const [agoMinutes, setAgoMinutes] = useState(60);
|
const [agoMinutes, setAgoMinutes] = useState(60);
|
||||||
const [agoMinutesText, setAgoMinutesText] = useState("60");
|
const [agoMinutesText, setAgoMinutesText] = useState("60");
|
||||||
const [optionsExpanded, setOptionsExpanded] = useState(false);
|
const [optionsExpanded, setOptionsExpanded] = useState(false);
|
||||||
const [clientsExpanded, setClientsExpanded] = useState(false);
|
|
||||||
const [editEntryId, setEditEntryId] = useState<string | null>(null);
|
const [editEntryId, setEditEntryId] = useState<string | null>(null);
|
||||||
const [runningStartedAt, setRunningStartedAt] = useState(() => new Date());
|
const [runningStartedAt, setRunningStartedAt] = useState(() => new Date());
|
||||||
const [featuredClientIds, setFeaturedClientIds] = useState<string[]>([]);
|
|
||||||
const [storedLastClientId, setStoredLastClientId] = useState<string | null>(null);
|
|
||||||
const [prefsLoaded, setPrefsLoaded] = useState(false);
|
|
||||||
|
|
||||||
const running = runningQuery.data;
|
const running = runningQuery.data;
|
||||||
const elapsed = useRunningElapsed(running?.startedAt);
|
const elapsed = useRunningElapsed(running?.startedAt);
|
||||||
@@ -124,19 +118,6 @@ export function TimeClockPanel({
|
|||||||
|
|
||||||
const entriesQuery = api.timeEntries.getAll.useQuery();
|
const entriesQuery = api.timeEntries.getAll.useQuery();
|
||||||
|
|
||||||
const recentClientIds = useMemo(() => {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const ids: string[] = [];
|
|
||||||
for (const entry of entriesQuery.data ?? []) {
|
|
||||||
if (entry.clientId && !seen.has(entry.clientId)) {
|
|
||||||
seen.add(entry.clientId);
|
|
||||||
ids.push(entry.clientId);
|
|
||||||
if (ids.length >= 2) break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ids;
|
|
||||||
}, [entriesQuery.data]);
|
|
||||||
|
|
||||||
const clockIn = api.timeEntries.clockIn.useMutation({
|
const clockIn = api.timeEntries.clockIn.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await utils.timeEntries.getRunning.invalidate();
|
await utils.timeEntries.getRunning.invalidate();
|
||||||
@@ -180,18 +161,6 @@ export function TimeClockPanel({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!activeAccountId) {
|
|
||||||
setPrefsLoaded(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPrefsLoaded(false);
|
|
||||||
void getLastTimeClockClientId(activeAccountId).then((id) => {
|
|
||||||
setStoredLastClientId(id);
|
|
||||||
setPrefsLoaded(true);
|
|
||||||
});
|
|
||||||
}, [activeAccountId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!running) return;
|
if (!running) return;
|
||||||
setClientId(running.clientId ?? "");
|
setClientId(running.clientId ?? "");
|
||||||
@@ -209,22 +178,6 @@ export function TimeClockPanel({
|
|||||||
setRateText((current) => current.trim() || clientRateText(client));
|
setRateText((current) => current.trim() || clientRateText(client));
|
||||||
}, [clientId, clients, running]);
|
}, [clientId, clients, running]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (featuredClientIds.length > 0 || !prefsLoaded || clients.length === 0) return;
|
|
||||||
|
|
||||||
const ids: string[] = [];
|
|
||||||
const add = (id: string | null | undefined) => {
|
|
||||||
if (!id || ids.includes(id)) return;
|
|
||||||
if (!clients.some((client) => client.id === id)) return;
|
|
||||||
ids.push(id);
|
|
||||||
};
|
|
||||||
|
|
||||||
add(storedLastClientId);
|
|
||||||
for (const id of recentClientIds) add(id);
|
|
||||||
|
|
||||||
setFeaturedClientIds(ids.slice(0, 1));
|
|
||||||
}, [clients, featuredClientIds.length, prefsLoaded, recentClientIds, storedLastClientId]);
|
|
||||||
|
|
||||||
const selectedClient = clients.find((client) => client.id === clientId);
|
const selectedClient = clients.find((client) => client.id === clientId);
|
||||||
const rateCurrency = selectedClient?.currency ?? "USD";
|
const rateCurrency = selectedClient?.currency ?? "USD";
|
||||||
const effectiveRate = resolveEffectiveHourlyRate(
|
const effectiveRate = resolveEffectiveHourlyRate(
|
||||||
@@ -235,21 +188,6 @@ export function TimeClockPanel({
|
|||||||
? (running.rate ?? effectiveRate ?? 0)
|
? (running.rate ?? effectiveRate ?? 0)
|
||||||
: (effectiveRate ?? 0);
|
: (effectiveRate ?? 0);
|
||||||
|
|
||||||
const featuredClients = useMemo(
|
|
||||||
() =>
|
|
||||||
featuredClientIds
|
|
||||||
.map((id) => clients.find((client) => client.id === id))
|
|
||||||
.filter((client) => client != null),
|
|
||||||
[clients, featuredClientIds],
|
|
||||||
);
|
|
||||||
|
|
||||||
const moreClients = useMemo(() => {
|
|
||||||
const featuredIds = new Set(featuredClientIds);
|
|
||||||
return clients
|
|
||||||
.filter((client) => !featuredIds.has(client.id))
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
}, [clients, featuredClientIds]);
|
|
||||||
|
|
||||||
const resolvedStartAt = useMemo(() => {
|
const resolvedStartAt = useMemo(() => {
|
||||||
if (startMode === "now") return new Date();
|
if (startMode === "now") return new Date();
|
||||||
if (startMode === "ago") return startedAtFromMinutesAgo(agoMinutes);
|
if (startMode === "ago") return startedAtFromMinutesAgo(agoMinutes);
|
||||||
@@ -285,11 +223,14 @@ export function TimeClockPanel({
|
|||||||
),
|
),
|
||||||
[entriesQuery.data, todayStart],
|
[entriesQuery.data, todayStart],
|
||||||
);
|
);
|
||||||
|
const todayHours = useMemo(
|
||||||
|
() => todayEntries.reduce((total, entry) => total + Number(entry.hours ?? 0), 0),
|
||||||
|
[todayEntries],
|
||||||
|
);
|
||||||
|
|
||||||
async function persistClientChoice(nextClientId: string, syncState = false) {
|
async function persistClientChoice(nextClientId: string) {
|
||||||
if (!activeAccountId || !nextClientId) return;
|
if (!activeAccountId || !nextClientId) return;
|
||||||
await setLastTimeClockClientId(activeAccountId, nextClientId);
|
await setLastTimeClockClientId(activeAccountId, nextClientId);
|
||||||
if (syncState) setStoredLastClientId(nextClientId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectClient(nextClientId: string) {
|
function selectClient(nextClientId: string) {
|
||||||
@@ -305,9 +246,6 @@ export function TimeClockPanel({
|
|||||||
setClientId(nextClientId);
|
setClientId(nextClientId);
|
||||||
setInvoiceId("");
|
setInvoiceId("");
|
||||||
setRateText(clientRateText(client));
|
setRateText(clientRateText(client));
|
||||||
if (nextClientId && !featuredClientIds.includes(nextClientId)) {
|
|
||||||
setClientsExpanded(true);
|
|
||||||
}
|
|
||||||
if (nextClientId) {
|
if (nextClientId) {
|
||||||
void persistClientChoice(nextClientId);
|
void persistClientChoice(nextClientId);
|
||||||
}
|
}
|
||||||
@@ -420,63 +358,83 @@ export function TimeClockPanel({
|
|||||||
.join(" · ");
|
.join(" · ");
|
||||||
|
|
||||||
|
|
||||||
function renderClientChip(client: (typeof clients)[number]) {
|
|
||||||
return (
|
|
||||||
<FilterChip
|
|
||||||
key={client.id}
|
|
||||||
label={client.name}
|
|
||||||
active={clientId === client.id}
|
|
||||||
onPress={() => selectClient(client.id)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TabScrollView
|
<TabScrollView
|
||||||
style={styles.scroll}
|
style={styles.scroll}
|
||||||
header={header}
|
header={header}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<PullToRefresh
|
||||||
refreshing={runningQuery.isRefetching}
|
onRefresh={() =>
|
||||||
onRefresh={() => {
|
Promise.all([
|
||||||
void runningQuery.refetch();
|
runningQuery.refetch(),
|
||||||
void clientsQuery.refetch();
|
clientsQuery.refetch(),
|
||||||
void billableQuery.refetch();
|
billableQuery.refetch(),
|
||||||
void entriesQuery.refetch();
|
entriesQuery.refetch(),
|
||||||
}}
|
])
|
||||||
|
}
|
||||||
tintColor={colors.primary}
|
tintColor={colors.primary}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{running || !compact ? (
|
{running ? (
|
||||||
<GlassSurface style={running ? styles.runningCard : undefined}>
|
<GlassSurface style={running ? styles.runningCard : undefined}>
|
||||||
<View style={[styles.hero, running && styles.heroRunning]}>
|
<View style={[styles.hero, running && styles.heroRunning]}>
|
||||||
{running ? (
|
<View style={styles.heroHeader}>
|
||||||
<>
|
<View style={styles.pulseDot} />
|
||||||
<View style={styles.heroHeader}>
|
<Text style={styles.heroLabelRunning}>In progress</Text>
|
||||||
<View style={styles.pulseDot} />
|
</View>
|
||||||
<Text style={styles.heroLabelRunning}>Timer running</Text>
|
<Text selectable style={styles.timerValue}>
|
||||||
</View>
|
{formatElapsedSeconds(elapsed)}
|
||||||
<Text style={styles.timerValue}>{formatElapsedSeconds(elapsed)}</Text>
|
</Text>
|
||||||
<Text style={styles.runningTitle}>{runningTitle}</Text>
|
<Text selectable style={styles.runningTitle}>{runningTitle}</Text>
|
||||||
{runningMeta ? (
|
{runningMeta ? (
|
||||||
<Text style={styles.runningMeta}>{runningMeta}</Text>
|
<Text selectable style={styles.runningMeta}>{runningMeta}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Text style={styles.idleHint}>
|
|
||||||
Start the timer anytime — add client, invoice, and details later.
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</GlassSurface>
|
</GlassSurface>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<GlassSurface style={styles.setupCard}>
|
<GlassSurface style={styles.setupCard}>
|
||||||
<Text style={styles.cardTitle}>{running ? "Update & stop" : "Clock in"}</Text>
|
{!running ? (
|
||||||
|
<View style={styles.idleIntro}>
|
||||||
|
<Text style={styles.idleEyebrow}>Ready to start</Text>
|
||||||
|
<Text style={styles.idleTitle}>What are you working on?</Text>
|
||||||
|
<Text style={styles.idleCopy}>
|
||||||
|
Add what you know now. You can update the rest while the timer runs.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{running ? (
|
{running ? (
|
||||||
<View style={styles.formSection}>
|
<>
|
||||||
|
<Button
|
||||||
|
title={clockOut.isPending ? "Stopping…" : "Stop & save entry"}
|
||||||
|
variant="danger"
|
||||||
|
leftIcon="stop"
|
||||||
|
loading={clockOut.isPending}
|
||||||
|
onPress={handleClockOut}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
accessible
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityState={{ expanded: optionsExpanded }}
|
||||||
|
onPress={() => setOptionsExpanded((open) => !open)}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.optionsToggle,
|
||||||
|
pressed && styles.optionsTogglePressed,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.optionsToggleText}>
|
||||||
|
<Text style={styles.optionsToggleLabel}>Edit timer details</Text>
|
||||||
|
<Text style={styles.optionsToggleSummary}>
|
||||||
|
Started {formatDateTime(runningStartedAt)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.optionsChevron}>{optionsExpanded ? "−" : "+"}</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{optionsExpanded ? <View style={styles.formSection}>
|
||||||
<Input
|
<Input
|
||||||
label="What are you working on?"
|
label="What are you working on?"
|
||||||
value={description}
|
value={description}
|
||||||
@@ -544,79 +502,49 @@ export function TimeClockPanel({
|
|||||||
}
|
}
|
||||||
returnKeyType="done"
|
returnKeyType="done"
|
||||||
/>
|
/>
|
||||||
<Button
|
</View> : null}
|
||||||
title={clockOut.isPending ? "Stopping…" : "Stop & save"}
|
</>
|
||||||
variant="danger"
|
|
||||||
loading={clockOut.isPending}
|
|
||||||
onPress={handleClockOut}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<TextInput
|
<View style={styles.idleFields}>
|
||||||
value={description}
|
<Input
|
||||||
onChangeText={setDescription}
|
label="Description"
|
||||||
placeholder="What are you working on?"
|
value={description}
|
||||||
placeholderTextColor={colors.mutedForeground}
|
onChangeText={setDescription}
|
||||||
returnKeyType="done"
|
placeholder="e.g. Client kickoff…"
|
||||||
style={[styles.titleField, { color: colors.foreground }]}
|
returnKeyType="done"
|
||||||
/>
|
style={styles.titleField}
|
||||||
|
containerStyle={styles.timerField}
|
||||||
|
/>
|
||||||
|
|
||||||
<View style={styles.setupSection}>
|
<SelectField
|
||||||
<Text style={styles.sectionLabel}>Client</Text>
|
label="Client"
|
||||||
{clients.length === 0 ? (
|
placeholder="Select a client"
|
||||||
<Text style={styles.emptyClients}>
|
value={clientId}
|
||||||
No clients yet — you can still start the timer and assign a client later.
|
options={[
|
||||||
</Text>
|
{ label: "No client", value: "" },
|
||||||
) : (
|
...clients.map((client) => ({ label: client.name, value: client.id })),
|
||||||
<>
|
]}
|
||||||
<View style={styles.chipWrap}>
|
onValueChange={selectClient}
|
||||||
<FilterChip
|
containerStyle={styles.timerField}
|
||||||
label="No client"
|
/>
|
||||||
active={!clientId}
|
|
||||||
onPress={() => selectClient("")}
|
|
||||||
/>
|
|
||||||
{featuredClients.map((client) => renderClientChip(client))}
|
|
||||||
{moreClients.length > 0 ? (
|
|
||||||
<FilterChip
|
|
||||||
label={clientsExpanded ? "Show less" : "Show more"}
|
|
||||||
active={clientsExpanded}
|
|
||||||
onPress={() => setClientsExpanded((open) => !open)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
{clientsExpanded && moreClients.length > 0 ? (
|
|
||||||
<View style={[styles.chipWrap, styles.moreClientsWrap]}>
|
|
||||||
{moreClients.map((client) => renderClientChip(client))}
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.setupSection}>
|
<SelectField
|
||||||
<Text style={styles.sectionLabel}>Invoice (optional)</Text>
|
label="Invoice (optional)"
|
||||||
{!clientId ? (
|
placeholder={clientId ? "Select an invoice" : "Choose a client first"}
|
||||||
<Text style={styles.emptyClients}>
|
value={clientId ? invoiceId : "__client_required__"}
|
||||||
No invoice for now. Add a client and invoice later if this becomes billable.
|
disabled={!clientId}
|
||||||
</Text>
|
options={[
|
||||||
) : (
|
{ label: "Entry only — no invoice", value: "" },
|
||||||
<View style={styles.chipWrap}>
|
...billableInvoices.map((invoice) => ({
|
||||||
<FilterChip label="Entry only" active={!invoiceId} onPress={() => setInvoiceId("")} />
|
label: `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`,
|
||||||
{billableInvoices.map((invoice) => {
|
value: invoice.id,
|
||||||
const label = `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`;
|
})),
|
||||||
return (
|
]}
|
||||||
<FilterChip
|
onValueChange={selectInvoice}
|
||||||
key={invoice.id}
|
containerStyle={styles.timerField}
|
||||||
label={label}
|
/>
|
||||||
active={invoiceId === invoice.id}
|
|
||||||
onPress={() => setInvoiceId(invoice.id)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.setupSection}>
|
<View style={styles.setupSection}>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -736,9 +664,8 @@ export function TimeClockPanel({
|
|||||||
)}
|
)}
|
||||||
</GlassSurface>
|
</GlassSurface>
|
||||||
|
|
||||||
{todayEntries.length > 0 ? (
|
<Card title={`Today · ${todayHours.toFixed(2)}h`}>
|
||||||
<Card title="Today's entries">
|
{todayEntries.length > 0 ? todayEntries.map((entry) => {
|
||||||
{todayEntries.map((entry) => {
|
|
||||||
const invoiceLabel = entry.invoice
|
const invoiceLabel = entry.invoice
|
||||||
? `${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
|
? `${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
|
||||||
: null;
|
: null;
|
||||||
@@ -783,9 +710,20 @@ export function TimeClockPanel({
|
|||||||
</View>
|
</View>
|
||||||
</SwipeableRow>
|
</SwipeableRow>
|
||||||
);
|
);
|
||||||
})}
|
}) : (
|
||||||
</Card>
|
<View style={styles.todayEmpty}>
|
||||||
) : null}
|
<Text style={styles.todayEmptyTitle}>No time logged yet</Text>
|
||||||
|
<Text style={styles.todayEmptyCopy}>
|
||||||
|
Start your first timer or open history to add an entry manually.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
title="View time history"
|
||||||
|
variant="secondary"
|
||||||
|
onPress={() => router.push("/(app)/more/time-entries")}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<TimeEntryEditSheet
|
<TimeEntryEditSheet
|
||||||
entryId={editEntryId}
|
entryId={editEntryId}
|
||||||
@@ -857,25 +795,45 @@ const createTimeClockStyles = (colors: ThemeColors, isDark: boolean) =>
|
|||||||
},
|
},
|
||||||
setupCard: {
|
setupCard: {
|
||||||
padding: spacing.lg,
|
padding: spacing.lg,
|
||||||
gap: spacing.lg,
|
gap: spacing.md,
|
||||||
},
|
},
|
||||||
cardTitle: {
|
idleIntro: {
|
||||||
fontSize: 16,
|
gap: spacing.xs,
|
||||||
|
paddingBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
idleEyebrow: {
|
||||||
|
fontSize: 11,
|
||||||
fontFamily: fonts.bodySemiBold,
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 0.7,
|
||||||
|
},
|
||||||
|
idleTitle: {
|
||||||
|
fontSize: 22,
|
||||||
|
lineHeight: 28,
|
||||||
|
fontFamily: fonts.heading,
|
||||||
color: colors.foreground,
|
color: colors.foreground,
|
||||||
},
|
},
|
||||||
|
idleCopy: {
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 18,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
},
|
||||||
formSection: {
|
formSection: {
|
||||||
gap: spacing.md,
|
gap: spacing.md,
|
||||||
},
|
},
|
||||||
titleField: {
|
titleField: {
|
||||||
fontSize: 18,
|
|
||||||
fontFamily: fonts.bodyMedium,
|
|
||||||
minHeight: 48,
|
minHeight: 48,
|
||||||
paddingVertical: spacing.xs,
|
},
|
||||||
|
idleFields: {
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
timerField: {
|
||||||
|
gap: 6,
|
||||||
},
|
},
|
||||||
setupSection: {
|
setupSection: {
|
||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
paddingTop: spacing.lg,
|
|
||||||
},
|
},
|
||||||
sectionLabel: {
|
sectionLabel: {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
@@ -892,9 +850,6 @@ const createTimeClockStyles = (colors: ThemeColors, isDark: boolean) =>
|
|||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
},
|
},
|
||||||
moreClientsWrap: {
|
|
||||||
paddingTop: spacing.xs,
|
|
||||||
},
|
|
||||||
emptyClients: {
|
emptyClients: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: fonts.body,
|
fontFamily: fonts.body,
|
||||||
@@ -996,4 +951,23 @@ const createTimeClockStyles = (colors: ThemeColors, isDark: boolean) =>
|
|||||||
color: colors.foreground,
|
color: colors.foreground,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
},
|
},
|
||||||
|
todayEmpty: {
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.xs,
|
||||||
|
paddingVertical: spacing.lg,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
},
|
||||||
|
todayEmptyTitle: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
color: colors.foreground,
|
||||||
|
fontSize: 14,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
todayEmptyCopy: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 18,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -62,7 +62,12 @@ export function DateTimeField({
|
|||||||
<View style={styles.wrapper}>
|
<View style={styles.wrapper}>
|
||||||
<Text style={[styles.label, { color: colors.mutedForeground }]}>{label}</Text>
|
<Text style={[styles.label, { color: colors.mutedForeground }]}>{label}</Text>
|
||||||
<Pressable
|
<Pressable
|
||||||
|
accessible
|
||||||
|
accessibilityLabel={`${label}, ${
|
||||||
|
mode === "date" ? formatDate(value) : formatDateTime(value)
|
||||||
|
}`}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
|
accessibilityState={{ expanded: open }}
|
||||||
onPress={openPicker}
|
onPress={openPicker}
|
||||||
style={({ pressed }) => [
|
style={({ pressed }) => [
|
||||||
styles.trigger,
|
styles.trigger,
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
View,
|
View,
|
||||||
|
type StyleProp,
|
||||||
type TextInputProps,
|
type TextInputProps,
|
||||||
|
type ViewStyle,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
@@ -17,6 +19,7 @@ type InputProps = TextInputProps & {
|
|||||||
leftIcon?: keyof typeof Ionicons.glyphMap;
|
leftIcon?: keyof typeof Ionicons.glyphMap;
|
||||||
labelAccessory?: React.ReactNode;
|
labelAccessory?: React.ReactNode;
|
||||||
hint?: string;
|
hint?: string;
|
||||||
|
containerStyle?: StyleProp<ViewStyle>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Input({
|
export function Input({
|
||||||
@@ -26,13 +29,14 @@ export function Input({
|
|||||||
leftIcon,
|
leftIcon,
|
||||||
labelAccessory,
|
labelAccessory,
|
||||||
hint,
|
hint,
|
||||||
|
containerStyle,
|
||||||
style,
|
style,
|
||||||
...props
|
...props
|
||||||
}: InputProps) {
|
}: InputProps) {
|
||||||
const { colors } = useAppTheme();
|
const { colors } = useAppTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.wrapper}>
|
<View style={[styles.wrapper, containerStyle]}>
|
||||||
<View style={styles.labelRow}>
|
<View style={styles.labelRow}>
|
||||||
<Text style={[styles.label, { color: colors.foreground }]}>
|
<Text style={[styles.label, { color: colors.foreground }]}>
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
|
type StyleProp,
|
||||||
|
type ViewStyle,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
|
||||||
import { fonts, radii, spacing } from "@/constants/theme";
|
import { fonts, radii, spacing } from "@/constants/theme";
|
||||||
@@ -26,6 +28,8 @@ type SelectFieldProps = {
|
|||||||
required?: boolean;
|
required?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
onValueChange: (value: string) => void;
|
onValueChange: (value: string) => void;
|
||||||
|
onBlur?: () => void;
|
||||||
|
containerStyle?: StyleProp<ViewStyle>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SelectField({
|
export function SelectField({
|
||||||
@@ -37,19 +41,29 @@ export function SelectField({
|
|||||||
required,
|
required,
|
||||||
error,
|
error,
|
||||||
onValueChange,
|
onValueChange,
|
||||||
|
onBlur,
|
||||||
|
containerStyle,
|
||||||
}: SelectFieldProps) {
|
}: SelectFieldProps) {
|
||||||
const { colors } = useAppTheme();
|
const { colors } = useAppTheme();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const selected = options.find((option) => option.value === value);
|
const selected = options.find((option) => option.value === value);
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
setOpen(false);
|
||||||
|
onBlur?.();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.wrapper}>
|
<View style={[styles.wrapper, containerStyle]}>
|
||||||
<Text style={[styles.label, { color: colors.foreground }]}>
|
<Text style={[styles.label, { color: colors.foreground }]}>
|
||||||
{label}
|
{label}
|
||||||
{required ? <Text style={{ color: colors.destructive }}> *</Text> : null}
|
{required ? <Text style={{ color: colors.destructive }}> *</Text> : null}
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable
|
<Pressable
|
||||||
|
accessible
|
||||||
|
accessibilityLabel={`${label}, ${selected?.label ?? placeholder}`}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
|
accessibilityState={{ disabled: Boolean(disabled), expanded: open }}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onPress={() => setOpen(true)}
|
onPress={() => setOpen(true)}
|
||||||
style={({ pressed }) => [
|
style={({ pressed }) => [
|
||||||
@@ -77,18 +91,18 @@ export function SelectField({
|
|||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
animationType="slide"
|
animationType="slide"
|
||||||
onRequestClose={() => setOpen(false)}
|
onRequestClose={close}
|
||||||
transparent
|
transparent
|
||||||
visible={open}
|
visible={open}
|
||||||
>
|
>
|
||||||
<Pressable style={styles.backdrop} onPress={() => setOpen(false)}>
|
<Pressable style={styles.backdrop} onPress={close}>
|
||||||
<Pressable
|
<Pressable
|
||||||
style={[styles.sheet, { backgroundColor: colors.background }]}
|
style={[styles.sheet, { backgroundColor: colors.background }]}
|
||||||
onPress={(event) => event.stopPropagation()}
|
onPress={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
<View style={[styles.sheetHeader, { borderBottomColor: colors.border }]}>
|
<View style={[styles.sheetHeader, { borderBottomColor: colors.border }]}>
|
||||||
<Text style={[styles.sheetTitle, { color: colors.foreground }]}>{label}</Text>
|
<Text style={[styles.sheetTitle, { color: colors.foreground }]}>{label}</Text>
|
||||||
<Pressable accessibilityRole="button" onPress={() => setOpen(false)}>
|
<Pressable accessibilityRole="button" onPress={close}>
|
||||||
<Text style={[styles.done, { color: colors.primary }]}>Done</Text>
|
<Text style={[styles.done, { color: colors.primary }]}>Done</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
@@ -101,7 +115,7 @@ export function SelectField({
|
|||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
onValueChange(option.value);
|
onValueChange(option.value);
|
||||||
setOpen(false);
|
close();
|
||||||
}}
|
}}
|
||||||
style={({ pressed }) => [
|
style={({ pressed }) => [
|
||||||
styles.option,
|
styles.option,
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
const DARK_ACTION_FOREGROUND = "#18181B";
|
||||||
|
const LIGHT_ACTION_FOREGROUND = "#FFFFFF";
|
||||||
|
const MIN_TEXT_CONTRAST = 4.5;
|
||||||
|
|
||||||
|
function parseHexColor(color: string): [number, number, number] | null {
|
||||||
|
const value = color.trim().replace(/^#/, "");
|
||||||
|
const expanded =
|
||||||
|
value.length === 3
|
||||||
|
? value
|
||||||
|
.split("")
|
||||||
|
.map((character) => character.repeat(2))
|
||||||
|
.join("")
|
||||||
|
: value.slice(0, 6);
|
||||||
|
|
||||||
|
if (expanded.length !== 6 || !/^[0-9a-f]+$/i.test(expanded)) return null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
Number.parseInt(expanded.slice(0, 2), 16),
|
||||||
|
Number.parseInt(expanded.slice(2, 4), 16),
|
||||||
|
Number.parseInt(expanded.slice(4, 6), 16),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function luminance(color: string): number | null {
|
||||||
|
const rgb = parseHexColor(color);
|
||||||
|
if (!rgb) return null;
|
||||||
|
|
||||||
|
const channels = rgb.map((channel) => {
|
||||||
|
const value = channel / 255;
|
||||||
|
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||||||
|
});
|
||||||
|
|
||||||
|
return 0.2126 * channels[0]! + 0.7152 * channels[1]! + 0.0722 * channels[2]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contrastRatio(foreground: string, background: string): number | null {
|
||||||
|
const foregroundLuminance = luminance(foreground);
|
||||||
|
const backgroundLuminance = luminance(background);
|
||||||
|
if (foregroundLuminance == null || backgroundLuminance == null) return null;
|
||||||
|
|
||||||
|
const lighter = Math.max(foregroundLuminance, backgroundLuminance);
|
||||||
|
const darker = Math.min(foregroundLuminance, backgroundLuminance);
|
||||||
|
return (lighter + 0.05) / (darker + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chooses a readable action label/icon color while preserving a valid requested color. */
|
||||||
|
export function resolveActionForeground(background: string, requested: string): string {
|
||||||
|
const requestedContrast = contrastRatio(requested, background);
|
||||||
|
if (requestedContrast == null || requestedContrast >= MIN_TEXT_CONTRAST) {
|
||||||
|
return requested;
|
||||||
|
}
|
||||||
|
|
||||||
|
const darkContrast = contrastRatio(DARK_ACTION_FOREGROUND, background) ?? 0;
|
||||||
|
const lightContrast = contrastRatio(LIGHT_ACTION_FOREGROUND, background) ?? 0;
|
||||||
|
return darkContrast >= lightContrast ? DARK_ACTION_FOREGROUND : LIGHT_ACTION_FOREGROUND;
|
||||||
|
}
|
||||||
@@ -11,6 +11,18 @@ const CHUNK_MARKER = "\u0001ba-chunks:";
|
|||||||
const SESSION_TOKEN_COOKIE_PART =
|
const SESSION_TOKEN_COOKIE_PART =
|
||||||
/(?:^|;\s*)(?:__Secure-)?[^=]*session_token=([^;]+)/;
|
/(?:^|;\s*)(?:__Secure-)?[^=]*session_token=([^;]+)/;
|
||||||
const AUTH_COOKIE_DEBUG = process.env.EXPO_PUBLIC_AUTH_COOKIE_DEBUG === "1";
|
const AUTH_COOKIE_DEBUG = process.env.EXPO_PUBLIC_AUTH_COOKIE_DEBUG === "1";
|
||||||
|
const lastAuthCookieDebugState = new Map<string, string>();
|
||||||
|
|
||||||
|
function debugAuthCookie(event: string, storagePrefix: string, details: Record<string, unknown>) {
|
||||||
|
if (!AUTH_COOKIE_DEBUG) return;
|
||||||
|
|
||||||
|
const key = `${event}:${storagePrefix}`;
|
||||||
|
const state = JSON.stringify(details);
|
||||||
|
if (lastAuthCookieDebugState.get(key) === state) return;
|
||||||
|
|
||||||
|
lastAuthCookieDebugState.set(key, state);
|
||||||
|
console.info(`[auth-cookie] ${event}`, { storagePrefix, ...details });
|
||||||
|
}
|
||||||
|
|
||||||
function readSecureStoreValueSync(key: string): string | null {
|
function readSecureStoreValueSync(key: string): string | null {
|
||||||
const value = SecureStore.getItem(key);
|
const value = SecureStore.getItem(key);
|
||||||
@@ -56,25 +68,19 @@ export function getAuthCookie(
|
|||||||
).getCookie?.();
|
).getCookie?.();
|
||||||
if (fromClient?.trim()) {
|
if (fromClient?.trim()) {
|
||||||
const cookie = fromClient.trim();
|
const cookie = fromClient.trim();
|
||||||
if (AUTH_COOKIE_DEBUG) {
|
debugAuthCookie("using client cookie", storagePrefix, {
|
||||||
console.info("[auth-cookie] using client cookie", {
|
length: cookie.length,
|
||||||
storagePrefix,
|
names: cookieNames(cookie),
|
||||||
length: cookie.length,
|
});
|
||||||
names: cookieNames(cookie),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return cookie;
|
return cookie;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fromPrefix = readStoredCookie(storagePrefix);
|
const fromPrefix = readStoredCookie(storagePrefix);
|
||||||
if (fromPrefix) {
|
if (fromPrefix) {
|
||||||
if (AUTH_COOKIE_DEBUG) {
|
debugAuthCookie("using stored cookie", storagePrefix, {
|
||||||
console.info("[auth-cookie] using stored cookie", {
|
length: fromPrefix.length,
|
||||||
storagePrefix,
|
names: cookieNames(fromPrefix),
|
||||||
length: fromPrefix.length,
|
});
|
||||||
names: cookieNames(fromPrefix),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return fromPrefix;
|
return fromPrefix;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,18 +88,15 @@ export function getAuthCookie(
|
|||||||
storagePrefix === GUEST_AUTH_STORAGE_PREFIX
|
storagePrefix === GUEST_AUTH_STORAGE_PREFIX
|
||||||
? null
|
? null
|
||||||
: readStoredCookie(GUEST_AUTH_STORAGE_PREFIX);
|
: readStoredCookie(GUEST_AUTH_STORAGE_PREFIX);
|
||||||
if (AUTH_COOKIE_DEBUG) {
|
debugAuthCookie("resolved tRPC cookie", storagePrefix, {
|
||||||
console.info("[auth-cookie] resolved tRPC cookie", {
|
fallbackPrefix:
|
||||||
storagePrefix,
|
fromGuest && storagePrefix !== GUEST_AUTH_STORAGE_PREFIX
|
||||||
fallbackPrefix:
|
? GUEST_AUTH_STORAGE_PREFIX
|
||||||
fromGuest && storagePrefix !== GUEST_AUTH_STORAGE_PREFIX
|
: null,
|
||||||
? GUEST_AUTH_STORAGE_PREFIX
|
hasCookie: Boolean(fromGuest),
|
||||||
: null,
|
length: fromGuest?.length ?? 0,
|
||||||
hasCookie: Boolean(fromGuest),
|
names: fromGuest ? cookieNames(fromGuest) : [],
|
||||||
length: fromGuest?.length ?? 0,
|
});
|
||||||
names: fromGuest ? cookieNames(fromGuest) : [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return fromGuest;
|
return fromGuest;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,21 +106,16 @@ export function getAuthCookieHeaders(
|
|||||||
): Record<string, string> {
|
): Record<string, string> {
|
||||||
const cookie = getAuthCookie(authClient, storagePrefix);
|
const cookie = getAuthCookie(authClient, storagePrefix);
|
||||||
if (!cookie) {
|
if (!cookie) {
|
||||||
if (AUTH_COOKIE_DEBUG) {
|
debugAuthCookie("no tRPC auth cookie", storagePrefix, {});
|
||||||
console.info("[auth-cookie] no tRPC auth cookie", { storagePrefix });
|
|
||||||
}
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionToken = cookie.match(SESSION_TOKEN_COOKIE_PART)?.[1];
|
const sessionToken = cookie.match(SESSION_TOKEN_COOKIE_PART)?.[1];
|
||||||
if (AUTH_COOKIE_DEBUG) {
|
debugAuthCookie("sending tRPC auth headers", storagePrefix, {
|
||||||
console.info("[auth-cookie] sending tRPC auth headers", {
|
cookieLength: cookie.length,
|
||||||
storagePrefix,
|
cookieNames: cookieNames(cookie),
|
||||||
cookieLength: cookie.length,
|
hasSessionTokenHeader: Boolean(sessionToken),
|
||||||
cookieNames: cookieNames(cookie),
|
});
|
||||||
hasSessionTokenHeader: Boolean(sessionToken),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
cookie,
|
cookie,
|
||||||
Cookie: cookie,
|
Cookie: cookie,
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
/** Matches web invoice-form default numbering. */
|
/** Matches web invoice-form default numbering. */
|
||||||
export function generateInvoiceNumber(): string {
|
export function generateInvoiceNumber(now = new Date()): string {
|
||||||
const date = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
const date = [
|
||||||
return `INV-${date}-${String(Date.now()).slice(-6)}`;
|
now.getFullYear(),
|
||||||
|
String(now.getMonth() + 1).padStart(2, "0"),
|
||||||
|
String(now.getDate()).padStart(2, "0"),
|
||||||
|
].join("");
|
||||||
|
|
||||||
|
return `INV-${date}-${String(now.getTime()).slice(-6)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function defaultDueDate(issueDate: Date): Date {
|
export function defaultDueDate(issueDate: Date): Date {
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ import { spacing } from "@/constants/theme";
|
|||||||
/** Standard UITabBar content height (home indicator is separate). */
|
/** Standard UITabBar content height (home indicator is separate). */
|
||||||
const IOS_TAB_BAR_HEIGHT = 49;
|
const IOS_TAB_BAR_HEIGHT = 49;
|
||||||
|
|
||||||
/** Trim extra inset so scroll content sits closer to the tab bar. */
|
|
||||||
const TAB_BAR_PADDING_TRIM = spacing.lg;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pixels between the bottom of the safe-area layout frame and the window bottom.
|
* Pixels between the bottom of the safe-area layout frame and the window bottom.
|
||||||
*/
|
*/
|
||||||
@@ -39,7 +36,7 @@ export function useTabBarScrollPadding(): number {
|
|||||||
const tabBar = useNativeTabBarHeight();
|
const tabBar = useNativeTabBarHeight();
|
||||||
const clearance = tabBar + homeIndicator;
|
const clearance = tabBar + homeIndicator;
|
||||||
|
|
||||||
return Math.max(spacing.xs, clearance - TAB_BAR_PADDING_TRIM);
|
return clearance + spacing.sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bottom offset for floating action buttons above the tab bar. */
|
/** Bottom offset for floating action buttons above the tab bar. */
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import {
|
|||||||
import { fetchAuthCapabilities } from "../lib/auth-capabilities";
|
import { fetchAuthCapabilities } from "../lib/auth-capabilities";
|
||||||
import { EXPENSE_CATEGORIES as appExpenseCategories } from "../lib/expense-categories";
|
import { EXPENSE_CATEGORIES as appExpenseCategories } from "../lib/expense-categories";
|
||||||
import { getInvoiceStatus } from "../lib/invoice-status";
|
import { getInvoiceStatus } from "../lib/invoice-status";
|
||||||
|
import { generateInvoiceNumber as generateMobileInvoiceNumber } from "../lib/invoice-number";
|
||||||
import { formatElapsedSeconds as formatAppElapsedSeconds } from "../lib/time-clock";
|
import { formatElapsedSeconds as formatAppElapsedSeconds } from "../lib/time-clock";
|
||||||
|
import { generateInvoiceNumber as generateWebInvoiceNumber } from "../../web/src/lib/draft-invoice";
|
||||||
import { safeCallbackPath } from "../../web/src/lib/safe-callback-url";
|
import { safeCallbackPath } from "../../web/src/lib/safe-callback-url";
|
||||||
import {
|
import {
|
||||||
normalizeOptionalId,
|
normalizeOptionalId,
|
||||||
@@ -47,6 +49,13 @@ describe("auth contract smoke checks", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("invoice parity", () => {
|
describe("invoice parity", () => {
|
||||||
|
test("web and mobile use the device-local date in invoice numbers", () => {
|
||||||
|
const lateLocalEvening = new Date(2026, 7, 16, 23, 30, 0, 123);
|
||||||
|
|
||||||
|
expect(generateMobileInvoiceNumber(lateLocalEvening)).toStartWith("INV-20260816-");
|
||||||
|
expect(generateWebInvoiceNumber(lateLocalEvening)).toStartWith("INV-20260816-");
|
||||||
|
});
|
||||||
|
|
||||||
test("web and mobile agree on draft, paid, sent, and overdue states", () => {
|
test("web and mobile agree on draft, paid, sent, and overdue states", () => {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/// <reference types="bun" />
|
||||||
|
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import { resolveActionForeground } from "../lib/action-contrast";
|
||||||
|
|
||||||
|
describe("swipe action contrast", () => {
|
||||||
|
test("uses dark content on bright dark-mode action colors", () => {
|
||||||
|
expect(resolveActionForeground("#FAFAFA", "#FFFFFF")).toBe("#18181B");
|
||||||
|
expect(resolveActionForeground("#A1A1AA", "#FFFFFF")).toBe("#18181B");
|
||||||
|
expect(resolveActionForeground("#4ADE80", "#FFFFFF")).toBe("#18181B");
|
||||||
|
expect(resolveActionForeground("#FBBF24", "#FFFFFF")).toBe("#18181B");
|
||||||
|
expect(resolveActionForeground("#F87171", "#FFFFFF")).toBe("#18181B");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps white content on the light-mode primary action", () => {
|
||||||
|
expect(resolveActionForeground("#18181B", "#FFFFFF")).toBe("#FFFFFF");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
Mail,
|
Mail,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { SUPPORTED_CURRENCIES } from "~/lib/currency";
|
import { SUPPORTED_CURRENCIES } from "~/lib/currency";
|
||||||
|
import { generateInvoiceNumber } from "~/lib/draft-invoice";
|
||||||
import { Textarea } from "~/components/ui/textarea";
|
import { Textarea } from "~/components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -108,7 +109,7 @@ function plainTextToHtml(value: string) {
|
|||||||
|
|
||||||
function createDefaultInvoiceFormData(): InvoiceFormData {
|
function createDefaultInvoiceFormData(): InvoiceFormData {
|
||||||
return {
|
return {
|
||||||
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`,
|
invoiceNumber: generateInvoiceNumber(),
|
||||||
invoicePrefix: "#",
|
invoicePrefix: "#",
|
||||||
businessId: "",
|
businessId: "",
|
||||||
clientId: "",
|
clientId: "",
|
||||||
|
|||||||
@@ -3,7 +3,14 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { NumberInput } from "~/components/ui/number-input";
|
import { NumberInput } from "~/components/ui/number-input";
|
||||||
@@ -11,6 +18,7 @@ import { Label } from "~/components/ui/label";
|
|||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
@@ -20,7 +28,7 @@ import {
|
|||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "~/components/ui/collapsible";
|
} from "~/components/ui/collapsible";
|
||||||
import { ChevronDown, Clock, Play, Square } from "lucide-react";
|
import { ChevronDown, Play, Square } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import {
|
import {
|
||||||
@@ -39,8 +47,6 @@ import { invoiceLabel } from "~/lib/time-entry-display";
|
|||||||
import { TimeEntryList } from "~/components/time-clock/time-entry-list";
|
import { TimeEntryList } from "~/components/time-clock/time-entry-list";
|
||||||
import { TimeEntryEditDialog } from "~/components/time-clock/time-entry-edit-dialog";
|
import { TimeEntryEditDialog } from "~/components/time-clock/time-entry-edit-dialog";
|
||||||
|
|
||||||
const FEATURED_CLIENT_COUNT = 4;
|
|
||||||
|
|
||||||
type StartMode = "now" | "pick" | "ago";
|
type StartMode = "now" | "pick" | "ago";
|
||||||
|
|
||||||
function toDatetimeLocalValue(value: Date | string) {
|
function toDatetimeLocalValue(value: Date | string) {
|
||||||
@@ -67,21 +73,25 @@ function RunningTextFields({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="clock-running-title">What are you working on?</Label>
|
<Label htmlFor="clock-running-title">What are you working on?</Label>
|
||||||
<Input
|
<Input
|
||||||
id="clock-running-title"
|
id="clock-running-title"
|
||||||
|
name="description"
|
||||||
|
autoComplete="off"
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
onBlur={() => onDescriptionCommit(title)}
|
onBlur={() => onDescriptionCommit(title)}
|
||||||
placeholder="What are you working on?"
|
placeholder="e.g. Client kickoff…"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="clock-running-start">Started at</Label>
|
<Label htmlFor="clock-running-start">Started at</Label>
|
||||||
<Input
|
<Input
|
||||||
id="clock-running-start"
|
id="clock-running-start"
|
||||||
|
name="startedAt"
|
||||||
|
autoComplete="off"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={runningStartedAt}
|
value={runningStartedAt}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -105,31 +115,6 @@ export type TimeClockPanelProps = {
|
|||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function ClientChip({
|
|
||||||
label,
|
|
||||||
active,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
active: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
className={cn(
|
|
||||||
"rounded-full border px-3 py-1.5 text-sm font-medium transition-colors",
|
|
||||||
active
|
|
||||||
? "border-primary bg-primary text-primary-foreground"
|
|
||||||
: "border-border bg-background hover:bg-muted",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TimeClockPanel({
|
export function TimeClockPanel({
|
||||||
defaultClientId = "",
|
defaultClientId = "",
|
||||||
defaultInvoiceId = "",
|
defaultInvoiceId = "",
|
||||||
@@ -161,7 +146,6 @@ export function TimeClockPanel({
|
|||||||
const [stopNote, setStopNote] = useState("");
|
const [stopNote, setStopNote] = useState("");
|
||||||
const [rate, setRate] = useState(0);
|
const [rate, setRate] = useState(0);
|
||||||
const [elapsed, setElapsed] = useState(0);
|
const [elapsed, setElapsed] = useState(0);
|
||||||
const [showAllClients, setShowAllClients] = useState(false);
|
|
||||||
const [optionsOpen, setOptionsOpen] = useState(false);
|
const [optionsOpen, setOptionsOpen] = useState(false);
|
||||||
const [startMode, setStartMode] = useState<StartMode>("now");
|
const [startMode, setStartMode] = useState<StartMode>("now");
|
||||||
const [pickedStart, setPickedStart] = useState("");
|
const [pickedStart, setPickedStart] = useState("");
|
||||||
@@ -180,40 +164,6 @@ export function TimeClockPanel({
|
|||||||
[clients, clientId],
|
[clients, clientId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const featuredClientIds = useMemo(() => {
|
|
||||||
const ids: string[] = [];
|
|
||||||
const last = getLastTimeClockClientId();
|
|
||||||
if (last) ids.push(last);
|
|
||||||
|
|
||||||
if (running?.clientId && !ids.includes(running.clientId)) {
|
|
||||||
ids.unshift(running.clientId);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const entry of todayEntries ?? []) {
|
|
||||||
if (entry.clientId && !ids.includes(entry.clientId)) {
|
|
||||||
ids.push(entry.clientId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const client of clients ?? []) {
|
|
||||||
if (!ids.includes(client.id)) ids.push(client.id);
|
|
||||||
if (ids.length >= FEATURED_CLIENT_COUNT) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ids;
|
|
||||||
}, [clients, todayEntries, running]);
|
|
||||||
|
|
||||||
const visibleClients = useMemo(() => {
|
|
||||||
if (!clients?.length) return [];
|
|
||||||
if (showAllClients) return clients;
|
|
||||||
const featured = featuredClientIds
|
|
||||||
.map((id) => clients.find((c) => c.id === id))
|
|
||||||
.filter((c): c is NonNullable<typeof c> => Boolean(c));
|
|
||||||
return featured.length > 0 ? featured : clients.slice(0, FEATURED_CLIENT_COUNT);
|
|
||||||
}, [clients, featuredClientIds, showAllClients]);
|
|
||||||
|
|
||||||
const hiddenClientCount = Math.max(0, (clients?.length ?? 0) - visibleClients.length);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
if (!running) return;
|
if (!running) return;
|
||||||
@@ -373,292 +323,63 @@ export function TimeClockPanel({
|
|||||||
const runningTitle = formatRunningTimerLabel(running?.description);
|
const runningTitle = formatRunningTimerLabel(running?.description);
|
||||||
const activeClientId = running ? (running.clientId ?? "") : clientId;
|
const activeClientId = running ? (running.clientId ?? "") : clientId;
|
||||||
const activeInvoiceId = running ? (running.invoiceId ?? "") : invoiceId;
|
const activeInvoiceId = running ? (running.invoiceId ?? "") : invoiceId;
|
||||||
|
const completedToday = todayEntries?.filter((entry) => entry.endedAt) ?? [];
|
||||||
|
const todayHours = completedToday.reduce(
|
||||||
|
(total, entry) => total + Number(entry.hours ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={compact ? "space-y-4" : "space-y-6"}>
|
<div className={cn("flex flex-col gap-6", !compact && "xl:grid xl:grid-cols-[minmax(0,1fr)_22rem]")}>
|
||||||
{running ? (
|
<Card className="min-w-0 overflow-hidden">
|
||||||
<div className="border-primary/20 bg-primary/5 rounded-2xl border p-6 text-center shadow-sm">
|
<CardHeader className="gap-3">
|
||||||
<div className="mb-3 flex items-center justify-center gap-2">
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<span className="relative flex h-2.5 w-2.5">
|
<div className="flex min-w-0 flex-col gap-1.5">
|
||||||
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" />
|
<p className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">
|
||||||
<span className="bg-primary relative inline-flex h-2.5 w-2.5 rounded-full" />
|
{running ? "In progress" : "Ready to start"}
|
||||||
</span>
|
</p>
|
||||||
<span className="text-primary text-sm font-medium">Timer running</span>
|
<CardTitle className="text-pretty text-2xl">
|
||||||
|
{running ? runningTitle : "What are you working on?"}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-pretty">
|
||||||
|
{running
|
||||||
|
? [
|
||||||
|
running.client?.name ?? "No client",
|
||||||
|
running.invoice ? invoiceLabel(running.invoice) : null,
|
||||||
|
displayRate ? `$${displayRate}/hr` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")
|
||||||
|
: "Add the details you know now. You can update the rest while the timer runs."}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
{running ? (
|
||||||
|
<p
|
||||||
|
aria-label={`${elapsed} elapsed seconds`}
|
||||||
|
className="font-mono text-4xl font-semibold tracking-tight tabular-nums sm:text-5xl"
|
||||||
|
>
|
||||||
|
{formatElapsedSeconds(elapsed)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-primary font-mono text-5xl font-bold tracking-tight tabular-nums sm:text-6xl">
|
|
||||||
{formatElapsedSeconds(elapsed)}
|
|
||||||
</p>
|
|
||||||
<p className="mt-3 text-lg font-medium">{runningTitle}</p>
|
|
||||||
<p className="text-muted-foreground mt-1 text-sm">
|
|
||||||
{running.client?.name ?? "No client"}
|
|
||||||
{running.invoice ? ` · ${invoiceLabel(running.invoice)}` : ""}
|
|
||||||
{displayRate ? ` · $${displayRate}/hr` : ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
|
||||||
{!running ? <Clock className="h-4 w-4" /> : null}
|
|
||||||
{running ? "Update & stop" : "Clock in"}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-5">
|
<CardContent className="flex flex-col gap-4">
|
||||||
{!running ? (
|
{!running ? (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="clock-title" className="sr-only">
|
<Label htmlFor="clock-title">Description</Label>
|
||||||
What are you working on?
|
|
||||||
</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="clock-title"
|
id="clock-title"
|
||||||
|
name="description"
|
||||||
|
autoComplete="off"
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder="What are you working on?"
|
placeholder="e.g. Client kickoff…"
|
||||||
className="h-12 border-0 bg-transparent px-0 text-lg font-medium shadow-none focus-visible:ring-0"
|
className="h-11"
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Client</Label>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{visibleClients.map((client) => (
|
|
||||||
<ClientChip
|
|
||||||
key={client.id}
|
|
||||||
label={client.name}
|
|
||||||
active={activeClientId === client.id}
|
|
||||||
onClick={() => handleClientChange(client.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{!showAllClients && hiddenClientCount > 0 ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="rounded-full"
|
|
||||||
onClick={() => setShowAllClients(true)}
|
|
||||||
>
|
|
||||||
+{hiddenClientCount} more
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{(showAllClients || (clients?.length ?? 0) > FEATURED_CLIENT_COUNT) && (
|
|
||||||
<Select value={clientId || undefined} onValueChange={handleClientChange}>
|
|
||||||
<SelectTrigger className="mt-1">
|
|
||||||
<SelectValue placeholder="Select client" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{clients?.map((c) => (
|
|
||||||
<SelectItem key={c.id} value={c.id}>
|
|
||||||
{c.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Invoice</Label>
|
|
||||||
<Select
|
|
||||||
value={invoiceId || "__none__"}
|
|
||||||
onValueChange={handleInvoiceChange}
|
|
||||||
disabled={!clientId}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue
|
|
||||||
placeholder={
|
|
||||||
clientId ? "Draft invoice (optional)" : "Choose a client first"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="__none__">No invoice — save entry only</SelectItem>
|
|
||||||
{billableInvoices?.map((inv) => (
|
|
||||||
<SelectItem key={inv.id} value={inv.id}>
|
|
||||||
{invoiceLabel(inv)}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Collapsible open={optionsOpen} onOpenChange={setOptionsOpen}>
|
|
||||||
<CollapsibleTrigger asChild>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
className="text-muted-foreground h-auto w-full justify-between px-0 py-1 font-normal hover:bg-transparent"
|
|
||||||
>
|
|
||||||
Rate & start time
|
|
||||||
<ChevronDown
|
|
||||||
className={cn(
|
|
||||||
"h-4 w-4 shrink-0 transition-transform",
|
|
||||||
optionsOpen && "rotate-180",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<CollapsibleContent className="space-y-4 pt-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Hourly rate</Label>
|
|
||||||
<NumberInput
|
|
||||||
value={rate}
|
|
||||||
onChange={setRate}
|
|
||||||
min={0}
|
|
||||||
step={0.01}
|
|
||||||
placeholder="0.00"
|
|
||||||
/>
|
|
||||||
{clientId && rate === 0 && selectedClient?.defaultHourlyRate ? (
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
Client default: ${selectedClient.defaultHourlyRate}/hr (used when left at zero).
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>When to start</Label>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{(
|
|
||||||
[
|
|
||||||
["now", "Now"],
|
|
||||||
["pick", "Pick time"],
|
|
||||||
["ago", "Time ago"],
|
|
||||||
] as const
|
|
||||||
).map(([mode, label]) => (
|
|
||||||
<Button
|
|
||||||
key={mode}
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant={startMode === mode ? "default" : "outline"}
|
|
||||||
className="rounded-full"
|
|
||||||
onClick={() => selectStartMode(mode)}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{startMode === "pick" ? (
|
|
||||||
<Input
|
|
||||||
type="datetime-local"
|
|
||||||
value={pickedStart}
|
|
||||||
onChange={(e) => setPickedStart(e.target.value)}
|
|
||||||
className="mt-2"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{startMode === "ago" ? (
|
|
||||||
<div className="mt-2 flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={1440}
|
|
||||||
value={minutesAgo}
|
|
||||||
onChange={(e) => setMinutesAgo(e.target.value)}
|
|
||||||
className="w-24"
|
|
||||||
/>
|
|
||||||
<span className="text-muted-foreground text-sm">minutes ago</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<RunningTextFields
|
|
||||||
key={running.id}
|
|
||||||
running={running}
|
|
||||||
updateRunningPending={updateRunning.isPending}
|
|
||||||
onDescriptionCommit={handleRunningDescriptionCommit}
|
|
||||||
onStartedAtCommit={handleRunningStartedAtCommit}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Client</Label>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{visibleClients.map((client) => (
|
|
||||||
<ClientChip
|
|
||||||
key={client.id}
|
|
||||||
label={client.name}
|
|
||||||
active={activeClientId === client.id}
|
|
||||||
onClick={() => handleClientChange(client.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{!showAllClients && hiddenClientCount > 0 ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="rounded-full"
|
|
||||||
onClick={() => setShowAllClients(true)}
|
|
||||||
>
|
|
||||||
+{hiddenClientCount} more
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{(showAllClients || (clients?.length ?? 0) > FEATURED_CLIENT_COUNT) && (
|
|
||||||
<Select
|
|
||||||
value={activeClientId || undefined}
|
|
||||||
onValueChange={handleClientChange}
|
|
||||||
disabled={updateRunning.isPending}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="mt-1">
|
|
||||||
<SelectValue placeholder="Select client" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{clients?.map((c) => (
|
|
||||||
<SelectItem key={c.id} value={c.id}>
|
|
||||||
{c.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Invoice</Label>
|
|
||||||
<Select
|
|
||||||
value={activeInvoiceId || "__none__"}
|
|
||||||
onValueChange={handleInvoiceChange}
|
|
||||||
disabled={!activeClientId || updateRunning.isPending}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue
|
|
||||||
placeholder={
|
|
||||||
activeClientId
|
|
||||||
? "Draft invoice (optional)"
|
|
||||||
: "Choose a client first"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="__none__">No invoice — save entry only</SelectItem>
|
|
||||||
{billableInvoices?.map((inv) => (
|
|
||||||
<SelectItem key={inv.id} value={inv.id}>
|
|
||||||
{invoiceLabel(inv)}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="clock-stop-note">Note on stop (optional)</Label>
|
|
||||||
<Input
|
|
||||||
id="clock-stop-note"
|
|
||||||
value={stopNote}
|
|
||||||
onChange={(e) => setStopNote(e.target.value)}
|
|
||||||
placeholder={
|
|
||||||
running?.description?.trim()
|
|
||||||
? running.description
|
|
||||||
: "Update description when you stop"
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
{running ? (
|
{running ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -672,49 +393,246 @@ export function TimeClockPanel({
|
|||||||
}
|
}
|
||||||
disabled={clockOut.isPending}
|
disabled={clockOut.isPending}
|
||||||
>
|
>
|
||||||
<Square className="mr-2 h-4 w-4" />
|
<Square data-icon="inline-start" aria-hidden="true" />
|
||||||
{clockOut.isPending ? "Stopping…" : "Stop & save"}
|
{clockOut.isPending ? "Stopping…" : "Stop & save entry"}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid gap-5 md:grid-cols-2">
|
||||||
|
<div className="flex min-w-0 flex-col gap-1.5">
|
||||||
|
<Label htmlFor="clock-client">Client</Label>
|
||||||
|
<Select
|
||||||
|
value={activeClientId || "__none__"}
|
||||||
|
onValueChange={(value) => handleClientChange(value === "__none__" ? "" : value)}
|
||||||
|
disabled={Boolean(running && updateRunning.isPending)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="clock-client" aria-label="Client" className="h-11 w-full">
|
||||||
|
<SelectValue placeholder="Select client…" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value="__none__">No client</SelectItem>
|
||||||
|
{clients?.map((client) => (
|
||||||
|
<SelectItem key={client.id} value={client.id}>
|
||||||
|
{client.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-col gap-1.5">
|
||||||
|
<Label htmlFor="clock-invoice">Invoice</Label>
|
||||||
|
<Select
|
||||||
|
value={activeInvoiceId || "__none__"}
|
||||||
|
onValueChange={handleInvoiceChange}
|
||||||
|
disabled={!activeClientId || Boolean(running && updateRunning.isPending)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="clock-invoice" aria-label="Invoice" className="h-11 w-full">
|
||||||
|
<SelectValue
|
||||||
|
placeholder={activeClientId ? "Select invoice…" : "Choose a client first"}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value="__none__">Entry only — no invoice</SelectItem>
|
||||||
|
{billableInvoices?.map((invoice) => (
|
||||||
|
<SelectItem key={invoice.id} value={invoice.id}>
|
||||||
|
{invoiceLabel(invoice)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-muted-foreground text-xs text-pretty">
|
||||||
|
{activeClientId
|
||||||
|
? "Linking an invoice adds the completed time as a billable line item."
|
||||||
|
: "Choose a client to see billable invoices."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Collapsible open={optionsOpen} onOpenChange={setOptionsOpen}>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-auto w-full justify-between rounded-none border-t px-0 pt-4 pb-0 font-normal"
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 flex-col items-start gap-0.5 text-left">
|
||||||
|
<span className="font-medium">
|
||||||
|
{running ? "Edit timer details" : "Rate & start time"}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground truncate text-xs">
|
||||||
|
{running
|
||||||
|
? `Started ${new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
}).format(new Date(running.startedAt))}`
|
||||||
|
: `${displayRate ? `$${displayRate}/hr` : "No rate"} · ${
|
||||||
|
startMode === "now" ? "Starting now" : "Custom start"
|
||||||
|
}`}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
data-icon="inline-end"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 transition-transform",
|
||||||
|
optionsOpen && "rotate-180",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="pt-4">
|
||||||
|
<div className="grid gap-5 md:grid-cols-2">
|
||||||
|
{running ? (
|
||||||
|
<RunningTextFields
|
||||||
|
key={running.id}
|
||||||
|
running={running}
|
||||||
|
updateRunningPending={updateRunning.isPending}
|
||||||
|
onDescriptionCommit={handleRunningDescriptionCommit}
|
||||||
|
onStartedAtCommit={handleRunningStartedAtCommit}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="clock-rate">Hourly rate</Label>
|
||||||
|
<NumberInput
|
||||||
|
id="clock-rate"
|
||||||
|
value={rate}
|
||||||
|
onChange={setRate}
|
||||||
|
min={0}
|
||||||
|
step={0.01}
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
{clientId && rate === 0 && selectedClient?.defaultHourlyRate ? (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{`Uses ${selectedClient.defaultHourlyRate}/hr from ${selectedClient.name}.`}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>When to start</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["now", "Now"],
|
||||||
|
["pick", "Pick time"],
|
||||||
|
["ago", "Time ago"],
|
||||||
|
] as const
|
||||||
|
).map(([mode, label]) => (
|
||||||
|
<Button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={startMode === mode ? "default" : "outline"}
|
||||||
|
className="rounded-full"
|
||||||
|
aria-pressed={startMode === mode}
|
||||||
|
onClick={() => selectStartMode(mode)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{startMode === "pick" ? (
|
||||||
|
<Input
|
||||||
|
aria-label="Start date and time"
|
||||||
|
name="startedAt"
|
||||||
|
autoComplete="off"
|
||||||
|
type="datetime-local"
|
||||||
|
value={pickedStart}
|
||||||
|
onChange={(event) => setPickedStart(event.target.value)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{startMode === "ago" ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
aria-label="Minutes ago"
|
||||||
|
name="minutesAgo"
|
||||||
|
autoComplete="off"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={1440}
|
||||||
|
value={minutesAgo}
|
||||||
|
onChange={(event) => setMinutesAgo(event.target.value)}
|
||||||
|
className="w-24"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-sm">minutes ago</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{running ? (
|
||||||
|
<div className="mt-5 flex flex-col gap-2">
|
||||||
|
<Label htmlFor="clock-stop-note">Note on stop</Label>
|
||||||
|
<Input
|
||||||
|
id="clock-stop-note"
|
||||||
|
name="stopNote"
|
||||||
|
autoComplete="off"
|
||||||
|
value={stopNote}
|
||||||
|
onChange={(event) => setStopNote(event.target.value)}
|
||||||
|
placeholder="Add a final note…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
{!running ? (
|
||||||
|
<CardFooter className="pt-0">
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={handleStart}
|
onClick={handleStart}
|
||||||
disabled={clockIn.isPending}
|
disabled={clockIn.isPending}
|
||||||
>
|
>
|
||||||
<Play className="mr-2 h-4 w-4" />
|
<Play data-icon="inline-start" aria-hidden="true" />
|
||||||
{clockIn.isPending ? "Starting…" : "Start timer"}
|
{clockIn.isPending ? "Starting…" : "Start timer"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</CardFooter>
|
||||||
</CardContent>
|
) : null}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{!compact ? (
|
{!compact ? (
|
||||||
<Card>
|
<Card className="h-fit min-w-0">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Today's entries</CardTitle>
|
<div className="flex items-start justify-between gap-3">
|
||||||
<Button variant="ghost" size="sm" className="h-8" asChild>
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
<Link href="/dashboard/time-clock/entries">View all entries</Link>
|
<CardTitle>Today</CardTitle>
|
||||||
</Button>
|
<CardDescription>
|
||||||
|
{completedToday.length === 1
|
||||||
|
? "1 completed entry"
|
||||||
|
: `${completedToday.length} completed entries`}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<p className="font-mono text-xl font-semibold tabular-nums">
|
||||||
|
{todayHours.toFixed(2)}h
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{todayEntries?.some((e) => e.endedAt) ? (
|
{completedToday.length > 0 ? (
|
||||||
<TimeEntryList
|
<TimeEntryList entries={completedToday} onEdit={(entry) => setEditEntryId(entry.id)} />
|
||||||
entries={todayEntries}
|
|
||||||
onEdit={(entry) => setEditEntryId(entry.id)}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<p className="text-muted-foreground py-4 text-center text-sm">
|
<div className="flex flex-col gap-1 py-6 text-center">
|
||||||
No entries today.{" "}
|
<p className="font-medium">No time logged yet</p>
|
||||||
<Link
|
<p className="text-muted-foreground text-sm text-pretty">
|
||||||
href="/dashboard/time-clock/entries"
|
Start your first timer or open history to add an entry manually.
|
||||||
className="text-primary hover:underline"
|
</p>
|
||||||
>
|
</div>
|
||||||
View history
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button variant="outline" className="w-full" asChild>
|
||||||
|
<Link href="/dashboard/time-clock/entries">View time history</Link>
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
/** Default invoice number format (matches web/mobile create forms). */
|
/** Default invoice number format (matches web/mobile create forms). */
|
||||||
export function generateInvoiceNumber(now = new Date()): string {
|
export function generateInvoiceNumber(now = new Date()): string {
|
||||||
const date = now.toISOString().slice(0, 10).replace(/-/g, "");
|
const date = [
|
||||||
|
now.getFullYear(),
|
||||||
|
String(now.getMonth() + 1).padStart(2, "0"),
|
||||||
|
String(now.getDate()).padStart(2, "0"),
|
||||||
|
].join("");
|
||||||
|
|
||||||
return `INV-${date}-${String(now.getTime()).slice(-6)}`;
|
return `INV-${date}-${String(now.getTime()).slice(-6)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user