Add 'apps/mobile/' from commit '5fa30f365f21531094cd4d2045042bb4f1370ac3'
git-subtree-dir: apps/mobile git-subtree-mainline:86f8987dffgit-subtree-split:5fa30f365f
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { useEffect } from "react";
|
||||
import { AppState } from "react-native";
|
||||
|
||||
import {
|
||||
endTimeClockLiveActivity,
|
||||
syncTimeClockLiveActivity,
|
||||
} from "@/lib/time-clock-live-activity";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
/** Keeps the iOS Live Activity in sync while a timer runs — app-wide, not just on the Timer tab. */
|
||||
export function TimeClockLiveActivitySync() {
|
||||
const runningQuery = api.timeEntries.getRunning.useQuery(undefined, {
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
const running = runningQuery.data;
|
||||
|
||||
useEffect(() => {
|
||||
if (!running) {
|
||||
void endTimeClockLiveActivity();
|
||||
return;
|
||||
}
|
||||
|
||||
const sync = () => {
|
||||
const seconds = Math.floor(
|
||||
(Date.now() - new Date(running.startedAt).getTime()) / 1000,
|
||||
);
|
||||
void syncTimeClockLiveActivity(running, seconds);
|
||||
};
|
||||
|
||||
sync();
|
||||
|
||||
const interval = setInterval(sync, 60_000);
|
||||
const subscription = AppState.addEventListener("change", (state) => {
|
||||
if (state === "active") sync();
|
||||
});
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
subscription.remove();
|
||||
};
|
||||
}, [running]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,999 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { router } from "expo-router";
|
||||
|
||||
import { FilterChip } from "@/components/FilterChip";
|
||||
import { GlassSurface } from "@/components/GlassSurface";
|
||||
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||
import { SwipeableRow } from "@/components/SwipeableRow";
|
||||
import { TabScrollView } from "@/components/TabScrollView";
|
||||
import { TimeEntryEditSheet } from "@/components/time-clock/TimeEntryEditSheet";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { DateTimeField } from "@/components/ui/DateTimeField";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAccounts } from "@/contexts/AccountsContext";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { formatCurrency, formatDateTime } from "@/lib/format";
|
||||
import { parseNonNegativeNumber } from "@/lib/form-validation";
|
||||
import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import {
|
||||
getLastTimeClockClientId,
|
||||
setLastTimeClockClientId,
|
||||
} from "@/lib/time-clock-prefs";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
import {
|
||||
endTimeClockLiveActivity,
|
||||
} from "@/lib/time-clock-live-activity";
|
||||
import {
|
||||
DEFAULT_CLOCK_DESCRIPTION,
|
||||
describeClockOutOutcome,
|
||||
formatElapsedSeconds,
|
||||
formatRunningTimerLabel,
|
||||
resolveClockDescription,
|
||||
resolveEffectiveHourlyRate,
|
||||
startedAtFromMinutesAgo,
|
||||
} from "@/lib/time-clock";
|
||||
import { useRunningElapsed } from "@/lib/use-running-elapsed";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
export type TimeClockPanelProps = {
|
||||
defaultClientId?: string;
|
||||
defaultInvoiceId?: string;
|
||||
compact?: boolean;
|
||||
header?: ReactNode;
|
||||
};
|
||||
|
||||
type ClientRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
defaultHourlyRate: number | null;
|
||||
currency?: string;
|
||||
};
|
||||
|
||||
type StartMode = "now" | "at" | "ago";
|
||||
|
||||
const AGO_PRESETS = [
|
||||
{ label: "15m", minutes: 15 },
|
||||
{ label: "30m", minutes: 30 },
|
||||
{ label: "1h", minutes: 60 },
|
||||
{ label: "2h", minutes: 120 },
|
||||
{ label: "4h", minutes: 240 },
|
||||
] as const;
|
||||
|
||||
function clientRateText(client: ClientRow | undefined): string {
|
||||
return client?.defaultHourlyRate != null ? String(client.defaultHourlyRate) : "";
|
||||
}
|
||||
|
||||
export function TimeClockPanel({
|
||||
defaultClientId = "",
|
||||
defaultInvoiceId = "",
|
||||
compact = false,
|
||||
header,
|
||||
}: TimeClockPanelProps) {
|
||||
const { colors } = useAppTheme();
|
||||
const styles = useThemedStyles(createTimeClockStyles);
|
||||
const { activeAccountId } = useAccounts();
|
||||
const utils = api.useUtils();
|
||||
const runningQuery = api.timeEntries.getRunning.useQuery(undefined, {
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
const clientsQuery = api.clients.getAll.useQuery();
|
||||
|
||||
const [clientId, setClientId] = useState(defaultClientId);
|
||||
const [invoiceId, setInvoiceId] = useState(defaultInvoiceId);
|
||||
const [description, setDescription] = useState("");
|
||||
const [stopNote, setStopNote] = useState("");
|
||||
const [rateText, setRateText] = useState("");
|
||||
const [startedAt, setStartedAt] = useState(() => new Date());
|
||||
const [startMode, setStartMode] = useState<StartMode>("now");
|
||||
const [agoMinutes, setAgoMinutes] = useState(60);
|
||||
const [agoMinutesText, setAgoMinutesText] = useState("60");
|
||||
const [optionsExpanded, setOptionsExpanded] = useState(false);
|
||||
const [clientsExpanded, setClientsExpanded] = useState(false);
|
||||
const [editEntryId, setEditEntryId] = useState<string | null>(null);
|
||||
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 elapsed = useRunningElapsed(running?.startedAt);
|
||||
const clients = clientsQuery.data ?? [];
|
||||
const activeClientId = running?.clientId ?? clientId;
|
||||
|
||||
const billableQuery = api.invoices.getBillable.useQuery(
|
||||
activeClientId ? { clientId: activeClientId } : undefined,
|
||||
);
|
||||
const billableInvoices = billableQuery.data ?? [];
|
||||
|
||||
const todayStart = useMemo(() => {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}, []);
|
||||
|
||||
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({
|
||||
onSuccess: async () => {
|
||||
await utils.timeEntries.getRunning.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const updateRunning = api.timeEntries.updateRunning.useMutation({
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
utils.timeEntries.getRunning.invalidate(),
|
||||
utils.invoices.getBillable.invalidate(),
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
const clockOut = api.timeEntries.clockOut.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
await endTimeClockLiveActivity();
|
||||
if (running?.clientId && activeAccountId) {
|
||||
await setLastTimeClockClientId(activeAccountId, running.clientId);
|
||||
}
|
||||
const message = describeClockOutOutcome({
|
||||
outcome: data.outcome,
|
||||
hours: data.hours,
|
||||
rate: data.rate,
|
||||
invoice: data.invoice,
|
||||
});
|
||||
Alert.alert(
|
||||
data.outcome === "linked_to_invoice" ? "Time logged" : "Timer stopped",
|
||||
message,
|
||||
);
|
||||
await Promise.all([
|
||||
utils.timeEntries.getRunning.invalidate(),
|
||||
utils.timeEntries.getAll.invalidate(),
|
||||
utils.invoices.getAll.invalidate(),
|
||||
utils.invoices.getBillable.invalidate(),
|
||||
utils.dashboard.getStats.invalidate(),
|
||||
]);
|
||||
setDescription("");
|
||||
setStopNote("");
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeAccountId) {
|
||||
setPrefsLoaded(true);
|
||||
return;
|
||||
}
|
||||
setPrefsLoaded(false);
|
||||
void getLastTimeClockClientId(activeAccountId).then((id) => {
|
||||
setStoredLastClientId(id);
|
||||
setPrefsLoaded(true);
|
||||
});
|
||||
}, [activeAccountId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
setClientId(running.clientId ?? "");
|
||||
setInvoiceId(running.invoiceId ?? "");
|
||||
setDescription(running.description ?? "");
|
||||
setStopNote("");
|
||||
setRateText(running.rate != null ? String(running.rate) : "");
|
||||
setRunningStartedAt(new Date(running.startedAt));
|
||||
}, [running]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!clientId || running || clients.length === 0) return;
|
||||
const client = clients.find((c) => c.id === clientId);
|
||||
if (!client?.defaultHourlyRate) return;
|
||||
setRateText((current) => current.trim() || clientRateText(client));
|
||||
}, [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 rateCurrency = selectedClient?.currency ?? "USD";
|
||||
const effectiveRate = resolveEffectiveHourlyRate(
|
||||
rateText,
|
||||
selectedClient?.defaultHourlyRate,
|
||||
);
|
||||
const displayRate = running
|
||||
? (running.rate ?? 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(() => {
|
||||
if (startMode === "now") return new Date();
|
||||
if (startMode === "ago") return startedAtFromMinutesAgo(agoMinutes);
|
||||
return startedAt;
|
||||
}, [agoMinutes, startMode, startedAt]);
|
||||
|
||||
const clockInErrors = useMemo(() => {
|
||||
const next: { rate?: string; start?: string } = {};
|
||||
if (rateText.trim() && parseNonNegativeNumber(rateText) === null) {
|
||||
next.rate = "Enter a valid hourly rate";
|
||||
}
|
||||
if (startMode === "ago" && agoMinutes <= 0) {
|
||||
next.start = "Enter how long ago you started";
|
||||
}
|
||||
return next;
|
||||
}, [agoMinutes, rateText, startMode]);
|
||||
|
||||
const canClockIn = Object.keys(clockInErrors).length === 0;
|
||||
|
||||
const optionsSummary = useMemo(() => {
|
||||
const rate =
|
||||
effectiveRate ??
|
||||
(selectedClient?.defaultHourlyRate != null ? selectedClient.defaultHourlyRate : null);
|
||||
const rateLabel = rate != null ? `${formatCurrency(rate, rateCurrency)}/hr` : "No rate";
|
||||
const startLabel = startMode === "now" ? "Starting now" : formatDateTime(resolvedStartAt);
|
||||
return `${rateLabel} · ${startLabel}`;
|
||||
}, [effectiveRate, rateCurrency, resolvedStartAt, selectedClient?.defaultHourlyRate, startMode]);
|
||||
|
||||
const todayEntries = useMemo(
|
||||
() =>
|
||||
(entriesQuery.data ?? []).filter(
|
||||
(entry) => entry.endedAt && new Date(entry.startedAt) >= todayStart,
|
||||
),
|
||||
[entriesQuery.data, todayStart],
|
||||
);
|
||||
|
||||
async function persistClientChoice(nextClientId: string, syncState = false) {
|
||||
if (!activeAccountId || !nextClientId) return;
|
||||
await setLastTimeClockClientId(activeAccountId, nextClientId);
|
||||
if (syncState) setStoredLastClientId(nextClientId);
|
||||
}
|
||||
|
||||
function selectClient(nextClientId: string) {
|
||||
const client = clients.find((c) => c.id === nextClientId);
|
||||
if (running) {
|
||||
updateRunning.mutate({
|
||||
clientId: nextClientId,
|
||||
invoiceId: "",
|
||||
rate: client?.defaultHourlyRate ?? undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setClientId(nextClientId);
|
||||
setInvoiceId("");
|
||||
setRateText(clientRateText(client));
|
||||
if (nextClientId && !featuredClientIds.includes(nextClientId)) {
|
||||
setClientsExpanded(true);
|
||||
}
|
||||
if (nextClientId) {
|
||||
void persistClientChoice(nextClientId);
|
||||
}
|
||||
}
|
||||
|
||||
function selectInvoice(nextInvoiceId: string) {
|
||||
if (running) {
|
||||
updateRunning.mutate({ invoiceId: nextInvoiceId || "" });
|
||||
return;
|
||||
}
|
||||
setInvoiceId(nextInvoiceId);
|
||||
}
|
||||
|
||||
function handleRunningDescriptionBlur() {
|
||||
if (!running) return;
|
||||
const next = resolveClockDescription(description);
|
||||
if (next === (running.description ?? "")) return;
|
||||
updateRunning.mutate({ description: next });
|
||||
}
|
||||
|
||||
function handleRunningStartedAtChange(date: Date) {
|
||||
setRunningStartedAt(date);
|
||||
if (!running || date > new Date()) return;
|
||||
updateRunning.mutate({ startedAt: date });
|
||||
}
|
||||
|
||||
function selectStartMode(mode: StartMode) {
|
||||
setStartMode(mode);
|
||||
if (mode !== "now") setOptionsExpanded(true);
|
||||
if (mode === "now") {
|
||||
setStartedAt(new Date());
|
||||
return;
|
||||
}
|
||||
if (mode === "ago") {
|
||||
setStartedAt(startedAtFromMinutesAgo(agoMinutes));
|
||||
return;
|
||||
}
|
||||
setStartedAt((current) =>
|
||||
Math.abs(Date.now() - current.getTime()) < 60_000 ? current : new Date(),
|
||||
);
|
||||
}
|
||||
|
||||
function selectAgoPreset(minutes: number) {
|
||||
setStartMode("ago");
|
||||
setAgoMinutes(minutes);
|
||||
setAgoMinutesText(String(minutes));
|
||||
setStartedAt(startedAtFromMinutesAgo(minutes));
|
||||
}
|
||||
|
||||
function handleAgoMinutesChange(text: string) {
|
||||
setAgoMinutesText(text);
|
||||
const parsed = Number(text);
|
||||
if (!Number.isNaN(parsed) && parsed > 0) {
|
||||
setAgoMinutes(parsed);
|
||||
setStartedAt(startedAtFromMinutesAgo(parsed));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClockIn() {
|
||||
if (!canClockIn) {
|
||||
if (clockInErrors.rate || clockInErrors.start) setOptionsExpanded(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const backdated =
|
||||
startMode === "now" ? undefined : resolvedStartAt;
|
||||
await clockIn.mutateAsync({
|
||||
description: resolveClockDescription(description),
|
||||
clientId: clientId || "",
|
||||
invoiceId: invoiceId || undefined,
|
||||
rate: effectiveRate ?? undefined,
|
||||
startedAt: backdated,
|
||||
});
|
||||
if (clientId) {
|
||||
await persistClientChoice(clientId);
|
||||
}
|
||||
setStartMode("now");
|
||||
setStartedAt(new Date());
|
||||
setAgoMinutes(60);
|
||||
setAgoMinutesText("60");
|
||||
} catch (err) {
|
||||
Alert.alert("Clock in failed", err instanceof Error ? err.message : "Try again");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClockOut() {
|
||||
try {
|
||||
await clockOut.mutateAsync({
|
||||
description: stopNote.trim() ? stopNote.trim() : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
Alert.alert("Clock out failed", err instanceof Error ? err.message : "Try again");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (runningQuery.isLoading || clientsQuery.isLoading) {
|
||||
return <LoadingScreen message="Loading time clock…" />;
|
||||
}
|
||||
|
||||
const runningTitle = formatRunningTimerLabel(running?.description);
|
||||
const runningMeta = [
|
||||
running?.client?.name ?? (running ? "No client" : null),
|
||||
running?.invoice
|
||||
? `${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
|
||||
: null,
|
||||
displayRate ? `$${displayRate}/hr` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
|
||||
function renderClientChip(client: (typeof clients)[number]) {
|
||||
return (
|
||||
<FilterChip
|
||||
key={client.id}
|
||||
label={client.name}
|
||||
active={clientId === client.id}
|
||||
onPress={() => selectClient(client.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TabScrollView
|
||||
style={styles.scroll}
|
||||
header={header}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={runningQuery.isRefetching}
|
||||
onRefresh={() => {
|
||||
void runningQuery.refetch();
|
||||
void clientsQuery.refetch();
|
||||
void billableQuery.refetch();
|
||||
void entriesQuery.refetch();
|
||||
}}
|
||||
tintColor={colors.primary}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{running || !compact ? (
|
||||
<GlassSurface style={running ? styles.runningCard : undefined}>
|
||||
<View style={[styles.hero, running && styles.heroRunning]}>
|
||||
{running ? (
|
||||
<>
|
||||
<View style={styles.heroHeader}>
|
||||
<View style={styles.pulseDot} />
|
||||
<Text style={styles.heroLabelRunning}>Timer running</Text>
|
||||
</View>
|
||||
<Text style={styles.timerValue}>{formatElapsedSeconds(elapsed)}</Text>
|
||||
<Text style={styles.runningTitle}>{runningTitle}</Text>
|
||||
{runningMeta ? (
|
||||
<Text style={styles.runningMeta}>{runningMeta}</Text>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<Text style={styles.idleHint}>
|
||||
Start the timer anytime — add client, invoice, and details later.
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</GlassSurface>
|
||||
) : null}
|
||||
|
||||
<GlassSurface style={styles.setupCard}>
|
||||
<Text style={styles.cardTitle}>{running ? "Update & stop" : "Clock in"}</Text>
|
||||
|
||||
{running ? (
|
||||
<View style={styles.formSection}>
|
||||
<Input
|
||||
label="What are you working on?"
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
onBlur={handleRunningDescriptionBlur}
|
||||
placeholder="What are you working on?"
|
||||
returnKeyType="done"
|
||||
/>
|
||||
|
||||
<DateTimeField
|
||||
label="Started at"
|
||||
value={runningStartedAt}
|
||||
maximumDate={new Date()}
|
||||
onChange={handleRunningStartedAtChange}
|
||||
/>
|
||||
|
||||
<View style={styles.setupSection}>
|
||||
<Text style={styles.sectionLabel}>Client</Text>
|
||||
<View style={styles.chipWrap}>
|
||||
<FilterChip
|
||||
label="None"
|
||||
active={!clientId}
|
||||
onPress={() => selectClient("")}
|
||||
/>
|
||||
{clients.map((client) => (
|
||||
<FilterChip
|
||||
key={client.id}
|
||||
label={client.name}
|
||||
active={clientId === client.id}
|
||||
onPress={() => selectClient(client.id)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{clientId ? (
|
||||
<View style={styles.setupSection}>
|
||||
<Text style={styles.sectionLabel}>Invoice</Text>
|
||||
<View style={styles.chipWrap}>
|
||||
<FilterChip
|
||||
label="Entry only"
|
||||
active={!invoiceId}
|
||||
onPress={() => selectInvoice("")}
|
||||
/>
|
||||
{billableInvoices.map((invoice) => (
|
||||
<FilterChip
|
||||
key={invoice.id}
|
||||
label={`${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`}
|
||||
active={invoiceId === invoice.id}
|
||||
onPress={() => selectInvoice(invoice.id)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<Input
|
||||
label="Note on stop (optional)"
|
||||
value={stopNote}
|
||||
onChangeText={setStopNote}
|
||||
placeholder={
|
||||
running.description?.trim()
|
||||
? running.description
|
||||
: "Update description when you stop"
|
||||
}
|
||||
returnKeyType="done"
|
||||
/>
|
||||
<Button
|
||||
title={clockOut.isPending ? "Stopping…" : "Stop & save"}
|
||||
variant="danger"
|
||||
loading={clockOut.isPending}
|
||||
onPress={handleClockOut}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<TextInput
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
placeholder="What are you working on?"
|
||||
placeholderTextColor={colors.mutedForeground}
|
||||
returnKeyType="done"
|
||||
style={[styles.titleField, { color: colors.foreground }]}
|
||||
/>
|
||||
|
||||
<View style={styles.setupSection}>
|
||||
<Text style={styles.sectionLabel}>Client</Text>
|
||||
{clients.length === 0 ? (
|
||||
<Text style={styles.emptyClients}>
|
||||
No clients yet — you can still start the timer and assign a client later.
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.chipWrap}>
|
||||
<FilterChip
|
||||
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}>
|
||||
<Text style={styles.sectionLabel}>Invoice (optional)</Text>
|
||||
{!clientId ? (
|
||||
<Text style={styles.emptyClients}>
|
||||
No invoice for now. Add a client and invoice later if this becomes billable.
|
||||
</Text>
|
||||
) : (
|
||||
<View style={styles.chipWrap}>
|
||||
<FilterChip label="Entry only" active={!invoiceId} onPress={() => setInvoiceId("")} />
|
||||
{billableInvoices.map((invoice) => {
|
||||
const label = `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`;
|
||||
return (
|
||||
<FilterChip
|
||||
key={invoice.id}
|
||||
label={label}
|
||||
active={invoiceId === invoice.id}
|
||||
onPress={() => setInvoiceId(invoice.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.setupSection}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ expanded: optionsExpanded }}
|
||||
onPress={() => setOptionsExpanded((open) => !open)}
|
||||
style={({ pressed }) => [styles.optionsToggle, pressed && styles.optionsTogglePressed]}
|
||||
>
|
||||
<View style={styles.optionsToggleText}>
|
||||
<Text style={styles.optionsToggleLabel}>Rate & start time</Text>
|
||||
{!optionsExpanded ? (
|
||||
<Text style={styles.optionsToggleSummary}>{optionsSummary}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Text style={styles.optionsChevron}>{optionsExpanded ? "−" : "+"}</Text>
|
||||
</Pressable>
|
||||
|
||||
{optionsExpanded ? (
|
||||
<View style={styles.optionsBody}>
|
||||
<Input
|
||||
label="Hourly rate"
|
||||
value={rateText}
|
||||
onChangeText={setRateText}
|
||||
keyboardType="decimal-pad"
|
||||
placeholder={
|
||||
selectedClient?.defaultHourlyRate != null
|
||||
? String(selectedClient.defaultHourlyRate)
|
||||
: "0"
|
||||
}
|
||||
error={clockInErrors.rate}
|
||||
/>
|
||||
{selectedClient?.defaultHourlyRate != null && !rateText.trim() ? (
|
||||
<Text style={styles.rateHint}>
|
||||
Defaults to{" "}
|
||||
{formatCurrency(selectedClient.defaultHourlyRate, rateCurrency)}/hr from client
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Text style={[styles.sectionLabel, styles.sectionLabelInset]}>Start</Text>
|
||||
<View style={styles.chipWrap}>
|
||||
<FilterChip
|
||||
label="Now"
|
||||
active={startMode === "now"}
|
||||
onPress={() => selectStartMode("now")}
|
||||
/>
|
||||
<FilterChip
|
||||
label="Pick time"
|
||||
active={startMode === "at"}
|
||||
onPress={() => selectStartMode("at")}
|
||||
/>
|
||||
<FilterChip
|
||||
label="Time ago"
|
||||
active={startMode === "ago"}
|
||||
onPress={() => selectStartMode("ago")}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{startMode === "at" ? (
|
||||
<DateTimeField
|
||||
label="Started at"
|
||||
value={startedAt}
|
||||
maximumDate={new Date()}
|
||||
onChange={(date) => {
|
||||
setStartedAt(date);
|
||||
setStartMode("at");
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{startMode === "ago" ? (
|
||||
<View style={styles.agoBlock}>
|
||||
<View style={styles.chipWrap}>
|
||||
{AGO_PRESETS.map((preset) => (
|
||||
<FilterChip
|
||||
key={preset.label}
|
||||
label={preset.label}
|
||||
active={agoMinutes === preset.minutes}
|
||||
onPress={() => selectAgoPreset(preset.minutes)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<View style={styles.agoCustomRow}>
|
||||
<Text style={[styles.agoCustomLabel, { color: colors.mutedForeground }]}>
|
||||
Started
|
||||
</Text>
|
||||
<TextInput
|
||||
value={agoMinutesText}
|
||||
onChangeText={handleAgoMinutesChange}
|
||||
keyboardType="number-pad"
|
||||
style={[
|
||||
styles.agoInput,
|
||||
{ color: colors.foreground, borderColor: colors.border },
|
||||
]}
|
||||
/>
|
||||
<Text style={[styles.agoCustomLabel, { color: colors.mutedForeground }]}>
|
||||
min ago
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{clockInErrors.start ? (
|
||||
<Text style={styles.fieldError}>{clockInErrors.start}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<Button
|
||||
title={clockIn.isPending ? "Starting…" : "Start timer"}
|
||||
loading={clockIn.isPending}
|
||||
disabled={!canClockIn}
|
||||
showArrow={!clockIn.isPending}
|
||||
onPress={handleClockIn}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</GlassSurface>
|
||||
|
||||
{todayEntries.length > 0 ? (
|
||||
<Card title="Today's entries">
|
||||
{todayEntries.map((entry) => {
|
||||
const invoiceLabel = entry.invoice
|
||||
? `${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<SwipeableRow
|
||||
key={entry.id}
|
||||
actions={[
|
||||
{
|
||||
key: "edit",
|
||||
label: "Edit",
|
||||
icon: "create-outline",
|
||||
color: "#fff",
|
||||
backgroundColor: colors.primary,
|
||||
onPress: () => setEditEntryId(entry.id),
|
||||
},
|
||||
{
|
||||
key: "invoice",
|
||||
label: "Invoice",
|
||||
icon: "document-text-outline",
|
||||
color: "#fff",
|
||||
backgroundColor: colors.mutedForeground,
|
||||
onPress: () => {
|
||||
if (entry.invoice?.id) {
|
||||
router.push(`/(app)/invoices/${entry.invoice.id}`);
|
||||
} else {
|
||||
setEditEntryId(entry.id);
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.entryRow}>
|
||||
<View style={styles.entryMeta}>
|
||||
<Text style={styles.entryTitle}>{formatRunningTimerLabel(entry.description)}</Text>
|
||||
<Text style={styles.entrySub}>
|
||||
{entry.client?.name ?? "No client"}
|
||||
{invoiceLabel ? ` · ${invoiceLabel}` : " · not billed"}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.entryHours}>{entry.hours ?? "—"}h</Text>
|
||||
</View>
|
||||
</SwipeableRow>
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<TimeEntryEditSheet
|
||||
entryId={editEntryId}
|
||||
visible={editEntryId != null}
|
||||
onClose={() => setEditEntryId(null)}
|
||||
/>
|
||||
</TabScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const createTimeClockStyles = (colors: ThemeColors, isDark: boolean) =>
|
||||
StyleSheet.create({
|
||||
scroll: {
|
||||
flex: 1,
|
||||
},
|
||||
runningCard: {
|
||||
borderColor: isDark ? "rgba(250, 250, 250, 0.12)" : "rgba(24, 24, 27, 0.12)",
|
||||
backgroundColor: isDark ? "rgba(250, 250, 250, 0.06)" : "rgba(24, 24, 27, 0.04)",
|
||||
},
|
||||
hero: {
|
||||
padding: spacing.lg,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
heroRunning: {
|
||||
alignItems: "center",
|
||||
},
|
||||
heroHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
pulseDot: {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
heroLabelRunning: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.bodyMedium,
|
||||
color: colors.primary,
|
||||
},
|
||||
timerValue: {
|
||||
fontSize: 56,
|
||||
lineHeight: 60,
|
||||
fontFamily: fonts.mono,
|
||||
color: colors.primary,
|
||||
fontVariant: ["tabular-nums"],
|
||||
textAlign: "center",
|
||||
},
|
||||
runningTitle: {
|
||||
fontSize: 18,
|
||||
fontFamily: fonts.bodyMedium,
|
||||
color: colors.foreground,
|
||||
textAlign: "center",
|
||||
},
|
||||
runningMeta: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
color: colors.mutedForeground,
|
||||
textAlign: "center",
|
||||
},
|
||||
idleHint: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
color: colors.mutedForeground,
|
||||
lineHeight: 20,
|
||||
},
|
||||
setupCard: {
|
||||
padding: spacing.lg,
|
||||
gap: spacing.lg,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 16,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
},
|
||||
formSection: {
|
||||
gap: spacing.md,
|
||||
},
|
||||
titleField: {
|
||||
fontSize: 18,
|
||||
fontFamily: fonts.bodyMedium,
|
||||
minHeight: 48,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
setupSection: {
|
||||
gap: spacing.sm,
|
||||
paddingTop: spacing.lg,
|
||||
},
|
||||
sectionLabel: {
|
||||
fontSize: 11,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.mutedForeground,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.6,
|
||||
},
|
||||
sectionLabelInset: {
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
chipWrap: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
moreClientsWrap: {
|
||||
paddingTop: spacing.xs,
|
||||
},
|
||||
emptyClients: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
fieldError: {
|
||||
fontSize: 12,
|
||||
fontFamily: fonts.body,
|
||||
color: colors.destructive,
|
||||
},
|
||||
optionsToggle: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
optionsTogglePressed: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
optionsToggleText: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
optionsToggleLabel: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
},
|
||||
optionsToggleSummary: {
|
||||
fontSize: 12,
|
||||
fontFamily: fonts.body,
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
optionsChevron: {
|
||||
fontSize: 20,
|
||||
lineHeight: 22,
|
||||
fontFamily: fonts.bodyMedium,
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
optionsBody: {
|
||||
gap: spacing.md,
|
||||
paddingBottom: spacing.xs,
|
||||
},
|
||||
rateHint: {
|
||||
fontSize: 12,
|
||||
fontFamily: fonts.body,
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
agoBlock: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
agoCustomRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
agoCustomLabel: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
},
|
||||
agoInput: {
|
||||
minWidth: 56,
|
||||
fontSize: 16,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
paddingVertical: 4,
|
||||
textAlign: "center",
|
||||
},
|
||||
entryRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
gap: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
entryRowPressed: {
|
||||
opacity: 0.65,
|
||||
},
|
||||
entryMeta: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
entryTitle: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
fontSize: 14,
|
||||
},
|
||||
entrySub: {
|
||||
fontFamily: fonts.body,
|
||||
color: colors.mutedForeground,
|
||||
fontSize: 12,
|
||||
},
|
||||
entryHours: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
color: colors.foreground,
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
import { FilterChip } from "@/components/FilterChip";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { DateTimeField } from "@/components/ui/DateTimeField";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import { parseNonNegativeNumber } from "@/lib/form-validation";
|
||||
import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
type TimeEntryEditSheetProps = {
|
||||
entryId: string | null;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function TimeEntryEditSheet({ entryId, visible, onClose }: TimeEntryEditSheetProps) {
|
||||
const { colors } = useAppTheme();
|
||||
const styles = useThemedStyles(createStyles);
|
||||
const utils = api.useUtils();
|
||||
|
||||
const entryQuery = api.timeEntries.getById.useQuery(
|
||||
{ id: entryId ?? "" },
|
||||
{ enabled: visible && Boolean(entryId) },
|
||||
);
|
||||
const clientsQuery = api.clients.getAll.useQuery(undefined, { enabled: visible });
|
||||
|
||||
const [description, setDescription] = useState("");
|
||||
const [clientId, setClientId] = useState("");
|
||||
const [invoiceId, setInvoiceId] = useState("");
|
||||
const [rateText, setRateText] = useState("");
|
||||
const [startedAt, setStartedAt] = useState(() => new Date());
|
||||
const [endedAt, setEndedAt] = useState(() => new Date());
|
||||
|
||||
const billableQuery = api.invoices.getBillable.useQuery(
|
||||
clientId ? { clientId } : undefined,
|
||||
{ enabled: visible && Boolean(clientId) },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const entry = entryQuery.data;
|
||||
if (!entry) return;
|
||||
setDescription(entry.description ?? "");
|
||||
setClientId(entry.clientId ?? "");
|
||||
setInvoiceId(entry.invoiceId ?? "");
|
||||
setRateText(entry.rate != null ? String(entry.rate) : "");
|
||||
setStartedAt(new Date(entry.startedAt));
|
||||
setEndedAt(entry.endedAt ? new Date(entry.endedAt) : new Date());
|
||||
}, [entryQuery.data]);
|
||||
|
||||
const hoursPreview = useMemo(() => {
|
||||
if (endedAt <= startedAt) return null;
|
||||
return Math.max(0, (endedAt.getTime() - startedAt.getTime()) / 3_600_000);
|
||||
}, [endedAt, startedAt]);
|
||||
|
||||
const rate = parseNonNegativeNumber(rateText);
|
||||
|
||||
const updateEntry = api.timeEntries.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
utils.timeEntries.getAll.invalidate(),
|
||||
utils.timeEntries.getById.invalidate(),
|
||||
utils.invoices.getAll.invalidate(),
|
||||
utils.dashboard.getStats.invalidate(),
|
||||
]);
|
||||
onClose();
|
||||
},
|
||||
onError: (err) => Alert.alert("Could not save", err.message),
|
||||
});
|
||||
|
||||
const deleteEntry = api.timeEntries.delete.useMutation({
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
utils.timeEntries.getAll.invalidate(),
|
||||
utils.invoices.getAll.invalidate(),
|
||||
utils.dashboard.getStats.invalidate(),
|
||||
]);
|
||||
onClose();
|
||||
},
|
||||
onError: (err) => Alert.alert("Could not delete", err.message),
|
||||
});
|
||||
|
||||
function handleSave() {
|
||||
if (!entryId) return;
|
||||
if (endedAt <= startedAt) {
|
||||
Alert.alert("Invalid times", "End time must be after start time.");
|
||||
return;
|
||||
}
|
||||
|
||||
updateEntry.mutate({
|
||||
id: entryId,
|
||||
description,
|
||||
clientId: clientId || "",
|
||||
invoiceId: invoiceId || "",
|
||||
rate: rate ?? undefined,
|
||||
startedAt,
|
||||
endedAt,
|
||||
hours: hoursPreview ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
if (!entryId) return;
|
||||
Alert.alert("Delete time entry?", "This removes the entry and any linked invoice line.", [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: () => deleteEntry.mutate({ id: entryId }),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet" onRequestClose={onClose}>
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: colors.foreground }]}>Edit time entry</Text>
|
||||
<Pressable onPress={onClose} hitSlop={8}>
|
||||
<Text style={[styles.close, { color: colors.mutedForeground }]}>Close</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={styles.body} keyboardShouldPersistTaps="handled">
|
||||
{entryQuery.isLoading ? (
|
||||
<Text style={{ color: colors.mutedForeground }}>Loading…</Text>
|
||||
) : (
|
||||
<>
|
||||
<Input label="Description" value={description} onChangeText={setDescription} />
|
||||
|
||||
<Text style={[styles.label, { color: colors.foreground }]}>Client</Text>
|
||||
<View style={styles.chipWrap}>
|
||||
<FilterChip
|
||||
label="None"
|
||||
active={!clientId}
|
||||
onPress={() => {
|
||||
setClientId("");
|
||||
setInvoiceId("");
|
||||
}}
|
||||
/>
|
||||
{(clientsQuery.data ?? []).map((client) => (
|
||||
<FilterChip
|
||||
key={client.id}
|
||||
label={client.name}
|
||||
active={clientId === client.id}
|
||||
onPress={() => {
|
||||
setClientId(client.id);
|
||||
setInvoiceId("");
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{clientId ? (
|
||||
<>
|
||||
<Text style={[styles.label, { color: colors.foreground }]}>Invoice</Text>
|
||||
<View style={styles.chipWrap}>
|
||||
<FilterChip
|
||||
label="Not on invoice"
|
||||
active={!invoiceId}
|
||||
onPress={() => setInvoiceId("")}
|
||||
/>
|
||||
{(billableQuery.data ?? []).map((invoice) => (
|
||||
<FilterChip
|
||||
key={invoice.id}
|
||||
label={`${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`}
|
||||
active={invoiceId === invoice.id}
|
||||
onPress={() => setInvoiceId(invoice.id)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Input
|
||||
label="Hourly rate"
|
||||
value={rateText}
|
||||
onChangeText={setRateText}
|
||||
keyboardType="decimal-pad"
|
||||
/>
|
||||
|
||||
<DateTimeField
|
||||
label="Started"
|
||||
value={startedAt}
|
||||
maximumDate={endedAt}
|
||||
onChange={setStartedAt}
|
||||
/>
|
||||
<DateTimeField label="Ended" value={endedAt} minimumDate={startedAt} onChange={setEndedAt} />
|
||||
|
||||
{hoursPreview != null ? (
|
||||
<Text style={[styles.preview, { color: colors.mutedForeground }]}>
|
||||
{hoursPreview.toFixed(2)}h
|
||||
{rate != null && rate > 0
|
||||
? ` · ${formatCurrency(hoursPreview * rate)}`
|
||||
: ""}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Button title="Save changes" loading={updateEntry.isPending} onPress={handleSave} />
|
||||
<Button
|
||||
title="Delete entry"
|
||||
variant="danger"
|
||||
loading={deleteEntry.isPending}
|
||||
onPress={confirmDelete}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const createStyles = (colors: ThemeColors) =>
|
||||
StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.lg,
|
||||
paddingBottom: spacing.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
title: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 18,
|
||||
},
|
||||
close: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 15,
|
||||
},
|
||||
body: {
|
||||
padding: spacing.lg,
|
||||
gap: spacing.md,
|
||||
},
|
||||
label: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 14,
|
||||
},
|
||||
chipWrap: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
preview: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user